fix(distribution): 修正商品归属回填、禁止全站灌货并拒绝无效站点头
用货架写入时间与上传来源回滚误标产地,不再依赖绝对时间戳;同步仅收本站上传与显式勾选。
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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, '同步商品失败'))
|
||||
|
||||
Reference in New Issue
Block a user