3 Commits
Author SHA1 Message Date
yml2213 766c923a6a fix(distribution): 修复分站搜不到已审核商品并实现审核通过自动铺货
问题:分站上架商品审核通过后主站能搜到、分站看不到,且没有自动同步。

根因:分站前台/搜索只认 distribution_site_accounts 中 display_status='enabled'
的货架行,而主站前台不按站点过滤。货架行此前只由「号主在该站上架」或站长手动
「一键同步主站商品」写入,审核通过不回写;同时一键同步的失效清理把「非
published+approved」的货架行一律置为 disabled,商品重新在售后也不会恢复。

- 新增 EnsureListingShelvedTx:商品变为 published+approved 时自动铺到所有
  display_account_enabled=true 的站点货架(缺行新增、系统下架恢复、人工下架不动),
  经 ListingShelfSyncer 接口注入,避免 listing 反向依赖 distribution
- 新增 distribution_site_accounts.display_disabled_by(operator/system/空)区分下架
  来源;人工上下架各入口写入来源标记
- 一键同步的失效清理收紧为只清理「货源记录已删除」的孤儿行,并支持恢复系统下架行,
  结果新增 reenabled_count
- 新增迁移 000074 回填存量缺失货架行,部署后无需逐站手动同步
- 修复 CreateSite 显式传 false/0 的配置(不允许自动展示、结算冷却 0 天)被 GORM
  default 标签忽略而静默回落默认值
- 前端:去掉会跨站串扰的 localStorage 站点码回退,站点探测完成后再挂载应用,
  避免分站发布因缺少站点码被记成主站商品;同步弹窗文案与统计项同步更新
- 补充分销货架自动铺货、清理口径、建站零值落库与审核铺货回归测试
2026-09-30 21:11:09 +08:00
yml2213andCursor 7833e90eee feat(distribution): 分站品牌装修可配置首页入口并上传图片
分站可单独配置回收、合作反馈和客服二维码,未配置时继承主站;Logo、图标和轮播支持直接上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 17:44:56 +08:00
yml2213andCursor 170e279e71 fix(distribution): 分站挂牌的号主收入记入对应分站钱包
结账和仲裁不再把分销站号主收入写入主站钱包,用户列表也只展示主站余额。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 17:25:33 +08:00
35 changed files with 1238 additions and 151 deletions
+37 -32
View File
@@ -4,27 +4,29 @@ import "time"
// DistributionSite 表示一个独立分销网站主体。
type DistributionSite struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"`
SiteName string `gorm:"size:128;not null" json:"site_name"`
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"`
FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"`
BannersJSON string `gorm:"type:text" json:"banners_json"`
AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"`
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
SMSConfigJSON string `gorm:"type:text" json:"-"`
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `gorm:"primaryKey" json:"id"`
SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"`
SiteName string `gorm:"size:128;not null" json:"site_name"`
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"`
FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"`
BannersJSON string `gorm:"type:text" json:"banners_json"`
AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"`
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
AWRecycleJSON string `gorm:"type:text" json:"aw_recycle_json"`
CooperationFeedbackJSON string `gorm:"type:text" json:"cooperation_feedback_json"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
SMSConfigJSON string `gorm:"type:text" json:"-"`
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSite) TableName() string { return "distribution_sites" }
@@ -45,17 +47,20 @@ func (DistributionSiteMember) TableName() string { return "distribution_site_mem
// DistributionSiteAccount 控制账号在分销网站的展示状态。
type DistributionSiteAccount struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"site_id"`
AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta"`
DisplayStartedAt *time.Time `json:"display_started_at,omitempty"`
DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"site_id"`
AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
// DisplayDisabledBy 记录下架来源:operator=站长人工下架,system=系统自动下架,空=展示中或历史数据。
// 自动铺货只会恢复 system/空 来源的下架行,人工下架保持不动。
DisplayDisabledBy string `gorm:"size:16;not null;default:''" json:"display_disabled_by"`
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta"`
DisplayStartedAt *time.Time `json:"display_started_at,omitempty"`
DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSiteAccount) TableName() string { return "distribution_site_accounts" }
@@ -58,7 +58,7 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id")
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id AND w.distribution_site_id = 0")
listTx = applyUserFilter(listTx, query, growthConfig)
err = listTx.
Order("u.id DESC").
@@ -397,7 +397,7 @@ func (r *Repository) Find(ctx context.Context, userID uint64) (*UserDTO, error)
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Joins("LEFT JOIN (SELECT renter_id AS user_id, SUM(deposit_waived_amount_cent) AS deposit_free_used_cent FROM rental_orders WHERE status NOT IN ('completed', 'cancelled', 'closed') GROUP BY renter_id) AS df ON df.user_id = u.id").
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id").
Joins("LEFT JOIN wallet_accounts AS w ON w.user_id = u.id AND w.distribution_site_id = 0").
Where("u.id = ?", userID).
First(&row).Error
if err != nil {
@@ -356,7 +356,12 @@ func resolveListingQrCode(tx *gorm.DB, siteID uint64) (*model.ChatQrCode, string
if qrcode != nil {
return qrcode, qrcode.ImageURL, nil
}
return nil, "", nil
// 分站没有固定码、也没有自己的码池时,沿用主站二维码池。
mainQR, err := fetchUnusedQrCode(tx)
if err != nil || mainQR == nil {
return nil, "", err
}
return mainQR, mainQR.ImageURL, nil
}
qrcode, err := fetchUnusedQrCodeForSite(tx, nil)
if err != nil {
@@ -99,6 +99,7 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
); err != nil {
return err
}
stampOwnerIncomeSite(&settlement, &listing)
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
return err
}
@@ -442,6 +443,22 @@ func newArbitrationSettlement(order model.RentalOrder, renterFrozenBalanceCent i
return settlement
}
func stampOwnerIncomeSite(settlement *arbitrationSettlement, listing *model.RentalListing) {
if settlement == nil {
return
}
siteID := uint64(0)
if listing != nil && listing.DistributionSiteID != nil {
siteID = *listing.DistributionSiteID
}
for i := range settlement.Entries {
entry := &settlement.Entries[i]
if entry.Direction == "in" && entry.BalanceType == "available" && entry.BizType == "arbitration_owner_income" {
entry.DistributionSiteID = siteID
}
}
}
func (settlement *arbitrationSettlement) addOwnerIncome(order model.RentalOrder, amountCent int64, remark string) {
if settlement == nil || amountCent <= 0 {
return
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/datatypes"
"gorm.io/gorm"
@@ -753,3 +754,19 @@ func TestApplyAdminListFiltersSmartRouting(t *testing.T) {
})
}
}
func TestStampOwnerIncomeSiteUsesListingSite(t *testing.T) {
siteID := uint64(3)
orderID := uint64(9)
settlement := arbitrationSettlement{Entries: []wallet.Entry{
{UserID: 1, OrderID: &orderID, Direction: "in", AmountCent: 100, BalanceType: "available", BizType: "arbitration_owner_income"},
{UserID: 2, OrderID: &orderID, Direction: "out", AmountCent: 50, BalanceType: "frozen", BizType: "arbitration_release_frozen"},
}}
stampOwnerIncomeSite(&settlement, &model.RentalListing{DistributionSiteID: &siteID})
if settlement.Entries[0].DistributionSiteID != 3 {
t.Fatalf("号主入账站点 = %d, want 3", settlement.Entries[0].DistributionSiteID)
}
if settlement.Entries[1].DistributionSiteID != 0 {
t.Fatalf("租客解冻站点 = %d, want 0", settlement.Entries[1].DistributionSiteID)
}
}
+35 -29
View File
@@ -3,30 +3,32 @@ package distribution
import "time"
type SiteDTO struct {
ID uint64 `json:"id"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Status string `json:"status"`
DisplayAccountEnabled bool `json:"display_account_enabled"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
BannersJSON string `json:"banners_json"`
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `json:"listing_qrcode_url"`
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
ReceiveCostDelta float64 `json:"receive_cost_delta"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
SettlementHoldDays int `json:"settlement_hold_days"`
AdminUserID *uint64 `json:"admin_user_id,omitempty"`
AdminUsername string `json:"admin_username,omitempty"`
AdminNickname string `json:"admin_nickname,omitempty"`
MemberType string `json:"member_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Status string `json:"status"`
DisplayAccountEnabled bool `json:"display_account_enabled"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
BannersJSON string `json:"banners_json"`
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
AWRecycleJSON string `json:"aw_recycle_json"`
CooperationFeedbackJSON string `json:"cooperation_feedback_json"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `json:"listing_qrcode_url"`
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
ReceiveCostDelta float64 `json:"receive_cost_delta"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
SettlementHoldDays int `json:"settlement_hold_days"`
AdminUserID *uint64 `json:"admin_user_id,omitempty"`
AdminUsername string `json:"admin_username,omitempty"`
AdminNickname string `json:"admin_nickname,omitempty"`
MemberType string `json:"member_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ResetSiteAdminPasswordRequest struct {
@@ -216,6 +218,7 @@ type SyncSiteAccountsResult struct {
SyncedCount int64 `json:"synced_count"`
SkippedCount int64 `json:"skipped_count"`
UpdatedCount int64 `json:"updated_count"`
ReenabledCount int64 `json:"reenabled_count"`
DisabledStaleCount int64 `json:"disabled_stale_count"`
TotalActive int64 `json:"total_active"`
}
@@ -418,11 +421,14 @@ type UpdateReceivedListingStatusRequest struct {
// 站点品牌与装修 DTO。指针字段未传则保持原值,避免空字符串把装修清掉。
type UpdateSiteBrandingRequest struct {
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
AnnouncementsJSON *string `json:"announcements_json"`
FooterInfoJSON *string `json:"footer_info_json"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
AnnouncementsJSON *string `json:"announcements_json"`
FooterInfoJSON *string `json:"footer_info_json"`
AWRecycleJSON *string `json:"aw_recycle_json"`
CooperationFeedbackJSON *string `json:"cooperation_feedback_json"`
ListingQrcodeURL *string `json:"listing_qrcode_url"`
}
// 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。
@@ -417,9 +417,21 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
} else {
row.SettlementHoldDays = r.resolveDefaultSettlementHoldDays(ctx)
}
// 模型上带 default 标签的布尔/整型字段(display_account_enabled、tax_enabled、
// settlement_hold_days)在零值(false/0)时会被 GORM 忽略并回落数据库默认值,
// 因此显式传 false/0 的配置必须在插入后补写,否则会被静默改掉。
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
if explicitZeros := explicitZeroSiteColumns(req); len(explicitZeros) > 0 {
if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}).Where("id = ?", row.ID).
Updates(explicitZeros).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).First(&row, row.ID).Error; err != nil {
return nil, err
}
}
if err := wallet.EnsureAccountTx(r.db.WithContext(ctx), 0, row.ID); err != nil {
return nil, err
}
@@ -855,26 +867,28 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, s
func siteDTO(row model.DistributionSite) SiteDTO {
return SiteDTO{
ID: row.ID,
SiteCode: row.SiteCode,
SiteName: row.SiteName,
Domain: row.Domain,
Status: row.Status,
DisplayAccountEnabled: row.DisplayAccountEnabled,
DefaultSupportGroupID: row.DefaultSupportGroupID,
ListingQrcodeURL: row.ListingQrcodeURL,
ReceiveRatioDelta: row.ReceiveRatioDelta,
ReceiveCostDelta: row.ReceiveCostDelta,
TaxEnabled: row.TaxEnabled,
TaxRateBPS: row.TaxRateBPS,
SettlementHoldDays: row.SettlementHoldDays,
LogoURL: row.LogoURL,
FaviconURL: row.FaviconURL,
BannersJSON: row.BannersJSON,
AnnouncementsJSON: row.AnnouncementsJSON,
FooterInfoJSON: row.FooterInfoJSON,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
SiteCode: row.SiteCode,
SiteName: row.SiteName,
Domain: row.Domain,
Status: row.Status,
DisplayAccountEnabled: row.DisplayAccountEnabled,
DefaultSupportGroupID: row.DefaultSupportGroupID,
ListingQrcodeURL: row.ListingQrcodeURL,
ReceiveRatioDelta: row.ReceiveRatioDelta,
ReceiveCostDelta: row.ReceiveCostDelta,
TaxEnabled: row.TaxEnabled,
TaxRateBPS: row.TaxRateBPS,
SettlementHoldDays: row.SettlementHoldDays,
LogoURL: row.LogoURL,
FaviconURL: row.FaviconURL,
BannersJSON: row.BannersJSON,
AnnouncementsJSON: row.AnnouncementsJSON,
FooterInfoJSON: row.FooterInfoJSON,
AWRecycleJSON: row.AWRecycleJSON,
CooperationFeedbackJSON: row.CooperationFeedbackJSON,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
@@ -1523,6 +1537,7 @@ func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint6
return nil, err
}
row.DisplayStatus = strings.TrimSpace(status)
row.DisplayDisabledBy = displayDisabledBy(row.DisplayStatus)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
@@ -1651,6 +1666,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
seenAccount := make(map[uint64]bool)
var skippedCount int64
var updatedCount int64
var reenabledCount int64
for _, l := range candidates {
if seenAccount[l.AccountID] {
@@ -1659,11 +1675,23 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
seenAccount[l.AccountID] = true
if ea, exists := existingMap[l.AccountID]; exists {
updates := map[string]any{}
if ea.ListingID != l.ID {
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("id = ?", ea.ID).Update("listing_id", l.ID).Error
updates["listing_id"] = l.ID
updatedCount++
}
// 系统下架(或历史无来源标记)的行随铺货恢复展示;站长人工下架的行保持不动。
if ea.DisplayStatus != "enabled" && shelfDisabledBySystem(ea.DisplayDisabledBy) {
updates["display_status"] = "enabled"
updates["display_disabled_by"] = ""
reenabledCount++
}
if len(updates) > 0 {
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("id = ?", ea.ID).Updates(updates).Error; err != nil {
return nil, err
}
}
skippedCount++
} else {
toInsert = append(toInsert, model.DistributionSiteAccount{
@@ -1683,12 +1711,16 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
}
}
// 3. 联动下架主站非在售/已失效的货架商品(若开启 CleanStaleListings)
// 3. 联动下架货源已被删除的货架商品(若开启 CleanStaleListings)。
//
// 这里只处理「商品记录本身已不存在」的孤儿货架行。已下架、已出租、待审核等
// 只是暂时不可售的商品必须保留展示状态:分站前台本来就要求 published+approved
// 才展示,而一旦把它们的货架行置为 disabled,商品重新上架后就再也不会出现在分站。
var disabledStaleCount int64
if req.CleanStaleListings {
var staleIDs []uint64
err := r.db.WithContext(ctx).Table("distribution_site_accounts AS dsa").
Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id AND l.status = 'published' AND l.review_status = 'approved'").
Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id").
Where("dsa.site_id = ? AND dsa.display_status = 'enabled' AND l.id IS NULL", siteID).
Pluck("dsa.id", &staleIDs).Error
if err == nil && len(staleIDs) > 0 {
@@ -1699,7 +1731,10 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
}
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("id IN ?", staleIDs[i:end]).
Update("display_status", "disabled").Error
Updates(map[string]any{
"display_status": "disabled",
"display_disabled_by": displayDisabledBySystem,
}).Error
}
disabledStaleCount = int64(len(staleIDs))
}
@@ -1709,6 +1744,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
SyncedCount: int64(len(toInsert)),
SkippedCount: skippedCount,
UpdatedCount: updatedCount,
ReenabledCount: reenabledCount,
DisabledStaleCount: disabledStaleCount,
TotalActive: int64(len(candidates)),
}, nil
@@ -2700,6 +2736,18 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin
site.FooterInfoJSON = strings.TrimSpace(*req.FooterInfoJSON)
updates["footer_info_json"] = site.FooterInfoJSON
}
if req.AWRecycleJSON != nil {
site.AWRecycleJSON = strings.TrimSpace(*req.AWRecycleJSON)
updates["aw_recycle_json"] = site.AWRecycleJSON
}
if req.CooperationFeedbackJSON != nil {
site.CooperationFeedbackJSON = strings.TrimSpace(*req.CooperationFeedbackJSON)
updates["cooperation_feedback_json"] = site.CooperationFeedbackJSON
}
if req.ListingQrcodeURL != nil {
site.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL)
updates["listing_qrcode_url"] = site.ListingQrcodeURL
}
if len(updates) > 0 {
if err := r.db.WithContext(ctx).Model(&site).Updates(updates).Error; err != nil {
return nil, err
@@ -2816,7 +2864,10 @@ func (r *Repository) BatchUpdateAccountDisplay(ctx context.Context, adminID, sit
} else {
q = q.Where("listing_id IN ?", listingIDs)
}
return q.Update("display_status", targetStatus).Error
return q.Updates(map[string]any{
"display_status": targetStatus,
"display_disabled_by": displayDisabledBy(targetStatus),
}).Error
}
func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) {
@@ -2994,17 +3045,21 @@ func (r *Repository) UpdateReceivedListingStatus(ctx context.Context, adminID, l
err = r.db.WithContext(ctx).Where("site_id = ? AND listing_id = ?", siteID, listing.ID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.DistributionSiteAccount{
SiteID: siteID,
AccountID: listing.AccountID,
ListingID: listing.ID,
DisplayStatus: displayStatus,
SiteID: siteID,
AccountID: listing.AccountID,
ListingID: listing.ID,
DisplayStatus: displayStatus,
DisplayDisabledBy: displayDisabledBy(displayStatus),
}
return r.db.WithContext(ctx).Create(&row).Error
}
if err != nil {
return err
}
return r.db.WithContext(ctx).Model(&row).Update("display_status", displayStatus).Error
return r.db.WithContext(ctx).Model(&row).Updates(map[string]any{
"display_status": displayStatus,
"display_disabled_by": displayDisabledBy(displayStatus),
}).Error
}
func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) {
@@ -1308,7 +1308,50 @@ func TestSyncSiteAccountsOptimizations(t *testing.T) {
t.Fatalf("dsa1 mismatch: ratio=%v, status=%s", dsa1.ShelfRatioDelta, dsa1.DisplayStatus)
}
// 3. 验证主站商品下架联动(CleanStaleListings = true)
// 3. 站长人工下架属于选品决策,再次同步不得覆盖
var dsa1After model.DistributionSiteAccount
if _, err := repo.UpdateAccountDisplay(t.Context(), 0, dsa1.ID, "disabled"); err != nil {
t.Fatalf("UpdateAccountDisplay failed: %v", err)
}
resKeep, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
CleanStaleListings: true,
})
if err != nil {
t.Fatalf("SyncSiteAccounts keep failed: %v", err)
}
if resKeep.ReenabledCount != 0 {
t.Fatalf("人工下架不应被自动恢复: %+v", resKeep)
}
if err := db.First(&dsa1After, dsa1.ID).Error; err != nil {
t.Fatal(err)
}
if dsa1After.DisplayStatus != "disabled" || dsa1After.DisplayDisabledBy != displayDisabledByOperator {
t.Fatalf("人工下架标记丢失: status=%s by=%s", dsa1After.DisplayStatus, dsa1After.DisplayDisabledBy)
}
// 3.1 系统下架(含历史无来源标记的数据)应被铺货恢复
if err := db.Model(&model.DistributionSiteAccount{}).Where("id = ?", dsa1.ID).
Updates(map[string]any{"display_status": "disabled", "display_disabled_by": ""}).Error; err != nil {
t.Fatal(err)
}
resRepair, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
CleanStaleListings: true,
})
if err != nil {
t.Fatalf("SyncSiteAccounts repair failed: %v", err)
}
if resRepair.ReenabledCount != 1 {
t.Fatalf("expected 1 reenabled shelf row, got %+v", resRepair)
}
if err := db.First(&dsa1After, dsa1.ID).Error; err != nil {
t.Fatal(err)
}
if dsa1After.DisplayStatus != "enabled" || dsa1After.DisplayDisabledBy != "" {
t.Fatalf("系统下架应被恢复: status=%s by=%s", dsa1After.DisplayStatus, dsa1After.DisplayDisabledBy)
}
// 3.2 商品下架不清理货架行:分站前台本来就要求 published+approved 才展示,
// 一旦把货架行写成 disabled,商品重新上架后就再也不会出现在分站。
db.Model(&l1).Update("status", "offline")
resClean, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
@@ -1317,15 +1360,35 @@ func TestSyncSiteAccountsOptimizations(t *testing.T) {
if err != nil {
t.Fatalf("SyncSiteAccounts clean failed: %v", err)
}
if resClean.DisabledStaleCount != 1 {
t.Fatalf("expected 1 disabled stale listing, got %d", resClean.DisabledStaleCount)
if resClean.DisabledStaleCount != 0 || resClean.ReenabledCount != 0 {
t.Fatalf("下架商品不应清理货架行: %+v", resClean)
}
if err := db.First(&dsa1After, dsa1.ID).Error; err != nil {
t.Fatal(err)
}
if dsa1After.DisplayStatus != "enabled" {
t.Fatalf("商品下架后货架行应保持 enabled,got: %s", dsa1After.DisplayStatus)
}
// 验证 dsa1 已被标记为 disabled
var dsa1After model.DistributionSiteAccount
db.First(&dsa1After, dsa1.ID)
if dsa1After.DisplayStatus != "disabled" {
t.Fatalf("expected dsa1 disabled, got: %s", dsa1After.DisplayStatus)
// 3.3 只有货源已被删除的孤儿货架行才会被清理
if err := db.Delete(&model.RentalListing{}, l2.ID).Error; err != nil {
t.Fatal(err)
}
resStale, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
CleanStaleListings: true,
})
if err != nil {
t.Fatalf("SyncSiteAccounts stale failed: %v", err)
}
if resStale.DisabledStaleCount != 1 {
t.Fatalf("expected 1 disabled stale listing, got %+v", resStale)
}
var dsa2 model.DistributionSiteAccount
if err := db.Where("site_id = ? AND listing_id = ?", site.ID, l2.ID).First(&dsa2).Error; err != nil {
t.Fatal(err)
}
if dsa2.DisplayStatus != "disabled" || dsa2.DisplayDisabledBy != displayDisabledBySystem {
t.Fatalf("已删除货源应被系统下架: status=%s by=%s", dsa2.DisplayStatus, dsa2.DisplayDisabledBy)
}
// 4. 验证并发互斥锁(ErrSyncInProgress)
@@ -0,0 +1,144 @@
package distribution
import (
"strings"
"sync"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 货架下架来源标记:写入 distribution_site_accounts.display_disabled_by。
// 所有站点共用同一批已审核商品,因此「上架成功后自动铺货」只允许恢复系统下架的行,
// 站长人工下架(选品)必须保持不动;两个来源必须可区分,否则同步会覆盖人工操作。
const (
displayDisabledByOperator = "operator"
displayDisabledBySystem = "system"
)
// shelfDisabledBySystem 判断某条下架行是否允许被自动铺货恢复。
// 历史数据没有来源标记(空串),按系统下架处理,使存量被误下架的商品可自动恢复。
func shelfDisabledBySystem(disabledBy string) bool {
switch strings.TrimSpace(disabledBy) {
case "", displayDisabledBySystem:
return true
default:
return false
}
}
// explicitZeroSiteColumns 收集建站请求里「显式传了零值」但模型带 default 标签的列。
//
// GORM 对带 default 标签的字段会忽略零值(false/0)并回落数据库默认值,
// 例如 display_account_enabled 默认 true:站长建站时明确关闭自动展示,
// 若不显式补写就会出现「配置被静默改成开启」。
func explicitZeroSiteColumns(req CreateSiteRequest) map[string]any {
columns := map[string]any{}
if req.DisplayAccountEnabled != nil && !*req.DisplayAccountEnabled {
columns["display_account_enabled"] = false
}
if req.TaxEnabled != nil && !*req.TaxEnabled {
columns["tax_enabled"] = false
}
if req.SettlementHoldDays != nil && *req.SettlementHoldDays == 0 {
columns["settlement_hold_days"] = 0
}
return columns
}
// displayDisabledBy 把站长人工上下架动作翻译成下架来源标记:下架记 operator,恢复展示则清空标记。
func displayDisabledBy(status string) string {
if strings.TrimSpace(status) == "enabled" {
return ""
}
return displayDisabledByOperator
}
// autoDisplaySiteIDs 返回开启「自动展示账号」的可用站点 ID。
// display_account_enabled=false 表示该站不参与自动铺货,需要站长手动同步。
func autoDisplaySiteIDs(tx *gorm.DB) ([]uint64, error) {
var ids []uint64
err := tx.Model(&model.DistributionSite{}).
Where("status = ? AND display_account_enabled = ?", "active", true).
Pluck("id", &ids).Error
return ids, err
}
// shelfTablePresent 探测分销货架表是否存在,仅首次访问查询 information_schema,
// 兼容尚未执行分销迁移的旧环境,避免商品上架事务因缺表而失败。
var (
shelfTablePresent bool
shelfTableCheckOnce sync.Once
)
func shelfTablesPresent(tx *gorm.DB) bool {
shelfTableCheckOnce.Do(func() {
shelfTablePresent = tx.Migrator().HasTable(&model.DistributionSiteAccount{})
})
return shelfTablePresent
}
// EnsureListingShelvedTx 在商品审核通过(published + approved)后自动铺到各分站货架。
//
// 站点货架与主站共用同一套货源(rental_listings / game_accounts),因此无需站长手动
// 「一键同步」才能看到新商品:
// - 缺少货架行:新增 enabled 行,加价保持 0(等于主站原价,站长可再自主加价)
// - 已有系统下架/历史无标记的下架行:恢复为 enabled
// - 已有站长人工下架的行:保持 disabled,尊重人工选品
//
// 本方法在调用方事务内执行,幂等:重复调用不会产生重复货架行。
func (r *Repository) EnsureListingShelvedTx(tx *gorm.DB, listingID, accountID uint64) error {
if tx == nil || listingID == 0 || accountID == 0 {
return nil
}
if !shelfTablesPresent(tx) {
return nil
}
siteIDs, err := autoDisplaySiteIDs(tx)
if err != nil || len(siteIDs) == 0 {
return err
}
var existing []model.DistributionSiteAccount
if err := tx.Where("site_id IN ? AND account_id = ?", siteIDs, accountID).Find(&existing).Error; err != nil {
return err
}
bySite := make(map[uint64]model.DistributionSiteAccount, len(existing))
for _, row := range existing {
bySite[row.SiteID] = row
}
toInsert := make([]model.DistributionSiteAccount, 0, len(siteIDs))
for _, siteID := range siteIDs {
row, ok := bySite[siteID]
if !ok {
toInsert = append(toInsert, model.DistributionSiteAccount{
SiteID: siteID,
AccountID: accountID,
ListingID: listingID,
DisplayStatus: "enabled",
})
continue
}
updates := map[string]any{}
if row.ListingID != listingID {
updates["listing_id"] = listingID
}
if row.DisplayStatus != "enabled" && shelfDisabledBySystem(row.DisplayDisabledBy) {
updates["display_status"] = "enabled"
updates["display_disabled_by"] = ""
}
if len(updates) == 0 {
continue
}
if err := tx.Model(&model.DistributionSiteAccount{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return err
}
}
if len(toInsert) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toInsert).Error
}
@@ -0,0 +1,156 @@
package distribution
import (
"testing"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// TestEnsureListingShelvedTxAutoShelf 覆盖「所有站点共用同一套已审核货源」的自动铺货语义:
// 开启自动展示的站点自动上架、人工下架不被覆盖、系统下架可恢复、重复调用幂等,
// 并且铺货后的商品能在分站前台公开列表里被搜到。
func TestEnsureListingShelvedTxAutoShelf(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
autoSite, err := repo.CreateSite(t.Context(), CreateSiteRequest{
SiteCode: "auto_shelf_on", SiteName: "自动铺货站", Domain: "auto-on.hfb.local",
})
if err != nil {
t.Fatal(err)
}
if !autoSite.DisplayAccountEnabled {
t.Fatalf("expected display_account_enabled true, got %+v", autoSite)
}
// 关闭自动展示:建站时显式传 false 必须真正落库,否则自动铺货会铺到站长明确排除的站点。
displayOff := false
manualSite, err := repo.CreateSite(t.Context(), CreateSiteRequest{
SiteCode: "auto_shelf_off", SiteName: "手动铺货站", Domain: "auto-off.hfb.local",
DisplayAccountEnabled: &displayOff,
})
if err != nil {
t.Fatal(err)
}
if manualSite.DisplayAccountEnabled {
t.Fatalf("display_account_enabled=false 应在建站时生效, got %+v", manualSite)
}
account := model.GameAccount{
Title: "自动铺货商品", GameName: "delta_force", ServerRegion: "QQ",
LoginPlatform: "PC", RankLevel: "王者", HafCoinAmount: 10_000_000, Status: "published",
}
if err := db.Create(&account).Error; err != nil {
t.Fatal(err)
}
listing := model.RentalListing{
ListingNo: "L-AUTO-SHELF-1", AccountID: account.ID, OwnerID: 1,
PriceCent: 5000, DepositAmountCent: 10000, Status: "published", ReviewStatus: "approved",
}
if err := db.Create(&listing).Error; err != nil {
t.Fatal(err)
}
// 1. 只铺到开启自动展示的站点,加价保持 0(不改变主站原价)
if err := repo.EnsureListingShelvedTx(db, listing.ID, account.ID); err != nil {
t.Fatalf("EnsureListingShelvedTx failed: %v", err)
}
rows := loadShelfRows(t, db, listing.ID)
if len(rows) != 1 {
t.Fatalf("expected 1 shelf row for auto display site, got %+v", rows)
}
if rows[0].SiteID != autoSite.ID || rows[0].DisplayStatus != "enabled" || rows[0].ShelfRatioDelta != 0 {
t.Fatalf("unexpected shelf row: %+v", rows[0])
}
// 2. 幂等:重复铺货不产生重复行
if err := repo.EnsureListingShelvedTx(db, listing.ID, account.ID); err != nil {
t.Fatalf("EnsureListingShelvedTx repeat failed: %v", err)
}
if rows = loadShelfRows(t, db, listing.ID); len(rows) != 1 {
t.Fatalf("auto shelf must be idempotent, got %+v", rows)
}
// 3. 站长人工下架是选品决策,自动铺货不得覆盖
if _, err := repo.UpdateAccountDisplay(t.Context(), 0, rows[0].ID, "disabled"); err != nil {
t.Fatalf("UpdateAccountDisplay failed: %v", err)
}
if err := repo.EnsureListingShelvedTx(db, listing.ID, account.ID); err != nil {
t.Fatalf("EnsureListingShelvedTx after operator disable failed: %v", err)
}
rows = loadShelfRows(t, db, listing.ID)
if rows[0].DisplayStatus != "disabled" || rows[0].DisplayDisabledBy != displayDisabledByOperator {
t.Fatalf("operator disable must be preserved: %+v", rows[0])
}
// 4. 系统下架(含历史无来源标记)可被自动铺货恢复
if err := db.Model(&model.DistributionSiteAccount{}).Where("id = ?", rows[0].ID).
Updates(map[string]any{"display_status": "disabled", "display_disabled_by": displayDisabledBySystem}).Error; err != nil {
t.Fatal(err)
}
if err := repo.EnsureListingShelvedTx(db, listing.ID, account.ID); err != nil {
t.Fatalf("EnsureListingShelvedTx after system disable failed: %v", err)
}
rows = loadShelfRows(t, db, listing.ID)
if rows[0].DisplayStatus != "enabled" || rows[0].DisplayDisabledBy != "" {
t.Fatalf("system disable must be recovered: %+v", rows[0])
}
// 5. 铺货结果能在分站前台公开列表看到;未开启自动展示的站点看不到
page, err := repo.ListPublicSiteListings(t.Context(), autoSite.SiteCode, 1, 20)
if err != nil {
t.Fatalf("ListPublicSiteListings failed: %v", err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ListingID != listing.ID {
t.Fatalf("auto display site should expose the listing, got %+v", page)
}
pageOff, err := repo.ListPublicSiteListings(t.Context(), manualSite.SiteCode, 1, 20)
if err != nil {
t.Fatalf("ListPublicSiteListings manual failed: %v", err)
}
if pageOff.Total != 0 || len(pageOff.Items) != 0 {
t.Fatalf("display_account_enabled=false site must not expose listings, got %+v", pageOff)
}
}
func loadShelfRows(t *testing.T, db *gorm.DB, listingID uint64) []model.DistributionSiteAccount {
t.Helper()
var rows []model.DistributionSiteAccount
if err := db.Where("listing_id = ?", listingID).Find(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestCreateSitePersistsExplicitZeroFlags 回归:模型上带 default 标签的字段在显式传
// false/0 时不能被 GORM 忽略并回落数据库默认值,否则「不允许自动展示」会静默变成开启,
// 从而让自动铺货铺到站长明确排除的站点。
func TestCreateSitePersistsExplicitZeroFlags(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
displayOff := false
zeroHoldDays := 0
dto, err := repo.CreateSite(t.Context(), CreateSiteRequest{
SiteCode: "explicit_zero_site", SiteName: "显式零值站点", Domain: "explicit-zero.hfb.local",
DisplayAccountEnabled: &displayOff, SettlementHoldDays: &zeroHoldDays,
})
if err != nil {
t.Fatalf("CreateSite failed: %v", err)
}
if dto.DisplayAccountEnabled || dto.SettlementHoldDays != 0 {
t.Fatalf("DTO 未反映显式零值: enabled=%v hold=%d", dto.DisplayAccountEnabled, dto.SettlementHoldDays)
}
var site model.DistributionSite
if err := db.Where("site_code = ?", "explicit_zero_site").First(&site).Error; err != nil {
t.Fatal(err)
}
if site.DisplayAccountEnabled {
t.Fatalf("display_account_enabled 显式 false 未落库")
}
if site.SettlementHoldDays != 0 {
t.Fatalf("settlement_hold_days 显式 0 未落库, got %d", site.SettlementHoldDays)
}
}
+2 -1
View File
@@ -101,7 +101,8 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
!strings.HasPrefix(key, "mohong/") &&
!strings.HasPrefix(key, "crash/") &&
!strings.HasPrefix(key, "aw-recycle/") &&
!strings.HasPrefix(key, "cooperation-feedback/") {
!strings.HasPrefix(key, "cooperation-feedback/") &&
!strings.HasPrefix(key, "support-qrcode/") {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return
}
+2 -2
View File
@@ -109,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" || scene == "support-qrcode" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
}
return fileURL
@@ -118,7 +118,7 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene))
switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense":
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "support-qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense":
return scene
case "listing_screenshot", "listing-screenshot":
return "listing"
@@ -77,6 +77,16 @@ func TestCooperationFeedbackSceneUsesPublicFileURL(t *testing.T) {
}
}
func TestSupportQrcodeSceneUsesPublicFileURL(t *testing.T) {
if got := normalizeScene("support-qrcode"); got != "support-qrcode" {
t.Fatalf("normalize support-qrcode scene = %q", got)
}
got := fileURLForScene("support-qrcode", "support-qrcode/2026/09/25/example.png")
if !strings.HasPrefix(got, "/api/public/files/object?") {
t.Fatalf("support-qrcode file url = %q, want public file endpoint", got)
}
}
func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) {
if got := normalizeScene("listing_screenshot"); got != "listing" {
t.Fatalf("normalize listing_screenshot scene = %q, want listing", got)
@@ -98,6 +98,11 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
}
}
// 免审直接上架时,同步铺货到其余开启自动展示的分站货架。
if err := r.syncPublishedShelf(tx, &listing); err != nil {
return err
}
if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil {
return err
}
@@ -300,6 +305,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "编辑后上架"); err != nil {
return err
}
if err := r.syncPublishedShelf(tx, listing); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
@@ -332,6 +340,9 @@ func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "提交发布"); err != nil {
return err
}
if err := r.syncPublishedShelf(tx, listing); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
@@ -14,11 +14,20 @@ type ListingChatCreator interface {
EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, supportAdminID uint64) (conversationID uint64, err error)
}
// ListingShelfSyncer 商品上架后的分站货架铺货接口(由 distribution 模块实现,避免模块反向依赖)。
//
// 所有站点与主站共用同一批已审核商品,因此商品审核通过后必须自动出现在各分站货架,
// 不能依赖站长手动「一键同步主站商品」。
type ListingShelfSyncer interface {
EnsureListingShelvedTx(tx *gorm.DB, listingID, accountID uint64) error
}
type Repository struct {
db *gorm.DB
publicZoneCountsMu sync.Mutex
publicZoneCounts publicZoneCountCache
chatCreator ListingChatCreator
shelfSyncer ListingShelfSyncer
}
func NewRepository(db *gorm.DB, chatCreator ListingChatCreator) *Repository {
@@ -28,6 +37,26 @@ func NewRepository(db *gorm.DB, chatCreator ListingChatCreator) *Repository {
}
}
// SetShelfSyncer 注入货架铺货实现;未注入(如未接入分销模块的环境)时自动铺货为 no-op。
func (r *Repository) SetShelfSyncer(syncer ListingShelfSyncer) {
if r == nil {
return
}
r.shelfSyncer = syncer
}
// syncPublishedShelf 只在商品真正对外可售(published + approved)时铺货到各分站货架。
// 草稿、待审核、已下架商品不需要铺货:分站前台本身就要求 published + approved 才展示。
func (r *Repository) syncPublishedShelf(tx *gorm.DB, listing *model.RentalListing) error {
if r == nil || r.shelfSyncer == nil || listing == nil {
return nil
}
if listing.Status != "published" || listing.ReviewStatus != "approved" {
return nil
}
return r.shelfSyncer.EnsureListingShelvedTx(tx, listing.ID, listing.AccountID)
}
type publicZoneCountCache struct {
Counts map[string]int64
ExpiresAt time.Time
@@ -185,6 +185,10 @@ func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, list
if err := listingstatus.AppendTransition(tx, listing, beforeListingStatus, source, listingstatus.ActorAdmin, adminID, req.Reason); err != nil {
return err
}
// 后台直接上架(listingStatus=published)时同样自动铺货到各分站货架。
if err := r.syncPublishedShelf(tx, listing); err != nil {
return err
}
if err := notification.Append(tx, notification.Entry{
UserID: listing.OwnerID,
Type: "listing_admin",
@@ -241,6 +245,10 @@ func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceReview, listingstatus.ActorAdmin, 0, "审核通过上架"); err != nil {
return err
}
// 审核通过即自动铺货到各分站货架,无需站长手动同步。
if err := r.syncPublishedShelf(tx, listing); err != nil {
return err
}
listingID := listing.ID
if err := notification.Append(tx, notification.Entry{
UserID: listing.OwnerID,
@@ -0,0 +1,134 @@
package listing_test
import (
"context"
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/distribution"
"hfb_sys/backend/internal/modules/listing"
)
// TestApproveAutoShelvesListingToAutoDisplaySites 验证商品审核通过后自动铺到
// 各开启自动展示的分站货架,不再依赖站长手动「一键同步主站商品」。
func TestApproveAutoShelvesListingToAutoDisplaySites(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
autoSite := model.DistributionSite{
SiteCode: "approve_shelf_on", SiteName: "自动铺货站", Domain: "approve-on.hfb.local",
Status: "active", DisplayAccountEnabled: true,
}
manualSite := model.DistributionSite{
SiteCode: "approve_shelf_off", SiteName: "手动铺货站", Domain: "approve-off.hfb.local",
Status: "active", DisplayAccountEnabled: true,
}
if err := db.Create(&autoSite).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&manualSite).Error; err != nil {
t.Fatal(err)
}
// GORM 会忽略带 default 标签字段的零值,关闭自动展示必须显式写库。
if err := db.Model(&model.DistributionSite{}).Where("id = ?", manualSite.ID).
Update("display_account_enabled", false).Error; err != nil {
t.Fatal(err)
}
owner := model.User{Phone: "18800009999", Nickname: "自动铺货卖家", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&owner).Error; err != nil {
t.Fatal(err)
}
account := model.GameAccount{
OwnerID: owner.ID, Title: "待审商品", GameName: "delta_force", ServerRegion: "QQ",
LoginPlatform: "PC", HafCoinAmount: 5_000_000, Status: "draft",
}
if err := db.Create(&account).Error; err != nil {
t.Fatal(err)
}
pending := model.RentalListing{
ListingNo: "L-SHELF-APPROVE-1", AccountID: account.ID, OwnerID: owner.ID,
PriceCent: 1000, Status: "draft", ReviewStatus: "pending",
}
if err := db.Create(&pending).Error; err != nil {
t.Fatal(err)
}
repo := listing.NewRepository(db, nil)
repo.SetShelfSyncer(distribution.NewRepository(db))
if _, err := repo.Approve(context.Background(), pending.ID); err != nil {
t.Fatalf("Approve failed: %v", err)
}
var rows []model.DistributionSiteAccount
if err := db.Where("listing_id = ?", pending.ID).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 auto shelf row, got %+v", rows)
}
if rows[0].SiteID != autoSite.ID || rows[0].DisplayStatus != "enabled" {
t.Fatalf("unexpected shelf row: %+v", rows[0])
}
if rows[0].ShelfRatioDelta != 0 {
t.Fatalf("auto shelf must not change price, got %v", rows[0].ShelfRatioDelta)
}
if rows[0].DisplayDisabledBy != "" {
t.Fatalf("auto shelf row must not carry disable marker, got %q", rows[0].DisplayDisabledBy)
}
}
// TestSyncPublishedShelfRequiresPublishedApproved 验证只有真正对外可售
// (published + approved)的商品才会自动铺货:待审核/已下架商品不铺货。
func TestSyncPublishedShelfRequiresPublishedApproved(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
site := model.DistributionSite{
SiteCode: "approve_shelf_gate", SiteName: "开关站点", Domain: "approve-gate.hfb.local",
Status: "active", DisplayAccountEnabled: true,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
owner := model.User{Phone: "18800009998", Nickname: "门禁卖家", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&owner).Error; err != nil {
t.Fatal(err)
}
account := model.GameAccount{
OwnerID: owner.ID, Title: "待审商品", GameName: "delta_force", ServerRegion: "QQ",
LoginPlatform: "PC", HafCoinAmount: 5_000_000, Status: "draft",
}
if err := db.Create(&account).Error; err != nil {
t.Fatal(err)
}
pending := model.RentalListing{
ListingNo: "L-SHELF-GATE-1", AccountID: account.ID, OwnerID: owner.ID,
PriceCent: 1000, Status: "draft", ReviewStatus: "pending",
}
if err := db.Create(&pending).Error; err != nil {
t.Fatal(err)
}
repo := listing.NewRepository(db, nil)
repo.SetShelfSyncer(distribution.NewRepository(db))
// 拒绝审核:商品不对外可售,不得铺货
if _, err := repo.Reject(context.Background(), pending.ID, listing.ReviewRequest{Reason: "资料不全"}); err != nil {
t.Fatalf("Reject failed: %v", err)
}
var count int64
if err := db.Model(&model.DistributionSiteAccount{}).Where("listing_id = ?", pending.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("rejected listing must not be shelved, got %d rows", count)
}
}
@@ -33,7 +33,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
if settlement.ShortfallCent > 0 {
return nil, ErrCheckoutDepositShortfall
}
if err := appendCheckoutOwnerIncome(tx, order, settlement); err != nil {
if err := appendCheckoutOwnerIncome(tx, order, listing, settlement); err != nil {
return nil, err
}
refund, err := r.prepareCheckoutRefund(order, settlement)
@@ -65,7 +65,14 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
return refund, nil
}
func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement checkoutSettlement) error {
func ownerWalletSiteID(listing *model.RentalListing) uint64 {
if listing != nil && listing.DistributionSiteID != nil {
return *listing.DistributionSiteID
}
return 0
}
func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, listing *model.RentalListing, settlement checkoutSettlement) error {
if isPlatformSettlementOrder(*order) {
amountCent := settlement.OwnerRentIncomeCent + settlement.DepositCompensationCent
order.OfflineSettlementAmountCent = amountCent
@@ -77,11 +84,12 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
return nil
}
orderID := order.ID
siteID := ownerWalletSiteID(listing)
var ownerEntries []wallet.Entry
if settlement.OwnerRentIncomeCent > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
DistributionSiteID: 0,
DistributionSiteID: siteID,
OrderID: &orderID,
Direction: "in",
AmountCent: settlement.OwnerRentIncomeCent,
@@ -94,7 +102,7 @@ func appendCheckoutOwnerIncome(tx *gorm.DB, order *model.RentalOrder, settlement
if settlement.DepositCompensationCent > 0 {
ownerEntries = append(ownerEntries, wallet.Entry{
UserID: order.OwnerID,
DistributionSiteID: 0,
DistributionSiteID: siteID,
OrderID: &orderID,
Direction: "in",
AmountCent: settlement.DepositCompensationCent,
@@ -694,3 +694,16 @@ func TestApplyCheckoutPriceView_RoleIsolationAndPlatformFeeHidden(t *testing.T)
t.Fatalf("租客视角必须展示 RenterRefundAmountCent, 实际为: %v", renterDTO.RenterRefundAmountCent)
}
}
func TestOwnerWalletSiteIDFollowsListingSite(t *testing.T) {
if got := ownerWalletSiteID(nil); got != 0 {
t.Fatalf("空挂牌站点 = %d, want 0", got)
}
if got := ownerWalletSiteID(&model.RentalListing{}); got != 0 {
t.Fatalf("主站挂牌站点 = %d, want 0", got)
}
siteID := uint64(3)
if got := ownerWalletSiteID(&model.RentalListing{DistributionSiteID: &siteID}); got != 3 {
t.Fatalf("分站挂牌站点 = %d, want 3", got)
}
}
@@ -5,6 +5,7 @@ import (
"net/http"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/sitecode"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
@@ -82,7 +83,7 @@ func (h *Handler) HomeAnnouncements(c *gin.Context) {
}
func (h *Handler) HomeConfig(c *gin.Context) {
config, err := h.service.HomeConfig(c.Request.Context())
config, err := h.service.HomeConfig(c.Request.Context(), sitecode.FromRequest(c))
if err != nil {
writeConfigError(c, err)
return
@@ -0,0 +1,23 @@
package systemconfig
import "testing"
func TestParseHomeEntryOverrideInheritsWhenEmpty(t *testing.T) {
if _, ok := parseAWRecycleOverride(" "); ok {
t.Fatal("空配置应继承主站")
}
if _, ok := parseCooperationFeedbackOverride(""); ok {
t.Fatal("空配置应继承主站")
}
}
func TestParseHomeEntryOverrideUsesSiteJSON(t *testing.T) {
aw, ok := parseAWRecycleOverride(`{"title":"分站回收","subtitle":"看图","image_url":"/aw.png","enabled":true}`)
if !ok || aw.Title != "分站回收" || aw.ImageURL != "/aw.png" {
t.Fatalf("回收覆盖 = %+v ok=%v", aw, ok)
}
coop, ok := parseCooperationFeedbackOverride(`{"title":"分站合作","subtitle":"联系","image_url":"/coop.png","enabled":false}`)
if !ok || coop.Title != "分站合作" || coop.Enabled {
t.Fatalf("合作覆盖 = %+v ok=%v", coop, ok)
}
}
@@ -3,6 +3,9 @@ package systemconfig
import (
"context"
"encoding/json"
"strings"
"hfb_sys/backend/internal/model"
)
func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, error) {
@@ -16,7 +19,7 @@ func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO,
return &HomeAnnouncementsDTO{Items: items}, nil
}
func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) {
func (s *Service) HomeConfig(ctx context.Context, siteCode string) (*HomeConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
@@ -40,13 +43,61 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) {
if err != nil {
return nil, err
}
return &HomeConfigDTO{
config := &HomeConfigDTO{
Announcements: announcements,
Banners: banners,
PublishOptions: publishOptions,
AWRecycle: awRecycle,
CooperationFeedback: cooperationFeedback,
}, nil
}
s.applySiteHomeOverrides(ctx, siteCode, config)
return config, nil
}
// applySiteHomeOverrides 用分站自己的首页入口覆盖主站配置。字段为空表示继续继承主站。
func (s *Service) applySiteHomeOverrides(ctx context.Context, siteCode string, config *HomeConfigDTO) {
siteCode = strings.TrimSpace(siteCode)
if s == nil || s.repo == nil || s.repo.db == nil || config == nil || siteCode == "" {
return
}
var site model.DistributionSite
err := s.repo.db.WithContext(ctx).
Select("aw_recycle_json", "cooperation_feedback_json").
Where("site_code = ? AND status = ?", siteCode, "active").
First(&site).Error
if err != nil {
return
}
if item, ok := parseAWRecycleOverride(site.AWRecycleJSON); ok {
config.AWRecycle = item
}
if item, ok := parseCooperationFeedbackOverride(site.CooperationFeedbackJSON); ok {
config.CooperationFeedback = item
}
}
func parseAWRecycleOverride(raw string) (AWRecycleConfig, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return AWRecycleConfig{}, false
}
var item AWRecycleConfig
if err := json.Unmarshal([]byte(raw), &item); err != nil {
return AWRecycleConfig{}, false
}
return normalizeAWRecycle(item), true
}
func parseCooperationFeedbackOverride(raw string) (CooperationFeedbackConfig, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return CooperationFeedbackConfig{}, false
}
var item CooperationFeedbackConfig
if err := json.Unmarshal([]byte(raw), &item); err != nil {
return CooperationFeedbackConfig{}, false
}
return normalizeCooperationFeedback(item), true
}
func (s *Service) homeAnnouncements(ctx context.Context) ([]string, error) {
+4
View File
@@ -320,6 +320,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
distributionRepo.SetEncryptor(fieldEncryptor)
distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv))
}
// 商品审核通过后自动铺货到各分站货架(所有站点共用同一套已审核货源,无需站长手动同步)。
if listingRepo != nil && distributionRepo != nil {
listingRepo.SetShelfSyncer(distributionRepo)
}
distributionService := distribution.NewService(distributionRepo, jwtManager)
if distributionRepo != nil {
distributionResolver := distribution.NewSMSResolver(distributionRepo, logger)
@@ -0,0 +1,12 @@
-- +goose Up
-- 分站首页入口。空字符串表示继承主站 mobile.aw_recycle / mobile.cooperation_feedback。
ALTER TABLE distribution_sites
ADD COLUMN aw_recycle_json TEXT NULL COMMENT '分站 AW炫彩回收 JSON,空则继承主站' AFTER footer_info_json,
ADD COLUMN cooperation_feedback_json TEXT NULL COMMENT '分站合作与反馈 JSON,空则继承主站' AFTER aw_recycle_json;
-- +goose Down
ALTER TABLE distribution_sites
DROP COLUMN cooperation_feedback_json,
DROP COLUMN aw_recycle_json;
@@ -0,0 +1,12 @@
-- +goose Up
-- 货架下架来源:区分「系统自动下架」与「站长人工下架」。
-- 背景:所有站点共用同一批已审核商品,商品审核通过会自动铺到各分站货架;
-- 自动铺货只允许恢复系统下架(或历史无标记)的货架行,避免覆盖站长的人工选品。
ALTER TABLE distribution_site_accounts
ADD COLUMN display_disabled_by VARCHAR(16) NOT NULL DEFAULT '' COMMENT '下架来源:operator=站长人工下架,system=系统自动下架,空=展示中或历史数据' AFTER display_status;
-- +goose Down
ALTER TABLE distribution_site_accounts
DROP COLUMN display_disabled_by;
@@ -0,0 +1,17 @@
-- +goose Up
-- 历史数据回填:所有站点共用同一套已审核货源,分站货架行此前只由站长手动
-- 「一键同步主站商品」补齐,导致部分已审核商品在主站可见但分站在售列表里搜不到。
-- 这里为开启自动展示的站点补齐缺失的货架行(加价 0 = 主站原价,站长可再自主加价)。
-- INSERT IGNORE 兼容 uk_distribution_site_account(site_id, account_id) 已存在的情况,可重复执行。
INSERT IGNORE INTO distribution_site_accounts
(site_id, account_id, listing_id, display_status, display_disabled_by, markup_amount_cent, shelf_ratio_delta, created_at, updated_at)
SELECT ds.id, l.account_id, l.id, 'enabled', '', 0, 0, NOW(), NOW()
FROM distribution_sites AS ds
JOIN rental_listings AS l ON l.status = 'published' AND l.review_status = 'approved'
LEFT JOIN distribution_site_accounts AS dsa ON dsa.site_id = ds.id AND dsa.account_id = l.account_id
WHERE ds.status = 'active' AND ds.display_account_enabled = 1 AND dsa.id IS NULL;
-- +goose Down
-- 回填迁移不回滚:删除这些货架行会抹掉站长在其上已经设置的加价与展示状态。
+16
View File
@@ -127,6 +127,8 @@ export interface Site {
banners_json?: string
announcements_json?: string
footer_info_json?: string
aw_recycle_json?: string
cooperation_feedback_json?: string
listing_qrcode_url?: string
receive_ratio_delta?: number
receive_cost_delta?: number
@@ -245,6 +247,7 @@ export interface SyncSiteAccountsResult {
synced_count: number
skipped_count: number
updated_count?: number
reenabled_count?: number
disabled_stale_count?: number
total_active: number
}
@@ -646,6 +649,19 @@ export interface SiteBrandingPayload {
banners_json?: string
announcements_json?: string
footer_info_json?: string
aw_recycle_json?: string
cooperation_feedback_json?: string
listing_qrcode_url?: string
}
export async function uploadSiteImage(file: File, scene: string): Promise<string> {
const form = new FormData()
form.append('file', file)
form.append('scene', scene)
const resp = await api.post<ApiResponse<{ url: string }>>('/admin/files/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return extract(resp).url
}
export async function updateSiteBranding(siteID: number, payload: SiteBrandingPayload): Promise<Site> {
+15 -5
View File
@@ -1292,10 +1292,10 @@ export default function Accounts() {
checked={syncCleanStale}
onChange={(e) => setSyncCleanStale(e.target.checked)}
>
<Typography.Text strong>自动下架主站已失效/已出租商品</Typography.Text>
<Typography.Text strong>清理货源已删除的货架行</Typography.Text>
</Checkbox>
<div style={{ paddingLeft: 24, fontSize: 12, color: '#64748b', marginTop: 4 }}>
推荐勾选。同步时自动将主站已下架、已出租或被删除的商品标记为已下架,避免历史幽灵商品滞留货架。
推荐勾选。只把「商品记录已被删除」的历史货架行标记为已下架;已下架、已出租、待审核的商品会保留在货架,并随商品重新在售自动恢复展示,不会影响您的人工下架选品。
</div>
</div>
@@ -1370,8 +1370,8 @@ export default function Accounts() {
<Col span={12}>
<Card size="small" style={{ background: '#fffbe6', borderColor: '#ffe58f', textAlign: 'center' }}>
<Statistic
title="联动下架失效商品"
value={syncResult?.disabled_stale_count ?? 0}
title="恢复展示"
value={syncResult?.reenabled_count ?? 0}
valueStyle={{ color: '#faad14', fontWeight: 'bold' }}
suffix="个"
/>
@@ -1380,7 +1380,17 @@ export default function Accounts() {
<Col span={12}>
<Card size="small" style={{ background: '#fafafa', borderColor: '#d9d9d9', textAlign: 'center' }}>
<Statistic
title="主站共享池在售"
title="清理已删除货源"
value={syncResult?.disabled_stale_count ?? 0}
valueStyle={{ color: '#595959', fontWeight: 'bold' }}
suffix="个"
/>
</Card>
</Col>
<Col span={24}>
<Card size="small" style={{ background: '#fafafa', borderColor: '#d9d9d9', textAlign: 'center' }}>
<Statistic
title="全网在售候选"
value={syncResult?.total_active ?? 0}
valueStyle={{ color: '#595959', fontWeight: 'bold' }}
suffix="个"
+244 -20
View File
@@ -19,6 +19,7 @@ import {
Switch,
Alert,
Popconfirm,
Upload,
} from 'antd'
import {
SaveOutlined,
@@ -41,6 +42,7 @@ import {
fetchSiteSMSConfig,
updateSiteSMSConfig,
testSiteSMSConfig,
uploadSiteImage,
type Site,
type SiteBrandingPayload,
type SiteSMSConfig,
@@ -66,6 +68,46 @@ interface FooterInfo {
contact?: string
}
interface HomeEntryConfig {
title: string
subtitle: string
image_url: string
enabled: boolean
}
const defaultAWEntry: HomeEntryConfig = {
title: 'AW炫彩回收',
subtitle: '点击查看回收说明',
image_url: '',
enabled: true,
}
const defaultCoopEntry: HomeEntryConfig = {
title: '合作与反馈',
subtitle: '点击查看联系方式',
image_url: '',
enabled: true,
}
function parseHomeEntry(raw: string | undefined, fallback: HomeEntryConfig) {
const text = (raw || '').trim()
if (!text) return { useMain: true, value: fallback }
try {
const parsed = JSON.parse(text) as Partial<HomeEntryConfig>
return {
useMain: false,
value: {
title: parsed.title || fallback.title,
subtitle: parsed.subtitle || fallback.subtitle,
image_url: parsed.image_url || '',
enabled: parsed.enabled !== false,
},
}
} catch {
return { useMain: true, value: fallback }
}
}
export default function SiteSettings() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
@@ -85,6 +127,11 @@ export default function SiteSettings() {
contact: '',
})
const [receiveCostDelta, setReceiveCostDelta] = useState(0)
const [awUseMain, setAwUseMain] = useState(true)
const [awEntry, setAwEntry] = useState<HomeEntryConfig>(defaultAWEntry)
const [coopUseMain, setCoopUseMain] = useState(true)
const [coopEntry, setCoopEntry] = useState<HomeEntryConfig>(defaultCoopEntry)
const [listingQrcode, setListingQrcode] = useState('')
const [savingRatio, setSavingRatio] = useState(false)
// SMS config state
@@ -141,6 +188,13 @@ export default function SiteSettings() {
setFooterInfo({})
}
setReceiveCostDelta(Math.max(0, found.receive_cost_delta || 0))
const aw = parseHomeEntry(found.aw_recycle_json, defaultAWEntry)
setAwUseMain(aw.useMain)
setAwEntry(aw.value)
const coop = parseHomeEntry(found.cooperation_feedback_json, defaultCoopEntry)
setCoopUseMain(coop.useMain)
setCoopEntry(coop.value)
setListingQrcode(found.listing_qrcode_url || '')
try {
const sms = await fetchSiteSMSConfig(id)
@@ -193,7 +247,7 @@ export default function SiteSettings() {
setBanners([
...banners,
{
image_url: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=1200&q=80',
image_url: '',
title: '热门推荐活动',
link: '',
},
@@ -349,6 +403,71 @@ export default function SiteSettings() {
}
}
async function uploadEntryImage(file: File, scene: string, apply: (url: string) => void) {
try {
const url = await uploadSiteImage(file, scene)
apply(url)
message.success('图片已上传,保存后才会在前台生效')
} catch {
message.error('图片上传失败')
}
return false
}
function renderEntryEditor(
useMain: boolean,
setUseMain: (value: boolean) => void,
entry: HomeEntryConfig,
setEntry: (value: HomeEntryConfig) => void,
scene: string,
) {
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text>继承主站配置</Typography.Text>
<Switch checked={useMain} onChange={setUseMain} />
</div>
{useMain ? (
<Typography.Text type="secondary">当前展示主站的标题、说明和图片。</Typography.Text>
) : (
<>
<Input
placeholder="标题"
value={entry.title}
onChange={(e) => setEntry({ ...entry, title: e.target.value })}
/>
<Input
placeholder="副标题"
value={entry.subtitle}
onChange={(e) => setEntry({ ...entry, subtitle: e.target.value })}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text>前台显示该入口</Typography.Text>
<Switch checked={entry.enabled} onChange={(checked) => setEntry({ ...entry, enabled: checked })} />
</div>
{entry.image_url ? (
<Image src={entry.image_url} width={160} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">尚未上传图片</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, scene, (url) => setEntry({ ...entry, image_url: url }))}
>
<Button icon={<PictureOutlined />}>上传图片</Button>
</Upload>
<Button disabled={!entry.image_url} onClick={() => setEntry({ ...entry, image_url: '' })}>
清除图片
</Button>
</Space>
</>
)}
</Space>
)
}
async function handleSave() {
if (!siteID) return
setSaving(true)
@@ -359,6 +478,9 @@ export default function SiteSettings() {
banners_json: JSON.stringify(banners),
announcements_json: JSON.stringify(announcements),
footer_info_json: JSON.stringify(footerInfo),
aw_recycle_json: awUseMain ? '' : JSON.stringify(awEntry),
cooperation_feedback_json: coopUseMain ? '' : JSON.stringify(coopEntry),
listing_qrcode_url: listingQrcode.trim(),
}
const updated = await updateSiteBranding(siteID, payload)
setCurrentSite(updated)
@@ -379,7 +501,7 @@ export default function SiteSettings() {
品牌与站点可视化装修
</Typography.Title>
<Typography.Text type="secondary">
可视化配置当前分销独立站的 Logo、首页轮播海报、跑马灯公告及页脚客服信息。
可视化配置当前分销独立站的 Logo、首页入口、客服二维码、轮播海报和页脚信息。未单独配置的回收与合作入口继续使用主站。
</Typography.Text>
</div>
<Space>
@@ -606,28 +728,64 @@ export default function SiteSettings() {
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text strong>站点 Logo 图片链接:</Typography.Text>
<Input
placeholder="https://.../logo.png"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
allowClear
style={{ marginTop: 6 }}
/>
<Typography.Text strong>站点 Logo:</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 6 }} size="small">
{logoUrl ? (
<Image src={logoUrl} height={48} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">尚未上传 Logo</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, 'avatar', setLogoUrl)}
>
<Button icon={<PictureOutlined />}>上传 Logo</Button>
</Upload>
<Button disabled={!logoUrl} onClick={() => setLogoUrl('')}>
清除
</Button>
</Space>
<Input
placeholder="或粘贴图片链接 https://.../logo.png"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
allowClear
/>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
建议尺寸 240x60 像素,透明底色 PNG 或 SVG 格式
建议尺寸 240x60 像素,透明底色 PNG
</Typography.Text>
</div>
<div>
<Typography.Text strong>浏览器标签页 Favicon 图标:</Typography.Text>
<Input
placeholder="https://.../favicon.ico"
value={faviconUrl}
onChange={(e) => setFaviconUrl(e.target.value)}
allowClear
style={{ marginTop: 6 }}
/>
<Typography.Text strong>浏览器标签页 Favicon:</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 6 }} size="small">
{faviconUrl ? (
<Image src={faviconUrl} width={32} height={32} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">尚未上传图标</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, 'avatar', setFaviconUrl)}
>
<Button icon={<PictureOutlined />}>上传图标</Button>
</Upload>
<Button disabled={!faviconUrl} onClick={() => setFaviconUrl('')}>
清除
</Button>
</Space>
<Input
placeholder="或粘贴图片链接 https://.../favicon.png"
value={faviconUrl}
onChange={(e) => setFaviconUrl(e.target.value)}
allowClear
/>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
浏览器选项卡显示的小图标,建议 32x32 像素
</Typography.Text>
@@ -693,8 +851,21 @@ export default function SiteSettings() {
</Col>
<Col span={18}>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) =>
uploadEntryImage(file, 'home-banner', (url) =>
handleUpdateBanner(idx, 'image_url', url),
)
}
>
<Button icon={<PictureOutlined />}>上传横幅</Button>
</Upload>
</Space>
<Input
placeholder="输入图片 URL (https://...)"
placeholder="或粘贴图片链接 https://..."
value={item.image_url}
onChange={(e) => handleUpdateBanner(idx, 'image_url', e.target.value)}
/>
@@ -777,6 +948,59 @@ export default function SiteSettings() {
</Space>
),
},
{
key: 'entries',
label: '首页入口与客服二维码',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert
type="info"
showIcon
message="回收和合作入口默认继承主站。关掉继承后,只影响当前分销站首页。客服二维码用于号主发布后进群;留空时先用本站二维码池,没有再使用主站二维码池。"
/>
<div>
<Typography.Text strong>AW炫彩回收</Typography.Text>
<div style={{ marginTop: 8 }}>
{renderEntryEditor(awUseMain, setAwUseMain, awEntry, setAwEntry, 'aw-recycle')}
</div>
</div>
<div>
<Typography.Text strong>合作与反馈</Typography.Text>
<div style={{ marginTop: 8 }}>
{renderEntryEditor(
coopUseMain,
setCoopUseMain,
coopEntry,
setCoopEntry,
'cooperation-feedback',
)}
</div>
</div>
<div>
<Typography.Text strong>客服二维码</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 8 }} size="small">
{listingQrcode ? (
<Image src={listingQrcode} width={160} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">未配置,发布群使用主站二维码池。</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, 'support-qrcode', setListingQrcode)}
>
<Button icon={<PictureOutlined />}>上传二维码</Button>
</Upload>
<Button disabled={!listingQrcode} onClick={() => setListingQrcode('')}>
恢复主站
</Button>
</Space>
</Space>
</div>
</Space>
),
},
{
key: 'footer',
label: '页脚与客服备案',
+1 -1
View File
@@ -323,7 +323,7 @@ export default function Storefront() {
description={
<span style={{ color: '#94a3b8' }}>
{items.length === 0
? '当前分销站点暂无上架商品,站长可通过分销后台一键同步主站商品。'
? '当前分销站点暂无在售商品。全网新审核通过的商品会自动上架到本站货架。'
: '未找到符合条件的账号商品,请更换搜索词重试。'}
</span>
}
+27 -1
View File
@@ -234,7 +234,30 @@ offline
关闭展示不删除账号、不影响历史订单,只阻止新展示或新订单,并写入审计日志。
一个账号是否允许同时挂载多个分销网站:待确认。第一版建议一个账号在同一时刻只归属一个可售分销网站,避免重复出售和归因冲突。
#### 6.3.1 货源与自动铺货(已实现)
所有站点与主站共用同一套货源(`rental_listings` / `game_accounts`),分站货架不是独立商品库,
而是「同一批已审核商品在本站的展示关系 + 本站加价」。据此确定铺货规则:
- 商品变为 `published + approved`(审核通过、后台直接上架、免审上架、编辑后重新上架)时,
自动为所有 `status='active' AND display_account_enabled=true` 的站点写入/恢复货架行
(`listing.Approve` → `distribution.EnsureListingShelvedTx`),不需要站长手动「一键同步主站商品」。
- `display_account_enabled` 的语义是「本站是否参与自动铺货」:关闭后该站不自动接新货,
仍可由站长手动同步与选品。
- 新增行加价 `shelf_ratio_delta = 0`(等于主站原价),站长可在货架页自主加价;
自动铺货不会覆盖已有加价。
- 站长人工下架是选品决策,自动铺货与手动同步都不会覆盖;因此
`distribution_site_accounts.display_disabled_by` 记录下架来源
(`operator` = 站长人工、`system` = 系统自动、空 = 展示中或历史数据)。
- 分站前台/搜索只要求 `published + approved` 且货架行 `enabled`;商品暂时下架、出租、
待审核都不需要改动货架行(前台条件本身就会隐藏),否则商品重新在售后会永久从分站消失。
- 「一键同步主站商品」保留为兜底与修复入口:补全缺失货架行、恢复系统下架/历史无标记的行,
`clean_stale_listings` 只清理「商品记录已被删除」的孤儿货架行。
货源归属与展示归属是两个概念:`rental_listings.distribution_site_id` 表示「这条货是谁收的」
(唯一),而 `distribution_site_accounts` 表示「这条货在哪些站的货架上卖」(可多站)。
具体分成口径由 `resolveDistributionMode` 按「买入站 × 货源站」判定
(同站自卖 / 主站收号分站卖 / 分站收号主站卖 / 三方跨站),避免重复出售与归因冲突。
### 6.4 不纳入分销网的业务
@@ -838,6 +861,9 @@ distribution.hao858.com {
4. 分站货架自主提价溢价(`shelf_markup_cent`)100% 独享归卖方分站,不参与三方切分;
5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。
- 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
- 货架铺货与展示口径(详见 6.3.1):商品变为 `published + approved` 时自动铺到所有开启自动展示的站点货架,
不再依赖站长手动同步;`distribution_site_accounts.display_disabled_by` 记录下架来源,自动铺货不覆盖站长人工下架;
`clean_stale_listings` 只清理商品记录已删除的孤儿货架行。
- 生产与测试口径一致:结算批次入账、站点提现与冷却期自动释放不再按 `APP_ENV` 冻结(原 `liveMoneyMovementEnabled` 生产保护已移除)。
- 新建站点的默认结算冷却天数由系统配置 `distribution.default_settlement_hold_days` 统一控制(默认 7 天,0=立即释放,最大 365),管理端可在系统配置页调整;站点级 `settlement_hold_days` 仍可单独覆盖。
- `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。
@@ -311,6 +311,7 @@ export interface SyncSiteAccountsResult {
synced_count: number
skipped_count: number
updated_count?: number
reenabled_count?: number
disabled_stale_count?: number
total_active: number
}
@@ -490,8 +490,9 @@ async function handleSyncAccounts(row: DistributionSite) {
syncingSiteId.value = row.id
try {
const res = await syncDistributionSiteAccounts(row.id)
const reenabled = res.reenabled_count ? `,恢复展示 ${res.reenabled_count} 个` : ''
ElMessage.success(
`同步成功!为【${row.site_name}】新增本站上传货 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(本次候选 ${res.total_active} 个)`
`同步成功!为【${row.site_name}】新增货架商品 ${res.synced_count} 个,已存在 ${res.skipped_count} 个${reenabled}(本次候选 ${res.total_active} 个)`
)
} catch (err) {
ElMessage.error(readError(err, '同步商品失败'))
+12 -4
View File
@@ -19,9 +19,6 @@ const pinia = createPinia()
app.use(pinia).use(router)
// 初始化分销站点租户配置(如 ?site=xxx 或独立域名访问)
useDistributionSiteStore(pinia).initSite()
window.addEventListener('auth-storage-changed', () => {
useSessionStore(pinia).syncFromStorage()
useAdminSessionStore(pinia).syncFromStorage()
@@ -34,4 +31,15 @@ window.addEventListener('storage', event => {
}
})
router.isReady().then(() => app.mount('#app'))
// 初始化分销站点租户配置(如 ?site=xxx 或独立域名访问)。
// 必须在挂载前完成:独立域名下的站点码由 /distribution/site-info 探测后写入 sessionStorage,
// 若在探测完成前渲染,分站页面的请求(包括发布商品)会缺少站点码而被当成主站发布。
async function bootstrap() {
await useDistributionSiteStore(pinia)
.initSite()
.catch(() => undefined)
await router.isReady()
app.mount('#app')
}
void bootstrap()
+4 -5
View File
@@ -37,7 +37,10 @@ export interface DistributionSiteInfo {
* 1. 环境变量 VITE_DISTRIBUTION_SITE_CODE(针对独立分销前台开发端口,如 5191)
* 2. 当前标签页 sessionStorage 中的 hfb_site_code(避免跨 Tab 污染主站)
* 3. 本地二级域名(如 test_1.localhost:5189)
* 4. 生产环境或独立域名绑定的 localStorage 缓存
*
* 注意:独立域名下的站点编码只来自 sessionStorage,由 initSite() 通过
* /distribution/site-info?domain= 探测后写入。历史上曾读取 localStorage 缓存,
* 会让同一浏览器访问主站时串到分站站点码(把主站发布记到分站/反之),因此已移除。
*/
export function getEffectiveSiteCode(): string {
if (typeof window === 'undefined') return ''
@@ -52,10 +55,6 @@ export function getEffectiveSiteCode(): string {
return hostname.replace(/\.localhost$/, '')
}
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
return localStorage.getItem('hfb_site_code') || ''
}
return ''
}