fix(distribution): 收紧分销权限与货架状态,开通时创建站点金库
无站点成员不再视为超管;分站下架只改展示状态;装修、批量加价与提现参数对齐。
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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([])
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user