diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go
index 386c42f..ddb2deb 100644
--- a/backend/internal/model/listing.go
+++ b/backend/internal/model/listing.go
@@ -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"`
diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go
index e549665..efda0d1 100644
--- a/backend/internal/modules/distribution/dto.go
+++ b/backend/internal/modules/distribution/dto.go
@@ -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"`
diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go
index e118abe..f7a0236 100644
--- a/backend/internal/modules/distribution/handler.go
+++ b/backend/internal/modules/distribution/handler.go
@@ -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):
diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go
index b80c47e..66c8dc2 100644
--- a/backend/internal/modules/distribution/repository.go
+++ b/backend/internal/modules/distribution/repository.go
@@ -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),
diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go
index 760f445..94e106b 100644
--- a/backend/internal/modules/distribution/repository_test.go
+++ b/backend/internal/modules/distribution/repository_test.go
@@ -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)
diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go
index 9e77d75..7896f79 100644
--- a/backend/internal/modules/distribution/service.go
+++ b/backend/internal/modules/distribution/service.go
@@ -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) {
diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go
index 8d9f7dd..be4be34 100644
--- a/backend/internal/modules/listing/handler_error.go
+++ b/backend/internal/modules/listing/handler_error.go
@@ -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):
diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go
index 68c62cf..70ff8aa 100644
--- a/backend/internal/modules/listing/mutation.go
+++ b/backend/internal/modules/listing/mutation.go
@@ -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
diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go
index 157943e..56ad6ad 100644
--- a/backend/internal/modules/listing/presenter.go
+++ b/backend/internal/modules/listing/presenter.go
@@ -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,
diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go
index a1a86a9..ed28152 100644
--- a/backend/internal/modules/listing/query.go
+++ b/backend/internal/modules/listing/query.go
@@ -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").
diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go
index 9c63393..634ac89 100644
--- a/backend/internal/modules/listing/service.go
+++ b/backend/internal/modules/listing/service.go
@@ -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 {
diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go
index 1ec8251..bfe177b 100644
--- a/backend/internal/modules/listing/service_test.go
+++ b/backend/internal/modules/listing/service_test.go
@@ -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 {
diff --git a/backend/migrations/000076_fix_listing_attribution_without_timestamp.sql b/backend/migrations/000076_fix_listing_attribution_without_timestamp.sql
new file mode 100644
index 0000000..a09beb2
--- /dev/null
+++ b/backend/migrations/000076_fix_listing_attribution_without_timestamp.sql
@@ -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;
diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx
index 27f554e..d6486b6 100644
--- a/distribution-admin/src/pages/Accounts.tsx
+++ b/distribution-admin/src/pages/Accounts.tsx
@@ -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() {
商品与账号展示
- 管理当前分销网站对外展示与销售的游戏账号商品,支持实时从主站同步最新上架货源。
+ 管理当前分销网站对外展示的商品。默认同步本站上传货,主站货需显式勾选,不会灌入全站目录。
diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue
index 384415c..cc05305 100644
--- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue
+++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue
@@ -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, '同步商品失败'))