合并 main 分支并融合订单列表最小化与 SQL 治理

- Auth/权限中间件取 main 演进版(错误细分、token 来源),保留 dev 的用户 cookie 与登出撤销语义
- 后台订单列表:融合 main 的首页免 COUNT 优化与关键词索引路由,保留 dev 的最小化 AdminOrderListItemDTO(结账明细仍由详情接口返回,同步调整对应集成测试)
- 保留 dev 的 token_version 冻结/解冻递增与 main 的状态变化判断及通知
- 前端:保留 dev 登出接口与 main 的 fetchMe silent 参数、SSE 登录跳转;后台订单表按 dev 最小化 DTO 移除号主到手列
This commit is contained in:
yml2213
2026-09-05 11:43:13 +08:00
294 changed files with 30171 additions and 13439 deletions
+208 -110
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"gorm.io/gorm"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
"strings"
"time"
)
@@ -33,15 +34,17 @@ type AdminConversationCountsDTO struct {
}
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 {
// 验证当前操作者是会话参与者
current, err := r.findParticipant(tx, principal, conversationID, false)
if err != nil {
return err
}
if current.Role != "support" || current.ParticipantType != "admin" {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if principal.Type != "admin" {
return ErrPermissionDenied
}
var conversation model.ChatConversation
if err := tx.First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
// 验证目标客服存在、活跃且拥有客服角色,避免转接给超级管理员。
if !adminIsSupport(tx, toAdminID) {
return fmt.Errorf("目标客服不存在、已禁用或不是客服角色")
@@ -56,29 +59,45 @@ func (r *Repository) TransferConversation(ctx context.Context, principal Princip
if count > 0 {
return fmt.Errorf("该客服已在会话中")
}
// 只转接当前客服本人,避免误删同群里的收号组/卖号组其他客服。
if err := tx.Model(&model.ChatParticipant{}).
Where("id = ?", current.ID).
Updates(map[string]interface{}{
var current model.ChatParticipant
currentErr := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", principal.ID).
First(&current).Error
if currentErr == nil {
if current.Role != "support" {
return ErrPermissionDenied
}
// 只转接当前客服本人,避免误删同群里的其他客服。
if err := tx.Model(&current).Updates(map[string]interface{}{
"participant_id": toAdminID,
"joined_at": time.Now(),
"last_read_at": nil,
}).Error; err != nil {
return err
return err
}
} else if errors.Is(currentErr, gorm.ErrRecordNotFound) {
// 未分配会话允许从“全部/未分配”直接指派给目标客服。
if err := tx.Create(&model.ChatParticipant{
ConversationID: conversationID,
ParticipantType: "admin",
ParticipantID: toAdminID,
Role: "support",
JoinedAt: time.Now(),
}).Error; err != nil {
return err
}
} else {
return currentErr
}
// 添加系统消息记录转接
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: "会话已转接给其他客服",
AttachmentURLS: emptyJSONList(),
}
if err := tx.Create(&message).Error; err != nil {
if err := sendSystemMessage(tx, conversationID, "会话已转接给其他客服"); err != nil {
return err
}
return nil
})
if err == nil && r.hub != nil {
r.hub.NotifyConversation(conversationID, &chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
r.hub.NotifyAllAdmins(&chathub.ChatEvent{Type: "conversation_updated", ConversationID: conversationID})
}
return err
}
func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdminDTO, error) {
db := r.db.WithContext(ctx)
@@ -202,11 +221,8 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
}
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
var total int64
countDB := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(countDB, filter, principal)
applyAdminChatStageFilter(countDB, stage, principal)
if err := countDB.Count(&total).Error; err != nil {
total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword)
if err != nil {
return nil, err
}
@@ -216,16 +232,21 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id,
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
COALESCE(cp_me.role, 'admin') AS role,
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
COALESCE(cas.remark, '') AS admin_remark,
COALESCE(explicit_lo.id, c.latest_order_id) AS latest_order_id,
COALESCE(explicit_lo.order_no, c.latest_order_no) AS latest_order_no,
COALESCE(explicit_lo.status, c.latest_order_status) AS latest_order_status,
COALESCE(explicit_lo.handoff_status, c.latest_order_handoff_status) AS latest_handoff_status,
COALESCE(explicit_lo.refund_status, c.latest_order_refund_status) AS latest_refund_status,
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
CASE WHEN c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply,
(
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_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
) END AS unread_count`, principal.Type, principal.ID)
AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user')
AND cm.id > COALESCE(cas.last_read_message_id, 0)
) AS unread_count`)
applyAdminChatOwnershipFilter(queryDB, filter, principal)
applyAdminChatStageFilter(queryDB, stage, principal)
if err := queryDB.
@@ -236,68 +257,127 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
return nil, err
}
items := make([]ConversationDTO, 0, len(rows))
ids := make([]uint64, 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))
ids = append(ids, row.ID)
}
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
participantsByConversation, err := r.participantsForConversations(ctx, ids)
if err != nil {
return nil, err
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
items := make([]ConversationDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO(participantsByConversation[row.ID]))
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
filter = normalizeAdminChatFilter(filter)
stage = normalizeAdminChatStage(stage)
keyword = strings.TrimSpace(keyword)
if cached := r.loadAdminChatCountsCache(ctx, principal, filter, stage, keyword); cached != nil {
return cached, nil
}
result, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
if err != nil {
return nil, err
}
r.storeAdminChatCountsCache(ctx, principal, filter, stage, keyword, result.DTO)
return result.DTO, nil
}
func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) {
db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword))
applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal)
applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal)
var total int64
if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
Joins("LEFT JOIN chat_admin_conversation_states AS cas ON cas.conversation_id = c.id AND cas.admin_user_id = ?", principal.ID).
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))").
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)").
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
Joins("LEFT JOIN rental_orders AS explicit_lo ON explicit_lo.id = c.order_id").
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)")
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
OR renter.phone LIKE ? OR owner.phone LIKE ?
OR EXISTS (
SELECT 1 FROM chat_participants AS cp_kw
WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ?
)`, like, like, like, like, like, like, like)
upper := strings.ToUpper(keyword)
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)").
Where("(renter.phone = ? OR owner.phone = ?)", keyword, keyword)
} else if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
db = db.Where("(explicit_lo.order_no = ? OR c.latest_order_no = ?)", keyword, keyword)
} else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) {
db = db.Where("l.listing_no = ?", keyword)
} else {
db = db.Joins("LEFT JOIN users AS renter ON renter.id = explicit_lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, l.owner_id)")
like := "%" + keyword + "%"
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR COALESCE(explicit_lo.order_no, c.latest_order_no) LIKE ? OR l.listing_no LIKE ?
OR renter.phone LIKE ? OR owner.phone LIKE ?
OR EXISTS (
SELECT 1 FROM chat_participants AS cp_kw
WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ?
)`, like, like, like, like, like, like, like)
}
}
return db
}
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
counts := &AdminConversationCountsDTO{
Ownership: make(map[string]int64, len(adminChatFilters)),
Stages: make(map[string]int64, len(adminChatStages)),
}
type adminConversationCountsResult struct {
Total int64
DTO *AdminConversationCountsDTO
}
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*adminConversationCountsResult, error) {
ownership := make(map[string]string, len(adminChatFilters))
ownershipArgs := make(map[string][]interface{}, len(adminChatFilters))
for _, item := range adminChatFilters {
db := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(db, item, principal)
applyAdminChatStageFilter(db, stage, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Ownership[item] = count
ownership[item], ownershipArgs[item] = adminChatOwnershipCondition(item, principal)
}
stages := make(map[string]string, len(adminChatStages))
stageArgs := make(map[string][]interface{}, len(adminChatStages))
for _, item := range adminChatStages {
stages[item], stageArgs[item] = adminChatStageCondition(item, principal)
}
parts := make([]string, 0, 1+len(adminChatFilters)+len(adminChatStages))
args := make([]interface{}, 0)
addCount := func(alias, left, right string, leftArgs, rightArgs []interface{}) {
parts = append(parts, "SUM(CASE WHEN "+left+" AND "+right+" THEN 1 ELSE 0 END) AS "+alias)
args = append(args, leftArgs...)
args = append(args, rightArgs...)
}
addCount("total", ownership[filter], stages[stage], ownershipArgs[filter], stageArgs[stage])
for _, item := range adminChatFilters {
addCount("ownership_"+item, ownership[item], stages[stage], ownershipArgs[item], stageArgs[stage])
}
for _, item := range adminChatStages {
db := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(db, filter, principal)
applyAdminChatStageFilter(db, item, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Stages[item] = count
addCount("stage_"+item, ownership[filter], stages[item], ownershipArgs[filter], stageArgs[item])
}
return counts, nil
type row struct {
Total int64
OwnershipMine, OwnershipAll, OwnershipUnassigned int64
StageAll, StagePending, StageUnjoined, StageHandoff, StageRenting, StageAfterSale, StageEnded int64
}
var result row
if err := r.adminConversationBase(ctx, principal, keyword).Select(strings.Join(parts, ", "), args...).Scan(&result).Error; err != nil {
return nil, err
}
return &adminConversationCountsResult{
Total: result.Total,
DTO: &AdminConversationCountsDTO{
Ownership: map[string]int64{adminChatFilterMine: result.OwnershipMine, adminChatFilterAll: result.OwnershipAll, adminChatFilterUnassigned: result.OwnershipUnassigned},
Stages: map[string]int64{adminChatStageAll: result.StageAll, adminChatStagePending: result.StagePending, adminChatStageUnjoined: result.StageUnjoined, adminChatStageHandoff: result.StageHandoff, adminChatStageRenting: result.StageRenting, adminChatStageAfterSale: result.StageAfterSale, adminChatStageEnded: result.StageEnded},
},
}, nil
}
func normalizeAdminChatFilter(filter string) string {
@@ -319,55 +399,61 @@ func normalizeAdminChatStage(stage string) string {
}
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
switch filter {
case adminChatFilterMine:
db.Where("cp_me.id IS NOT NULL")
case adminChatFilterUnassigned:
db.Where(`NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = ?
AND cp_support.role = ?
)`, "admin", "support")
case adminChatFilterAll:
return
default:
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
}
condition, args := adminChatOwnershipCondition(filter, principal)
db.Where(condition, args...)
}
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
condition, args := adminChatStageCondition(stage, principal)
db.Where(condition, args...)
}
func adminChatOwnershipCondition(filter string, principal Principal) (string, []interface{}) {
switch filter {
case adminChatFilterAll:
return "1 = 1", nil
case adminChatFilterUnassigned:
return `NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = 'admin'
AND cp_support.role = 'support'
)`, nil
case adminChatFilterMine:
fallthrough
default:
return "cp_me.id IS NOT NULL", nil
}
}
func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) {
latestID := "COALESCE(explicit_lo.id, c.latest_order_id)"
latestStatus := "COALESCE(explicit_lo.status, c.latest_order_status)"
latestRefundStatus := "COALESCE(explicit_lo.refund_status, c.latest_order_refund_status)"
switch stage {
case adminChatStagePending:
db.Where(`(
lm.sender_type = ?
OR (
cp_me.id IS NOT NULL
AND EXISTS (
SELECT 1
FROM chat_messages AS cm_pending
WHERE cm_pending.conversation_id = c.id
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
)
)
)`, "user", principal.Type, principal.ID)
return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil
case adminChatStageUnjoined:
db.Where("lo.id IS NULL")
return latestID + " IS NULL", nil
case adminChatStageHandoff:
db.Where("lo.status = ?", "pending_handoff")
return latestStatus + " = 'pending_handoff'", nil
case adminChatStageRenting:
db.Where("lo.status IN ?", []string{"renting", "overdue"})
return latestStatus + " IN ('renting', 'overdue')", nil
case adminChatStageAfterSale:
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))",
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
return "(" + latestStatus + " IN ('pending_checkout_confirm', 'pending_checkout_accept', 'checkout_disputing', 'abnormal') OR (" + latestRefundStatus + " IS NOT NULL AND " + latestRefundStatus + " <> 'none'))", nil
case adminChatStageEnded:
db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
return "(c.status IN ('archived', 'closed') OR " + latestStatus + " IN ('completed', 'cancelled', 'closed'))", nil
case adminChatStageAll:
return
fallthrough
default:
return "1 = 1", nil
}
}
func adminNeedsReplyExpression() string {
return "c.last_attention_message_id > c.last_admin_message_id"
}
// ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。
// 用于客服封存订单时同事务解散群聊;发布群不存在时静默跳过,不阻断封存流程。
// 归档后 SendMessage 的 status 校验会阻断所有成员继续发言。
@@ -394,3 +480,15 @@ func ArchiveListingConversation(tx *gorm.DB, listingID uint64, reason string) er
}
return sendSystemMessage(tx, conversation.ID, content)
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}