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 站点码回退,站点探测完成后再挂载应用,
  避免分站发布因缺少站点码被记成主站商品;同步弹窗文案与统计项同步更新
- 补充分销货架自动铺货、清理口径、建站零值落库与审核铺货回归测试
This commit is contained in:
yml2213
2026-09-30 21:11:09 +08:00
parent 7833e90eee
commit 766c923a6a
21 changed files with 716 additions and 47 deletions
@@ -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)
}
}