Files
junhong_cmp_fiber/internal/application/distributionwithdrawal/registration_approval.go
break 6333f4ad13
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
fix(代理分销注册): 校验手机号/用户名/店铺编号唯一性并支持驳回后重注册
- 提交写事务内先取事务级 advisory lock,再校验既有未删除账号/店铺与其它待审批申请:
  手机号 1014、用户名 1013、店铺编号 1031、待审批占用 1007,冲突不落库且不消费短信验证码
- 已驳回(含通过后撤销)与已通过的终态记录不阻塞重新注册,形成新记录与新审批实例
- 并发同关键字段提交串行裁决,同一关键字段至多一条待审批记录
- 审批通过建店建号前复检关键字段,冲突返回可定位错误并整体回滚,不再以裸数据库错误收场
- 归档 Change fix-agent-distribution-registration-duplicate-guard 并同步主 Spec

验证:junhong_cmp_test + Redis DB 6 受控脚手架 37 项通过 / 0 项失败(含 6 路并发仅 1 条落库、
审批冲突回滚与无冲突建店回归),清理后 fixture 残留 0;gofmt/go build/go vet 全绿;
openspec validate --all 35 项通过、doctor healthy、context-health 通过
2026-09-17 19:02:14 +08:00

288 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SubordinateCacheInvalidator 在审批通过事务提交后清理上级店铺下级集合缓存。
type SubordinateCacheInvalidator interface {
InvalidateSubordinateCache(ctx context.Context, shopID uint)
}
// DistributionApprovalHandler 将渠道无关企业微信终态应用到代理扫码注册记录。
// 通过才在单一事务内创建启用店铺、代理主账号、所需钱包、上级层级与业务员快照;
// 驳回只标记注册记录,不创建任何实体;重复或乱序回调不重复创建账号、层级或钱包。
type DistributionApprovalHandler struct {
db *gorm.DB
audit AuditWriter
cache SubordinateCacheInvalidator
}
// NewDistributionApprovalHandler 创建代理分销注册审批终态消费者。
func NewDistributionApprovalHandler(
db *gorm.DB,
audit AuditWriter,
cache SubordinateCacheInvalidator,
) *DistributionApprovalHandler {
return &DistributionApprovalHandler{db: db, audit: audit, cache: cache}
}
// Handle 幂等消费标准审批终态。
// 业务标识为待审批注册记录主键;先锁定注册记录并校验审批实例一致,再按条件更新推进状态。
func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "代理分销注册审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeAgentDistribution ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理分销注册审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
parentShopID := uint(0)
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
registration, err := lockRegistrationForUpdate(ctx, tx, event.BusinessID)
if err != nil {
return err
}
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "扫码注册记录关联的审批实例不一致")
}
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
// 已是终态:重复或乱序回调不再改变事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
parentShopID = registration.ParentShopID
return h.applyApproved(ctx, tx, registration, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted:
return h.applyRejected(ctx, tx, registration, event)
case constants.ApprovalDecisionRevokedAfterApproved:
// 注册记录无已建立的对外资金事实;通过后撤销按驳回处理并保留渠道决策痕迹。
return h.applyRejected(ctx, tx, registration, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的代理分销注册审批终态")
}
})
if err != nil {
return err
}
if parentShopID != 0 && h.cache != nil {
// 缓存清理必须在事务提交后执行,避免回滚后缓存与库内事实不一致。
h.cache.InvalidateSubordinateCache(ctx, parentShopID)
}
return nil
}
// applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
// 上级店铺必须仍然存在且启用;手机号、用户名或店铺编号已被既有账号/店铺占用时整体回滚,不留半套实体。
// 审批路径不取提交侧的关键字段 advisory lock提交侧的校验与插入同处一个串行化区间
// 且账号/店铺写入与注册记录状态推进同事务提交,因此提交侧只会看到「已提交的账号/店铺」或「仍待审批的冲突记录」,
// 两种情况都会拒绝。
func (h *DistributionApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
) error {
parent, err := loadEnabledParentShop(ctx, tx, registration.ParentShopID)
if err != nil {
return err
}
level := parent.Level + 1
if level > constants.ShopMaxLevel {
return errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
// 提交时的关键字段门禁可能已被此后的并发事实占用(平台手工建店、历史待审批记录):
// 此处复检把裸唯一索引错误换成可定位错误码,仍整体回滚,注册记录保持待审批。
if err := ensureRegistrationKeysAvailable(ctx, tx, registration.Phone, registration.Username, registration.ShopCode); err != nil {
return err
}
role, err := loadEnabledCustomerRole(ctx, tx)
if err != nil {
return err
}
shop := &model.Shop{
ShopName: registration.ShopName, ShopCode: registration.ShopCode,
ParentID: &parent.ID, Level: level,
ContactName: registration.ContactName, Province: registration.Province,
City: registration.City, District: registration.District, Address: registration.Address,
Status: constants.ShopStatusEnabled,
}
shop.BusinessOwnerAccountID = parent.BusinessOwnerAccountID
shop.Creator = registration.ID
shop.Updater = registration.ID
// 新店铺生成自己的分销码:注册记录上的分销码是上级店铺快照,复用会与父店铺同码并命中唯一索引。
if err := shopapp.CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
return err
}
account := &model.Account{
Username: registration.Username, Phone: registration.Phone,
Password: registration.PasswordHash, UserType: constants.UserTypeAgent,
ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
}
account.Creator = registration.ID
account.Updater = registration.ID
if err := tx.WithContext(ctx).Create(account).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建扫码注册代理账号失败")
}
if err := tx.WithContext(ctx).Create(&model.AccountRole{
AccountID: account.ID, RoleID: role.ID, Status: constants.StatusEnabled,
Creator: registration.ID, Updater: registration.ID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "为扫码注册代理账号分配角色失败")
}
if err := tx.WithContext(ctx).Create(&model.ShopRole{
ShopID: shop.ID, RoleID: role.ID, Status: constants.StatusEnabled,
Creator: registration.ID, Updater: registration.ID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "设置扫码注册店铺默认角色失败")
}
if err := tx.WithContext(ctx).Create([]*model.AgentWallet{
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
CreditEnabled: false, CreditLimit: 0,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "初始化扫码注册店铺钱包失败")
}
if err := markRegistrationApproved(ctx, tx, registration); err != nil {
return err
}
// 建店与业务员归属的访问审计不在本用例职责内:该动作面向后台账号入口,
// 由审批消费任务触发的建店无法提供其要求的操作者/数据范围投影,
// 强行写入会以「账号权限或组织审计操作者不完整」失败并中止事务。
// 新建店铺已作为 CreatedShop 资源记录在本用例的分销审计中。
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":approved",
ActionCode: constants.AuditActionAgentDistributionRegistrationApproved,
Summary: "企业微信通过扫码注册,已创建店铺与代理账号",
CorrelationID: event.CorrelationID, Registration: registration,
ParentShop: parent, CreatedShop: shop,
AppliedDistributionCode: registration.DistributionCode,
AfterData: registrationAuditSnapshot(registration),
})
}
// applyRejected 只标记注册记录终态,不创建店铺、账号、钱包或层级。
func (h *DistributionApprovalHandler) applyRejected(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
reason := rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
Updates(map[string]any{
"status": constants.AgentDistributionRegistrationStatusRejected,
"reject_reason": reason, "decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
}
before := registration.Status
registration.Status = constants.AgentDistributionRegistrationStatusRejected
registration.RejectReason = reason
registration.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":rejected",
ActionCode: constants.AuditActionAgentDistributionRegistrationRejected,
Summary: "企业微信未通过扫码注册,未创建任何实体",
CorrelationID: event.CorrelationID, Registration: registration,
BeforeData: map[string]any{"status": before},
AfterData: registrationAuditSnapshot(registration),
})
}
// markRegistrationApproved 以待审批状态条件更新标记注册记录已通过,重复回调不重复推进。
func markRegistrationApproved(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
Updates(map[string]any{
"status": constants.AgentDistributionRegistrationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
}
registration.Status = constants.AgentDistributionRegistrationStatusApproved
registration.DecidedAt = &now
return nil
}
// rejectionReason 把渠道决策映射为可查询的中文驳回原因。
func rejectionReason(decision string) string {
switch decision {
case constants.ApprovalDecisionCancelled:
return "企业微信审批已撤销"
case constants.ApprovalDecisionDeleted:
return "企业微信审批已删除"
case constants.ApprovalDecisionRevokedAfterApproved:
return "企业微信审批通过后撤销"
default:
return "企业微信审批已驳回"
}
}
// loadEnabledParentShop 校验分销码所属店铺仍存在且启用。
func loadEnabledParentShop(ctx context.Context, tx *gorm.DB, shopID uint) (*model.Shop, error) {
var parent model.Shop
if err := tx.WithContext(ctx).First(&parent, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "上级店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺失败")
}
if parent.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺已停用,不允许注册下级")
}
return &parent, nil
}
// loadEnabledCustomerRole 读取启用的客户角色,用于新建代理店铺的默认角色与信用额度。
func loadEnabledCustomerRole(ctx context.Context, tx *gorm.DB) (*model.Role, error) {
var role model.Role
if err := tx.WithContext(ctx).
Where("role_type = ? AND status = ?", constants.RoleTypeCustomer, constants.StatusEnabled).
Order("id ASC").First(&role).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "缺少启用的客户角色,无法创建代理店铺")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询启用客户角色失败")
}
return &role, nil
}