拆分大型 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
Reference in New Issue
Block a user