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
@@ -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) {