diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index 2fcf3a1..17fa620 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -10,7 +10,8 @@ import ( type ChatConversation struct { ID uint64 `gorm:"primaryKey" json:"id"` OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"` - ListingID *uint64 `gorm:"index" json:"listing_id"` + ListingID *uint64 `gorm:"index" json:"listing_id"` + DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"` // 发布群没有固定订单时,以下字段保存该商品最新订单的轻量快照,供客服列表筛选使用。 // 这样读取会话列表不必每次扫描全部订单再按商品分组。 LatestOrderID *uint64 `gorm:"index" json:"-"` diff --git a/backend/internal/model/wallet.go b/backend/internal/model/wallet.go index 869e5e2..4f1cdbd 100644 --- a/backend/internal/model/wallet.go +++ b/backend/internal/model/wallet.go @@ -4,7 +4,8 @@ import "time" type WalletAccount struct { ID uint64 `gorm:"primaryKey" json:"id"` - UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"` + UserID uint64 `gorm:"not null;uniqueIndex:uk_wallet_accounts_user_site" json:"user_id"` + DistributionSiteID uint64 `gorm:"not null;default:0;uniqueIndex:uk_wallet_accounts_user_site" json:"distribution_site_id"` AvailableBalanceCent int64 `gorm:"not null;default:0" json:"-"` FrozenBalanceCent int64 `gorm:"not null;default:0" json:"-"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` @@ -17,18 +18,19 @@ func (WalletAccount) TableName() string { } type WalletLedger struct { - ID uint64 `gorm:"primaryKey" json:"id"` - LedgerNo string `gorm:"size:64;not null;uniqueIndex" json:"ledger_no"` - UserID uint64 `gorm:"not null;index" json:"user_id"` - OrderID *uint64 `json:"order_id"` - Direction string `gorm:"size:16;not null" json:"direction"` - AmountCent int64 `gorm:"not null;default:0" json:"-"` - BalanceAfterCent int64 `gorm:"not null;default:0" json:"-"` - BalanceType string `gorm:"size:32;not null" json:"balance_type"` - BizType string `gorm:"size:32;not null" json:"biz_type"` - BizNo string `gorm:"size:64;not null" json:"biz_no"` - Remark string `gorm:"size:255;not null;default:''" json:"remark"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + LedgerNo string `gorm:"size:64;not null;uniqueIndex" json:"ledger_no"` + UserID uint64 `gorm:"not null;index:idx_wallet_ledger_user_site" json:"user_id"` + DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_wallet_ledger_user_site" json:"distribution_site_id"` + OrderID *uint64 `json:"order_id"` + Direction string `gorm:"size:16;not null" json:"direction"` + AmountCent int64 `gorm:"not null;default:0" json:"-"` + BalanceAfterCent int64 `gorm:"not null;default:0" json:"-"` + BalanceType string `gorm:"size:32;not null" json:"balance_type"` + BizType string `gorm:"size:32;not null" json:"biz_type"` + BizNo string `gorm:"size:64;not null" json:"biz_no"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` } func (WalletLedger) TableName() string { diff --git a/backend/internal/model/withdrawal.go b/backend/internal/model/withdrawal.go index 3af5010..4c5df5b 100644 --- a/backend/internal/model/withdrawal.go +++ b/backend/internal/model/withdrawal.go @@ -5,7 +5,8 @@ import "time" type WithdrawalRequest struct { ID uint64 `gorm:"primaryKey" json:"id"` WithdrawNo string `gorm:"size:64;not null;uniqueIndex" json:"withdraw_no"` - UserID uint64 `gorm:"not null;index" json:"user_id"` + UserID uint64 `gorm:"not null;index:idx_withdrawal_user_site" json:"user_id"` + DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_withdrawal_user_site" json:"distribution_site_id"` AmountCent int64 `gorm:"not null;default:0" json:"-"` FeeCent int64 `gorm:"not null;default:0" json:"-"` ActualAmountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/modules/chat/conversation.go b/backend/internal/modules/chat/conversation.go index ed3cfe0..dc9dcf3 100644 --- a/backend/internal/modules/chat/conversation.go +++ b/backend/internal/modules/chat/conversation.go @@ -3,28 +3,72 @@ package chat import ( "context" "errors" + "fmt" + "strings" + "time" + + "hfb_sys/backend/internal/model" + "gorm.io/gorm" "gorm.io/gorm/clause" - "hfb_sys/backend/internal/model" - "time" ) -func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int) (*PaginatedResult, error) { +func (r *Repository) resolveSite(ctx context.Context, siteCode string) (*model.DistributionSite, error) { + siteCode = strings.TrimSpace(siteCode) + if siteCode == "" { + return nil, nil + } + var site model.DistributionSite + if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("distribution site unavailable") + } + return nil, err + } + return &site, nil +} + +func (r *Repository) ListConversations(ctx context.Context, principal Principal, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) - var total int64 + + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + site, err := r.resolveSite(ctx, code) + if err != nil { + return nil, err + } + countDB := db.Table("chat_conversations AS c"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID). Where("c.status = ?", "active") + + dataQuery := r.conversationQuery(ctx, principal). + Where("c.status = ?", "active") + + if principal.Type == "user" { + if site != nil { + siteFilter := "(c.distribution_site_id = ? OR (c.order_id IS NOT NULL AND c.order_id IN (SELECT ro.id FROM rental_orders ro WHERE ro.distribution_site_id = ?)))" + countDB = countDB.Where(siteFilter, site.ID, site.ID) + dataQuery = dataQuery.Where(siteFilter, site.ID, site.ID) + } else { + mainFilter := "(c.distribution_site_id IS NULL OR c.distribution_site_id = 0) AND (c.order_id IS NULL OR c.order_id NOT IN (SELECT ro.id FROM rental_orders ro WHERE ro.distribution_site_id IS NOT NULL AND ro.distribution_site_id > 0))" + countDB = countDB.Where(mainFilter) + dataQuery = dataQuery.Where(mainFilter) + } + } + + var total int64 if err := countDB.Count(&total).Error; err != nil { return nil, err } var rows []conversationRow offset := (page - 1) * pageSize - err := r.conversationQuery(ctx, principal). - Where("c.status = ?", "active"). + err = dataQuery. Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC"). Offset(offset). Limit(pageSize). @@ -38,6 +82,7 @@ func (r *Repository) ListConversations(ctx context.Context, principal Principal, } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } + func (r *Repository) FindConversation(ctx context.Context, principal Principal, id uint64) (*ConversationDTO, error) { db := r.db.WithContext(ctx) // 管理员可以查看任意会话,无需是 participant @@ -69,6 +114,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, ID: conversation.ID, OrderID: conversation.OrderID, ListingID: conversation.ListingID, + DistributionSiteID: conversation.DistributionSiteID, Type: conversation.Type, SupportScene: conversation.SupportScene, Title: conversation.Title, @@ -102,6 +148,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal, dto := row.toDTO(participants) return &dto, nil } + func (r *Repository) FindOrderConversation(ctx context.Context, userID uint64, orderID uint64) (*ConversationDTO, error) { principal := Principal{Type: "user", ID: userID} var order model.RentalOrder @@ -112,6 +159,22 @@ func (r *Repository) FindOrderConversation(ctx context.Context, userID uint64, o return nil, err } + if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { + var row conversationRow + err := r.conversationQuery(ctx, principal).Where("c.order_id = ?", orderID).First(&row).Error + if err == nil { + participants, err := r.participants(ctx, row.ID) + if err != nil { + return nil, err + } + dto := row.toDTO(participants) + return &dto, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if order.ListingID > 0 { var row conversationRow err := r.conversationQuery(ctx, principal). @@ -145,31 +208,52 @@ func (r *Repository) FindOrderConversation(ctx context.Context, userID uint64, o dto := row.toDTO(participants) return &dto, nil } -func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint64, scene string) (*ConversationDTO, error) { + +func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint64, scene string, siteCode ...string) (*ConversationDTO, error) { + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + site, err := r.resolveSite(ctx, code) + if err != nil { + return nil, err + } + scene, title, groupCode, welcome := resolveSupportScene(scene) + if site != nil { + title = fmt.Sprintf("【%s】%s", site.SiteName, title) + welcome = fmt.Sprintf("您好,欢迎咨询【%s】在线客服!请直接描述您遇到的问题,客服会尽快回复。", site.SiteName) + } + var conversationID uint64 - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.ChatConversation - err := tx.Table("chat_conversations AS c"). + existingQuery := tx.Table("chat_conversations AS c"). Select("c.*"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where( "c.type = ? AND c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ?", ConversationTypeGeneralSupport, scene, "user", userID, - ). - Order("c.id ASC"). - Limit(1). - Find(&existing).Error + ) + + if site != nil { + existingQuery = existingQuery.Where("c.distribution_site_id = ?", site.ID) + } else { + existingQuery = existingQuery.Where("c.distribution_site_id IS NULL OR c.distribution_site_id = 0") + } + + err := existingQuery.Order("c.id ASC").Limit(1).Find(&existing).Error if err != nil { return err } - // 兼容旧数据:scene=general 时也匹配未回填 support_scene 的历史会话 - if existing.ID == 0 && scene == SupportSceneGeneral { + + // 兼容旧数据:主站 scene=general 时也匹配未回填 support_scene 的历史会话 + if site == nil && existing.ID == 0 && scene == SupportSceneGeneral { err = tx.Table("chat_conversations AS c"). Select("c.*"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where( - "c.type = ? AND (c.support_scene = '' OR c.support_scene IS NULL) AND cp.participant_type = ? AND cp.participant_id = ?", + "c.type = ? AND (c.support_scene = '' OR c.support_scene IS NULL) AND cp.participant_type = ? AND cp.participant_id = ? AND (c.distribution_site_id IS NULL OR c.distribution_site_id = 0)", ConversationTypeGeneralSupport, "user", userID, ). Order("c.id ASC"). @@ -183,12 +267,12 @@ func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint6 } } // 兼容旧摸大红场景码 mohong → crash - if existing.ID == 0 && scene == SupportSceneCrash { + if site == nil && existing.ID == 0 && scene == SupportSceneCrash { err = tx.Table("chat_conversations AS c"). Select("c.*"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where( - "c.type = ? AND c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ?", + "c.type = ? AND c.support_scene = ? AND cp.participant_type = ? AND cp.participant_id = ? AND (c.distribution_site_id IS NULL OR c.distribution_site_id = 0)", ConversationTypeGeneralSupport, SupportSceneMohong, "user", userID, ). Order("c.id ASC"). @@ -210,6 +294,9 @@ func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint6 Title: title, Status: "active", } + if site != nil { + conversation.DistributionSiteID = &site.ID + } if err := tx.Create(&conversation).Error; err != nil { return err } @@ -223,7 +310,19 @@ func (r *Repository) EnsureSupportConversation(ctx context.Context, userID uint6 JoinedAt: now, }, } - if supportID := pickSupportAdminForScene(tx, groupCode); supportID > 0 { + + var supportID uint64 + if site != nil && site.DefaultSupportGroupID != nil && *site.DefaultSupportGroupID > 0 { + var member model.ChatSupportGroupMember + if err := tx.Where("group_id = ?", *site.DefaultSupportGroupID).Order("id ASC").First(&member).Error; err == nil && member.AdminID > 0 { + supportID = member.AdminID + } + } + if supportID == 0 { + supportID = pickSupportAdminForScene(tx, groupCode) + } + + if supportID > 0 { participants = append(participants, model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "admin", diff --git a/backend/internal/modules/chat/conversation_test.go b/backend/internal/modules/chat/conversation_test.go index b47fea6..0b640d6 100644 --- a/backend/internal/modules/chat/conversation_test.go +++ b/backend/internal/modules/chat/conversation_test.go @@ -26,6 +26,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB { &model.ChatParticipant{}, &model.ChatAdminConversationState{}, &model.ChatMessage{}, + &model.DistributionSite{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) } @@ -151,3 +152,127 @@ func TestFindOrderConversationFallsBackToOrderGroup(t *testing.T) { t.Fatalf("会话类型 = %q, want %q", dto.Type, ConversationTypeOrderGroup) } } + +func TestChatTenantIsolation(t *testing.T) { + db := setupConversationTestDB(t) + repo := NewRepository(db, nil, nil) + now := time.Now() + + user := model.User{Phone: "13900002001", Nickname: "分销用户"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + site := model.DistributionSite{ + ID: 20, + SiteCode: "site_beta", + SiteName: "贝塔分销", + Domain: "beta.hfb.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatalf("创建分销站点失败: %v", err) + } + + // 1. 创建主站客服会话 + mainConv := model.ChatConversation{ + Type: ConversationTypeGeneralSupport, + SupportScene: SupportSceneGeneral, + Title: "主站客服", + Status: "active", + } + if err := db.Create(&mainConv).Error; err != nil { + t.Fatalf("创建主站客服会话失败: %v", err) + } + if err := db.Create(&model.ChatParticipant{ + ConversationID: mainConv.ID, + ParticipantType: "user", + ParticipantID: user.ID, + Role: "customer", + JoinedAt: now, + }).Error; err != nil { + t.Fatalf("添加主站会话成员失败: %v", err) + } + + // 2. 创建分销站客服会话 + siteConv := model.ChatConversation{ + DistributionSiteID: &site.ID, + Type: ConversationTypeGeneralSupport, + SupportScene: SupportSceneGeneral, + Title: "【贝塔分销】平台客服", + Status: "active", + } + if err := db.Create(&siteConv).Error; err != nil { + t.Fatalf("创建分站客服会话失败: %v", err) + } + if err := db.Create(&model.ChatParticipant{ + ConversationID: siteConv.ID, + ParticipantType: "user", + ParticipantID: user.ID, + Role: "customer", + JoinedAt: now, + }).Error; err != nil { + t.Fatalf("添加分站会话成员失败: %v", err) + } + + principal := Principal{Type: "user", ID: user.ID} + + // 3. 校验主站查询会话列表:严格只看到主站会话 + mainList, err := repo.ListConversations(t.Context(), principal, 1, 20) + if err != nil { + t.Fatalf("查询主站会话列表失败: %v", err) + } + if mainList.Total != 1 { + t.Fatalf("主站会话数量 = %d, want 1", mainList.Total) + } + mainItems := mainList.Items.([]ConversationDTO) + if mainItems[0].ID != mainConv.ID { + t.Fatalf("主站会话 ID = %d, want %d", mainItems[0].ID, mainConv.ID) + } + if mainItems[0].DistributionSiteID != nil { + t.Fatalf("主站会话 DistributionSiteID = %v, want nil", mainItems[0].DistributionSiteID) + } + + // 4. 校验分站查询会话列表:严格只看到分站会话 + siteList, err := repo.ListConversations(t.Context(), principal, 1, 20, site.SiteCode) + if err != nil { + t.Fatalf("查询分站会话列表失败: %v", err) + } + if siteList.Total != 1 { + t.Fatalf("分站会话数量 = %d, want 1", siteList.Total) + } + siteItems := siteList.Items.([]ConversationDTO) + if siteItems[0].ID != siteConv.ID { + t.Fatalf("分站会话 ID = %d, want %d", siteItems[0].ID, siteConv.ID) + } + if siteItems[0].DistributionSiteID == nil || *siteItems[0].DistributionSiteID != site.ID { + t.Fatalf("分站会话 DistributionSiteID = %v, want %d", siteItems[0].DistributionSiteID, site.ID) + } + + // 5. 测试 EnsureSupportConversation 分销站点场景 + ensuredSiteConv, err := repo.EnsureSupportConversation(t.Context(), user.ID, "crash", site.SiteCode) + if err != nil { + t.Fatalf("分站创建撞车客服失败: %v", err) + } + if ensuredSiteConv.DistributionSiteID == nil || *ensuredSiteConv.DistributionSiteID != site.ID { + t.Fatalf("新建分站会话 DistributionSiteID = %v, want %d", ensuredSiteConv.DistributionSiteID, site.ID) + } + + // 主站再次查询,仍然只有 1 个主站会话 + mainListAfter, err := repo.ListConversations(t.Context(), principal, 1, 20) + if err != nil { + t.Fatalf("主站列表查询失败: %v", err) + } + if mainListAfter.Total != 1 { + t.Fatalf("主站会话数量 = %d, want 1 (分站新增会话不应出现在主站)", mainListAfter.Total) + } + + // 分站再次查询,有 2 个分站会话 + siteListAfter, err := repo.ListConversations(t.Context(), principal, 1, 20, site.SiteCode) + if err != nil { + t.Fatalf("分站列表查询失败: %v", err) + } + if siteListAfter.Total != 2 { + t.Fatalf("分站会话数量 = %d, want 2", siteListAfter.Total) + } +} diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index ac02646..8687697 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -11,6 +11,7 @@ type ConversationDTO struct { ID uint64 `json:"id"` OrderID *uint64 `json:"order_id"` ListingID *uint64 `json:"listing_id"` + DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"` LatestOrderID *uint64 `json:"latest_order_id,omitempty"` LatestOrderNo string `json:"latest_order_no,omitempty"` LatestOrderStatus string `json:"latest_order_status,omitempty"` @@ -38,7 +39,8 @@ type ConversationDTO struct { // EnsureSupportRequest 创建/复用客服会话。 type EnsureSupportRequest struct { // Scene 业务场景:general(默认)/ crash(撞车,兼容旧值 mohong) - Scene string `json:"scene"` + Scene string `json:"scene"` + DistributionSiteCode string `json:"distribution_site_code"` } type ParticipantDTO struct { diff --git a/backend/internal/modules/chat/handler_common.go b/backend/internal/modules/chat/handler_common.go index e890626..a5a79e6 100644 --- a/backend/internal/modules/chat/handler_common.go +++ b/backend/internal/modules/chat/handler_common.go @@ -1,14 +1,31 @@ package chat import ( + "strings" + "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" ) +func parseSiteCode(c *gin.Context) string { + for _, val := range []string{ + c.Query("distribution_site_code"), + c.Query("site_code"), + c.GetHeader("X-Distribution-Site-Code"), + c.GetHeader("X-Site-Code"), + } { + if trimmed := strings.TrimSpace(val); trimmed != "" { + return trimmed + } + } + return "" +} + func (h *Handler) list(c *gin.Context, principal Principal) { page, pageSize := parsePagination(c) - result, err := h.service.ListConversations(c.Request.Context(), principal, page, pageSize) + siteCode := parseSiteCode(c) + result, err := h.service.ListConversations(c.Request.Context(), principal, page, pageSize, siteCode) if err != nil { writeChatError(c, err) return diff --git a/backend/internal/modules/chat/handler_user.go b/backend/internal/modules/chat/handler_user.go index acfce5e..b9636da 100644 --- a/backend/internal/modules/chat/handler_user.go +++ b/backend/internal/modules/chat/handler_user.go @@ -21,7 +21,8 @@ func (h *Handler) UnreadCount(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - result, err := h.service.CountUnreadMessages(c.Request.Context(), Principal{Type: "user", ID: userID}) + siteCode := parseSiteCode(c) + result, err := h.service.CountUnreadMessages(c.Request.Context(), Principal{Type: "user", ID: userID}, siteCode) if err != nil { writeChatError(c, err) return @@ -65,7 +66,11 @@ func (h *Handler) EnsureSupportConversation(c *gin.Context) { var req EnsureSupportRequest // scene 可选;空 body 仍按平台客服处理 _ = c.ShouldBindJSON(&req) - item, err := h.service.EnsureSupportConversation(c.Request.Context(), userID, req.Scene) + siteCode := parseSiteCode(c) + if siteCode == "" && req.DistributionSiteCode != "" { + siteCode = req.DistributionSiteCode + } + item, err := h.service.EnsureSupportConversation(c.Request.Context(), userID, req.Scene, siteCode) if err != nil { writeChatError(c, err) return diff --git a/backend/internal/modules/chat/participant.go b/backend/internal/modules/chat/participant.go index 7410185..af44f06 100644 --- a/backend/internal/modules/chat/participant.go +++ b/backend/internal/modules/chat/participant.go @@ -15,6 +15,7 @@ type conversationRow struct { ID uint64 OrderID *uint64 ListingID *uint64 + DistributionSiteID *uint64 LatestOrderID *uint64 LatestOrderNo string LatestOrderStatus string @@ -40,7 +41,7 @@ type conversationRow struct { func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB { return r.db.WithContext(ctx).Table("chat_conversations AS c"). - Select(`c.id, c.order_id, c.listing_id, c.type, c.support_scene, c.title, c.status, c.last_message_id, + Select(`c.id, c.order_id, c.listing_id, c.distribution_site_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, cp.role, ( SELECT COUNT(1) @@ -54,17 +55,37 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal) Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) } -func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principal) (int64, error) { - var total int64 - err := r.db.WithContext(ctx).Table("chat_messages AS cm"). +func (r *Repository) CountUnreadMessages(ctx context.Context, principal Principal, siteCode ...string) (int64, error) { + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + site, err := r.resolveSite(ctx, code) + if err != nil { + return 0, err + } + + query := r.db.WithContext(ctx).Table("chat_messages AS cm"). Joins("JOIN chat_participants AS cp ON cp.conversation_id = cm.conversation_id"). Joins("JOIN chat_conversations AS c ON c.id = cm.conversation_id"). 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 + Where("(cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)") + + if principal.Type == "user" { + if site != nil { + siteFilter := "(c.distribution_site_id = ? OR (c.order_id IS NOT NULL AND c.order_id IN (SELECT ro.id FROM rental_orders ro WHERE ro.distribution_site_id = ?)))" + query = query.Where(siteFilter, site.ID, site.ID) + } else { + mainFilter := "(c.distribution_site_id IS NULL OR c.distribution_site_id = 0) AND (c.order_id IS NULL OR c.order_id NOT IN (SELECT ro.id FROM rental_orders ro WHERE ro.distribution_site_id IS NOT NULL AND ro.distribution_site_id > 0))" + query = query.Where(mainFilter) + } + } + + var total int64 + err = query.Count(&total).Error return total, err } @@ -315,6 +336,7 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO ID: row.ID, OrderID: row.OrderID, ListingID: row.ListingID, + DistributionSiteID: row.DistributionSiteID, LatestOrderID: row.LatestOrderID, LatestOrderNo: row.LatestOrderNo, LatestOrderStatus: row.LatestOrderStatus, diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index d9891df..502a9fa 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -89,10 +89,11 @@ func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatC now := time.Now() conversation := model.ChatConversation{ - OrderID: &order.ID, - Type: "order_group", - Title: orderConversationTitle(order), - Status: "active", + OrderID: &order.ID, + DistributionSiteID: order.DistributionSiteID, + Type: "order_group", + Title: orderConversationTitle(order), + Status: "active", } if err := tx.Create(&conversation).Error; err != nil { return nil, err diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index f60ea05..3e8be1f 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -22,18 +22,18 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func (s *Service) ListConversations(ctx context.Context, principal Principal, page, pageSize int) (*PaginatedResult, error) { +func (s *Service) ListConversations(ctx context.Context, principal Principal, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListConversations(ctx, principal, page, pageSize) + return s.repo.ListConversations(ctx, principal, page, pageSize, siteCode...) } -func (s *Service) CountUnreadMessages(ctx context.Context, principal Principal) (*UnreadCountDTO, error) { +func (s *Service) CountUnreadMessages(ctx context.Context, principal Principal, siteCode ...string) (*UnreadCountDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - total, err := s.repo.CountUnreadMessages(ctx, principal) + total, err := s.repo.CountUnreadMessages(ctx, principal, siteCode...) if err != nil { return nil, err } @@ -54,14 +54,14 @@ func (s *Service) FindOrderConversation(ctx context.Context, userID uint64, orde return s.repo.FindOrderConversation(ctx, userID, orderID) } -func (s *Service) EnsureSupportConversation(ctx context.Context, userID uint64, scene string) (*ConversationDTO, error) { +func (s *Service) EnsureSupportConversation(ctx context.Context, userID uint64, scene string, siteCode ...string) (*ConversationDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if userID == 0 { return nil, ErrPermissionDenied } - return s.repo.EnsureSupportConversation(ctx, userID, scene) + return s.repo.EnsureSupportConversation(ctx, userID, scene, siteCode...) } func (s *Service) Messages(ctx context.Context, principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 41d093d..081affe 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -69,29 +69,35 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement return nil } orderID := order.ID + var siteID uint64 + if order.DistributionSiteID != nil { + siteID = *order.DistributionSiteID + } var ownerEntries []wallet.Entry if settlement.OwnerRentIncomeCent > 0 { ownerEntries = append(ownerEntries, wallet.Entry{ - UserID: order.OwnerID, - OrderID: &orderID, - Direction: "in", - AmountCent: settlement.OwnerRentIncomeCent, - BalanceType: "available", - BizType: walletBizOwnerIncome, - BizNo: order.OrderNo, - Remark: "订单结账租金收入", + UserID: order.OwnerID, + DistributionSiteID: siteID, + OrderID: &orderID, + Direction: "in", + AmountCent: settlement.OwnerRentIncomeCent, + BalanceType: "available", + BizType: walletBizOwnerIncome, + BizNo: order.OrderNo, + Remark: "订单结账租金收入", }) } if settlement.DepositCompensationCent > 0 { ownerEntries = append(ownerEntries, wallet.Entry{ - UserID: order.OwnerID, - OrderID: &orderID, - Direction: "in", - AmountCent: settlement.DepositCompensationCent, - BalanceType: "available", - BizType: walletBizDepositCompensation, - BizNo: order.OrderNo, - Remark: "订单结账押金赔付", + UserID: order.OwnerID, + DistributionSiteID: siteID, + OrderID: &orderID, + Direction: "in", + AmountCent: settlement.DepositCompensationCent, + BalanceType: "available", + BizType: walletBizDepositCompensation, + BizNo: order.OrderNo, + Remark: "订单结账押金赔付", }) } if len(ownerEntries) == 0 { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index dfb0bbc..23b7430 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -273,15 +273,25 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint return 0, err } - // 拉租客进发布群(替代原来的建订单群) + // 分销订单创建独立分站群聊;主站订单拉租客进发布群 conversationID := uint64(0) - if err := chat.AddRenterToListingConversation(tx, listing.ID, order.RenterID, order.OrderNo); err != nil { - return 0, err - } - // 查询发布群ID用于返回 - var listingConv model.ChatConversation - if err := tx.Where("listing_id = ?", listing.ID).First(&listingConv).Error; err == nil { - conversationID = listingConv.ID + if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { + orderConv, err := chat.EnsureOrderConversation(tx, *order) + if err != nil { + return 0, err + } + if orderConv != nil { + conversationID = orderConv.ID + } + } else { + if err := chat.AddRenterToListingConversation(tx, listing.ID, order.RenterID, order.OrderNo); err != nil { + return 0, err + } + // 查询发布群ID用于返回 + var listingConv model.ChatConversation + if err := tx.Where("listing_id = ?", listing.ID).First(&listingConv).Error; err == nil { + conversationID = listingConv.ID + } } if isPlatformHandoffOrder(*order) { diff --git a/backend/internal/modules/wallet/dto.go b/backend/internal/modules/wallet/dto.go index a945369..b6aebb5 100644 --- a/backend/internal/modules/wallet/dto.go +++ b/backend/internal/modules/wallet/dto.go @@ -4,6 +4,7 @@ import "time" type AccountDTO struct { UserID uint64 `json:"user_id"` + DistributionSiteID uint64 `json:"distribution_site_id"` AvailableBalanceCent int64 `json:"available_balance_cent"` FrozenBalanceCent int64 `json:"frozen_balance_cent"` Status string `json:"status"` @@ -14,18 +15,19 @@ type WithdrawRequest struct { } type LedgerDTO struct { - ID uint64 `json:"id"` - LedgerNo string `json:"ledger_no"` - UserID uint64 `json:"user_id"` - OrderID *uint64 `json:"order_id"` - Direction string `json:"direction"` - AmountCent int64 `json:"amount_cent"` - BalanceAfterCent int64 `json:"balance_after_cent"` - BalanceType string `json:"balance_type"` - BizType string `json:"biz_type"` - BizNo string `json:"biz_no"` - Remark string `json:"remark"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + LedgerNo string `json:"ledger_no"` + UserID uint64 `json:"user_id"` + DistributionSiteID uint64 `json:"distribution_site_id"` + OrderID *uint64 `json:"order_id"` + Direction string `json:"direction"` + AmountCent int64 `json:"amount_cent"` + BalanceAfterCent int64 `json:"balance_after_cent"` + BalanceType string `json:"balance_type"` + BizType string `json:"biz_type"` + BizNo string `json:"biz_no"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` } type AdminLedgerQuery struct { diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go index 103b43c..9f9b491 100644 --- a/backend/internal/modules/wallet/handler.go +++ b/backend/internal/modules/wallet/handler.go @@ -3,6 +3,7 @@ package wallet import ( "errors" "strconv" + "strings" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/pkg/response" @@ -18,13 +19,28 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func parseSiteCode(c *gin.Context) string { + for _, val := range []string{ + c.Query("distribution_site_code"), + c.Query("site_code"), + c.GetHeader("X-Distribution-Site-Code"), + c.GetHeader("X-Site-Code"), + } { + if trimmed := strings.TrimSpace(val); trimmed != "" { + return trimmed + } + } + return "" +} + func (h *Handler) Balance(c *gin.Context) { userID, ok := currentUserID(c) if !ok { response.Unauthorized(c, "缺少用户上下文") return } - account, err := h.service.Account(c.Request.Context(), userID) + siteCode := parseSiteCode(c) + account, err := h.service.Account(c.Request.Context(), userID, siteCode) if err != nil { writeWalletError(c, err) return @@ -54,7 +70,8 @@ func (h *Handler) Ledger(c *gin.Context) { return } page, pageSize := parsePagination(c) - result, err := h.service.Ledger(c.Request.Context(), userID, page, pageSize) + siteCode := parseSiteCode(c) + result, err := h.service.Ledger(c.Request.Context(), userID, page, pageSize, siteCode) if err != nil { writeWalletError(c, err) return diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 43c4114..ba7a4f7 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -3,7 +3,9 @@ package wallet import ( "context" "crypto/rand" + "errors" "fmt" + "strings" "time" "hfb_sys/backend/internal/model" @@ -19,27 +21,52 @@ type Repository struct { } type Entry struct { - UserID uint64 - OrderID *uint64 - Direction string - AmountCent int64 - BalanceType string - BizType string - BizNo string - Remark string + UserID uint64 + DistributionSiteID uint64 + OrderID *uint64 + Direction string + AmountCent int64 + BalanceType string + BizType string + BizNo string + Remark string } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } -func (r *Repository) Account(ctx context.Context, userID uint64) (*AccountDTO, error) { +func (r *Repository) resolveSiteID(ctx context.Context, siteCode string) (uint64, error) { + siteCode = strings.TrimSpace(siteCode) + if siteCode == "" { + return 0, nil + } + var site model.DistributionSite + if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, errors.New("distribution site unavailable") + } + return 0, err + } + return site.ID, nil +} + +func (r *Repository) Account(ctx context.Context, userID uint64, siteCode ...string) (*AccountDTO, error) { + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + siteID, err := r.resolveSiteID(ctx, code) + if err != nil { + return nil, err + } + var account model.WalletAccount - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := ensureAccount(tx, userID); err != nil { + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := ensureAccount(tx, userID, siteID); err != nil { return err } - return tx.Where("user_id = ?", userID).First(&account).Error + return tx.Where("user_id = ? AND distribution_site_id = ?", userID, siteID).First(&account).Error }) if err != nil { return nil, err @@ -47,14 +74,36 @@ func (r *Repository) Account(ctx context.Context, userID uint64) (*AccountDTO, e return toAccountDTO(account), nil } -func (r *Repository) Ledger(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { +func (r *Repository) Ledger(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + siteID, err := r.resolveSiteID(ctx, code) + if err != nil { + return nil, err + } + + query := r.db.WithContext(ctx).Model(&model.WalletLedger{}).Where("user_id = ?", userID) + if siteID > 0 { + query = query.Where("distribution_site_id = ?", siteID) + } else { + query = query.Where("distribution_site_id = 0 OR distribution_site_id IS NULL") + } + var total int64 - if err := r.db.WithContext(ctx).Model(&model.WalletLedger{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { + if err := query.Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var rows []model.WalletLedger - if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { + dataQuery := r.db.WithContext(ctx).Where("user_id = ?", userID) + if siteID > 0 { + dataQuery = dataQuery.Where("distribution_site_id = ?", siteID) + } else { + dataQuery = dataQuery.Where("distribution_site_id = 0 OR distribution_site_id IS NULL") + } + if err := dataQuery.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { return nil, err } items := make([]LedgerDTO, 0, len(rows)) @@ -127,11 +176,11 @@ func AppendEntries(tx *gorm.DB, entries ...Entry) error { if entry.AmountCent <= 0 { continue } - if err := ensureAccount(tx, entry.UserID); err != nil { + if err := ensureAccount(tx, entry.UserID, entry.DistributionSiteID); err != nil { return err } var account model.WalletAccount - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", entry.UserID).First(&account).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ? AND distribution_site_id = ?", entry.UserID, entry.DistributionSiteID).First(&account).Error; err != nil { return err } balanceAfterCent, err := applyEntry(&account, entry) @@ -146,16 +195,17 @@ func AppendEntries(tx *gorm.DB, entries ...Entry) error { return err } ledger := model.WalletLedger{ - LedgerNo: ledgerNo, - UserID: entry.UserID, - OrderID: entry.OrderID, - Direction: entry.Direction, - AmountCent: entry.AmountCent, - BalanceAfterCent: balanceAfterCent, - BalanceType: entry.BalanceType, - BizType: entry.BizType, - BizNo: entry.BizNo, - Remark: entry.Remark, + LedgerNo: ledgerNo, + UserID: entry.UserID, + DistributionSiteID: entry.DistributionSiteID, + OrderID: entry.OrderID, + Direction: entry.Direction, + AmountCent: entry.AmountCent, + BalanceAfterCent: balanceAfterCent, + BalanceType: entry.BalanceType, + BizType: entry.BizType, + BizNo: entry.BizNo, + Remark: entry.Remark, } if err := tx.Create(&ledger).Error; err != nil { return err @@ -164,14 +214,19 @@ func AppendEntries(tx *gorm.DB, entries ...Entry) error { return nil } -func ensureAccount(tx *gorm.DB, userID uint64) error { - return EnsureAccountTx(tx, userID) +func ensureAccount(tx *gorm.DB, userID uint64, siteID ...uint64) error { + return EnsureAccountTx(tx, userID, siteID...) } // EnsureAccountTx 在事务内确保用户钱包账户存在(幂等)。 -func EnsureAccountTx(tx *gorm.DB, userID uint64) error { +func EnsureAccountTx(tx *gorm.DB, userID uint64, siteID ...uint64) error { + var sID uint64 + if len(siteID) > 0 { + sID = siteID[0] + } account := model.WalletAccount{ UserID: userID, + DistributionSiteID: sID, AvailableBalanceCent: 0, FrozenBalanceCent: 0, Status: "active", @@ -213,6 +268,7 @@ func roundWalletMoney(yuan float64) float64 { func toAccountDTO(account model.WalletAccount) *AccountDTO { return &AccountDTO{ UserID: account.UserID, + DistributionSiteID: account.DistributionSiteID, AvailableBalanceCent: account.AvailableBalanceCent, FrozenBalanceCent: account.FrozenBalanceCent, Status: account.Status, @@ -221,18 +277,19 @@ func toAccountDTO(account model.WalletAccount) *AccountDTO { func toLedgerDTO(ledger model.WalletLedger) LedgerDTO { return LedgerDTO{ - ID: ledger.ID, - LedgerNo: ledger.LedgerNo, - UserID: ledger.UserID, - OrderID: ledger.OrderID, - Direction: ledger.Direction, - AmountCent: ledger.AmountCent, - BalanceAfterCent: ledger.BalanceAfterCent, - BalanceType: ledger.BalanceType, - BizType: ledger.BizType, - BizNo: ledger.BizNo, - Remark: ledger.Remark, - CreatedAt: ledger.CreatedAt, + ID: ledger.ID, + LedgerNo: ledger.LedgerNo, + UserID: ledger.UserID, + DistributionSiteID: ledger.DistributionSiteID, + OrderID: ledger.OrderID, + Direction: ledger.Direction, + AmountCent: ledger.AmountCent, + BalanceAfterCent: ledger.BalanceAfterCent, + BalanceType: ledger.BalanceType, + BizType: ledger.BizType, + BizNo: ledger.BizNo, + Remark: ledger.Remark, + CreatedAt: ledger.CreatedAt, } } diff --git a/backend/internal/modules/wallet/repository_integration_test.go b/backend/internal/modules/wallet/repository_integration_test.go index daa510c..6ee251e 100644 --- a/backend/internal/modules/wallet/repository_integration_test.go +++ b/backend/internal/modules/wallet/repository_integration_test.go @@ -24,6 +24,7 @@ func setupTestDB(t *testing.T) *gorm.DB { if err := db.AutoMigrate( &model.WalletAccount{}, &model.WalletLedger{}, + &model.DistributionSite{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) } @@ -259,3 +260,138 @@ func TestRepositoryLedgerPagination(t *testing.T) { t.Fatalf("Page 3 items = %d, want 5", len(items3)) } } + +func TestWalletTenantIsolation(t *testing.T) { + db := setupTestDB(t) + repo := NewRepository(db) + ctx := context.Background() + userID := uint64(2001) + + // 创建测试分销站点 + site := model.DistributionSite{ + ID: 10, + SiteCode: "branch_alpha", + SiteName: "阿尔法分站", + Domain: "alpha.hfb.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatalf("创建分销站点失败: %v", err) + } + + // 1. 在主站入账 5000 分 + err := db.Transaction(func(tx *gorm.DB) error { + return AppendEntries(tx, Entry{ + UserID: userID, + DistributionSiteID: 0, + Direction: "in", + AmountCent: 5000, + BalanceType: "available", + BizType: "main_recharge", + BizNo: "MAIN_001", + Remark: "主站充值", + }) + }) + if err != nil { + t.Fatalf("主站充值失败: %v", err) + } + + // 2. 在分销站点入账 3000 分 + err = db.Transaction(func(tx *gorm.DB) error { + return AppendEntries(tx, Entry{ + UserID: userID, + DistributionSiteID: site.ID, + Direction: "in", + AmountCent: 3000, + BalanceType: "available", + BizType: "site_income", + BizNo: "SITE_001", + Remark: "分销站收益", + }) + }) + if err != nil { + t.Fatalf("分站入账失败: %v", err) + } + + // 3. 校验主站钱包余额 + mainAccount, err := repo.Account(ctx, userID) + if err != nil { + t.Fatalf("查询主站钱包失败: %v", err) + } + if mainAccount.AvailableBalanceCent != 5000 { + t.Fatalf("主站余额 = %d, want 5000", mainAccount.AvailableBalanceCent) + } + if mainAccount.DistributionSiteID != 0 { + t.Fatalf("主站账号 DistributionSiteID = %d, want 0", mainAccount.DistributionSiteID) + } + + // 4. 校验分销站点钱包余额 + siteAccount, err := repo.Account(ctx, userID, site.SiteCode) + if err != nil { + t.Fatalf("查询分站钱包失败: %v", err) + } + if siteAccount.AvailableBalanceCent != 3000 { + t.Fatalf("分站余额 = %d, want 3000", siteAccount.AvailableBalanceCent) + } + if siteAccount.DistributionSiteID != site.ID { + t.Fatalf("分站账号 DistributionSiteID = %d, want %d", siteAccount.DistributionSiteID, site.ID) + } + + // 5. 校验流水隔离 + mainLedger, err := repo.Ledger(ctx, userID, 1, 10) + if err != nil { + t.Fatalf("查询主站流水失败: %v", err) + } + if mainLedger.Total != 1 { + t.Fatalf("主站流水条数 = %d, want 1", mainLedger.Total) + } + mainItems := mainLedger.Items.([]LedgerDTO) + if mainItems[0].BizNo != "MAIN_001" { + t.Fatalf("主站流水第一条 BizNo = %s, want MAIN_001", mainItems[0].BizNo) + } + + siteLedger, err := repo.Ledger(ctx, userID, 1, 10, site.SiteCode) + if err != nil { + t.Fatalf("查询分站流水失败: %v", err) + } + if siteLedger.Total != 1 { + t.Fatalf("分站流水条数 = %d, want 1", siteLedger.Total) + } + siteItems := siteLedger.Items.([]LedgerDTO) + if siteItems[0].BizNo != "SITE_001" { + t.Fatalf("分站流水第一条 BizNo = %s, want SITE_001", siteItems[0].BizNo) + } + + // 6. 分站扣款 2000 分,主站余额不受任何影响 + err = db.Transaction(func(tx *gorm.DB) error { + return AppendEntries(tx, Entry{ + UserID: userID, + DistributionSiteID: site.ID, + Direction: "out", + AmountCent: 2000, + BalanceType: "available", + BizType: "site_withdraw", + BizNo: "SITE_WD_001", + Remark: "分站提现支出", + }) + }) + if err != nil { + t.Fatalf("分站扣款失败: %v", err) + } + + siteAccountAfter, err := repo.Account(ctx, userID, site.SiteCode) + if err != nil { + t.Fatalf("查询扣款后分站钱包失败: %v", err) + } + if siteAccountAfter.AvailableBalanceCent != 1000 { + t.Fatalf("分站扣款后余额 = %d, want 1000", siteAccountAfter.AvailableBalanceCent) + } + + mainAccountAfter, err := repo.Account(ctx, userID) + if err != nil { + t.Fatalf("再次查询主站钱包失败: %v", err) + } + if mainAccountAfter.AvailableBalanceCent != 5000 { + t.Fatalf("主站余额受影响: got %d, want 5000", mainAccountAfter.AvailableBalanceCent) + } +} diff --git a/backend/internal/modules/wallet/service.go b/backend/internal/modules/wallet/service.go index fa96afe..a85e1d5 100644 --- a/backend/internal/modules/wallet/service.go +++ b/backend/internal/modules/wallet/service.go @@ -20,18 +20,18 @@ func NewService(repo *Repository) *Service { return &Service{repo: repo} } -func (s *Service) Account(ctx context.Context, userID uint64) (*AccountDTO, error) { +func (s *Service) Account(ctx context.Context, userID uint64, siteCode ...string) (*AccountDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.Account(ctx, userID) + return s.repo.Account(ctx, userID, siteCode...) } -func (s *Service) Ledger(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { +func (s *Service) Ledger(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.Ledger(ctx, userID, page, pageSize) + return s.repo.Ledger(ctx, userID, page, pageSize, siteCode...) } func (s *Service) Withdraw(ctx context.Context, userID uint64, req WithdrawRequest) (*AccountDTO, error) { diff --git a/backend/internal/modules/withdrawal/admin.go b/backend/internal/modules/withdrawal/admin.go index 7316907..585ccf8 100644 --- a/backend/internal/modules/withdrawal/admin.go +++ b/backend/internal/modules/withdrawal/admin.go @@ -106,21 +106,23 @@ func (r *Repository) Review(ctx context.Context, adminID, id uint64, req ReviewW // 如果拒绝,解冻余额 if !approved { if err := wallet.AppendEntries(tx, wallet.Entry{ - UserID: withdrawal.UserID, - Direction: "out", - AmountCent: withdrawal.AmountCent, - BalanceType: "frozen", - BizType: "withdraw_reject", - BizNo: withdrawal.WithdrawNo, - Remark: fmt.Sprintf("提现被拒绝: %s", req.Remark), + UserID: withdrawal.UserID, + DistributionSiteID: withdrawal.DistributionSiteID, + Direction: "out", + AmountCent: withdrawal.AmountCent, + BalanceType: "frozen", + BizType: "withdraw_reject", + BizNo: withdrawal.WithdrawNo, + Remark: fmt.Sprintf("提现被拒绝: %s", req.Remark), }, wallet.Entry{ - UserID: withdrawal.UserID, - Direction: "in", - AmountCent: withdrawal.AmountCent, - BalanceType: "available", - BizType: "withdraw_reject", - BizNo: withdrawal.WithdrawNo, - Remark: fmt.Sprintf("提现被拒绝: %s", req.Remark), + UserID: withdrawal.UserID, + DistributionSiteID: withdrawal.DistributionSiteID, + Direction: "in", + AmountCent: withdrawal.AmountCent, + BalanceType: "available", + BizType: "withdraw_reject", + BizNo: withdrawal.WithdrawNo, + Remark: fmt.Sprintf("提现被拒绝: %s", req.Remark), }); err != nil { return err } @@ -166,13 +168,14 @@ func (r *Repository) ConfirmPayment(ctx context.Context, adminID, id uint64, req // 扣除冻结余额 if err := wallet.AppendEntries(tx, wallet.Entry{ - UserID: withdrawal.UserID, - Direction: "out", - AmountCent: withdrawal.AmountCent, - BalanceType: "frozen", - BizType: "withdraw_complete", - BizNo: withdrawal.WithdrawNo, - Remark: "提现完成", + UserID: withdrawal.UserID, + DistributionSiteID: withdrawal.DistributionSiteID, + Direction: "out", + AmountCent: withdrawal.AmountCent, + BalanceType: "frozen", + BizType: "withdraw_complete", + BizNo: withdrawal.WithdrawNo, + Remark: "提现完成", }); err != nil { return err } diff --git a/backend/internal/modules/withdrawal/dto.go b/backend/internal/modules/withdrawal/dto.go index 75fe421..1faa62a 100644 --- a/backend/internal/modules/withdrawal/dto.go +++ b/backend/internal/modules/withdrawal/dto.go @@ -6,31 +6,33 @@ import ( // 用户端 DTO type WithdrawalDTO struct { - ID uint64 `json:"id"` - WithdrawNo string `json:"withdraw_no"` - UserID uint64 `json:"user_id"` - AmountCent int64 `json:"amount_cent"` - FeeCent int64 `json:"fee_cent"` - ActualAmountCent int64 `json:"actual_amount_cent"` - AccountType string `json:"account_type"` - AccountName string `json:"account_name"` - AccountNo string `json:"account_no"` // 脱敏 - BankName string `json:"bank_name"` - Status string `json:"status"` - ReviewRemark string `json:"review_remark"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ReviewedAt *time.Time `json:"reviewed_at"` - PaidAt *time.Time `json:"paid_at"` + ID uint64 `json:"id"` + WithdrawNo string `json:"withdraw_no"` + UserID uint64 `json:"user_id"` + DistributionSiteID uint64 `json:"distribution_site_id"` + AmountCent int64 `json:"amount_cent"` + FeeCent int64 `json:"fee_cent"` + ActualAmountCent int64 `json:"actual_amount_cent"` + AccountType string `json:"account_type"` + AccountName string `json:"account_name"` + AccountNo string `json:"account_no"` // 脱敏 + BankName string `json:"bank_name"` + Status string `json:"status"` + ReviewRemark string `json:"review_remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ReviewedAt *time.Time `json:"reviewed_at"` + PaidAt *time.Time `json:"paid_at"` } // 管理员端详细 DTO type WithdrawalDetailDTO struct { - ID uint64 `json:"id"` - WithdrawNo string `json:"withdraw_no"` - UserID uint64 `json:"user_id"` - UserNickname string `json:"user_nickname"` - UserPhone string `json:"user_phone"` + ID uint64 `json:"id"` + WithdrawNo string `json:"withdraw_no"` + UserID uint64 `json:"user_id"` + DistributionSiteID uint64 `json:"distribution_site_id"` + UserNickname string `json:"user_nickname"` + UserPhone string `json:"user_phone"` AmountCent int64 `json:"amount_cent"` FeeCent int64 `json:"fee_cent"` ActualAmountCent int64 `json:"actual_amount_cent"` diff --git a/backend/internal/modules/withdrawal/handler.go b/backend/internal/modules/withdrawal/handler.go index 062c0fd..c3c5c6a 100644 --- a/backend/internal/modules/withdrawal/handler.go +++ b/backend/internal/modules/withdrawal/handler.go @@ -1,8 +1,10 @@ package withdrawal import ( - "hfb_sys/backend/pkg/response" "strconv" + "strings" + + "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" ) @@ -15,6 +17,20 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func parseSiteCode(c *gin.Context) string { + for _, val := range []string{ + c.Query("distribution_site_code"), + c.Query("site_code"), + c.GetHeader("X-Distribution-Site-Code"), + c.GetHeader("X-Site-Code"), + } { + if trimmed := strings.TrimSpace(val); trimmed != "" { + return trimmed + } + } + return "" +} + // ========== 用户端接口 ========== func (h *Handler) Create(c *gin.Context) { @@ -30,7 +46,8 @@ func (h *Handler) Create(c *gin.Context) { return } - withdrawal, err := h.service.Create(c.Request.Context(), userID, req) + siteCode := parseSiteCode(c) + withdrawal, err := h.service.Create(c.Request.Context(), userID, req, siteCode) if err != nil { writeError(c, err) return @@ -49,7 +66,8 @@ func (h *Handler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - result, err := h.service.List(c.Request.Context(), userID, page, pageSize) + siteCode := parseSiteCode(c) + result, err := h.service.List(c.Request.Context(), userID, page, pageSize, siteCode) if err != nil { writeError(c, err) return diff --git a/backend/internal/modules/withdrawal/presenter.go b/backend/internal/modules/withdrawal/presenter.go index bac8907..62a7882 100644 --- a/backend/internal/modules/withdrawal/presenter.go +++ b/backend/internal/modules/withdrawal/presenter.go @@ -10,22 +10,23 @@ import ( // 转换为用户DTO func toDTO(w model.WithdrawalRequest) WithdrawalDTO { return WithdrawalDTO{ - ID: w.ID, - WithdrawNo: w.WithdrawNo, - UserID: w.UserID, - AmountCent: w.AmountCent, - FeeCent: w.FeeCent, - ActualAmountCent: w.ActualAmountCent, - AccountType: w.AccountType, - AccountName: w.AccountName, - AccountNo: w.AccountNo, - BankName: w.BankName, - Status: w.Status, - ReviewRemark: w.ReviewRemark, - CreatedAt: w.CreatedAt, - UpdatedAt: w.UpdatedAt, - ReviewedAt: w.ReviewedAt, - PaidAt: w.PaidAt, + ID: w.ID, + WithdrawNo: w.WithdrawNo, + UserID: w.UserID, + DistributionSiteID: w.DistributionSiteID, + AmountCent: w.AmountCent, + FeeCent: w.FeeCent, + ActualAmountCent: w.ActualAmountCent, + AccountType: w.AccountType, + AccountName: w.AccountName, + AccountNo: w.AccountNo, + BankName: w.BankName, + Status: w.Status, + ReviewRemark: w.ReviewRemark, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + ReviewedAt: w.ReviewedAt, + PaidAt: w.PaidAt, } } @@ -75,10 +76,11 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest) } return &WithdrawalDetailDTO{ - ID: w.ID, - WithdrawNo: w.WithdrawNo, - UserID: w.UserID, - UserNickname: user.Nickname, + ID: w.ID, + WithdrawNo: w.WithdrawNo, + UserID: w.UserID, + DistributionSiteID: w.DistributionSiteID, + UserNickname: user.Nickname, UserPhone: user.Phone, AmountCent: w.AmountCent, FeeCent: w.FeeCent, diff --git a/backend/internal/modules/withdrawal/service.go b/backend/internal/modules/withdrawal/service.go index a4eee2b..651319e 100644 --- a/backend/internal/modules/withdrawal/service.go +++ b/backend/internal/modules/withdrawal/service.go @@ -32,7 +32,7 @@ func NewService(repo *Repository) *Service { } // 用户端方法 -func (s *Service) Create(ctx context.Context, userID uint64, req CreateWithdrawalRequest) (*WithdrawalDTO, error) { +func (s *Service) Create(ctx context.Context, userID uint64, req CreateWithdrawalRequest, siteCode ...string) (*WithdrawalDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } @@ -45,10 +45,10 @@ func (s *Service) Create(ctx context.Context, userID uint64, req CreateWithdrawa return nil, ErrMaxWithdrawalAmount } - return s.repo.Create(ctx, userID, req) + return s.repo.Create(ctx, userID, req, siteCode...) } -func (s *Service) List(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { +func (s *Service) List(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } @@ -58,7 +58,7 @@ func (s *Service) List(ctx context.Context, userID uint64, page, pageSize int) ( if pageSize < 1 || pageSize > 100 { pageSize = 20 } - return s.repo.List(ctx, userID, page, pageSize) + return s.repo.List(ctx, userID, page, pageSize, siteCode...) } func (s *Service) FindByID(ctx context.Context, userID, id uint64) (*WithdrawalDTO, error) { diff --git a/backend/internal/modules/withdrawal/user.go b/backend/internal/modules/withdrawal/user.go index a033ea9..dfc4407 100644 --- a/backend/internal/modules/withdrawal/user.go +++ b/backend/internal/modules/withdrawal/user.go @@ -3,6 +3,7 @@ package withdrawal import ( "context" "errors" + "strings" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/wallet" @@ -11,8 +12,23 @@ import ( "gorm.io/gorm/clause" ) +func (r *Repository) resolveSiteID(ctx context.Context, siteCode string) (uint64, error) { + siteCode = strings.TrimSpace(siteCode) + if siteCode == "" { + return 0, nil + } + var site model.DistributionSite + if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, errors.New("distribution site unavailable") + } + return 0, err + } + return site.ID, nil +} + // 用户创建提现申请 -func (r *Repository) Create(ctx context.Context, userID uint64, req CreateWithdrawalRequest) (*WithdrawalDTO, error) { +func (r *Repository) Create(ctx context.Context, userID uint64, req CreateWithdrawalRequest, siteCode ...string) (*WithdrawalDTO, error) { db := r.db.WithContext(ctx) // 验证收款账号 var paymentAccount model.UserPaymentAccount @@ -24,6 +40,15 @@ func (r *Repository) Create(ctx context.Context, userID uint64, req CreateWithdr return nil, err } + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + siteID, err := r.resolveSiteID(ctx, code) + if err != nil { + return nil, err + } + // 计算手续费和实际到账金额(分) feeCent := int64(float64(req.AmountCent) * WithdrawalFeeRate) actualAmountCent := req.AmountCent - feeCent @@ -41,18 +66,19 @@ func (r *Repository) Create(ctx context.Context, userID uint64, req CreateWithdr } withdrawal := model.WithdrawalRequest{ - WithdrawNo: withdrawNo, - UserID: userID, - AmountCent: req.AmountCent, - FeeCent: feeCent, - ActualAmountCent: actualAmountCent, - PaymentAccountID: &req.PaymentAccountID, - AccountType: paymentAccount.AccountType, - AccountName: paymentAccount.AccountName, - AccountNo: maskAccountNo(decryptedNo, paymentAccount.AccountType), - BankName: paymentAccount.BankName, - BankBranch: paymentAccount.BankBranch, - Status: "pending", + WithdrawNo: withdrawNo, + UserID: userID, + DistributionSiteID: siteID, + AmountCent: req.AmountCent, + FeeCent: feeCent, + ActualAmountCent: actualAmountCent, + PaymentAccountID: &req.PaymentAccountID, + AccountType: paymentAccount.AccountType, + AccountName: paymentAccount.AccountName, + AccountNo: maskAccountNo(decryptedNo, paymentAccount.AccountType), + BankName: paymentAccount.BankName, + BankBranch: paymentAccount.BankBranch, + Status: "pending", } // 事务处理 @@ -64,21 +90,23 @@ func (r *Repository) Create(ctx context.Context, userID uint64, req CreateWithdr // 冻结用户余额 if err := wallet.AppendEntries(tx, wallet.Entry{ - UserID: userID, - Direction: "out", - AmountCent: req.AmountCent, - BalanceType: "available", - BizType: "withdraw_freeze", - BizNo: withdrawNo, - Remark: "提现冻结", + UserID: userID, + DistributionSiteID: siteID, + Direction: "out", + AmountCent: req.AmountCent, + BalanceType: "available", + BizType: "withdraw_freeze", + BizNo: withdrawNo, + Remark: "提现冻结", }, wallet.Entry{ - UserID: userID, - Direction: "in", - AmountCent: req.AmountCent, - BalanceType: "frozen", - BizType: "withdraw_freeze", - BizNo: withdrawNo, - Remark: "提现冻结", + UserID: userID, + DistributionSiteID: siteID, + Direction: "in", + AmountCent: req.AmountCent, + BalanceType: "frozen", + BizType: "withdraw_freeze", + BizNo: withdrawNo, + Remark: "提现冻结", }); err != nil { return err } @@ -94,21 +122,35 @@ func (r *Repository) Create(ctx context.Context, userID uint64, req CreateWithdr } // 用户查询提现列表 - -// 用户查询提现列表 -func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { +func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*PaginatedResult, error) { db := r.db.WithContext(ctx) + var code string + if len(siteCode) > 0 { + code = siteCode[0] + } + siteID, err := r.resolveSiteID(ctx, code) + if err != nil { + return nil, err + } + + countDB := db.Model(&model.WithdrawalRequest{}).Where("user_id = ?", userID) + dataDB := db.Where("user_id = ?", userID) + if siteID > 0 { + countDB = countDB.Where("distribution_site_id = ?", siteID) + dataDB = dataDB.Where("distribution_site_id = ?", siteID) + } else { + countDB = countDB.Where("distribution_site_id = 0 OR distribution_site_id IS NULL") + dataDB = dataDB.Where("distribution_site_id = 0 OR distribution_site_id IS NULL") + } + var total int64 - if err := db.Model(&model.WithdrawalRequest{}). - Where("user_id = ?", userID). - Count(&total).Error; err != nil { + if err := countDB.Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var withdrawals []model.WithdrawalRequest - if err := db.Where("user_id = ?", userID). - Order("created_at DESC"). + if err := dataDB.Order("created_at DESC"). Offset(offset).Limit(pageSize). Find(&withdrawals).Error; err != nil { return nil, err @@ -170,21 +212,23 @@ func (r *Repository) Cancel(ctx context.Context, userID, id uint64) error { // 解冻余额 if err := wallet.AppendEntries(tx, wallet.Entry{ - UserID: userID, - Direction: "out", - AmountCent: withdrawal.AmountCent, - BalanceType: "frozen", - BizType: "withdraw_cancel", - BizNo: withdrawal.WithdrawNo, - Remark: "用户取消提现", + UserID: userID, + DistributionSiteID: withdrawal.DistributionSiteID, + Direction: "out", + AmountCent: withdrawal.AmountCent, + BalanceType: "frozen", + BizType: "withdraw_cancel", + BizNo: withdrawal.WithdrawNo, + Remark: "用户取消提现", }, wallet.Entry{ - UserID: userID, - Direction: "in", - AmountCent: withdrawal.AmountCent, - BalanceType: "available", - BizType: "withdraw_cancel", - BizNo: withdrawal.WithdrawNo, - Remark: "用户取消提现", + UserID: userID, + DistributionSiteID: withdrawal.DistributionSiteID, + Direction: "in", + AmountCent: withdrawal.AmountCent, + BalanceType: "available", + BizType: "withdraw_cancel", + BizNo: withdrawal.WithdrawNo, + Remark: "用户取消提现", }); err != nil { return err } diff --git a/backend/migrations/000070_distribution_chat_wallet_isolation.sql b/backend/migrations/000070_distribution_chat_wallet_isolation.sql new file mode 100644 index 0000000..80921e0 --- /dev/null +++ b/backend/migrations/000070_distribution_chat_wallet_isolation.sql @@ -0,0 +1,41 @@ +-- +goose Up + +-- 1. chat_conversations 表新增 distribution_site_id +ALTER TABLE chat_conversations + ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER listing_id, + ADD KEY idx_chat_conversations_distribution_site (distribution_site_id, status); + +-- 2. wallet_accounts 表新增 distribution_site_id 并将唯一键变更为 (user_id, distribution_site_id) +ALTER TABLE wallet_accounts + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id, + DROP KEY uk_wallet_accounts_user_id, + ADD UNIQUE KEY uk_wallet_accounts_user_site (user_id, distribution_site_id); + +-- 3. wallet_ledger 表新增 distribution_site_id 列与联合索引 +ALTER TABLE wallet_ledger + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id, + ADD KEY idx_wallet_ledger_user_site (user_id, distribution_site_id); + +-- 4. withdrawal_requests 表新增 distribution_site_id 列与联合索引 +ALTER TABLE withdrawal_requests + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id, + ADD KEY idx_withdrawal_user_site (user_id, distribution_site_id); + +-- +goose Down + +ALTER TABLE withdrawal_requests + DROP KEY idx_withdrawal_user_site, + DROP COLUMN distribution_site_id; + +ALTER TABLE wallet_ledger + DROP KEY idx_wallet_ledger_user_site, + DROP COLUMN distribution_site_id; + +ALTER TABLE wallet_accounts + DROP KEY uk_wallet_accounts_user_site, + ADD UNIQUE KEY uk_wallet_accounts_user_id (user_id), + DROP COLUMN distribution_site_id; + +ALTER TABLE chat_conversations + DROP KEY idx_chat_conversations_distribution_site, + DROP COLUMN distribution_site_id; diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index 0916d85..72fb134 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -40,6 +40,7 @@ export interface ChatConversation { last_sender_role?: string unread_count: number needs_reply: boolean + distribution_site_id?: number | null created_at: string updated_at: string } @@ -92,17 +93,34 @@ export interface ChatMessage { created_at: string } -export async function fetchChats(page = 1, pageSize = 20) { +export async function fetchChats(page = 1, pageSize = 20, distributionSiteCode?: string) { + const siteCode = + distributionSiteCode || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const params: Record = { page, page_size: pageSize } + if (siteCode) { + params.distribution_site_code = siteCode + } const { data } = await apiClient.get>>('/chats', { - params: { page, page_size: pageSize }, + params, }) return data.data } -export async function fetchUnreadChatCount() { +export async function fetchUnreadChatCount(distributionSiteCode?: string) { + const siteCode = + distributionSiteCode || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const params: Record = {} + if (siteCode) { + params.distribution_site_code = siteCode + } const { data } = await apiClient.get>( '/chats/unread-count', { + params, silent: true, } ) @@ -119,10 +137,21 @@ export async function fetchOrderChat(orderId: number) { return data.data } -export async function ensureSupportChat(scene: SupportScene | string = 'general') { - const { data } = await apiClient.post>('/chats/support', { +export async function ensureSupportChat( + scene: SupportScene | string = 'general', + distributionSiteCode?: string +) { + const siteCode = + distributionSiteCode || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const payload: { scene: string; distribution_site_code?: string } = { scene: scene || 'general', - }) + } + if (siteCode) { + payload.distribution_site_code = siteCode + } + const { data } = await apiClient.post>('/chats/support', payload) return data.data } diff --git a/frontend/src/features/chats/views/MessagesView.vue b/frontend/src/features/chats/views/MessagesView.vue index 72f5b6a..ec6c7f7 100644 --- a/frontend/src/features/chats/views/MessagesView.vue +++ b/frontend/src/features/chats/views/MessagesView.vue @@ -18,9 +18,11 @@ import { useNotificationUnreadCount, } from '@/features/auth/composables/useNotificationUnreadCount' import { formatDateMinute, formatDateTime } from '@/shared/utils/time' +import { useDistributionSiteStore } from '@/stores/distributionSite' const router = useRouter() const route = useRoute() +const distributionStore = useDistributionSiteStore() const currentUserId = Number(localStorage.getItem('user_id') || 0) // ── 聊天状态 ── @@ -65,7 +67,11 @@ async function loadChats(isRefresh = false, showLoading = true) { if (loading.value) return if (showLoading) loading.value = true try { - const res = await fetchChats(page.value, pageSize) + const res = await fetchChats( + page.value, + pageSize, + distributionStore.isDistribution ? distributionStore.siteCode : undefined + ) conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items] total.value = res.total if (conversations.value.length < res.total && res.items.length > 0) { @@ -180,13 +186,30 @@ function handleTabChange(tab: string) {
@@ -352,6 +375,12 @@ function handleTabChange(tab: string) { align-items: flex-end; } +.distribution-site-tag { + margin-left: 8px; + vertical-align: middle; + font-weight: 500; +} + .header-actions { display: flex; gap: 10px; diff --git a/frontend/src/features/chats/views/MobileMessagesView.vue b/frontend/src/features/chats/views/MobileMessagesView.vue index 6953709..c47233b 100644 --- a/frontend/src/features/chats/views/MobileMessagesView.vue +++ b/frontend/src/features/chats/views/MobileMessagesView.vue @@ -5,8 +5,10 @@ import { showToast } from 'vant' import MobileBottomNav from '@/components/MobileBottomNav.vue' import { fetchChats, type ChatConversation } from '@/features/chats/api/chats' import { formatDateMinute } from '@/shared/utils/time' +import { useDistributionSiteStore } from '@/stores/distributionSite' const router = useRouter() +const distributionStore = useDistributionSiteStore() const loading = ref(false) const refreshing = ref(false) const finished = ref(false) @@ -26,7 +28,11 @@ async function loadChats(isRefresh = false) { } loading.value = true try { - const res = await fetchChats(page.value, pageSize) + const res = await fetchChats( + page.value, + pageSize, + distributionStore.isDistribution ? distributionStore.siteCode : undefined + ) conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items] total.value = res.total if (conversations.value.length >= res.total || res.items.length === 0) { @@ -85,7 +91,9 @@ const unreadTotal = computed(() => -

消息

+

+ {{ distributionStore.isDistribution ? `${distributionStore.siteName} · 消息` : '消息' }} +

{{ unreadTotal > 0 ? `${unreadTotal} 未读` : '' }} diff --git a/frontend/src/features/wallet/api/wallet.ts b/frontend/src/features/wallet/api/wallet.ts index 8b76ad7..44040ef 100644 --- a/frontend/src/features/wallet/api/wallet.ts +++ b/frontend/src/features/wallet/api/wallet.ts @@ -8,6 +8,7 @@ export interface WalletAccount { available_balance_cent: number frozen_balance_cent: number status: WalletStatus + distribution_site_id?: number } export interface WalletLedger { @@ -22,19 +23,36 @@ export interface WalletLedger { biz_type: string biz_no: string remark: string + distribution_site_id?: number created_at: string } -export async function fetchWalletBalance() { - const { data } = await apiClient.get>('/wallet/balance') +export async function fetchWalletBalance(distributionSiteCode?: string) { + const siteCode = + distributionSiteCode || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const params: Record = {} + if (siteCode) { + params.distribution_site_code = siteCode + } + const { data } = await apiClient.get>('/wallet/balance', { params }) return data.data } -export async function fetchWalletLedger(page = 1, pageSize = 20) { +export async function fetchWalletLedger(page = 1, pageSize = 20, distributionSiteCode?: string) { + const siteCode = + distributionSiteCode || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const params: Record = { page, page_size: pageSize } + if (siteCode) { + params.distribution_site_code = siteCode + } const { data } = await apiClient.get>>( '/wallet/ledger', { - params: { page, page_size: pageSize }, + params, } ) return data.data diff --git a/frontend/src/features/wallet/api/withdrawal.ts b/frontend/src/features/wallet/api/withdrawal.ts index b0efa5f..dc9ce3e 100644 --- a/frontend/src/features/wallet/api/withdrawal.ts +++ b/frontend/src/features/wallet/api/withdrawal.ts @@ -55,6 +55,7 @@ export interface WithdrawalRequest { bank_name: string status: WithdrawalStatus review_remark: string + distribution_site_id?: number created_at: string updated_at: string reviewed_at: string | null @@ -65,6 +66,7 @@ export interface WithdrawalRequest { export interface CreateWithdrawalRequest { payment_account_id: number amount_cent: number + distribution_site_code?: string } // ========== 收款账号管理 API ========== @@ -110,16 +112,36 @@ export async function setDefaultPaymentAccount(id: number) { // ========== 提现申请 API ========== -export async function createWithdrawal(req: CreateWithdrawalRequest) { - const { data } = await apiClient.post>('/withdrawals', req) +export async function createWithdrawal( + req: CreateWithdrawalRequest, + distributionSiteCode?: string +) { + const siteCode = + distributionSiteCode || + req.distribution_site_code || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const payload: CreateWithdrawalRequest = { ...req } + if (siteCode) { + payload.distribution_site_code = siteCode + } + const { data } = await apiClient.post>('/withdrawals', payload) return data.data } -export async function fetchWithdrawals(page = 1, pageSize = 20) { +export async function fetchWithdrawals(page = 1, pageSize = 20, distributionSiteCode?: string) { + const siteCode = + distributionSiteCode || + (typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') || + undefined + const params: Record = { page, page_size: pageSize } + if (siteCode) { + params.distribution_site_code = siteCode + } const { data } = await apiClient.get>>( '/withdrawals', { - params: { page, page_size: pageSize }, + params, } ) return data.data diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.css b/frontend/src/features/wallet/views/MobileWithdrawalView.css index 2e36b65..90c7ba4 100644 --- a/frontend/src/features/wallet/views/MobileWithdrawalView.css +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.css @@ -52,6 +52,19 @@ box-shadow: 0 14px 28px rgba(255, 106, 0, 0.22); } +.site-account-tip { + display: inline-block; + align-self: start; + width: fit-content; + padding: 2px 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.25); + color: #fff; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.5px; +} + .balance-panel span, .balance-panel small { color: rgba(255, 255, 255, 0.72); diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.vue b/frontend/src/features/wallet/views/MobileWithdrawalView.vue index d2341a9..443a6f0 100644 --- a/frontend/src/features/wallet/views/MobileWithdrawalView.vue +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.vue @@ -23,8 +23,10 @@ import { formatCent, formatMoney, yuanToCent } from '@/shared/utils/money' import { formatDateMinute } from '@/shared/utils/time' import alipayReceiveQrExample from '@/assets/alipay-receive-qr-example.png' import wechatReceiveQrExample from '@/assets/wechat-receive-qr-example.png' +import { useDistributionSiteStore } from '@/stores/distributionSite' const router = useRouter() +const distributionStore = useDistributionSiteStore() const loading = ref(false) const submitting = ref(false) @@ -104,10 +106,11 @@ onMounted(loadData) async function loadData() { loading.value = true try { + const siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined const [balanceData, accountsData, withdrawalsData] = await Promise.all([ - fetchWalletBalance(), + fetchWalletBalance(siteCode), fetchPaymentAccounts(1, 100), - fetchWithdrawals(1, 20), + fetchWithdrawals(1, 20, siteCode), ]) account.value = balanceData paymentAccounts.value = accountsData.items @@ -142,10 +145,13 @@ async function handleSubmit() { submitting.value = true try { - await createWithdrawal({ - payment_account_id: withdrawForm.payment_account_id, - amount_cent: amountCent.value, - }) + await createWithdrawal( + { + payment_account_id: withdrawForm.payment_account_id, + amount_cent: amountCent.value, + }, + distributionStore.isDistribution ? distributionStore.siteCode : undefined + ) showToast({ message: '提现申请已提交', icon: 'passed' }) withdrawForm.amount = '' await loadData() @@ -386,13 +392,18 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning -

提现

+

+ {{ distributionStore.isDistribution ? `${distributionStore.siteName} · 提现` : '提现' }} +

+ 可用余额 ¥{{ formatCent(availableBalanceCent) }} 冻结余额 ¥{{ formatCent(account?.frozen_balance_cent) }} diff --git a/frontend/src/features/wallet/views/WalletView.vue b/frontend/src/features/wallet/views/WalletView.vue index d2d63e1..4c91772 100644 --- a/frontend/src/features/wallet/views/WalletView.vue +++ b/frontend/src/features/wallet/views/WalletView.vue @@ -23,8 +23,10 @@ import { } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import { formatCentWithSymbol } from '@/shared/utils/money' +import { useDistributionSiteStore } from '@/stores/distributionSite' const router = useRouter() +const distributionStore = useDistributionSiteStore() const loading = ref(false) const account = ref(null) const ledger = ref([]) @@ -66,9 +68,10 @@ onMounted(loadWallet) async function loadWallet() { loading.value = true try { + const siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined const [balance, result] = await Promise.all([ - fetchWalletBalance(), - fetchWalletLedger(currentPage.value, currentPageSize.value), + fetchWalletBalance(siteCode), + fetchWalletLedger(currentPage.value, currentPageSize.value, siteCode), ]) account.value = balance ledger.value = result.items @@ -138,9 +141,32 @@ function amountPrefix(direction: string) {
当前可用 @@ -258,6 +284,12 @@ function amountPrefix(direction: string) { max-width: 780px; } +.distribution-site-tag { + margin-left: 8px; + vertical-align: middle; + font-weight: 500; +} + .wallet-hero-action { min-width: 220px; padding: 16px 18px; diff --git a/frontend/src/features/wallet/views/WithdrawalView.vue b/frontend/src/features/wallet/views/WithdrawalView.vue index 8374519..a3a8d35 100644 --- a/frontend/src/features/wallet/views/WithdrawalView.vue +++ b/frontend/src/features/wallet/views/WithdrawalView.vue @@ -14,8 +14,10 @@ import { } from '../api/withdrawal' import { fetchWalletBalance, type WalletAccount } from '../api/wallet' import { formatMoney, formatMoneyWithSymbol, formatCent, yuanToCent } from '@/shared/utils/money' +import { useDistributionSiteStore } from '@/stores/distributionSite' const router = useRouter() +const distributionStore = useDistributionSiteStore() const loading = ref(false) const submitting = ref(false) @@ -42,10 +44,11 @@ onMounted(() => { async function loadData() { loading.value = true try { + const siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined const [balanceData, accountsData, withdrawalsData] = await Promise.all([ - fetchWalletBalance(), + fetchWalletBalance(siteCode), fetchPaymentAccounts(1, 100), - fetchWithdrawals(currentPage.value, pageSize.value), + fetchWithdrawals(currentPage.value, pageSize.value, siteCode), ]) account.value = balanceData paymentAccounts.value = accountsData.items @@ -95,10 +98,13 @@ async function handleSubmit() { ) submitting.value = true - await createWithdrawal({ - payment_account_id: withdrawForm.value.payment_account_id, - amount_cent: yuanToCent(withdrawForm.value.amount), - }) + await createWithdrawal( + { + payment_account_id: withdrawForm.value.payment_account_id, + amount_cent: yuanToCent(withdrawForm.value.amount), + }, + distributionStore.isDistribution ? distributionStore.siteCode : undefined + ) ElMessage.success('提现申请已提交') withdrawForm.value.amount = 0 await loadData() @@ -167,8 +173,25 @@ function accountTypeLabel(type: string) {