feat(distribution): 优化货架批量操作、结算大事务、大盘与前台多端解析缓存及货架分页
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
Reference in New Issue
Block a user