Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
AUG26-008。
- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。
验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。
运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
291 lines
12 KiB
Go
291 lines
12 KiB
Go
package shop
|
||
|
||
import (
|
||
"context"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
)
|
||
|
||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||
type UpdateService struct {
|
||
db *gorm.DB
|
||
audit accessauditapp.Writer
|
||
qualificationInvalidator WithdrawalQualificationInvalidator
|
||
}
|
||
|
||
// WithdrawalQualificationInvalidator 在店铺停用事务内联动失效提现资料资格。
|
||
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
|
||
type WithdrawalQualificationInvalidator interface {
|
||
InvalidateByShopDisable(ctx context.Context, tx *gorm.DB, shopID uint, reason string) error
|
||
}
|
||
|
||
// SetWithdrawalQualificationInvalidator 注入店铺停用联动的提现资料资格失效接缝。
|
||
// 未注入时停用不联动,用于不依赖该能力的旧装配路径。
|
||
func (s *UpdateService) SetWithdrawalQualificationInvalidator(invalidator WithdrawalQualificationInvalidator) {
|
||
s.qualificationInvalidator = invalidator
|
||
}
|
||
|
||
// NewUpdateService 创建店铺更新事务脚本。
|
||
func NewUpdateService(db *gorm.DB, audit accessauditapp.Writer) *UpdateService {
|
||
return &UpdateService{db: db, audit: audit}
|
||
}
|
||
|
||
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
|
||
func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
|
||
if shopID == 0 {
|
||
return nil, errors.New(errors.CodeInvalidParam)
|
||
}
|
||
userType := middleware.GetUserTypeFromContext(ctx)
|
||
if userType == constants.UserTypeEnterprise {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
if err := middleware.CanManageShop(ctx, shopID); err != nil {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
|
||
}
|
||
if userType == constants.UserTypeAgent && request.ClientLoginDisabled != nil && middleware.GetShopIDFromContext(ctx) != shopID {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限修改其他店铺的C端登录限制")
|
||
}
|
||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
|
||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||
if operatorID == 0 {
|
||
return nil, errors.New(errors.CodeUnauthorized)
|
||
}
|
||
var response *dto.ShopResponse
|
||
var beforeShop *model.Shop
|
||
var parentShop *model.Shop
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var shop model.Shop
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
|
||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
before := shop
|
||
beforeShop = &before
|
||
parentShop = loadAuditParentShop(tx, shop.ParentID)
|
||
if request.BusinessOwnerAccountIDSet {
|
||
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
shop.BusinessOwnerAccountID = ownerID
|
||
}
|
||
if request.ClientLoginDisabled != nil {
|
||
shop.ClientLoginDisabled = *request.ClientLoginDisabled
|
||
}
|
||
shop.ShopName = request.ShopName
|
||
shop.ContactName = request.ContactName
|
||
shop.ContactPhone = request.ContactPhone
|
||
shop.Province = request.Province
|
||
shop.City = request.City
|
||
shop.District = request.District
|
||
shop.Address = request.Address
|
||
shop.Status = request.Status
|
||
shop.Updater = operatorID
|
||
// 分销码创建后不可修改:Save 写全列,这里显式保留加锁读取到的原值。
|
||
shop.DistributionCode = before.DistributionCode
|
||
if err := tx.Save(&shop).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
|
||
}
|
||
parentName := ""
|
||
if shop.ParentID != nil {
|
||
var parent model.Shop
|
||
if err := tx.Select("shop_name").First(&parent, *shop.ParentID).Error; err == nil {
|
||
parentName = parent.ShopName
|
||
}
|
||
}
|
||
response = newShopResponse(&shop, parentName)
|
||
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
|
||
return err
|
||
}
|
||
if shopProfileChanged(&before, &shop) {
|
||
if s.audit == nil {
|
||
return errors.New(errors.CodeInvalidStatus, "店铺更新审计接缝未配置")
|
||
}
|
||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料",
|
||
OperatorID: operatorID, Shop: &shop, ParentShop: parentShop,
|
||
BeforeData: shopProfileData(&before), AfterData: shopProfileData(&shop),
|
||
}); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺更新审计失败")
|
||
}
|
||
}
|
||
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
|
||
return err
|
||
}
|
||
// 店铺停用必须使该店铺全部有效提现资料资格失效,且与停用同事务提交。
|
||
if before.Status != constants.ShopStatusDisabled && shop.Status == constants.ShopStatusDisabled &&
|
||
s.qualificationInvalidator != nil {
|
||
if err := s.qualificationInvalidator.InvalidateByShopDisable(
|
||
ctx, tx, shop.ID, "代理店铺已停用,提现资料资格自动失效"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
if beforeShop != nil && requestedShopProfileChanged(beforeShop, request) {
|
||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料失败", Result: shopAuditFailureResult(err),
|
||
OperatorID: operatorID, Shop: beforeShop, ParentShop: parentShop,
|
||
}, err)
|
||
}
|
||
s.recordStateFailures(ctx, beforeShop, parentShop, request, operatorID, err)
|
||
return nil, err
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
func (s *UpdateService) writeStateAudits(ctx context.Context, tx *gorm.DB, before, after, parent *model.Shop, operatorID uint) error {
|
||
if s.audit == nil && (before.Status != after.Status || !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) || before.ClientLoginDisabled != after.ClientLoginDisabled) {
|
||
return errors.New(errors.CodeInvalidStatus, "店铺状态审计接缝未配置")
|
||
}
|
||
if before.Status != after.Status {
|
||
action, summary, subject := constants.AuditActionShopDisabled, "禁用店铺", "店铺已禁用"
|
||
if after.Status == constants.StatusEnabled {
|
||
action, summary, subject = constants.AuditActionShopEnabled, "启用店铺", "店铺已启用"
|
||
}
|
||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||
ActionCode: action, Summary: summary, OperatorID: operatorID, Shop: after, ParentShop: parent,
|
||
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": after.Status},
|
||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
|
||
}); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺状态审计失败")
|
||
}
|
||
}
|
||
if !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) {
|
||
accounts := businessOwnerAuditAccounts(tx, before.BusinessOwnerAccountID, after.BusinessOwnerAccountID)
|
||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "更新店铺业务员归属",
|
||
OperatorID: operatorID, Shop: after, ParentShop: parent, Accounts: accounts,
|
||
BeforeData: map[string]any{"business_owner_account_id": before.BusinessOwnerAccountID},
|
||
AfterData: map[string]any{"business_owner_account_id": after.BusinessOwnerAccountID},
|
||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已更新",
|
||
}); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
|
||
}
|
||
}
|
||
if before.ClientLoginDisabled != after.ClientLoginDisabled {
|
||
subject := "店铺 C 端登录限制已解除"
|
||
if after.ClientLoginDisabled {
|
||
subject = "店铺 C 端登录已限制"
|
||
}
|
||
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
||
ActionCode: constants.AuditActionShopClientLoginLimitUpdated, Summary: "更新店铺 C 端登录限制",
|
||
OperatorID: operatorID, Shop: after, ParentShop: parent,
|
||
BeforeData: map[string]any{"client_login_disabled": before.ClientLoginDisabled},
|
||
AfterData: map[string]any{"client_login_disabled": after.ClientLoginDisabled},
|
||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
|
||
}); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "写入店铺登录限制审计失败")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *UpdateService) recordStateFailures(ctx context.Context, shop, parent *model.Shop, request *dto.UpdateShopRequest, operatorID uint, originalErr error) {
|
||
if shop == nil {
|
||
return
|
||
}
|
||
record := func(action, summary string) {
|
||
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
|
||
ActionCode: action, Summary: summary, Result: shopAuditFailureResult(originalErr),
|
||
OperatorID: operatorID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||
}, originalErr)
|
||
}
|
||
if shop.Status != request.Status {
|
||
action := constants.AuditActionShopDisabled
|
||
if request.Status == constants.StatusEnabled {
|
||
action = constants.AuditActionShopEnabled
|
||
}
|
||
record(action, "更新店铺状态失败")
|
||
}
|
||
if request.BusinessOwnerAccountIDSet && !sameOptionalUint(shop.BusinessOwnerAccountID, request.BusinessOwnerAccountID) {
|
||
record(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属失败")
|
||
}
|
||
if request.ClientLoginDisabled != nil && shop.ClientLoginDisabled != *request.ClientLoginDisabled {
|
||
record(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制失败")
|
||
}
|
||
}
|
||
|
||
func businessOwnerAuditAccounts(tx *gorm.DB, beforeID, afterID *uint) []accessauditapp.AccountChange {
|
||
changes := make([]accessauditapp.AccountChange, 0, 2)
|
||
if account := loadAuditAccount(tx, beforeID); account != nil {
|
||
changes = append(changes, accessauditapp.AccountChange{
|
||
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopPreviousBusinessOwner,
|
||
BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false},
|
||
})
|
||
}
|
||
if account := loadAuditAccount(tx, afterID); account != nil {
|
||
changes = append(changes, accessauditapp.AccountChange{
|
||
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopBusinessOwner,
|
||
BeforeData: map[string]any{"assigned": false}, AfterData: map[string]any{"assigned": true},
|
||
})
|
||
}
|
||
return changes
|
||
}
|
||
|
||
func loadAuditAccount(tx *gorm.DB, accountID *uint) *model.Account {
|
||
if accountID == nil {
|
||
return nil
|
||
}
|
||
var account model.Account
|
||
if err := tx.Unscoped().First(&account, *accountID).Error; err != nil {
|
||
return nil
|
||
}
|
||
return &account
|
||
}
|
||
|
||
func sameOptionalUint(left, right *uint) bool {
|
||
if left == nil || right == nil {
|
||
return left == nil && right == nil
|
||
}
|
||
return *left == *right
|
||
}
|
||
|
||
func requestedShopProfileChanged(shop *model.Shop, request *dto.UpdateShopRequest) bool {
|
||
return shop.ShopName != request.ShopName || shop.ContactName != request.ContactName ||
|
||
shop.ContactPhone != request.ContactPhone || shop.Province != request.Province || shop.City != request.City ||
|
||
shop.District != request.District || shop.Address != request.Address
|
||
}
|
||
|
||
func loadAuditParentShop(tx *gorm.DB, parentID *uint) *model.Shop {
|
||
if parentID == nil {
|
||
return nil
|
||
}
|
||
var parent model.Shop
|
||
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
|
||
return nil
|
||
}
|
||
return &parent
|
||
}
|
||
|
||
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
|
||
if requestedID == nil {
|
||
return nil, nil
|
||
}
|
||
if *requestedID == 0 {
|
||
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
|
||
}
|
||
var account model.Account
|
||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
|
||
Where("id = ? AND user_type = ? AND status = ?", *requestedID, constants.UserTypePlatform, constants.StatusEnabled).
|
||
First(&account).Error; err != nil {
|
||
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
|
||
}
|
||
ownerID := account.ID
|
||
return &ownerID, nil
|
||
}
|