拆分大型 Repository 文件职责

This commit is contained in:
yml2213
2026-06-10 14:28:16 +08:00
parent 6ae8f0e830
commit 60ff513463
23 changed files with 3977 additions and 3906 deletions
@@ -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
}
+185
View File
@@ -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
})
}
@@ -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
}
@@ -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
}
@@ -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
}
File diff suppressed because it is too large Load Diff
+215
View File
@@ -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
}
+19
View File
@@ -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,
})
}
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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"`
}
+168
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -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
}
@@ -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
}
+163
View File
@@ -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
}
@@ -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
}
@@ -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 ""
}
+158
View File
@@ -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,
}
}
+310
View File
@@ -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 ""
}
File diff suppressed because it is too large Load Diff
@@ -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 {