feat(distribution): 分销站点管理支持完整 CRUD、安全删除保护与管理员密码重置

- 后端支持分销站点编辑(域名唯一性校验、收号压价点位及税率更新)
- 后端支持分销站点安全删除校验(阻断存在业务订单、财务流水、结算批次或钱包余额的站点删除;对干净站点连带清理分成规则、展示账号绑定、支付通道配置及专有站长账号)
- 后端支持站长账号展示与超级管理员重置站长密码
- 主管理后台与分销后台前端全面支持筛选搜索、编辑模态窗、状态快捷切换、站长密码重置与安全删除确认
- 补充后端 CRUD、删除保护与密码重置覆盖率测试用例
This commit is contained in:
yml2213
2026-09-17 19:35:52 +08:00
parent 079e18bd3a
commit 1c3a75eef0
10 changed files with 1452 additions and 72 deletions
@@ -21,10 +21,18 @@ type SiteDTO struct {
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
SettlementHoldDays int `json:"settlement_hold_days"`
AdminUserID *uint64 `json:"admin_user_id,omitempty"`
AdminUsername string `json:"admin_username,omitempty"`
AdminNickname string `json:"admin_nickname,omitempty"`
MemberType string `json:"member_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ResetSiteAdminPasswordRequest struct {
NewPassword string `json:"new_password" binding:"required,min=6"`
}
type CreateSiteRequest struct {
SiteCode string `json:"site_code" binding:"required,max=64"`
SiteName string `json:"site_name" binding:"required,max=128"`
@@ -83,6 +83,37 @@ func (h *Handler) UpdateSite(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) DeleteSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
if err := h.service.DeleteSite(c.Request.Context(), adminID, id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ResetSiteAdminPassword(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req ResetSiteAdminPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "密码格式不正确(至少6位)")
return
}
adminID, _ := currentAdminID(c)
if err := h.service.ResetSiteAdminPassword(c.Request.Context(), adminID, id, req.NewPassword); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"message": "分销商密码重置成功"})
}
func (h *Handler) GetSiteSMSConfig(c *gin.Context) {
id, ok := parseID(c)
if !ok {
@@ -281,9 +281,46 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
return nil, err
}
items := make([]SiteDTO, 0, len(rows))
for _, row := range rows {
if len(rows) == 0 {
return items, nil
}
siteIDs := make([]uint64, len(rows))
for i, row := range rows {
siteIDs[i] = row.ID
items = append(items, siteDTO(row))
}
type memberRow struct {
SiteID uint64 `gorm:"column:site_id"`
AdminUserID uint64 `gorm:"column:admin_user_id"`
Username string `gorm:"column:username"`
Nickname string `gorm:"column:nickname"`
MemberType string `gorm:"column:member_type"`
}
var memberRows []memberRow
_ = r.db.WithContext(ctx).Table("distribution_site_members").
Select("distribution_site_members.site_id, distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type").
Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id").
Where("distribution_site_members.site_id IN ?", siteIDs).
Scan(&memberRows).Error
memberMap := make(map[uint64]memberRow, len(memberRows))
for _, m := range memberRows {
if _, exists := memberMap[m.SiteID]; !exists {
memberMap[m.SiteID] = m
}
}
for i := range items {
if m, ok := memberMap[items[i].ID]; ok {
items[i].AdminUserID = &m.AdminUserID
items[i].AdminUsername = m.Username
items[i].AdminNickname = m.Nickname
items[i].MemberType = m.MemberType
}
}
return items, nil
}
@@ -299,6 +336,24 @@ func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO
return nil, err
}
item := siteDTO(row)
var m struct {
AdminUserID uint64 `gorm:"column:admin_user_id"`
Username string `gorm:"column:username"`
Nickname string `gorm:"column:nickname"`
MemberType string `gorm:"column:member_type"`
}
if err := r.db.WithContext(ctx).Table("distribution_site_members").
Select("distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type").
Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id").
Where("distribution_site_members.site_id = ?", row.ID).
First(&m).Error; err == nil {
item.AdminUserID = &m.AdminUserID
item.AdminUsername = m.Username
item.AdminNickname = m.Nickname
item.MemberType = m.MemberType
}
return &item, nil
}
@@ -416,10 +471,23 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
row.SiteName = strings.TrimSpace(*req.SiteName)
}
if req.Domain != nil {
row.Domain = strings.TrimSpace(*req.Domain)
newDomain := strings.TrimSpace(*req.Domain)
if newDomain != "" && newDomain != row.Domain {
var count int64
if err := tx.Model(&model.DistributionSite{}).Where("domain = ? AND id != ?", newDomain, row.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("专属域名已被其他分销站点占用")
}
row.Domain = newDomain
}
}
if req.Status != nil {
row.Status = strings.TrimSpace(*req.Status)
status := strings.TrimSpace(*req.Status)
if status == "active" || status == "disabled" {
row.Status = status
}
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
@@ -473,9 +541,211 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
}
r.evictSiteInfoCache(ctx, &row)
item := siteDTO(row)
var m struct {
AdminUserID uint64 `gorm:"column:admin_user_id"`
Username string `gorm:"column:username"`
Nickname string `gorm:"column:nickname"`
MemberType string `gorm:"column:member_type"`
}
if err := r.db.WithContext(ctx).Table("distribution_site_members").
Select("distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type").
Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id").
Where("distribution_site_members.site_id = ?", row.ID).
First(&m).Error; err == nil {
item.AdminUserID = &m.AdminUserID
item.AdminUsername = m.Username
item.AdminNickname = m.Nickname
item.MemberType = m.MemberType
}
return &item, nil
}
// DeleteSite 删除分销站点。
// 若分销站点已产生业务订单、财务流水或结算记录,则拒绝物理删除,保护数据与审计链路;
// 对于未产生业务的测试站点,将级联清理成员关联、专属分销商账号、规则快照、上架关系与钱包账户。
func (r *Repository) DeleteSite(ctx context.Context, adminID, siteID uint64) error {
if r == nil || r.db == nil {
return ErrDependencyUnavailable
}
global, err := r.hasGlobalDistributionAccess(ctx, adminID)
if err != nil {
return err
}
if !global && adminID != 0 {
return ErrSiteAccessDenied
}
var site model.DistributionSite
if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSiteNotFound
}
return err
}
// 1. 检查是否存在订单
var orderCount int64
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).
Where("distribution_site_id = ? OR markup_site_id = ?", siteID, siteID).
Count(&orderCount).Error; err != nil {
return err
}
if orderCount > 0 {
return fmt.Errorf("分销站点【%s】已产生 %d 笔业务订单,为保障财务数据完整性无法删除;请将站点状态修改为「已停用」", site.SiteName, orderCount)
}
// 2. 检查是否存在财务快照流水
var financialCount int64
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
Where("site_id = ?", siteID).
Count(&financialCount).Error; err != nil {
return err
}
if financialCount > 0 {
return fmt.Errorf("分销站点【%s】已存在分账财务流水,无法直接删除;请将站点状态修改为「已停用」", site.SiteName)
}
// 3. 检查是否存在结算批次
var settlementCount int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSettlementBatch{}).
Where("site_id = ?", siteID).
Count(&settlementCount).Error; err != nil {
return err
}
if settlementCount > 0 {
return fmt.Errorf("分销站点【%s】已存在结算批次记录,无法直接删除", site.SiteName)
}
// 4. 检查钱包账户余额
var walletAccounts []model.WalletAccount
if err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).Find(&walletAccounts).Error; err == nil {
for _, wa := range walletAccounts {
if wa.AvailableBalanceCent > 0 || wa.FrozenBalanceCent > 0 {
return fmt.Errorf("分销站点【%s】钱包尚有未结算/未提现余额,无法删除", site.SiteName)
}
}
}
// 5. 执行事务级联清理
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 查询关联管理员
var members []model.DistributionSiteMember
if err := tx.Where("site_id = ?", siteID).Find(&members).Error; err != nil {
return err
}
// 清理成员关系
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSiteMember{}).Error; err != nil {
return err
}
// 若管理员用户仅绑定此分销站点且非系统超管,清理其账号与角色,便于用户名复用
for _, m := range members {
var otherMemberCount int64
_ = tx.Model(&model.DistributionSiteMember{}).Where("admin_user_id = ?", m.AdminUserID).Count(&otherMemberCount).Error
if otherMemberCount == 0 {
var adminUser model.AdminUser
if err := tx.First(&adminUser, m.AdminUserID).Error; err == nil {
var superRoleCount int64
_ = tx.Table("admin_user_roles").
Joins("JOIN roles ON roles.id = admin_user_roles.role_id").
Where("admin_user_roles.admin_user_id = ? AND roles.code IN ('super_admin', 'admin')", m.AdminUserID).
Count(&superRoleCount).Error
if superRoleCount == 0 {
_ = tx.Table("admin_user_roles").Where("admin_user_id = ?", m.AdminUserID).Delete(nil).Error
_ = tx.Delete(&adminUser).Error
}
}
}
}
// 清理规则版本
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionRuleVersion{}).Error; err != nil {
return err
}
// 清理上架货架
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSiteAccount{}).Error; err != nil {
return err
}
// 清理支付通道绑定
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSitePaymentChannel{}).Error; err != nil {
return err
}
// 清理分销钱包与钱包账户
_ = tx.Table("distribution_site_wallets").Where("site_id = ?", siteID).Delete(nil).Error
_ = tx.Where("distribution_site_id = ?", siteID).Delete(&model.WalletAccount{}).Error
// 删除站点主体
if err := tx.Delete(&site).Error; err != nil {
return err
}
return nil
})
if err != nil {
return err
}
r.evictSiteInfoCache(ctx, &site)
return nil
}
// ResetSiteAdminPassword 重置分销站点绑定的管理员密码。
func (r *Repository) ResetSiteAdminPassword(ctx context.Context, operatorAdminID, siteID uint64, newPassword string) error {
if r == nil || r.db == nil {
return ErrDependencyUnavailable
}
global, err := r.hasGlobalDistributionAccess(ctx, operatorAdminID)
if err != nil {
return err
}
if !global && operatorAdminID != 0 {
return ErrSiteAccessDenied
}
newPassword = strings.TrimSpace(newPassword)
if len(newPassword) < 6 {
return errors.New("新密码长度不能少于6位")
}
var site model.DistributionSite
if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrSiteNotFound
}
return err
}
var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Find(&members).Error; err != nil {
return err
}
if len(members) == 0 {
return errors.New("该分销站点未绑定管理员账号")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
adminUserIDs := make([]uint64, 0, len(members))
for _, m := range members {
adminUserIDs = append(adminUserIDs, m.AdminUserID)
}
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
Where("id IN ?", adminUserIDs).
Updates(map[string]interface{}{
"password_hash": string(hash),
"token_version": gorm.Expr("token_version + 1"),
}).Error
}
func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).
Order("CASE WHEN site_id IS NULL THEN 0 ELSE 1 END ASC, site_id ASC, mode ASC, version DESC, id DESC")
@@ -117,6 +117,18 @@ func (s *Service) UpdateSite(ctx context.Context, adminID, id uint64, req Update
}
return s.repo.UpdateSite(ctx, adminID, id, req)
}
func (s *Service) DeleteSite(ctx context.Context, adminID, siteID uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.DeleteSite(ctx, adminID, siteID)
}
func (s *Service) ResetSiteAdminPassword(ctx context.Context, operatorAdminID, siteID uint64, newPassword string) error {
if err := s.available(); err != nil {
return err
}
return s.repo.ResetSiteAdminPassword(ctx, operatorAdminID, siteID, newPassword)
}
func (s *Service) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
@@ -0,0 +1,158 @@
package distribution
import (
"testing"
"hfb_sys/backend/internal/model"
"golang.org/x/crypto/bcrypt"
)
func TestSiteCRUD_UpdateSiteAndDomainUniqueness(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
ctx := t.Context()
_, err := repo.CreateSite(ctx, CreateSiteRequest{
SiteCode: "site_crud_a",
SiteName: "站点A",
Domain: "a.domain.test",
})
if err != nil {
t.Fatalf("CreateSite A failed: %v", err)
}
siteB, err := repo.CreateSite(ctx, CreateSiteRequest{
SiteCode: "site_crud_b",
SiteName: "站点B",
Domain: "b.domain.test",
})
if err != nil {
t.Fatalf("CreateSite B failed: %v", err)
}
// 1. 尝试将站点B的域名更新为已存在的站点A域名 -> 应该报错
dupDomain := "a.domain.test"
_, err = repo.UpdateSite(ctx, 0, siteB.ID, UpdateSiteRequest{
Domain: &dupDomain,
})
if err == nil {
t.Fatalf("expected error when updating to duplicate domain, got nil")
}
// 2. 正常更新站点B的名称、域名、状态为 disabled、展示主站账号为 false
newName := "站点B更新后"
newDomain := "b-new.domain.test"
newStatus := "disabled"
dispFalse := false
updated, err := repo.UpdateSite(ctx, 0, siteB.ID, UpdateSiteRequest{
SiteName: &newName,
Domain: &newDomain,
Status: &newStatus,
DisplayAccountEnabled: &dispFalse,
})
if err != nil {
t.Fatalf("UpdateSite failed: %v", err)
}
if updated.SiteName != newName || updated.Domain != newDomain || updated.Status != "disabled" || updated.DisplayAccountEnabled != false {
t.Fatalf("updated site values mismatch: %+v", updated)
}
}
func TestSiteCRUD_DeleteSiteProtectionAndCascade(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
ctx := t.Context()
// 1. 使用 OnboardSite 创建一个完整的测试分销站(包含成员与管理员)
onboardResp, err := repo.OnboardSite(ctx, OnboardSiteRequest{
SiteCode: "del_test_site",
SiteName: "待删除测试分销站",
Domain: "del.domain.test",
AdminUsername: "del_test_admin",
AdminPassword: "password123",
})
if err != nil {
t.Fatalf("OnboardSite failed: %v", err)
}
siteID := onboardResp.Site.ID
adminUserID := onboardResp.Member.AdminID
// 2. 测试密码重置
newPwd := "new_secret_pwd_666"
if err := repo.ResetSiteAdminPassword(ctx, 0, siteID, newPwd); err != nil {
t.Fatalf("ResetSiteAdminPassword failed: %v", err)
}
// 校验密码是否成功重置
var adminUser model.AdminUser
if err := db.First(&adminUser, adminUserID).Error; err != nil {
t.Fatalf("failed to find admin user: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(adminUser.PasswordHash), []byte(newPwd)); err != nil {
t.Fatalf("password hash does not match new password: %v", err)
}
// 3. 验证 ListSites 与 FindSite 能够正确携带管理员信息
sites, err := repo.ListSites(ctx)
if err != nil {
t.Fatalf("ListSites failed: %v", err)
}
var foundSite *SiteDTO
for i := range sites {
if sites[i].ID == siteID {
foundSite = &sites[i]
break
}
}
if foundSite == nil {
t.Fatalf("site not found in ListSites")
}
if foundSite.AdminUsername != "del_test_admin" {
t.Fatalf("expected admin_username del_test_admin, got %s", foundSite.AdminUsername)
}
// 4. 测试有订单时的删除保护
// 创建一笔模拟关联订单
dummyOrder := model.RentalOrder{
OrderNo: "ORD_DEL_TEST_001",
RenterID: 999,
AccountID: 888,
DistributionSiteID: &siteID,
}
if err := db.Create(&dummyOrder).Error; err != nil {
t.Fatalf("failed to create dummy order: %v", err)
}
err = repo.DeleteSite(ctx, 0, siteID)
if err == nil {
t.Fatalf("expected error when deleting site with orders, got nil")
}
// 删除此测试订单,继续测试级联删除
if err := db.Delete(&dummyOrder).Error; err != nil {
t.Fatalf("failed to remove dummy order: %v", err)
}
// 5. 测试无订单时的干净级联删除
if err := repo.DeleteSite(ctx, 0, siteID); err != nil {
t.Fatalf("DeleteSite failed: %v", err)
}
// 校验站点主体已删除
var checkSite model.DistributionSite
if err := db.First(&checkSite, siteID).Error; err == nil {
t.Fatalf("site should be deleted from db")
}
// 校验专属管理员用户已同步清理
var checkAdmin model.AdminUser
if err := db.First(&checkAdmin, adminUserID).Error; err == nil {
t.Fatalf("dedicated admin user should be deleted from db")
}
// 校验规则已清理
var ruleCount int64
db.Model(&model.DistributionRuleVersion{}).Where("site_id = ?", siteID).Count(&ruleCount)
if ruleCount > 0 {
t.Fatalf("expected 0 rules for deleted site, got %d", ruleCount)
}
}