diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go
index 3608c55..f611162 100644
--- a/backend/internal/model/distribution.go
+++ b/backend/internal/model/distribution.go
@@ -47,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" }
diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go
index d812bde..07ea5d1 100644
--- a/backend/internal/modules/distribution/dto.go
+++ b/backend/internal/modules/distribution/dto.go
@@ -218,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"`
}
diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go
index e6a0d53..370a46c 100644
--- a/backend/internal/modules/distribution/repository.go
+++ b/backend/internal/modules/distribution/repository.go
@@ -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
}
@@ -1525,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
}
@@ -1653,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] {
@@ -1661,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{
@@ -1685,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 {
@@ -1701,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))
}
@@ -1711,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
@@ -2830,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) {
@@ -3008,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) {
diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go
index f22ed5a..f83cfe4 100644
--- a/backend/internal/modules/distribution/repository_test.go
+++ b/backend/internal/modules/distribution/repository_test.go
@@ -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)
diff --git a/backend/internal/modules/distribution/shelf.go b/backend/internal/modules/distribution/shelf.go
new file mode 100644
index 0000000..3cec47d
--- /dev/null
+++ b/backend/internal/modules/distribution/shelf.go
@@ -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
+}
diff --git a/backend/internal/modules/distribution/shelf_test.go b/backend/internal/modules/distribution/shelf_test.go
new file mode 100644
index 0000000..acab2e4
--- /dev/null
+++ b/backend/internal/modules/distribution/shelf_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go
index 70ff8aa..5a97afb 100644
--- a/backend/internal/modules/listing/mutation.go
+++ b/backend/internal/modules/listing/mutation.go
@@ -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
})
diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go
index b71f0fa..92a9564 100644
--- a/backend/internal/modules/listing/repository.go
+++ b/backend/internal/modules/listing/repository.go
@@ -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
diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go
index 3be7456..c848724 100644
--- a/backend/internal/modules/listing/review.go
+++ b/backend/internal/modules/listing/review.go
@@ -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,
diff --git a/backend/internal/modules/listing/shelf_sync_test.go b/backend/internal/modules/listing/shelf_sync_test.go
new file mode 100644
index 0000000..b3f40c2
--- /dev/null
+++ b/backend/internal/modules/listing/shelf_sync_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 6c2518d..f6d5e2c 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -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)
diff --git a/backend/migrations/000074_distribution_shelf_display_owner.sql b/backend/migrations/000074_distribution_shelf_display_owner.sql
new file mode 100644
index 0000000..370ebe6
--- /dev/null
+++ b/backend/migrations/000074_distribution_shelf_display_owner.sql
@@ -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;
diff --git a/backend/migrations/000075_distribution_shelf_backfill.sql b/backend/migrations/000075_distribution_shelf_backfill.sql
new file mode 100644
index 0000000..49f7e44
--- /dev/null
+++ b/backend/migrations/000075_distribution_shelf_backfill.sql
@@ -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
+
+-- 回填迁移不回滚:删除这些货架行会抹掉站长在其上已经设置的加价与展示状态。
diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts
index 8c8c69e..edf8e22 100644
--- a/distribution-admin/src/api.ts
+++ b/distribution-admin/src/api.ts
@@ -247,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
}
diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx
index 9f15346..c2397d1 100644
--- a/distribution-admin/src/pages/Accounts.tsx
+++ b/distribution-admin/src/pages/Accounts.tsx
@@ -1292,10 +1292,10 @@ export default function Accounts() {
checked={syncCleanStale}
onChange={(e) => setSyncCleanStale(e.target.checked)}
>
-