feat(distribution): 分销站点管理支持完整 CRUD、安全删除保护与管理员密码重置
- 后端支持分销站点编辑(域名唯一性校验、收号压价点位及税率更新) - 后端支持分销站点安全删除校验(阻断存在业务订单、财务流水、结算批次或钱包余额的站点删除;对干净站点连带清理分成规则、展示账号绑定、支付通道配置及专有站长账号) - 后端支持站长账号展示与超级管理员重置站长密码 - 主管理后台与分销后台前端全面支持筛选搜索、编辑模态窗、状态快捷切换、站长密码重置与安全删除确认 - 补充后端 CRUD、删除保护与密码重置覆盖率测试用例
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user