feat(distribution): 优化货架批量操作、结算大事务、大盘与前台多端解析缓存及货架分页

This commit is contained in:
yml2213
2026-09-11 12:59:33 +08:00
parent 9ae6d5eaea
commit 3508608a8f
10 changed files with 518 additions and 48 deletions
+30 -2
View File
@@ -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
@@ -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 {
@@ -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 {
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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
+2
View File
@@ -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)
+40 -5
View File
@@ -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<void> {
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<void> {
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<void> {
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
+27 -27
View File
@@ -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([])