From 48a296124529d4ac7daa1d639c3e7595327c55de Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 8 Sep 2026 17:45:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(distribution):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=88=86=E9=94=80=E6=9D=83=E9=99=90=E4=B8=8E=E8=B4=A7=E6=9E=B6?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=8C=E5=BC=80=E9=80=9A=E6=97=B6=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E7=AB=99=E7=82=B9=E9=87=91=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无站点成员不再视为超管;分站下架只改展示状态;装修、批量加价与提现参数对齐。 --- .../modules/distribution/auth_onboard_test.go | 29 ++- backend/internal/modules/distribution/dto.go | 14 +- .../internal/modules/distribution/handler.go | 4 + .../modules/distribution/repository.go | 192 ++++++++++++++---- .../modules/distribution/repository_test.go | 108 +++++++++- .../internal/modules/distribution/service.go | 23 ++- ...00077_distribution_all_view_permission.sql | 24 +++ distribution-admin/src/api.ts | 2 +- distribution-admin/src/pages/Accounts.tsx | 4 + distribution-admin/src/pages/Orders.tsx | 10 +- 10 files changed, 336 insertions(+), 74 deletions(-) create mode 100644 backend/migrations/000077_distribution_all_view_permission.sql diff --git a/backend/internal/modules/distribution/auth_onboard_test.go b/backend/internal/modules/distribution/auth_onboard_test.go index 0c5fac7..bf2df8b 100644 --- a/backend/internal/modules/distribution/auth_onboard_test.go +++ b/backend/internal/modules/distribution/auth_onboard_test.go @@ -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", diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index efda0d1..cc20d25 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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"` diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index f7a0236..29aa3fc 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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): diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 66c8dc2..bb23004 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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) { diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 94e106b..7c9cbb7 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -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)} diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 7896f79..eb89822 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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 { diff --git a/backend/migrations/000077_distribution_all_view_permission.sql b/backend/migrations/000077_distribution_all_view_permission.sql new file mode 100644 index 0000000..3b87a28 --- /dev/null +++ b/backend/migrations/000077_distribution_all_view_permission.sql @@ -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'; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index b935405..93e1f3e 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -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 diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index d6486b6..8100ee7 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -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([]) diff --git a/distribution-admin/src/pages/Orders.tsx b/distribution-admin/src/pages/Orders.tsx index 2c5153a..31fb16d 100644 --- a/distribution-admin/src/pages/Orders.tsx +++ b/distribution-admin/src/pages/Orders.tsx @@ -48,7 +48,7 @@ const REVIEW_STATUS_LABELS: Record = { const LISTING_STATUS_LABELS: Record = { 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' }) { ), },