合并 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
+68 -21
View File
@@ -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 {
@@ -42,22 +53,58 @@ func (ChatParticipant) TableName() string {
return "chat_participants"
}
// ChatAdminConversationState stores per-admin state even when the admin is not
// an assigned participant of the conversation.
type ChatAdminConversationState struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"conversation_id"`
AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_chat_admin_conversation_state;index" json:"admin_user_id"`
Remark string `gorm:"size:128;not null;default:''" json:"remark"`
LastReadMessageID uint64 `gorm:"not null;default:0" json:"last_read_message_id"`
LastReadAt *time.Time `json:"last_read_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatAdminConversationState) TableName() string {
return "chat_admin_conversation_states"
}
type ChatMessage struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
CreatedAt time.Time `json:"created_at"`
ID uint64 `gorm:"primaryKey" json:"id"`
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
SenderType string `gorm:"size:16;not null" json:"sender_type"`
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
Content string `json:"content"`
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"`
CreatedAt time.Time `json:"created_at"`
}
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"`
+19
View File
@@ -4,6 +4,7 @@ import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type RentalOrder struct {
@@ -77,6 +78,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 (
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// AdminPickup 管理员线下提号记录。
// 独立于 rental_orders,不进入正常订单状态机与财务统计口径。
// 独立于 rental_orders 和正常订单状态机;完成后进入线下提号财务统计。
// 站内上传完成时入卖家钱包;平台代管完成后进入线下结算。
type AdminPickup struct {
ID uint64 `gorm:"primaryKey" json:"id"`
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// ProcessEvent 是订单、提号等交易流程的不可变操作留痕。
// 当前业务表仍保存当前状态;此表只追加,用于还原每一步由谁填写、修改和确认。
type ProcessEvent struct {
ID uint64 `gorm:"primaryKey" json:"id"`
BusinessType string `gorm:"size:32;not null;index:idx_process_event_business" json:"business_type"`
BusinessID uint64 `gorm:"not null;index:idx_process_event_business" json:"business_id"`
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
Action string `gorm:"size:64;not null" json:"action"`
ActorType string `gorm:"size:16;not null" json:"actor_type"`
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
ActorName string `gorm:"size:128;not null;default:''" json:"actor_name"`
TargetType string `gorm:"size:16;not null;default:''" json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `gorm:"size:128;not null;default:''" json:"target_name"`
Content string `gorm:"type:text" json:"content"`
Reason string `gorm:"type:text" json:"reason"`
Payload datatypes.JSON `gorm:"not null" json:"payload"`
AttachmentURLs datatypes.JSON `gorm:"column:attachment_urls;not null" json:"attachment_urls"`
StateBefore datatypes.JSON `gorm:"column:state_before;not null" json:"state_before"`
StateAfter datatypes.JSON `gorm:"column:state_after;not null" json:"state_after"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (ProcessEvent) TableName() string {
return "process_events"
}