From 31bf6dd3b6643b5340bc663c6dabb4d90f717aea Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 8 Sep 2026 17:30:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(distribution):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=BD=92=E5=B1=9E=E5=9B=9E=E5=A1=AB=E3=80=81?= =?UTF-8?q?=E7=A6=81=E6=AD=A2=E5=85=A8=E7=AB=99=E7=81=8C=E8=B4=A7=E5=B9=B6?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=97=A0=E6=95=88=E7=AB=99=E7=82=B9=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用货架写入时间与上传来源回滚误标产地,不再依赖绝对时间戳;同步仅收本站上传与显式勾选。 --- backend/internal/model/listing.go | 1 + backend/internal/modules/distribution/dto.go | 4 + .../internal/modules/distribution/handler.go | 13 ++- .../modules/distribution/repository.go | 89 ++++++++++------- .../modules/distribution/repository_test.go | 97 +++++++++++++------ .../internal/modules/distribution/service.go | 4 +- .../internal/modules/listing/handler_error.go | 2 + backend/internal/modules/listing/mutation.go | 28 ++++-- backend/internal/modules/listing/presenter.go | 4 +- backend/internal/modules/listing/query.go | 2 +- backend/internal/modules/listing/service.go | 1 + .../internal/modules/listing/service_test.go | 45 +++++++++ ..._listing_attribution_without_timestamp.sql | 71 ++++++++++++++ distribution-admin/src/pages/Accounts.tsx | 8 +- .../views/AdminDistributionSitesView.vue | 2 +- 15 files changed, 287 insertions(+), 84 deletions(-) create mode 100644 backend/migrations/000076_fix_listing_attribution_without_timestamp.sql 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, '同步商品失败'))