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