fix(distribution): 修正商品归属回填、禁止全站灌货并拒绝无效站点头

用货架写入时间与上传来源回滚误标产地,不再依赖绝对时间戳;同步仅收本站上传与显式勾选。
This commit is contained in:
yml2213
2026-09-08 17:30:12 +08:00
parent 96bc6a4db6
commit 31bf6dd3b6
15 changed files with 287 additions and 84 deletions
+1
View File
@@ -42,6 +42,7 @@ type RentalListing struct {
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
DistributionSiteID *uint64 `gorm:"column:distribution_site_id;index" json:"distribution_site_id,omitempty"`
SourceChannel string `gorm:"size:64;not null;default:''" json:"-"`
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
PublishedAt *time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
@@ -150,6 +150,10 @@ type AccountDisplayDTO struct {
InTransaction bool `json:"in_transaction,omitempty"`
}
type SyncSiteAccountsRequest struct {
ListingIDs []uint64 `json:"listing_ids"`
}
type SyncSiteAccountsResult struct {
SyncedCount int64 `json:"synced_count"`
SkippedCount int64 `json:"skipped_count"`
@@ -237,6 +237,13 @@ func (h *Handler) SyncSiteAccounts(c *gin.Context) {
siteID = uint64(qID)
}
}
var req SyncSiteAccountsRequest
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "同步参数不正确")
return
}
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, siteID)
if err != nil {
@@ -247,7 +254,7 @@ func (h *Handler) SyncSiteAccounts(c *gin.Context) {
response.BadRequest(c, "site_id 不正确")
return
}
result, err := h.service.SyncSiteAccounts(c.Request.Context(), adminID, scoped)
result, err := h.service.SyncSiteAccounts(c.Request.Context(), adminID, scoped, req.ListingIDs)
if err != nil {
writeError(c, err)
return
@@ -741,6 +748,10 @@ func writeError(c *gin.Context, err error) {
response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrTooManySyncListings):
response.BadRequest(c, "一次勾选同步的商品过多")
case errors.Is(err, ErrSyncListingNotFound):
response.BadRequest(c, "勾选的商品不存在")
case errors.Is(err, ErrLiveMoneyMovementFrozen):
response.Error(c, http.StatusForbidden, "settlement_frozen", "分销结算与提现已冻结,待分成口径确认后再开放")
case errors.Is(err, ErrSiteAccessDenied):
@@ -41,8 +41,12 @@ var (
ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists")
ErrDuplicateUsername = errors.New("username already exists")
ErrLiveMoneyMovementFrozen = errors.New("distribution settlement and withdrawal frozen until split rules are verified")
ErrTooManySyncListings = errors.New("too many listings to sync")
ErrSyncListingNotFound = errors.New("sync listing not found")
)
const maxSyncListingIDs = 200
func liveMoneyMovementEnabled() bool {
env := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
return env != "production" && env != "prod"
@@ -507,7 +511,26 @@ func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint6
return item, nil
}
func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64) (*SyncSiteAccountsResult, error) {
func uniquePositiveIDs(ids []uint64) []uint64 {
if len(ids) == 0 {
return nil
}
seen := make(map[uint64]struct{}, len(ids))
out := make([]uint64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, listingIDs []uint64) (*SyncSiteAccountsResult, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
}
@@ -522,14 +545,40 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
return nil, err
}
var activeListings []model.RentalListing
selectedIDs := uniquePositiveIDs(listingIDs)
if len(selectedIDs) > maxSyncListingIDs {
return nil, ErrTooManySyncListings
}
var candidates []model.RentalListing
if err := r.db.WithContext(ctx).
Where("status = ? AND review_status = ?", "published", "approved").
Where("distribution_site_id = ? AND status = ? AND review_status = ?", siteID, "published", "approved").
Order("id ASC").
Find(&activeListings).Error; err != nil {
Find(&candidates).Error; err != nil {
return nil, err
}
if len(selectedIDs) > 0 {
var selected []model.RentalListing
if err := r.db.WithContext(ctx).Where("id IN ?", selectedIDs).Find(&selected).Error; err != nil {
return nil, err
}
if len(selected) != len(selectedIDs) {
return nil, ErrSyncListingNotFound
}
seenListing := make(map[uint64]bool, len(candidates)+len(selected))
for _, l := range candidates {
seenListing[l.ID] = true
}
for _, l := range selected {
if seenListing[l.ID] {
continue
}
seenListing[l.ID] = true
candidates = append(candidates, l)
}
}
var existingAccounts []model.DistributionSiteAccount
if err := r.db.WithContext(ctx).
Where("site_id = ?", siteID).
@@ -546,7 +595,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
seenAccount := make(map[uint64]bool)
var skippedCount int64
for _, l := range activeListings {
for _, l := range candidates {
if seenAccount[l.AccountID] {
continue
}
@@ -577,7 +626,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
return &SyncSiteAccountsResult{
SyncedCount: int64(len(toInsert)),
SkippedCount: skippedCount,
TotalActive: int64(len(activeListings)),
TotalActive: int64(len(candidates)),
}, nil
}
@@ -1096,34 +1145,6 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return err
}
if displayEnabled {
var activeListings []model.RentalListing
if err := tx.Where("status = ? AND review_status = ?", "published", "approved").Order("id ASC").Find(&activeListings).Error; err != nil {
return err
}
if len(activeListings) > 0 {
siteAccounts := make([]model.DistributionSiteAccount, 0, len(activeListings))
seenAccount := make(map[uint64]bool)
for _, l := range activeListings {
if seenAccount[l.AccountID] {
continue
}
seenAccount[l.AccountID] = true
siteAccounts = append(siteAccounts, model.DistributionSiteAccount{
SiteID: site.ID,
AccountID: l.AccountID,
ListingID: l.ID,
DisplayStatus: "enabled",
})
}
if len(siteAccounts) > 0 {
if err := tx.Create(&siteAccounts).Error; err != nil {
return err
}
}
}
}
resp = OnboardSiteResponse{
Site: siteDTO(site),
Rule: ruleDTO(rule),
@@ -225,7 +225,7 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
t.Fatal(err)
}
listing1 := model.RentalListing{
mainListing := model.RentalListing{
ListingNo: "L10001",
AccountID: account1.ID,
OwnerID: 1,
@@ -235,11 +235,36 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
ReviewStatus: "approved",
InTransaction: false,
}
if err := db.Create(&listing1).Error; err != nil {
if err := db.Create(&mainListing).Error; err != nil {
t.Fatal(err)
}
ownAccount := model.GameAccount{
Title: "本站上传号",
GameName: "delta_force",
ServerRegion: "QQ区",
LoginPlatform: "PC",
Status: "active",
}
if err := db.Create(&ownAccount).Error; err != nil {
t.Fatal(err)
}
siteID := site.ID
ownListing := model.RentalListing{
ListingNo: "L10003",
AccountID: ownAccount.ID,
OwnerID: 2,
PriceCent: 1800,
DepositAmountCent: 8000,
Status: "published",
ReviewStatus: "approved",
DistributionSiteID: &siteID,
SourceChannel: site.SiteCode,
}
if err := db.Create(&ownListing).Error; err != nil {
t.Fatal(err)
}
// 草稿状态的商品不应该被同步
listingDraft := model.RentalListing{
ListingNo: "L10002",
AccountID: 999,
@@ -253,17 +278,16 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
t.Fatal(err)
}
// 首次同步:应当成功新增 1 条,跳过 0 条
res1, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID)
// 默认同步不得灌入主站全量目录,只收本站上传货
resMain, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, nil)
if err != nil {
t.Fatalf("SyncSiteAccounts err = %v", err)
t.Fatalf("SyncSiteAccounts own-only err = %v", err)
}
if res1.SyncedCount != 1 || res1.SkippedCount != 0 || res1.TotalActive != 1 {
t.Fatalf("unexpected res1: %+v", res1)
if resMain.SyncedCount != 1 || resMain.SkippedCount != 0 || resMain.TotalActive != 1 {
t.Fatalf("unexpected own-only res: %+v", resMain)
}
// 再次同步:幂等保证,跳过 1 条,新增 0 条
res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID)
res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, nil)
if err != nil {
t.Fatalf("SyncSiteAccounts retry err = %v", err)
}
@@ -271,35 +295,51 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
t.Fatalf("unexpected res2: %+v", res2)
}
// 验证管理后台列表查询携带丰富字段
if _, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, []uint64{999999}); !errors.Is(err, ErrSyncListingNotFound) {
t.Fatalf("missing listing_ids err = %v", err)
}
resSelect, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, []uint64{mainListing.ID})
if err != nil {
t.Fatalf("explicit select err = %v", err)
}
if resSelect.SyncedCount != 1 || resSelect.SkippedCount != 1 || resSelect.TotalActive != 2 {
t.Fatalf("unexpected explicit select res: %+v", resSelect)
}
displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "")
if err != nil {
t.Fatalf("ListAccountDisplays err = %v", err)
}
if len(displays) != 1 {
t.Fatalf("expected 1 display, got %d", len(displays))
if len(displays) != 2 {
t.Fatalf("expected 2 displays, got %d", len(displays))
}
d := displays[0]
if d.Title != "三角洲极品金枪号" || d.GameName != "delta_force" || d.PriceCent != 2500 || d.DepositAmountCent != 10000 || d.ListingNo != "L10001" {
t.Fatalf("unexpected enriched display: %+v", d)
byNo := map[string]AccountDisplayDTO{}
for _, item := range displays {
byNo[item.ListingNo] = item
}
mainDisplay, ok := byNo["L10001"]
if !ok || mainDisplay.Title != "三角洲极品金枪号" || mainDisplay.PriceCent != 2500 || mainDisplay.DepositAmountCent != 10000 {
t.Fatalf("unexpected main display: %+v", byNo)
}
if _, ok := byNo["L10003"]; !ok {
t.Fatalf("missing own-site display: %+v", byNo)
}
// 验证公共端点浏览查询
pubList, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20)
if err != nil {
t.Fatalf("ListPublicSiteListings err = %v", err)
}
if pubList.Total != 1 || len(pubList.Items) != 1 {
t.Fatalf("expected 1 public item, got %+v", pubList)
}
if pubList.Items[0].Title != "三角洲极品金枪号" || pubList.Items[0].SiteCode != site.SiteCode {
t.Fatalf("unexpected public item: %+v", pubList.Items[0])
if pubList.Total != 2 || len(pubList.Items) != 2 {
t.Fatalf("expected 2 public items, got %+v", pubList)
}
// 下架该展示账号后,公共端点不再返回
if _, err := repo.UpdateAccountDisplay(t.Context(), 0, d.ID, "disabled"); err != nil {
if _, err := repo.UpdateAccountDisplay(t.Context(), 0, mainDisplay.ID, "disabled"); err != nil {
t.Fatalf("UpdateAccountDisplay disabled err = %v", err)
}
if _, err := repo.UpdateAccountDisplay(t.Context(), 0, byNo["L10003"].ID, "disabled"); err != nil {
t.Fatalf("UpdateAccountDisplay own disabled err = %v", err)
}
pubList2, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20)
if err != nil {
t.Fatalf("ListPublicSiteListings after disabled err = %v", err)
@@ -309,7 +349,7 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
}
}
func TestOnboardSiteAutoSyncsActiveListings(t *testing.T) {
func TestOnboardSiteDoesNotDumpCatalog(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
@@ -353,11 +393,8 @@ func TestOnboardSiteAutoSyncsActiveListings(t *testing.T) {
if err != nil {
t.Fatalf("ListAccountDisplays err = %v", err)
}
if len(displays) != 1 {
t.Fatalf("expected 1 auto-synced account, got %d", len(displays))
}
if displays[0].Title != "王者荣耀国服账号" || displays[0].ListingNo != "L20001" {
t.Fatalf("unexpected auto-synced display: %+v", displays[0])
if len(displays) != 0 {
t.Fatalf("onboard must not dump catalog, got %d displays", len(displays))
}
if onboardResp.Rule.Status != "draft" || onboardResp.Rule.DistributorReceiveRatio != 0 || onboardResp.Rule.PlatformSellRatio != 0 {
t.Fatalf("onboard must not activate default 3.5/4 split, got %+v", onboardResp.Rule)
@@ -218,11 +218,11 @@ func (s *Service) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*Onb
return s.repo.OnboardSite(ctx, req)
}
func (s *Service) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64) (*SyncSiteAccountsResult, error) {
func (s *Service) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, listingIDs []uint64) (*SyncSiteAccountsResult, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.SyncSiteAccounts(ctx, adminID, siteID)
return s.repo.SyncSiteAccounts(ctx, adminID, siteID, listingIDs)
}
func (s *Service) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {
@@ -55,6 +55,8 @@ func writeListingError(c *gin.Context, err error) {
response.BadRequest(c, "外部商品调整参数不正确")
case errors.Is(err, ErrSaleRatioOutOfRange):
response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间")
case errors.Is(err, ErrDistributionSiteInvalid):
response.BadRequest(c, "分销站点无效或已停用")
case isUploadValidationError(err):
response.BadRequest(c, err.Error())
case isFireLevelTooLow(err):
+19 -9
View File
@@ -22,6 +22,22 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
if err := r.ensureCreateCooldown(tx, ownerID, publishCooldown, now); err != nil {
return err
}
var site model.DistributionSite
var siteID *uint64
originChannel := ""
if len(siteCode) > 0 && strings.TrimSpace(siteCode[0]) != "" {
trimmedSiteCode := strings.TrimSpace(siteCode[0])
if err := tx.Where("site_code = ? AND status = 'active'", trimmedSiteCode).First(&site).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrDistributionSiteInvalid
}
return err
}
siteID = &site.ID
originChannel = site.SiteCode
}
listingNo, err := r.nextListingNo(tx, now)
if err != nil {
return err
@@ -35,6 +51,7 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
return err
}
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
account := model.GameAccount{
OwnerID: ownerID,
GameName: "delta_force",
@@ -52,15 +69,6 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
return err
}
var site model.DistributionSite
var siteID *uint64
if len(siteCode) > 0 && strings.TrimSpace(siteCode[0]) != "" {
trimmedSiteCode := strings.TrimSpace(siteCode[0])
if err := tx.Where("site_code = ? AND status = 'active'", trimmedSiteCode).First(&site).Error; err == nil {
siteID = &site.ID
}
}
priceCent := normalizedListingPriceCent(req)
listing := model.RentalListing{
ListingNo: listingNo,
@@ -72,6 +80,7 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
ReviewStatus: reviewStatus,
PublishedAt: publishedAt,
DistributionSiteID: siteID,
SourceChannel: originChannel,
}
if err := tx.Create(&listing).Error; err != nil {
return err
@@ -203,6 +212,7 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern
HandoffMode: "platform",
SettlementMode: "platform_managed",
ManagedAdminID: &admin.ID,
SourceChannel: upload.SourceChannel,
}
if err := tx.Create(&listing).Error; err != nil {
return err
@@ -18,7 +18,7 @@ type listingRow struct {
Title string
OwnerPhone string
OwnerNickname string
SourceChannel string `gorm:"column:source_channel"`
DisplaySourceChannel string `gorm:"column:display_source_channel"`
IsExternalUpload bool `gorm:"column:is_external_upload"`
DistributionSiteCode string `gorm:"column:distribution_site_code"`
DistributionSiteName string `gorm:"column:distribution_site_name"`
@@ -171,7 +171,7 @@ func (row listingRow) toDTO() ListingDTO {
OwnerID: row.OwnerID,
OwnerPhone: row.OwnerPhone,
OwnerNickname: row.OwnerNickname,
SourceChannel: row.SourceChannel,
SourceChannel: row.DisplaySourceChannel,
IsExternalUpload: row.IsExternalUpload,
Title: row.Title,
Description: row.Description,
+1 -1
View File
@@ -233,7 +233,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
WHEN lu.id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
END AS display_source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
Joins("LEFT JOIN distribution_sites AS src_site ON src_site.id = l.distribution_site_id").
@@ -32,6 +32,7 @@ var (
ErrExternalListingRequired = errors.New("external listing required")
ErrExternalAdjustment = errors.New("invalid external listing adjustment")
ErrSaleRatioOutOfRange = errors.New("sale ratio out of range")
ErrDistributionSiteInvalid = errors.New("invalid distribution site")
)
type Service struct {
@@ -891,6 +891,14 @@ func TestCreateListingWithDistributionSiteCode(t *testing.T) {
t.Fatalf("expected enabled, got %s", dsa.DisplayStatus)
}
var stored model.RentalListing
if err := db.First(&stored, dto.ID).Error; err != nil {
t.Fatalf("load listing: %v", err)
}
if stored.SourceChannel != site.SiteCode {
t.Fatalf("stored source_channel = %q, want %q", stored.SourceChannel, site.SiteCode)
}
// 验证审核工作台列表展示分销站来源
pending, err := repo.ListPendingReview(context.Background())
if err != nil {
@@ -904,6 +912,43 @@ func TestCreateListingWithDistributionSiteCode(t *testing.T) {
}
}
func TestCreateListingInvalidDistributionSiteCode(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
repo := NewRepository(db, nil)
user := model.User{Phone: "18800000011", Nickname: "分销卖家", RealnameStatus: "verified", Status: "active"}
db.Create(&user)
inactive := model.DistributionSite{
SiteCode: "dist_inactive",
SiteName: "已停用分销站",
Domain: "inactive.example.com",
Status: "disabled",
DisplayAccountEnabled: true,
}
db.Create(&inactive)
req := CreateRequest{Title: "无效站点商品", ServerRegion: "QQ", LoginPlatform: "PC", PriceCent: 1000}
if _, err := repo.Create(context.Background(), user.ID, req, true, 0, "does_not_exist"); !errors.Is(err, ErrDistributionSiteInvalid) {
t.Fatalf("unknown site err = %v, want ErrDistributionSiteInvalid", err)
}
if _, err := repo.Create(context.Background(), user.ID, req, true, 0, "dist_inactive"); !errors.Is(err, ErrDistributionSiteInvalid) {
t.Fatalf("inactive site err = %v, want ErrDistributionSiteInvalid", err)
}
var count int64
if err := db.Model(&model.RentalListing{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("invalid site must not create listing, got %d", count)
}
}
func TestListMineSiteIsolation(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
@@ -0,0 +1,71 @@
-- +goose Up
-- 补偿 000072 / 000073:货架展示不等于产地,禁止用绝对时间回滚。
-- 本站上传特征:无 listing_uploads(外部导入走该表,source_channel=咸鱼/淘宝等),
-- 且 distribution_site_accounts 与商品在同一发布事务内写入(created_at 相差不超过 5 秒)。
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'source_channel') = 0,
'ALTER TABLE rental_listings ADD COLUMN source_channel VARCHAR(64) NOT NULL DEFAULT '''' AFTER distribution_site_id',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 1. 恢复被 000073 时间戳误清空的本站上传归属
UPDATE rental_listings l
INNER JOIN (
SELECT dsa.listing_id, MIN(dsa.site_id) AS site_id
FROM distribution_site_accounts dsa
INNER JOIN rental_listings l2 ON l2.id = dsa.listing_id
LEFT JOIN listing_uploads lu ON lu.listing_id = dsa.listing_id
WHERE lu.id IS NULL
AND TIMESTAMPDIFF(SECOND, l2.created_at, dsa.created_at) BETWEEN 0 AND 5
GROUP BY dsa.listing_id
) origin ON origin.listing_id = l.id
SET l.distribution_site_id = origin.site_id
WHERE l.distribution_site_id IS NULL OR l.distribution_site_id = 0;
-- 2. 清掉仅因货架回填产生的归属(外部导入,或 DSA 明显晚于商品创建)
UPDATE rental_listings l
LEFT JOIN listing_uploads lu ON lu.listing_id = l.id
LEFT JOIN distribution_site_accounts origin_dsa
ON origin_dsa.listing_id = l.id
AND origin_dsa.site_id = l.distribution_site_id
AND TIMESTAMPDIFF(SECOND, l.created_at, origin_dsa.created_at) BETWEEN 0 AND 5
SET l.distribution_site_id = NULL
WHERE l.distribution_site_id IS NOT NULL
AND l.distribution_site_id <> 0
AND (lu.id IS NOT NULL OR origin_dsa.id IS NULL);
-- 3. 回填来源渠道快照:分销站用 site_code,外部导入用 listing_uploads.source_channel
UPDATE rental_listings l
INNER JOIN distribution_sites s ON s.id = l.distribution_site_id
SET l.source_channel = s.site_code
WHERE l.distribution_site_id IS NOT NULL
AND l.distribution_site_id <> 0
AND l.source_channel = '';
UPDATE rental_listings l
INNER JOIN (
SELECT lu.listing_id, MAX(NULLIF(lu.source_channel, '')) AS source_channel
FROM listing_uploads lu
WHERE lu.listing_id IS NOT NULL
GROUP BY lu.listing_id
) upload ON upload.listing_id = l.id
SET l.source_channel = COALESCE(upload.source_channel, '')
WHERE (l.distribution_site_id IS NULL OR l.distribution_site_id = 0)
AND l.source_channel = ''
AND COALESCE(upload.source_channel, '') <> '';
-- +goose Down
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'source_channel') > 0,
'ALTER TABLE rental_listings DROP COLUMN source_channel',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+4 -4
View File
@@ -69,13 +69,13 @@ export default function Accounts() {
try {
const res = await syncSiteAccounts(siteID)
message.success(
`同步完成!新增可售账号 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(主站当前在售总数 ${res.total_active})`,
`同步完成!新增本站上传货 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(本次候选 ${res.total_active} 个)`,
5,
)
await load(siteID, status)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '同步主站商品失败')
message.error(respMessage ?? '同步本站上传商品失败')
} finally {
setSyncing(false)
}
@@ -186,7 +186,7 @@ export default function Accounts() {
商品与账号展示
</Typography.Title>
<Typography.Text type="secondary">
管理当前分销网站对外展示与销售的游戏账号商品,支持实时从主站同步最新上架货源。
管理当前分销网站对外展示的商品。默认同步本站上传货,主站货需显式勾选,不会灌入全站目录。
</Typography.Text>
</div>
<Button
@@ -196,7 +196,7 @@ export default function Accounts() {
onClick={handleSync}
style={{ background: '#1677ff' }}
>
一键同步主站商品
同步本站上传商品
</Button>
</div>
@@ -134,7 +134,7 @@ async function handleSyncAccounts(row: DistributionSite) {
try {
const res = await syncDistributionSiteAccounts(row.id)
ElMessage.success(
`同步成功!为【${row.site_name}】新增可售账号 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(主站当前可上架总数 ${res.total_active})`
`同步成功!为【${row.site_name}】新增本站上传货 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(本次候选 ${res.total_active} 个)`
)
} catch (err) {
ElMessage.error(readError(err, '同步商品失败'))