diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index 27ab334..1d82311 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -313,7 +313,15 @@ func applyMigrations(t *testing.T, db *sql.DB) { t.Fatal("无法定位当前测试文件") } migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations") - for _, name := range []string{"000001_init.sql", "000002_dispute_cancel_snapshot.sql"} { + entries, err := os.ReadDir(migrationDir) + if err != nil { + t.Fatalf("读取迁移目录失败: %v", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + name := entry.Name() migrationPath := filepath.Join(migrationDir, name) raw, err := os.ReadFile(migrationPath) if err != nil { diff --git a/backend/internal/jobs/refundretry/job_test.go b/backend/internal/jobs/refundretry/job_test.go index a003f33..b9068e3 100644 --- a/backend/internal/jobs/refundretry/job_test.go +++ b/backend/internal/jobs/refundretry/job_test.go @@ -21,7 +21,7 @@ func setupRefundRetryTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("创建测试数据库失败: %v", err) } - if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}); err != nil { + if err := db.AutoMigrate(&model.PaymentOrder{}, &model.RentalOrder{}, &model.ChatConversation{}); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db @@ -165,19 +165,19 @@ func TestResetRetryClearsRetryFields(t *testing.T) { func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { t.Helper() order := model.RentalOrder{ - OrderNo: "ORD" + suffix, - ListingID: 1, - AccountID: 1, - OwnerID: 1, - RenterID: 2, - RentAmountCent: 1000, - DepositAmountCent: 500, - Status: "closed", - HandoffStatus: "arbitrated", - SettlementStatus: settlementStatus, - RefundStatus: "pending", - RefundAmountCent: 800, - UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 + OrderNo: "ORD" + suffix, + ListingID: 1, + AccountID: 1, + OwnerID: 1, + RenterID: 2, + RentAmountCent: 1000, + DepositAmountCent: 500, + Status: "closed", + HandoffStatus: "arbitrated", + SettlementStatus: settlementStatus, + RefundStatus: "pending", + RefundAmountCent: 800, + UpdatedAt: time.Now().Add(-20 * time.Minute), // 超过 10min 静默窗口 } if err := db.Create(&order).Error; err != nil { t.Fatalf("create order failed: %v", err) diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index d4cf89d..92f9d90 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -4,21 +4,32 @@ import ( "time" "gorm.io/datatypes" + "gorm.io/gorm" ) type ChatConversation struct { - ID uint64 `gorm:"primaryKey" json:"id"` - OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` - ListingID *uint64 `gorm:"index" json:"listing_id"` - Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` - SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"` - Title string `gorm:"size:128;not null" json:"title"` - Status string `gorm:"size:32;not null;default:'active'" json:"status"` - LastMessageID *uint64 `json:"last_message_id"` - LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"` - LastMessageAt *time.Time `json:"last_message_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` + ListingID *uint64 `gorm:"index" json:"listing_id"` + // 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。 + // 这样读取会话列表不必每次扫描全部订单再按商品分组。 + LatestOrderID *uint64 `gorm:"index" json:"-"` + LatestOrderNo string `gorm:"size:64;not null;default:''" json:"-"` + LatestOrderStatus string `gorm:"size:32;not null;default:'';index" json:"-"` + LatestOrderHandoffStatus string `gorm:"size:32;not null;default:''" json:"-"` + LatestOrderRefundStatus string `gorm:"size:32;not null;default:''" json:"-"` + // 待回复判断只需比较两个游标,避免每个会话再扫描聊天消息表。 + LastAttentionMessageID uint64 `gorm:"not null;default:0" json:"-"` + LastAdminMessageID uint64 `gorm:"not null;default:0" json:"-"` + Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` + SupportScene string `gorm:"column:support_scene;size:32;not null;default:''" json:"support_scene"` + Title string `gorm:"size:128;not null" json:"title"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + LastMessageID *uint64 `json:"last_message_id"` + LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"` + LastMessageAt *time.Time `json:"last_message_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (ChatConversation) TableName() string { @@ -76,6 +87,24 @@ func (ChatMessage) TableName() string { return "chat_messages" } +// AfterCreate 维护客服列表所需的两个消息游标;列表页只比较游标,不再对每个会话聚合消息表。 +func (m *ChatMessage) AfterCreate(tx *gorm.DB) error { + if m.ID == 0 || m.ConversationID == 0 { + return nil + } + updates := map[string]any{} + if m.AdminAttentionType != "" || m.SenderType == "user" { + updates["last_attention_message_id"] = m.ID + } + if m.SenderType == "admin" { + updates["last_admin_message_id"] = m.ID + } + if len(updates) == 0 { + return nil + } + return tx.Model(&ChatConversation{}).Where("id = ?", m.ConversationID).Updates(updates).Error +} + type ChatQrCode struct { ID uint64 `gorm:"primaryKey" json:"id"` ImageURL string `gorm:"size:512;not null" json:"image_url"` diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 272cdfb..3150cd6 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -4,6 +4,7 @@ import ( "time" "gorm.io/datatypes" + "gorm.io/gorm" ) type RentalOrder struct { @@ -73,6 +74,24 @@ func (RentalOrder) TableName() string { return "rental_orders" } +// AfterSave 将商品关联发布群的最新订单快照一并更新。 +// 客服列表会高频按订单状态筛选,直接读这个快照可避免每次聚合全量 rental_orders。 +func (o *RentalOrder) AfterSave(tx *gorm.DB) error { + if o.ID == 0 || o.ListingID == 0 { + return nil + } + return tx.Model(&ChatConversation{}). + Where("listing_id = ? AND order_id IS NULL", o.ListingID). + Where("latest_order_id IS NULL OR latest_order_id <= ?", o.ID). + Updates(map[string]any{ + "latest_order_id": o.ID, + "latest_order_no": o.OrderNo, + "latest_order_status": o.Status, + "latest_order_handoff_status": o.HandoffStatus, + "latest_order_refund_status": o.RefundStatus, + }).Error +} + // 押金暂扣状态:客服可对进行中订单的押金退款进行暂扣,订单照常结算, // 但本应原路退给租客的押金部分挂起不退,后续由客服手动归还。 const ( diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index dcd7ae1..4edaf5c 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -7,6 +7,9 @@ import ( ) func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*DashboardDTO, error) { + if cached := r.loadDashboardCache(ctx, query); cached != nil { + return cached, nil + } dailyItems, err := r.dailyItems(ctx, query) if err != nil { return nil, err @@ -31,7 +34,7 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash if err != nil { return nil, err } - return &DashboardDTO{ + result := &DashboardDTO{ Summary: *summary, DailyItems: dailyItems, PickupSummary: *pickup, @@ -39,7 +42,9 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash DisbursementSummary: *disbursement, OperatingExpenseSummary: *operatingExpense, GeneratedAt: timeutil.ShanghaiNow(), - }, nil + } + r.storeDashboardCache(ctx, query, result) + return result, nil } func (r *Repository) operatingExpenseSummary(ctx context.Context, query DashboardQuery) (*OperatingExpenseSummaryDTO, error) { diff --git a/backend/internal/modules/adminfinance/dashboard_cache.go b/backend/internal/modules/adminfinance/dashboard_cache.go new file mode 100644 index 0000000..8ffcdc9 --- /dev/null +++ b/backend/internal/modules/adminfinance/dashboard_cache.go @@ -0,0 +1,41 @@ +package adminfinance + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +const dashboardCacheTTL = time.Minute + +func (r *Repository) dashboardCacheKey(query DashboardQuery) string { + return fmt.Sprintf("admin-finance:dashboard:v1:%d:%d", query.StartDate.Unix(), query.EndDate.Unix()) +} + +func (r *Repository) loadDashboardCache(ctx context.Context, query DashboardQuery) *DashboardDTO { + if r.redis == nil { + return nil + } + raw, err := r.redis.Get(ctx, r.dashboardCacheKey(query)).Bytes() + if err != nil { + return nil + } + var value DashboardDTO + if err := json.Unmarshal(raw, &value); err != nil { + return nil + } + return &value +} + +func (r *Repository) storeDashboardCache(ctx context.Context, query DashboardQuery, value *DashboardDTO) { + if r.redis == nil || value == nil { + return + } + raw, err := json.Marshal(value) + if err != nil { + return + } + // 缓存不可用时静默降级到实时统计,不能影响财务页面可用性。 + _ = r.redis.Set(ctx, r.dashboardCacheKey(query), raw, dashboardCacheTTL).Err() +} diff --git a/backend/internal/modules/adminfinance/repository.go b/backend/internal/modules/adminfinance/repository.go index fa79478..558a399 100644 --- a/backend/internal/modules/adminfinance/repository.go +++ b/backend/internal/modules/adminfinance/repository.go @@ -1,13 +1,19 @@ package adminfinance import ( + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type Repository struct { - db *gorm.DB + db *gorm.DB + redis *redis.Client } -func NewRepository(db *gorm.DB) *Repository { - return &Repository{db: db} +func NewRepository(db *gorm.DB, redisClient ...*redis.Client) *Repository { + repo := &Repository{db: db} + if len(redisClient) > 0 { + repo.redis = redisClient[0] + } + return repo } diff --git a/backend/internal/modules/chat/admin_counts_cache.go b/backend/internal/modules/chat/admin_counts_cache.go new file mode 100644 index 0000000..5ed8d12 --- /dev/null +++ b/backend/internal/modules/chat/admin_counts_cache.go @@ -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() +} diff --git a/backend/internal/modules/chat/admin_list_test.go b/backend/internal/modules/chat/admin_list_test.go index e0fd371..aabb703 100644 --- a/backend/internal/modules/chat/admin_list_test.go +++ b/backend/internal/modules/chat/admin_list_test.go @@ -139,3 +139,61 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) { 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) + } +} diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index 9d79938..44f7b89 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -113,6 +113,12 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve 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 } diff --git a/backend/internal/modules/chat/support.go b/backend/internal/modules/chat/support.go index 68fbf09..dc9597c 100644 --- a/backend/internal/modules/chat/support.go +++ b/backend/internal/modules/chat/support.go @@ -233,11 +233,11 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, COALESCE(cp_me.role, 'admin') AS role, COALESCE(cas.remark, '') AS admin_remark, - COALESCE(explicit_lo.id, listing_lo.id) AS latest_order_id, - COALESCE(explicit_lo.order_no, listing_lo.order_no) AS latest_order_no, - COALESCE(explicit_lo.status, listing_lo.status) AS latest_order_status, - COALESCE(explicit_lo.handoff_status, listing_lo.handoff_status) AS latest_handoff_status, - COALESCE(explicit_lo.refund_status, listing_lo.refund_status) AS latest_refund_status, + 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 c.type = 'general_support' AND (` + adminNeedsReplyExpression() + `) THEN 1 ELSE 0 END AS needs_reply, ( @@ -273,10 +273,17 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ } func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) { - result, err := r.adminConversationCounts(ctx, principal, normalizeAdminChatFilter(filter), normalizeAdminChatStage(stage), strings.TrimSpace(keyword)) + 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 } @@ -297,13 +304,12 @@ func (r *Repository) adminConversationBase(ctx context.Context, principal Princi 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 explicit_lo ON explicit_lo.id = c.order_id"). - Joins("LEFT JOIN (SELECT ro.* FROM rental_orders AS ro JOIN (SELECT listing_id, MAX(id) AS max_id FROM rental_orders GROUP BY listing_id) AS latest ON latest.max_id = ro.id) AS listing_lo ON listing_lo.listing_id = c.listing_id AND c.order_id IS NULL"). - Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, listing_lo.listing_id, c.listing_id)") + Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(explicit_lo.listing_id, c.listing_id)") if keyword != "" { - db = db.Joins("LEFT JOIN users AS renter ON renter.id = COALESCE(explicit_lo.renter_id, listing_lo.renter_id)"). - Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(explicit_lo.owner_id, listing_lo.owner_id, l.owner_id)") + 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, listing_lo.order_no) LIKE ? OR l.listing_no LIKE ? + 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 @@ -410,9 +416,9 @@ func adminChatOwnershipCondition(filter string, principal Principal) (string, [] } func adminChatStageCondition(stage string, principal Principal) (string, []interface{}) { - latestID := "COALESCE(explicit_lo.id, listing_lo.id)" - latestStatus := "COALESCE(explicit_lo.status, listing_lo.status)" - latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)" + 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: return "c.type = 'general_support' AND (" + adminNeedsReplyExpression() + ")", nil @@ -434,17 +440,7 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter } func adminNeedsReplyExpression() string { - return `COALESCE(( - SELECT MAX(cm_attention.id) - FROM chat_messages AS cm_attention - WHERE cm_attention.conversation_id = c.id - AND (cm_attention.admin_attention_type <> '' OR cm_attention.sender_type = 'user') - ), 0) > COALESCE(( - SELECT MAX(cm_admin.id) - FROM chat_messages AS cm_admin - WHERE cm_admin.conversation_id = c.id - AND cm_admin.sender_type = 'admin' - ), 0)` + return "c.last_attention_message_id > c.last_admin_message_id" } // ArchiveListingConversation 将商品关联的发布群标记为已归档(解散),并写入系统提示。 diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index f064fcc..61aa97e 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -98,9 +98,9 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g like := "%" + keyword + "%" if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { db = db.Where( - `(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`, - like, - like, + `(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`, + keyword, + keyword, id, id, id, @@ -204,22 +204,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L } func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { - uploadSource := r.db.WithContext(ctx).Table("listing_uploads"). - Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel"). - Where("listing_id IS NOT NULL"). - Group("listing_id") return r.db.WithContext(ctx).Table("rental_listings AS l"). Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname, - CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload, + CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload, CASE - WHEN lu.upload_id IS NULL THEN ? + WHEN lu.id IS NULL THEN ? WHEN COALESCE(lu.source_channel, '') = '' THEN ? ELSE lu.source_channel END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown). Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Joins("LEFT JOIN users AS u ON u.id = l.owner_id"). - Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource) + // 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。 + Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = ( + SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id + )`) } func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 1679881..5967d53 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -100,12 +100,11 @@ func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { like := "%" + keyword + "%" if id, err := strconv.ParseUint(keyword, 10, 64); err == nil { db = db.Where( - `(o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, - like, - like, - like, - like, - like, + `(o.order_no = ? OR l.listing_no = ? OR owner.phone = ? OR renter.phone = ? OR o.id = ? OR o.listing_id = ? OR o.owner_id = ? OR o.renter_id = ?)`, + keyword, + keyword, + keyword, + keyword, id, id, id, diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3786bde..5eb043e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -134,7 +134,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) var adminFinanceRepo *adminfinance.Repository if deps.DB != nil { - adminFinanceRepo = adminfinance.NewRepository(deps.DB) + adminFinanceRepo = adminfinance.NewRepository(deps.DB, deps.Redis) } adminFinanceService := adminfinance.NewService(adminFinanceRepo) adminFinanceHandler := adminfinance.NewHandler(adminFinanceService) diff --git a/backend/migrations/000059_query_performance_snapshots.sql b/backend/migrations/000059_query_performance_snapshots.sql new file mode 100644 index 0000000..d39a0ac --- /dev/null +++ b/backend/migrations/000059_query_performance_snapshots.sql @@ -0,0 +1,52 @@ +-- +goose Up +-- 高频客服列表不再实时扫描全部订单与聊天消息;以下字段是可由业务记录重建的读取快照。 + +ALTER TABLE chat_conversations + ADD COLUMN latest_order_id BIGINT UNSIGNED NULL COMMENT '发布群最新订单ID快照' AFTER listing_id, + ADD COLUMN latest_order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发布群最新订单号快照' AFTER latest_order_id, + ADD COLUMN latest_order_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新订单状态快照' AFTER latest_order_no, + ADD COLUMN latest_order_handoff_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新交接状态快照' AFTER latest_order_status, + ADD COLUMN latest_order_refund_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发布群最新退款状态快照' AFTER latest_order_handoff_status, + ADD COLUMN last_attention_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一条需客服关注消息ID' AFTER latest_order_refund_status, + ADD COLUMN last_admin_message_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后一条客服消息ID' AFTER last_attention_message_id, + ADD KEY idx_chat_conversations_latest_order_status (latest_order_status, last_message_at, id); + +UPDATE chat_conversations AS c +JOIN ( + SELECT ro.id, ro.listing_id, ro.order_no, ro.status, ro.handoff_status, ro.refund_status + FROM rental_orders AS ro + JOIN ( + SELECT listing_id, MAX(id) AS id + FROM rental_orders + GROUP BY listing_id + ) AS latest ON latest.id = ro.id +) AS o ON o.listing_id = c.listing_id +SET c.latest_order_id = o.id, + c.latest_order_no = o.order_no, + c.latest_order_status = o.status, + c.latest_order_handoff_status = o.handoff_status, + c.latest_order_refund_status = o.refund_status +WHERE c.order_id IS NULL; + +UPDATE chat_conversations AS c +LEFT JOIN ( + SELECT conversation_id, + COALESCE(MAX(CASE WHEN admin_attention_type <> '' OR sender_type = 'user' THEN id END), 0) AS attention_id, + COALESCE(MAX(CASE WHEN sender_type = 'admin' THEN id END), 0) AS admin_id + FROM chat_messages + GROUP BY conversation_id +) AS m ON m.conversation_id = c.id +SET c.last_attention_message_id = COALESCE(m.attention_id, 0), + c.last_admin_message_id = COALESCE(m.admin_id, 0); + +-- +goose Down + +ALTER TABLE chat_conversations + DROP KEY idx_chat_conversations_latest_order_status, + DROP COLUMN last_admin_message_id, + DROP COLUMN last_attention_message_id, + DROP COLUMN latest_order_refund_status, + DROP COLUMN latest_order_handoff_status, + DROP COLUMN latest_order_status, + DROP COLUMN latest_order_no, + DROP COLUMN latest_order_id; diff --git a/frontend/src/features/admin/components/ProcessTimeline.vue b/frontend/src/features/admin/components/ProcessTimeline.vue index cfdfa6f..e9826f7 100644 --- a/frontend/src/features/admin/components/ProcessTimeline.vue +++ b/frontend/src/features/admin/components/ProcessTimeline.vue @@ -24,61 +24,126 @@ export interface ProcessTimelineEvent { const props = defineProps<{ events: ProcessTimelineEvent[]; emptyText?: string }>() const actionLabels: Record = { - owner_handoff_submitted: '号主提交交接', platform_handoff_submitted: '客服代交接', - admin_force_handoff: '客服确认交接', renter_received_confirmed: '租客确认收号', - checkout_submitted: '租客发起结账', checkout_countered: '修改结账方案', - checkout_confirmed: '号主确认结账', checkout_accepted: '租客接受结账方案', - platform_checkout_countered: '客服修改结账方案', platform_checkout_confirmed: '客服确认结账', - platform_checkout_dispute_opened: '客服发起结账争议', offline_settlement_confirmed: '确认线下结算', - deposit_held: '暂扣押金', deposit_released: '归还暂扣押金', pickup_created: '创建提号', - pickup_completed: '完成提号', pickup_profit_updated: '修改提号利润', - pickup_financial_adjusted: '创建财务调整', pickup_adjustment_settled: '确认财务调整', - pickup_offline_settlement_confirmed: '确认提号线下结算', pickup_cancelled: '取消提号', + owner_handoff_submitted: '号主提交交接', + platform_handoff_submitted: '客服代交接', + admin_force_handoff: '客服确认交接', + renter_received_confirmed: '租客确认收号', + checkout_submitted: '租客发起结账', + checkout_countered: '修改结账方案', + checkout_confirmed: '号主确认结账', + checkout_accepted: '租客接受结账方案', + platform_checkout_countered: '客服修改结账方案', + platform_checkout_confirmed: '客服确认结账', + platform_checkout_dispute_opened: '客服发起结账争议', + offline_settlement_confirmed: '确认线下结算', + deposit_held: '暂扣押金', + deposit_released: '归还暂扣押金', + pickup_created: '创建提号', + pickup_completed: '完成提号', + pickup_profit_updated: '修改提号利润', + pickup_financial_adjusted: '创建财务调整', + pickup_adjustment_settled: '确认财务调整', + pickup_offline_settlement_confirmed: '确认提号线下结算', + pickup_cancelled: '取消提号', } const stageLabels: Record = { - handoff: '交接', checkout: '结账', settlement: '结算', dispute: '争议', pickup: '提号', + handoff: '交接', + checkout: '结账', + settlement: '结算', + dispute: '争议', + pickup: '提号', } const payloadLabels: Record = { - checkout_id: '结账单', round: '协商轮次', turn: '等待确认方', proposed_by: '方案提出人', - rent_amount_cent: '实际结算租金', owner_rent_amount_cent: '号主租金', platform_fee_cent: '平台费用', - deposit_amount_cent: '押金', consumable_amount_cent: '消耗品金额', coin_consumed_m: '哈夫币消耗', - deposit_deduct_amount_cent: '押金赔付扣除', renter_refund_amount_cent: '退还租客', - owner_income_amount_cent: '号主最终收入', shortfall_cent: '押金不足差额', overshoot_amount_cent: '打超金额', - offline_settlement_amount_cent: '线下结算金额', deposit_hold_amount_cent: '暂扣金额', - profit_amount_cent: '利润金额', settle_amount_cent: '结算给号主', profit_delta_cent: '利润调整', - settle_delta_cent: '结算调整', settlement_mode: '结算方式', account_source: '账号来源', source_channel: '来源渠道', + checkout_id: '结账单', + round: '协商轮次', + turn: '等待确认方', + proposed_by: '方案提出人', + rent_amount_cent: '实际结算租金', + owner_rent_amount_cent: '号主租金', + platform_fee_cent: '平台费用', + deposit_amount_cent: '押金', + consumable_amount_cent: '消耗品金额', + coin_consumed_m: '哈夫币消耗', + deposit_deduct_amount_cent: '押金赔付扣除', + renter_refund_amount_cent: '退还租客', + owner_income_amount_cent: '号主最终收入', + shortfall_cent: '押金不足差额', + overshoot_amount_cent: '打超金额', + offline_settlement_amount_cent: '线下结算金额', + deposit_hold_amount_cent: '暂扣金额', + profit_amount_cent: '利润金额', + settle_amount_cent: '结算给号主', + profit_delta_cent: '利润调整', + settle_delta_cent: '结算调整', + settlement_mode: '结算方式', + account_source: '账号来源', + source_channel: '来源渠道', } const primaryPayloadKeys = new Set([ - 'round', 'turn', 'rent_amount_cent', 'renter_refund_amount_cent', 'owner_income_amount_cent', - 'offline_settlement_amount_cent', 'profit_amount_cent', 'settle_amount_cent', + 'round', + 'turn', + 'rent_amount_cent', + 'renter_refund_amount_cent', + 'owner_income_amount_cent', + 'offline_settlement_amount_cent', + 'profit_amount_cent', + 'settle_amount_cent', ]) const stateLabels: Record> = { order_status: { - pending_payment: '待支付', pending_handoff: '待交接', renting: '租用中', overdue: '已逾期', - pending_checkout_confirm: '待号主确认结账', pending_checkout_accept: '待租客确认结账', - completed: '已完成', cancelled: '已取消', closed: '已关闭', abnormal: '异常', + pending_payment: '待支付', + pending_handoff: '待交接', + renting: '租用中', + overdue: '已逾期', + pending_checkout_confirm: '待号主确认结账', + pending_checkout_accept: '待租客确认结账', + completed: '已完成', + cancelled: '已取消', + closed: '已关闭', + abnormal: '异常', }, handoff_status: { - none: '未开始', pending_owner: '待号主交接', pending_renter_confirm: '待租客确认收号', - received: '租客已收号', pending_owner_checkout: '待号主确认结账', - pending_renter_checkout: '待租客确认结账', returned: '已归还', + none: '未开始', + pending_owner: '待号主交接', + pending_renter_confirm: '待租客确认收号', + received: '租客已收号', + pending_owner_checkout: '待号主确认结账', + pending_renter_checkout: '待租客确认结账', + returned: '已归还', + }, + settlement_status: { + unsettled: '未结算', + pending: '结算待确认', + settled: '已结算', + arbitrated: '仲裁结算', }, - settlement_status: { unsettled: '未结算', pending: '结算待确认', settled: '已结算', arbitrated: '仲裁结算' }, offline_settlement_status: { none: '无需线下结算', pending: '待线下结算', settled: '已线下结算' }, - pickup_status: { pending: '待处理', processing: '处理中', completed: '已完成', cancelled: '已取消' }, + pickup_status: { + pending: '待处理', + processing: '处理中', + completed: '已完成', + cancelled: '已取消', + }, } const stateFieldLabels: Record = { - order_status: '订单', handoff_status: '交接', settlement_status: '结算', - offline_settlement_status: '线下结算', pickup_status: '提号', + order_status: '订单', + handoff_status: '交接', + settlement_status: '结算', + offline_settlement_status: '线下结算', + pickup_status: '提号', } -function actionLabel(action: string) { return actionLabels[action] || action } -function stageLabel(stage: string) { return stageLabels[stage] || '流程' } +function actionLabel(action: string) { + return actionLabels[action] || action +} +function stageLabel(stage: string) { + return stageLabels[stage] || '流程' +} function actorLabel(item: ProcessTimelineEvent) { if (item.actor_name) return item.actor_name if (item.actor_type === 'system') return '系统' @@ -91,7 +156,9 @@ function actorRole(item: ProcessTimelineEvent) { } function payloadRows(payload?: Record) { if (!payload || typeof payload !== 'object') return [] - return Object.entries(payload).filter(([, value]) => value !== null && value !== undefined && value !== '') + return Object.entries(payload).filter( + ([, value]) => value !== null && value !== undefined && value !== '' + ) } function primaryPayloadRows(item: ProcessTimelineEvent) { return payloadRows(item.payload).filter(([key]) => primaryPayloadKeys.has(key)) @@ -99,12 +166,16 @@ function primaryPayloadRows(item: ProcessTimelineEvent) { function detailPayloadRows(item: ProcessTimelineEvent) { return payloadRows(item.payload).filter(([key]) => !primaryPayloadKeys.has(key)) } -function payloadLabel(key: string) { return payloadLabels[key] || key } +function payloadLabel(key: string) { + return payloadLabels[key] || key +} function payloadValue(key: string, value: unknown) { if (key.endsWith('_cent')) return formatCentWithSymbol(Number(value || 0)) - if (key === 'turn') return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value) + if (key === 'turn') + return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value) if (key === 'account_source') return value === 'external' ? '外部上传' : '站内上传' - if (key === 'settlement_mode') return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算' + if (key === 'settlement_mode') + return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算' if (typeof value === 'object') return JSON.stringify(value) return String(value) } @@ -117,7 +188,11 @@ function stateChanges(item: ProcessTimelineEvent) { const after = item.state_after || {} return Object.keys(stateFieldLabels) .filter(key => before[key] !== undefined && before[key] !== after[key]) - .map(key => ({ label: stateFieldLabels[key], before: stateValue(key, before[key]), after: stateValue(key, after[key]) })) + .map(key => ({ + label: stateFieldLabels[key], + before: stateValue(key, before[key]), + after: stateValue(key, after[key]), + })) } function hasMore(item: ProcessTimelineEvent) { return detailPayloadRows(item).length > 0 || stateChanges(item).length > 0 @@ -138,7 +213,10 @@ function hasMore(item: ProcessTimelineEvent) {
- {{ actorRole(item) }}{{ actorLabel(item) }} + {{ actorRole(item) }}{{ actorLabel(item) }} 通知/关联:{{ item.target_name }}

{{ item.content }}

@@ -182,44 +260,214 @@ function hasMore(item: ProcessTimelineEvent) { diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 2388f15..34c5e0d 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -46,8 +46,8 @@ export interface Order { growth_points_awarded?: number growth_points_awarded_at?: string account_snapshot?: Record - account_source?: 'internal' | 'external' | string - source_channel?: string + account_source?: 'internal' | 'external' | string + source_channel?: string listing_snapshot?: string checkout_info?: string counter_info?: string @@ -152,24 +152,24 @@ export interface HandoffRecord { } export interface ProcessEvent { - id: number - business_type: string - business_id: number - stage: string - action: string - actor_type: 'user' | 'admin' | 'system' | string - actor_id: number - actor_name: string - target_type: string - target_id?: number - target_name: string - content: string - reason: string - payload: Record - attachment_urls: string[] - state_before: Record - state_after: Record - created_at: string + id: number + business_type: string + business_id: number + stage: string + action: string + actor_type: 'user' | 'admin' | 'system' | string + actor_id: number + actor_name: string + target_type: string + target_id?: number + target_name: string + content: string + reason: string + payload: Record + attachment_urls: string[] + state_before: Record + state_after: Record + created_at: string } export interface PaymentOrder { @@ -312,10 +312,10 @@ export async function fetchHandoffRecords(id: string | number) { } export async function fetchAdminOrderProcessEvents(id: string | number) { - const { data } = await apiClient.get>( - `/admin/orders/${id}/process-events` - ) - return Array.isArray(data.data?.items) ? data.data.items : [] + const { data } = await apiClient.get>( + `/admin/orders/${id}/process-events` + ) + return Array.isArray(data.data?.items) ? data.data.items : [] } export async function confirmReceive(id: number) {