拆分大型 Repository 文件职责
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user