diff --git a/backend/internal/modules/chat/adapter.go b/backend/internal/modules/chat/adapter.go new file mode 100644 index 0000000..17907e2 --- /dev/null +++ b/backend/internal/modules/chat/adapter.go @@ -0,0 +1,24 @@ +package chat + +import ( + "gorm.io/gorm" + "hfb_sys/backend/internal/model" +) + +// ListingChatCreatorAdapter 适配器,实现 listing.ListingChatCreator 接口 +type ListingChatCreatorAdapter struct { + // 无需字段,直接调用包级函数 +} + +func NewListingChatCreatorAdapter() *ListingChatCreatorAdapter { + return &ListingChatCreatorAdapter{} +} + +// EnsureListingConversation 实现接口 +func (a *ListingChatCreatorAdapter) EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (uint64, error) { + conv, err := EnsureListingConversation(tx, listing) + if err != nil { + return 0, err + } + return conv.ID, nil +} diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go new file mode 100644 index 0000000..2040f97 --- /dev/null +++ b/backend/internal/modules/chat/listing_group.go @@ -0,0 +1,319 @@ +package chat + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hfb_sys/backend/internal/model" +) + +// 会话类型常量 +const ( + ConversationTypeOrderGroup = "order_group" + ConversationTypeListingGroup = "listing_group" + ConversationTypeGeneralSupport = "general_support" +) + +var ( + ErrListingConversationExists = errors.New("发布群已存在") +) + +// EnsureListingConversation 确保发布群存在(幂等) +func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (*model.ChatConversation, error) { + // 1. 幂等查重 + var existing model.ChatConversation + err := tx.Where("listing_id = ?", listing.ID).First(&existing).Error + if err == nil { + return &existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + // 2. 建会话 + now := time.Now() + conversation := model.ChatConversation{ + ListingID: &listing.ID, + Type: ConversationTypeListingGroup, + Title: listingConversationTitle(listing), + Status: "active", + } + if err := tx.Create(&conversation).Error; err != nil { + return nil, err + } + + // 3. 写参与者: 号主 + 客服 + participants := []model.ChatParticipant{ + { + ConversationID: conversation.ID, + ParticipantType: "user", + ParticipantID: listing.OwnerID, + Role: "owner", + JoinedAt: now, + }, + } + + // 获取客服 + if supportID := defaultSupportAdminID(tx); 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 nil, err + } + } + + // 4. 取二维码(带行锁) + var qrcode *model.ChatQrCode + var qrcodeImageURL string + qrcodeObj, err := fetchUnusedQrCode(tx) + if err != nil { + return nil, err + } + if qrcodeObj != nil { + qrcode = qrcodeObj + qrcodeImageURL = qrcode.ImageURL + // 标记二维码为已使用 + if err := markQrCodeAsUsed(tx, qrcode.ID, conversation.ID); err != nil { + return nil, err + } + } + + // 5. 发欢迎语 + welcomeMessage := getListingGroupWelcomeMessage(tx) + if err := sendSystemMessage(tx, conversation.ID, welcomeMessage); err != nil { + return nil, err + } + + // 6. 发二维码图片(如果有) + if qrcodeImageURL != "" { + if err := sendQrCodeImage(tx, conversation.ID, qrcodeImageURL); err != nil { + return nil, err + } + } else { + // 无二维码,发提示 + noQrTip := "客服企业微信群二维码补充中,请稍后在群内关注" + if err := sendSystemMessage(tx, conversation.ID, noQrTip); err != nil { + return nil, err + } + } + + // 7. 库存预警检查 + if qrcode != nil { + if err := checkQrCodeStockAndAlert(tx, &conversation); err != nil { + // 预警失败不阻塞建群,仅记录日志 + // TODO: 添加日志 + } + } + + return &conversation, nil +} + +// AddRenterToListingConversation 拉租客进发布群 +func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint64, orderNo string) error { + // 1. 查找发布群 + var conv model.ChatConversation + err := tx.Where("listing_id = ?", listingID).First(&conv).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 历史数据无发布群,优雅跳过 + return nil + } + return err + } + + // 2. 幂等插入租客 + now := time.Now() + participant := model.ChatParticipant{ + ConversationID: conv.ID, + ParticipantType: "user", + ParticipantID: renterID, + Role: "renter", + JoinedAt: now, // 关键: 记录加入时间,用于消息可见性过滤 + } + + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { + return err + } + + // 3. 发系统消息 + message := "租客已加入群聊(订单 " + orderNo + ")" + return sendSystemMessage(tx, conv.ID, message) +} + +// RemoveRenterFromListingConversation 移出租客 +func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) error { + // 1. 查找发布群 + var conv model.ChatConversation + err := tx.Where("listing_id = ?", listingID).First(&conv).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 无发布群,跳过 + return nil + } + return err + } + + // 2. 删除租客参与者记录 + result := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ? AND role = ?", + conv.ID, "user", renterID, "renter"). + Delete(&model.ChatParticipant{}) + + if result.Error != nil { + return result.Error + } + + // 3. 发系统消息(如果确实删除了) + if result.RowsAffected > 0 { + message := "订单已结束,租客已退出群聊" + return sendSystemMessage(tx, conv.ID, message) + } + + return nil +} + +// 辅助函数 + +func listingConversationTitle(listing model.RentalListing) string { + return "账号群 " + listing.ListingNo +} + +func getListingGroupWelcomeMessage(tx *gorm.DB) string { + defaultMsg := "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。" + + var cfg model.SystemConfig + if err := tx.Where("`key` = ?", "chat.listing_group_welcome").First(&cfg).Error; err == nil && cfg.Value != "" { + return cfg.Value + } + + return defaultMsg +} + +func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error { + message := model.ChatMessage{ + ConversationID: conversationID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: content, + AttachmentURLS: emptyJSONList(), + } + + if err := tx.Create(&message).Error; err != nil { + return err + } + + // 更新会话的最后消息 + return updateConversationLastMessage(tx, conversationID, &message) +} + +func sendQrCodeImage(tx *gorm.DB, conversationID uint64, imageURL string) error { + attachmentURLs := `["` + imageURL + `"]` + content := "👇 请扫码加入企业微信群" + + message := model.ChatMessage{ + ConversationID: conversationID, + SenderType: "system", + SenderRole: "system", + ContentType: "image", + Content: content, + AttachmentURLS: []byte(attachmentURLs), + } + + if err := tx.Create(&message).Error; err != nil { + return err + } + + // 更新会话的最后消息 + return updateConversationLastMessage(tx, conversationID, &message) +} + +func updateConversationLastMessage(tx *gorm.DB, conversationID uint64, message *model.ChatMessage) error { + updates := map[string]interface{}{ + "last_message_id": message.ID, + "last_message_preview": truncatePreview(message.Content), + "last_message_at": message.CreatedAt, + } + + return tx.Model(&model.ChatConversation{}). + Where("id = ?", conversationID). + Updates(updates).Error +} + +func fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) { + var qrcode model.ChatQrCode + now := time.Now() + + err := tx.Where("status = ?", QrCodeStatusUnused). + Where("expires_at IS NULL OR expires_at > ?", now). + Order("id ASC"). + Limit(1). + Clauses(clause.Locking{Strength: "UPDATE"}). + First(&qrcode).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // 无可用二维码,返回 nil 而非错误 + } + return nil, err + } + + return &qrcode, nil +} + +func markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error { + now := time.Now() + return tx.Model(&model.ChatQrCode{}). + Where("id = ?", qrcodeID). + Updates(map[string]interface{}{ + "status": QrCodeStatusUsed, + "conversation_id": conversationID, + "used_at": now, + }).Error +} + +func checkQrCodeStockAndAlert(tx *gorm.DB, conversation *model.ChatConversation) error { + // 获取库存阈值 + threshold := int64(5) + var cfg model.SystemConfig + if err := tx.Where("`key` = ?", "chat.qrcode_low_stock_threshold").First(&cfg).Error; err == nil && cfg.Value != "" { + // 尝试解析为数字 + if val, err := parseThreshold(cfg.Value); err == nil { + threshold = val + } + } + + // 统计未使用的二维码数量 + var count int64 + if err := tx.Model(&model.ChatQrCode{}). + Where("status = ?", QrCodeStatusUnused). + Count(&count).Error; err != nil { + return err + } + + // 如果低于阈值,发送预警给所有客服 + if count <= threshold { + // TODO: 发送站内信给所有 cs 角色客服 + // 需要 notification 模块支持 + } + + return nil +} + +func parseThreshold(value string) (int64, error) { + var threshold int64 + if _, err := fmt.Sscanf(value, "%d", &threshold); err != nil { + return 0, err + } + return threshold, nil +} diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index ae2bd05..34ae252 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -14,11 +14,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) + var participant *model.ChatParticipant + // 管理员可以查看任意会话的消息,普通用户需要是 participant if principal.Type != "admin" { - if _, err := r.findParticipant(db, principal, conversationID, false); err != nil { + p, err := r.findParticipant(db, principal, conversationID, false) + if err != nil { return nil, err } + participant = p } else { // 管理员需要验证会话存在 var count int64 @@ -30,14 +34,22 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa } } + // 构建查询 + query := db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID) + + // 租客只能看到加入时间之后的消息 + if principal.Type == "user" && participant != nil && participant.Role == "renter" { + query = query.Where("created_at >= ?", participant.JoinedAt) + } + var total int64 - if err := db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil { + if err := query.Count(&total).Error; err != nil { return nil, err } + offset := (page - 1) * pageSize var rows []model.ChatMessage - if err := db.Where("conversation_id = ?", conversationID). - Order("id ASC"). + if err := query.Order("id ASC"). Offset(offset). Limit(pageSize). Find(&rows).Error; err != nil { diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 69a2007..ef7ffb1 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -9,32 +9,33 @@ import ( ) type ListingDTO struct { - ID uint64 `json:"id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - OwnerPhone string `json:"owner_phone,omitempty"` - OwnerNickname string `json:"owner_nickname,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - GameName string `json:"game_name"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - RankLevel string `json:"rank_level"` - HafCoinAmount int64 `json:"haf_coin_amount"` - AssetSummary map[string]any `json:"asset_summary,omitempty"` - ScreenshotURLS []string `json:"screenshot_urls"` - CoverURL string `json:"cover_url"` - PriceCent int64 `json:"price_cent"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - IsAccelerated bool `json:"is_accelerated_sale"` - InTransaction bool `json:"in_transaction"` - Status string `json:"status"` - ReviewStatus string `json:"review_status"` - ReviewReason string `json:"review_reason"` - PublishedAt *time.Time `json:"published_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + OwnerNickname string `json:"owner_nickname,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + GameName string `json:"game_name"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + AssetSummary map[string]any `json:"asset_summary,omitempty"` + ScreenshotURLS []string `json:"screenshot_urls"` + CoverURL string `json:"cover_url"` + PriceCent int64 `json:"price_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + IsAccelerated bool `json:"is_accelerated_sale"` + InTransaction bool `json:"in_transaction"` + Status string `json:"status"` + ReviewStatus string `json:"review_status"` + ReviewReason string `json:"review_reason"` + PublishedAt *time.Time `json:"published_at"` + ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index 84776c5..6b4b15e 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -59,7 +59,19 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque if err := tx.Create(&listing).Error; err != nil { return err } + + // 发布提交时建发布群 + var conversationID uint64 + if r.chatCreator != nil { + convID, err := r.chatCreator.EnsureListingConversation(tx, listing) + if err != nil { + return err + } + conversationID = convID + } + dto = toDTO(account, listing) + dto.ListingGroupConversationID = conversationID return nil }) return dto, err diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 26d1551..44cb330 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -6,16 +6,26 @@ import ( "time" "gorm.io/gorm" + "hfb_sys/backend/internal/model" ) +// ListingChatCreator 发布群创建接口(由 chat 模块实现,避免直接依赖) +type ListingChatCreator interface { + EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (conversationID uint64, err error) +} + type Repository struct { db *gorm.DB publicZoneCountsMu sync.Mutex publicZoneCounts publicZoneCountCache + chatCreator ListingChatCreator } -func NewRepository(db *gorm.DB) *Repository { - return &Repository{db: db} +func NewRepository(db *gorm.DB, chatCreator ListingChatCreator) *Repository { + return &Repository{ + db: db, + chatCreator: chatCreator, + } } type publicZoneCountCache struct { diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 0e08ca6..c2341f5 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -4,6 +4,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" @@ -38,6 +39,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil { return nil, err } + + // 订单完成,移出租客 + if err := chat.RemoveRenterFromListingConversation(tx, listing.ID, order.RenterID); err != nil { + // 移出失败不阻塞订单完成,仅记录日志 + // TODO: 添加日志 + } + return refund, nil } diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index bb31bd9..c4fa522 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -182,10 +182,18 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint order.HandoffStatus = handoffStatusPendingOwner order.HandoffStartedAt = &now markAssetsRented(listing, account) - conv, err := chat.EnsureOrderConversation(tx, *order) - if err != nil { + + // 拉租客进发布群(替代原来的建订单群) + conversationID := uint64(0) + if err := chat.AddRenterToListingConversation(tx, listing.ID, order.RenterID, order.OrderNo); err != nil { return 0, err } + // 查询发布群ID用于返回 + var listingConv model.ChatConversation + if err := tx.Where("listing_id = ?", listing.ID).First(&listingConv).Error; err == nil { + conversationID = listingConv.ID + } + if err := notification.Append(tx, notification.Entry{ UserID: order.OwnerID, @@ -215,7 +223,7 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint if err := tx.Save(account).Error; err != nil { return 0, err } - return conv.ID, nil + return conversationID, nil } func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) error { @@ -271,6 +279,14 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil { return err } + + // 订单取消,移出租客(仅付款后取消需要移出) + if beforeStatus == orderStatusPendingHandoff { + if err := chat.RemoveRenterFromListingConversation(tx, listing.ID, order.RenterID); err != nil { + // 移出失败不阻塞订单取消 + } + } + if err := tx.Save(&order).Error; err != nil { return err } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d96cb9a..2cc7849 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -151,10 +151,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } realnameService := realname.NewService(realnameRepo, newRealnameProvider(cfg, fieldEncryptor, logger), logger) realnameHandler := realname.NewHandler(realnameService) - var listingRepo *listing.Repository - if deps.DB != nil { - listingRepo = listing.NewRepository(deps.DB) - } + var chatHub *chathub.Hub if deps.DB != nil { chatHub = chathub.NewHub(deps.DB) @@ -163,6 +160,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { chatRepo = chat.NewRepository(deps.DB, chatHub) } + + // 创建 chat 适配器用于 listing + chatCreator := chat.NewListingChatCreatorAdapter() + + var listingRepo *listing.Repository + if deps.DB != nil { + listingRepo = listing.NewRepository(deps.DB, chatCreator) + } var paymentRepo *payment.Repository var orderRepo *order.Repository if deps.DB != nil {