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:
@@ -45,17 +45,20 @@ func (DistributionSiteMember) TableName() string { return "distribution_site_mem
|
|||||||
|
|
||||||
// DistributionSiteAccount 控制账号在分销网站的展示状态。
|
// DistributionSiteAccount 控制账号在分销网站的展示状态。
|
||||||
type DistributionSiteAccount struct {
|
type DistributionSiteAccount struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"site_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"`
|
AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"`
|
||||||
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
||||||
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
|
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
|
||||||
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
|
// DisplayDisabledBy 记录下架来源:operator=站长人工下架,system=系统自动下架,空=展示中或历史数据。
|
||||||
ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta"`
|
// 自动铺货只会恢复 system/空 来源的下架行,人工下架保持不动。
|
||||||
DisplayStartedAt *time.Time `json:"display_started_at,omitempty"`
|
DisplayDisabledBy string `gorm:"size:16;not null;default:''" json:"display_disabled_by"`
|
||||||
DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"`
|
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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" }
|
func (DistributionSiteAccount) TableName() string { return "distribution_site_accounts" }
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ type SyncSiteAccountsResult struct {
|
|||||||
SyncedCount int64 `json:"synced_count"`
|
SyncedCount int64 `json:"synced_count"`
|
||||||
SkippedCount int64 `json:"skipped_count"`
|
SkippedCount int64 `json:"skipped_count"`
|
||||||
UpdatedCount int64 `json:"updated_count"`
|
UpdatedCount int64 `json:"updated_count"`
|
||||||
|
ReenabledCount int64 `json:"reenabled_count"`
|
||||||
DisabledStaleCount int64 `json:"disabled_stale_count"`
|
DisabledStaleCount int64 `json:"disabled_stale_count"`
|
||||||
TotalActive int64 `json:"total_active"`
|
TotalActive int64 `json:"total_active"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,9 +417,21 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
|||||||
} else {
|
} else {
|
||||||
row.SettlementHoldDays = r.resolveDefaultSettlementHoldDays(ctx)
|
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 {
|
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||||
return nil, err
|
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 {
|
if err := wallet.EnsureAccountTx(r.db.WithContext(ctx), 0, row.ID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1523,6 +1535,7 @@ func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint6
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row.DisplayStatus = strings.TrimSpace(status)
|
row.DisplayStatus = strings.TrimSpace(status)
|
||||||
|
row.DisplayDisabledBy = displayDisabledBy(row.DisplayStatus)
|
||||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1651,6 +1664,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
seenAccount := make(map[uint64]bool)
|
seenAccount := make(map[uint64]bool)
|
||||||
var skippedCount int64
|
var skippedCount int64
|
||||||
var updatedCount int64
|
var updatedCount int64
|
||||||
|
var reenabledCount int64
|
||||||
|
|
||||||
for _, l := range candidates {
|
for _, l := range candidates {
|
||||||
if seenAccount[l.AccountID] {
|
if seenAccount[l.AccountID] {
|
||||||
@@ -1659,11 +1673,23 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
seenAccount[l.AccountID] = true
|
seenAccount[l.AccountID] = true
|
||||||
|
|
||||||
if ea, exists := existingMap[l.AccountID]; exists {
|
if ea, exists := existingMap[l.AccountID]; exists {
|
||||||
|
updates := map[string]any{}
|
||||||
if ea.ListingID != l.ID {
|
if ea.ListingID != l.ID {
|
||||||
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
|
updates["listing_id"] = l.ID
|
||||||
Where("id = ?", ea.ID).Update("listing_id", l.ID).Error
|
|
||||||
updatedCount++
|
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++
|
skippedCount++
|
||||||
} else {
|
} else {
|
||||||
toInsert = append(toInsert, model.DistributionSiteAccount{
|
toInsert = append(toInsert, model.DistributionSiteAccount{
|
||||||
@@ -1683,12 +1709,16 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 联动下架主站非在售/已失效的货架商品(若开启 CleanStaleListings)
|
// 3. 联动下架货源已被删除的货架商品(若开启 CleanStaleListings)。
|
||||||
|
//
|
||||||
|
// 这里只处理「商品记录本身已不存在」的孤儿货架行。已下架、已出租、待审核等
|
||||||
|
// 只是暂时不可售的商品必须保留展示状态:分站前台本来就要求 published+approved
|
||||||
|
// 才展示,而一旦把它们的货架行置为 disabled,商品重新上架后就再也不会出现在分站。
|
||||||
var disabledStaleCount int64
|
var disabledStaleCount int64
|
||||||
if req.CleanStaleListings {
|
if req.CleanStaleListings {
|
||||||
var staleIDs []uint64
|
var staleIDs []uint64
|
||||||
err := r.db.WithContext(ctx).Table("distribution_site_accounts AS dsa").
|
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).
|
Where("dsa.site_id = ? AND dsa.display_status = 'enabled' AND l.id IS NULL", siteID).
|
||||||
Pluck("dsa.id", &staleIDs).Error
|
Pluck("dsa.id", &staleIDs).Error
|
||||||
if err == nil && len(staleIDs) > 0 {
|
if err == nil && len(staleIDs) > 0 {
|
||||||
@@ -1699,7 +1729,10 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
}
|
}
|
||||||
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
|
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
|
||||||
Where("id IN ?", staleIDs[i:end]).
|
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))
|
disabledStaleCount = int64(len(staleIDs))
|
||||||
}
|
}
|
||||||
@@ -1709,6 +1742,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
SyncedCount: int64(len(toInsert)),
|
SyncedCount: int64(len(toInsert)),
|
||||||
SkippedCount: skippedCount,
|
SkippedCount: skippedCount,
|
||||||
UpdatedCount: updatedCount,
|
UpdatedCount: updatedCount,
|
||||||
|
ReenabledCount: reenabledCount,
|
||||||
DisabledStaleCount: disabledStaleCount,
|
DisabledStaleCount: disabledStaleCount,
|
||||||
TotalActive: int64(len(candidates)),
|
TotalActive: int64(len(candidates)),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -2816,7 +2850,10 @@ func (r *Repository) BatchUpdateAccountDisplay(ctx context.Context, adminID, sit
|
|||||||
} else {
|
} else {
|
||||||
q = q.Where("listing_id IN ?", listingIDs)
|
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) {
|
func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) {
|
||||||
@@ -2994,17 +3031,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
|
err = r.db.WithContext(ctx).Where("site_id = ? AND listing_id = ?", siteID, listing.ID).First(&row).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
row = model.DistributionSiteAccount{
|
row = model.DistributionSiteAccount{
|
||||||
SiteID: siteID,
|
SiteID: siteID,
|
||||||
AccountID: listing.AccountID,
|
AccountID: listing.AccountID,
|
||||||
ListingID: listing.ID,
|
ListingID: listing.ID,
|
||||||
DisplayStatus: displayStatus,
|
DisplayStatus: displayStatus,
|
||||||
|
DisplayDisabledBy: displayDisabledBy(displayStatus),
|
||||||
}
|
}
|
||||||
return r.db.WithContext(ctx).Create(&row).Error
|
return r.db.WithContext(ctx).Create(&row).Error
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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) {
|
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)
|
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")
|
db.Model(&l1).Update("status", "offline")
|
||||||
|
|
||||||
resClean, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
|
resClean, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
|
||||||
@@ -1317,15 +1360,35 @@ func TestSyncSiteAccountsOptimizations(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SyncSiteAccounts clean failed: %v", err)
|
t.Fatalf("SyncSiteAccounts clean failed: %v", err)
|
||||||
}
|
}
|
||||||
if resClean.DisabledStaleCount != 1 {
|
if resClean.DisabledStaleCount != 0 || resClean.ReenabledCount != 0 {
|
||||||
t.Fatalf("expected 1 disabled stale listing, got %d", resClean.DisabledStaleCount)
|
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
|
// 3.3 只有货源已被删除的孤儿货架行才会被清理
|
||||||
var dsa1After model.DistributionSiteAccount
|
if err := db.Delete(&model.RentalListing{}, l2.ID).Error; err != nil {
|
||||||
db.First(&dsa1After, dsa1.ID)
|
t.Fatal(err)
|
||||||
if dsa1After.DisplayStatus != "disabled" {
|
}
|
||||||
t.Fatalf("expected dsa1 disabled, got: %s", dsa1After.DisplayStatus)
|
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)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil {
|
||||||
return err
|
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 {
|
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "编辑后上架"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.syncPublishedShelf(tx, listing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
dto = toDTO(*account, *listing)
|
dto = toDTO(*account, *listing)
|
||||||
return nil
|
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 {
|
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "提交发布"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.syncPublishedShelf(tx, listing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
dto = toDTO(*account, *listing)
|
dto = toDTO(*account, *listing)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,11 +14,20 @@ type ListingChatCreator interface {
|
|||||||
EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, supportAdminID uint64) (conversationID uint64, err error)
|
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 {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
publicZoneCountsMu sync.Mutex
|
publicZoneCountsMu sync.Mutex
|
||||||
publicZoneCounts publicZoneCountCache
|
publicZoneCounts publicZoneCountCache
|
||||||
chatCreator ListingChatCreator
|
chatCreator ListingChatCreator
|
||||||
|
shelfSyncer ListingShelfSyncer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB, chatCreator ListingChatCreator) *Repository {
|
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 {
|
type publicZoneCountCache struct {
|
||||||
Counts map[string]int64
|
Counts map[string]int64
|
||||||
ExpiresAt time.Time
|
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 {
|
if err := listingstatus.AppendTransition(tx, listing, beforeListingStatus, source, listingstatus.ActorAdmin, adminID, req.Reason); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 后台直接上架(listingStatus=published)时同样自动铺货到各分站货架。
|
||||||
|
if err := r.syncPublishedShelf(tx, listing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if err := notification.Append(tx, notification.Entry{
|
||||||
UserID: listing.OwnerID,
|
UserID: listing.OwnerID,
|
||||||
Type: "listing_admin",
|
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 {
|
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceReview, listingstatus.ActorAdmin, 0, "审核通过上架"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 审核通过即自动铺货到各分站货架,无需站长手动同步。
|
||||||
|
if err := r.syncPublishedShelf(tx, listing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
listingID := listing.ID
|
listingID := listing.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if err := notification.Append(tx, notification.Entry{
|
||||||
UserID: listing.OwnerID,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -320,6 +320,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
distributionRepo.SetEncryptor(fieldEncryptor)
|
distributionRepo.SetEncryptor(fieldEncryptor)
|
||||||
distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv))
|
distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv))
|
||||||
}
|
}
|
||||||
|
// 商品审核通过后自动铺货到各分站货架(所有站点共用同一套已审核货源,无需站长手动同步)。
|
||||||
|
if listingRepo != nil && distributionRepo != nil {
|
||||||
|
listingRepo.SetShelfSyncer(distributionRepo)
|
||||||
|
}
|
||||||
distributionService := distribution.NewService(distributionRepo, jwtManager)
|
distributionService := distribution.NewService(distributionRepo, jwtManager)
|
||||||
if distributionRepo != nil {
|
if distributionRepo != nil {
|
||||||
distributionResolver := distribution.NewSMSResolver(distributionRepo, logger)
|
distributionResolver := distribution.NewSMSResolver(distributionRepo, logger)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
-- 回填迁移不回滚:删除这些货架行会抹掉站长在其上已经设置的加价与展示状态。
|
||||||
@@ -245,6 +245,7 @@ export interface SyncSiteAccountsResult {
|
|||||||
synced_count: number
|
synced_count: number
|
||||||
skipped_count: number
|
skipped_count: number
|
||||||
updated_count?: number
|
updated_count?: number
|
||||||
|
reenabled_count?: number
|
||||||
disabled_stale_count?: number
|
disabled_stale_count?: number
|
||||||
total_active: number
|
total_active: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1292,10 +1292,10 @@ export default function Accounts() {
|
|||||||
checked={syncCleanStale}
|
checked={syncCleanStale}
|
||||||
onChange={(e) => setSyncCleanStale(e.target.checked)}
|
onChange={(e) => setSyncCleanStale(e.target.checked)}
|
||||||
>
|
>
|
||||||
<Typography.Text strong>自动下架主站已失效/已出租商品</Typography.Text>
|
<Typography.Text strong>清理货源已删除的货架行</Typography.Text>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<div style={{ paddingLeft: 24, fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
<div style={{ paddingLeft: 24, fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||||||
推荐勾选。同步时自动将主站已下架、已出租或被删除的商品标记为已下架,避免历史幽灵商品滞留货架。
|
推荐勾选。只把「商品记录已被删除」的历史货架行标记为已下架;已下架、已出租、待审核的商品会保留在货架,并随商品重新在售自动恢复展示,不会影响您的人工下架选品。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1370,8 +1370,8 @@ export default function Accounts() {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Card size="small" style={{ background: '#fffbe6', borderColor: '#ffe58f', textAlign: 'center' }}>
|
<Card size="small" style={{ background: '#fffbe6', borderColor: '#ffe58f', textAlign: 'center' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="联动下架失效商品"
|
title="恢复展示"
|
||||||
value={syncResult?.disabled_stale_count ?? 0}
|
value={syncResult?.reenabled_count ?? 0}
|
||||||
valueStyle={{ color: '#faad14', fontWeight: 'bold' }}
|
valueStyle={{ color: '#faad14', fontWeight: 'bold' }}
|
||||||
suffix="个"
|
suffix="个"
|
||||||
/>
|
/>
|
||||||
@@ -1380,7 +1380,17 @@ export default function Accounts() {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Card size="small" style={{ background: '#fafafa', borderColor: '#d9d9d9', textAlign: 'center' }}>
|
<Card size="small" style={{ background: '#fafafa', borderColor: '#d9d9d9', textAlign: 'center' }}>
|
||||||
<Statistic
|
<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}
|
value={syncResult?.total_active ?? 0}
|
||||||
valueStyle={{ color: '#595959', fontWeight: 'bold' }}
|
valueStyle={{ color: '#595959', fontWeight: 'bold' }}
|
||||||
suffix="个"
|
suffix="个"
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export default function Storefront() {
|
|||||||
description={
|
description={
|
||||||
<span style={{ color: '#94a3b8' }}>
|
<span style={{ color: '#94a3b8' }}>
|
||||||
{items.length === 0
|
{items.length === 0
|
||||||
? '当前分销站点暂无上架商品,站长可通过分销后台一键同步主站商品。'
|
? '当前分销站点暂无在售商品。全网新审核通过的商品会自动上架到本站货架。'
|
||||||
: '未找到符合条件的账号商品,请更换搜索词重试。'}
|
: '未找到符合条件的账号商品,请更换搜索词重试。'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -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 不纳入分销网的业务
|
### 6.4 不纳入分销网的业务
|
||||||
|
|
||||||
@@ -838,6 +861,9 @@ distribution.hao858.com {
|
|||||||
4. 分站货架自主提价溢价(`shelf_markup_cent`)100% 独享归卖方分站,不参与三方切分;
|
4. 分站货架自主提价溢价(`shelf_markup_cent`)100% 独享归卖方分站,不参与三方切分;
|
||||||
5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。
|
5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。
|
||||||
- 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
|
- 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
|
||||||
|
- 货架铺货与展示口径(详见 6.3.1):商品变为 `published + approved` 时自动铺到所有开启自动展示的站点货架,
|
||||||
|
不再依赖站长手动同步;`distribution_site_accounts.display_disabled_by` 记录下架来源,自动铺货不覆盖站长人工下架;
|
||||||
|
`clean_stale_listings` 只清理商品记录已删除的孤儿货架行。
|
||||||
- 生产与测试口径一致:结算批次入账、站点提现与冷却期自动释放不再按 `APP_ENV` 冻结(原 `liveMoneyMovementEnabled` 生产保护已移除)。
|
- 生产与测试口径一致:结算批次入账、站点提现与冷却期自动释放不再按 `APP_ENV` 冻结(原 `liveMoneyMovementEnabled` 生产保护已移除)。
|
||||||
- 新建站点的默认结算冷却天数由系统配置 `distribution.default_settlement_hold_days` 统一控制(默认 7 天,0=立即释放,最大 365),管理端可在系统配置页调整;站点级 `settlement_hold_days` 仍可单独覆盖。
|
- 新建站点的默认结算冷却天数由系统配置 `distribution.default_settlement_hold_days` 统一控制(默认 7 天,0=立即释放,最大 365),管理端可在系统配置页调整;站点级 `settlement_hold_days` 仍可单独覆盖。
|
||||||
- `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。
|
- `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export interface SyncSiteAccountsResult {
|
|||||||
synced_count: number
|
synced_count: number
|
||||||
skipped_count: number
|
skipped_count: number
|
||||||
updated_count?: number
|
updated_count?: number
|
||||||
|
reenabled_count?: number
|
||||||
disabled_stale_count?: number
|
disabled_stale_count?: number
|
||||||
total_active: number
|
total_active: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -490,8 +490,9 @@ async function handleSyncAccounts(row: DistributionSite) {
|
|||||||
syncingSiteId.value = row.id
|
syncingSiteId.value = row.id
|
||||||
try {
|
try {
|
||||||
const res = await syncDistributionSiteAccounts(row.id)
|
const res = await syncDistributionSiteAccounts(row.id)
|
||||||
|
const reenabled = res.reenabled_count ? `,恢复展示 ${res.reenabled_count} 个` : ''
|
||||||
ElMessage.success(
|
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) {
|
} catch (err) {
|
||||||
ElMessage.error(readError(err, '同步商品失败'))
|
ElMessage.error(readError(err, '同步商品失败'))
|
||||||
|
|||||||
+12
-4
@@ -19,9 +19,6 @@ const pinia = createPinia()
|
|||||||
|
|
||||||
app.use(pinia).use(router)
|
app.use(pinia).use(router)
|
||||||
|
|
||||||
// 初始化分销站点租户配置(如 ?site=xxx 或独立域名访问)
|
|
||||||
useDistributionSiteStore(pinia).initSite()
|
|
||||||
|
|
||||||
window.addEventListener('auth-storage-changed', () => {
|
window.addEventListener('auth-storage-changed', () => {
|
||||||
useSessionStore(pinia).syncFromStorage()
|
useSessionStore(pinia).syncFromStorage()
|
||||||
useAdminSessionStore(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()
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export interface DistributionSiteInfo {
|
|||||||
* 1. 环境变量 VITE_DISTRIBUTION_SITE_CODE(针对独立分销前台开发端口,如 5191)
|
* 1. 环境变量 VITE_DISTRIBUTION_SITE_CODE(针对独立分销前台开发端口,如 5191)
|
||||||
* 2. 当前标签页 sessionStorage 中的 hfb_site_code(避免跨 Tab 污染主站)
|
* 2. 当前标签页 sessionStorage 中的 hfb_site_code(避免跨 Tab 污染主站)
|
||||||
* 3. 本地二级域名(如 test_1.localhost:5189)
|
* 3. 本地二级域名(如 test_1.localhost:5189)
|
||||||
* 4. 生产环境或独立域名绑定的 localStorage 缓存
|
*
|
||||||
|
* 注意:独立域名下的站点编码只来自 sessionStorage,由 initSite() 通过
|
||||||
|
* /distribution/site-info?domain= 探测后写入。历史上曾读取 localStorage 缓存,
|
||||||
|
* 会让同一浏览器访问主站时串到分站站点码(把主站发布记到分站/反之),因此已移除。
|
||||||
*/
|
*/
|
||||||
export function getEffectiveSiteCode(): string {
|
export function getEffectiveSiteCode(): string {
|
||||||
if (typeof window === 'undefined') return ''
|
if (typeof window === 'undefined') return ''
|
||||||
@@ -52,10 +55,6 @@ export function getEffectiveSiteCode(): string {
|
|||||||
return hostname.replace(/\.localhost$/, '')
|
return hostname.replace(/\.localhost$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
|
||||||
return localStorage.getItem('hfb_site_code') || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user