合并 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
@@ -0,0 +1,43 @@
package chat
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
const adminChatCountsCacheTTL = 5 * time.Second
func (r *Repository) adminChatCountsCacheKey(principal Principal, filter, stage, keyword string) string {
sum := sha256.Sum256([]byte(keyword))
return fmt.Sprintf("admin-chat:counts:v2:%s:%d:%s:%s:%s", principal.Type, principal.ID, filter, stage, hex.EncodeToString(sum[:8]))
}
func (r *Repository) loadAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string) *AdminConversationCountsDTO {
if r.redis == nil {
return nil
}
raw, err := r.redis.Get(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword)).Bytes()
if err != nil {
return nil
}
var value AdminConversationCountsDTO
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
return &value
}
func (r *Repository) storeAdminChatCountsCache(ctx context.Context, principal Principal, filter, stage, keyword string, value *AdminConversationCountsDTO) {
if r.redis == nil || value == nil {
return
}
raw, err := json.Marshal(value)
if err != nil {
return
}
_ = r.redis.Set(ctx, r.adminChatCountsCacheKey(principal, filter, stage, keyword), raw, adminChatCountsCacheTTL).Err()
}
@@ -0,0 +1,199 @@
package chat
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupAdminListTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.AutoMigrate(
&model.User{},
&model.AdminUser{},
&model.RentalListing{},
&model.RentalOrder{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{},
); err != nil {
t.Fatalf("迁移测试数据库失败: %v", err)
}
return db
}
func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) {
db := setupAdminListTestDB(t)
repo := NewRepository(db, nil, nil)
admin := model.AdminUser{Username: "cs-1", Nickname: "客服一", Status: "active"}
user := model.User{Phone: "13800000001", Nickname: "租客"}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("创建管理员失败: %v", err)
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
listing := model.RentalListing{ListingNo: "L202608250001", OwnerID: user.ID, AccountID: 1, Status: "active"}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
order := model.RentalOrder{OrderNo: "RO-CHAT-1", ListingID: listing.ID, AccountID: 1, OwnerID: user.ID, RenterID: user.ID, Status: "renting"}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
conversation := model.ChatConversation{OrderID: &order.ID, Type: ConversationTypeOrderGroup, Title: "订单群", Status: "active"}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建会话失败: %v", err)
}
now := time.Now()
if err := db.Create(&[]model.ChatParticipant{
{ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: user.ID, Role: "renter", JoinedAt: now},
{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: now},
}).Error; err != nil {
t.Fatalf("创建参与人失败: %v", err)
}
system := model.ChatMessage{ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "欢迎", CreatedAt: now}
userMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "user", SenderID: user.ID, SenderRole: "renter", ContentType: "text", Content: "请问?", CreatedAt: now.Add(time.Second)}
if err := db.Create(&[]model.ChatMessage{system, userMessage}).Error; err != nil {
t.Fatalf("创建消息失败: %v", err)
}
if err := db.Model(&conversation).Updates(map[string]interface{}{
"last_message_id": userMessage.ID,
"last_message_preview": userMessage.Content,
"last_message_at": userMessage.CreatedAt,
}).Error; err != nil {
t.Fatalf("更新会话摘要失败: %v", err)
}
if err := db.Create(&model.ChatAdminConversationState{
ConversationID: conversation.ID,
AdminUserID: admin.ID,
Remark: "客户一",
LastReadMessageID: system.ID,
LastReadAt: &system.CreatedAt,
}).Error; err != nil {
t.Fatalf("创建管理员状态失败: %v", err)
}
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageAll, "")
if err != nil {
t.Fatalf("查询后台会话失败: %v", err)
}
if result.Total != 1 || len(result.Items.([]ConversationDTO)) != 1 {
t.Fatalf("会话数量 = total %d/items %d, want 1/1", result.Total, len(result.Items.([]ConversationDTO)))
}
item := result.Items.([]ConversationDTO)[0]
if item.AdminRemark != "客户一" {
t.Fatalf("管理员备注 = %q, want 客户一", item.AdminRemark)
}
if item.UnreadCount != 1 {
t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount)
}
if item.NeedsReply {
t.Fatal("订单群用户消息不应因未回复停留在待处理")
}
counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "")
if err != nil {
t.Fatalf("查询客服统计失败: %v", err)
}
if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 {
t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages)
}
if err := db.Model(&conversation).Update("type", ConversationTypeGeneralSupport).Error; err != nil {
t.Fatalf("切换咨询会话类型失败: %v", err)
}
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "")
if err != nil {
t.Fatalf("查询咨询待处理会话失败: %v", err)
}
if result.Total != 1 {
t.Fatalf("咨询未回复待处理数量 = %d, want 1", result.Total)
}
adminMessage := model.ChatMessage{ConversationID: conversation.ID, SenderType: "admin", SenderID: admin.ID, SenderRole: "support", ContentType: "text", Content: "客服回复", CreatedAt: now.Add(2 * time.Second)}
if err := db.Create(&adminMessage).Error; err != nil {
t.Fatalf("创建客服回复失败: %v", err)
}
if err := db.Model(&conversation).Updates(map[string]interface{}{
"last_message_id": adminMessage.ID,
"last_message_preview": adminMessage.Content,
"last_message_at": adminMessage.CreatedAt,
}).Error; err != nil {
t.Fatalf("更新客服回复摘要失败: %v", err)
}
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStagePending, "")
if err != nil {
t.Fatalf("查询已回复待处理会话失败: %v", err)
}
if result.Total != 0 {
t.Fatalf("客服回复后待处理数量 = %d, want 0", result.Total)
}
}
func TestListAdminConversationsUsesListingOrderSnapshot(t *testing.T) {
db := setupAdminListTestDB(t)
repo := NewRepository(db, nil, nil)
admin := model.AdminUser{Username: "cs-snapshot", Nickname: "客服快照", Status: "active"}
owner := model.User{Phone: "13800000011", Nickname: "号主"}
renter := model.User{Phone: "13800000012", Nickname: "租客"}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("创建客服失败: %v", err)
}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建号主失败: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("创建租客失败: %v", err)
}
listing := model.RentalListing{ListingNo: "L202608250002", OwnerID: owner.ID, AccountID: 2, Status: "active"}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
conversation := model.ChatConversation{ListingID: &listing.ID, Type: ConversationTypeListingGroup, Title: "发布群", Status: "active"}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建发布群失败: %v", err)
}
if err := db.Create(&model.ChatParticipant{ConversationID: conversation.ID, ParticipantType: "admin", ParticipantID: admin.ID, Role: "support", JoinedAt: time.Now()}).Error; err != nil {
t.Fatalf("创建客服成员失败: %v", err)
}
order := model.RentalOrder{
OrderNo: "RO-SNAPSHOT-1", ListingID: listing.ID, AccountID: listing.AccountID,
OwnerID: owner.ID, RenterID: renter.ID, Status: "pending_handoff", HandoffStatus: "pending_owner", RefundStatus: "none",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
result, err := repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageHandoff, "")
if err != nil {
t.Fatalf("按交接阶段查询发布群失败: %v", err)
}
items := result.Items.([]ConversationDTO)
if result.Total != 1 || len(items) != 1 {
t.Fatalf("发布群数量 = total %d/items %d, want 1/1", result.Total, len(items))
}
if items[0].LatestOrderID == nil || *items[0].LatestOrderID != order.ID || items[0].LatestOrderStatus != "pending_handoff" {
t.Fatalf("最新订单快照未生效: %+v", items[0])
}
order.Status = "renting"
if err := db.Save(&order).Error; err != nil {
t.Fatalf("更新订单状态失败: %v", err)
}
result, err = repo.ListConversationsWithFilter(t.Context(), Principal{Type: "admin", ID: admin.ID}, 1, 20, adminChatFilterMine, adminChatStageRenting, "")
if err != nil || result.Total != 1 {
t.Fatalf("更新后使用中筛选失败: total=%d err=%v", result.Total, err)
}
}
+14 -1
View File
@@ -53,6 +53,18 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
if err != nil {
return nil, err
}
var state model.ChatAdminConversationState
if err := db.Where("conversation_id = ? AND admin_user_id = ?", conversation.ID, principal.ID).First(&state).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var unreadCount int64
if err := db.Table("chat_messages AS cm").
Where("cm.conversation_id = ?", conversation.ID).
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
Where("cm.id > ?", state.LastReadMessageID).
Count(&unreadCount).Error; err != nil {
return nil, err
}
dto := ConversationDTO{
ID: conversation.ID,
OrderID: conversation.OrderID,
@@ -62,11 +74,12 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
Title: conversation.Title,
Status: conversation.Status,
Role: "admin", // 管理员角色
AdminRemark: state.Remark,
Participants: participants,
LastMessageID: conversation.LastMessageID,
LastMessagePreview: conversation.LastMessagePreview,
LastMessageAt: conversation.LastMessageAt,
UnreadCount: 0, // 管理员不计未读
UnreadCount: unreadCount,
CreatedAt: conversation.CreatedAt,
UpdatedAt: conversation.UpdatedAt,
}
@@ -24,6 +24,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB {
&model.RentalOrder{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{},
); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
+16 -13
View File
@@ -21,6 +21,7 @@ type ConversationDTO struct {
Title string `json:"title"`
Status string `json:"status"`
Role string `json:"role"`
AdminRemark string `json:"admin_remark,omitempty"`
Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"`
@@ -29,6 +30,7 @@ type ConversationDTO struct {
LastSenderID uint64 `json:"last_sender_id,omitempty"`
LastSenderRole string `json:"last_sender_role,omitempty"`
UnreadCount int64 `json:"unread_count"`
NeedsReply bool `json:"needs_reply"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -53,19 +55,20 @@ type ParticipantDTO struct {
}
type MessageDTO struct {
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"`
IsRead bool `json:"is_read"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
ID uint64 `json:"id"`
ConversationID uint64 `json:"conversation_id"`
SenderType string `json:"sender_type"`
SenderID uint64 `json:"sender_id"`
SenderRole string `json:"sender_role"`
SenderName string `json:"sender_name"`
SenderAvatar string `json:"sender_avatar"`
IsSelf bool `json:"is_self"`
IsRead bool `json:"is_read"`
ContentType string `json:"content_type"`
Content string `json:"content"`
AttachmentURLS []string `json:"attachment_urls"`
AdminAttentionType string `json:"admin_attention_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type UnreadCountDTO struct {
@@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) {
response.OK(c, result)
}
func (h *Handler) AdminCounts(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
principal := Principal{Type: "admin", ID: adminID}
result, err := h.service.AdminConversationCounts(
c.Request.Context(),
principal,
c.DefaultQuery("filter", "all"),
c.DefaultQuery("stage", "all"),
c.Query("keyword"),
)
if err != nil {
writeChatError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) AdminDetail(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -61,6 +82,19 @@ func (h *Handler) AdminMarkRead(c *gin.Context) {
h.markRead(c, Principal{Type: "admin", ID: adminID})
}
func (h *Handler) AdminMarkAllRead(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
if err := h.service.MarkAllAdminConversationsRead(c.Request.Context(), adminID); err != nil {
writeChatError(c, err)
return
}
response.OK(c, gin.H{"read": true})
}
func (h *Handler) AdminTransfer(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -128,6 +128,7 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
return
}
if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return
}
@@ -164,6 +165,7 @@ func (h *Handler) SubmitOCRJobHandler(c *gin.Context) {
return
}
if errors.Is(err, ErrQrCodeOCRUnavailable) {
response.RecordError(c, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"})
return
}
+12 -7
View File
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
if handoffSupportID > 0 {
message += ",卖号组客服已接入"
}
return sendSystemMessage(tx, conv.ID, message)
return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid")
}
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
@@ -248,13 +248,18 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
}
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
return sendSystemMessageWithAttention(tx, conversationID, content, "")
}
func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error {
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: content,
AttachmentURLS: emptyJSONList(),
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: content,
AttachmentURLS: emptyJSONList(),
AdminAttentionType: attentionType,
}
if err := tx.Create(&message).Error; err != nil {
+87 -23
View File
@@ -40,12 +40,15 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa
offset := (page - 1) * pageSize
var rows []model.ChatMessage
if err := query.Order("id ASC").
if err := query.Order("id DESC").
Offset(offset).
Limit(pageSize).
Find(&rows).Error; err != nil {
return nil, err
}
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
rows[left], rows[right] = rows[right], rows[left]
}
items, err := r.toMessageDTOs(ctx, principal, rows)
if err != nil {
return nil, err
@@ -101,12 +104,21 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
Content: req.Content,
AttachmentURLS: encodeStringList(req.AttachmentURLS),
}
if principal.Type == "user" {
message.AdminAttentionType = "user_inquiry"
}
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 message.AdminAttentionType != "" || message.SenderType == "user" {
conversation.LastAttentionMessageID = message.ID
}
if message.SenderType == "admin" {
conversation.LastAdminMessageID = message.ID
}
if err := tx.Save(&conversation).Error; err != nil {
return err
}
@@ -143,16 +155,17 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
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),
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,
AdminAttentionType: msg.AdminAttentionType,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
},
}
r.hub.NotifyConversation(conversationID, event)
@@ -166,21 +179,44 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
now := time.Now()
updated := false
err := 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 记录,更新已读时间
updated = true
return tx.Model(&participant).Update("last_read_at", now).Error
} else if errors.Is(err, gorm.ErrRecordNotFound) {
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
var conversation model.ChatConversation
if err := tx.Select("id", "last_message_id").First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
lastMessageID := uint64(0)
if conversation.LastMessageID != nil {
lastMessageID = *conversation.LastMessageID
}
var existing model.ChatAdminConversationState
err := tx.Where("conversation_id = ? AND admin_user_id = ?", conversationID, principal.ID).
First(&existing).Error
if err == nil && existing.LastReadMessageID >= lastMessageID {
return nil
}
return err
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
state := model.ChatAdminConversationState{
ConversationID: conversationID,
AdminUserID: principal.ID,
LastReadMessageID: lastMessageID,
LastReadAt: &now,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"last_read_message_id": lastMessageID,
"last_read_at": now,
}),
}).Create(&state).Error; err != nil {
return err
}
updated = true
return nil
}
// 普通用户必须是 participant
@@ -209,3 +245,31 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
}
return nil
}
// MarkAllAdminConversationsRead advances one admin's read cursor for every conversation in one transaction.
func (r *Repository) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
now := time.Now()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先补齐状态行,再更新游标。拆成两步可兼容本地 MySQL/MariaDB 对
// INSERT ... SELECT 同表读取并 ON DUPLICATE KEY UPDATE 的限制。
if err := tx.Exec(`
INSERT INTO chat_admin_conversation_states
(conversation_id, admin_user_id, remark, last_read_message_id, last_read_at)
SELECT c.id, ?, COALESCE(existing.remark, ''), COALESCE(c.last_message_id, 0), ?
FROM chat_conversations AS c
LEFT JOIN chat_admin_conversation_states AS existing
ON existing.conversation_id = c.id AND existing.admin_user_id = ?
WHERE existing.id IS NULL`, adminID, now, adminID).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE chat_admin_conversation_states AS cas
JOIN chat_conversations AS c ON c.id = cas.conversation_id
SET cas.last_read_message_id = c.last_message_id,
cas.last_read_at = ?,
cas.updated_at = CURRENT_TIMESTAMP
WHERE cas.admin_user_id = ?
AND c.last_message_id IS NOT NULL
AND cas.last_read_message_id < c.last_message_id`, now, adminID).Error
})
}
+33 -11
View File
@@ -25,6 +25,7 @@ type conversationRow struct {
Title string
Status string
Role string
AdminRemark string
LastMessageID *uint64
LastMessagePreview string
LastMessageAt *time.Time
@@ -32,6 +33,7 @@ type conversationRow struct {
LastSenderID uint64
LastSenderRole string
UnreadCount int64
NeedsReply int64
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -44,8 +46,9 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
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)
AND cm.sender_type <> 'system'
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)
@@ -59,6 +62,7 @@ func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principa
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID).
Where("c.status = ?", "active").
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", principal.Type, principal.ID).
Where("cm.sender_type <> ?", "system").
Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)").
Count(&total).Error
return total, err
@@ -81,15 +85,29 @@ func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversat
return &participant, nil
}
func (r *Repository) participants(ctx context.Context, conversationID uint64) ([]ParticipantDTO, error) {
grouped, err := r.participantsForConversations(ctx, []uint64{conversationID})
if err != nil {
return nil, err
}
return grouped[conversationID], nil
}
func (r *Repository) participantsForConversations(ctx context.Context, conversationIDs []uint64) (map[uint64][]ParticipantDTO, error) {
result := make(map[uint64][]ParticipantDTO, len(conversationIDs))
if len(conversationIDs) == 0 {
return result, nil
}
var rows []model.ChatParticipant
if err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil {
if err := r.db.WithContext(ctx).
Where("conversation_id IN ?", uniqueIDs(conversationIDs)).
Order("conversation_id ASC, 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 := ""
@@ -100,19 +118,20 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([
if row.ParticipantType == "admin" {
name = adminNames[row.ParticipantID]
}
items = append(items, ParticipantDTO{
result[row.ConversationID] = append(result[row.ConversationID], ParticipantDTO{
ID: row.ID,
ConversationID: row.ConversationID,
ParticipantType: row.ParticipantType,
ParticipantID: row.ParticipantID,
Role: row.Role,
Remark: row.Remark,
DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name),
AvatarURL: avatar,
LastReadAt: row.LastReadAt,
JoinedAt: row.JoinedAt,
})
}
return items, nil
return result, nil
}
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
userIDs := make([]uint64, 0)
@@ -166,11 +185,12 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
SenderAvatar: avatar,
IsSelf: isSelf,
// 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
ContentType: row.ContentType,
Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS),
CreatedAt: row.CreatedAt,
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
ContentType: row.ContentType,
Content: row.Content,
AttachmentURLS: decodeStringList(row.AttachmentURLS),
AdminAttentionType: row.AdminAttentionType,
CreatedAt: row.CreatedAt,
})
}
return items, nil
@@ -305,6 +325,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
Title: row.Title,
Status: row.Status,
Role: row.Role,
AdminRemark: row.AdminRemark,
Participants: participants,
LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview,
@@ -313,6 +334,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO
LastSenderID: row.LastSenderID,
LastSenderRole: row.LastSenderRole,
UnreadCount: row.UnreadCount,
NeedsReply: row.NeedsReply > 0,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
+452 -79
View File
@@ -6,21 +6,46 @@ import (
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"math"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"hfb_sys/backend/internal/logging"
"github.com/google/uuid"
"go.uber.org/zap"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024
maxQrCodeOCRPixels = 40_000_000
maxQrCodeOCRSide = 2560
qrCodeOCRJPEGQuality = 92
ocrRequestConcurrency = 2
ocrRequestMinInterval = 250 * time.Millisecond
ocrRequestMaxAttempts = 3
ocrRetryBaseDelay = 500 * time.Millisecond
ocrResponseBodyLimit = 2 * 1024 * 1024
ocrResultBodyLimit = 10 * 1024 * 1024
ocrLogBodyLimit = 2048
ocrJobRedisKeyPrefix = "ocr:job:"
ocrJobRedisTTL = 10 * time.Minute
@@ -38,9 +63,87 @@ var (
ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期")
ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis")
ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
sharedOCRRequestGate = newOCRRequestGate(ocrRequestConcurrency, ocrRequestMinInterval)
)
type ocrRequestGate struct {
slots chan struct{}
mu sync.Mutex
nextAllowed time.Time
minInterval time.Duration
}
func newOCRRequestGate(concurrency int, minInterval time.Duration) *ocrRequestGate {
if concurrency < 1 {
concurrency = 1
}
return &ocrRequestGate{
slots: make(chan struct{}, concurrency),
minInterval: max(0, minInterval),
}
}
func (g *ocrRequestGate) acquire(ctx context.Context, sleep func(context.Context, time.Duration) error) (func(), error) {
select {
case g.slots <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
release := func() { <-g.slots }
g.mu.Lock()
now := time.Now()
wait := max(time.Duration(0), g.nextAllowed.Sub(now))
startAt := now.Add(wait)
g.nextAllowed = startAt.Add(g.minInterval)
g.mu.Unlock()
if wait > 0 {
if err := sleep(ctx, wait); err != nil {
release()
return nil, err
}
}
return release, nil
}
type ocrUpstreamError struct {
Operation string
StatusCode int
Code string
Message string
TraceID string
RetryAfter time.Duration
BodySnippet string
Cause error
Retriable bool
}
func (e *ocrUpstreamError) Error() string {
parts := []string{ErrQrCodeOCRUnavailable.Error(), "operation=" + e.Operation}
if e.StatusCode != 0 {
parts = append(parts, "status="+strconv.Itoa(e.StatusCode))
}
if e.Code != "" {
parts = append(parts, "code="+e.Code)
}
if e.Message != "" {
parts = append(parts, "message="+e.Message)
}
if e.TraceID != "" {
parts = append(parts, "trace_id="+e.TraceID)
}
if e.Cause != nil {
parts = append(parts, "cause="+e.Cause.Error())
}
return strings.Join(parts, " ")
}
func (e *ocrUpstreamError) Unwrap() error {
return ErrQrCodeOCRUnavailable
}
type QrCodeOCRResult struct {
GroupName string `json:"group_name"`
Candidates []string `json:"candidates"`
@@ -79,13 +182,46 @@ func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, strin
if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize {
return nil, "", ErrQrCodeOCRInvalidFile
}
if contentType == "" {
contentType = http.DetectContentType(data)
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if contentType == "" || !strings.HasPrefix(contentType, "image/") {
contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
}
if !strings.HasPrefix(contentType, "image/") {
return nil, "", ErrQrCodeOCRInvalidFile
}
return data, contentType, nil
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > maxQrCodeOCRPixels {
return nil, "", ErrQrCodeOCRInvalidFile
}
source, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", ErrQrCodeOCRInvalidFile
}
normalized := resizeOCRImage(source, maxQrCodeOCRSide)
var buffer bytes.Buffer
if err := jpeg.Encode(&buffer, normalized, &jpeg.Options{Quality: qrCodeOCRJPEGQuality}); err != nil {
return nil, "", ErrQrCodeOCRInvalidFile
}
if buffer.Len() == 0 || buffer.Len() > maxQrCodeOCRFileSize {
return nil, "", ErrQrCodeOCRInvalidFile
}
return buffer.Bytes(), "image/jpeg", nil
}
func resizeOCRImage(source image.Image, maxSide int) image.Image {
bounds := source.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return source
}
scale := math.Min(1, float64(maxSide)/float64(max(width, height)))
targetWidth := max(1, int(math.Round(float64(width)*scale)))
targetHeight := max(1, int(math.Round(float64(height)*scale)))
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.Draw(target, target.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
xdraw.ApproxBiLinear.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
return target
}
func ocrJobKey(jobID string) string {
@@ -132,15 +268,15 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
return nil, err
}
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil {
return nil, err
}
jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID)
jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID)
if err != nil {
return nil, err
}
rawText, err := fetchPaddleOCRText(ctx, jsonURL)
rawText, err := r.fetchPaddleOCRText(ctx, jsonURL)
if err != nil {
return nil, err
}
@@ -173,7 +309,7 @@ func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType str
return "", err
}
paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data)
paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil {
return "", err
}
@@ -206,47 +342,48 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
}
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err == nil {
req.Header.Set("Authorization", "bearer "+config.Token)
}
return req, err
})
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := ocrHTTPClient.Do(req)
if err != nil {
// 网络错误不更新 Redis,保留当前状态让前端重试
return record, nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 保留当前状态,让下一次前端轮询继续尝试。
return record, nil
}
var statusPayload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &statusPayload); err != nil {
upstreamErr := newOCRUpstreamError("status_decode", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return record, nil
}
switch statusPayload.Data.State {
case "pending", "running":
record.Status = ocrJobStatusRunning
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
case "done":
if statusPayload.Data.ResultURL.JSONURL == "" {
record.Status = ocrJobStatusFailed
record.Error = "未返回识别结果地址"
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.ocrLogger(ctx).Error("PaddleOCR 任务缺少结果地址",
zap.String("operation", "status"),
zap.String("paddle_job_id", record.PaddleJobID),
zap.String("upstream_state", statusPayload.Data.State),
)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
}
rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
if err != nil {
record.Status = ocrJobStatusFailed
record.Error = err.Error()
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
}
candidates := parseQrCodeGroupNameCandidates(rawText)
@@ -260,7 +397,7 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
Candidates: candidates,
RawText: rawText,
}
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
case "failed":
@@ -270,14 +407,36 @@ func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJo
} else {
record.Error = "PaddleOCR 识别失败"
}
_ = r.saveOCRJobRecord(ctx, jobID, record)
r.ocrLogger(ctx).Error("PaddleOCR 任务执行失败",
zap.String("operation", "status"),
zap.String("paddle_job_id", record.PaddleJobID),
zap.String("upstream_state", statusPayload.Data.State),
zap.String("upstream_message", truncateOCRLogValue(record.Error, 512)),
)
r.saveOCRJobRecordWithLog(ctx, jobID, record)
return record, nil
default:
upstreamErr := newOCRUpstreamError("status_payload", http.StatusOK, nil, respBody, errors.New("unexpected OCR job state"))
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
}
return record, nil
}
func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
func (r *Repository) saveOCRJobRecordWithLog(ctx context.Context, jobID string, record *OCRJobRecord) {
if err := r.saveOCRJobRecord(ctx, jobID, record); err != nil {
r.ocrLogger(ctx).Error("OCR 任务状态保存失败",
zap.String("ocr_job_id", jobID),
zap.String("paddle_job_id", record.PaddleJobID),
zap.String("ocr_status", record.Status),
zap.Error(err),
)
}
}
func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
filename = normalizeOCRFilename(filename)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("model", config.Model)
@@ -301,51 +460,69 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename,
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body)
requestBody := append([]byte(nil), body.Bytes()...)
formContentType := writer.FormDataContentType()
respBody, err := r.doPaddleRequest(ctx, "submit", func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, bytes.NewReader(requestBody))
if err == nil {
req.Header.Set("Authorization", "bearer "+config.Token)
req.Header.Set("Content-Type", formContentType)
}
return req, err
})
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req.Header.Set("Authorization", "bearer "+config.Token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
return "", err
}
var payload paddleOCRJobResponse
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
}
if payload.Data.JobID == "" {
return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable)
upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, errors.New("missing jobId"))
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
}
r.ocrLogger(ctx).Info("PaddleOCR 任务提交成功",
zap.String("operation", "submit"),
zap.String("paddle_job_id", payload.Data.JobID),
zap.String("model", config.Model),
zap.String("file_content_type", contentType),
zap.Int("file_size", len(data)),
)
return payload.Data.JobID, nil
}
func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
func normalizeOCRFilename(filename string) string {
filename = strings.TrimSpace(filename)
if filename == "" {
return "qrcode.jpg"
}
if dot := strings.LastIndex(filename, "."); dot > 0 {
filename = filename[:dot]
}
return filename + ".jpg"
}
func (r *Repository) waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) {
for i := 0; i < 12; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil)
statusURL := strings.TrimRight(config.JobURL, "/") + "/" + jobID
respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err == nil {
req.Header.Set("Authorization", "bearer "+config.Token)
}
return req, err
})
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
return "", err
}
var payload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &payload); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
upstreamErr := newOCRUpstreamError("status", http.StatusOK, nil, respBody, err)
r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0)
return "", upstreamErr
}
switch payload.Data.State {
case "done":
@@ -359,36 +536,232 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string
}
return "", ErrQrCodeOCRUnavailable
}
select {
case <-ctx.Done():
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
case <-time.After(2 * time.Second):
if err := r.ocrSleep(ctx, 2*time.Second); err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
}
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
}
func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil)
})
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
resp, err := ocrHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
return "", err
}
return collectPaddleOCRJSONLText(string(body)), nil
}
type ocrRequestFactory func(context.Context) (*http.Request, error)
func (r *Repository) doPaddleRequest(ctx context.Context, operation string, factory ocrRequestFactory) ([]byte, error) {
var lastErr *ocrUpstreamError
for attempt := 1; attempt <= ocrRequestMaxAttempts; attempt++ {
release, err := r.ocrGate.acquire(ctx, r.ocrSleep)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req, err := factory(ctx)
if err != nil {
release()
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
startedAt := time.Now()
resp, requestErr := r.ocrHTTPClient.Do(req)
duration := time.Since(startedAt)
if requestErr != nil {
release()
lastErr = newOCRUpstreamError(operation, 0, nil, nil, requestErr)
lastErr.Retriable = isRetriableOCRNetworkError(ctx, requestErr)
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
} else {
bodyLimit := int64(ocrResponseBodyLimit)
if operation == "result" {
bodyLimit = ocrResultBodyLimit
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
_ = resp.Body.Close()
release()
if int64(len(body)) > bodyLimit {
body = body[:bodyLimit]
readErr = errors.New("upstream response exceeded limit")
}
if readErr == nil && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return body, nil
}
lastErr = newOCRUpstreamError(operation, resp.StatusCode, resp.Header, body, readErr)
lastErr.Retriable = isRetriableOCRStatus(resp.StatusCode) || readErr != nil
r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration)
}
if !lastErr.Retriable || attempt == ocrRequestMaxAttempts {
return nil, lastErr
}
delay := ocrRetryDelay(attempt, lastErr.RetryAfter)
if err := r.ocrSleep(ctx, delay); err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
}
return nil, lastErr
}
func newOCRUpstreamError(operation string, statusCode int, headers http.Header, body []byte, cause error) *ocrUpstreamError {
upstreamErr := &ocrUpstreamError{
Operation: operation,
StatusCode: statusCode,
BodySnippet: truncateOCRLogValue(string(body), ocrLogBodyLimit),
Cause: cause,
}
if headers != nil {
upstreamErr.RetryAfter = parseOCRRetryAfter(headers.Get("Retry-After"), time.Now())
upstreamErr.TraceID = firstNonEmpty(
headers.Get("X-Request-ID"),
headers.Get("X-Trace-ID"),
headers.Get("Trace-ID"),
)
}
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
upstreamErr.Code = firstJSONText(payload, "code", "errorCode", "error_code")
upstreamErr.Message = firstJSONText(payload, "message", "msg", "error", "errorMsg", "error_message")
if upstreamErr.TraceID == "" {
upstreamErr.TraceID = firstJSONText(payload, "traceId", "trace_id", "requestId", "request_id")
}
}
return upstreamErr
}
func (r *Repository) logOCRUpstreamFailure(ctx context.Context, upstreamErr *ocrUpstreamError, attempt int, final bool, duration time.Duration) {
if upstreamErr == nil {
return
}
fields := []zap.Field{
zap.String("operation", upstreamErr.Operation),
zap.Int("attempt", attempt),
zap.Bool("final", final),
zap.Bool("retriable", upstreamErr.Retriable),
zap.Int("upstream_status", upstreamErr.StatusCode),
zap.String("upstream_code", upstreamErr.Code),
zap.String("upstream_message", upstreamErr.Message),
zap.String("upstream_trace_id", upstreamErr.TraceID),
zap.String("upstream_body", upstreamErr.BodySnippet),
zap.Int64("retry_after_ms", upstreamErr.RetryAfter.Milliseconds()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
}
if upstreamErr.Cause != nil {
fields = append(fields, zap.Error(upstreamErr.Cause))
}
if final {
r.ocrLogger(ctx).Error("PaddleOCR 请求失败", fields...)
} else {
r.ocrLogger(ctx).Warn("PaddleOCR 请求失败,准备重试", fields...)
}
}
func (r *Repository) ocrLogger(ctx context.Context) *zap.Logger {
logger := r.logger
if logger == nil {
logger = zap.NewNop()
}
fields := []zap.Field{zap.String("module", "chat_ocr")}
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if adminID := logging.AdminIDFromContext(ctx); adminID != 0 {
fields = append(fields, zap.Uint64("admin_id", adminID))
}
return logger.With(fields...)
}
func isRetriableOCRStatus(status int) bool {
switch status {
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func isRetriableOCRNetworkError(ctx context.Context, err error) bool {
return err != nil && ctx.Err() == nil
}
func ocrRetryDelay(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return min(retryAfter, 10*time.Second)
}
delay := ocrRetryBaseDelay * time.Duration(1<<(attempt-1))
// Deterministic jitter avoids synchronized retries without relying on global randomness.
jitter := time.Duration((attempt*137)%250) * time.Millisecond
return delay + jitter
}
func parseOCRRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
return max(0, time.Duration(seconds)*time.Second)
}
if when, err := http.ParseTime(value); err == nil {
return max(0, when.Sub(now))
}
return 0
}
func sleepWithContext(ctx context.Context, duration time.Duration) error {
if duration <= 0 {
return nil
}
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func firstJSONText(payload map[string]any, keys ...string) string {
for _, key := range keys {
value, ok := payload[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
return truncateOCRLogValue(text, 512)
}
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return truncateOCRLogValue(value, 256)
}
}
return ""
}
func truncateOCRLogValue(value string, limit int) string {
value = strings.Join(strings.Fields(value), " ")
if len(value) <= limit {
return value
}
return value[:limit] + "..."
}
func collectPaddleOCRJSONLText(raw string) string {
var texts []string
for _, line := range strings.Split(raw, "\n") {
@@ -0,0 +1,224 @@
package chat
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"image"
"image/color"
"image/draw"
"image/png"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func newOCRTestRepository(client *http.Client, logger *zap.Logger, sleep func(context.Context, time.Duration) error) *Repository {
return NewRepository(nil, nil, nil,
withOCRHTTPClient(client),
withOCRGate(newOCRRequestGate(ocrRequestConcurrency, 0)),
WithLogger(logger),
withOCRSleep(sleep),
)
}
func TestDoPaddleRequestRetries429AndHonorsRetryAfter(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if requests.Add(1) == 1 {
w.Header().Set("Retry-After", "2")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"rate_limit","message":"busy"}`))
return
}
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
var delays []time.Duration
repo := newOCRTestRepository(server.Client(), zap.NewNop(), func(_ context.Context, delay time.Duration) error {
delays = append(delays, delay)
return nil
})
body, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
})
if err != nil {
t.Fatalf("Paddle 请求失败: %v", err)
}
if string(body) != "ok" || requests.Load() != 2 {
t.Fatalf("body = %q, requests = %d", body, requests.Load())
}
if len(delays) != 1 || delays[0] != 2*time.Second {
t.Fatalf("retry delays = %v, want [2s]", delays)
}
}
func TestDoPaddleRequestDoesNotRetryDeterministic4xx(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = w.Write([]byte(`{"code":"invalid_format","message":"unsupported image"}`))
}))
t.Cleanup(server.Close)
repo := newOCRTestRepository(server.Client(), zap.NewNop(), func(context.Context, time.Duration) error { return nil })
_, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
})
var upstreamErr *ocrUpstreamError
if !errors.As(err, &upstreamErr) {
t.Fatalf("error = %v, want ocrUpstreamError", err)
}
if requests.Load() != 1 || upstreamErr.Retriable {
t.Fatalf("requests = %d, retriable = %v", requests.Load(), upstreamErr.Retriable)
}
if upstreamErr.StatusCode != http.StatusUnsupportedMediaType || upstreamErr.Code != "invalid_format" {
t.Fatalf("upstream error = %+v", upstreamErr)
}
}
func TestDoPaddleRequestLogsStructured502Failure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Trace-ID", "trace-502")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"code":"capacity","message":"upstream busy"}`))
}))
t.Cleanup(server.Close)
core, observed := observer.New(zap.DebugLevel)
repo := newOCRTestRepository(server.Client(), zap.New(core), func(context.Context, time.Duration) error { return nil })
_, err := repo.doPaddleRequest(t.Context(), "submit", func(ctx context.Context) (*http.Request, error) {
req, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
if requestErr == nil {
req.Header.Set("Authorization", "bearer secret-token")
}
return req, requestErr
})
var upstreamErr *ocrUpstreamError
if !errors.As(err, &upstreamErr) {
t.Fatalf("error = %v, want ocrUpstreamError", err)
}
if upstreamErr.StatusCode != http.StatusBadGateway || upstreamErr.TraceID != "trace-502" || upstreamErr.Code != "capacity" {
t.Fatalf("upstream error = %+v", upstreamErr)
}
entries := observed.AllUntimed()
if len(entries) != ocrRequestMaxAttempts {
t.Fatalf("log entries = %d, want %d", len(entries), ocrRequestMaxAttempts)
}
fields := entries[len(entries)-1].ContextMap()
if fields["final"] != true || fields["upstream_status"] != int64(http.StatusBadGateway) || fields["upstream_trace_id"] != "trace-502" {
t.Fatalf("final log fields = %#v", fields)
}
for _, entry := range entries {
if strings.Contains(entry.Message, "secret-token") || strings.Contains(fmt.Sprint(entry.Context), "secret-token") {
t.Fatal("OCR 日志泄露了 Authorization Token")
}
}
}
func TestReadAndValidateOCRFileNormalizesJPEGAndSize(t *testing.T) {
source := image.NewNRGBA(image.Rect(0, 0, 3000, 1200))
draw.Draw(source, source.Bounds(), &image.Uniform{C: color.NRGBA{R: 25, G: 80, B: 160, A: 255}}, image.Point{}, draw.Src)
var input bytes.Buffer
if err := png.Encode(&input, source); err != nil {
t.Fatalf("编码测试图片失败: %v", err)
}
data, contentType, err := readAndValidateOCRFile(&input, "image/png")
if err != nil {
t.Fatalf("归一化图片失败: %v", err)
}
if contentType != "image/jpeg" || len(data) < 2 || data[0] != 0xff || data[1] != 0xd8 {
t.Fatalf("content_type = %q, signature = %x", contentType, data[:min(2, len(data))])
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
t.Fatalf("读取归一化图片失败: %v", err)
}
if format != "jpeg" || config.Width != 2560 || config.Height != 1024 {
t.Fatalf("format = %q, size = %dx%d", format, config.Width, config.Height)
}
}
func TestReadAndValidateOCRFileRejectsExcessiveDecodedPixels(t *testing.T) {
data := pngHeader(8001, 5000)
config, _, decodeErr := image.DecodeConfig(bytes.NewReader(data))
if decodeErr != nil || config.Width != 8001 || config.Height != 5000 {
t.Fatalf("测试 PNG 头无效: config=%+v, error=%v", config, decodeErr)
}
_, _, err := readAndValidateOCRFile(bytes.NewReader(data), "image/png")
if !errors.Is(err, ErrQrCodeOCRInvalidFile) {
t.Fatalf("error = %v, want ErrQrCodeOCRInvalidFile", err)
}
}
func TestOCRRequestGateLimitsConcurrency(t *testing.T) {
gate := newOCRRequestGate(2, 0)
sleep := func(context.Context, time.Duration) error { return nil }
releaseFirst, err := gate.acquire(t.Context(), sleep)
if err != nil {
t.Fatal(err)
}
releaseSecond, err := gate.acquire(t.Context(), sleep)
if err != nil {
t.Fatal(err)
}
third := make(chan func(), 1)
go func() {
release, acquireErr := gate.acquire(t.Context(), sleep)
if acquireErr == nil {
third <- release
}
}()
select {
case release := <-third:
release()
t.Fatal("第三个请求在并发槽释放前进入")
case <-time.After(30 * time.Millisecond):
}
releaseFirst()
select {
case release := <-third:
release()
case <-time.After(time.Second):
t.Fatal("并发槽释放后第三个请求仍未进入")
}
releaseSecond()
}
func TestNormalizeOCRFilenameUsesJPEGExtension(t *testing.T) {
if got := normalizeOCRFilename("group.qrcode.webp"); got != "group.qrcode.jpg" {
t.Fatalf("filename = %q", got)
}
if got := normalizeOCRFilename(""); got != "qrcode.jpg" {
t.Fatalf("empty filename = %q", got)
}
}
func pngHeader(width, height uint32) []byte {
var result bytes.Buffer
result.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})
data := make([]byte, 13)
binary.BigEndian.PutUint32(data[0:4], width)
binary.BigEndian.PutUint32(data[4:8], height)
data[8] = 8
data[9] = 2
binary.Write(&result, binary.BigEndian, uint32(len(data)))
result.WriteString("IHDR")
result.Write(data)
checksum := crc32.ChecksumIEEE(append([]byte("IHDR"), data...))
binary.Write(&result, binary.BigEndian, checksum)
return result.Bytes()
}
+25 -3
View File
@@ -2,7 +2,12 @@ package chat
import (
"context"
"errors"
"hfb_sys/backend/internal/model"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -13,9 +18,26 @@ const (
)
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
if principal.Type != "admin" {
return ErrPermissionDenied
}
db := r.db.WithContext(ctx)
var conversation model.ChatConversation
if err := db.Select("id").First(&conversation, conversationID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConversationNotFound
}
return err
}
state := model.ChatAdminConversationState{
ConversationID: conversationID,
AdminUserID: principal.ID,
Remark: strings.TrimSpace(remark),
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "conversation_id"}, {Name: "admin_user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"remark", "updated_at"}),
}).Create(&state).Error
}
func (r *Repository) ListQuickReplies(ctx context.Context, adminID uint64) ([]QuickReplyDTO, error) {
var replies []model.ChatQuickReply
+60 -6
View File
@@ -1,27 +1,81 @@
package chat
import (
"context"
"errors"
"net/http"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chathub"
"time"
)
type Repository struct {
db *gorm.DB
hub *chathub.Hub
redis *redis.Client
db *gorm.DB
hub *chathub.Hub
redis *redis.Client
logger *zap.Logger
ocrHTTPClient *http.Client
ocrGate *ocrRequestGate
ocrSleep func(context.Context, time.Duration) error
}
const (
defaultSupportRoleCode = "cs"
)
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository {
return &Repository{db: db, hub: hub, redis: redis}
type RepositoryOption func(*Repository)
func WithLogger(logger *zap.Logger) RepositoryOption {
return func(repo *Repository) {
if logger != nil {
repo.logger = logger
}
}
}
func withOCRHTTPClient(client *http.Client) RepositoryOption {
return func(repo *Repository) {
if client != nil {
repo.ocrHTTPClient = client
}
}
}
func withOCRGate(gate *ocrRequestGate) RepositoryOption {
return func(repo *Repository) {
if gate != nil {
repo.ocrGate = gate
}
}
}
func withOCRSleep(sleep func(context.Context, time.Duration) error) RepositoryOption {
return func(repo *Repository) {
if sleep != nil {
repo.ocrSleep = sleep
}
}
}
func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client, options ...RepositoryOption) *Repository {
repo := &Repository{
db: db,
hub: hub,
redis: redis,
logger: zap.NewNop(),
ocrHTTPClient: ocrHTTPClient,
ocrGate: sharedOCRRequestGate,
ocrSleep: sleepWithContext,
}
for _, option := range options {
option(repo)
}
return repo
}
func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
var existing model.ChatConversation
+21
View File
@@ -131,6 +131,16 @@ func (s *Service) MarkRead(ctx context.Context, principal Principal, conversatio
return s.repo.MarkRead(ctx, principal, conversationID)
}
func (s *Service) MarkAllAdminConversationsRead(ctx context.Context, adminID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if adminID == 0 {
return ErrPermissionDenied
}
return s.repo.MarkAllAdminConversationsRead(ctx, adminID)
}
func (s *Service) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, req TransferRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
@@ -155,10 +165,21 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
}
func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword)
}
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
req.Remark = strings.TrimSpace(req.Remark)
if len([]rune(req.Remark)) > 128 {
return ErrInvalidMessage
}
return s.repo.UpdateRemark(ctx, principal, conversationID, req.Remark)
}
+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
}