fix(distribution): 收紧分销权限与货架状态,开通时创建站点金库

无站点成员不再视为超管;分站下架只改展示状态;装修、批量加价与提现参数对齐。
This commit is contained in:
yml2213
2026-09-08 17:45:05 +08:00
parent 31bf6dd3b6
commit 48a2961245
10 changed files with 336 additions and 74 deletions
@@ -116,8 +116,32 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
t.Fatalf("expected ErrAdminDisabled, got %v", err)
}
// 7. Test superadmin login (user without site member binding)
hash, _ := bcrypt.GenerateFromPassword([]byte("SuperAdmin123!"), bcrypt.DefaultCost)
plainAdmin := model.AdminUser{
Username: "plain_admin",
PasswordHash: string(hash),
Nickname: "无角色管理员",
Status: "active",
TokenVersion: 1,
}
if err := db.Create(&plainAdmin).Error; err != nil {
t.Fatal(err)
}
plainLogin, err := svc.Login(ctx, DistributionLoginRequest{
Username: "plain_admin",
Password: "SuperAdmin123!",
}, "127.0.0.1")
if err != nil {
t.Fatalf("plain admin login failed: %v", err)
}
if plainLogin.Member.IsSuperadmin {
t.Fatalf("admin without super_admin role or all_view must not be superadmin")
}
superRole := model.Role{Code: "super_admin", Name: "超级管理员"}
if err := db.Create(&superRole).Error; err != nil {
t.Fatal(err)
}
superAdmin := model.AdminUser{
Username: "platform_admin",
PasswordHash: string(hash),
@@ -128,6 +152,9 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
if err := db.Create(&superAdmin).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AdminUserRole{AdminID: superAdmin.ID, RoleID: superRole.ID}).Error; err != nil {
t.Fatal(err)
}
superLoginResp, err := svc.Login(ctx, DistributionLoginRequest{
Username: "platform_admin",
+7 -7
View File
@@ -350,13 +350,13 @@ type UpdateReceivedListingStatusRequest struct {
Status string `json:"status" binding:"required,oneof=published offline"`
}
// 站点品牌与装修 DTO
// 站点品牌与装修 DTO。指针字段未传则保持原值,避免空字符串把装修清掉。
type UpdateSiteBrandingRequest struct {
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
BannersJSON string `json:"banners_json"`
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
AnnouncementsJSON *string `json:"announcements_json"`
FooterInfoJSON *string `json:"footer_info_json"`
}
// 自主加价 DTO
@@ -379,7 +379,7 @@ type SiteWalletDTO struct {
}
type ApplySiteWithdrawalRequest struct {
SiteID uint64 `json:"site_id" binding:"required"`
SiteID uint64 `json:"site_id"`
AmountCent int64 `json:"amount_cent" binding:"required,gt=0"`
AccountType string `json:"account_type" binding:"required,oneof=alipay bank"`
AccountName string `json:"account_name" binding:"required"`
@@ -752,6 +752,10 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "一次勾选同步的商品过多")
case errors.Is(err, ErrSyncListingNotFound):
response.BadRequest(c, "勾选的商品不存在")
case errors.Is(err, ErrEmptyListingIDs):
response.BadRequest(c, "请选择要加价的商品")
case errors.Is(err, ErrInvalidListingStatus):
response.BadRequest(c, "商品展示状态不正确")
case errors.Is(err, ErrLiveMoneyMovementFrozen):
response.Error(c, http.StatusForbidden, "settlement_frozen", "分销结算与提现已冻结,待分成口径确认后再开放")
case errors.Is(err, ErrSiteAccessDenied):
@@ -43,6 +43,8 @@ var (
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")
ErrEmptyListingIDs = errors.New("listing ids required")
ErrInvalidListingStatus = errors.New("invalid listing status")
)
const maxSyncListingIDs = 200
@@ -65,43 +67,88 @@ func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
return r
}
func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) {
if adminID == 0 {
return true, nil
}
if r.db == nil {
return false, nil
}
var n int64
if err := r.db.WithContext(ctx).Table("roles").
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
Where("aur.admin_user_id = ? AND roles.code = ?", adminID, "super_admin").
Count(&n).Error; err != nil {
return false, err
}
if n > 0 {
return true, nil
}
if err := r.db.WithContext(ctx).Table("permissions").
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
Where("aur.admin_user_id = ? AND permissions.code = ?", adminID, "distribution:all_view").
Count(&n).Error; err != nil {
return false, err
}
return n > 0, nil
}
func (r *Repository) listActiveMemberSiteIDs(ctx context.Context, adminID uint64) ([]uint64, error) {
var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
return nil, err
}
ids := make([]uint64, 0, len(members))
for _, member := range members {
ids = append(ids, member.SiteID)
}
return ids, nil
}
func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
if adminID == 0 {
return requested, nil
}
var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
global, err := r.hasGlobalDistributionAccess(ctx, adminID)
if err != nil {
return 0, err
}
if len(members) == 0 {
if global {
return requested, nil
}
siteIDs, err := r.listActiveMemberSiteIDs(ctx, adminID)
if err != nil {
return 0, err
}
if len(siteIDs) == 0 {
return 0, ErrSiteAccessDenied
}
if requested > 0 {
for _, member := range members {
if member.SiteID == requested {
for _, siteID := range siteIDs {
if siteID == requested {
return requested, nil
}
}
return 0, ErrSiteAccessDenied
}
if len(members) == 1 {
return members[0].SiteID, nil
if len(siteIDs) == 1 {
return siteIDs[0], nil
}
return 0, nil
}
// ensureSiteAccess 校验管理员可访问指定站点:
// 无成员记录(超级管理员语义)放行,有成员记录则必须命中。
// ensureSiteAccess 校验管理员可访问指定站点。
// 仅超级管理员或持有 distribution:all_view 的账号可跨站;无成员记录不再视为超管。
func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error {
if adminID == 0 {
return nil
}
var count int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
Where("admin_user_id = ? AND status = 'active'", adminID).Count(&count).Error; err != nil {
global, err := r.hasGlobalDistributionAccess(ctx, adminID)
if err != nil {
return err
}
if count == 0 {
if global {
return nil
}
if siteID == 0 {
@@ -165,6 +212,9 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
if err := wallet.EnsureAccountTx(r.db.WithContext(ctx), 0, row.ID); err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
@@ -950,11 +1000,15 @@ func (r *Repository) LoginMember(ctx context.Context, username, password string)
return nil, nil, err
}
global, err := r.hasGlobalDistributionAccess(ctx, admin.ID)
if err != nil {
return nil, nil, err
}
dto := DistributionCurrentMemberDTO{
AdminID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
IsSuperadmin: len(members) == 0,
IsSuperadmin: global,
}
if len(members) > 0 {
var site model.DistributionSite
@@ -972,7 +1026,7 @@ func (r *Repository) LoginMember(ctx context.Context, username, password string)
_ = r.db.WithContext(ctx).Model(&admin).Update("last_login_at", now).Error
if r.redis != nil {
if dto.IsSuperadmin {
if global {
raw, _ := json.Marshal([]string{"*"})
_ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err()
} else {
@@ -1018,11 +1072,15 @@ func (r *Repository) GetMemberProfile(ctx context.Context, adminID uint64) (*Dis
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
return nil, err
}
global, err := r.hasGlobalDistributionAccess(ctx, adminID)
if err != nil {
return nil, err
}
dto := DistributionCurrentMemberDTO{
AdminID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
IsSuperadmin: len(members) == 0,
IsSuperadmin: global,
}
if len(members) > 0 {
var site model.DistributionSite
@@ -1144,6 +1202,9 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
if err := tx.Create(&rule).Error; err != nil {
return err
}
if err := wallet.EnsureAccountTx(tx, 0, site.ID); err != nil {
return err
}
resp = OnboardSiteResponse{
Site: siteDTO(site),
@@ -1365,19 +1426,31 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin
}
return nil, err
}
site.LogoURL = strings.TrimSpace(req.LogoURL)
site.FaviconURL = strings.TrimSpace(req.FaviconURL)
site.BannersJSON = strings.TrimSpace(req.BannersJSON)
site.AnnouncementsJSON = strings.TrimSpace(req.AnnouncementsJSON)
site.FooterInfoJSON = strings.TrimSpace(req.FooterInfoJSON)
if err := r.db.WithContext(ctx).Model(&site).Updates(map[string]any{
"logo_url": site.LogoURL,
"favicon_url": site.FaviconURL,
"banners_json": site.BannersJSON,
"announcements_json": site.AnnouncementsJSON,
"footer_info_json": site.FooterInfoJSON,
}).Error; err != nil {
return nil, err
updates := map[string]any{}
if req.LogoURL != nil {
site.LogoURL = strings.TrimSpace(*req.LogoURL)
updates["logo_url"] = site.LogoURL
}
if req.FaviconURL != nil {
site.FaviconURL = strings.TrimSpace(*req.FaviconURL)
updates["favicon_url"] = site.FaviconURL
}
if req.BannersJSON != nil {
site.BannersJSON = strings.TrimSpace(*req.BannersJSON)
updates["banners_json"] = site.BannersJSON
}
if req.AnnouncementsJSON != nil {
site.AnnouncementsJSON = strings.TrimSpace(*req.AnnouncementsJSON)
updates["announcements_json"] = site.AnnouncementsJSON
}
if req.FooterInfoJSON != nil {
site.FooterInfoJSON = strings.TrimSpace(*req.FooterInfoJSON)
updates["footer_info_json"] = site.FooterInfoJSON
}
if len(updates) > 0 {
if err := r.db.WithContext(ctx).Model(&site).Updates(updates).Error; err != nil {
return nil, err
}
}
dto := siteDTO(site)
return &dto, nil
@@ -1422,11 +1495,12 @@ func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, site
if req.MarkupAmountCent < 0 {
return ErrInvalidAmount
}
query := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID)
if len(req.ListingIDs) > 0 {
query = query.Where("listing_id IN (?)", req.ListingIDs)
if len(req.ListingIDs) == 0 {
return ErrEmptyListingIDs
}
return query.Update("markup_amount_cent", req.MarkupAmountCent).Error
return r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("site_id = ? AND listing_id IN ?", siteID, req.ListingIDs).
Update("markup_amount_cent", req.MarkupAmountCent).Error
}
func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) {
@@ -1454,12 +1528,19 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
base := r.db.WithContext(ctx).Table("rental_listings rl").
Joins("JOIN game_accounts ga ON ga.id = rl.account_id").
Joins("LEFT JOIN users u ON u.id = rl.owner_id").
Joins("LEFT JOIN distribution_site_accounts dsa ON dsa.listing_id = rl.id AND dsa.site_id = ?", siteID).
Where("rl.distribution_site_id = ?", siteID)
if q.ReviewStatus != "" {
base = base.Where("rl.review_status = ?", q.ReviewStatus)
}
if q.Status != "" {
switch strings.TrimSpace(q.Status) {
case "":
case "offline", "disabled":
base = base.Where("dsa.display_status = ?", "disabled")
case "published", "enabled":
base = base.Where("dsa.id IS NULL OR dsa.display_status = ?", "enabled")
default:
base = base.Where("rl.status = ?", q.Status)
}
if q.GameName != "" {
@@ -1495,6 +1576,7 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
ReviewReason string `gorm:"column:review_reason"`
InTransaction bool `gorm:"column:in_transaction"`
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
DisplayStatus string `gorm:"column:display_status"`
CreatedAt time.Time `gorm:"column:created_at"`
PublishedAt *time.Time `gorm:"column:published_at"`
}
@@ -1507,6 +1589,7 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
ga.game_name, ga.title, ga.server_region, ga.login_platform, ga.rank_level, ga.haf_coin_amount,
rl.price_cent, rl.deposit_amount_cent, rl.status, rl.review_status, rl.review_reason,
rl.in_transaction, COALESCE(rl.distribution_site_id, 0) AS distribution_site_id,
COALESCE(dsa.display_status, 'enabled') AS display_status,
rl.created_at, rl.published_at
`
if err := base.Select(selectSQL).Order("rl.id DESC").Offset((page - 1) * size).Limit(size).Scan(&rows).Error; err != nil {
@@ -1519,6 +1602,10 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
if len(maskedPhone) >= 7 {
maskedPhone = maskedPhone[:3] + "****" + maskedPhone[len(maskedPhone)-4:]
}
status := r.Status
if r.DisplayStatus == "disabled" {
status = "offline"
}
items = append(items, ReceivedListingDTO{
ListingID: r.ID,
ListingNo: r.ListingNo,
@@ -1535,7 +1622,7 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
HafCoinAmount: r.HafCoinAmount,
PriceCent: r.PriceCent,
DepositAmountCent: r.DepositAmountCent,
Status: r.Status,
Status: status,
ReviewStatus: r.ReviewStatus,
ReviewReason: r.ReviewReason,
PublishedAt: r.PublishedAt,
@@ -1551,10 +1638,25 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
}, nil
}
func receivedDisplayStatus(status string) (string, error) {
switch strings.TrimSpace(status) {
case "published", "enabled":
return "enabled", nil
case "offline", "disabled":
return "disabled", nil
default:
return "", ErrInvalidListingStatus
}
}
func (r *Repository) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error {
if listingID == 0 {
return errors.New("invalid listing id")
}
displayStatus, err := receivedDisplayStatus(status)
if err != nil {
return err
}
var listing model.RentalListing
if err := r.db.WithContext(ctx).First(&listing, listingID).Error; err != nil {
return err
@@ -1562,13 +1664,25 @@ func (r *Repository) UpdateReceivedListingStatus(ctx context.Context, adminID, l
if listing.DistributionSiteID == nil || *listing.DistributionSiteID == 0 {
return ErrSiteAccessDenied
}
if err := r.ensureSiteAccess(ctx, adminID, *listing.DistributionSiteID); err != nil {
siteID := *listing.DistributionSiteID
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return err
}
if status != "published" && status != "inactive" && status != "closed" {
return errors.New("invalid listing status")
var row model.DistributionSiteAccount
err = r.db.WithContext(ctx).Where("site_id = ? AND listing_id = ?", siteID, listing.ID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.DistributionSiteAccount{
SiteID: siteID,
AccountID: listing.AccountID,
ListingID: listing.ID,
DisplayStatus: displayStatus,
}
return r.db.WithContext(ctx).Create(&row).Error
}
return r.db.WithContext(ctx).Model(&listing).Update("status", status).Error
if err != nil {
return err
}
return r.db.WithContext(ctx).Model(&row).Update("display_status", displayStatus).Error
}
func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) {
@@ -18,7 +18,8 @@ func distributionTestDB(t *testing.T) *gorm.DB {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.AdminUser{}, &model.Role{}, &model.ChatConversation{}, &model.RentalOrder{},
&model.AdminUser{}, &model.Role{}, &model.Permission{}, &model.AdminUserRole{}, &model.RolePermission{},
&model.ChatConversation{}, &model.RentalOrder{},
&model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{},
&model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{},
&model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{},
@@ -177,8 +178,37 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
if _, err := repo.ScopeSiteID(t.Context(), 7, site.ID+1); err != ErrSiteAccessDenied {
t.Fatalf("access error = %v", err)
}
if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 {
t.Fatalf("global scope = %d, err=%v", got, err)
if _, err := repo.ScopeSiteID(t.Context(), 99, 123); err != ErrSiteAccessDenied {
t.Fatalf("unbound admin must not inherit superadmin, err=%v", err)
}
superRole := model.Role{Code: "super_admin", Name: "超级管理员"}
if err := db.Create(&superRole).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AdminUserRole{AdminID: 88, RoleID: superRole.ID}).Error; err != nil {
t.Fatal(err)
}
if got, err := repo.ScopeSiteID(t.Context(), 88, other.ID); err != nil || got != other.ID {
t.Fatalf("superadmin scope = %d, err=%v", got, err)
}
allView := model.Permission{Code: "distribution:all_view", Name: "查看全部分销站点", Resource: "distribution", Action: "all_view"}
if err := db.Create(&allView).Error; err != nil {
t.Fatal(err)
}
opsRole := model.Role{Code: "platform_ops", Name: "平台运营"}
if err := db.Create(&opsRole).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.RolePermission{RoleID: opsRole.ID, PermissionID: allView.ID}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AdminUserRole{AdminID: 77, RoleID: opsRole.ID}).Error; err != nil {
t.Fatal(err)
}
if got, err := repo.ScopeSiteID(t.Context(), 77, other.ID); err != nil || got != other.ID {
t.Fatalf("all_view scope = %d, err=%v", got, err)
}
// 被限制到站点 A 的成员不能通过资源 ID 跨站点访问。
@@ -195,6 +225,16 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
if _, err := repo.UpdateAccountDisplay(t.Context(), 7, display.ID, "disabled"); err != ErrSiteAccessDenied {
t.Fatalf("UpdateAccountDisplay cross-site err = %v", err)
}
svc := NewService(repo)
unboundSites, err := svc.ListSitesForAdmin(t.Context(), 99)
if err != nil || len(unboundSites) != 0 {
t.Fatalf("unbound admin must not list all sites, got %d err=%v", len(unboundSites), err)
}
superSites, err := svc.ListSitesForAdmin(t.Context(), 88)
if err != nil || len(superSites) != 2 {
t.Fatalf("superadmin should list all sites, got %d err=%v", len(superSites), err)
}
}
func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
@@ -396,6 +436,10 @@ func TestOnboardSiteDoesNotDumpCatalog(t *testing.T) {
if len(displays) != 0 {
t.Fatalf("onboard must not dump catalog, got %d displays", len(displays))
}
var treasury model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", onboardResp.Site.ID).First(&treasury).Error; err != nil {
t.Fatalf("onboard must create site treasury wallet, err=%v", err)
}
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)
}
@@ -456,13 +500,17 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
t.Fatal(err)
}
// 1. Update branding
logo := "https://cdn.example.com/logo.png"
favicon := "https://cdn.example.com/favicon.ico"
banners := `[{"image_url":"https://cdn.example.com/b1.jpg"}]`
announcements := `[{"title":"开业大吉"}]`
footer := `{"copyright":"2026 Brand"}`
updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{
LogoURL: "https://cdn.example.com/logo.png",
FaviconURL: "https://cdn.example.com/favicon.ico",
BannersJSON: `[{"image_url":"https://cdn.example.com/b1.jpg"}]`,
AnnouncementsJSON: `[{"title":"开业大吉"}]`,
FooterInfoJSON: `{"copyright":"2026 Brand"}`,
LogoURL: &logo,
FaviconURL: &favicon,
BannersJSON: &banners,
AnnouncementsJSON: &announcements,
FooterInfoJSON: &footer,
})
if err != nil {
t.Fatalf("UpdateSiteBranding failed: %v", err)
@@ -480,6 +528,15 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
t.Fatalf("expected LogoURL, got: %+v", pubInfo)
}
faviconOnly := "https://cdn.example.com/favicon2.ico"
partial, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{FaviconURL: &faviconOnly})
if err != nil {
t.Fatalf("partial branding update failed: %v", err)
}
if partial.LogoURL != "https://cdn.example.com/logo.png" || partial.FaviconURL != faviconOnly {
t.Fatalf("omitted branding fields must stay, got %+v", partial)
}
// 2. Account Markup
dsa := model.DistributionSiteAccount{
SiteID: site.ID,
@@ -500,6 +557,12 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
}
// Batch update markup
if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{
ListingIDs: nil,
MarkupAmountCent: 800,
}); !errors.Is(err, ErrEmptyListingIDs) {
t.Fatalf("empty listing_ids err = %v", err)
}
if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{
ListingIDs: []uint64{201},
MarkupAmountCent: 800,
@@ -577,10 +640,33 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
t.Fatalf("unexpected received listing: %+v", item)
}
// Update received listing status
if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); err != nil {
if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); !errors.Is(err, ErrInvalidListingStatus) {
t.Fatalf("inactive status err = %v", err)
}
if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "offline"); err != nil {
t.Fatalf("UpdateReceivedListingStatus failed: %v", err)
}
var listingAfter model.RentalListing
if err := db.First(&listingAfter, listing.ID).Error; err != nil {
t.Fatal(err)
}
if listingAfter.Status != "published" {
t.Fatalf("shelf offline must not change listing.status, got %s", listingAfter.Status)
}
var dsa model.DistributionSiteAccount
if err := db.Where("site_id = ? AND listing_id = ?", site.ID, listing.ID).First(&dsa).Error; err != nil {
t.Fatalf("expected display row, err=%v", err)
}
if dsa.DisplayStatus != "disabled" {
t.Fatalf("expected display disabled, got %s", dsa.DisplayStatus)
}
offlinePage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{Page: 1, PageSize: 10})
if err != nil {
t.Fatalf("ListReceivedListings after offline failed: %v", err)
}
if len(offlinePage.Items) != 1 || offlinePage.Items[0].Status != "offline" {
t.Fatalf("expected shelf status offline, got %+v", offlinePage.Items)
}
// 2. Settlement batch only credits the site party, never the platform share.
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)}
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/auth"
)
@@ -43,19 +42,23 @@ func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]Site
if err != nil {
return nil, err
}
var members []model.DistributionSiteMember
if adminID == 0 || s.repo.db == nil {
return items, nil
}
if err := s.repo.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
global, err := s.repo.hasGlobalDistributionAccess(ctx, adminID)
if err != nil {
return nil, err
}
if len(members) == 0 {
if global {
return items, nil
}
allowed := make(map[uint64]struct{}, len(members))
for _, member := range members {
allowed[member.SiteID] = struct{}{}
siteIDs, err := s.repo.listActiveMemberSiteIDs(ctx, adminID)
if err != nil {
return nil, err
}
if len(siteIDs) == 0 {
return []SiteDTO{}, nil
}
allowed := make(map[uint64]struct{}, len(siteIDs))
for _, siteID := range siteIDs {
allowed[siteID] = struct{}{}
}
filtered := make([]SiteDTO, 0, len(allowed))
for _, item := range items {
@@ -0,0 +1,24 @@
-- +goose Up
-- 跨站点查看/管理分销数据的显式权限。无站点成员不再视为超管。
INSERT INTO permissions (code, name, resource, action) VALUES
('distribution:all_view', '查看全部分销站点', 'distribution', 'all_view')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
resource = VALUES(resource),
action = VALUES(action);
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.code = 'distribution:all_view'
WHERE r.code = 'super_admin';
-- +goose Down
DELETE rp
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code = 'distribution:all_view';
DELETE FROM permissions WHERE code = 'distribution:all_view';
+1 -1
View File
@@ -514,7 +514,7 @@ export async function fetchSiteWithdrawals(siteID: number, page = 1, pageSize =
}
export interface ApplySiteWithdrawalPayload {
site_id: number
site_id?: number
amount_cent: number
account_type: 'alipay' | 'bank'
account_name: string
@@ -150,6 +150,10 @@ export default function Accounts() {
const listingIDs = items
.filter((it) => selectedRowKeys.includes(it.id))
.map((it) => it.listing_id)
if (listingIDs.length === 0) {
message.warning('请先勾选要加价的商品')
return
}
await batchUpdateAccountMarkup(siteID, listingIDs, markupCent)
message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`)
setSelectedRowKeys([])
+5 -5
View File
@@ -48,7 +48,7 @@ const REVIEW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
const LISTING_STATUS_LABELS: Record<string, { label: string; color: string }> = {
published: { label: '上架在售', color: 'green' },
inactive: { label: '已下架', color: 'default' },
offline: { label: '已下架', color: 'default' },
closed: { label: '已关闭', color: 'error' },
draft: { label: '草稿', color: 'warning' },
}
@@ -134,7 +134,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
}, [kind, activeTab, loadOrders, loadReceivedListings])
async function handleToggleListingStatus(item: ReceivedListing) {
const nextStatus = item.status === 'published' ? 'inactive' : 'published'
const nextStatus = item.status === 'offline' ? 'published' : 'offline'
const actionName = nextStatus === 'published' ? '重新上架' : '下架'
Modal.confirm({
title: `确认${actionName}该账号?`,
@@ -323,11 +323,11 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
<Button
type="link"
size="small"
danger={row.status === 'published'}
icon={row.status === 'published' ? <StopOutlined /> : <CheckCircleOutlined />}
danger={row.status !== 'offline'}
icon={row.status === 'offline' ? <CheckCircleOutlined /> : <StopOutlined />}
onClick={() => void handleToggleListingStatus(row)}
>
{row.status === 'published' ? '下架' : '上架'}
{row.status === 'offline' ? '上架' : '下架'}
</Button>
),
},