From 3508608a8fa24bf362684691eb7c0232b0c3bc9d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 11 Sep 2026 12:59:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B4=A7=E6=9E=B6=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C=E3=80=81?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=A4=A7=E4=BA=8B=E5=8A=A1=E3=80=81=E5=A4=A7?= =?UTF-8?q?=E7=9B=98=E4=B8=8E=E5=89=8D=E5=8F=B0=E5=A4=9A=E7=AB=AF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=BC=93=E5=AD=98=E5=8F=8A=E8=B4=A7=E6=9E=B6=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/distribution/dto.go | 32 +- .../internal/modules/distribution/handler.go | 23 ++ .../distribution/handler_integration_test.go | 30 +- .../internal/modules/distribution/release.go | 1 + .../modules/distribution/repository.go | 279 +++++++++++++++++- .../modules/distribution/repository_test.go | 93 ++++++ .../internal/modules/distribution/service.go | 7 + backend/internal/router/router.go | 2 + distribution-admin/src/api.ts | 45 ++- distribution-admin/src/pages/Accounts.tsx | 54 ++-- 10 files changed, 518 insertions(+), 48 deletions(-) diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 8349a61..6fa566a 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -408,9 +408,37 @@ type UpdateAccountMarkupRequest struct { ShelfRatioDelta float64 `json:"shelf_ratio_delta"` } +type AccountMarkupItem struct { + ID uint64 `json:"id,omitempty"` + ListingID uint64 `json:"listing_id,omitempty"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta"` +} + type BatchUpdateAccountMarkupRequest struct { - ListingIDs []uint64 `json:"listing_ids" binding:"required"` - ShelfRatioDelta float64 `json:"shelf_ratio_delta"` + ListingIDs []uint64 `json:"listing_ids"` + DisplayIDs []uint64 `json:"display_ids"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta"` + Items []AccountMarkupItem `json:"items"` +} + +type BatchUpdateAccountDisplayRequest struct { + DisplayIDs []uint64 `json:"display_ids"` + ListingIDs []uint64 `json:"listing_ids"` + TargetStatus string `json:"target_status" binding:"required,oneof=enabled disabled"` +} + +type AccountDisplayQuery struct { + Page int `form:"page" json:"page"` + PageSize int `form:"page_size" json:"page_size"` + Status string `form:"status" json:"status"` + Keyword string `form:"keyword" json:"keyword"` +} + +type AccountDisplayPageDTO struct { + Items []AccountDisplayDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` } // 商户钱包与提现 DTO diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index f24f56e..c97f7d5 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -614,6 +614,29 @@ func (h *Handler) BatchUpdateAccountMarkup(c *gin.Context) { response.OK(c, gin.H{"updated": true}) } +func (h *Handler) BatchUpdateAccountDisplay(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + var req BatchUpdateAccountDisplayRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "批量启停参数格式不正确") + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + adminID, _ := currentAdminID(c) + if err := h.service.BatchUpdateAccountDisplay(c.Request.Context(), adminID, siteID, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + func (h *Handler) ListReceivedListings(c *gin.Context) { siteID, err := h.resolveSiteID(c) if err != nil { diff --git a/backend/internal/modules/distribution/handler_integration_test.go b/backend/internal/modules/distribution/handler_integration_test.go index d01e4b6..dea0950 100644 --- a/backend/internal/modules/distribution/handler_integration_test.go +++ b/backend/internal/modules/distribution/handler_integration_test.go @@ -74,6 +74,7 @@ func setupHandlerTestEnv(t *testing.T) *handlerTestEnv { siteProtected.PUT("/received-listings/:id/status", h.UpdateReceivedListingStatus) siteProtected.PUT("/accounts/:id/markup", h.UpdateAccountMarkup) siteProtected.POST("/accounts/batch-markup", h.BatchUpdateAccountMarkup) + siteProtected.POST("/accounts/batch-display", h.BatchUpdateAccountDisplay) } // 3. 总台管理路由 @@ -453,16 +454,39 @@ func TestHandler_SiteOperatorEndpoints(t *testing.T) { t.Fatalf("update markup expected 200, got %d: %s", wMarkup.Code, wMarkup.Body.String()) } - // 8. 批量调价 + // 8. 批量调价(支持 Items 个性化与统一加价) batchMarkupReq := BatchUpdateAccountMarkupRequest{ - ListingIDs: []uint64{masterListing.ID}, - ShelfRatioDelta: 20, + Items: []AccountMarkupItem{ + {ListingID: masterListing.ID, ShelfRatioDelta: 20}, + }, } wBatchMarkup := env.request("POST", "/api/v1/distribution/accounts/batch-markup", batchMarkupReq, adminA.ID, nil) if wBatchMarkup.Code != http.StatusOK { t.Fatalf("batch markup expected 200, got %d: %s", wBatchMarkup.Code, wBatchMarkup.Body.String()) } + // 8.1 批量启停下架(POST /api/v1/distribution/accounts/batch-display) + batchDisplayReq := BatchUpdateAccountDisplayRequest{ + DisplayIDs: []uint64{dsa.ID}, + TargetStatus: "disabled", + } + wBatchDisplay := env.request("POST", "/api/v1/distribution/accounts/batch-display", batchDisplayReq, adminA.ID, nil) + if wBatchDisplay.Code != http.StatusOK { + t.Fatalf("batch display expected 200, got %d: %s", wBatchDisplay.Code, wBatchDisplay.Body.String()) + } + var dsaCheck model.DistributionSiteAccount + env.db.First(&dsaCheck, dsa.ID) + if dsaCheck.DisplayStatus != "disabled" { + t.Fatalf("expected dsa disabled, got %s", dsaCheck.DisplayStatus) + } + + // 恢复展示 + batchDisplayReq.TargetStatus = "enabled" + wBatchDisplayEnable := env.request("POST", "/api/v1/distribution/accounts/batch-display", batchDisplayReq, adminA.ID, nil) + if wBatchDisplayEnable.Code != http.StatusOK { + t.Fatalf("batch display enable expected 200, got %d: %s", wBatchDisplayEnable.Code, wBatchDisplayEnable.Body.String()) + } + // 9. 查询收号资产列表 wRecList := env.request("GET", "/api/v1/distribution/received-listings?page=1&size=10", nil, adminA.ID, nil) if wRecList.Code != http.StatusOK { diff --git a/backend/internal/modules/distribution/release.go b/backend/internal/modules/distribution/release.go index 2a949b2..0da56fc 100644 --- a/backend/internal/modules/distribution/release.go +++ b/backend/internal/modules/distribution/release.go @@ -41,6 +41,7 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error) Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses). Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses). Order("f.order_id ASC"). + Limit(1000). Scan(&orderIDs).Error; err != nil { return 0, err } diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index a41d45a..5964501 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -346,6 +346,7 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd if err != nil { return nil, err } + r.evictSiteInfoCache(ctx, &row) item := siteDTO(row) return &item, nil } @@ -855,6 +856,130 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta return items, nil } +func (r *Repository) ListAccountDisplaysPaged(ctx context.Context, siteID uint64, q AccountDisplayQuery) (*AccountDisplayPageDTO, error) { + page := q.Page + if page <= 0 { + page = 1 + } + pageSize := q.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + baseQuery := r.db.WithContext(ctx). + Table("distribution_site_accounts AS dsa"). + Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id"). + Joins("LEFT JOIN game_accounts AS ga ON ga.id = dsa.account_id"). + Where("dsa.site_id = ?", siteID) + + if q.Status != "" { + baseQuery = baseQuery.Where("dsa.display_status = ?", q.Status) + } + keyword := strings.TrimSpace(q.Keyword) + if keyword != "" { + baseQuery = baseQuery.Where("(ga.title LIKE ? OR l.listing_no LIKE ? OR ga.rank_level LIKE ?)", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + + var total int64 + if err := baseQuery.Count(&total).Error; err != nil { + return nil, err + } + + type accountDisplayRow struct { + ID uint64 `gorm:"column:id"` + SiteID uint64 `gorm:"column:site_id"` + AccountID uint64 `gorm:"column:account_id"` + ListingID uint64 `gorm:"column:listing_id"` + DisplayStatus string `gorm:"column:display_status"` + MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` + ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` + ListingNo string `gorm:"column:listing_no"` + Title string `gorm:"column:title"` + GameName string `gorm:"column:game_name"` + ServerRegion string `gorm:"column:server_region"` + LoginPlatform string `gorm:"column:login_platform"` + RankLevel string `gorm:"column:rank_level"` + HafCoinAmount int64 `gorm:"column:haf_coin_amount"` + PriceCent int64 `gorm:"column:price_cent"` + DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` + ListingStatus string `gorm:"column:listing_status"` + InTransaction bool `gorm:"column:in_transaction"` + ListingSiteID *uint64 `gorm:"column:listing_site_id"` + SourceChannel string `gorm:"column:source_channel"` + } + + var rows []accountDisplayRow + err := baseQuery.Select(` + dsa.id, + dsa.site_id, + dsa.account_id, + dsa.listing_id, + dsa.display_status, + COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, + COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta, + COALESCE(l.listing_no, '') AS listing_no, + l.distribution_site_id AS listing_site_id, + COALESCE(l.source_channel, '') AS source_channel, + COALESCE(ga.title, '') AS title, + COALESCE(ga.game_name, '') AS game_name, + COALESCE(ga.server_region, '') AS server_region, + COALESCE(ga.login_platform, '') AS login_platform, + COALESCE(ga.rank_level, '') AS rank_level, + COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, + COALESCE(l.price_cent, 0) AS price_cent, + COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, + COALESCE(l.status, '') AS listing_status, + COALESCE(l.in_transaction, 0) AS in_transaction + `). + Order("dsa.id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Scan(&rows).Error + if err != nil { + return nil, err + } + + items := make([]AccountDisplayDTO, 0, len(rows)) + for _, row := range rows { + sitePrice, baseRatio, siteRatio := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta) + items = append(items, AccountDisplayDTO{ + ID: row.ID, + SiteID: row.SiteID, + AccountID: row.AccountID, + ListingID: row.ListingID, + DisplayStatus: row.DisplayStatus, + MarkupAmountCent: sitePrice - row.PriceCent, + ShelfRatioDelta: row.ShelfRatioDelta, + BaseRatio: baseRatio, + SiteRatio: siteRatio, + ListingNo: row.ListingNo, + Title: row.Title, + GameName: row.GameName, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RankLevel: row.RankLevel, + HafCoinAmount: row.HafCoinAmount, + PriceCent: row.PriceCent, + SitePriceCent: sitePrice, + DepositAmountCent: row.DepositAmountCent, + ListingStatus: row.ListingStatus, + InTransaction: row.InTransaction, + ListingSiteID: row.ListingSiteID, + SourceChannel: row.SourceChannel, + }) + } + + return &AccountDisplayPageDTO{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) { var row model.DistributionSiteAccount if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { @@ -1172,12 +1297,21 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, return err } total := int64(0) + items := make([]model.DistributionSettlementItem, 0, len(parties)) for _, party := range parties { - item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: party.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"} - if err := tx.Create(&item).Error; err != nil { + items = append(items, model.DistributionSettlementItem{ + BatchID: batch.ID, + OrderID: party.OrderID, + PartyID: party.ID, + AmountCent: party.IncomeAmountCent, + Status: "pending", + }) + total += party.IncomeAmountCent + } + if len(items) > 0 { + if err := tx.CreateInBatches(&items, 500).Error; err != nil { return err } - total += party.IncomeAmountCent } batch.TotalAmountCent = total return tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID). @@ -1887,6 +2021,22 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string }, nil } +func (r *Repository) evictSiteInfoCache(ctx context.Context, site *model.DistributionSite) { + if r.redis == nil || site == nil { + return + } + keys := make([]string, 0, 2) + if site.SiteCode != "" { + keys = append(keys, fmt.Sprintf("dist:site_info:code:%s", site.SiteCode)) + } + if site.Domain != "" { + keys = append(keys, fmt.Sprintf("dist:site_info:domain:%s", site.Domain)) + } + if len(keys) > 0 { + _ = r.redis.Del(ctx, keys...).Err() + } +} + func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) { if r.db == nil { return nil, ErrDependencyUnavailable @@ -1897,6 +2047,22 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) return nil, ErrSiteNotFound } + var cacheKey string + if code != "" { + cacheKey = fmt.Sprintf("dist:site_info:code:%s", code) + } else if domain != "" { + cacheKey = fmt.Sprintf("dist:site_info:domain:%s", domain) + } + if cacheKey != "" && r.redis != nil { + val, err := r.redis.Get(ctx, cacheKey).Result() + if err == nil && val != "" { + var cached PublicSiteInfoDTO + if jsonErr := json.Unmarshal([]byte(val), &cached); jsonErr == nil { + return &cached, nil + } + } + } + var site model.DistributionSite query := r.db.WithContext(ctx).Where("status = 'active'") if code != "" { @@ -1912,7 +2078,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) return nil, err } - return &PublicSiteInfoDTO{ + res := &PublicSiteInfoDTO{ SiteCode: site.SiteCode, SiteName: site.SiteName, Domain: site.Domain, @@ -1924,7 +2090,18 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) BannersJSON: site.BannersJSON, AnnouncementsJSON: site.AnnouncementsJSON, FooterInfoJSON: site.FooterInfoJSON, - }, nil + } + if r.redis != nil { + if bytes, err := json.Marshal(res); err == nil { + if site.SiteCode != "" { + _ = r.redis.Set(ctx, fmt.Sprintf("dist:site_info:code:%s", site.SiteCode), string(bytes), 10*time.Minute).Err() + } + if site.Domain != "" { + _ = r.redis.Set(ctx, fmt.Sprintf("dist:site_info:domain:%s", site.Domain), string(bytes), 10*time.Minute).Err() + } + } + } + return res, nil } func generateWithdrawNo() (string, error) { @@ -1995,6 +2172,7 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin return nil, err } } + r.evictSiteInfoCache(ctx, &site) dto := siteDTO(site) return &dto, nil } @@ -2035,15 +2213,77 @@ func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, site if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return err } + + // 模式 A:单次提交多个商品的个性化加价列表 + if len(req.Items) > 0 { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, item := range req.Items { + if item.ShelfRatioDelta < 0 { + return ErrInvalidAmount + } + q := tx.Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) + if item.ID > 0 { + q = q.Where("id = ?", item.ID) + } else if item.ListingID > 0 { + q = q.Where("listing_id = ?", item.ListingID) + } else { + continue + } + if err := q.Update("shelf_ratio_delta", item.ShelfRatioDelta).Error; err != nil { + return err + } + } + return nil + }) + } + + // 模式 B:统一加价比例 if req.ShelfRatioDelta < 0 { return ErrInvalidAmount } - if len(req.ListingIDs) == 0 { + displayIDs := uniquePositiveIDs(req.DisplayIDs) + listingIDs := uniquePositiveIDs(req.ListingIDs) + if len(displayIDs) == 0 && len(listingIDs) == 0 { return ErrEmptyListingIDs } - return r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}). - Where("site_id = ? AND listing_id IN ?", siteID, req.ListingIDs). - Update("shelf_ratio_delta", req.ShelfRatioDelta).Error + + q := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) + if len(displayIDs) > 0 && len(listingIDs) > 0 { + q = q.Where("(id IN ? OR listing_id IN ?)", displayIDs, listingIDs) + } else if len(displayIDs) > 0 { + q = q.Where("id IN ?", displayIDs) + } else { + q = q.Where("listing_id IN ?", listingIDs) + } + return q.Update("shelf_ratio_delta", req.ShelfRatioDelta).Error +} + +func (r *Repository) BatchUpdateAccountDisplay(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountDisplayRequest) error { + if siteID == 0 { + return ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return err + } + targetStatus := strings.TrimSpace(req.TargetStatus) + if targetStatus != "enabled" && targetStatus != "disabled" { + return ErrInvalidListingStatus + } + displayIDs := uniquePositiveIDs(req.DisplayIDs) + listingIDs := uniquePositiveIDs(req.ListingIDs) + if len(displayIDs) == 0 && len(listingIDs) == 0 { + return ErrEmptyListingIDs + } + + q := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) + if len(displayIDs) > 0 && len(listingIDs) > 0 { + q = q.Where("(id IN ? OR listing_id IN ?)", displayIDs, listingIDs) + } else if len(displayIDs) > 0 { + q = q.Where("id IN ?", displayIDs) + } else { + q = q.Where("listing_id IN ?", listingIDs) + } + return q.Update("display_status", targetStatus).Error } func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { @@ -2450,6 +2690,17 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID return nil, err } + cacheKey := fmt.Sprintf("dist:analytics:%d", siteID) + if r.redis != nil { + val, err := r.redis.Get(ctx, cacheKey).Result() + if err == nil && val != "" { + var cached DistributionAnalyticsDTO + if jsonErr := json.Unmarshal([]byte(val), &cached); jsonErr == nil { + return &cached, nil + } + } + } + todayStr := time.Now().Format("2006-01-02") monthStr := time.Now().Format("2006-01") @@ -2555,7 +2806,7 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID }) } - return &DistributionAnalyticsDTO{ + dto := &DistributionAnalyticsDTO{ TodayReceivedCount: todayReceived, TotalReceivedCount: totalReceived, ActiveListingCount: activeListings, @@ -2565,5 +2816,11 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID MonthProfitCent: monthProfit, AvailableBalanceCent: wallet.AvailableBalanceCent, TrendDays: trendDays, - }, nil + } + if r.redis != nil { + if bytes, err := json.Marshal(dto); err == nil { + _ = r.redis.Set(ctx, cacheKey, string(bytes), 60*time.Second).Err() + } + } + return dto, nil } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 5800a32..6099922 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -1287,3 +1287,96 @@ func TestSyncSiteAccountsOptimizations(t *testing.T) { } repo.siteSyncLocks.Delete(site.ID) } + +func TestBatchOperationsAndCachingOptimizations(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + + site, err := repo.CreateSite(t.Context(), CreateSiteRequest{ + SiteCode: "opt_batch_site", + SiteName: "批量优化分站", + Domain: "opt-batch.hfb.local", + }) + if err != nil { + t.Fatal(err) + } + + acc1 := model.GameAccount{Title: "王者生化账号-1", GameName: "cfhd", ServerRegion: "东部", HafCoinAmount: 50_000_000, Status: "active"} + acc2 := model.GameAccount{Title: "王者爆破账号-2", GameName: "cfhd", ServerRegion: "北部", HafCoinAmount: 60_000_000, Status: "active"} + db.Create(&acc1) + db.Create(&acc2) + + l1 := model.RentalListing{ListingNo: "L-BATCH-1", AccountID: acc1.ID, OwnerID: 1, PriceCent: 3000, Status: "published", ReviewStatus: "approved"} + l2 := model.RentalListing{ListingNo: "L-BATCH-2", AccountID: acc2.ID, OwnerID: 1, PriceCent: 4000, Status: "published", ReviewStatus: "approved"} + db.Create(&l1) + db.Create(&l2) + + // 同步到货架 + _, err = repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{}) + if err != nil { + t.Fatal(err) + } + + var dsaList []model.DistributionSiteAccount + db.Where("site_id = ?", site.ID).Find(&dsaList) + if len(dsaList) != 2 { + t.Fatalf("expected 2 accounts on shelf, got %d", len(dsaList)) + } + + // 1. 验证 BatchUpdateAccountDisplay 批量下架与恢复展示 + if err := repo.BatchUpdateAccountDisplay(t.Context(), 0, site.ID, BatchUpdateAccountDisplayRequest{ + DisplayIDs: []uint64{dsaList[0].ID, dsaList[1].ID}, + TargetStatus: "disabled", + }); err != nil { + t.Fatalf("BatchUpdateAccountDisplay disabled failed: %v", err) + } + var countDisabled int64 + db.Model(&model.DistributionSiteAccount{}).Where("site_id = ? AND display_status = 'disabled'", site.ID).Count(&countDisabled) + if countDisabled != 2 { + t.Fatalf("expected 2 disabled, got %d", countDisabled) + } + + // 批量恢复 + if err := repo.BatchUpdateAccountDisplay(t.Context(), 0, site.ID, BatchUpdateAccountDisplayRequest{ + ListingIDs: []uint64{l1.ID, l2.ID}, + TargetStatus: "enabled", + }); err != nil { + t.Fatalf("BatchUpdateAccountDisplay enabled failed: %v", err) + } + var countEnabled int64 + db.Model(&model.DistributionSiteAccount{}).Where("site_id = ? AND display_status = 'enabled'", site.ID).Count(&countEnabled) + if countEnabled != 2 { + t.Fatalf("expected 2 enabled, got %d", countEnabled) + } + + // 2. 验证 BatchUpdateAccountMarkup 支持原子提交 Items 差异化加价 + if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ + Items: []AccountMarkupItem{ + {ListingID: l1.ID, ShelfRatioDelta: 12.0}, + {ListingID: l2.ID, ShelfRatioDelta: 18.5}, + }, + }); err != nil { + t.Fatalf("BatchUpdateAccountMarkup with Items failed: %v", err) + } + + var dsa1 model.DistributionSiteAccount + var dsa2 model.DistributionSiteAccount + db.Where("site_id = ? AND listing_id = ?", site.ID, l1.ID).First(&dsa1) + db.Where("site_id = ? AND listing_id = ?", site.ID, l2.ID).First(&dsa2) + if dsa1.ShelfRatioDelta != 12.0 || dsa2.ShelfRatioDelta != 18.5 { + t.Fatalf("items markup mismatch: dsa1=%v, dsa2=%v", dsa1.ShelfRatioDelta, dsa2.ShelfRatioDelta) + } + + // 3. 验证 ListAccountDisplaysPaged 分页与关键字筛选 + pagedResult, err := repo.ListAccountDisplaysPaged(t.Context(), site.ID, AccountDisplayQuery{ + Page: 1, + PageSize: 10, + Keyword: "生化", + }) + if err != nil { + t.Fatalf("ListAccountDisplaysPaged failed: %v", err) + } + if pagedResult.Total != 1 || len(pagedResult.Items) != 1 || pagedResult.Items[0].ListingNo != "L-BATCH-1" { + t.Fatalf("paged keyword mismatch: total=%d, items=%+v", pagedResult.Total, pagedResult.Items) + } +} diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index d26960a..0d43929 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -275,6 +275,13 @@ func (s *Service) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID return s.repo.BatchUpdateAccountMarkup(ctx, adminID, siteID, req) } +func (s *Service) BatchUpdateAccountDisplay(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountDisplayRequest) error { + if err := s.available(); err != nil { + return err + } + return s.repo.BatchUpdateAccountDisplay(ctx, adminID, siteID, req) +} + func (s *Service) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { if err := s.available(); err != nil { return nil, err diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6dadf1c..551f6e4 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -626,6 +626,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { distributionProtectedRoutes.PUT("/received-listings/:id/status", distributionHandler.UpdateReceivedListingStatus) distributionProtectedRoutes.PUT("/accounts/:id/markup", distributionHandler.UpdateAccountMarkup) distributionProtectedRoutes.POST("/accounts/batch-markup", distributionHandler.BatchUpdateAccountMarkup) + distributionProtectedRoutes.POST("/accounts/batch-display", distributionHandler.BatchUpdateAccountDisplay) } adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged()) @@ -661,6 +662,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.PUT("/distribution/accounts/:id/display", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountDisplay) adminRoutes.PUT("/distribution/accounts/:id/markup", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountMarkup) adminRoutes.POST("/distribution/accounts/batch-markup", requirePerm("distribution:account_display"), distributionHandler.BatchUpdateAccountMarkup) + adminRoutes.POST("/distribution/accounts/batch-display", requirePerm("distribution:account_display"), distributionHandler.BatchUpdateAccountDisplay) adminRoutes.GET("/distribution/payment-channels", requirePerm("distribution:payment_view"), distributionHandler.ListPaymentChannels) adminRoutes.POST("/distribution/payment-channels", requirePerm("distribution:payment_manage"), distributionHandler.BindPaymentChannel) adminRoutes.DELETE("/distribution/payment-channels/:id", requirePerm("distribution:payment_manage"), distributionHandler.UnbindPaymentChannel) diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 77f7a84..0fc3067 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -610,15 +610,50 @@ export async function updateAccountMarkup(id: number, shelfRatioDelta: number): return extract(resp) } -export async function batchUpdateAccountMarkup(siteID: number, listingIDs: number[], shelfRatioDelta: number): Promise { - await api.post(`/admin/distribution/accounts/batch-markup`, { - listing_ids: listingIDs, - shelf_ratio_delta: shelfRatioDelta, - }, { +export interface BatchUpdateAccountMarkupItem { + id?: number + listing_id?: number + shelf_ratio_delta: number +} + +export interface BatchUpdateAccountMarkupRequest { + listing_ids?: number[] + display_ids?: number[] + shelf_ratio_delta?: number + items?: BatchUpdateAccountMarkupItem[] +} + +export async function batchUpdateAccountMarkup( + siteID: number, + reqOrListingIDs: BatchUpdateAccountMarkupRequest | number[], + shelfRatioDelta?: number, +): Promise { + const payload: BatchUpdateAccountMarkupRequest = Array.isArray(reqOrListingIDs) + ? { listing_ids: reqOrListingIDs, shelf_ratio_delta: shelfRatioDelta ?? 0 } + : reqOrListingIDs + + await api.post(`/admin/distribution/accounts/batch-markup`, payload, { params: { site_id: siteID }, }) } +export async function batchUpdateAccountDisplay( + siteID: number, + displayIDs: number[], + targetStatus: 'enabled' | 'disabled', +): Promise { + await api.post( + `/admin/distribution/accounts/batch-display`, + { + display_ids: displayIDs, + target_status: targetStatus, + }, + { + params: { site_id: siteID }, + }, + ) +} + export interface DistributionAnalytics { today_received_count: number total_received_count: number diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index 9d003e7..9f15346 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -40,6 +40,7 @@ import { syncSiteAccounts, updateAccountMarkup, batchUpdateAccountMarkup, + batchUpdateAccountDisplay, formatMoney, getMe, type AccountDisplay, @@ -254,23 +255,24 @@ export default function Accounts() { message.warning('请先勾选需要批量操作的账号') return } + if (!siteID) { + message.warning('无法获取当前分销站点信息') + return + } const actionLabel = targetStatus === 'enabled' ? '批量恢复展示' : '批量下架' Modal.confirm({ title: `确认${actionLabel}?`, content: `即将对选中的 ${selectedRowKeys.length} 个账号执行${actionLabel}操作。`, onOk: async () => { - let successCount = 0 - for (const id of selectedRowKeys) { - try { - await updateAccountDisplay(Number(id), targetStatus) - successCount++ - } catch { - // continue next - } + try { + await batchUpdateAccountDisplay(siteID, selectedRowKeys.map(Number), targetStatus) + message.success(`已成功完成选中的 ${selectedRowKeys.length} 个账号的${actionLabel}`) + setSelectedRowKeys([]) + void load(siteID) + } catch (error: unknown) { + const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message + message.error(respMessage ?? `${actionLabel}失败`) } - message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`) - setSelectedRowKeys([]) - if (siteID) void load(siteID) }, }) } @@ -404,23 +406,21 @@ export default function Accounts() { const listingIDs = selectedItems.map((it) => it.listing_id) await batchUpdateAccountMarkup(siteID, listingIDs, batchValue) } else if (batchMode === 'percent') { - await Promise.all( - selectedItems.map((it) => { - const basePriceYuan = (it.price_cent || 0) / 100 - const targetYuan = basePriceYuan * (1 + batchValue / 100) - const delta = calcDeltaFromTargetPrice(it.haf_coin_amount || 0, it.price_cent || 0, targetYuan) - return updateAccountMarkup(it.id, delta) - }) - ) + const itemsToUpdate = selectedItems.map((it) => { + const basePriceYuan = (it.price_cent || 0) / 100 + const targetYuan = basePriceYuan * (1 + batchValue / 100) + const delta = calcDeltaFromTargetPrice(it.haf_coin_amount || 0, it.price_cent || 0, targetYuan) + return { id: it.id, listing_id: it.listing_id, shelf_ratio_delta: delta } + }) + await batchUpdateAccountMarkup(siteID, { items: itemsToUpdate }) } else if (batchMode === 'add_amount') { - await Promise.all( - selectedItems.map((it) => { - const basePriceYuan = (it.price_cent || 0) / 100 - const targetYuan = basePriceYuan + batchValue - const delta = calcDeltaFromTargetPrice(it.haf_coin_amount || 0, it.price_cent || 0, targetYuan) - return updateAccountMarkup(it.id, delta) - }) - ) + const itemsToUpdate = selectedItems.map((it) => { + const basePriceYuan = (it.price_cent || 0) / 100 + const targetYuan = basePriceYuan + batchValue + const delta = calcDeltaFromTargetPrice(it.haf_coin_amount || 0, it.price_cent || 0, targetYuan) + return { id: it.id, listing_id: it.listing_id, shelf_ratio_delta: delta } + }) + await batchUpdateAccountMarkup(siteID, { items: itemsToUpdate }) } message.success(`已成功为选中的 ${selectedItems.length} 个商品批量设置提价!`) setSelectedRowKeys([])