chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *Repository) resolveSite(ctx context.Context, siteCode string) (*model.DistributionSite, error) {
|
||||
siteCode = strings.TrimSpace(siteCode)
|
||||
if siteCode == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var site model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("distribution site unavailable")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &site, nil
|
||||
}
|
||||
|
||||
// userSiteConversationFilter 限制用户在当前站点能看到的会话。
|
||||
// 账号群只有一个:本站发布的群按站点归属显示;跨站成交时,租客在下单的站点也能看到这个群。
|
||||
func userSiteConversationFilter(siteID uint64, userID uint64) (string, []any) {
|
||||
if siteID > 0 {
|
||||
filter := `(
|
||||
c.distribution_site_id = ?
|
||||
OR (
|
||||
c.order_id IS NOT NULL
|
||||
AND c.order_id IN (
|
||||
SELECT ro.id FROM rental_orders ro
|
||||
WHERE ro.distribution_site_id = ?
|
||||
AND ro.distribution_mode != 'two_party_distributor_receive'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
c.type = ?
|
||||
AND c.listing_id IN (
|
||||
SELECT ro.listing_id FROM rental_orders ro
|
||||
WHERE ro.distribution_site_id = ?
|
||||
AND ro.renter_id = ?
|
||||
AND ro.distribution_mode != 'two_party_distributor_receive'
|
||||
)
|
||||
)
|
||||
)`
|
||||
return filter, []any{siteID, siteID, ConversationTypeListingGroup, siteID, userID}
|
||||
}
|
||||
filter := `(
|
||||
(
|
||||
(c.distribution_site_id IS NULL OR c.distribution_site_id = 0)
|
||||
AND (
|
||||
c.order_id IS NULL
|
||||
OR c.order_id NOT IN (
|
||||
SELECT ro.id FROM rental_orders ro
|
||||
WHERE ro.distribution_site_id IS NOT NULL
|
||||
AND ro.distribution_site_id > 0
|
||||
AND ro.distribution_mode != 'two_party_distributor_receive'
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
c.type = ?
|
||||
AND c.listing_id IN (
|
||||
SELECT ro.listing_id FROM rental_orders ro
|
||||
WHERE ro.renter_id = ?
|
||||
AND ro.distribution_mode = 'two_party_distributor_receive'
|
||||
)
|
||||
)
|
||||
)`
|
||||
return filter, []any{ConversationTypeListingGroup, userID}
|
||||
}
|
||||
|
||||
func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int, siteCode ...string) (*PaginatedResult, error) {
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
db := r.db.WithContext(ctx)
|
||||
|
||||
var code string
|
||||
if len(siteCode) > 0 {
|
||||
code = siteCode[0]
|
||||
}
|
||||
site, err := r.resolveSite(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
countDB := db.Table("chat_conversations AS c").
|
||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
|
||||
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
|
||||
Where("c.status = ?", "active")
|
||||
|
||||
dataQuery := r.conversationQuery(ctx, principal).
|
||||
Where("c.status = ?", "active")
|
||||
|
||||
if principal.Type == "user" {
|
||||
siteID := uint64(0)
|
||||
if site != nil {
|
||||
siteID = site.ID
|
||||
}
|
||||
filter, args := userSiteConversationFilter(siteID, principal.ID)
|
||||
countDB = countDB.Where(filter, args...)
|
||||
dataQuery = dataQuery.Where(filter, args...)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []conversationRow
|
||||
offset := (page - 1) * pageSize
|
||||
err = dataQuery.
|
||||
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ConversationDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO(nil))
|
||||
}
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindConversation(ctx context.Context, principal Principal, id uint64) (*ConversationDTO, error) {
|
||||
db := r.db.WithContext(ctx)
|
||||
// 管理员可以查看任意会话,无需是 participant
|
||||
if principal.Type == "admin" {
|
||||
var conversation model.ChatConversation
|
||||
if err := db.First(&conversation, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConversationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
participants, err := r.participants(ctx, conversation.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var state model.ChatAdminConversationState
|
||||
if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
var unreadCount int64
|
||||
if err := db.Table("chat_messages AS cm").
|
||||
Where("cm.conversation_id = ?", conversation.ID).
|
||||
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
|
||||
Where("cm.id > ?", state.LastReadMessageID).
|
||||
Count(&unreadCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := ConversationDTO{
|
||||
ID: conversation.ID,
|
||||
OrderID: conversation.OrderID,
|
||||
ListingID: conversation.ListingID,
|
||||
DistributionSiteID: conversation.DistributionSiteID,
|
||||
Type: conversation.Type,
|
||||
SupportScene: conversation.SupportScene,
|
||||
Title: conversation.Title,
|
||||
Status: conversation.Status,
|
||||
Role: "admin", // 管理员角色
|
||||
AdminRemark: state.Remark,
|
||||
Participants: participants,
|
||||
LastMessageID: conversation.LastMessageID,
|
||||
LastMessagePreview: conversation.LastMessagePreview,
|
||||
LastMessageAt: conversation.LastMessageAt,
|
||||
UnreadCount: unreadCount,
|
||||
CreatedAt: conversation.CreatedAt,
|
||||
UpdatedAt: conversation.UpdatedAt,
|
||||
}
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// 普通用户需要是 participant
|
||||
var row conversationRow
|
||||
err := r.conversationQuery(ctx, principal).Where("c.id = ?", id).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConversationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
participants, err := r.participants(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := row.toDTO(participants)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindOrderConversation(ctx context.Context, userID uint64, orderID uint64) (*ConversationDTO, error) {
|
||||
principal := Principal{Type: "user", ID: userID}
|
||||
var order model.RentalOrder
|
||||
if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConversationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if order.ListingID > 0 {
|
||||
var row conversationRow
|
||||
err := r.conversationQuery(ctx, principal).
|
||||
Where("c.listing_id = ? AND c.type = ?", order.ListingID, ConversationTypeListingGroup).
|
||||
First(&row).Error
|
||||
if err == nil {
|
||||
participants, err := r.participants(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := row.toDTO(participants)
|
||||
return &dto, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var row conversationRow
|
||||
err := r.conversationQuery(ctx, principal).Where("c.order_id = ?", orderID).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrConversationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
participants, err := r.participants(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := row.toDTO(participants)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint64, scene string, siteCode ...string) (*ConversationDTO, error) {
|
||||
var code string
|
||||
if len(siteCode) > 0 {
|
||||
code = siteCode[0]
|
||||
}
|
||||
site, err := r.resolveSite(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scene, title, groupCode, welcome := resolveSupportScene(scene)
|
||||
if site != nil {
|
||||
title = fmt.Sprintf("【%s】%s", site.SiteName, title)
|
||||
welcome = fmt.Sprintf("您好,欢迎咨询【%s】在线客服!请直接描述您遇到的问题,客服会尽快回复。", site.SiteName)
|
||||
}
|
||||
|
||||
var conversationID uint64
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.ChatConversation
|
||||
existingQuery := tx.Table("chat_conversations AS c").
|
||||
Select("c.*").
|
||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
|
||||
Where(
|
||||
"c.type = ? AND c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ?",
|
||||
ConversationTypeGeneralSupport, scene, "user", userID,
|
||||
)
|
||||
|
||||
if site != nil {
|
||||
existingQuery = existingQuery.Where("c.distribution_site_id = ?", site.ID)
|
||||
} else {
|
||||
existingQuery = existingQuery.Where("c.distribution_site_id IS NULL OR c.distribution_site_id = 0")
|
||||
}
|
||||
|
||||
err := existingQuery.Order("c.id ASC").Limit(1).Find(&existing).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 兼容旧数据:主站 scene=general 时也匹配未回填 support_scene 的历史会话
|
||||
if site == nil && existing.ID == 0 && scene == SupportSceneGeneral {
|
||||
err = tx.Table("chat_conversations AS c").
|
||||
Select("c.*").
|
||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
|
||||
Where(
|
||||
"c.type = ? AND (c.support_scene = '' OR c.support_scene IS NULL) AND cp.participant_type = ? AND cp.participant_id = ? AND (c.distribution_site_id IS NULL OR c.distribution_site_id = 0)",
|
||||
ConversationTypeGeneralSupport, "user", userID,
|
||||
).
|
||||
Order("c.id ASC").
|
||||
Limit(1).
|
||||
Find(&existing).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ID > 0 && existing.SupportScene == "" {
|
||||
_ = tx.Model(&existing).Update("support_scene", SupportSceneGeneral).Error
|
||||
}
|
||||
}
|
||||
// 兼容旧摸大红场景码 mohong → crash
|
||||
if site == nil && existing.ID == 0 && scene == SupportSceneCrash {
|
||||
err = tx.Table("chat_conversations AS c").
|
||||
Select("c.*").
|
||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
|
||||
Where(
|
||||
"c.type = ? AND c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ? AND (c.distribution_site_id IS NULL OR c.distribution_site_id = 0)",
|
||||
ConversationTypeGeneralSupport, SupportSceneMohong, "user", userID,
|
||||
).
|
||||
Order("c.id ASC").
|
||||
Limit(1).
|
||||
Find(&existing).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if existing.ID > 0 {
|
||||
conversationID = existing.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
conversation := model.ChatConversation{
|
||||
Type: ConversationTypeGeneralSupport,
|
||||
SupportScene: scene,
|
||||
Title: title,
|
||||
Status: "active",
|
||||
}
|
||||
if site != nil {
|
||||
conversation.DistributionSiteID = &site.ID
|
||||
}
|
||||
if err := tx.Create(&conversation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
participants := []model.ChatParticipant{
|
||||
{
|
||||
ConversationID: conversation.ID,
|
||||
ParticipantType: "user",
|
||||
ParticipantID: userID,
|
||||
Role: "customer",
|
||||
JoinedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
var supportID uint64
|
||||
if site != nil && site.DefaultSupportGroupID != nil && *site.DefaultSupportGroupID > 0 {
|
||||
var member model.ChatSupportGroupMember
|
||||
if err := tx.Where("group_id = ?", *site.DefaultSupportGroupID).Order("id ASC").First(&member).Error; err == nil && member.AdminID > 0 {
|
||||
supportID = member.AdminID
|
||||
}
|
||||
}
|
||||
if supportID == 0 {
|
||||
supportID = pickSupportAdminForScene(tx, groupCode)
|
||||
}
|
||||
|
||||
if supportID > 0 {
|
||||
participants = append(participants, model.ChatParticipant{
|
||||
ConversationID: conversation.ID,
|
||||
ParticipantType: "admin",
|
||||
ParticipantID: supportID,
|
||||
Role: "support",
|
||||
JoinedAt: now,
|
||||
})
|
||||
}
|
||||
for _, participant := range participants {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
message := model.ChatMessage{
|
||||
ConversationID: conversation.ID,
|
||||
SenderType: "system",
|
||||
SenderRole: "system",
|
||||
ContentType: "system",
|
||||
Content: welcome,
|
||||
AttachmentURLS: emptyJSONList(),
|
||||
}
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
conversation.LastMessageID = &message.ID
|
||||
conversation.LastMessagePreview = truncatePreview(message.Content)
|
||||
conversation.LastMessageAt = &message.CreatedAt
|
||||
if err := tx.Save(&conversation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
conversationID = conversation.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := r.FindConversation(ctx, Principal{Type: "user", ID: userID}, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.NotifyNewConversation(conversationID)
|
||||
return item, nil
|
||||
}
|
||||
Reference in New Issue
Block a user