diff --git a/backend/internal/modules/chat/conversation.go b/backend/internal/modules/chat/conversation.go new file mode 100644 index 0000000..3d5d779 --- /dev/null +++ b/backend/internal/modules/chat/conversation.go @@ -0,0 +1,187 @@ +package chat + +import ( + "context" + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hfb_sys/backend/internal/model" + "time" +) + +func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int) (*PaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + db := r.db.WithContext(ctx) + var total int64 + 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) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + var rows []conversationRow + offset := (page - 1) * pageSize + err := r.conversationQuery(ctx, principal). + 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 + } + dto := ConversationDTO{ + ID: conversation.ID, + OrderID: conversation.OrderID, + Type: conversation.Type, + Title: conversation.Title, + Status: conversation.Status, + Role: "admin", // 管理员角色 + Participants: participants, + LastMessageID: conversation.LastMessageID, + LastMessagePreview: conversation.LastMessagePreview, + LastMessageAt: conversation.LastMessageAt, + UnreadCount: 0, // 管理员不计未读 + 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) { + var row conversationRow + principal := Principal{Type: "user", ID: userID} + 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) (*ConversationDTO, error) { + var conversationID uint64 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.ChatConversation + 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 cp.participant_type = ? AND cp.participant_id = ?", "general_support", "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: "general_support", + Title: "平台客服", + Status: "active", + } + if err := tx.Create(&conversation).Error; err != nil { + return err + } + + participants := []model.ChatParticipant{ + { + ConversationID: conversation.ID, + ParticipantType: "user", + ParticipantID: userID, + Role: "customer", + 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 err + } + } + + message := model.ChatMessage{ + ConversationID: conversation.ID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: "您好,客服会尽快回复,请直接描述您遇到的问题。", + 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 +} diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go new file mode 100644 index 0000000..e530689 --- /dev/null +++ b/backend/internal/modules/chat/message.go @@ -0,0 +1,185 @@ +package chat + +import ( + "context" + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chathub" + "time" +) + +func (r *Repository) Messages(ctx context.Context, principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + db := r.db.WithContext(ctx) + + // 管理员可以查看任意会话的消息,普通用户需要是 participant + if principal.Type != "admin" { + if _, err := r.findParticipant(db, principal, conversationID, false); err != nil { + return nil, err + } + } else { + // 管理员需要验证会话存在 + var count int64 + if err := db.Model(&model.ChatConversation{}).Where("id = ?", conversationID).Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, ErrConversationNotFound + } + } + + var total int64 + if err := db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).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"). + Offset(offset). + Limit(pageSize). + Find(&rows).Error; err != nil { + return nil, err + } + items, err := r.toMessageDTOs(ctx, principal, rows) + if err != nil { + return nil, err + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} +func (r *Repository) SendMessage(ctx context.Context, principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) { + var messageID uint64 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var conversation model.ChatConversation + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil { + return err + } + if conversation.Status != "active" { + return ErrPermissionDenied + } + + var senderRole string + var participant *model.ChatParticipant + + // 管理员可以在任意会话发送消息,无需是 participant + if principal.Type == "admin" { + // 尝试查找管理员的 participant 记录 + var p model.ChatParticipant + err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", + conversationID, principal.Type, principal.ID).First(&p).Error + if err == nil { + // 管理员是 participant,使用其角色 + participant = &p + senderRole = p.Role + } else if errors.Is(err, gorm.ErrRecordNotFound) { + // 管理员不是 participant,使用特殊角色 "admin" + senderRole = "admin" + } else { + return err + } + } else { + // 普通用户必须是 participant + p, err := r.findParticipant(tx, principal, conversationID, true) + if err != nil { + return err + } + participant = p + senderRole = p.Role + } + + message := model.ChatMessage{ + ConversationID: conversation.ID, + SenderType: principal.Type, + SenderID: principal.ID, + SenderRole: senderRole, + ContentType: "text", + Content: req.Content, + AttachmentURLS: encodeStringList(req.AttachmentURLS), + } + if err := tx.Create(&message).Error; err != nil { + return err + } + conversation.LastMessageID = &message.ID + conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) + conversation.LastMessageAt = &message.CreatedAt + if err := tx.Save(&conversation).Error; err != nil { + return err + } + + // 更新 participant 的已读时间(仅当是 participant 时) + if participant != nil { + now := time.Now() + if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil { + return err + } + } + + messageID = message.ID + return nil + }) + if err != nil { + return nil, err + } + var message model.ChatMessage + if err := r.db.WithContext(ctx).First(&message, messageID).Error; err != nil { + return nil, err + } + items, err := r.toMessageDTOs(ctx, principal, []model.ChatMessage{message}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, ErrConversationNotFound + } + // 推送新消息事件给会话中的在线参与者 + if r.hub != nil { + msg := items[0] + r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{ + Type: "new_message", + ConversationID: conversationID, + Message: &chathub.MessageData{ + ID: msg.ID, + ConversationID: msg.ConversationID, + SenderType: msg.SenderType, + SenderID: msg.SenderID, + SenderRole: msg.SenderRole, + SenderName: msg.SenderName, + ContentType: msg.ContentType, + Content: msg.Content, + AttachmentURLS: msg.AttachmentURLS, + CreatedAt: msg.CreatedAt.Format(time.RFC3339), + }, + }) + } + return &items[0], nil +} +func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversationID uint64) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 管理员可以不是 participant,直接返回成功 + if principal.Type == "admin" { + // 尝试查找 participant 记录,如果有就更新 + var participant model.ChatParticipant + err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", + conversationID, principal.Type, principal.ID).First(&participant).Error + if err == nil { + // 有 participant 记录,更新已读时间 + now := time.Now() + return tx.Model(&participant).Update("last_read_at", now).Error + } else if errors.Is(err, gorm.ErrRecordNotFound) { + // 没有 participant 记录,直接返回成功(管理员无需记录已读) + return nil + } + return err + } + + // 普通用户必须是 participant + participant, err := r.findParticipant(tx, principal, conversationID, true) + if err != nil { + return err + } + now := time.Now() + return tx.Model(participant).Update("last_read_at", now).Error + }) +} diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go new file mode 100644 index 0000000..b3b166a --- /dev/null +++ b/backend/internal/modules/chat/participant.go @@ -0,0 +1,323 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hfb_sys/backend/internal/model" + "strconv" + "time" +) + +type conversationRow struct { + ID uint64 + OrderID *uint64 + Type string + Title string + Status string + Role string + LastMessageID *uint64 + LastMessagePreview string + LastMessageAt *time.Time + UnreadCount int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB { + return r.db.WithContext(ctx).Table("chat_conversations AS c"). + Select(`c.id, c.order_id, c.type, c.title, c.status, c.last_message_id, + c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, cp.role, + ( + SELECT COUNT(1) + FROM chat_messages AS cm + WHERE cm.conversation_id = c.id + AND NOT (cm.sender_type = ? AND cm.sender_id = ?) + AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) + ) AS unread_count`, principal.Type, principal.ID). + Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). + Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) +} +func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) { + var participant model.ChatParticipant + db := tx + if lock { + db = db.Clauses(clause.Locking{Strength: "UPDATE"}) + } + err := db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). + First(&participant).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPermissionDenied + } + return nil, err + } + return &participant, nil +} +func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { + var rows []model.ChatParticipant + if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows) + if err != nil { + return nil, err + } + items := make([]ParticipantDTO, 0, len(rows)) + for _, row := range rows { + name := "系统" + avatar := "" + if row.ParticipantType == "user" { + name = userNames[row.ParticipantID] + avatar = userAvatars[row.ParticipantID] + } + if row.ParticipantType == "admin" { + name = adminNames[row.ParticipantID] + } + items = append(items, ParticipantDTO{ + ID: row.ID, + ConversationID: row.ConversationID, + ParticipantType: row.ParticipantType, + ParticipantID: row.ParticipantID, + Role: row.Role, + DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), + AvatarURL: avatar, + LastReadAt: row.LastReadAt, + JoinedAt: row.JoinedAt, + }) + } + return items, nil +} +func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { + userIDs := make([]uint64, 0) + adminIDs := make([]uint64, 0) + for _, row := range rows { + if row.SenderType == "user" && row.SenderID > 0 { + userIDs = append(userIDs, row.SenderID) + } + if row.SenderType == "admin" && row.SenderID > 0 { + adminIDs = append(adminIDs, row.SenderID) + } + } + userNames, userAvatars, err := r.userNames(ctx, userIDs) + if err != nil { + return nil, err + } + adminNames, err := r.adminNames(ctx, adminIDs) + if err != nil { + return nil, err + } + items := make([]MessageDTO, 0, len(rows)) + for _, row := range rows { + name := "系统" + avatar := "" + if row.SenderType == "user" { + name = userNames[row.SenderID] + avatar = userAvatars[row.SenderID] + } + if row.SenderType == "admin" { + name = adminNames[row.SenderID] + // 如果角色是 "admin"(不是 participant 的管理员),在名字后添加标识 + if row.SenderRole == "admin" { + name = name + " (管理员)" + } + } + items = append(items, MessageDTO{ + ID: row.ID, + ConversationID: row.ConversationID, + SenderType: row.SenderType, + SenderID: row.SenderID, + SenderRole: row.SenderRole, + SenderName: fallbackName(row.SenderType, row.SenderID, name), + SenderAvatar: avatar, + IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID, + ContentType: row.ContentType, + Content: row.Content, + AttachmentURLS: decodeStringList(row.AttachmentURLS), + CreatedAt: row.CreatedAt, + }) + } + return items, nil +} +func (r *Repository) participantNames(ctx context.Context, rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) { + userIDs := make([]uint64, 0) + adminIDs := make([]uint64, 0) + for _, row := range rows { + if row.ParticipantType == "user" { + userIDs = append(userIDs, row.ParticipantID) + } + if row.ParticipantType == "admin" { + adminIDs = append(adminIDs, row.ParticipantID) + } + } + userNames, userAvatars, err := r.userNames(ctx, userIDs) + if err != nil { + return nil, nil, nil, err + } + adminNames, err := r.adminNames(ctx, adminIDs) + if err != nil { + return nil, nil, nil, err + } + return userNames, userAvatars, adminNames, nil +} +func (r *Repository) userNames(ctx context.Context, ids []uint64) (map[uint64]string, map[uint64]string, error) { + names := map[uint64]string{} + avatars := map[uint64]string{} + if len(ids) == 0 { + return names, avatars, nil + } + var users []model.User + if err := r.db.WithContext(ctx).Where("id IN ?", uniqueIDs(ids)).Find(&users).Error; err != nil { + return nil, nil, err + } + for _, user := range users { + name := user.Nickname + if name == "" { + name = user.Phone + } + names[user.ID] = name + avatars[user.ID] = user.AvatarURL + } + return names, avatars, nil +} +func (r *Repository) adminNames(ctx context.Context, ids []uint64) (map[uint64]string, error) { + names := map[uint64]string{} + if len(ids) == 0 { + return names, nil + } + var admins []model.AdminUser + if err := r.db.WithContext(ctx).Where("id IN ?", uniqueIDs(ids)).Find(&admins).Error; err != nil { + return nil, err + } + for _, admin := range admins { + name := admin.Nickname + if name == "" { + name = admin.Username + } + names[admin.ID] = name + } + return names, nil +} +func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO { + return ConversationDTO{ + ID: row.ID, + OrderID: row.OrderID, + Type: row.Type, + Title: row.Title, + Status: row.Status, + Role: row.Role, + Participants: participants, + LastMessageID: row.LastMessageID, + LastMessagePreview: row.LastMessagePreview, + LastMessageAt: row.LastMessageAt, + UnreadCount: row.UnreadCount, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} +func defaultSupportAdminID(tx *gorm.DB) uint64 { + if supportID := configuredDefaultSupportAdminID(tx); supportID > 0 { + return supportID + } + + adminIDs, err := supportAdminIDs(tx) + if err != nil || len(adminIDs) == 0 { + return 0 + } + + // 未配置默认客服时,按当前会话负载选择客服角色中最空闲的一位。 + type adminLoad struct { + AdminID uint64 + Count int64 + } + var loads []adminLoad + tx.Table("chat_participants AS cp"). + Select("cp.participant_id AS admin_id, COUNT(*) AS count"). + Where("cp.participant_type = ? AND cp.role = ? AND cp.participant_id IN ?", "admin", "support", adminIDs). + Group("cp.participant_id"). + Scan(&loads) + + loadMap := make(map[uint64]int64) + for _, l := range loads { + loadMap[l.AdminID] = l.Count + } + + // 找到负载最少的客服 + var minLoad int64 = -1 + var selectedID uint64 + for _, id := range adminIDs { + count := loadMap[id] + if minLoad < 0 || count < minLoad { + minLoad = count + selectedID = id + } + } + if selectedID > 0 { + return selectedID + } + return 0 +} +func configuredDefaultSupportAdminID(tx *gorm.DB) uint64 { + var cfg model.SystemConfig + if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil { + id, parseErr := strconv.ParseUint(cfg.Value, 10, 64) + if parseErr == nil && id > 0 && adminIsSupport(tx, id) { + return id + } + } + return 0 +} +func supportAdminIDs(tx *gorm.DB) ([]uint64, error) { + var adminIDs []uint64 + err := tx.Table("admin_users AS au"). + Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). + Joins("JOIN roles AS r ON r.id = aur.role_id"). + Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode). + Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). + Pluck("au.id", &adminIDs).Error + return adminIDs, err +} +func adminIsSupport(tx *gorm.DB, id uint64) bool { + var count int64 + if err := tx.Table("admin_users AS au"). + Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). + Joins("JOIN roles AS r ON r.id = aur.role_id"). + Where("au.id = ? AND au.status = ? AND r.code = ?", id, "active", defaultSupportRoleCode). + Count(&count).Error; err != nil { + return false + } + return count > 0 +} +func orderConversationTitle(order model.RentalOrder) string { + if order.OrderNo == "" { + return fmt.Sprintf("订单群聊 #%d", order.ID) + } + return "订单群聊 " + order.OrderNo +} +func fallbackName(participantType string, id uint64, name string) string { + if name != "" { + return name + } + switch participantType { + case "admin": + return "客服" + case "system": + return "系统" + default: + return fmt.Sprintf("用户%d", id) + } +} +func uniqueIDs(ids []uint64) []uint64 { + seen := map[uint64]bool{} + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + return result +} diff --git a/backend/internal/modules/chat/presenter.go b/backend/internal/modules/chat/presenter.go new file mode 100644 index 0000000..3bbe1d1 --- /dev/null +++ b/backend/internal/modules/chat/presenter.go @@ -0,0 +1,55 @@ +package chat + +import ( + "encoding/json" + "gorm.io/datatypes" + "strings" +) + +func normalizePagination(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} +func truncatePreview(content string) string { + runes := []rune(content) + if len(runes) <= 80 { + return content + } + return string(runes[:80]) +} +func messagePreview(content string, attachments []string) string { + content = strings.TrimSpace(content) + if content != "" { + return truncatePreview(content) + } + if len(attachments) > 0 { + return "[图片]" + } + return "" +} +func emptyJSONList() datatypes.JSON { + raw, _ := json.Marshal([]string{}) + return datatypes.JSON(raw) +} +func encodeStringList(items []string) datatypes.JSON { + raw, _ := json.Marshal(items) + return datatypes.JSON(raw) +} +func decodeStringList(raw datatypes.JSON) []string { + if len(raw) == 0 { + return []string{} + } + var items []string + if err := json.Unmarshal(raw, &items); err != nil { + return []string{} + } + return items +} diff --git a/backend/internal/modules/chat/quick_reply.go b/backend/internal/modules/chat/quick_reply.go new file mode 100644 index 0000000..9b2d117 --- /dev/null +++ b/backend/internal/modules/chat/quick_reply.go @@ -0,0 +1,89 @@ +package chat + +import ( + "context" + "hfb_sys/backend/internal/model" +) + +func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error { + return r.db.WithContext(ctx).Model(&model.ChatParticipant{}). + Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). + Update("remark", remark).Error +} +func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) { + var replies []model.ChatQuickReply + err := r.db.WithContext(ctx).Where("admin_user_id = ? OR admin_user_id = 0", adminID). + Order("admin_user_id DESC, sort_order ASC, id ASC"). + Find(&replies).Error + if err != nil { + return nil, err + } + result := make([]QuickReplyDTO, len(replies)) + for i, reply := range replies { + result[i] = QuickReplyDTO{ + ID: reply.ID, + AdminUserID: reply.AdminUserID, + Title: reply.Title, + Content: reply.Content, + SortOrder: reply.SortOrder, + IsGlobal: reply.AdminUserID == 0, + } + } + return result, nil +} +func (r *Repository) CreateQuickReply(ctx context.Context, adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) { + ownerID := adminID + if req.IsGlobal { + ownerID = 0 + } + reply := model.ChatQuickReply{ + AdminUserID: ownerID, + Title: req.Title, + Content: req.Content, + SortOrder: req.SortOrder, + } + if err := r.db.WithContext(ctx).Create(&reply).Error; err != nil { + return nil, err + } + return &QuickReplyDTO{ + ID: reply.ID, + AdminUserID: reply.AdminUserID, + Title: reply.Title, + Content: reply.Content, + SortOrder: reply.SortOrder, + IsGlobal: reply.AdminUserID == 0, + }, nil +} +func (r *Repository) UpdateQuickReply(ctx context.Context, adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error { + query := r.db.WithContext(ctx).Model(&model.ChatQuickReply{}).Where("id = ? AND (admin_user_id = ? OR admin_user_id = 0)", replyID, adminID) + updates := map[string]interface{}{} + if req.Title != "" { + updates["title"] = req.Title + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + if len(updates) == 0 { + return nil + } + return query.Updates(updates).Error +} +func (r *Repository) DeleteQuickReply(ctx context.Context, adminID uint64, replyID uint64) error { + return r.db.WithContext(ctx).Where("id = ? AND admin_user_id = ?", replyID, adminID). + Delete(&model.ChatQuickReply{}).Error +} +func (r *Repository) GetAutoWelcomeMessage(ctx context.Context) string { + var cfg model.SystemConfig + if err := r.db.WithContext(ctx).Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err != nil { + return "欢迎加入订单群聊!如有任何问题,请随时沟通。" + } + return cfg.Value +} +func (r *Repository) UpdateAutoWelcomeMessage(ctx context.Context, message string) error { + return r.db.WithContext(ctx).Model(&model.SystemConfig{}). + Where("`key` = ?", "chat.auto_welcome_message"). + Update("value", message).Error +} diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index 0405709..9bb8b67 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -1,20 +1,12 @@ package chat import ( - "context" - "encoding/json" "errors" - "fmt" - "strconv" - "strings" - "time" - - "hfb_sys/backend/internal/model" - "hfb_sys/backend/internal/modules/chathub" - - "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chathub" + "time" ) type Repository struct { @@ -29,7 +21,6 @@ const ( func NewRepository(db *gorm.DB, hub *chathub.Hub) *Repository { return &Repository{db: db, hub: hub} } - func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { var existing model.ChatConversation err := tx.Where("order_id = ?", order.ID).First(&existing).Error @@ -107,8 +98,6 @@ func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatC } return &conversation, nil } - -// NotifyNewConversation 推送群聊创建事件给会话参与者。应在事务提交后调用。 func (r *Repository) NotifyNewConversation(conversationID uint64) { if r.hub == nil { return @@ -118,1044 +107,3 @@ func (r *Repository) NotifyNewConversation(conversationID uint64) { ConversationID: conversationID, }) } - -func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int) (*PaginatedResult, error) { - page, pageSize = normalizePagination(page, pageSize) - db := r.db.WithContext(ctx) - var total int64 - 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) - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - - var rows []conversationRow - offset := (page - 1) * pageSize - err := r.conversationQuery(ctx, principal). - 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 - } - dto := ConversationDTO{ - ID: conversation.ID, - OrderID: conversation.OrderID, - Type: conversation.Type, - Title: conversation.Title, - Status: conversation.Status, - Role: "admin", // 管理员角色 - Participants: participants, - LastMessageID: conversation.LastMessageID, - LastMessagePreview: conversation.LastMessagePreview, - LastMessageAt: conversation.LastMessageAt, - UnreadCount: 0, // 管理员不计未读 - 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) { - var row conversationRow - principal := Principal{Type: "user", ID: userID} - 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) (*ConversationDTO, error) { - var conversationID uint64 - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var existing model.ChatConversation - 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 cp.participant_type = ? AND cp.participant_id = ?", "general_support", "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: "general_support", - Title: "平台客服", - Status: "active", - } - if err := tx.Create(&conversation).Error; err != nil { - return err - } - - participants := []model.ChatParticipant{ - { - ConversationID: conversation.ID, - ParticipantType: "user", - ParticipantID: userID, - Role: "customer", - 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 err - } - } - - message := model.ChatMessage{ - ConversationID: conversation.ID, - SenderType: "system", - SenderRole: "system", - ContentType: "system", - Content: "您好,客服会尽快回复,请直接描述您遇到的问题。", - 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 -} - -func (r *Repository) Messages(ctx context.Context, principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { - page, pageSize = normalizePagination(page, pageSize) - db := r.db.WithContext(ctx) - - // 管理员可以查看任意会话的消息,普通用户需要是 participant - if principal.Type != "admin" { - if _, err := r.findParticipant(db, principal, conversationID, false); err != nil { - return nil, err - } - } else { - // 管理员需要验证会话存在 - var count int64 - if err := db.Model(&model.ChatConversation{}).Where("id = ?", conversationID).Count(&count).Error; err != nil { - return nil, err - } - if count == 0 { - return nil, ErrConversationNotFound - } - } - - var total int64 - if err := db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).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"). - Offset(offset). - Limit(pageSize). - Find(&rows).Error; err != nil { - return nil, err - } - items, err := r.toMessageDTOs(ctx, principal, rows) - if err != nil { - return nil, err - } - return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil -} - -func (r *Repository) SendMessage(ctx context.Context, principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) { - var messageID uint64 - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var conversation model.ChatConversation - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil { - return err - } - if conversation.Status != "active" { - return ErrPermissionDenied - } - - var senderRole string - var participant *model.ChatParticipant - - // 管理员可以在任意会话发送消息,无需是 participant - if principal.Type == "admin" { - // 尝试查找管理员的 participant 记录 - var p model.ChatParticipant - err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", - conversationID, principal.Type, principal.ID).First(&p).Error - if err == nil { - // 管理员是 participant,使用其角色 - participant = &p - senderRole = p.Role - } else if errors.Is(err, gorm.ErrRecordNotFound) { - // 管理员不是 participant,使用特殊角色 "admin" - senderRole = "admin" - } else { - return err - } - } else { - // 普通用户必须是 participant - p, err := r.findParticipant(tx, principal, conversationID, true) - if err != nil { - return err - } - participant = p - senderRole = p.Role - } - - message := model.ChatMessage{ - ConversationID: conversation.ID, - SenderType: principal.Type, - SenderID: principal.ID, - SenderRole: senderRole, - ContentType: "text", - Content: req.Content, - AttachmentURLS: encodeStringList(req.AttachmentURLS), - } - if err := tx.Create(&message).Error; err != nil { - return err - } - conversation.LastMessageID = &message.ID - conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) - conversation.LastMessageAt = &message.CreatedAt - if err := tx.Save(&conversation).Error; err != nil { - return err - } - - // 更新 participant 的已读时间(仅当是 participant 时) - if participant != nil { - now := time.Now() - if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil { - return err - } - } - - messageID = message.ID - return nil - }) - if err != nil { - return nil, err - } - var message model.ChatMessage - if err := r.db.WithContext(ctx).First(&message, messageID).Error; err != nil { - return nil, err - } - items, err := r.toMessageDTOs(ctx, principal, []model.ChatMessage{message}) - if err != nil { - return nil, err - } - if len(items) == 0 { - return nil, ErrConversationNotFound - } - // 推送新消息事件给会话中的在线参与者 - if r.hub != nil { - msg := items[0] - r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{ - Type: "new_message", - ConversationID: conversationID, - Message: &chathub.MessageData{ - ID: msg.ID, - ConversationID: msg.ConversationID, - SenderType: msg.SenderType, - SenderID: msg.SenderID, - SenderRole: msg.SenderRole, - SenderName: msg.SenderName, - ContentType: msg.ContentType, - Content: msg.Content, - AttachmentURLS: msg.AttachmentURLS, - CreatedAt: msg.CreatedAt.Format(time.RFC3339), - }, - }) - } - return &items[0], nil -} - -func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversationID uint64) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 管理员可以不是 participant,直接返回成功 - if principal.Type == "admin" { - // 尝试查找 participant 记录,如果有就更新 - var participant model.ChatParticipant - err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", - conversationID, principal.Type, principal.ID).First(&participant).Error - if err == nil { - // 有 participant 记录,更新已读时间 - now := time.Now() - return tx.Model(&participant).Update("last_read_at", now).Error - } else if errors.Is(err, gorm.ErrRecordNotFound) { - // 没有 participant 记录,直接返回成功(管理员无需记录已读) - return nil - } - return err - } - - // 普通用户必须是 participant - participant, err := r.findParticipant(tx, principal, conversationID, true) - if err != nil { - return err - } - now := time.Now() - return tx.Model(participant).Update("last_read_at", now).Error - }) -} - -func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB { - return r.db.WithContext(ctx).Table("chat_conversations AS c"). - Select(`c.id, c.order_id, c.type, c.title, c.status, c.last_message_id, - c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, cp.role, - ( - SELECT COUNT(1) - FROM chat_messages AS cm - WHERE cm.conversation_id = c.id - AND NOT (cm.sender_type = ? AND cm.sender_id = ?) - AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) - ) AS unread_count`, principal.Type, principal.ID). - Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). - Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) -} - -func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) { - var participant model.ChatParticipant - db := tx - if lock { - db = db.Clauses(clause.Locking{Strength: "UPDATE"}) - } - err := db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). - First(&participant).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrPermissionDenied - } - return nil, err - } - return &participant, nil -} - -func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) { - var rows []model.ChatParticipant - if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil { - return nil, err - } - userNames, userAvatars, adminNames, err := r.participantNames(ctx, rows) - if err != nil { - return nil, err - } - items := make([]ParticipantDTO, 0, len(rows)) - for _, row := range rows { - name := "系统" - avatar := "" - if row.ParticipantType == "user" { - name = userNames[row.ParticipantID] - avatar = userAvatars[row.ParticipantID] - } - if row.ParticipantType == "admin" { - name = adminNames[row.ParticipantID] - } - items = append(items, ParticipantDTO{ - ID: row.ID, - ConversationID: row.ConversationID, - ParticipantType: row.ParticipantType, - ParticipantID: row.ParticipantID, - Role: row.Role, - DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), - AvatarURL: avatar, - LastReadAt: row.LastReadAt, - JoinedAt: row.JoinedAt, - }) - } - return items, nil -} - -func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { - userIDs := make([]uint64, 0) - adminIDs := make([]uint64, 0) - for _, row := range rows { - if row.SenderType == "user" && row.SenderID > 0 { - userIDs = append(userIDs, row.SenderID) - } - if row.SenderType == "admin" && row.SenderID > 0 { - adminIDs = append(adminIDs, row.SenderID) - } - } - userNames, userAvatars, err := r.userNames(ctx, userIDs) - if err != nil { - return nil, err - } - adminNames, err := r.adminNames(ctx, adminIDs) - if err != nil { - return nil, err - } - items := make([]MessageDTO, 0, len(rows)) - for _, row := range rows { - name := "系统" - avatar := "" - if row.SenderType == "user" { - name = userNames[row.SenderID] - avatar = userAvatars[row.SenderID] - } - if row.SenderType == "admin" { - name = adminNames[row.SenderID] - // 如果角色是 "admin"(不是 participant 的管理员),在名字后添加标识 - if row.SenderRole == "admin" { - name = name + " (管理员)" - } - } - items = append(items, MessageDTO{ - ID: row.ID, - ConversationID: row.ConversationID, - SenderType: row.SenderType, - SenderID: row.SenderID, - SenderRole: row.SenderRole, - SenderName: fallbackName(row.SenderType, row.SenderID, name), - SenderAvatar: avatar, - IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID, - ContentType: row.ContentType, - Content: row.Content, - AttachmentURLS: decodeStringList(row.AttachmentURLS), - CreatedAt: row.CreatedAt, - }) - } - return items, nil -} - -func (r *Repository) participantNames(ctx context.Context, rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) { - userIDs := make([]uint64, 0) - adminIDs := make([]uint64, 0) - for _, row := range rows { - if row.ParticipantType == "user" { - userIDs = append(userIDs, row.ParticipantID) - } - if row.ParticipantType == "admin" { - adminIDs = append(adminIDs, row.ParticipantID) - } - } - userNames, userAvatars, err := r.userNames(ctx, userIDs) - if err != nil { - return nil, nil, nil, err - } - adminNames, err := r.adminNames(ctx, adminIDs) - if err != nil { - return nil, nil, nil, err - } - return userNames, userAvatars, adminNames, nil -} - -func (r *Repository) userNames(ctx context.Context, ids []uint64) (map[uint64]string, map[uint64]string, error) { - names := map[uint64]string{} - avatars := map[uint64]string{} - if len(ids) == 0 { - return names, avatars, nil - } - var users []model.User - if err := r.db.WithContext(ctx).Where("id IN ?", uniqueIDs(ids)).Find(&users).Error; err != nil { - return nil, nil, err - } - for _, user := range users { - name := user.Nickname - if name == "" { - name = user.Phone - } - names[user.ID] = name - avatars[user.ID] = user.AvatarURL - } - return names, avatars, nil -} - -func (r *Repository) adminNames(ctx context.Context, ids []uint64) (map[uint64]string, error) { - names := map[uint64]string{} - if len(ids) == 0 { - return names, nil - } - var admins []model.AdminUser - if err := r.db.WithContext(ctx).Where("id IN ?", uniqueIDs(ids)).Find(&admins).Error; err != nil { - return nil, err - } - for _, admin := range admins { - name := admin.Nickname - if name == "" { - name = admin.Username - } - names[admin.ID] = name - } - return names, nil -} - -type conversationRow struct { - ID uint64 - OrderID *uint64 - Type string - Title string - Status string - Role string - LastMessageID *uint64 - LastMessagePreview string - LastMessageAt *time.Time - UnreadCount int64 - CreatedAt time.Time - UpdatedAt time.Time -} - -func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO { - return ConversationDTO{ - ID: row.ID, - OrderID: row.OrderID, - Type: row.Type, - Title: row.Title, - Status: row.Status, - Role: row.Role, - Participants: participants, - LastMessageID: row.LastMessageID, - LastMessagePreview: row.LastMessagePreview, - LastMessageAt: row.LastMessageAt, - UnreadCount: row.UnreadCount, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func defaultSupportAdminID(tx *gorm.DB) uint64 { - if supportID := configuredDefaultSupportAdminID(tx); supportID > 0 { - return supportID - } - - adminIDs, err := supportAdminIDs(tx) - if err != nil || len(adminIDs) == 0 { - return 0 - } - - // 未配置默认客服时,按当前会话负载选择客服角色中最空闲的一位。 - type adminLoad struct { - AdminID uint64 - Count int64 - } - var loads []adminLoad - tx.Table("chat_participants AS cp"). - Select("cp.participant_id AS admin_id, COUNT(*) AS count"). - Where("cp.participant_type = ? AND cp.role = ? AND cp.participant_id IN ?", "admin", "support", adminIDs). - Group("cp.participant_id"). - Scan(&loads) - - loadMap := make(map[uint64]int64) - for _, l := range loads { - loadMap[l.AdminID] = l.Count - } - - // 找到负载最少的客服 - var minLoad int64 = -1 - var selectedID uint64 - for _, id := range adminIDs { - count := loadMap[id] - if minLoad < 0 || count < minLoad { - minLoad = count - selectedID = id - } - } - if selectedID > 0 { - return selectedID - } - return 0 -} - -func configuredDefaultSupportAdminID(tx *gorm.DB) uint64 { - var cfg model.SystemConfig - if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil { - id, parseErr := strconv.ParseUint(cfg.Value, 10, 64) - if parseErr == nil && id > 0 && adminIsSupport(tx, id) { - return id - } - } - return 0 -} - -func supportAdminIDs(tx *gorm.DB) ([]uint64, error) { - var adminIDs []uint64 - err := tx.Table("admin_users AS au"). - Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). - Joins("JOIN roles AS r ON r.id = aur.role_id"). - Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode). - Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). - Pluck("au.id", &adminIDs).Error - return adminIDs, err -} - -func adminIsSupport(tx *gorm.DB, id uint64) bool { - var count int64 - if err := tx.Table("admin_users AS au"). - Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). - Joins("JOIN roles AS r ON r.id = aur.role_id"). - Where("au.id = ? AND au.status = ? AND r.code = ?", id, "active", defaultSupportRoleCode). - Count(&count).Error; err != nil { - return false - } - return count > 0 -} - -func orderConversationTitle(order model.RentalOrder) string { - if order.OrderNo == "" { - return fmt.Sprintf("订单群聊 #%d", order.ID) - } - return "订单群聊 " + order.OrderNo -} - -func normalizePagination(page, pageSize int) (int, int) { - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - if pageSize > 100 { - pageSize = 100 - } - return page, pageSize -} - -func truncatePreview(content string) string { - runes := []rune(content) - if len(runes) <= 80 { - return content - } - return string(runes[:80]) -} - -func messagePreview(content string, attachments []string) string { - content = strings.TrimSpace(content) - if content != "" { - return truncatePreview(content) - } - if len(attachments) > 0 { - return "[图片]" - } - return "" -} - -// TransferConversation 转接会话给其他客服 -func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 验证当前操作者是会话参与者 - if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil { - return err - } - // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 - if !adminIsSupport(tx, toAdminID) { - return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") - } - // 检查目标客服是否已有该会话 - var count int64 - if err := tx.Model(&model.ChatParticipant{}). - Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", toAdminID). - Count(&count).Error; err != nil { - return err - } - if count > 0 { - return fmt.Errorf("该客服已在会话中") - } - // 删除原客服参与者 - if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support"). - Delete(&model.ChatParticipant{}).Error; err != nil { - return err - } - // 添加新客服参与者 - participant := model.ChatParticipant{ - ConversationID: conversationID, - ParticipantType: "admin", - ParticipantID: toAdminID, - Role: "support", - JoinedAt: time.Now(), - } - if err := tx.Create(&participant).Error; err != nil { - return err - } - // 添加系统消息记录转接 - message := model.ChatMessage{ - ConversationID: conversationID, - SenderType: "system", - SenderRole: "system", - ContentType: "system", - Content: "会话已转接给其他客服", - AttachmentURLS: emptyJSONList(), - } - if err := tx.Create(&message).Error; err != nil { - return err - } - return nil - }) -} - -// GetAvailableSupportAdmins 获取可用客服列表及其会话数 -func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) { - db := r.db.WithContext(ctx) - // 仅展示客服角色管理员,超级管理员即使有 chat:view 权限也不作为客服候选。 - type adminRow struct { - ID uint64 - Nickname string - SupportStatus string - } - var admins []adminRow - err := db.Table("admin_users AS au"). - Select("au.id, COALESCE(NULLIF(au.nickname, ''), au.username) AS nickname, au.support_status"). - Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). - Joins("JOIN roles AS r ON r.id = aur.role_id"). - Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode). - Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). - Scan(&admins).Error - if err != nil { - return nil, err - } - - // 统计每个客服的会话数 - type loadRow struct { - AdminID uint64 - Count int64 - } - var loads []loadRow - adminIDs := make([]uint64, len(admins)) - for i, a := range admins { - adminIDs[i] = a.ID - } - if len(adminIDs) > 0 { - db.Table("chat_participants"). - Select("participant_id AS admin_id, COUNT(*) AS count"). - Where("participant_type = ? AND role = ? AND participant_id IN ?", "admin", "support", adminIDs). - Group("participant_id"). - Scan(&loads) - } - loadMap := make(map[uint64]int64) - for _, l := range loads { - loadMap[l.AdminID] = l.Count - } - - result := make([]SupportAdminDTO, len(admins)) - for i, a := range admins { - result[i] = SupportAdminDTO{ - ID: a.ID, - Nickname: a.Nickname, - SupportStatus: a.SupportStatus, - ChatCount: loadMap[a.ID], - } - } - return result, nil -} - -// ListConversationsWithFilter 支持筛选的会话列表 -func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) { - page, pageSize = normalizePagination(page, pageSize) - db := r.db.WithContext(ctx) - - // 管理员在"全部"模式下直接查询所有会话 - if principal.Type == "admin" && filter == "all" { - var total int64 - if err := db.Model(&model.ChatConversation{}).Count(&total).Error; err != nil { - return nil, err - } - - var conversations []model.ChatConversation - offset := (page - 1) * pageSize - if err := db.Order("COALESCE(last_message_at, created_at) DESC, id DESC"). - Offset(offset). - Limit(pageSize). - Find(&conversations).Error; err != nil { - return nil, err - } - - items := make([]ConversationDTO, 0, len(conversations)) - for _, conv := range conversations { - participants, err := r.participants(ctx, conv.ID) - if err != nil { - return nil, err - } - items = append(items, ConversationDTO{ - ID: conv.ID, - OrderID: conv.OrderID, - Type: conv.Type, - Title: conv.Title, - Status: conv.Status, - Role: "admin", // 管理员角色 - Participants: participants, - LastMessageID: conv.LastMessageID, - LastMessagePreview: conv.LastMessagePreview, - LastMessageAt: conv.LastMessageAt, - UnreadCount: 0, // 管理员不计未读 - CreatedAt: conv.CreatedAt, - UpdatedAt: conv.UpdatedAt, - }) - } - return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil - } - - // 其他情况使用原有逻辑 - var total int64 - countDB := db.Table("chat_conversations AS c"). - Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id") - - switch filter { - case "mine": - // 只看我的会话 - countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) - case "unassigned": - // 未分配客服的会话 - countDB = countDB.Where("c.id NOT IN (?)", - db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) - default: - // 普通用户的全部会话 - countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) - } - - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - - var rows []conversationRow - offset := (page - 1) * pageSize - - queryDB := r.conversationQuery(ctx, principal) - switch filter { - case "mine": - queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) - case "unassigned": - queryDB = queryDB.Where("c.id NOT IN (?)", - db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) - default: - // 普通用户的全部会话 - queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) - } - - err := queryDB. - 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 { - participants, err := r.participants(ctx, row.ID) - if err != nil { - return nil, err - } - items = append(items, row.toDTO(participants)) - } - return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil -} - -func fallbackName(participantType string, id uint64, name string) string { - if name != "" { - return name - } - switch participantType { - case "admin": - return "客服" - case "system": - return "系统" - default: - return fmt.Sprintf("用户%d", id) - } -} - -func uniqueIDs(ids []uint64) []uint64 { - seen := map[uint64]bool{} - result := make([]uint64, 0, len(ids)) - for _, id := range ids { - if id == 0 || seen[id] { - continue - } - seen[id] = true - result = append(result, id) - } - return result -} - -func emptyJSONList() datatypes.JSON { - raw, _ := json.Marshal([]string{}) - return datatypes.JSON(raw) -} - -func encodeStringList(items []string) datatypes.JSON { - raw, _ := json.Marshal(items) - return datatypes.JSON(raw) -} - -// UpdateRemark 更新会话备注 -func (r *Repository) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, remark string) error { - return r.db.WithContext(ctx).Model(&model.ChatParticipant{}). - Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). - Update("remark", remark).Error -} - -// ListQuickReplies 获取快捷回复列表(个人 + 全局) -func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) { - var replies []model.ChatQuickReply - err := r.db.WithContext(ctx).Where("admin_user_id = ? OR admin_user_id = 0", adminID). - Order("admin_user_id DESC, sort_order ASC, id ASC"). - Find(&replies).Error - if err != nil { - return nil, err - } - result := make([]QuickReplyDTO, len(replies)) - for i, reply := range replies { - result[i] = QuickReplyDTO{ - ID: reply.ID, - AdminUserID: reply.AdminUserID, - Title: reply.Title, - Content: reply.Content, - SortOrder: reply.SortOrder, - IsGlobal: reply.AdminUserID == 0, - } - } - return result, nil -} - -// CreateQuickReply 创建快捷回复 -func (r *Repository) CreateQuickReply(ctx context.Context, adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) { - ownerID := adminID - if req.IsGlobal { - ownerID = 0 - } - reply := model.ChatQuickReply{ - AdminUserID: ownerID, - Title: req.Title, - Content: req.Content, - SortOrder: req.SortOrder, - } - if err := r.db.WithContext(ctx).Create(&reply).Error; err != nil { - return nil, err - } - return &QuickReplyDTO{ - ID: reply.ID, - AdminUserID: reply.AdminUserID, - Title: reply.Title, - Content: reply.Content, - SortOrder: reply.SortOrder, - IsGlobal: reply.AdminUserID == 0, - }, nil -} - -// UpdateQuickReply 更新快捷回复 -func (r *Repository) UpdateQuickReply(ctx context.Context, adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error { - query := r.db.WithContext(ctx).Model(&model.ChatQuickReply{}).Where("id = ? AND (admin_user_id = ? OR admin_user_id = 0)", replyID, adminID) - updates := map[string]interface{}{} - if req.Title != "" { - updates["title"] = req.Title - } - if req.Content != "" { - updates["content"] = req.Content - } - if req.SortOrder != nil { - updates["sort_order"] = *req.SortOrder - } - if len(updates) == 0 { - return nil - } - return query.Updates(updates).Error -} - -// DeleteQuickReply 删除快捷回复 -func (r *Repository) DeleteQuickReply(ctx context.Context, adminID uint64, replyID uint64) error { - return r.db.WithContext(ctx).Where("id = ? AND admin_user_id = ?", replyID, adminID). - Delete(&model.ChatQuickReply{}).Error -} - -// GetAutoWelcomeMessage 获取建群自动话术 -func (r *Repository) GetAutoWelcomeMessage(ctx context.Context) string { - var cfg model.SystemConfig - if err := r.db.WithContext(ctx).Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err != nil { - return "欢迎加入订单群聊!如有任何问题,请随时沟通。" - } - return cfg.Value -} - -// UpdateAutoWelcomeMessage 更新建群自动话术 -func (r *Repository) UpdateAutoWelcomeMessage(ctx context.Context, message string) error { - return r.db.WithContext(ctx).Model(&model.SystemConfig{}). - Where("`key` = ?", "chat.auto_welcome_message"). - Update("value", message).Error -} - -func decodeStringList(raw datatypes.JSON) []string { - if len(raw) == 0 { - return []string{} - } - var items []string - if err := json.Unmarshal(raw, &items); err != nil { - return []string{} - } - return items -} diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go new file mode 100644 index 0000000..0d4fc5c --- /dev/null +++ b/backend/internal/modules/chat/support.go @@ -0,0 +1,215 @@ +package chat + +import ( + "context" + "fmt" + "gorm.io/gorm" + "hfb_sys/backend/internal/model" + "time" +) + +func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 验证当前操作者是会话参与者 + if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil { + return err + } + // 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。 + if !adminIsSupport(tx, toAdminID) { + return fmt.Errorf("目标客服不存在、已禁用或不是客服角色") + } + // 检查目标客服是否已有该会话 + var count int64 + if err := tx.Model(&model.ChatParticipant{}). + Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", toAdminID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return fmt.Errorf("该客服已在会话中") + } + // 删除原客服参与者 + if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support"). + Delete(&model.ChatParticipant{}).Error; err != nil { + return err + } + // 添加新客服参与者 + participant := model.ChatParticipant{ + ConversationID: conversationID, + ParticipantType: "admin", + ParticipantID: toAdminID, + Role: "support", + JoinedAt: time.Now(), + } + if err := tx.Create(&participant).Error; err != nil { + return err + } + // 添加系统消息记录转接 + message := model.ChatMessage{ + ConversationID: conversationID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: "会话已转接给其他客服", + AttachmentURLS: emptyJSONList(), + } + if err := tx.Create(&message).Error; err != nil { + return err + } + return nil + }) +} +func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) { + db := r.db.WithContext(ctx) + // 仅展示客服角色管理员,超级管理员即使有 chat:view 权限也不作为客服候选。 + type adminRow struct { + ID uint64 + Nickname string + SupportStatus string + } + var admins []adminRow + err := db.Table("admin_users AS au"). + Select("au.id, COALESCE(NULLIF(au.nickname, ''), au.username) AS nickname, au.support_status"). + Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). + Joins("JOIN roles AS r ON r.id = aur.role_id"). + Where("au.status = ? AND r.code = ?", "active", defaultSupportRoleCode). + Order("CASE au.support_status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, au.id ASC"). + Scan(&admins).Error + if err != nil { + return nil, err + } + + // 统计每个客服的会话数 + type loadRow struct { + AdminID uint64 + Count int64 + } + var loads []loadRow + adminIDs := make([]uint64, len(admins)) + for i, a := range admins { + adminIDs[i] = a.ID + } + if len(adminIDs) > 0 { + db.Table("chat_participants"). + Select("participant_id AS admin_id, COUNT(*) AS count"). + Where("participant_type = ? AND role = ? AND participant_id IN ?", "admin", "support", adminIDs). + Group("participant_id"). + Scan(&loads) + } + loadMap := make(map[uint64]int64) + for _, l := range loads { + loadMap[l.AdminID] = l.Count + } + + result := make([]SupportAdminDTO, len(admins)) + for i, a := range admins { + result[i] = SupportAdminDTO{ + ID: a.ID, + Nickname: a.Nickname, + SupportStatus: a.SupportStatus, + ChatCount: loadMap[a.ID], + } + } + return result, nil +} +func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + db := r.db.WithContext(ctx) + + // 管理员在"全部"模式下直接查询所有会话 + if principal.Type == "admin" && filter == "all" { + var total int64 + if err := db.Model(&model.ChatConversation{}).Count(&total).Error; err != nil { + return nil, err + } + + var conversations []model.ChatConversation + offset := (page - 1) * pageSize + if err := db.Order("COALESCE(last_message_at, created_at) DESC, id DESC"). + Offset(offset). + Limit(pageSize). + Find(&conversations).Error; err != nil { + return nil, err + } + + items := make([]ConversationDTO, 0, len(conversations)) + for _, conv := range conversations { + participants, err := r.participants(ctx, conv.ID) + if err != nil { + return nil, err + } + items = append(items, ConversationDTO{ + ID: conv.ID, + OrderID: conv.OrderID, + Type: conv.Type, + Title: conv.Title, + Status: conv.Status, + Role: "admin", // 管理员角色 + Participants: participants, + LastMessageID: conv.LastMessageID, + LastMessagePreview: conv.LastMessagePreview, + LastMessageAt: conv.LastMessageAt, + UnreadCount: 0, // 管理员不计未读 + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + }) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil + } + + // 其他情况使用原有逻辑 + var total int64 + countDB := db.Table("chat_conversations AS c"). + Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id") + + switch filter { + case "mine": + // 只看我的会话 + countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + case "unassigned": + // 未分配客服的会话 + countDB = countDB.Where("c.id NOT IN (?)", + db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) + default: + // 普通用户的全部会话 + countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + } + + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + var rows []conversationRow + offset := (page - 1) * pageSize + + queryDB := r.conversationQuery(ctx, principal) + switch filter { + case "mine": + queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + case "unassigned": + queryDB = queryDB.Where("c.id NOT IN (?)", + db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) + default: + // 普通用户的全部会话 + queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + } + + err := queryDB. + 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 { + participants, err := r.participants(ctx, row.ID) + if err != nil { + return nil, err + } + items = append(items, row.toDTO(participants)) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} diff --git a/backend/internal/modules/listing/audit.go b/backend/internal/modules/listing/audit.go new file mode 100644 index 0000000..7d1a739 --- /dev/null +++ b/backend/internal/modules/listing/audit.go @@ -0,0 +1,19 @@ +package listing + +import ( + "hfb_sys/backend/internal/auditlog" + + "gorm.io/gorm" +) + +func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: actorID, + Action: action, + BizType: bizType, + BizID: &bizID, + Meta: meta, + Detail: detail, + }) +} diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go new file mode 100644 index 0000000..84776c5 --- /dev/null +++ b/backend/internal/modules/listing/mutation.go @@ -0,0 +1,314 @@ +package listing + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listingNo, err := r.nextListingNo(tx, time.Now()) + if err != nil { + return err + } + screenshots, err := marshalScreenshots(req.ScreenshotURLS) + if err != nil { + return err + } + assetSummary, err := marshalAssetSummary(req.AssetSummary) + if err != nil { + return err + } + listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) + account := model.GameAccount{ + OwnerID: ownerID, + GameName: "delta_force", + ServerRegion: req.ServerRegion, + LoginPlatform: req.LoginPlatform, + Title: req.Title, + Description: req.Description, + RankLevel: req.RankLevel, + HafCoinAmount: req.HafCoinAmount, + AssetSummary: assetSummary, + ScreenshotURLS: screenshots, + Status: listingStatus, + } + if err := tx.Create(&account).Error; err != nil { + return err + } + priceCent := normalizedListingPriceCent(req) + listing := model.RentalListing{ + ListingNo: listingNo, + AccountID: account.ID, + OwnerID: ownerID, + PriceCent: priceCent, + DepositAmountCent: req.DepositAmountCent, + Status: listingStatus, + ReviewStatus: reviewStatus, + PublishedAt: publishedAt, + } + if err := tx.Create(&listing).Error; err != nil { + return err + } + dto = toDTO(account, listing) + return nil + }) + return dto, err +} + +type externalUploadCreate struct { + UploaderName string + ClientUploadTime *time.Time + ClientIP string + RawPayload []byte + ParsedPayload []byte +} + +func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listingNo, err := r.nextListingNo(tx, time.Now()) + if err != nil { + return err + } + admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName) + if err != nil { + return err + } + owner, err := r.ensureUploadOwnerUser(tx, admin) + if err != nil { + return err + } + screenshots, err := marshalScreenshots(req.ScreenshotURLS) + if err != nil { + return err + } + assetSummary, err := marshalAssetSummary(req.AssetSummary) + if err != nil { + return err + } + account := model.GameAccount{ + OwnerID: owner.ID, + GameName: "delta_force", + ServerRegion: req.ServerRegion, + LoginPlatform: req.LoginPlatform, + Title: req.Title, + Description: req.Description, + RankLevel: req.RankLevel, + HafCoinAmount: req.HafCoinAmount, + AssetSummary: assetSummary, + ScreenshotURLS: screenshots, + Status: "draft", + } + if err := tx.Create(&account).Error; err != nil { + return err + } + priceCent := normalizedListingPriceCent(req) + listing := model.RentalListing{ + ListingNo: listingNo, + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: priceCent, + DepositAmountCent: req.DepositAmountCent, + Status: "draft", + ReviewStatus: "pending", + } + if err := tx.Create(&listing).Error; err != nil { + return err + } + matchedAdminID := admin.ID + ownerID := owner.ID + listingID := listing.ID + uploadRow := model.ListingUpload{ + UploaderName: upload.UploaderName, + MatchedAdminID: &matchedAdminID, + OwnerID: &ownerID, + ClientUploadTime: upload.ClientUploadTime, + ClientIP: upload.ClientIP, + RawPayload: datatypes.JSON(upload.RawPayload), + ParsedPayload: datatypes.JSON(upload.ParsedPayload), + ListingID: &listingID, + Status: "draft_created", + } + if err := tx.Create(&uploadRow).Error; err != nil { + return err + } + dto = toDTO(account, listing) + return nil + }) + return dto, err +} + +func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + + account.Title = req.Title + account.Description = req.Description + account.ServerRegion = req.ServerRegion + account.LoginPlatform = req.LoginPlatform + account.RankLevel = req.RankLevel + account.HafCoinAmount = req.HafCoinAmount + assetSummary, err := marshalAssetSummary(req.AssetSummary) + if err != nil { + return err + } + account.AssetSummary = assetSummary + screenshots, err := marshalScreenshots(req.ScreenshotURLS) + if err != nil { + return err + } + account.ScreenshotURLS = screenshots + + listing.PriceCent = normalizedListingPriceCent(req) + listing.DepositAmountCent = req.DepositAmountCent + listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) + listing.Status = listingStatus + listing.ReviewStatus = reviewStatus + listing.ReviewReason = "" + listing.PublishedAt = publishedAt + account.Status = listingStatus + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID uint64, reviewRequired bool) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) + listing.Status = listingStatus + listing.ReviewStatus = reviewStatus + listing.ReviewReason = "" + listing.PublishedAt = publishedAt + account.Status = listingStatus + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) findActiveUploadAdmin(tx *gorm.DB, uploaderName string) (*model.AdminUser, error) { + uploaderName = strings.TrimSpace(uploaderName) + var admin model.AdminUser + if err := tx.Where("username = ? AND status = ?", uploaderName, "active").First(&admin).Error; err == nil { + return &admin, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + var admins []model.AdminUser + if err := tx.Where("nickname = ? AND status = ?", uploaderName, "active").Limit(2).Find(&admins).Error; err != nil { + return nil, err + } + switch len(admins) { + case 0: + return nil, ErrUploaderNotFound + case 1: + return &admins[0], nil + default: + return nil, ErrUploaderAmbiguous + } +} + +func (r *Repository) ensureUploadOwnerUser(tx *gorm.DB, admin *model.AdminUser) (*model.User, error) { + phone := fmt.Sprintf("admin:%d", admin.ID) + nickname := strings.TrimSpace(admin.Nickname) + if nickname == "" { + nickname = admin.Username + } + var user model.User + err := tx.Where("phone = ?", phone).First(&user).Error + if err == nil { + updates := map[string]any{ + "nickname": nickname, + "status": "active", + "realname_status": "verified", + } + if err := tx.Model(&user).Updates(updates).Error; err != nil { + return nil, err + } + user.Nickname = nickname + user.Status = "active" + user.RealnameStatus = "verified" + return &user, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + user = model.User{ + Phone: phone, + Nickname: nickname, + RealnameStatus: "verified", + RiskStatus: "normal", + CreditScore: 100, + Status: "active", + } + if err := tx.Create(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint64) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + listing.Status = "offline" + listing.ReviewStatus = "none" + listing.ReviewReason = "号主已手动下架" + listing.PublishedAt = nil + account.Status = "offline" + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go new file mode 100644 index 0000000..b394718 --- /dev/null +++ b/backend/internal/modules/listing/presenter.go @@ -0,0 +1,374 @@ +package listing + +import ( + "encoding/json" + "math" + "net/url" + "strconv" + "strings" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/pkg/money" + + "gorm.io/datatypes" +) + +type listingRow struct { + model.RentalListing + Title string + OwnerPhone string + OwnerNickname string + Description string + GameName string + ServerRegion string + LoginPlatform string + RankLevel string + HafCoinAmount int64 + AssetSummary datatypes.JSON `gorm:"column:asset_summary"` + ScreenshotURLS datatypes.JSON `gorm:"column:screenshot_urls"` +} + +func rowsToDTO(rows []listingRow) []ListingDTO { + items := make([]ListingDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items +} + +func normalizedListingPriceCent(req CreateRequest) int64 { + if req.AssetSummary != nil { + if breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any); ok { + buyerPrice := readSummaryNumber(breakdown["buyer_total_price"]) + if buyerPrice > 0 { + return yuanToCent(buyerPrice) + } + } + } + return req.PriceCent +} + +func publicListings(items []ListingDTO) []ListingDTO { + for index := range items { + applyPublicListingURLs(&items[index]) + } + return items +} + +func sellerListings(items []ListingDTO) []ListingDTO { + for index := range items { + applySellerListingPrice(&items[index]) + } + return items +} + +func applyPublicListingURLs(item *ListingDTO) { + item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus) + if item.AssetSummary != nil { + delete(item.AssetSummary, "price_breakdown") + } +} + +func applySellerListingPrice(item *ListingDTO) { + if item == nil || item.AssetSummary == nil { + return + } + breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any) + if !ok { + return + } + sellerPrice := readSummaryNumber(breakdown["seller_total_price"]) + if sellerPrice > 0 { + item.PriceCent = int64(math.Round(sellerPrice * 100)) + } + sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) + if sellerRatio > 0 { + item.AssetSummary["publish_ratio"] = sellerRatio + } + delete(breakdown, "buyer_coin_base_price") + delete(breakdown, "buyer_total_price") + delete(breakdown, "buyer_ratio") + delete(breakdown, "platform_markup_amount") + delete(breakdown, "platform_rule_type") +} + +func (row listingRow) toDTO() ListingDTO { + assetSummary := decodeAssetSummary(row.AssetSummary) + screenshotURLS := cleanScreenshotURLs(decodeScreenshots(row.ScreenshotURLS)) + reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason) + return ListingDTO{ + ID: row.ID, + ListingNo: row.ListingNo, + AccountID: row.AccountID, + OwnerID: row.OwnerID, + OwnerPhone: row.OwnerPhone, + OwnerNickname: row.OwnerNickname, + Title: row.Title, + Description: row.Description, + GameName: row.GameName, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RankLevel: row.RankLevel, + HafCoinAmount: row.HafCoinAmount, + AssetSummary: assetSummary, + ScreenshotURLS: screenshotURLS, + CoverURL: publicCoverURL(row.ID, screenshotURLS, row.Status, row.ReviewStatus), + PriceCent: row.PriceCent, + DepositAmountCent: row.DepositAmountCent, + IsAccelerated: isAcceleratedSale(assetSummary), + InTransaction: row.InTransaction, + Status: row.Status, + ReviewStatus: reviewStatus, + ReviewReason: reviewReason, + PublishedAt: row.PublishedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { + assetSummary := decodeAssetSummary(account.AssetSummary) + screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS)) + reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason) + return &ListingDTO{ + ID: listing.ID, + ListingNo: listing.ListingNo, + AccountID: account.ID, + OwnerID: listing.OwnerID, + Title: account.Title, + Description: account.Description, + GameName: account.GameName, + ServerRegion: account.ServerRegion, + LoginPlatform: account.LoginPlatform, + RankLevel: account.RankLevel, + HafCoinAmount: account.HafCoinAmount, + AssetSummary: assetSummary, + ScreenshotURLS: screenshotURLS, + CoverURL: publicCoverURL(listing.ID, screenshotURLS, listing.Status, listing.ReviewStatus), + PriceCent: listing.PriceCent, + DepositAmountCent: listing.DepositAmountCent, + IsAccelerated: isAcceleratedSale(assetSummary), + InTransaction: listing.InTransaction, + Status: listing.Status, + ReviewStatus: reviewStatus, + ReviewReason: reviewReason, + PublishedAt: listing.PublishedAt, + CreatedAt: listing.CreatedAt, + UpdatedAt: listing.UpdatedAt, + } +} + +func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) { + if status == "offline" { + return "none", reviewReason + } + return reviewStatus, reviewReason +} + +func marshalScreenshots(urls []string) (datatypes.JSON, error) { + cleaned := cleanScreenshotURLs(urls) + if len(cleaned) > 12 { + cleaned = cleaned[:12] + } + raw, err := json.Marshal(cleaned) + if err != nil { + return nil, err + } + return datatypes.JSON(raw), nil +} + +func marshalAssetSummary(summary map[string]any) (datatypes.JSON, error) { + if summary == nil { + return nil, nil + } + raw, err := json.Marshal(summary) + if err != nil { + return nil, err + } + return datatypes.JSON(raw), nil +} + +func decodeScreenshots(raw datatypes.JSON) []string { + if len(raw) == 0 { + return []string{} + } + var urls []string + if err := json.Unmarshal(raw, &urls); err != nil { + return []string{} + } + return urls +} + +func decodeAssetSummary(raw datatypes.JSON) map[string]any { + if len(raw) == 0 { + return nil + } + var summary map[string]any + if err := json.Unmarshal(raw, &summary); err != nil { + return nil + } + return summary +} + +func isAcceleratedSale(summary map[string]any) bool { + if summary == nil { + return false + } + breakdown, ok := summary["price_breakdown"].(map[string]any) + if !ok { + return false + } + referenceRatio := readSummaryNumber(breakdown["seller_reference_ratio"]) + sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) + acceleratedRatio := readSummaryNumber(breakdown["accelerated_sale_ratio"]) + if referenceRatio <= 0 { + return false + } + return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio +} + +func readSummaryNumber(value any) float64 { + switch typed := value.(type) { + case float64: + return typed + case float32: + return float64(typed) + case int: + return float64(typed) + case int64: + return float64(typed) + case json.Number: + number, err := typed.Float64() + if err != nil { + return 0 + } + return number + case string: + number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + if err != nil { + return 0 + } + return number + default: + return 0 + } +} + +func ensurePriceBreakdown(summary map[string]any) map[string]any { + if summary == nil { + return map[string]any{} + } + breakdown, ok := summary["price_breakdown"].(map[string]any) + if ok { + return breakdown + } + breakdown = map[string]any{} + if raw, ok := summary["price_breakdown"].(map[string]interface{}); ok { + for key, value := range raw { + breakdown[key] = value + } + } + return breakdown +} + +func calculateAdminAdjustedPrice(req AdminPriceAdjustRequest, coinWan float64, consumablePrice float64) (float64, float64, float64) { + if req.BuyerTotalPriceCent > 0 { + buyerTotalPrice := roundMoney(centToYuan(req.BuyerTotalPriceCent)) + buyerCoinBasePrice := roundMoney(buyerTotalPrice - consumablePrice) + if buyerCoinBasePrice <= 0 || coinWan <= 0 { + return 0, 0, 0 + } + return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice) + } + if req.BuyerRatio <= 0 || coinWan <= 0 { + return 0, 0, 0 + } + buyerCoinBasePrice := roundMoney(coinWan / req.BuyerRatio) + buyerTotalPrice := roundMoney(buyerCoinBasePrice + consumablePrice) + return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice) +} + +func roundRatio(value float64) float64 { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return math.Round(value*10) / 10 +} + +func yuanToCent(value float64) int64 { + return int64(math.Round(roundMoney(value) * 100)) +} + +func centToYuan(value int64) float64 { + return float64(value) / 100 +} + +func cleanScreenshotURLs(urls []string) []string { + cleaned := make([]string, 0, len(urls)) + seen := make(map[string]struct{}, len(urls)) + for _, url := range urls { + url = strings.TrimSpace(url) + if url == "" { + continue + } + if _, ok := seen[url]; ok { + continue + } + seen[url] = struct{}{} + cleaned = append(cleaned, url) + } + return cleaned +} + +func firstScreenshotURL(urls []string) string { + if len(urls) == 0 { + return "" + } + return urls[0] +} + +func publicCoverURL(listingID uint64, urls []string, status string, reviewStatus string) string { + fallback := firstScreenshotURL(urls) + if status != "published" || reviewStatus != "approved" || extractListingObjectKey(fallback) == "" { + return fallback + } + return "/api/listings/" + strconv.FormatUint(listingID, 10) + "/cover" +} + +func publicScreenshotURLs(listingID uint64, urls []string, status string, reviewStatus string) []string { + if status != "published" || reviewStatus != "approved" { + return urls + } + publicURLs := make([]string, 0, len(urls)) + for index, fileURL := range urls { + if extractListingObjectKey(fileURL) == "" { + publicURLs = append(publicURLs, fileURL) + continue + } + publicURLs = append(publicURLs, "/api/listings/"+strconv.FormatUint(listingID, 10)+"/screenshots/"+strconv.Itoa(index)) + } + return publicURLs +} + +func extractListingObjectKey(fileURL string) string { + if fileURL == "" { + return "" + } + parsed, err := url.Parse(fileURL) + if err != nil { + return "" + } + key := parsed.Query().Get("key") + if key == "" { + return "" + } + if !strings.HasPrefix(key, "listing/") || strings.Contains(key, "..") { + return "" + } + return key +} + +// roundMoney 使用统一的角精度(0.1元) +func roundMoney(value float64) float64 { + return money.Round(value) +} diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go new file mode 100644 index 0000000..96e3db6 --- /dev/null +++ b/backend/internal/modules/listing/public_filter.go @@ -0,0 +1,384 @@ +package listing + +import ( + "sort" + "strconv" + "strings" + "time" +) + +func filterPublicListings(items []ListingDTO, query PublicListQuery) []ListingDTO { + filtered := make([]ListingDTO, 0, len(items)) + for _, item := range items { + if !matchesPublicQuery(item, query) { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool { + if !matchesPublicZone(item, query.Zone) { + return false + } + if keyword := strings.ToLower(strings.TrimSpace(query.Keyword)); keyword != "" && !strings.Contains(publicSearchText(item), keyword) { + return false + } + if !matchesAny(query.Server, strings.TrimSpace(item.ServerRegion)) { + return false + } + if len(query.Region) > 0 && !intersects(query.Region, assetRegionsFromSummary(item.AssetSummary)) { + return false + } + if !matchesAny(query.LoginMethod, strings.TrimSpace(item.LoginPlatform)) { + return false + } + if !matchesAny(query.Rank, strings.TrimSpace(item.RankLevel)) { + return false + } + if !matchesAny(query.Insurance, readAssetString(item.AssetSummary, "season_insurance")) { + return false + } + if !matchesAny(query.Stamina, readAssetString(item.AssetSummary, "stamina_level")) { + return false + } + if !matchesAny(query.Load, readAssetString(item.AssetSummary, "load_level")) { + return false + } + if len(query.SkinName) > 0 { + if len(query.SkinGroup) > 0 { + if !skinGroupsContainAny(item.AssetSummary, query.SkinGroup, query.SkinName) { + return false + } + } else if !intersects(query.SkinName, skinNamesFromSummary(item.AssetSummary)) { + return false + } + } else if len(query.SkinGroup) > 0 && !skinGroupsHaveAny(item.AssetSummary, query.SkinGroup) { + return false + } + + price := float64(item.PriceCent) / 100 + deposit := float64(item.DepositAmountCent) / 100 + total := price + deposit + coinM := coinMFromListing(item) + if !numberInRange(coinM, NumberRange{Min: query.MinCoin, Max: query.MaxCoin}) { + return false + } + if !numberInRange(price, NumberRange{Min: query.MinPrice, Max: query.MaxPrice}) { + return false + } + if !numberInRange(deposit, NumberRange{Min: query.MinDeposit, Max: query.MaxDeposit}) { + return false + } + if !numberInRange(total, NumberRange{Min: query.MinTotal, Max: query.MaxTotal}) { + return false + } + if !numberInRange(readSummaryNumber(item.AssetSummary["fire_level"]), NumberRange{Min: query.MinFireLevel, Max: query.MaxFireLevel}) { + return false + } + if !numberInRange(readSummaryNumber(item.AssetSummary["secret_kd"]), NumberRange{Min: query.MinSecretKD, Max: query.MaxSecretKD}) { + return false + } + for resourceKey, resourceRange := range query.ResourceRanges { + if !numberInRange(resourceQuantity(item.AssetSummary, resourceKey), resourceRange) { + return false + } + } + return true +} + +func sortPublicListings(items []ListingDTO, sortKey string) { + sort.SliceStable(items, func(i, j int) bool { + a := items[i] + b := items[j] + switch sortKey { + case "priceAsc": + return a.PriceCent < b.PriceCent + case "priceDesc": + return a.PriceCent > b.PriceCent + case "coinDesc": + return a.HafCoinAmount > b.HafCoinAmount + case "awmDesc": + aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo") + bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo") + if aAmmo != bAmmo { + return aAmmo > bAmmo + } + return a.HafCoinAmount > b.HafCoinAmount + case "published", "recommended", "comprehensive", "": + return publicRecentLess(a, b) + default: + return publicRecentLess(a, b) + } + }) +} + +func publicRecentLess(a ListingDTO, b ListingDTO) bool { + aTime := time.Time{} + bTime := time.Time{} + if a.PublishedAt != nil { + aTime = *a.PublishedAt + } + if b.PublishedAt != nil { + bTime = *b.PublishedAt + } + if !aTime.Equal(bTime) { + return aTime.After(bTime) + } + return a.ID > b.ID +} + +func matchesPublicZone(item ListingDTO, zone string) bool { + switch zone { + case "", "all": + return true + case "sale": + return item.IsAccelerated + case "gift": + return hasGiftResourcesSummary(item.AssetSummary) + case "night": + return isNightAvailableSummary(item.AssetSummary) + case "password": + return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码") + case "highCoin": + return coinMFromListing(item) >= 100 + default: + return true + } +} + +func publicZoneCounts(items []ListingDTO) map[string]int64 { + counts := map[string]int64{ + "all": int64(len(items)), + "sale": 0, + "gift": 0, + "night": 0, + "password": 0, + "highCoin": 0, + } + for _, item := range items { + for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} { + if matchesPublicZone(item, zone) { + counts[zone]++ + } + } + } + return counts +} + +func publicSearchText(item ListingDTO) string { + parts := []string{ + item.ListingNo, + strconv.FormatUint(item.ID, 10), + strconv.FormatUint(item.AccountID, 10), + item.Title, + item.Description, + item.RankLevel, + item.ServerRegion, + item.LoginPlatform, + } + parts = append(parts, assetRegionsFromSummary(item.AssetSummary)...) + parts = append(parts, skinNamesFromSummary(item.AssetSummary)...) + return strings.ToLower(strings.Join(parts, " ")) +} + +func matchesAny(options []string, value string) bool { + if len(options) == 0 { + return true + } + for _, option := range options { + if option == value { + return true + } + } + return false +} + +func intersects(options []string, values []string) bool { + if len(options) == 0 { + return true + } + valueSet := make(map[string]struct{}, len(values)) + for _, value := range values { + valueSet[value] = struct{}{} + } + for _, option := range options { + if _, ok := valueSet[option]; ok { + return true + } + } + return false +} + +func numberInRange(value float64, numberRange NumberRange) bool { + if numberRange.Min != nil && value < *numberRange.Min { + return false + } + if numberRange.Max != nil && value > *numberRange.Max { + return false + } + return true +} + +func coinMFromListing(item ListingDTO) float64 { + return float64(item.HafCoinAmount) / 1000000 +} + +func readAssetString(summary map[string]any, key string) string { + if summary == nil { + return "" + } + value, _ := summary[key].(string) + return strings.TrimSpace(value) +} + +func assetRegionsFromSummary(summary map[string]any) []string { + if summary == nil { + return nil + } + values, ok := summary["common_regions"].([]any) + if !ok { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if ok && strings.TrimSpace(text) != "" { + result = append(result, strings.TrimSpace(text)) + } + } + return result +} + +func skinNamesFromSummary(summary map[string]any) []string { + groups := skinGroupsFromSummary(summary) + result := make([]string, 0) + for _, skins := range groups { + result = append(result, skins...) + } + return result +} + +func skinGroupsContainAny(summary map[string]any, groupKeys []string, skinNames []string) bool { + groups := skinGroupsFromSummary(summary) + for _, groupKey := range groupKeys { + if intersects(skinNames, groups[groupKey]) { + return true + } + } + return false +} + +func skinGroupsHaveAny(summary map[string]any, groupKeys []string) bool { + groups := skinGroupsFromSummary(summary) + for _, groupKey := range groupKeys { + if len(groups[groupKey]) > 0 { + return true + } + } + return false +} + +func skinGroupsFromSummary(summary map[string]any) map[string][]string { + result := make(map[string][]string) + if summary == nil { + return result + } + rawGroups, ok := summary["skin_groups"].(map[string]any) + if !ok { + return result + } + for key, rawSkins := range rawGroups { + values, ok := rawSkins.([]any) + if !ok { + continue + } + for _, value := range values { + text, ok := value.(string) + if ok && strings.TrimSpace(text) != "" { + result[key] = append(result[key], strings.TrimSpace(text)) + } + } + } + return result +} + +func resourceQuantity(summary map[string]any, resourceKey string) float64 { + if summary == nil { + return 0 + } + resources, ok := summary["resources"].([]any) + if !ok { + return 0 + } + for _, resource := range resources { + row, ok := resource.(map[string]any) + if !ok { + continue + } + if rowKey, _ := row["key"].(string); rowKey == resourceKey { + return readSummaryNumber(row["quantity"]) + } + } + return 0 +} + +func hasGiftResourcesSummary(summary map[string]any) bool { + if summary == nil { + return false + } + resources, ok := summary["resources"].([]any) + if !ok { + return false + } + for _, resource := range resources { + row, ok := resource.(map[string]any) + if !ok { + continue + } + if mode, _ := row["mode"].(string); mode == "赠送" && readSummaryNumber(row["quantity"]) > 0 { + return true + } + } + return false +} + +func isNightAvailableSummary(summary map[string]any) bool { + if summary == nil { + return false + } + onlineTime, ok := summary["online_time"].(map[string]any) + if !ok { + return false + } + start, okStart := parseTimeHourValue(onlineTime["start"]) + end, okEnd := parseTimeHourValue(onlineTime["end"]) + if !okStart || !okEnd { + return false + } + return timeRangeCoversHour(start, end, 22) || timeRangeCoversHour(start, end, 23) || timeRangeCoversHour(start, end, 0) +} + +func parseTimeHourValue(value any) (int, bool) { + text, ok := value.(string) + if !ok { + return 0, false + } + parts := strings.Split(text, ":") + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, false + } + return hour, true +} + +func timeRangeCoversHour(start int, end int, hour int) bool { + if start == end { + return true + } + if start < end { + return hour >= start && hour <= end + } + return hour >= start || hour <= end +} diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go new file mode 100644 index 0000000..5ed02d3 --- /dev/null +++ b/backend/internal/modules/listing/public_query.go @@ -0,0 +1,242 @@ +package listing + +import ( + "context" + "strings" + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*PublicListResult, error) { + page, pageSize := normalizedPublicPage(query) + if canListPublicWithSQL(query) { + return r.listPublicPage(ctx, query, page, pageSize) + } + + var rows []listingRow + err := r.baseQuery(ctx). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). + Order("l.published_at DESC, l.id DESC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := publicListings(rowsToDTO(rows)) + baseQuery := query + baseQuery.Zone = "" + items = filterPublicListings(items, baseQuery) + zoneCounts := publicZoneCounts(items) + if query.Zone != "" && query.Zone != "all" { + items = filterPublicListings(items, query) + } + sortPublicListings(items, query.Sort) + total := int64(len(items)) + start := (page - 1) * pageSize + if start < 0 { + start = 0 + } + if start >= len(items) { + items = []ListingDTO{} + } else { + end := start + pageSize + if end > len(items) { + end = len(items) + } + items = items[start:end] + } + return &PublicListResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + ZoneCounts: zoneCounts, + }, nil +} + +func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, page int, pageSize int) (*PublicListResult, error) { + var total int64 + if err := r.db.WithContext(ctx).Table("rental_listings AS l"). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). + Count(&total).Error; err != nil { + return nil, err + } + + var rows []listingRow + offset := (page - 1) * pageSize + err := applyPublicSQLSort(r.baseQuery(ctx), query.Sort). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). + Limit(pageSize). + Offset(offset). + Scan(&rows).Error + if err != nil { + return nil, err + } + zoneCounts, err := r.publicZoneCountsCached(ctx) + if err != nil { + return nil, err + } + return &PublicListResult{ + Items: publicListings(rowsToDTO(rows)), + Total: total, + Page: page, + PageSize: pageSize, + ZoneCounts: zoneCounts, + }, nil +} + +func normalizedPublicPage(query PublicListQuery) (int, int) { + page := query.Page + if page <= 0 { + page = 1 + } + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func canListPublicWithSQL(query PublicListQuery) bool { + if query.Keyword != "" { + return false + } + if query.Zone != "" && query.Zone != "all" { + return false + } + if len(query.Server) > 0 || len(query.Region) > 0 || len(query.LoginMethod) > 0 || len(query.Rank) > 0 { + return false + } + if len(query.Insurance) > 0 || len(query.Stamina) > 0 || len(query.Load) > 0 { + return false + } + if len(query.SkinGroup) > 0 || len(query.SkinName) > 0 || len(query.ResourceRanges) > 0 { + return false + } + if query.MinCoin != nil || query.MaxCoin != nil || query.MinPrice != nil || query.MaxPrice != nil { + return false + } + if query.MinDeposit != nil || query.MaxDeposit != nil || query.MinTotal != nil || query.MaxTotal != nil { + return false + } + if query.MinFireLevel != nil || query.MaxFireLevel != nil || query.MinSecretKD != nil || query.MaxSecretKD != nil { + return false + } + switch query.Sort { + case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc": + return true + default: + return false + } +} + +func applyPublicSQLSort(db *gorm.DB, sortKey string) *gorm.DB { + switch sortKey { + case "priceAsc": + return db.Order("l.price_cent ASC, l.published_at DESC, l.id DESC") + case "priceDesc": + return db.Order("l.price_cent DESC, l.published_at DESC, l.id DESC") + case "coinDesc": + return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC") + default: + return db.Order("l.published_at DESC, l.id DESC") + } +} + +func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int64, error) { + now := time.Now() + r.publicZoneCountsMu.Lock() + defer r.publicZoneCountsMu.Unlock() + if r.publicZoneCounts.Counts != nil && now.Before(r.publicZoneCounts.ExpiresAt) { + return copyPublicZoneCounts(r.publicZoneCounts.Counts), nil + } + + var rows []publicZoneRow + err := r.db.WithContext(ctx).Table("rental_listings AS l"). + Select("a.login_platform, a.haf_coin_amount, a.asset_summary"). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). + Scan(&rows).Error + if err != nil { + return nil, err + } + counts := map[string]int64{ + "all": int64(len(rows)), + "sale": 0, + "gift": 0, + "night": 0, + "password": 0, + "highCoin": 0, + } + for _, row := range rows { + summary := decodeAssetSummary(row.AssetSummary) + if isAcceleratedSale(summary) { + counts["sale"]++ + } + if hasGiftResourcesSummary(summary) { + counts["gift"]++ + } + if isNightAvailableSummary(summary) { + counts["night"]++ + } + if strings.Contains(row.LoginPlatform, "账密") || strings.Contains(row.LoginPlatform, "账号密码") { + counts["password"]++ + } + if float64(row.HafCoinAmount)/1000000 >= 100 { + counts["highCoin"]++ + } + } + r.publicZoneCounts = publicZoneCountCache{ + Counts: counts, + ExpiresAt: now.Add(publicZoneCountCacheTTL), + } + return copyPublicZoneCounts(counts), nil +} + +func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { + copied := make(map[string]int64, len(counts)) + for key, value := range counts { + copied[key] = value + } + return copied +} + +func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { + dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) + if err != nil { + return nil, err + } + applyPublicListingURLs(dto) + return dto, nil +} + +func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { + return r.FindPublicScreenshotKey(ctx, id, 0) +} + +func (r *Repository) FindPublicScreenshotKey(ctx context.Context, id uint64, index int) (string, error) { + if index < 0 { + return "", gorm.ErrRecordNotFound + } + dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) + if err != nil { + return "", err + } + if index >= len(dto.ScreenshotURLS) { + return "", gorm.ErrRecordNotFound + } + if key := extractListingObjectKey(dto.ScreenshotURLS[index]); key != "" { + return key, nil + } + return "", gorm.ErrRecordNotFound +} + +type publicZoneRow struct { + LoginPlatform string + HafCoinAmount int64 + AssetSummary datatypes.JSON `gorm:"column:asset_summary"` +} diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go new file mode 100644 index 0000000..524dc24 --- /dev/null +++ b/backend/internal/modules/listing/query.go @@ -0,0 +1,168 @@ +package listing + +import ( + "context" + "fmt" + "strconv" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) { + page := query.Page + if page <= 0 { + page = 1 + } + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = query.Limit + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + db := r.applyAdminListFilters(r.baseQuery(ctx), query) + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + var rows []listingRow + err := db.Order("l.id DESC").Limit(pageSize).Offset(offset).Scan(&rows).Error + if err != nil { + return nil, err + } + return &AdminListResult{ + Items: rowsToDTO(rows), + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { + if query.OwnerID > 0 { + db = db.Where("l.owner_id = ?", query.OwnerID) + } + if query.Status != "" { + db = db.Where("l.status = ?", query.Status) + } + if query.ReviewStatus != "" { + db = db.Where("l.review_status = ?", query.ReviewStatus) + } + return db +} + +func (r *Repository) FindAdmin(ctx context.Context, listingID uint64) (*ListingDTO, error) { + return r.findDTO(ctx, "l.id = ?", listingID) +} + +func (r *Repository) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, error) { + var rows []listingRow + err := r.baseQuery(ctx). + Where("l.owner_id = ?", ownerID). + Order("l.id DESC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + return sellerListings(rowsToDTO(rows)), nil +} + +func (r *Repository) FindMine(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { + dto, err := r.findDTO(ctx, "l.id = ? AND l.owner_id = ?", id, ownerID) + if err != nil { + return nil, err + } + applySellerListingPrice(dto) + return dto, nil +} + +func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { + var listing model.RentalListing + if err := tx.Where("id = ? AND owner_id = ?", listingID, ownerID).First(&listing).Error; err != nil { + return nil, nil, err + } + var account model.GameAccount + if err := tx.Where("id = ? AND owner_id = ?", listing.AccountID, ownerID).First(&account).Error; err != nil { + return nil, nil, err + } + return &listing, &account, nil +} + +func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*ListingDTO, error) { + var row listingRow + err := r.baseQuery(ctx). + Where(where, args...). + First(&row).Error + if err != nil { + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx).Table("rental_listings AS l"). + Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, + a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Joins("LEFT JOIN users AS u ON u.id = l.owner_id") +} + +func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil { + return nil, nil, err + } + var account model.GameAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil { + return nil, nil, err + } + return &listing, &account, nil +} + +func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) { + bizDate := now.Format("20060102") + if tx.Dialector.Name() == "mysql" { + if err := tx.Exec(` + INSERT INTO listing_no_sequences (biz_date, next_seq) + VALUES (?, LAST_INSERT_ID(1)) + ON DUPLICATE KEY UPDATE next_seq = LAST_INSERT_ID(next_seq + 1) + `, bizDate).Error; err != nil { + return "", err + } + var seq int + if err := tx.Raw("SELECT LAST_INSERT_ID()").Scan(&seq).Error; err != nil { + return "", err + } + return fmt.Sprintf("%s%04d", bizDate, seq), nil + } + + var maxNo string + if err := tx.Table("rental_listings"). + Select("COALESCE(MAX(listing_no), '')"). + Where("listing_no LIKE ?", bizDate+"%"). + Scan(&maxNo).Error; err != nil { + return "", err + } + seq := 1 + if len(maxNo) > len(bizDate) { + if parsed, err := strconv.Atoi(maxNo[len(bizDate):]); err == nil { + seq = parsed + 1 + } + } + return fmt.Sprintf("%s%04d", bizDate, seq), nil +} diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 034cfdb..26d1551 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -1,26 +1,11 @@ package listing import ( - "context" - "encoding/json" "errors" - "fmt" - "math" - "net/url" - "sort" - "strconv" - "strings" "sync" "time" - "hfb_sys/backend/internal/auditlog" - "hfb_sys/backend/internal/model" - "hfb_sys/backend/internal/modules/notification" - "hfb_sys/backend/pkg/money" - - "gorm.io/datatypes" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type Repository struct { @@ -48,1685 +33,8 @@ func initialPublishState(reviewRequired bool) (string, string, *time.Time) { return "published", "approved", &now } -func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listingNo, err := r.nextListingNo(tx, time.Now()) - if err != nil { - return err - } - screenshots, err := marshalScreenshots(req.ScreenshotURLS) - if err != nil { - return err - } - assetSummary, err := marshalAssetSummary(req.AssetSummary) - if err != nil { - return err - } - listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) - account := model.GameAccount{ - OwnerID: ownerID, - GameName: "delta_force", - ServerRegion: req.ServerRegion, - LoginPlatform: req.LoginPlatform, - Title: req.Title, - Description: req.Description, - RankLevel: req.RankLevel, - HafCoinAmount: req.HafCoinAmount, - AssetSummary: assetSummary, - ScreenshotURLS: screenshots, - Status: listingStatus, - } - if err := tx.Create(&account).Error; err != nil { - return err - } - priceCent := normalizedListingPriceCent(req) - listing := model.RentalListing{ - ListingNo: listingNo, - AccountID: account.ID, - OwnerID: ownerID, - PriceCent: priceCent, - DepositAmountCent: req.DepositAmountCent, - Status: listingStatus, - ReviewStatus: reviewStatus, - PublishedAt: publishedAt, - } - if err := tx.Create(&listing).Error; err != nil { - return err - } - dto = toDTO(account, listing) - return nil - }) - return dto, err -} - -type externalUploadCreate struct { - UploaderName string - ClientUploadTime *time.Time - ClientIP string - RawPayload []byte - ParsedPayload []byte -} - -func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listingNo, err := r.nextListingNo(tx, time.Now()) - if err != nil { - return err - } - admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName) - if err != nil { - return err - } - owner, err := r.ensureUploadOwnerUser(tx, admin) - if err != nil { - return err - } - screenshots, err := marshalScreenshots(req.ScreenshotURLS) - if err != nil { - return err - } - assetSummary, err := marshalAssetSummary(req.AssetSummary) - if err != nil { - return err - } - account := model.GameAccount{ - OwnerID: owner.ID, - GameName: "delta_force", - ServerRegion: req.ServerRegion, - LoginPlatform: req.LoginPlatform, - Title: req.Title, - Description: req.Description, - RankLevel: req.RankLevel, - HafCoinAmount: req.HafCoinAmount, - AssetSummary: assetSummary, - ScreenshotURLS: screenshots, - Status: "draft", - } - if err := tx.Create(&account).Error; err != nil { - return err - } - priceCent := normalizedListingPriceCent(req) - listing := model.RentalListing{ - ListingNo: listingNo, - AccountID: account.ID, - OwnerID: owner.ID, - PriceCent: priceCent, - DepositAmountCent: req.DepositAmountCent, - Status: "draft", - ReviewStatus: "pending", - } - if err := tx.Create(&listing).Error; err != nil { - return err - } - matchedAdminID := admin.ID - ownerID := owner.ID - listingID := listing.ID - uploadRow := model.ListingUpload{ - UploaderName: upload.UploaderName, - MatchedAdminID: &matchedAdminID, - OwnerID: &ownerID, - ClientUploadTime: upload.ClientUploadTime, - ClientIP: upload.ClientIP, - RawPayload: datatypes.JSON(upload.RawPayload), - ParsedPayload: datatypes.JSON(upload.ParsedPayload), - ListingID: &listingID, - Status: "draft_created", - } - if err := tx.Create(&uploadRow).Error; err != nil { - return err - } - dto = toDTO(account, listing) - return nil - }) - return dto, err -} - -func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - - account.Title = req.Title - account.Description = req.Description - account.ServerRegion = req.ServerRegion - account.LoginPlatform = req.LoginPlatform - account.RankLevel = req.RankLevel - account.HafCoinAmount = req.HafCoinAmount - assetSummary, err := marshalAssetSummary(req.AssetSummary) - if err != nil { - return err - } - account.AssetSummary = assetSummary - screenshots, err := marshalScreenshots(req.ScreenshotURLS) - if err != nil { - return err - } - account.ScreenshotURLS = screenshots - - listing.PriceCent = normalizedListingPriceCent(req) - listing.DepositAmountCent = req.DepositAmountCent - listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) - listing.Status = listingStatus - listing.ReviewStatus = reviewStatus - listing.ReviewReason = "" - listing.PublishedAt = publishedAt - account.Status = listingStatus - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID uint64, reviewRequired bool) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) - listing.Status = listingStatus - listing.ReviewStatus = reviewStatus - listing.ReviewReason = "" - listing.PublishedAt = publishedAt - account.Status = listingStatus - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) ListPendingReview(ctx context.Context) ([]ListingDTO, error) { - var rows []listingRow - err := r.baseQuery(ctx). - Where("l.review_status = ? AND l.status <> ?", "pending", "offline"). - Order("l.updated_at ASC, l.id ASC"). - Limit(200). - Scan(&rows).Error - if err != nil { - return nil, err - } - return rowsToDTO(rows), nil -} - -func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) { - page := query.Page - if page <= 0 { - page = 1 - } - pageSize := query.PageSize - if pageSize <= 0 { - pageSize = query.Limit - } - if pageSize <= 0 { - pageSize = 20 - } - if pageSize > 100 { - pageSize = 100 - } - - countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query) - var total int64 - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - - db := r.applyAdminListFilters(r.baseQuery(ctx), query) - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - var rows []listingRow - err := db.Order("l.id DESC").Limit(pageSize).Offset(offset).Scan(&rows).Error - if err != nil { - return nil, err - } - return &AdminListResult{ - Items: rowsToDTO(rows), - Total: total, - Page: page, - PageSize: pageSize, - }, nil -} - -func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { - if query.OwnerID > 0 { - db = db.Where("l.owner_id = ?", query.OwnerID) - } - if query.Status != "" { - db = db.Where("l.status = ?", query.Status) - } - if query.ReviewStatus != "" { - db = db.Where("l.review_status = ?", query.ReviewStatus) - } - return db -} - -func (r *Repository) findActiveUploadAdmin(tx *gorm.DB, uploaderName string) (*model.AdminUser, error) { - uploaderName = strings.TrimSpace(uploaderName) - var admin model.AdminUser - if err := tx.Where("username = ? AND status = ?", uploaderName, "active").First(&admin).Error; err == nil { - return &admin, nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - var admins []model.AdminUser - if err := tx.Where("nickname = ? AND status = ?", uploaderName, "active").Limit(2).Find(&admins).Error; err != nil { - return nil, err - } - switch len(admins) { - case 0: - return nil, ErrUploaderNotFound - case 1: - return &admins[0], nil - default: - return nil, ErrUploaderAmbiguous - } -} - -func (r *Repository) ensureUploadOwnerUser(tx *gorm.DB, admin *model.AdminUser) (*model.User, error) { - phone := fmt.Sprintf("admin:%d", admin.ID) - nickname := strings.TrimSpace(admin.Nickname) - if nickname == "" { - nickname = admin.Username - } - var user model.User - err := tx.Where("phone = ?", phone).First(&user).Error - if err == nil { - updates := map[string]any{ - "nickname": nickname, - "status": "active", - "realname_status": "verified", - } - if err := tx.Model(&user).Updates(updates).Error; err != nil { - return nil, err - } - user.Nickname = nickname - user.Status = "active" - user.RealnameStatus = "verified" - return &user, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - user = model.User{ - Phone: phone, - Nickname: nickname, - RealnameStatus: "verified", - RiskStatus: "normal", - CreditScore: 100, - Status: "active", - } - if err := tx.Create(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - -func (r *Repository) FindAdmin(ctx context.Context, listingID uint64) (*ListingDTO, error) { - return r.findDTO(ctx, "l.id = ?", listingID) -} - -func (r *Repository) AdminOffline(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { - return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "offline", "offline", "listing.admin_offline", "商品已被后台下架", "你的租号商品已被后台下架,请查看原因后处理。") -} - -func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { - return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。") -} - -func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta, listingStatus string, accountStatus string, action string, title string, content string) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findForReviewUpdate(tx, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - beforeListingStatus := listing.Status - beforeAccountStatus := account.Status - beforeReviewReason := listing.ReviewReason - listing.Status = listingStatus - listing.ReviewReason = req.Reason - if listingStatus != "published" { - listing.PublishedAt = nil - } - account.Status = accountStatus - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - if err := notification.Append(tx, notification.Entry{ - UserID: listing.OwnerID, - Type: "listing_admin", - Title: title, - Content: content, - BizType: "listing", - BizID: &listingID, - }); err != nil { - return err - } - if err := appendAuditLog(tx, adminID, action, "listing", listing.ID, meta, map[string]any{ - "listing_id": listing.ID, - "account_id": account.ID, - "owner_id": listing.OwnerID, - "reason": req.Reason, - "before_listing_status": beforeListingStatus, - "after_listing_status": listing.Status, - "before_account_status": beforeAccountStatus, - "after_account_status": account.Status, - "before_review_reason": beforeReviewReason, - "after_review_reason": listing.ReviewReason, - }); err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findForReviewUpdate(tx, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - now := time.Now() - listing.Status = "published" - listing.ReviewStatus = "approved" - listing.ReviewReason = "" - listing.PublishedAt = &now - account.Status = "published" - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - listingID := listing.ID - if err := notification.Append(tx, notification.Entry{ - UserID: listing.OwnerID, - Type: "listing_review", - Title: "发布审核通过", - Content: "你的租号发布已审核通过并上架。", - BizType: "listing", - BizID: &listingID, - }); err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) AdjustReviewPrice(ctx context.Context, adminID uint64, listingID uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findForReviewUpdate(tx, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - summary := decodeAssetSummary(account.AssetSummary) - if summary == nil { - summary = map[string]any{} - } - breakdown := ensurePriceBreakdown(summary) - coinWan := float64(account.HafCoinAmount) / 10000 - consumablePrice := readSummaryNumber(breakdown["consumable_price"]) - if consumablePrice <= 0 { - consumablePrice = consumableValue(summary) - } - sellerTotalPrice := readSummaryNumber(breakdown["seller_total_price"]) - if sellerTotalPrice <= 0 { - sellerTotalPrice = math.Max(0, centToYuan(listing.PriceCent)-consumablePrice) - } - sellerCoinBasePrice := readSummaryNumber(breakdown["seller_coin_base_price"]) - if sellerCoinBasePrice <= 0 { - sellerCoinBasePrice = math.Max(0, sellerTotalPrice-consumablePrice) - } - sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) - if sellerRatio <= 0 && sellerCoinBasePrice > 0 { - sellerRatio = roundRatio(coinWan / sellerCoinBasePrice) - } - buyerCoinBasePrice, buyerTotalPrice, buyerRatio := calculateAdminAdjustedPrice(req, coinWan, consumablePrice) - if buyerCoinBasePrice <= 0 || buyerTotalPrice <= 0 || buyerRatio <= 0 { - return ErrInvalidPrice - } - - beforePriceCent := listing.PriceCent - beforeRatio := readSummaryNumber(breakdown["buyer_ratio"]) - if beforeRatio <= 0 && centToYuan(listing.PriceCent) > consumablePrice { - beforeRatio = roundRatio(coinWan / (centToYuan(listing.PriceCent) - consumablePrice)) - } - - listing.PriceCent = yuanToCent(buyerTotalPrice) - summary["publish_ratio"] = buyerRatio - breakdown["seller_coin_base_price"] = roundMoney(sellerCoinBasePrice) - breakdown["seller_total_price"] = roundMoney(sellerTotalPrice) - breakdown["seller_ratio"] = sellerRatio - breakdown["buyer_coin_base_price"] = buyerCoinBasePrice - breakdown["buyer_total_price"] = buyerTotalPrice - breakdown["buyer_ratio"] = buyerRatio - breakdown["platform_markup_amount"] = roundMoney(buyerTotalPrice - sellerTotalPrice) - breakdown["platform_rule_type"] = "admin_adjusted" - breakdown["admin_adjust_reason"] = strings.TrimSpace(req.Reason) - breakdown["admin_adjusted_at"] = time.Now().Format(time.RFC3339) - breakdown["admin_adjusted_by"] = adminID - summary["price_breakdown"] = breakdown - - assetSummary, err := marshalAssetSummary(summary) - if err != nil { - return err - } - account.AssetSummary = assetSummary - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - if err := appendAuditLog(tx, adminID, "listing.adjust_review_price", "listing", listing.ID, meta, map[string]any{ - "listing_id": listing.ID, - "account_id": account.ID, - "owner_id": listing.OwnerID, - "before_price_cent": beforePriceCent, - "after_price_cent": listing.PriceCent, - "before_buyer_ratio": beforeRatio, - "after_buyer_ratio": buyerRatio, - "platform_markup": breakdown["platform_markup_amount"], - "adjust_reason": req.Reason, - "buyer_coin_base": buyerCoinBasePrice, - "consumable_price": consumablePrice, - "seller_total_price": sellerTotalPrice, - }); err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) Reject(ctx context.Context, listingID uint64, req ReviewRequest) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findForReviewUpdate(tx, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - listing.Status = "draft" - listing.ReviewStatus = "rejected" - listing.ReviewReason = req.Reason - listing.PublishedAt = nil - account.Status = "draft" - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - listingID := listing.ID - if err := notification.Append(tx, notification.Entry{ - UserID: listing.OwnerID, - Type: "listing_review", - Title: "发布审核未通过", - Content: "你的租号发布未通过审核,请根据原因修改后重新提交。", - BizType: "listing", - BizID: &listingID, - }); err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint64) (*ListingDTO, error) { - var dto *ListingDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) - if err != nil { - return err - } - if listing.Status == "rented" || listing.InTransaction { - return ErrListingLocked - } - listing.Status = "offline" - listing.ReviewStatus = "none" - listing.ReviewReason = "号主已手动下架" - listing.PublishedAt = nil - account.Status = "offline" - if err := tx.Save(account).Error; err != nil { - return err - } - if err := tx.Save(listing).Error; err != nil { - return err - } - dto = toDTO(*account, *listing) - return nil - }) - return dto, err -} - -func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*PublicListResult, error) { - page, pageSize := normalizedPublicPage(query) - if canListPublicWithSQL(query) { - return r.listPublicPage(ctx, query, page, pageSize) - } - - var rows []listingRow - err := r.baseQuery(ctx). - Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). - Order("l.published_at DESC, l.id DESC"). - Scan(&rows).Error - if err != nil { - return nil, err - } - items := publicListings(rowsToDTO(rows)) - baseQuery := query - baseQuery.Zone = "" - items = filterPublicListings(items, baseQuery) - zoneCounts := publicZoneCounts(items) - if query.Zone != "" && query.Zone != "all" { - items = filterPublicListings(items, query) - } - sortPublicListings(items, query.Sort) - total := int64(len(items)) - start := (page - 1) * pageSize - if start < 0 { - start = 0 - } - if start >= len(items) { - items = []ListingDTO{} - } else { - end := start + pageSize - if end > len(items) { - end = len(items) - } - items = items[start:end] - } - return &PublicListResult{ - Items: items, - Total: total, - Page: page, - PageSize: pageSize, - ZoneCounts: zoneCounts, - }, nil -} - -func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, page int, pageSize int) (*PublicListResult, error) { - var total int64 - if err := r.db.WithContext(ctx).Table("rental_listings AS l"). - Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). - Count(&total).Error; err != nil { - return nil, err - } - - var rows []listingRow - offset := (page - 1) * pageSize - err := applyPublicSQLSort(r.baseQuery(ctx), query.Sort). - Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). - Limit(pageSize). - Offset(offset). - Scan(&rows).Error - if err != nil { - return nil, err - } - zoneCounts, err := r.publicZoneCountsCached(ctx) - if err != nil { - return nil, err - } - return &PublicListResult{ - Items: publicListings(rowsToDTO(rows)), - Total: total, - Page: page, - PageSize: pageSize, - ZoneCounts: zoneCounts, - }, nil -} - -func normalizedPublicPage(query PublicListQuery) (int, int) { - page := query.Page - if page <= 0 { - page = 1 - } - pageSize := query.PageSize - if pageSize <= 0 { - pageSize = 20 - } - if pageSize > 50 { - pageSize = 50 - } - return page, pageSize -} - -func canListPublicWithSQL(query PublicListQuery) bool { - if query.Keyword != "" { - return false - } - if query.Zone != "" && query.Zone != "all" { - return false - } - if len(query.Server) > 0 || len(query.Region) > 0 || len(query.LoginMethod) > 0 || len(query.Rank) > 0 { - return false - } - if len(query.Insurance) > 0 || len(query.Stamina) > 0 || len(query.Load) > 0 { - return false - } - if len(query.SkinGroup) > 0 || len(query.SkinName) > 0 || len(query.ResourceRanges) > 0 { - return false - } - if query.MinCoin != nil || query.MaxCoin != nil || query.MinPrice != nil || query.MaxPrice != nil { - return false - } - if query.MinDeposit != nil || query.MaxDeposit != nil || query.MinTotal != nil || query.MaxTotal != nil { - return false - } - if query.MinFireLevel != nil || query.MaxFireLevel != nil || query.MinSecretKD != nil || query.MaxSecretKD != nil { - return false - } - switch query.Sort { - case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc": - return true - default: - return false - } -} - -func applyPublicSQLSort(db *gorm.DB, sortKey string) *gorm.DB { - switch sortKey { - case "priceAsc": - return db.Order("l.price_cent ASC, l.published_at DESC, l.id DESC") - case "priceDesc": - return db.Order("l.price_cent DESC, l.published_at DESC, l.id DESC") - case "coinDesc": - return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC") - default: - return db.Order("l.published_at DESC, l.id DESC") - } -} - -func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int64, error) { - now := time.Now() - r.publicZoneCountsMu.Lock() - defer r.publicZoneCountsMu.Unlock() - if r.publicZoneCounts.Counts != nil && now.Before(r.publicZoneCounts.ExpiresAt) { - return copyPublicZoneCounts(r.publicZoneCounts.Counts), nil - } - - var rows []publicZoneRow - err := r.db.WithContext(ctx).Table("rental_listings AS l"). - Select("a.login_platform, a.haf_coin_amount, a.asset_summary"). - Joins("JOIN game_accounts AS a ON a.id = l.account_id"). - Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). - Scan(&rows).Error - if err != nil { - return nil, err - } - counts := map[string]int64{ - "all": int64(len(rows)), - "sale": 0, - "gift": 0, - "night": 0, - "password": 0, - "highCoin": 0, - } - for _, row := range rows { - summary := decodeAssetSummary(row.AssetSummary) - if isAcceleratedSale(summary) { - counts["sale"]++ - } - if hasGiftResourcesSummary(summary) { - counts["gift"]++ - } - if isNightAvailableSummary(summary) { - counts["night"]++ - } - if strings.Contains(row.LoginPlatform, "账密") || strings.Contains(row.LoginPlatform, "账号密码") { - counts["password"]++ - } - if float64(row.HafCoinAmount)/1000000 >= 100 { - counts["highCoin"]++ - } - } - r.publicZoneCounts = publicZoneCountCache{ - Counts: counts, - ExpiresAt: now.Add(publicZoneCountCacheTTL), - } - return copyPublicZoneCounts(counts), nil -} - -func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { - copied := make(map[string]int64, len(counts)) - for key, value := range counts { - copied[key] = value - } - return copied -} - -func (r *Repository) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, error) { - var rows []listingRow - err := r.baseQuery(ctx). - Where("l.owner_id = ?", ownerID). - Order("l.id DESC"). - Scan(&rows).Error - if err != nil { - return nil, err - } - return sellerListings(rowsToDTO(rows)), nil -} - -func filterPublicListings(items []ListingDTO, query PublicListQuery) []ListingDTO { - filtered := make([]ListingDTO, 0, len(items)) - for _, item := range items { - if !matchesPublicQuery(item, query) { - continue - } - filtered = append(filtered, item) - } - return filtered -} - -func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool { - if !matchesPublicZone(item, query.Zone) { - return false - } - if keyword := strings.ToLower(strings.TrimSpace(query.Keyword)); keyword != "" && !strings.Contains(publicSearchText(item), keyword) { - return false - } - if !matchesAny(query.Server, strings.TrimSpace(item.ServerRegion)) { - return false - } - if len(query.Region) > 0 && !intersects(query.Region, assetRegionsFromSummary(item.AssetSummary)) { - return false - } - if !matchesAny(query.LoginMethod, strings.TrimSpace(item.LoginPlatform)) { - return false - } - if !matchesAny(query.Rank, strings.TrimSpace(item.RankLevel)) { - return false - } - if !matchesAny(query.Insurance, readAssetString(item.AssetSummary, "season_insurance")) { - return false - } - if !matchesAny(query.Stamina, readAssetString(item.AssetSummary, "stamina_level")) { - return false - } - if !matchesAny(query.Load, readAssetString(item.AssetSummary, "load_level")) { - return false - } - if len(query.SkinName) > 0 { - if len(query.SkinGroup) > 0 { - if !skinGroupsContainAny(item.AssetSummary, query.SkinGroup, query.SkinName) { - return false - } - } else if !intersects(query.SkinName, skinNamesFromSummary(item.AssetSummary)) { - return false - } - } else if len(query.SkinGroup) > 0 && !skinGroupsHaveAny(item.AssetSummary, query.SkinGroup) { - return false - } - - price := float64(item.PriceCent) / 100 - deposit := float64(item.DepositAmountCent) / 100 - total := price + deposit - coinM := coinMFromListing(item) - if !numberInRange(coinM, NumberRange{Min: query.MinCoin, Max: query.MaxCoin}) { - return false - } - if !numberInRange(price, NumberRange{Min: query.MinPrice, Max: query.MaxPrice}) { - return false - } - if !numberInRange(deposit, NumberRange{Min: query.MinDeposit, Max: query.MaxDeposit}) { - return false - } - if !numberInRange(total, NumberRange{Min: query.MinTotal, Max: query.MaxTotal}) { - return false - } - if !numberInRange(readSummaryNumber(item.AssetSummary["fire_level"]), NumberRange{Min: query.MinFireLevel, Max: query.MaxFireLevel}) { - return false - } - if !numberInRange(readSummaryNumber(item.AssetSummary["secret_kd"]), NumberRange{Min: query.MinSecretKD, Max: query.MaxSecretKD}) { - return false - } - for resourceKey, resourceRange := range query.ResourceRanges { - if !numberInRange(resourceQuantity(item.AssetSummary, resourceKey), resourceRange) { - return false - } - } - return true -} - -func sortPublicListings(items []ListingDTO, sortKey string) { - sort.SliceStable(items, func(i, j int) bool { - a := items[i] - b := items[j] - switch sortKey { - case "priceAsc": - return a.PriceCent < b.PriceCent - case "priceDesc": - return a.PriceCent > b.PriceCent - case "coinDesc": - return a.HafCoinAmount > b.HafCoinAmount - case "awmDesc": - aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo") - bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo") - if aAmmo != bAmmo { - return aAmmo > bAmmo - } - return a.HafCoinAmount > b.HafCoinAmount - case "published", "recommended", "comprehensive", "": - return publicRecentLess(a, b) - default: - return publicRecentLess(a, b) - } - }) -} - -func publicRecentLess(a ListingDTO, b ListingDTO) bool { - aTime := time.Time{} - bTime := time.Time{} - if a.PublishedAt != nil { - aTime = *a.PublishedAt - } - if b.PublishedAt != nil { - bTime = *b.PublishedAt - } - if !aTime.Equal(bTime) { - return aTime.After(bTime) - } - return a.ID > b.ID -} - -func matchesPublicZone(item ListingDTO, zone string) bool { - switch zone { - case "", "all": - return true - case "sale": - return item.IsAccelerated - case "gift": - return hasGiftResourcesSummary(item.AssetSummary) - case "night": - return isNightAvailableSummary(item.AssetSummary) - case "password": - return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码") - case "highCoin": - return coinMFromListing(item) >= 100 - default: - return true - } -} - -func publicZoneCounts(items []ListingDTO) map[string]int64 { - counts := map[string]int64{ - "all": int64(len(items)), - "sale": 0, - "gift": 0, - "night": 0, - "password": 0, - "highCoin": 0, - } - for _, item := range items { - for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} { - if matchesPublicZone(item, zone) { - counts[zone]++ - } - } - } - return counts -} - -func publicSearchText(item ListingDTO) string { - parts := []string{ - item.ListingNo, - strconv.FormatUint(item.ID, 10), - strconv.FormatUint(item.AccountID, 10), - item.Title, - item.Description, - item.RankLevel, - item.ServerRegion, - item.LoginPlatform, - } - parts = append(parts, assetRegionsFromSummary(item.AssetSummary)...) - parts = append(parts, skinNamesFromSummary(item.AssetSummary)...) - return strings.ToLower(strings.Join(parts, " ")) -} - -func matchesAny(options []string, value string) bool { - if len(options) == 0 { - return true - } - for _, option := range options { - if option == value { - return true - } - } - return false -} - -func intersects(options []string, values []string) bool { - if len(options) == 0 { - return true - } - valueSet := make(map[string]struct{}, len(values)) - for _, value := range values { - valueSet[value] = struct{}{} - } - for _, option := range options { - if _, ok := valueSet[option]; ok { - return true - } - } - return false -} - -func numberInRange(value float64, numberRange NumberRange) bool { - if numberRange.Min != nil && value < *numberRange.Min { - return false - } - if numberRange.Max != nil && value > *numberRange.Max { - return false - } - return true -} - -func coinMFromListing(item ListingDTO) float64 { - return float64(item.HafCoinAmount) / 1000000 -} - -func readAssetString(summary map[string]any, key string) string { - if summary == nil { - return "" - } - value, _ := summary[key].(string) - return strings.TrimSpace(value) -} - -func assetRegionsFromSummary(summary map[string]any) []string { - if summary == nil { - return nil - } - values, ok := summary["common_regions"].([]any) - if !ok { - return nil - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, ok := value.(string) - if ok && strings.TrimSpace(text) != "" { - result = append(result, strings.TrimSpace(text)) - } - } - return result -} - -func skinNamesFromSummary(summary map[string]any) []string { - groups := skinGroupsFromSummary(summary) - result := make([]string, 0) - for _, skins := range groups { - result = append(result, skins...) - } - return result -} - -func skinGroupsContainAny(summary map[string]any, groupKeys []string, skinNames []string) bool { - groups := skinGroupsFromSummary(summary) - for _, groupKey := range groupKeys { - if intersects(skinNames, groups[groupKey]) { - return true - } - } - return false -} - -func skinGroupsHaveAny(summary map[string]any, groupKeys []string) bool { - groups := skinGroupsFromSummary(summary) - for _, groupKey := range groupKeys { - if len(groups[groupKey]) > 0 { - return true - } - } - return false -} - -func skinGroupsFromSummary(summary map[string]any) map[string][]string { - result := make(map[string][]string) - if summary == nil { - return result - } - rawGroups, ok := summary["skin_groups"].(map[string]any) - if !ok { - return result - } - for key, rawSkins := range rawGroups { - values, ok := rawSkins.([]any) - if !ok { - continue - } - for _, value := range values { - text, ok := value.(string) - if ok && strings.TrimSpace(text) != "" { - result[key] = append(result[key], strings.TrimSpace(text)) - } - } - } - return result -} - -func resourceQuantity(summary map[string]any, resourceKey string) float64 { - if summary == nil { - return 0 - } - resources, ok := summary["resources"].([]any) - if !ok { - return 0 - } - for _, resource := range resources { - row, ok := resource.(map[string]any) - if !ok { - continue - } - if rowKey, _ := row["key"].(string); rowKey == resourceKey { - return readSummaryNumber(row["quantity"]) - } - } - return 0 -} - -func hasGiftResourcesSummary(summary map[string]any) bool { - if summary == nil { - return false - } - resources, ok := summary["resources"].([]any) - if !ok { - return false - } - for _, resource := range resources { - row, ok := resource.(map[string]any) - if !ok { - continue - } - if mode, _ := row["mode"].(string); mode == "赠送" && readSummaryNumber(row["quantity"]) > 0 { - return true - } - } - return false -} - -func isNightAvailableSummary(summary map[string]any) bool { - if summary == nil { - return false - } - onlineTime, ok := summary["online_time"].(map[string]any) - if !ok { - return false - } - start, okStart := parseTimeHourValue(onlineTime["start"]) - end, okEnd := parseTimeHourValue(onlineTime["end"]) - if !okStart || !okEnd { - return false - } - return timeRangeCoversHour(start, end, 22) || timeRangeCoversHour(start, end, 23) || timeRangeCoversHour(start, end, 0) -} - -func parseTimeHourValue(value any) (int, bool) { - text, ok := value.(string) - if !ok { - return 0, false - } - parts := strings.Split(text, ":") - hour, err := strconv.Atoi(parts[0]) - if err != nil || hour < 0 || hour > 23 { - return 0, false - } - return hour, true -} - -func timeRangeCoversHour(start int, end int, hour int) bool { - if start == end { - return true - } - if start < end { - return hour >= start && hour <= end - } - return hour >= start || hour <= end -} - -func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { - dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) - if err != nil { - return nil, err - } - applyPublicListingURLs(dto) - return dto, nil -} - -func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { - return r.FindPublicScreenshotKey(ctx, id, 0) -} - -func (r *Repository) FindPublicScreenshotKey(ctx context.Context, id uint64, index int) (string, error) { - if index < 0 { - return "", gorm.ErrRecordNotFound - } - dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) - if err != nil { - return "", err - } - if index >= len(dto.ScreenshotURLS) { - return "", gorm.ErrRecordNotFound - } - if key := extractListingObjectKey(dto.ScreenshotURLS[index]); key != "" { - return key, nil - } - return "", gorm.ErrRecordNotFound -} - -func (r *Repository) FindMine(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { - dto, err := r.findDTO(ctx, "l.id = ? AND l.owner_id = ?", id, ownerID) - if err != nil { - return nil, err - } - applySellerListingPrice(dto) - return dto, nil -} - -func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { - var listing model.RentalListing - if err := tx.Where("id = ? AND owner_id = ?", listingID, ownerID).First(&listing).Error; err != nil { - return nil, nil, err - } - var account model.GameAccount - if err := tx.Where("id = ? AND owner_id = ?", listing.AccountID, ownerID).First(&account).Error; err != nil { - return nil, nil, err - } - return &listing, &account, nil -} - -func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*ListingDTO, error) { - var row listingRow - err := r.baseQuery(ctx). - Where(where, args...). - First(&row).Error - if err != nil { - return nil, err - } - dto := row.toDTO() - return &dto, nil -} - -func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { - return r.db.WithContext(ctx).Table("rental_listings AS l"). - Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, - a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`). - Joins("JOIN game_accounts AS a ON a.id = l.account_id"). - Joins("LEFT JOIN users AS u ON u.id = l.owner_id") -} - -func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { - var listing model.RentalListing - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil { - return nil, nil, err - } - var account model.GameAccount - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil { - return nil, nil, err - } - return &listing, &account, nil -} - -func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) { - bizDate := now.Format("20060102") - if tx.Dialector.Name() == "mysql" { - if err := tx.Exec(` - INSERT INTO listing_no_sequences (biz_date, next_seq) - VALUES (?, LAST_INSERT_ID(1)) - ON DUPLICATE KEY UPDATE next_seq = LAST_INSERT_ID(next_seq + 1) - `, bizDate).Error; err != nil { - return "", err - } - var seq int - if err := tx.Raw("SELECT LAST_INSERT_ID()").Scan(&seq).Error; err != nil { - return "", err - } - return fmt.Sprintf("%s%04d", bizDate, seq), nil - } - - var maxNo string - if err := tx.Table("rental_listings"). - Select("COALESCE(MAX(listing_no), '')"). - Where("listing_no LIKE ?", bizDate+"%"). - Scan(&maxNo).Error; err != nil { - return "", err - } - seq := 1 - if len(maxNo) > len(bizDate) { - if parsed, err := strconv.Atoi(maxNo[len(bizDate):]); err == nil { - seq = parsed + 1 - } - } - return fmt.Sprintf("%s%04d", bizDate, seq), nil -} - -type listingRow struct { - model.RentalListing - Title string - OwnerPhone string - OwnerNickname string - Description string - GameName string - ServerRegion string - LoginPlatform string - RankLevel string - HafCoinAmount int64 - AssetSummary datatypes.JSON `gorm:"column:asset_summary"` - ScreenshotURLS datatypes.JSON `gorm:"column:screenshot_urls"` -} - -type publicZoneRow struct { - LoginPlatform string - HafCoinAmount int64 - AssetSummary datatypes.JSON `gorm:"column:asset_summary"` -} - -func rowsToDTO(rows []listingRow) []ListingDTO { - items := make([]ListingDTO, 0, len(rows)) - for _, row := range rows { - items = append(items, row.toDTO()) - } - return items -} - -func normalizedListingPriceCent(req CreateRequest) int64 { - if req.AssetSummary != nil { - if breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any); ok { - buyerPrice := readSummaryNumber(breakdown["buyer_total_price"]) - if buyerPrice > 0 { - return yuanToCent(buyerPrice) - } - } - } - return req.PriceCent -} - -func publicListings(items []ListingDTO) []ListingDTO { - for index := range items { - applyPublicListingURLs(&items[index]) - } - return items -} - -func sellerListings(items []ListingDTO) []ListingDTO { - for index := range items { - applySellerListingPrice(&items[index]) - } - return items -} - -func applyPublicListingURLs(item *ListingDTO) { - item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus) - if item.AssetSummary != nil { - delete(item.AssetSummary, "price_breakdown") - } -} - -func applySellerListingPrice(item *ListingDTO) { - if item == nil || item.AssetSummary == nil { - return - } - breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any) - if !ok { - return - } - sellerPrice := readSummaryNumber(breakdown["seller_total_price"]) - if sellerPrice > 0 { - item.PriceCent = int64(math.Round(sellerPrice * 100)) - } - sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) - if sellerRatio > 0 { - item.AssetSummary["publish_ratio"] = sellerRatio - } - delete(breakdown, "buyer_coin_base_price") - delete(breakdown, "buyer_total_price") - delete(breakdown, "buyer_ratio") - delete(breakdown, "platform_markup_amount") - delete(breakdown, "platform_rule_type") -} - -func (row listingRow) toDTO() ListingDTO { - assetSummary := decodeAssetSummary(row.AssetSummary) - screenshotURLS := cleanScreenshotURLs(decodeScreenshots(row.ScreenshotURLS)) - reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason) - return ListingDTO{ - ID: row.ID, - ListingNo: row.ListingNo, - AccountID: row.AccountID, - OwnerID: row.OwnerID, - OwnerPhone: row.OwnerPhone, - OwnerNickname: row.OwnerNickname, - Title: row.Title, - Description: row.Description, - GameName: row.GameName, - ServerRegion: row.ServerRegion, - LoginPlatform: row.LoginPlatform, - RankLevel: row.RankLevel, - HafCoinAmount: row.HafCoinAmount, - AssetSummary: assetSummary, - ScreenshotURLS: screenshotURLS, - CoverURL: publicCoverURL(row.ID, screenshotURLS, row.Status, row.ReviewStatus), - PriceCent: row.PriceCent, - DepositAmountCent: row.DepositAmountCent, - IsAccelerated: isAcceleratedSale(assetSummary), - InTransaction: row.InTransaction, - Status: row.Status, - ReviewStatus: reviewStatus, - ReviewReason: reviewReason, - PublishedAt: row.PublishedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { - assetSummary := decodeAssetSummary(account.AssetSummary) - screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS)) - reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason) - return &ListingDTO{ - ID: listing.ID, - ListingNo: listing.ListingNo, - AccountID: account.ID, - OwnerID: listing.OwnerID, - Title: account.Title, - Description: account.Description, - GameName: account.GameName, - ServerRegion: account.ServerRegion, - LoginPlatform: account.LoginPlatform, - RankLevel: account.RankLevel, - HafCoinAmount: account.HafCoinAmount, - AssetSummary: assetSummary, - ScreenshotURLS: screenshotURLS, - CoverURL: publicCoverURL(listing.ID, screenshotURLS, listing.Status, listing.ReviewStatus), - PriceCent: listing.PriceCent, - DepositAmountCent: listing.DepositAmountCent, - IsAccelerated: isAcceleratedSale(assetSummary), - InTransaction: listing.InTransaction, - Status: listing.Status, - ReviewStatus: reviewStatus, - ReviewReason: reviewReason, - PublishedAt: listing.PublishedAt, - CreatedAt: listing.CreatedAt, - UpdatedAt: listing.UpdatedAt, - } -} - -func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) { - if status == "offline" { - return "none", reviewReason - } - return reviewStatus, reviewReason -} - -func marshalScreenshots(urls []string) (datatypes.JSON, error) { - cleaned := cleanScreenshotURLs(urls) - if len(cleaned) > 12 { - cleaned = cleaned[:12] - } - raw, err := json.Marshal(cleaned) - if err != nil { - return nil, err - } - return datatypes.JSON(raw), nil -} - -func marshalAssetSummary(summary map[string]any) (datatypes.JSON, error) { - if summary == nil { - return nil, nil - } - raw, err := json.Marshal(summary) - if err != nil { - return nil, err - } - return datatypes.JSON(raw), nil -} - -func decodeScreenshots(raw datatypes.JSON) []string { - if len(raw) == 0 { - return []string{} - } - var urls []string - if err := json.Unmarshal(raw, &urls); err != nil { - return []string{} - } - return urls -} - -func decodeAssetSummary(raw datatypes.JSON) map[string]any { - if len(raw) == 0 { - return nil - } - var summary map[string]any - if err := json.Unmarshal(raw, &summary); err != nil { - return nil - } - return summary -} - -func isAcceleratedSale(summary map[string]any) bool { - if summary == nil { - return false - } - breakdown, ok := summary["price_breakdown"].(map[string]any) - if !ok { - return false - } - referenceRatio := readSummaryNumber(breakdown["seller_reference_ratio"]) - sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) - acceleratedRatio := readSummaryNumber(breakdown["accelerated_sale_ratio"]) - if referenceRatio <= 0 { - return false - } - return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio -} - -func readSummaryNumber(value any) float64 { - switch typed := value.(type) { - case float64: - return typed - case float32: - return float64(typed) - case int: - return float64(typed) - case int64: - return float64(typed) - case json.Number: - number, err := typed.Float64() - if err != nil { - return 0 - } - return number - case string: - number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - if err != nil { - return 0 - } - return number - default: - return 0 - } -} - -func ensurePriceBreakdown(summary map[string]any) map[string]any { - if summary == nil { - return map[string]any{} - } - breakdown, ok := summary["price_breakdown"].(map[string]any) - if ok { - return breakdown - } - breakdown = map[string]any{} - if raw, ok := summary["price_breakdown"].(map[string]interface{}); ok { - for key, value := range raw { - breakdown[key] = value - } - } - return breakdown -} - -func calculateAdminAdjustedPrice(req AdminPriceAdjustRequest, coinWan float64, consumablePrice float64) (float64, float64, float64) { - if req.BuyerTotalPriceCent > 0 { - buyerTotalPrice := roundMoney(centToYuan(req.BuyerTotalPriceCent)) - buyerCoinBasePrice := roundMoney(buyerTotalPrice - consumablePrice) - if buyerCoinBasePrice <= 0 || coinWan <= 0 { - return 0, 0, 0 - } - return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice) - } - if req.BuyerRatio <= 0 || coinWan <= 0 { - return 0, 0, 0 - } - buyerCoinBasePrice := roundMoney(coinWan / req.BuyerRatio) - buyerTotalPrice := roundMoney(buyerCoinBasePrice + consumablePrice) - return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice) -} - -func roundRatio(value float64) float64 { - if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { - return 0 - } - return math.Round(value*10) / 10 -} - -func yuanToCent(value float64) int64 { - return int64(math.Round(roundMoney(value) * 100)) -} - -func centToYuan(value int64) float64 { - return float64(value) / 100 -} - -func cleanScreenshotURLs(urls []string) []string { - cleaned := make([]string, 0, len(urls)) - seen := make(map[string]struct{}, len(urls)) - for _, url := range urls { - url = strings.TrimSpace(url) - if url == "" { - continue - } - if _, ok := seen[url]; ok { - continue - } - seen[url] = struct{}{} - cleaned = append(cleaned, url) - } - return cleaned -} - -func firstScreenshotURL(urls []string) string { - if len(urls) == 0 { - return "" - } - return urls[0] -} - -func publicCoverURL(listingID uint64, urls []string, status string, reviewStatus string) string { - fallback := firstScreenshotURL(urls) - if status != "published" || reviewStatus != "approved" || extractListingObjectKey(fallback) == "" { - return fallback - } - return "/api/listings/" + strconv.FormatUint(listingID, 10) + "/cover" -} - -func publicScreenshotURLs(listingID uint64, urls []string, status string, reviewStatus string) []string { - if status != "published" || reviewStatus != "approved" { - return urls - } - publicURLs := make([]string, 0, len(urls)) - for index, fileURL := range urls { - if extractListingObjectKey(fileURL) == "" { - publicURLs = append(publicURLs, fileURL) - continue - } - publicURLs = append(publicURLs, "/api/listings/"+strconv.FormatUint(listingID, 10)+"/screenshots/"+strconv.Itoa(index)) - } - return publicURLs -} - -func extractListingObjectKey(fileURL string) string { - if fileURL == "" { - return "" - } - parsed, err := url.Parse(fileURL) - if err != nil { - return "" - } - key := parsed.Query().Get("key") - if key == "" { - return "" - } - if !strings.HasPrefix(key, "listing/") || strings.Contains(key, "..") { - return "" - } - return key -} - -func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { - return auditlog.Append(tx, auditlog.Entry{ - ActorType: "admin", - ActorID: actorID, - Action: action, - BizType: bizType, - BizID: &bizID, - Meta: meta, - Detail: detail, - }) -} - func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } // roundMoney 使用统一的角精度(0.1元) -func roundMoney(value float64) float64 { - return money.Round(value) -} diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go new file mode 100644 index 0000000..95c336f --- /dev/null +++ b/backend/internal/modules/listing/review.go @@ -0,0 +1,256 @@ +package listing + +import ( + "context" + "math" + "strings" + "time" + + "hfb_sys/backend/internal/modules/notification" + + "gorm.io/gorm" +) + +func (r *Repository) ListPendingReview(ctx context.Context) ([]ListingDTO, error) { + var rows []listingRow + err := r.baseQuery(ctx). + Where("l.review_status = ? AND l.status <> ?", "pending", "offline"). + Order("l.updated_at ASC, l.id ASC"). + Limit(200). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rowsToDTO(rows), nil +} + +func (r *Repository) AdminOffline(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { + return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "offline", "offline", "listing.admin_offline", "商品已被后台下架", "你的租号商品已被后台下架,请查看原因后处理。") +} + +func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { + return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。") +} + +func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta, listingStatus string, accountStatus string, action string, title string, content string) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + beforeListingStatus := listing.Status + beforeAccountStatus := account.Status + beforeReviewReason := listing.ReviewReason + listing.Status = listingStatus + listing.ReviewReason = req.Reason + if listingStatus != "published" { + listing.PublishedAt = nil + } + account.Status = accountStatus + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "listing_admin", + Title: title, + Content: content, + BizType: "listing", + BizID: &listingID, + }); err != nil { + return err + } + if err := appendAuditLog(tx, adminID, action, "listing", listing.ID, meta, map[string]any{ + "listing_id": listing.ID, + "account_id": account.ID, + "owner_id": listing.OwnerID, + "reason": req.Reason, + "before_listing_status": beforeListingStatus, + "after_listing_status": listing.Status, + "before_account_status": beforeAccountStatus, + "after_account_status": account.Status, + "before_review_reason": beforeReviewReason, + "after_review_reason": listing.ReviewReason, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + now := time.Now() + listing.Status = "published" + listing.ReviewStatus = "approved" + listing.ReviewReason = "" + listing.PublishedAt = &now + account.Status = "published" + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + listingID := listing.ID + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "listing_review", + Title: "发布审核通过", + Content: "你的租号发布已审核通过并上架。", + BizType: "listing", + BizID: &listingID, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) AdjustReviewPrice(ctx context.Context, adminID uint64, listingID uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + summary := decodeAssetSummary(account.AssetSummary) + if summary == nil { + summary = map[string]any{} + } + breakdown := ensurePriceBreakdown(summary) + coinWan := float64(account.HafCoinAmount) / 10000 + consumablePrice := readSummaryNumber(breakdown["consumable_price"]) + if consumablePrice <= 0 { + consumablePrice = consumableValue(summary) + } + sellerTotalPrice := readSummaryNumber(breakdown["seller_total_price"]) + if sellerTotalPrice <= 0 { + sellerTotalPrice = math.Max(0, centToYuan(listing.PriceCent)-consumablePrice) + } + sellerCoinBasePrice := readSummaryNumber(breakdown["seller_coin_base_price"]) + if sellerCoinBasePrice <= 0 { + sellerCoinBasePrice = math.Max(0, sellerTotalPrice-consumablePrice) + } + sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) + if sellerRatio <= 0 && sellerCoinBasePrice > 0 { + sellerRatio = roundRatio(coinWan / sellerCoinBasePrice) + } + buyerCoinBasePrice, buyerTotalPrice, buyerRatio := calculateAdminAdjustedPrice(req, coinWan, consumablePrice) + if buyerCoinBasePrice <= 0 || buyerTotalPrice <= 0 || buyerRatio <= 0 { + return ErrInvalidPrice + } + + beforePriceCent := listing.PriceCent + beforeRatio := readSummaryNumber(breakdown["buyer_ratio"]) + if beforeRatio <= 0 && centToYuan(listing.PriceCent) > consumablePrice { + beforeRatio = roundRatio(coinWan / (centToYuan(listing.PriceCent) - consumablePrice)) + } + + listing.PriceCent = yuanToCent(buyerTotalPrice) + summary["publish_ratio"] = buyerRatio + breakdown["seller_coin_base_price"] = roundMoney(sellerCoinBasePrice) + breakdown["seller_total_price"] = roundMoney(sellerTotalPrice) + breakdown["seller_ratio"] = sellerRatio + breakdown["buyer_coin_base_price"] = buyerCoinBasePrice + breakdown["buyer_total_price"] = buyerTotalPrice + breakdown["buyer_ratio"] = buyerRatio + breakdown["platform_markup_amount"] = roundMoney(buyerTotalPrice - sellerTotalPrice) + breakdown["platform_rule_type"] = "admin_adjusted" + breakdown["admin_adjust_reason"] = strings.TrimSpace(req.Reason) + breakdown["admin_adjusted_at"] = time.Now().Format(time.RFC3339) + breakdown["admin_adjusted_by"] = adminID + summary["price_breakdown"] = breakdown + + assetSummary, err := marshalAssetSummary(summary) + if err != nil { + return err + } + account.AssetSummary = assetSummary + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + if err := appendAuditLog(tx, adminID, "listing.adjust_review_price", "listing", listing.ID, meta, map[string]any{ + "listing_id": listing.ID, + "account_id": account.ID, + "owner_id": listing.OwnerID, + "before_price_cent": beforePriceCent, + "after_price_cent": listing.PriceCent, + "before_buyer_ratio": beforeRatio, + "after_buyer_ratio": buyerRatio, + "platform_markup": breakdown["platform_markup_amount"], + "adjust_reason": req.Reason, + "buyer_coin_base": buyerCoinBasePrice, + "consumable_price": consumablePrice, + "seller_total_price": sellerTotalPrice, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) Reject(ctx context.Context, listingID uint64, req ReviewRequest) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + listing.Status = "draft" + listing.ReviewStatus = "rejected" + listing.ReviewReason = req.Reason + listing.PublishedAt = nil + account.Status = "draft" + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + listingID := listing.ID + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "listing_review", + Title: "发布审核未通过", + Content: "你的租号发布未通过审核,请根据原因修改后重新提交。", + BizType: "listing", + BizID: &listingID, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} diff --git a/backend/internal/modules/payment/channel_status.go b/backend/internal/modules/payment/channel_status.go new file mode 100644 index 0000000..d541b28 --- /dev/null +++ b/backend/internal/modules/payment/channel_status.go @@ -0,0 +1,73 @@ +package payment + +import ( + "context" + "hfb_sys/backend/internal/model" + "time" +) + +func (r *Repository) applyChannelStatus(ctx context.Context, payment *model.PaymentOrder, status string, payTime string, raw map[string]string, source string) error { + switch status { + case "paid": + paidAt := parseChannelTime(payTime) + if paidAt == nil { + now := time.Now() + paidAt = &now + } + return r.confirmPaid(ctx, payment, status, *paidAt, raw, source) + case "closed": + return r.updateChannelStatus(ctx, payment.ID, "closed", raw, source) + case "failed": + return r.updateChannelStatus(ctx, payment.ID, "failed", raw, source) + default: + return r.updateChannelStatus(ctx, payment.ID, "paying", raw, source) + } +} +func (r *Repository) updateChannelStatus(ctx context.Context, paymentID uint64, status string, raw map[string]string, source string) error { + updates := map[string]any{ + "status": status, + "raw_response": jsonMap(withRawSource(raw, source)), + } + if source == channelSourceNotify { + updates["notified_at"] = time.Now() + } + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error +} +func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error { + if payment.Status != "paid" { + if payment.OrderID == 0 { + if r.walletRepo == nil { + return ErrDependencyUnavailable + } + if err := r.walletRepo.ConfirmRechargeFromChannel(ctx, payment.UserID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo), payment.AmountCent); err != nil { + return err + } + } else { + if r.orderRepo == nil { + return ErrDependencyUnavailable + } + if err := r.orderRepo.ConfirmPaidFromChannel(ctx, payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil { + return err + } + } + } + updates := map[string]any{ + "status": "paid", + "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], payment.ProviderOrderID), + "raw_response": jsonMap(withRawSource(raw, source)), + "paid_at": paidAt, + } + if source == channelSourceNotify { + updates["notified_at"] = time.Now() + } + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error +} +func (r *Repository) markPaymentFailed(ctx context.Context, paymentID uint64, raw map[string]string, message string) error { + if raw == nil { + raw = map[string]string{"error": message} + } + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ + "status": "failed", + "raw_response": jsonMap(raw), + }).Error +} diff --git a/backend/internal/modules/payment/notify.go b/backend/internal/modules/payment/notify.go new file mode 100644 index 0000000..e5f3326 --- /dev/null +++ b/backend/internal/modules/payment/notify.go @@ -0,0 +1,163 @@ +package payment + +import ( + "context" + "gorm.io/gorm" + "hfb_sys/backend/internal/model" + "log" + "time" +) + +func (r *Repository) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) { + return r.HandleNotify(ctx, "leshua", params, rawPayload, contentType, "") +} +func (r *Repository) HandleNotify(ctx context.Context, provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { + // 退款通知会携带 merchant_refund_id 或 leshua_refund_id。 + if params["merchant_refund_id"] != "" || params["leshua_refund_id"] != "" || params["provider_refund_id"] != "" { + return r.HandleRefundNotify(ctx, provider, params, rawPayload, contentType, authorization) + } + + payment, err := r.findPaymentForNotify(ctx, params) + if err != nil { + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + verify, err := r.verifyNotify(ctx, payment, runtimeConfig, params, rawPayload, contentType, authorization) + if err != nil { + return nil, err + } + + if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { + if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil { + log.Printf("[payment] %s notify diagnostic save failed third_order_id=%s err=%v", runtimeConfig.Provider, params["third_order_id"], err) + } + return nil, ErrPaymentVerifyFailed + } + raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") + if err := r.applyChannelStatus(ctx, payment, normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]), params["pay_time"], raw, channelSourceNotify); err != nil { + return nil, err + } + return &NotifyResult{OK: true, Message: "000000"}, nil +} +func (r *Repository) HandleRefundNotify(ctx context.Context, provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { + payment, err := r.findRefundPaymentForNotify(ctx, params) + if err != nil { + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + verify, err := r.verifyNotify(ctx, payment, runtimeConfig, params, rawPayload, contentType, authorization) + if err != nil { + return nil, err + } + + raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") + status := normalizeNotifyRefundStatus(provider, params["status"]) + switch status { + case "refunded": + now := time.Now() + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "refunded", + "paid_at": now, + "notified_at": now, + "raw_response": jsonMap(raw), + }).Error; err != nil { + return nil, err + } + _ = r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent) + case "failed": + r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "failed", + "notified_at": time.Now(), + "raw_response": jsonMap(raw), + }) + _ = r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent) + default: + r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "refunding", + "raw_response": jsonMap(raw), + }) + } + return &NotifyResult{OK: true, Message: "000000"}, nil +} +func (r *Repository) findPaymentForNotify(ctx context.Context, params map[string]string) (*model.PaymentOrder, error) { + thirdOrderID := params["third_order_id"] + if thirdOrderID == "" { + return nil, ErrPaymentNotFound + } + var payment model.PaymentOrder + if err := r.db.WithContext(ctx).Where("third_order_id = ?", thirdOrderID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + return &payment, nil +} +func (r *Repository) findRefundPaymentForNotify(ctx context.Context, params map[string]string) (*model.PaymentOrder, error) { + merchantRefundID := params["merchant_refund_id"] + if merchantRefundID == "" { + return nil, ErrPaymentNotFound + } + var payment model.PaymentOrder + if err := r.db.WithContext(ctx).Where("third_order_id = ?", merchantRefundID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + return &payment, nil +} +func (r *Repository) verifyNotify(ctx context.Context, payment *model.PaymentOrder, runtimeConfig *runtimePaymentConfig, params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { + var verify channelVerifyNotifyResult + if runtimeConfig.isMockMode() { + return verify, nil + } + if runtimeConfig.Channel == nil { + return verify, ErrPaymentUnavailable + } + verify, err := runtimeConfig.Channel.VerifyNotify(params, rawPayload, contentType, authorization) + if err != nil || !verify.OK { + log.Printf( + "[payment] %s notify verify failed payment_id=%d third_order_id=%s got=%s expected=%s keys=%v base_string=%s", + runtimeConfig.Provider, + payment.ID, + params["third_order_id"], + verify.Got, + firstNonEmpty(verify.Expected["notify_key"], verify.Expected["notify_cert"], verify.Expected["error"]), + verify.ParamKeys, + firstNonEmpty(verify.BaseString["notify_key"], verify.BaseString["notify_cert"]), + ) + if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "verify_failed"); err != nil { + log.Printf("[payment] %s notify diagnostic save failed payment_id=%d err=%v", runtimeConfig.Provider, payment.ID, err) + } + return verify, ErrPaymentVerifyFailed + } + log.Printf("[payment] %s notify verified payment_id=%d third_order_id=%s matched_key=%s", runtimeConfig.Provider, payment.ID, params["third_order_id"], verify.MatchedKey) + return verify, nil +} +func (r *Repository) recordNotifyDiagnostic(ctx context.Context, paymentID uint64, params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) error { + if paymentID == 0 { + return nil + } + raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, status) + return r.db.WithContext(ctx).Model(&model.PaymentOrder{}). + Where("id = ?", paymentID). + Update("raw_response", jsonMap(raw)).Error +} +func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) map[string]string { + raw := withRawSource(params, channelSourceNotify) + raw["_notify_diagnostic_status"] = status + raw["_raw_payload"] = rawPayload + raw["_raw_content_type"] = contentType + raw["_sign_got"] = verify.Got + raw["_sign_matched_key"] = verify.MatchedKey + raw["_sign_expected"] = jsonString(verify.Expected) + raw["_sign_base_strings"] = jsonString(verify.BaseString) + return raw +} diff --git a/backend/internal/modules/payment/payment_start.go b/backend/internal/modules/payment/payment_start.go new file mode 100644 index 0000000..d3e57a5 --- /dev/null +++ b/backend/internal/modules/payment/payment_start.go @@ -0,0 +1,335 @@ +package payment + +import ( + "context" + "encoding/json" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" + "log" + "time" +) + +func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { + defaultConfig, err := r.defaultRuntimeConfig(ctx) + if err != nil { + return nil, ErrPaymentUnavailable + } + payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req, *defaultConfig) + if err != nil { + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + if payment.Status == "paid" { + r.recordConfigUsage(ctx, runtimeConfig, payment) + dto := toDTO(*payment) + return &dto, nil + } + if runtimeConfig.isMockMode() { + if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{ + "mock": "true", + "third_order_id": payment.ThirdOrderID, + "leshua_order_id": payment.ProviderOrderID, + "status": "2", + }, channelSourceMock); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + r.recordConfigUsage(ctx, runtimeConfig, latest) + dto := toDTO(*latest) + return &dto, nil + } + if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") { + r.recordConfigUsage(ctx, runtimeConfig, payment) + dto := toDTO(*payment) + return &dto, nil + } + if runtimeConfig.Channel == nil { + _ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable") + return nil, ErrPaymentUnavailable + } + + log.Printf("[payment] payment start order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d third_order_id=%s", + orderID, orderRow.OrderNo, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) + resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + AmountCent: payment.AmountCent, + PayWay: payment.PayWay, + JSPayFlag: payment.JSPayFlag, + NotifyURL: runtimeConfig.NotifyURL, + JumpURL: runtimeConfig.JumpURL, + ClientIP: clientIP, + Body: "租号订单 " + orderRow.OrderNo, + Attach: orderRow.OrderNo, + }) + if err != nil { + _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) + log.Printf("[payment] payment request failed order_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", + orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) + return nil, err + } + if !resp.OK { + _ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage) + log.Printf("[payment] payment rejected order_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", + orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) + return nil, ErrPaymentUnavailable + } + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "paying", + "provider_order_id": resp.ProviderOrderID, + "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), + "td_code": resp.TDCode, + "jspay_url": resp.JSPayURL, + "jspay_info": resp.JSPayInfo, + "raw_request": jsonMap(resp.RawRequest), + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), + }).Error; err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + r.recordConfigUsage(ctx, runtimeConfig, latest) + log.Printf("[payment] payment result order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", + orderID, orderRow.OrderNo, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) + dto := toDTO(*latest) + return &dto, nil +} +func (r *Repository) StartWalletRecharge(ctx context.Context, userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) { + amountCent := req.AmountCent + if userID == 0 || amountCent < moneyCent(MinWalletRechargeAmount) { + return nil, ErrPaymentCannotStart + } + runtimeConfig, err := r.defaultRuntimeConfig(ctx) + if err != nil { + return nil, ErrPaymentUnavailable + } + payment, err := r.createWalletRechargePayment(ctx, userID, amountCent, req, *runtimeConfig) + if err != nil { + return nil, err + } + if runtimeConfig.isMockMode() { + if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{ + "mock": "true", + "third_order_id": payment.ThirdOrderID, + "leshua_order_id": payment.ProviderOrderID, + "status": "2", + }, channelSourceMock); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + r.recordConfigUsage(ctx, runtimeConfig, latest) + dto := toDTO(*latest) + return &dto, nil + } + if runtimeConfig.Channel == nil { + _ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable") + return nil, ErrPaymentUnavailable + } + log.Printf("[payment] wallet recharge start user_id=%d payment_id=%d provider=%s amount_cent=%d third_order_id=%s", + userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) + resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + AmountCent: payment.AmountCent, + PayWay: payment.PayWay, + JSPayFlag: payment.JSPayFlag, + NotifyURL: runtimeConfig.NotifyURL, + JumpURL: runtimeConfig.JumpURL, + ClientIP: clientIP, + Body: "钱包充值 " + payment.PaymentNo, + Attach: payment.PaymentNo, + }) + if err != nil { + _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) + log.Printf("[payment] wallet recharge request failed user_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", + userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) + return nil, err + } + if !resp.OK { + _ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage) + log.Printf("[payment] wallet recharge rejected user_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", + userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) + return nil, ErrPaymentUnavailable + } + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "paying", + "provider_order_id": resp.ProviderOrderID, + "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), + "td_code": resp.TDCode, + "jspay_url": resp.JSPayURL, + "jspay_info": resp.JSPayInfo, + "raw_request": jsonMap(resp.RawRequest), + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), + }).Error; err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + r.recordConfigUsage(ctx, runtimeConfig, latest) + log.Printf("[payment] wallet recharge result user_id=%d payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", + userID, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) + dto := toDTO(*latest) + return &dto, nil +} +func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) { + var paymentID uint64 + var orderRow model.RentalOrder + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var row model.RentalOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND renter_id = ?", orderID, userID). + First(&row).Error; err != nil { + return err + } + if row.Status != "pending_payment" { + return ErrPaymentCannotStart + } + amountCent := row.RentAmountCent + row.DepositAmountCent + if amountCent <= 0 { + return ErrPaymentCannotStart + } + var existing model.PaymentOrder + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ? AND biz_type = ?", row.ID, "order_pay"). + Order("id DESC"). + First(&existing).Error + if err == nil { + if canReuseOrderPayment(existing, runtimeConfig) { + existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") + existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") + existing.AmountCent = amountCent + existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider) + existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID) + if existing.Provider == "mock" && existing.ProviderOrderID == "" { + existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID + } + if err := tx.Save(&existing).Error; err != nil { + return err + } + paymentID = existing.ID + orderRow = row + return nil + } + } else if err != gorm.ErrRecordNotFound { + return err + } + payment, err := newOrderPayment(row, amountCent, req, runtimeConfig) + if err != nil { + return err + } + if err := tx.Create(&payment).Error; err != nil { + return err + } + paymentID = payment.ID + orderRow = row + return nil + }) + if err != nil { + return nil, nil, err + } + payment, err := r.findPaymentByID(ctx, paymentID) + if err != nil { + return nil, nil, err + } + return payment, &orderRow, nil +} +func canReuseOrderPayment(payment model.PaymentOrder, runtimeConfig runtimePaymentConfig) bool { + if payment.Status == "paid" { + return true + } + if payment.Status != "created" && payment.Status != "paying" { + return false + } + if payment.Provider != "" && runtimeConfig.Provider != "" && payment.Provider != runtimeConfig.Provider { + return false + } + if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID { + return false + } + if payment.Status == "paying" && paymentCashierExpired(payment) { + return false + } + return true +} +func paymentCashierExpired(payment model.PaymentOrder) bool { + if len(payment.RawRequest) == 0 { + return false + } + var raw map[string]string + if err := json.Unmarshal(payment.RawRequest, &raw); err != nil { + return false + } + deadline := parseChannelTime(raw["order_efficient_time"]) + if deadline == nil { + return false + } + return !timeutil.ShanghaiNow().Before(*deadline) +} +func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) { + paymentNo, err := newPaymentNo() + if err != nil { + return model.PaymentOrder{}, err + } + payment := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: row.ID, + OrderNo: row.OrderNo, + UserID: row.RenterID, + Provider: runtimeConfig.Provider, + MerchantID: runtimeConfig.MerchantID, + ThirdOrderID: paymentNo, + ProviderOrderID: "", + PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), + AmountCent: amountCent, + BizType: "order_pay", + Status: "created", + } + if runtimeConfig.isMockMode() { + payment.ProviderOrderID = "MOCK" + paymentNo + payment.TDCode = "mock://payment/pay/" + paymentNo + } + return payment, nil +} +func (r *Repository) createWalletRechargePayment(ctx context.Context, userID uint64, amountCent int64, req WalletRechargePaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, error) { + paymentNo, err := newPaymentNo() + if err != nil { + return nil, err + } + payment := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: 0, + OrderNo: paymentNo, + UserID: userID, + Provider: runtimeConfig.Provider, + MerchantID: runtimeConfig.MerchantID, + ThirdOrderID: paymentNo, + ProviderOrderID: "", + PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), + AmountCent: amountCent, + BizType: "wallet_recharge", + Status: "created", + } + if runtimeConfig.isMockMode() { + payment.ProviderOrderID = "MOCK" + paymentNo + payment.TDCode = "mock://payment/recharge/" + paymentNo + } + if err := r.db.WithContext(ctx).Create(&payment).Error; err != nil { + return nil, err + } + return &payment, nil +} diff --git a/backend/internal/modules/payment/presenter.go b/backend/internal/modules/payment/presenter.go new file mode 100644 index 0000000..18dc6b0 --- /dev/null +++ b/backend/internal/modules/payment/presenter.go @@ -0,0 +1,117 @@ +package payment + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "gorm.io/datatypes" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" + "math" + "time" +) + +func toDTO(payment model.PaymentOrder) PaymentDTO { + return PaymentDTO{ + ID: payment.ID, + PaymentNo: payment.PaymentNo, + OrderID: payment.OrderID, + OrderNo: payment.OrderNo, + Provider: payment.Provider, + ThirdOrderID: payment.ThirdOrderID, + ProviderOrderID: payment.ProviderOrderID, + PayWay: payment.PayWay, + JSPayFlag: payment.JSPayFlag, + AmountCent: payment.AmountCent, + Status: payment.Status, + TDCode: payment.TDCode, + JSPayURL: payment.JSPayURL, + JSPayInfo: payment.JSPayInfo, + Paid: payment.Status == "paid", + PaidAt: payment.PaidAt, + CreatedAt: payment.CreatedAt, + UpdatedAt: payment.UpdatedAt, + } +} +func paymentErrorSummary(status string, raw datatypes.JSON) (string, string) { + if status != "failed" { + return "", "" + } + if len(raw) == 0 { + return "", "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "", "" + } + code := firstStringValue(payload, "code", "resp_code", "result_code", "error_code", "status") + message := firstStringValue(payload, "msg", "message", "error", "error_message", "result_msg", "result_desc") + return code, message +} +func moneyCent(value float64) int64 { + return int64(math.Round(value * 100)) +} +func parseCent(value string) int64 { + var amount int64 + _, _ = fmt.Sscanf(value, "%d", &amount) + return amount +} +func parseChannelTime(value string) *time.Time { + if value == "" { + return nil + } + for _, layout := range []string{"2006-01-02 15:04:05", "20060102150405", time.RFC3339} { + parsed, err := time.ParseInLocation(layout, value, timeutil.ShanghaiLocation()) + if err == nil { + return &parsed + } + } + return nil +} +func jsonMap(value map[string]string) datatypes.JSON { + if value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + return datatypes.JSON(raw) +} +func withRawSource(raw map[string]string, source string) map[string]string { + out := map[string]string{} + for key, value := range raw { + out[key] = value + } + if source != "" { + out["_source"] = source + } + out["_recorded_at"] = time.Now().Format(time.RFC3339) + return out +} +func jsonString(value map[string]string) string { + if len(value) == 0 { + return "{}" + } + raw, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(raw) +} +func newPaymentNo() (string, error) { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("PAY%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil +} +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/modules/payment/query.go b/backend/internal/modules/payment/query.go new file mode 100644 index 0000000..f4c9a8a --- /dev/null +++ b/backend/internal/modules/payment/query.go @@ -0,0 +1,158 @@ +package payment + +import ( + "context" + "gorm.io/gorm" + "hfb_sys/backend/internal/model" +) + +func (r *Repository) QueryWalletRecharge(ctx context.Context, userID uint64, paymentID uint64) (*PaymentDTO, error) { + var payment model.PaymentOrder + if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ? AND order_id = 0", paymentID, userID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + if payment.Status == "paid" || runtimeConfig.isMockMode() { + dto := toDTO(payment) + return &dto, nil + } + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + if err != nil { + return nil, err + } + if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} +func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) { + var payment model.PaymentOrder + if err := r.db.WithContext(ctx).Where("order_id = ? AND user_id = ? AND biz_type = ?", orderID, userID, "order_pay").Order("id DESC").First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + if payment.Status == "paid" || runtimeConfig.isMockMode() { + dto := toDTO(payment) + return &dto, nil + } + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + if err != nil { + return nil, err + } + if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} +func (r *Repository) AdminList(ctx context.Context, query AdminPaymentQuery) (*PaginatedResult, error) { + db := r.db.WithContext(ctx).Table("payment_orders AS p"). + Select("p.*, COALESCE(u.phone, '') AS user_phone"). + Joins("LEFT JOIN users AS u ON u.id = p.user_id") + countDB := r.db.WithContext(ctx).Model(&model.PaymentOrder{}) + if query.UserID > 0 { + db = db.Where("p.user_id = ?", query.UserID) + countDB = countDB.Where("user_id = ?", query.UserID) + } + if query.OrderID > 0 { + db = db.Where("p.order_id = ?", query.OrderID) + countDB = countDB.Where("order_id = ?", query.OrderID) + } + if query.OrderNo != "" { + db = db.Where("p.order_no = ?", query.OrderNo) + countDB = countDB.Where("order_no = ?", query.OrderNo) + } + if query.BizType != "" { + db = db.Where("p.biz_type = ?", query.BizType) + countDB = countDB.Where("biz_type = ?", query.BizType) + } + if query.Status != "" { + db = db.Where("p.status = ?", query.Status) + countDB = countDB.Where("status = ?", query.Status) + } + if query.Provider != "" { + db = db.Where("p.provider = ?", query.Provider) + countDB = countDB.Where("provider = ?", query.Provider) + } + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + offset := (query.Page - 1) * query.PageSize + var rows []adminPaymentRow + if err := db.Order("p.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]AdminPaymentDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} +func (r *Repository) findPaymentByID(ctx context.Context, paymentID uint64) (*model.PaymentOrder, error) { + var payment model.PaymentOrder + if err := r.db.WithContext(ctx).First(&payment, paymentID).Error; err != nil { + return nil, err + } + return &payment, nil +} + +type adminPaymentRow struct { + model.PaymentOrder + UserPhone string +} + +func (row adminPaymentRow) toDTO() AdminPaymentDTO { + errorCode, errorMessage := paymentErrorSummary(row.Status, row.RawResponse) + return AdminPaymentDTO{ + ID: row.ID, + PaymentNo: row.PaymentNo, + OrderID: row.OrderID, + OrderNo: row.OrderNo, + UserID: row.UserID, + UserPhone: row.UserPhone, + Provider: row.Provider, + MerchantID: row.MerchantID, + ThirdOrderID: row.ThirdOrderID, + ProviderOrderID: row.ProviderOrderID, + PayWay: row.PayWay, + AmountCent: row.AmountCent, + BizType: row.BizType, + Status: row.Status, + ErrorCode: errorCode, + ErrorMessage: errorMessage, + RawRequest: row.RawRequest, + RawResponse: row.RawResponse, + PaidAt: row.PaidAt, + NotifiedAt: row.NotifiedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go new file mode 100644 index 0000000..a425d7f --- /dev/null +++ b/backend/internal/modules/payment/refund.go @@ -0,0 +1,310 @@ +package payment + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" + "hfb_sys/backend/internal/model" +) + +func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) { + var originalPayment model.PaymentOrder + if err := r.db.WithContext(ctx).Where("order_id = ? AND status = 'paid' AND biz_type = 'order_pay'", orderID).Order("id DESC").First(&originalPayment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, &originalPayment) + if err != nil { + return nil, ErrPaymentUnavailable + } + + var existingRefund model.PaymentOrder + err = r.db.WithContext(ctx).Where("order_id = ? AND biz_type = ? AND status NOT IN ('failed')", orderID, bizType).Order("id DESC").First(&existingRefund).Error + if err == nil { + dto := toRefundDTO(existingRefund) + return &dto, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + + paymentNo, err := newPaymentNo() + if err != nil { + return nil, err + } + merchantRefundID := "REF" + paymentNo[3:] + + refundOrder := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: orderID, + OrderNo: originalPayment.OrderNo, + UserID: originalPayment.UserID, + Provider: runtimeConfig.Provider, + MerchantID: runtimeConfig.MerchantID, + ThirdOrderID: merchantRefundID, + ProviderOrderID: "", + PayWay: originalPayment.PayWay, + JSPayFlag: originalPayment.JSPayFlag, + AmountCent: refundAmountCent, + BizType: bizType, + Status: "refunding", + } + + if runtimeConfig.isMockMode() { + refundOrder.ProviderOrderID = "MOCKREF" + merchantRefundID + refundOrder.Status = "refunded" + now := time.Now() + refundOrder.PaidAt = &now + if remark != "" { + refundOrder.RawResponse = datatypes.JSON([]byte(fmt.Sprintf(`{"mock":"true","remark":"%s"}`, remark))) + } + if err := r.db.WithContext(ctx).Create(&refundOrder).Error; err != nil { + return nil, err + } + r.recordConfigUsage(ctx, runtimeConfig, &refundOrder) + if err := r.updateOrderRefundStatus(ctx, orderID, refundAmountCent); err != nil { + log.Printf("[payment] mock update order refund status failed order_id=%d err=%v", orderID, err) + } + dto := toRefundDTO(refundOrder) + return &dto, nil + } + + if err := r.db.WithContext(ctx).Create(&refundOrder).Error; err != nil { + return nil, err + } + log.Printf("[payment] refund start order_id=%d order_no=%s payment_id=%d biz_type=%s provider=%s amount_cent=%d merchant_refund_id=%s origin_third_order_id=%s origin_provider_order_id=%s", + orderID, originalPayment.OrderNo, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, merchantRefundID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment)) + r.recordConfigUsage(ctx, runtimeConfig, &refundOrder) + if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil { + log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) + } + + if runtimeConfig.Channel == nil { + _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}) + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ + ThirdOrderID: originalPayment.ThirdOrderID, + ProviderOrderID: refundOriginProviderOrderID(originalPayment), + MerchantRefundID: merchantRefundID, + RefundAmountCent: refundAmountCent, + NotifyURL: runtimeConfig.NotifyURL, + Attach: originalPayment.OrderNo, + Remark: remark, + }) + if err != nil { + _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}) + log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v", + orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, err) + return nil, err + } + if !resp.OK { + _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw) + log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s", + orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) + return nil, ErrPaymentUnavailable + } + + refundStatus := "refunding" + var paidAt *time.Time + if resp.Status == "refunded" { + refundStatus = "refunded" + now := time.Now() + paidAt = &now + } else if resp.Status == "failed" { + refundStatus = "failed" + } + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", refundOrder.ID).Updates(map[string]any{ + "status": refundStatus, + "provider_order_id": resp.ProviderRefundID, + "raw_request": jsonMap(resp.RawRequest), + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), + "paid_at": paidAt, + }).Error; err != nil { + return nil, err + } + + if refundStatus == "refunded" { + _ = r.updateOrderRefundStatus(ctx, orderID, refundAmountCent) + refundOrder.PaidAt = paidAt + } else if refundStatus == "failed" { + _ = r.markOrderRefundFailed(ctx, orderID, refundAmountCent) + } else { + _ = r.markOrderRefunding(ctx, orderID, refundAmountCent) + } + refundOrder.Status = refundStatus + refundOrder.ProviderOrderID = resp.ProviderRefundID + log.Printf("[payment] refund result order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d status=%s provider_refund_id=%s", + orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, refundStatus, resp.ProviderRefundID) + + dto := toRefundDTO(refundOrder) + return &dto, nil +} +func (r *Repository) QueryRefundStatus(ctx context.Context, orderID uint64) (*RefundDTO, error) { + var payment model.PaymentOrder + if err := r.db.WithContext(ctx).Where("order_id = ? AND biz_type IN ?", orderID, refundBizTypes).Order("id DESC").First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + if payment.Status == "refunded" || payment.Status == "failed" || runtimeConfig.isMockMode() { + dto := toRefundDTO(payment) + return &dto, nil + } + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryRefund(ctx, channelQueryRefundRequest{ + ThirdOrderID: payment.ThirdOrderID, + MerchantRefundID: payment.ThirdOrderID, + ProviderRefundID: payment.ProviderOrderID, + }) + if err != nil { + return nil, err + } + if resp.Status == "refunded" { + now := time.Now() + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "refunded", + "paid_at": now, + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), + }).Error; err != nil { + return nil, err + } + payment.Status = "refunded" + payment.PaidAt = &now + _ = r.updateOrderRefundStatus(ctx, orderID, payment.AmountCent) + } else if resp.Status == "failed" { + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ + "status": "failed", + "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), + }).Error; err != nil { + return nil, err + } + payment.Status = "failed" + _ = r.markOrderRefundFailed(ctx, orderID, payment.AmountCent) + } + dto := toRefundDTO(payment) + return &dto, nil +} +func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error { + now := time.Now() + return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ + "refund_status": "refunded", + "refund_amount_cent": refundAmountCent, + "refunded_at": now, + }).Error +} +func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error { + return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ + "refund_status": "refunding", + "refund_amount_cent": refundAmountCent, + "refunded_at": nil, + }).Error +} +func (r *Repository) markOrderRefundFailed(ctx context.Context, orderID uint64, refundAmountCent int64) error { + return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ + "refund_status": "failed", + "refund_amount_cent": refundAmountCent, + "refunded_at": nil, + }).Error +} +func (r *Repository) markRefundFailed(ctx context.Context, paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string) error { + if raw == nil { + raw = map[string]string{"error": "refund failed"} + } + if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ + "status": "failed", + "raw_response": jsonMap(raw), + }).Error; err != nil { + return err + } + return r.markOrderRefundFailed(ctx, orderID, refundAmountCent) +} +func toRefundDTO(payment model.PaymentOrder) RefundDTO { + return RefundDTO{ + ID: payment.ID, + PaymentNo: payment.PaymentNo, + OrderID: payment.OrderID, + OrderNo: payment.OrderNo, + BizType: payment.BizType, + AmountCent: payment.AmountCent, + Status: payment.Status, + ProviderOrderID: payment.ProviderOrderID, + PaidAt: payment.PaidAt, + CreatedAt: payment.CreatedAt, + UpdatedAt: payment.UpdatedAt, + } +} +func refundOriginProviderOrderID(payment model.PaymentOrder) string { + if payment.Provider != "lakala" { + return payment.ProviderOrderID + } + if tradeID := lakalaOriginTradeID(payment.RawResponse); tradeID != "" { + return tradeID + } + return payment.ProviderOrderID +} +func lakalaOriginTradeID(raw datatypes.JSON) string { + if len(raw) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + if tradeID := firstStringValue(payload, "trade_no", "origin_trade_no"); tradeID != "" { + return tradeID + } + value, ok := payload["order_trade_info_list"] + if !ok { + return "" + } + switch typed := value.(type) { + case string: + var items []map[string]any + if err := json.Unmarshal([]byte(typed), &items); err != nil { + return "" + } + for _, item := range items { + if tradeID := firstStringValue(item, "trade_no", "origin_trade_no"); tradeID != "" { + return tradeID + } + } + case []any: + for _, item := range typed { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + if tradeID := firstStringValue(itemMap, "trade_no", "origin_trade_no"); tradeID != "" { + return tradeID + } + } + } + return "" +} +func firstStringValue(values map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := values[key] + if !ok { + continue + } + if text, ok := value.(string); ok && text != "" { + return text + } + } + return "" +} diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 77645cd..8862618 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -2,23 +2,13 @@ package payment import ( "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" "log" - "math" - "time" + "gorm.io/gorm" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/wallet" - "hfb_sys/backend/internal/timeutil" - - "gorm.io/datatypes" - "gorm.io/gorm" - "gorm.io/gorm/clause" ) type Repository struct { @@ -64,11 +54,9 @@ func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo walletRepo: walletRepo, } } - func (c runtimePaymentConfig) isMockMode() bool { return c.Provider == "mock" } - func (r *Repository) defaultRuntimeConfig(ctx context.Context) (*runtimePaymentConfig, error) { if r.configRepo == nil { return nil, ErrPaymentUnavailable @@ -79,7 +67,6 @@ func (r *Repository) defaultRuntimeConfig(ctx context.Context) (*runtimePaymentC } return runtimeConfigFromDTO(dto), nil } - func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model.PaymentOrder) (*runtimePaymentConfig, error) { provider := firstNonEmpty(payment.Provider, "mock") merchantID := payment.MerchantID @@ -114,7 +101,6 @@ func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model } return runtimeConfigFromDTO(dto), nil } - func runtimeConfigFromDTO(dto *paymentconfig.ConfigDTO) *runtimePaymentConfig { provider := firstNonEmpty(dto.Provider, "mock") payWay := firstNonEmpty(dto.PayWay, "ZFBZF") @@ -134,913 +120,6 @@ func runtimeConfigFromDTO(dto *paymentconfig.ConfigDTO) *runtimePaymentConfig { Channel: client, } } - -func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { - defaultConfig, err := r.defaultRuntimeConfig(ctx) - if err != nil { - return nil, ErrPaymentUnavailable - } - payment, orderRow, err := r.preparePayment(ctx, userID, orderID, req, *defaultConfig) - if err != nil { - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) - if err != nil { - return nil, ErrPaymentUnavailable - } - if payment.Status == "paid" { - r.recordConfigUsage(ctx, runtimeConfig, payment) - dto := toDTO(*payment) - return &dto, nil - } - if runtimeConfig.isMockMode() { - if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{ - "mock": "true", - "third_order_id": payment.ThirdOrderID, - "leshua_order_id": payment.ProviderOrderID, - "status": "2", - }, channelSourceMock); err != nil { - return nil, err - } - latest, err := r.findPaymentByID(ctx, payment.ID) - if err != nil { - return nil, err - } - r.recordConfigUsage(ctx, runtimeConfig, latest) - dto := toDTO(*latest) - return &dto, nil - } - if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") { - r.recordConfigUsage(ctx, runtimeConfig, payment) - dto := toDTO(*payment) - return &dto, nil - } - if runtimeConfig.Channel == nil { - _ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable") - return nil, ErrPaymentUnavailable - } - - log.Printf("[payment] payment start order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d third_order_id=%s", - orderID, orderRow.OrderNo, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) - resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{ - ThirdOrderID: payment.ThirdOrderID, - AmountCent: payment.AmountCent, - PayWay: payment.PayWay, - JSPayFlag: payment.JSPayFlag, - NotifyURL: runtimeConfig.NotifyURL, - JumpURL: runtimeConfig.JumpURL, - ClientIP: clientIP, - Body: "租号订单 " + orderRow.OrderNo, - Attach: orderRow.OrderNo, - }) - if err != nil { - _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) - log.Printf("[payment] payment request failed order_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", - orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) - return nil, err - } - if !resp.OK { - _ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage) - log.Printf("[payment] payment rejected order_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", - orderID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) - return nil, ErrPaymentUnavailable - } - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "paying", - "provider_order_id": resp.ProviderOrderID, - "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), - "td_code": resp.TDCode, - "jspay_url": resp.JSPayURL, - "jspay_info": resp.JSPayInfo, - "raw_request": jsonMap(resp.RawRequest), - "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), - }).Error; err != nil { - return nil, err - } - latest, err := r.findPaymentByID(ctx, payment.ID) - if err != nil { - return nil, err - } - r.recordConfigUsage(ctx, runtimeConfig, latest) - log.Printf("[payment] payment result order_id=%d order_no=%s payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", - orderID, orderRow.OrderNo, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) - dto := toDTO(*latest) - return &dto, nil -} - -func (r *Repository) StartWalletRecharge(ctx context.Context, userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) { - amountCent := req.AmountCent - if userID == 0 || amountCent < moneyCent(MinWalletRechargeAmount) { - return nil, ErrPaymentCannotStart - } - runtimeConfig, err := r.defaultRuntimeConfig(ctx) - if err != nil { - return nil, ErrPaymentUnavailable - } - payment, err := r.createWalletRechargePayment(ctx, userID, amountCent, req, *runtimeConfig) - if err != nil { - return nil, err - } - if runtimeConfig.isMockMode() { - if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{ - "mock": "true", - "third_order_id": payment.ThirdOrderID, - "leshua_order_id": payment.ProviderOrderID, - "status": "2", - }, channelSourceMock); err != nil { - return nil, err - } - latest, err := r.findPaymentByID(ctx, payment.ID) - if err != nil { - return nil, err - } - r.recordConfigUsage(ctx, runtimeConfig, latest) - dto := toDTO(*latest) - return &dto, nil - } - if runtimeConfig.Channel == nil { - _ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable") - return nil, ErrPaymentUnavailable - } - log.Printf("[payment] wallet recharge start user_id=%d payment_id=%d provider=%s amount_cent=%d third_order_id=%s", - userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID) - resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{ - ThirdOrderID: payment.ThirdOrderID, - AmountCent: payment.AmountCent, - PayWay: payment.PayWay, - JSPayFlag: payment.JSPayFlag, - NotifyURL: runtimeConfig.NotifyURL, - JumpURL: runtimeConfig.JumpURL, - ClientIP: clientIP, - Body: "钱包充值 " + payment.PaymentNo, - Attach: payment.PaymentNo, - }) - if err != nil { - _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) - log.Printf("[payment] wallet recharge request failed user_id=%d payment_id=%d provider=%s amount_cent=%d err=%v", - userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err) - return nil, err - } - if !resp.OK { - _ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage) - log.Printf("[payment] wallet recharge rejected user_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s", - userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) - return nil, ErrPaymentUnavailable - } - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "paying", - "provider_order_id": resp.ProviderOrderID, - "pay_way": firstNonEmpty(resp.PayWay, payment.PayWay), - "td_code": resp.TDCode, - "jspay_url": resp.JSPayURL, - "jspay_info": resp.JSPayInfo, - "raw_request": jsonMap(resp.RawRequest), - "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), - }).Error; err != nil { - return nil, err - } - latest, err := r.findPaymentByID(ctx, payment.ID) - if err != nil { - return nil, err - } - r.recordConfigUsage(ctx, runtimeConfig, latest) - log.Printf("[payment] wallet recharge result user_id=%d payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s", - userID, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID) - dto := toDTO(*latest) - return &dto, nil -} - -func (r *Repository) QueryWalletRecharge(ctx context.Context, userID uint64, paymentID uint64) (*PaymentDTO, error) { - var payment model.PaymentOrder - if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ? AND order_id = 0", paymentID, userID).First(&payment).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, ErrPaymentNotFound - } - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) - if err != nil { - return nil, ErrPaymentUnavailable - } - if payment.Status == "paid" || runtimeConfig.isMockMode() { - dto := toDTO(payment) - return &dto, nil - } - if runtimeConfig.Channel == nil { - return nil, ErrPaymentUnavailable - } - resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) - if err != nil { - return nil, err - } - if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { - return nil, err - } - latest, err := r.findPaymentByID(ctx, payment.ID) - if err != nil { - return nil, err - } - dto := toDTO(*latest) - return &dto, nil -} - -func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) { - var payment model.PaymentOrder - if err := r.db.WithContext(ctx).Where("order_id = ? AND user_id = ? AND biz_type = ?", orderID, userID, "order_pay").Order("id DESC").First(&payment).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, ErrPaymentNotFound - } - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) - if err != nil { - return nil, ErrPaymentUnavailable - } - if payment.Status == "paid" || runtimeConfig.isMockMode() { - dto := toDTO(payment) - return &dto, nil - } - if runtimeConfig.Channel == nil { - return nil, ErrPaymentUnavailable - } - resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) - if err != nil { - return nil, err - } - if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { - return nil, err - } - latest, err := r.findPaymentByID(ctx, payment.ID) - if err != nil { - return nil, err - } - dto := toDTO(*latest) - return &dto, nil -} - -func (r *Repository) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) { - return r.HandleNotify(ctx, "leshua", params, rawPayload, contentType, "") -} - -func (r *Repository) HandleNotify(ctx context.Context, provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { - // 退款通知会携带 merchant_refund_id 或 leshua_refund_id。 - if params["merchant_refund_id"] != "" || params["leshua_refund_id"] != "" || params["provider_refund_id"] != "" { - return r.HandleRefundNotify(ctx, provider, params, rawPayload, contentType, authorization) - } - - payment, err := r.findPaymentForNotify(ctx, params) - if err != nil { - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) - if err != nil { - return nil, ErrPaymentUnavailable - } - verify, err := r.verifyNotify(ctx, payment, runtimeConfig, params, rawPayload, contentType, authorization) - if err != nil { - return nil, err - } - - if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { - if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil { - log.Printf("[payment] %s notify diagnostic save failed third_order_id=%s err=%v", runtimeConfig.Provider, params["third_order_id"], err) - } - return nil, ErrPaymentVerifyFailed - } - raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") - if err := r.applyChannelStatus(ctx, payment, normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]), params["pay_time"], raw, channelSourceNotify); err != nil { - return nil, err - } - return &NotifyResult{OK: true, Message: "000000"}, nil -} - -// StartRefund 创建退款单,并在本地落库后调用乐刷退款接口。 -func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (*RefundDTO, error) { - var originalPayment model.PaymentOrder - if err := r.db.WithContext(ctx).Where("order_id = ? AND status = 'paid' AND biz_type = 'order_pay'", orderID).Order("id DESC").First(&originalPayment).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, ErrPaymentNotFound - } - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, &originalPayment) - if err != nil { - return nil, ErrPaymentUnavailable - } - - var existingRefund model.PaymentOrder - err = r.db.WithContext(ctx).Where("order_id = ? AND biz_type = ? AND status NOT IN ('failed')", orderID, bizType).Order("id DESC").First(&existingRefund).Error - if err == nil { - dto := toRefundDTO(existingRefund) - return &dto, nil - } - if err != gorm.ErrRecordNotFound { - return nil, err - } - - paymentNo, err := newPaymentNo() - if err != nil { - return nil, err - } - merchantRefundID := "REF" + paymentNo[3:] - - refundOrder := model.PaymentOrder{ - PaymentNo: paymentNo, - OrderID: orderID, - OrderNo: originalPayment.OrderNo, - UserID: originalPayment.UserID, - Provider: runtimeConfig.Provider, - MerchantID: runtimeConfig.MerchantID, - ThirdOrderID: merchantRefundID, - ProviderOrderID: "", - PayWay: originalPayment.PayWay, - JSPayFlag: originalPayment.JSPayFlag, - AmountCent: refundAmountCent, - BizType: bizType, - Status: "refunding", - } - - if runtimeConfig.isMockMode() { - refundOrder.ProviderOrderID = "MOCKREF" + merchantRefundID - refundOrder.Status = "refunded" - now := time.Now() - refundOrder.PaidAt = &now - if remark != "" { - refundOrder.RawResponse = datatypes.JSON([]byte(fmt.Sprintf(`{"mock":"true","remark":"%s"}`, remark))) - } - if err := r.db.WithContext(ctx).Create(&refundOrder).Error; err != nil { - return nil, err - } - r.recordConfigUsage(ctx, runtimeConfig, &refundOrder) - if err := r.updateOrderRefundStatus(ctx, orderID, refundAmountCent); err != nil { - log.Printf("[payment] mock update order refund status failed order_id=%d err=%v", orderID, err) - } - dto := toRefundDTO(refundOrder) - return &dto, nil - } - - if err := r.db.WithContext(ctx).Create(&refundOrder).Error; err != nil { - return nil, err - } - log.Printf("[payment] refund start order_id=%d order_no=%s payment_id=%d biz_type=%s provider=%s amount_cent=%d merchant_refund_id=%s origin_third_order_id=%s origin_provider_order_id=%s", - orderID, originalPayment.OrderNo, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, merchantRefundID, originalPayment.ThirdOrderID, refundOriginProviderOrderID(originalPayment)) - r.recordConfigUsage(ctx, runtimeConfig, &refundOrder) - if err := r.markOrderRefunding(ctx, orderID, refundAmountCent); err != nil { - log.Printf("[payment] mark order refunding failed order_id=%d err=%v", orderID, err) - } - - if runtimeConfig.Channel == nil { - _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": "payment channel unavailable"}) - return nil, ErrPaymentUnavailable - } - resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ - ThirdOrderID: originalPayment.ThirdOrderID, - ProviderOrderID: refundOriginProviderOrderID(originalPayment), - MerchantRefundID: merchantRefundID, - RefundAmountCent: refundAmountCent, - NotifyURL: runtimeConfig.NotifyURL, - Attach: originalPayment.OrderNo, - Remark: remark, - }) - if err != nil { - _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, map[string]string{"error": err.Error()}) - log.Printf("[payment] refund request failed order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d err=%v", - orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, err) - return nil, err - } - if !resp.OK { - _ = r.markRefundFailed(ctx, refundOrder.ID, orderID, refundAmountCent, resp.Raw) - log.Printf("[payment] refund rejected order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d code=%s message=%s", - orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage) - return nil, ErrPaymentUnavailable - } - - refundStatus := "refunding" - var paidAt *time.Time - if resp.Status == "refunded" { - refundStatus = "refunded" - now := time.Now() - paidAt = &now - } else if resp.Status == "failed" { - refundStatus = "failed" - } - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", refundOrder.ID).Updates(map[string]any{ - "status": refundStatus, - "provider_order_id": resp.ProviderRefundID, - "raw_request": jsonMap(resp.RawRequest), - "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)), - "paid_at": paidAt, - }).Error; err != nil { - return nil, err - } - - if refundStatus == "refunded" { - _ = r.updateOrderRefundStatus(ctx, orderID, refundAmountCent) - refundOrder.PaidAt = paidAt - } else if refundStatus == "failed" { - _ = r.markOrderRefundFailed(ctx, orderID, refundAmountCent) - } else { - _ = r.markOrderRefunding(ctx, orderID, refundAmountCent) - } - refundOrder.Status = refundStatus - refundOrder.ProviderOrderID = resp.ProviderRefundID - log.Printf("[payment] refund result order_id=%d payment_id=%d biz_type=%s provider=%s amount_cent=%d status=%s provider_refund_id=%s", - orderID, refundOrder.ID, bizType, runtimeConfig.Provider, refundAmountCent, refundStatus, resp.ProviderRefundID) - - dto := toRefundDTO(refundOrder) - return &dto, nil -} - -// QueryRefundStatus 查询订单最近一笔退款状态。 -func (r *Repository) QueryRefundStatus(ctx context.Context, orderID uint64) (*RefundDTO, error) { - var payment model.PaymentOrder - if err := r.db.WithContext(ctx).Where("order_id = ? AND biz_type IN ?", orderID, refundBizTypes).Order("id DESC").First(&payment).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, ErrPaymentNotFound - } - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) - if err != nil { - return nil, ErrPaymentUnavailable - } - if payment.Status == "refunded" || payment.Status == "failed" || runtimeConfig.isMockMode() { - dto := toRefundDTO(payment) - return &dto, nil - } - if runtimeConfig.Channel == nil { - return nil, ErrPaymentUnavailable - } - resp, err := runtimeConfig.Channel.QueryRefund(ctx, channelQueryRefundRequest{ - ThirdOrderID: payment.ThirdOrderID, - MerchantRefundID: payment.ThirdOrderID, - ProviderRefundID: payment.ProviderOrderID, - }) - if err != nil { - return nil, err - } - if resp.Status == "refunded" { - now := time.Now() - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "refunded", - "paid_at": now, - "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), - }).Error; err != nil { - return nil, err - } - payment.Status = "refunded" - payment.PaidAt = &now - _ = r.updateOrderRefundStatus(ctx, orderID, payment.AmountCent) - } else if resp.Status == "failed" { - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "failed", - "raw_response": jsonMap(withRawSource(resp.Raw, channelSourceQuery)), - }).Error; err != nil { - return nil, err - } - payment.Status = "failed" - _ = r.markOrderRefundFailed(ctx, orderID, payment.AmountCent) - } - dto := toRefundDTO(payment) - return &dto, nil -} - -func (r *Repository) AdminList(ctx context.Context, query AdminPaymentQuery) (*PaginatedResult, error) { - db := r.db.WithContext(ctx).Table("payment_orders AS p"). - Select("p.*, COALESCE(u.phone, '') AS user_phone"). - Joins("LEFT JOIN users AS u ON u.id = p.user_id") - countDB := r.db.WithContext(ctx).Model(&model.PaymentOrder{}) - if query.UserID > 0 { - db = db.Where("p.user_id = ?", query.UserID) - countDB = countDB.Where("user_id = ?", query.UserID) - } - if query.OrderID > 0 { - db = db.Where("p.order_id = ?", query.OrderID) - countDB = countDB.Where("order_id = ?", query.OrderID) - } - if query.OrderNo != "" { - db = db.Where("p.order_no = ?", query.OrderNo) - countDB = countDB.Where("order_no = ?", query.OrderNo) - } - if query.BizType != "" { - db = db.Where("p.biz_type = ?", query.BizType) - countDB = countDB.Where("biz_type = ?", query.BizType) - } - if query.Status != "" { - db = db.Where("p.status = ?", query.Status) - countDB = countDB.Where("status = ?", query.Status) - } - if query.Provider != "" { - db = db.Where("p.provider = ?", query.Provider) - countDB = countDB.Where("provider = ?", query.Provider) - } - var total int64 - if err := countDB.Count(&total).Error; err != nil { - return nil, err - } - offset := (query.Page - 1) * query.PageSize - var rows []adminPaymentRow - if err := db.Order("p.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { - return nil, err - } - items := make([]AdminPaymentDTO, 0, len(rows)) - for _, row := range rows { - items = append(items, row.toDTO()) - } - return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil -} - -// HandleRefundNotify 处理渠道退款通知。 -func (r *Repository) HandleRefundNotify(ctx context.Context, provider string, params map[string]string, rawPayload string, contentType string, authorization string) (*NotifyResult, error) { - payment, err := r.findRefundPaymentForNotify(ctx, params) - if err != nil { - return nil, err - } - runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) - if err != nil { - return nil, ErrPaymentUnavailable - } - verify, err := r.verifyNotify(ctx, payment, runtimeConfig, params, rawPayload, contentType, authorization) - if err != nil { - return nil, err - } - - raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") - status := normalizeNotifyRefundStatus(provider, params["status"]) - switch status { - case "refunded": - now := time.Now() - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "refunded", - "paid_at": now, - "notified_at": now, - "raw_response": jsonMap(raw), - }).Error; err != nil { - return nil, err - } - _ = r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent) - case "failed": - r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "failed", - "notified_at": time.Now(), - "raw_response": jsonMap(raw), - }) - _ = r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent) - default: - r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{ - "status": "refunding", - "raw_response": jsonMap(raw), - }) - } - return &NotifyResult{OK: true, Message: "000000"}, nil -} - -// updateOrderRefundStatus 更新订单退款成功状态。 -func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error { - now := time.Now() - return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ - "refund_status": "refunded", - "refund_amount_cent": refundAmountCent, - "refunded_at": now, - }).Error -} - -func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error { - return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ - "refund_status": "refunding", - "refund_amount_cent": refundAmountCent, - "refunded_at": nil, - }).Error -} - -func (r *Repository) markOrderRefundFailed(ctx context.Context, orderID uint64, refundAmountCent int64) error { - return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{ - "refund_status": "failed", - "refund_amount_cent": refundAmountCent, - "refunded_at": nil, - }).Error -} - -func (r *Repository) markRefundFailed(ctx context.Context, paymentID uint64, orderID uint64, refundAmountCent int64, raw map[string]string) error { - if raw == nil { - raw = map[string]string{"error": "refund failed"} - } - if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ - "status": "failed", - "raw_response": jsonMap(raw), - }).Error; err != nil { - return err - } - return r.markOrderRefundFailed(ctx, orderID, refundAmountCent) -} - -// toRefundDTO 将支付表里的退款单转换为接口 DTO。 -func toRefundDTO(payment model.PaymentOrder) RefundDTO { - return RefundDTO{ - ID: payment.ID, - PaymentNo: payment.PaymentNo, - OrderID: payment.OrderID, - OrderNo: payment.OrderNo, - BizType: payment.BizType, - AmountCent: payment.AmountCent, - Status: payment.Status, - ProviderOrderID: payment.ProviderOrderID, - PaidAt: payment.PaidAt, - CreatedAt: payment.CreatedAt, - UpdatedAt: payment.UpdatedAt, - } -} - -func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) { - var paymentID uint64 - var orderRow model.RentalOrder - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var row model.RentalOrder - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ? AND renter_id = ?", orderID, userID). - First(&row).Error; err != nil { - return err - } - if row.Status != "pending_payment" { - return ErrPaymentCannotStart - } - amountCent := row.RentAmountCent + row.DepositAmountCent - if amountCent <= 0 { - return ErrPaymentCannotStart - } - var existing model.PaymentOrder - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("order_id = ? AND biz_type = ?", row.ID, "order_pay"). - Order("id DESC"). - First(&existing).Error - if err == nil { - if canReuseOrderPayment(existing, runtimeConfig) { - existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") - existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") - existing.AmountCent = amountCent - existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider) - existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID) - if existing.Provider == "mock" && existing.ProviderOrderID == "" { - existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID - } - if err := tx.Save(&existing).Error; err != nil { - return err - } - paymentID = existing.ID - orderRow = row - return nil - } - } else if err != gorm.ErrRecordNotFound { - return err - } - payment, err := newOrderPayment(row, amountCent, req, runtimeConfig) - if err != nil { - return err - } - if err := tx.Create(&payment).Error; err != nil { - return err - } - paymentID = payment.ID - orderRow = row - return nil - }) - if err != nil { - return nil, nil, err - } - payment, err := r.findPaymentByID(ctx, paymentID) - if err != nil { - return nil, nil, err - } - return payment, &orderRow, nil -} - -func canReuseOrderPayment(payment model.PaymentOrder, runtimeConfig runtimePaymentConfig) bool { - if payment.Status == "paid" { - return true - } - if payment.Status != "created" && payment.Status != "paying" { - return false - } - if payment.Provider != "" && runtimeConfig.Provider != "" && payment.Provider != runtimeConfig.Provider { - return false - } - if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID { - return false - } - if payment.Status == "paying" && paymentCashierExpired(payment) { - return false - } - return true -} - -func paymentCashierExpired(payment model.PaymentOrder) bool { - if len(payment.RawRequest) == 0 { - return false - } - var raw map[string]string - if err := json.Unmarshal(payment.RawRequest, &raw); err != nil { - return false - } - deadline := parseChannelTime(raw["order_efficient_time"]) - if deadline == nil { - return false - } - return !timeutil.ShanghaiNow().Before(*deadline) -} - -func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) { - paymentNo, err := newPaymentNo() - if err != nil { - return model.PaymentOrder{}, err - } - payment := model.PaymentOrder{ - PaymentNo: paymentNo, - OrderID: row.ID, - OrderNo: row.OrderNo, - UserID: row.RenterID, - Provider: runtimeConfig.Provider, - MerchantID: runtimeConfig.MerchantID, - ThirdOrderID: paymentNo, - ProviderOrderID: "", - PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), - JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), - AmountCent: amountCent, - BizType: "order_pay", - Status: "created", - } - if runtimeConfig.isMockMode() { - payment.ProviderOrderID = "MOCK" + paymentNo - payment.TDCode = "mock://payment/pay/" + paymentNo - } - return payment, nil -} - -func (r *Repository) createWalletRechargePayment(ctx context.Context, userID uint64, amountCent int64, req WalletRechargePaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, error) { - paymentNo, err := newPaymentNo() - if err != nil { - return nil, err - } - payment := model.PaymentOrder{ - PaymentNo: paymentNo, - OrderID: 0, - OrderNo: paymentNo, - UserID: userID, - Provider: runtimeConfig.Provider, - MerchantID: runtimeConfig.MerchantID, - ThirdOrderID: paymentNo, - ProviderOrderID: "", - PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), - JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), - AmountCent: amountCent, - BizType: "wallet_recharge", - Status: "created", - } - if runtimeConfig.isMockMode() { - payment.ProviderOrderID = "MOCK" + paymentNo - payment.TDCode = "mock://payment/recharge/" + paymentNo - } - if err := r.db.WithContext(ctx).Create(&payment).Error; err != nil { - return nil, err - } - return &payment, nil -} - -func (r *Repository) applyChannelStatus(ctx context.Context, payment *model.PaymentOrder, status string, payTime string, raw map[string]string, source string) error { - switch status { - case "paid": - paidAt := parseChannelTime(payTime) - if paidAt == nil { - now := time.Now() - paidAt = &now - } - return r.confirmPaid(ctx, payment, status, *paidAt, raw, source) - case "closed": - return r.updateChannelStatus(ctx, payment.ID, "closed", raw, source) - case "failed": - return r.updateChannelStatus(ctx, payment.ID, "failed", raw, source) - default: - return r.updateChannelStatus(ctx, payment.ID, "paying", raw, source) - } -} - -func (r *Repository) updateChannelStatus(ctx context.Context, paymentID uint64, status string, raw map[string]string, source string) error { - updates := map[string]any{ - "status": status, - "raw_response": jsonMap(withRawSource(raw, source)), - } - if source == channelSourceNotify { - updates["notified_at"] = time.Now() - } - return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error -} - -func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error { - if payment.Status != "paid" { - if payment.OrderID == 0 { - if r.walletRepo == nil { - return ErrDependencyUnavailable - } - if err := r.walletRepo.ConfirmRechargeFromChannel(ctx, payment.UserID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo), payment.AmountCent); err != nil { - return err - } - } else { - if r.orderRepo == nil { - return ErrDependencyUnavailable - } - if err := r.orderRepo.ConfirmPaidFromChannel(ctx, payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil { - return err - } - } - } - updates := map[string]any{ - "status": "paid", - "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], payment.ProviderOrderID), - "raw_response": jsonMap(withRawSource(raw, source)), - "paid_at": paidAt, - } - if source == channelSourceNotify { - updates["notified_at"] = time.Now() - } - return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error -} - -func (r *Repository) markPaymentFailed(ctx context.Context, paymentID uint64, raw map[string]string, message string) error { - if raw == nil { - raw = map[string]string{"error": message} - } - return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(map[string]any{ - "status": "failed", - "raw_response": jsonMap(raw), - }).Error -} - -func (r *Repository) findPaymentByID(ctx context.Context, paymentID uint64) (*model.PaymentOrder, error) { - var payment model.PaymentOrder - if err := r.db.WithContext(ctx).First(&payment, paymentID).Error; err != nil { - return nil, err - } - return &payment, nil -} - -func (r *Repository) findPaymentForNotify(ctx context.Context, params map[string]string) (*model.PaymentOrder, error) { - thirdOrderID := params["third_order_id"] - if thirdOrderID == "" { - return nil, ErrPaymentNotFound - } - var payment model.PaymentOrder - if err := r.db.WithContext(ctx).Where("third_order_id = ?", thirdOrderID).First(&payment).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, ErrPaymentNotFound - } - return nil, err - } - return &payment, nil -} - -func (r *Repository) findRefundPaymentForNotify(ctx context.Context, params map[string]string) (*model.PaymentOrder, error) { - merchantRefundID := params["merchant_refund_id"] - if merchantRefundID == "" { - return nil, ErrPaymentNotFound - } - var payment model.PaymentOrder - if err := r.db.WithContext(ctx).Where("third_order_id = ?", merchantRefundID).First(&payment).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, ErrPaymentNotFound - } - return nil, err - } - return &payment, nil -} - -func (r *Repository) verifyNotify(ctx context.Context, payment *model.PaymentOrder, runtimeConfig *runtimePaymentConfig, params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { - var verify channelVerifyNotifyResult - if runtimeConfig.isMockMode() { - return verify, nil - } - if runtimeConfig.Channel == nil { - return verify, ErrPaymentUnavailable - } - verify, err := runtimeConfig.Channel.VerifyNotify(params, rawPayload, contentType, authorization) - if err != nil || !verify.OK { - log.Printf( - "[payment] %s notify verify failed payment_id=%d third_order_id=%s got=%s expected=%s keys=%v base_string=%s", - runtimeConfig.Provider, - payment.ID, - params["third_order_id"], - verify.Got, - firstNonEmpty(verify.Expected["notify_key"], verify.Expected["notify_cert"], verify.Expected["error"]), - verify.ParamKeys, - firstNonEmpty(verify.BaseString["notify_key"], verify.BaseString["notify_cert"]), - ) - if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "verify_failed"); err != nil { - log.Printf("[payment] %s notify diagnostic save failed payment_id=%d err=%v", runtimeConfig.Provider, payment.ID, err) - } - return verify, ErrPaymentVerifyFailed - } - log.Printf("[payment] %s notify verified payment_id=%d third_order_id=%s matched_key=%s", runtimeConfig.Provider, payment.ID, params["third_order_id"], verify.MatchedKey) - return verify, nil -} - func (r *Repository) recordConfigUsage(ctx context.Context, runtimeConfig *runtimePaymentConfig, payment *model.PaymentOrder) { if r.configRepo == nil || runtimeConfig == nil || payment == nil || runtimeConfig.ID == 0 { return @@ -1049,234 +128,3 @@ func (r *Repository) recordConfigUsage(ctx context.Context, runtimeConfig *runti log.Printf("[payment] record config usage failed config_id=%d payment_id=%d err=%v", runtimeConfig.ID, payment.ID, err) } } - -func toDTO(payment model.PaymentOrder) PaymentDTO { - return PaymentDTO{ - ID: payment.ID, - PaymentNo: payment.PaymentNo, - OrderID: payment.OrderID, - OrderNo: payment.OrderNo, - Provider: payment.Provider, - ThirdOrderID: payment.ThirdOrderID, - ProviderOrderID: payment.ProviderOrderID, - PayWay: payment.PayWay, - JSPayFlag: payment.JSPayFlag, - AmountCent: payment.AmountCent, - Status: payment.Status, - TDCode: payment.TDCode, - JSPayURL: payment.JSPayURL, - JSPayInfo: payment.JSPayInfo, - Paid: payment.Status == "paid", - PaidAt: payment.PaidAt, - CreatedAt: payment.CreatedAt, - UpdatedAt: payment.UpdatedAt, - } -} - -type adminPaymentRow struct { - model.PaymentOrder - UserPhone string -} - -func (row adminPaymentRow) toDTO() AdminPaymentDTO { - errorCode, errorMessage := paymentErrorSummary(row.Status, row.RawResponse) - return AdminPaymentDTO{ - ID: row.ID, - PaymentNo: row.PaymentNo, - OrderID: row.OrderID, - OrderNo: row.OrderNo, - UserID: row.UserID, - UserPhone: row.UserPhone, - Provider: row.Provider, - MerchantID: row.MerchantID, - ThirdOrderID: row.ThirdOrderID, - ProviderOrderID: row.ProviderOrderID, - PayWay: row.PayWay, - AmountCent: row.AmountCent, - BizType: row.BizType, - Status: row.Status, - ErrorCode: errorCode, - ErrorMessage: errorMessage, - RawRequest: row.RawRequest, - RawResponse: row.RawResponse, - PaidAt: row.PaidAt, - NotifiedAt: row.NotifiedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func paymentErrorSummary(status string, raw datatypes.JSON) (string, string) { - if status != "failed" { - return "", "" - } - if len(raw) == 0 { - return "", "" - } - var payload map[string]any - if err := json.Unmarshal(raw, &payload); err != nil { - return "", "" - } - code := firstStringValue(payload, "code", "resp_code", "result_code", "error_code", "status") - message := firstStringValue(payload, "msg", "message", "error", "error_message", "result_msg", "result_desc") - return code, message -} - -func moneyCent(value float64) int64 { - return int64(math.Round(value * 100)) -} - -func parseCent(value string) int64 { - var amount int64 - _, _ = fmt.Sscanf(value, "%d", &amount) - return amount -} - -func refundOriginProviderOrderID(payment model.PaymentOrder) string { - if payment.Provider != "lakala" { - return payment.ProviderOrderID - } - if tradeID := lakalaOriginTradeID(payment.RawResponse); tradeID != "" { - return tradeID - } - return payment.ProviderOrderID -} - -func lakalaOriginTradeID(raw datatypes.JSON) string { - if len(raw) == 0 { - return "" - } - var payload map[string]any - if err := json.Unmarshal(raw, &payload); err != nil { - return "" - } - if tradeID := firstStringValue(payload, "trade_no", "origin_trade_no"); tradeID != "" { - return tradeID - } - value, ok := payload["order_trade_info_list"] - if !ok { - return "" - } - switch typed := value.(type) { - case string: - var items []map[string]any - if err := json.Unmarshal([]byte(typed), &items); err != nil { - return "" - } - for _, item := range items { - if tradeID := firstStringValue(item, "trade_no", "origin_trade_no"); tradeID != "" { - return tradeID - } - } - case []any: - for _, item := range typed { - itemMap, ok := item.(map[string]any) - if !ok { - continue - } - if tradeID := firstStringValue(itemMap, "trade_no", "origin_trade_no"); tradeID != "" { - return tradeID - } - } - } - return "" -} - -func firstStringValue(values map[string]any, keys ...string) string { - for _, key := range keys { - value, ok := values[key] - if !ok { - continue - } - if text, ok := value.(string); ok && text != "" { - return text - } - } - return "" -} - -func parseChannelTime(value string) *time.Time { - if value == "" { - return nil - } - for _, layout := range []string{"2006-01-02 15:04:05", "20060102150405", time.RFC3339} { - parsed, err := time.ParseInLocation(layout, value, timeutil.ShanghaiLocation()) - if err == nil { - return &parsed - } - } - return nil -} - -func jsonMap(value map[string]string) datatypes.JSON { - if value == nil { - return nil - } - raw, err := json.Marshal(value) - if err != nil { - return nil - } - return datatypes.JSON(raw) -} - -func withRawSource(raw map[string]string, source string) map[string]string { - out := map[string]string{} - for key, value := range raw { - out[key] = value - } - if source != "" { - out["_source"] = source - } - out["_recorded_at"] = time.Now().Format(time.RFC3339) - return out -} - -func (r *Repository) recordNotifyDiagnostic(ctx context.Context, paymentID uint64, params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) error { - if paymentID == 0 { - return nil - } - raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, status) - return r.db.WithContext(ctx).Model(&model.PaymentOrder{}). - Where("id = ?", paymentID). - Update("raw_response", jsonMap(raw)).Error -} - -func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify channelVerifyNotifyResult, status string) map[string]string { - raw := withRawSource(params, channelSourceNotify) - raw["_notify_diagnostic_status"] = status - raw["_raw_payload"] = rawPayload - raw["_raw_content_type"] = contentType - raw["_sign_got"] = verify.Got - raw["_sign_matched_key"] = verify.MatchedKey - raw["_sign_expected"] = jsonString(verify.Expected) - raw["_sign_base_strings"] = jsonString(verify.BaseString) - return raw -} - -func jsonString(value map[string]string) string { - if len(value) == 0 { - return "{}" - } - raw, err := json.Marshal(value) - if err != nil { - return "{}" - } - return string(raw) -} - -func newPaymentNo() (string, error) { - buf := make([]byte, 4) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return fmt.Sprintf("PAY%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index d86436a..7cc4814 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -69,12 +69,12 @@ func TestCannotReusePaymentWithDifferentProvider(t *testing.T) { // TestPaymentStatusTransitions 测试支付单状态转换 func TestPaymentStatusTransitionsValid(t *testing.T) { validTransitions := map[string][]string{ - "pending": {"paying", "closed"}, - "paying": {"paid", "failed", "closed"}, - "paid": {"refunding", "refunded"}, - "failed": {}, // 终态 - "closed": {}, // 终态 - "refunded": {}, // 终态 + "pending": {"paying", "closed"}, + "paying": {"paid", "failed", "closed"}, + "paid": {"refunding", "refunded"}, + "failed": {}, // 终态 + "closed": {}, // 终态 + "refunded": {}, // 终态 } for from, toList := range validTransitions {