feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
This commit is contained in:
155
internal/application/distributionwithdrawal/audit.go
Normal file
155
internal/application/distributionwithdrawal/audit.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Package distributionwithdrawal 收口代理分销注册、提现资料资格与提现企业微信终审的用例。
|
||||
// 三者都以审批尝试/资料版本/注册记录主键作为通用审批业务标识,终态消费幂等且可重放。
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。
|
||||
// 校验成功即消费验证码,同一验证码不可二次使用。
|
||||
type VerificationCodeVerifier interface {
|
||||
VerifyCode(ctx context.Context, phone string, code string) error
|
||||
}
|
||||
|
||||
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。
|
||||
// 日志与审计不得记录密码、完整证件号、完整手机号或附件内容。
|
||||
type AuditChange struct {
|
||||
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
|
||||
EventID string
|
||||
// ActionCode 是已注册的审计动作码。
|
||||
ActionCode string
|
||||
// Summary 是给人工阅读的中文摘要。
|
||||
Summary string
|
||||
// CorrelationID 是来源业务链路标识。
|
||||
CorrelationID string
|
||||
// Registration 是本次动作后的扫码注册记录事实。
|
||||
Registration *model.AgentDistributionRegistration
|
||||
// ParentShop 是扫码注册使用的上级店铺。
|
||||
ParentShop *model.Shop
|
||||
// Shop 是本次动作所属或引用的店铺。
|
||||
Shop *model.Shop
|
||||
// CreatedShop 是注册审批通过时新建的店铺。
|
||||
// CreatedShop.DistributionCode 是本次为新店铺生成的随机码;
|
||||
// AppliedDistributionCode 是注册时使用的上级店铺码快照,二者必须区分,不得混用。
|
||||
CreatedShop *model.Shop
|
||||
// AppliedDistributionCode 是注册提交时使用的上级店铺分销码快照。
|
||||
AppliedDistributionCode string
|
||||
// Qualification 是本次动作后的提现资料资格版本。
|
||||
Qualification *model.WithdrawalQualification
|
||||
// Withdrawal 是本次动作后的提现申请事实。
|
||||
Withdrawal *model.CommissionWithdrawalRequest
|
||||
// Attempt 是本次动作对应的提现审批尝试记录。
|
||||
Attempt *model.CommissionWithdrawalRequestAttempt
|
||||
// Wallet 是本次动作影响的佣金钱包。
|
||||
Wallet *model.AgentWallet
|
||||
// Transaction 是本次动作产生的钱包流水。
|
||||
Transaction *model.AgentWalletTransaction
|
||||
// BeforeData 与 AfterData 是脱敏前后的字段快照。
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
// Result 是审计结果,空值按成功处理。
|
||||
Result string
|
||||
// ErrorCode 与 ErrorSummary 是失败或拒绝审计的稳定错误信息。
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
}
|
||||
|
||||
// AuditWriter 在业务事务内追加统一 Audit Event。
|
||||
type AuditWriter interface {
|
||||
WriteDistributionWithdrawal(ctx context.Context, tx *gorm.DB, change AuditChange) error
|
||||
}
|
||||
|
||||
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
|
||||
func RecordFailure(ctx context.Context, db *gorm.DB, writer AuditWriter, change AuditChange, businessErr error) {
|
||||
if writer == nil || db == nil || businessErr == nil {
|
||||
return
|
||||
}
|
||||
appErr := changeError(businessErr)
|
||||
change.Result = constants.AuditResultFailed
|
||||
switch appErr.Code {
|
||||
case errors.CodeForbidden, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeConflict,
|
||||
errors.CodeInvalidStatus, errors.CodeInsufficientBalance, errors.CodeShopLevelExceeded:
|
||||
change.Result = constants.AuditResultDenied
|
||||
}
|
||||
if change.ErrorCode == "" {
|
||||
change.ErrorCode = strconv.Itoa(appErr.Code)
|
||||
}
|
||||
if change.ErrorSummary == "" {
|
||||
change.ErrorSummary = appErr.Message
|
||||
}
|
||||
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return writer.WriteDistributionWithdrawal(ctx, tx, change)
|
||||
}); err != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(
|
||||
change.ActionCode, "", "", change.CorrelationID, change.ErrorCode, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// changeError 归一化底层错误为稳定 AppError,避免失败审计泄露底层文本。
|
||||
func changeError(err error) *errors.AppError {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr
|
||||
}
|
||||
return errors.New(errors.CodeInternalError, "分销注册或提现审批操作失败")
|
||||
}
|
||||
|
||||
// approvalSnapshots 生成通用审批的提交人快照与业务表单快照。
|
||||
func approvalSnapshots(accountID uint, accountName string, business map[string]any) ([]byte, []byte, error) {
|
||||
submitter, err := marshalJSON(map[string]any{
|
||||
"account_id": accountID, "account_name": accountName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request, err := marshalJSON(business)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return submitter, request, nil
|
||||
}
|
||||
|
||||
// marshalJSON 使用 sonic 序列化业务快照,禁止写入密码、完整证件号或附件内容。
|
||||
func marshalJSON(value any) ([]byte, error) {
|
||||
payload, err := sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化审批业务快照失败")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// createApprovalInTx 在业务事务内创建通用审批实例并返回引用。
|
||||
func createApprovalInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
port approvalapp.Port,
|
||||
preparation approvalapp.Preparation,
|
||||
businessType string,
|
||||
businessID uint,
|
||||
submitterAccountID uint,
|
||||
submitterSnapshot []byte,
|
||||
requestSnapshot []byte,
|
||||
correlationID string,
|
||||
) (approvalapp.Reference, error) {
|
||||
if port == nil {
|
||||
return approvalapp.Reference{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
|
||||
}
|
||||
return port.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: businessType, BusinessID: businessID,
|
||||
SubmitterAccountID: submitterAccountID, SubmitterSnapshot: submitterSnapshot,
|
||||
RequestSnapshot: requestSnapshot, CorrelationID: correlationID,
|
||||
})
|
||||
}
|
||||
463
internal/application/distributionwithdrawal/qualification.go
Normal file
463
internal/application/distributionwithdrawal/qualification.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// QualificationResult 返回已落库的资料版本与审批实例引用。
|
||||
type QualificationResult struct {
|
||||
QualificationID uint
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// QualificationService 受理提现资料资格的提交、替换、作废与停用失效。
|
||||
// 资格事实按版本不可变保存;替换合同或法人身份证即新增版本并在同一事务内失效旧有效版本。
|
||||
type QualificationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewQualificationService 创建提现资料资格用例。
|
||||
func NewQualificationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *QualificationService {
|
||||
return &QualificationService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Submit 提交或替换本人代理店铺的提现资料资格。
|
||||
// 已有待审批版本时拒绝;已有效版本在合同或法人身份证未变化时拒绝重复提交。
|
||||
func (s *QualificationService) Submit(
|
||||
ctx context.Context,
|
||||
shopID uint,
|
||||
input distributiondomain.QualificationInput,
|
||||
) (*QualificationResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
|
||||
}
|
||||
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
|
||||
// 越权提交资格属关键拒绝,必须留痕:以目标店铺为主要资源记录拒绝事实。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次,
|
||||
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
|
||||
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
|
||||
Summary: "提交提现资料资格被拒绝:越权或非本人店铺",
|
||||
Shop: failureShopResolved(ctx, s.db, shopID, nil),
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
normalized, err := distributiondomain.ValidateQualificationInput(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
submitter, err := resolveShopPrimaryAccount(ctx, s.db, shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
correlationID := "withdrawal_qualification:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shop, err := loadShop(ctx, s.db, shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := &model.WithdrawalQualification{
|
||||
ShopID: shopID, SubjectType: normalized.SubjectType, SubjectCode: normalized.SubjectCode,
|
||||
LegalPersonIDCard: normalized.LegalPersonIDCard, ContractFileKey: normalized.ContractFileKey,
|
||||
IDCardFrontFileKey: normalized.IDCardFrontFileKey, IDCardBackFileKey: normalized.IDCardBackFileKey,
|
||||
BusinessLicenseFileKey: normalized.BusinessLicenseFileKey, ShopFrontFileKey: normalized.ShopFrontFileKey,
|
||||
InvoiceFileKey: normalized.InvoiceFileKey, InvoiceTitle: normalized.InvoiceTitle,
|
||||
InvoiceSubjectCode: normalized.InvoiceSubjectCode,
|
||||
Status: constants.WithdrawalQualificationStatusPending,
|
||||
Creator: operatorID, Updater: operatorID,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
qualificationApprovalForm(version, shop))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &QualificationResult{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
replaced, err := invalidateReplacedVersion(ctx, tx, shopID, normalized, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(version).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现资料资格版本失败")
|
||||
}
|
||||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification, version.ID, submitter.ID,
|
||||
submitterSnapshot, requestSnapshot, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachQualificationInstance(ctx, tx, version, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":submit",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationSubmitted,
|
||||
Summary: qualificationSubmitSummary(replaced),
|
||||
CorrelationID: correlationID, Qualification: version, Shop: shop,
|
||||
AfterData: qualificationAuditSnapshot(version),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result.QualificationID = version.ID
|
||||
result.Status = version.Status
|
||||
result.ApprovalInstanceID = reference.InstanceID
|
||||
result.ApprovalStatus = reference.Status
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 提交在创建资料版本前被拒绝(存在待审批版本或参数非法),此时没有资料版本可作主要资源,
|
||||
// 以店铺为主要资源记录拒绝事实。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次,
|
||||
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
|
||||
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
|
||||
Summary: "提交提现资料资格被拒绝", CorrelationID: correlationID,
|
||||
Shop: shop,
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Void 由超级管理员填写原因后作废有效提现资料资格。
|
||||
// 原因必填;已失效或非有效版本返回稳定冲突错误。
|
||||
func (s *QualificationService) Void(ctx context.Context, id uint, reason string) error {
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
businessErr := errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
|
||||
// 关键拒绝必须留痕:作废原因必填是权限相关拒绝,按超管作废动作记录拒绝事实。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:该拒绝与「作废成功」是同一实体的两次不同发生,
|
||||
// 手工 ID 会让随后的成功作废审计被 event_id 唯一约束吞掉,造成审计与事实相反。
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
|
||||
Summary: "作废提现资料资格被拒绝:未填写原因",
|
||||
Qualification: &model.WithdrawalQualification{ID: id},
|
||||
}, businessErr)
|
||||
return businessErr
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
var version model.WithdrawalQualification
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&version, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现资料资格不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
|
||||
}
|
||||
if version.Status != constants.WithdrawalQualificationStatusApproved {
|
||||
return errors.New(errors.CodeConflict, "仅有效提现资料资格可作废")
|
||||
}
|
||||
before := qualificationAuditSnapshot(&version)
|
||||
if err := invalidateVersion(ctx, tx, &version, reason, operatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
shop, err := loadShop(ctx, tx, version.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":void",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
|
||||
Summary: "超级管理员作废提现资料资格", Qualification: &version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(&version),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
// 失败审计必须可追溯且恰好有一个主要资源:带上目标资料版本(至少含 ID)。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
|
||||
Summary: "作废提现资料资格失败",
|
||||
Qualification: &model.WithdrawalQualification{ID: id},
|
||||
}, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateByShopDisable 在店铺停用事务内使该店铺全部有效资格失效。
|
||||
// 历史版本与审批结果保留;由调用方保证与店铺停用处于同一事务。
|
||||
func (s *QualificationService) InvalidateByShopDisable(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
reason string,
|
||||
) error {
|
||||
if tx == nil || shopID == 0 {
|
||||
return nil
|
||||
}
|
||||
var versions []model.WithdrawalQualification
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
Order("id ASC").Find(&versions).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待失效提现资料资格失败")
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated,
|
||||
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
shop, err := loadShop(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
first := versions[0]
|
||||
first.Status = constants.WithdrawalQualificationStatusInvalidated
|
||||
first.InvalidReason = reason
|
||||
first.InvalidatedAt = &now
|
||||
summary := "代理店铺停用,全部有效提现资料资格失效"
|
||||
if strings.Contains(reason, "删除") {
|
||||
summary = "代理店铺已删除,全部有效提现资料资格失效"
|
||||
}
|
||||
// 不手工构造 EventID:同一店铺可先停用失效、后删除失效,属同一实体的两次不同发生,
|
||||
// 手工 ID 会让第二次失效审计被吞掉。
|
||||
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
|
||||
Summary: summary, Qualification: &first, Shop: shop,
|
||||
AfterData: map[string]any{
|
||||
"shop_id": shopID, "invalidated_count": result.RowsAffected,
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateReplacedVersion 在替换合同或法人身份证时失效旧有效版本。
|
||||
// 返回被失效的版本;没有需失效的版本时返回 nil。
|
||||
func invalidateReplacedVersion(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
input distributiondomain.QualificationInput,
|
||||
operatorID uint,
|
||||
) (*model.WithdrawalQualification, error) {
|
||||
var pending int64
|
||||
if err := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusPending).
|
||||
Count(&pending).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审批提现资料资格失败")
|
||||
}
|
||||
if pending > 0 {
|
||||
return nil, errors.New(errors.CodeConflict, "已存在待审批的提现资料资格,请等待审批结果")
|
||||
}
|
||||
var current model.WithdrawalQualification
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
First(¤t).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败")
|
||||
}
|
||||
if !qualificationRequiresApproval(¤t, input) {
|
||||
return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化")
|
||||
}
|
||||
if err := invalidateVersion(ctx, tx, ¤t, "代理替换合同或法人身份证资料", operatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¤t, nil
|
||||
}
|
||||
|
||||
// qualificationRequiresApproval 判断本次提交是否改变了合同或法人身份证事实。
|
||||
func qualificationRequiresApproval(
|
||||
current *model.WithdrawalQualification,
|
||||
input distributiondomain.QualificationInput,
|
||||
) bool {
|
||||
return current.ContractFileKey != input.ContractFileKey ||
|
||||
current.IDCardFrontFileKey != input.IDCardFrontFileKey ||
|
||||
current.IDCardBackFileKey != input.IDCardBackFileKey ||
|
||||
current.SubjectCode != input.SubjectCode ||
|
||||
current.LegalPersonIDCard != input.LegalPersonIDCard
|
||||
}
|
||||
|
||||
// invalidateVersion 条件更新单个资料版本为已失效。
|
||||
func invalidateVersion(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
reason string,
|
||||
operatorID uint,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, version.Status).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
|
||||
"invalidated_at": now, "invalidated_by": operatorID, "updater": operatorID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格版本失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusInvalidated
|
||||
version.InvalidReason = reason
|
||||
version.InvalidatedAt = &now
|
||||
version.InvalidatedBy = operatorID
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachQualificationInstance 回写资料版本关联的审批实例,写入一次后不可修改。
|
||||
func attachQualificationInstance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", version.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现资料资格审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格审批实例关联已变化")
|
||||
}
|
||||
version.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// qualificationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 证件号按脱敏值写入,附件只写入对象存储 Key 引用,不写入附件内容。
|
||||
func qualificationApprovalForm(version *model.WithdrawalQualification, shop *model.Shop) map[string]any {
|
||||
shopName := ""
|
||||
if shop != nil {
|
||||
shopName = shop.ShopName
|
||||
}
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldQualificationShopID: version.ShopID,
|
||||
constants.ApprovalFieldQualificationShopName: shopName,
|
||||
constants.ApprovalFieldQualificationSubjectType: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
|
||||
constants.ApprovalFieldQualificationSubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
|
||||
constants.ApprovalFieldQualificationLegalPersonMasked: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
|
||||
constants.ApprovalFieldQualificationContractKey: version.ContractFileKey,
|
||||
constants.ApprovalFieldQualificationIDCardFrontKey: version.IDCardFrontFileKey,
|
||||
constants.ApprovalFieldQualificationIDCardBackKey: version.IDCardBackFileKey,
|
||||
constants.ApprovalFieldQualificationBusinessLicenseKey: version.BusinessLicenseFileKey,
|
||||
constants.ApprovalFieldQualificationShopFrontKey: version.ShopFrontFileKey,
|
||||
constants.ApprovalFieldQualificationInvoiceKey: version.InvoiceFileKey,
|
||||
constants.ApprovalFieldQualificationInvoiceTitle: version.InvoiceTitle,
|
||||
}
|
||||
}
|
||||
|
||||
// qualificationAuditSnapshot 生成资料版本审计快照,证件号按脱敏值记录,不含附件内容。
|
||||
func qualificationAuditSnapshot(version *model.WithdrawalQualification) map[string]any {
|
||||
instanceID := uint(0)
|
||||
if version.ApprovalInstanceID != nil {
|
||||
instanceID = *version.ApprovalInstanceID
|
||||
}
|
||||
return map[string]any{
|
||||
"id": version.ID, "shop_id": version.ShopID, "subject_type": version.SubjectType,
|
||||
"subject_code_masked": distributiondomain.MaskSubjectCode(version.SubjectCode),
|
||||
"status": version.Status, "approval_instance_id": instanceID,
|
||||
"invalid_reason": version.InvalidReason,
|
||||
"attachment_count": 3 + boolToInt(version.BusinessLicenseFileKey != "") +
|
||||
boolToInt(version.ShopFrontFileKey != "") + boolToInt(version.InvoiceFileKey != ""),
|
||||
}
|
||||
}
|
||||
|
||||
// qualificationSubmitSummary 区分首次提交与替换提交的审计摘要。
|
||||
func qualificationSubmitSummary(replaced *model.WithdrawalQualification) string {
|
||||
if replaced != nil {
|
||||
return "替换合同或法人身份证资料,旧有效提现资料资格已失效"
|
||||
}
|
||||
return "提交提现资料资格"
|
||||
}
|
||||
|
||||
// ensureOwnAgentShop 校验当前账号为代理身份且目标即本人店铺。
|
||||
func ensureOwnAgentShop(ctx context.Context, shopID uint) error {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return errors.New(errors.CodeForbidden, "仅代理商用户可提交提现资料资格")
|
||||
}
|
||||
if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) {
|
||||
return errors.New(errors.CodeForbidden, "仅可为本人店铺提交提现资料资格")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveShopPrimaryAccount 解析店铺启用的主账号,作为审批发起主体。
|
||||
func resolveShopPrimaryAccount(ctx context.Context, db *gorm.DB, shopID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
if err := db.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ? AND is_primary = TRUE", shopID, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺缺少启用的主账号,无法提交审批")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主账号失败")
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// loadShopOrNil 读取店铺事实;店铺已软删除或不存在时返回 nil,供终态收敛使用。
|
||||
func loadShopOrNil(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
|
||||
shop, err := loadShop(ctx, db, shopID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return shop
|
||||
}
|
||||
|
||||
// loadShop 读取店铺事实,未找到返回稳定不存在错误。
|
||||
func loadShop(ctx context.Context, db *gorm.DB, shopID uint) (*model.Shop, error) {
|
||||
var shop model.Shop
|
||||
if err := db.WithContext(ctx).First(&shop, shopID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
|
||||
}
|
||||
return &shop, nil
|
||||
}
|
||||
|
||||
// boolToInt 将布尔值转换为 0/1,用于审计计数。
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"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"
|
||||
)
|
||||
|
||||
// QualificationApprovalHandler 将渠道无关企业微信终态应用到提现资料资格版本。
|
||||
// 通过才使版本生效;驳回只标记该版本,不影响其他版本已记录的审批结果。
|
||||
type QualificationApprovalHandler struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewQualificationApprovalHandler 创建提现资料资格审批终态消费者。
|
||||
func NewQualificationApprovalHandler(db *gorm.DB, audit AuditWriter) *QualificationApprovalHandler {
|
||||
return &QualificationApprovalHandler{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Handle 幂等消费标准审批终态。
|
||||
// 业务标识为资料版本主键;先锁定版本并校验审批实例一致,再以条件更新推进状态。
|
||||
func (h *QualificationApprovalHandler) 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.ApprovalBusinessTypeWithdrawalQualification ||
|
||||
event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "提现资料资格审批终态参数无效")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
|
||||
})
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var version model.WithdrawalQualification
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&version, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现资料资格版本不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
|
||||
}
|
||||
if version.ApprovalInstanceID == nil || *version.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "提现资料资格版本关联的审批实例不一致")
|
||||
}
|
||||
if version.Status != constants.WithdrawalQualificationStatusPending {
|
||||
// 已是终态(含被替换或作废):重复或乱序回调不再改变事实。
|
||||
return nil
|
||||
}
|
||||
// 店铺可能已被软删除:终态必须仍能收敛,不得把「店铺不存在」当成致命错误,
|
||||
// 否则该版本永久卡在待审批且终态事件永久重投。审计的店铺资源此时允许为空。
|
||||
shop := loadShopOrNil(ctx, tx, version.ShopID)
|
||||
before := qualificationAuditSnapshot(&version)
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return h.applyApproved(ctx, tx, &version, shop, before, event)
|
||||
case constants.ApprovalDecisionRejected,
|
||||
constants.ApprovalDecisionCancelled,
|
||||
constants.ApprovalDecisionDeleted,
|
||||
constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return h.applyRejected(ctx, tx, &version, shop, before, event)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyApproved 使资料版本生效。
|
||||
// 代理已提交替换版本时该版本已被失效,条件更新不再命中,不会覆盖更新版本。
|
||||
func (h *QualificationApprovalHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
shop *model.Shop,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
if qualificationShopDisabled(ctx, tx, version.ShopID) {
|
||||
// 店铺停用或不存在时资格必须失效:若停留在待审批,则「有待审批版本」门禁会让该店铺
|
||||
// 永远无法获得有效资格(作废仅接受有效版本),因此就地收敛为已失效终态并写审计。
|
||||
return h.invalidateForDisabledShop(ctx, tx, version, before, event, now)
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusApproved,
|
||||
"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, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusApproved
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":approved",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationApproved,
|
||||
Summary: "企业微信通过提现资料资格,版本已生效",
|
||||
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateForDisabledShop 在店铺停用或不存在时把待审批资料版本收敛为已失效。
|
||||
// 与代理停用联动失效语义一致(invalidated_by=0 表示系统联动),使该店铺可重新提交资格。
|
||||
func (h *QualificationApprovalHandler) invalidateForDisabledShop(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
now time.Time,
|
||||
) error {
|
||||
reason := "代理店铺已停用,资格自动失效"
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusInvalidated,
|
||||
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0,
|
||||
"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, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusInvalidated
|
||||
version.InvalidReason = reason
|
||||
version.InvalidatedAt = &now
|
||||
version.InvalidatedBy = 0
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":disabled",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
|
||||
Summary: "企业微信通过时店铺已停用,提现资料资格直接失效",
|
||||
CorrelationID: event.CorrelationID, Qualification: version,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
|
||||
// qualificationShopDisabled 判断资料版本所属店铺是否已停用或不存在。
|
||||
func qualificationShopDisabled(ctx context.Context, tx *gorm.DB, shopID uint) bool {
|
||||
var enabled int64
|
||||
if err := tx.WithContext(ctx).Model(&model.Shop{}).
|
||||
Where("id = ? AND status = ?", shopID, constants.ShopStatusEnabled).
|
||||
Count(&enabled).Error; err != nil {
|
||||
return true
|
||||
}
|
||||
return enabled == 0
|
||||
}
|
||||
|
||||
// applyRejected 标记资料版本已驳回,不影响其他版本已记录的审批结果。
|
||||
func (h *QualificationApprovalHandler) applyRejected(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
version *model.WithdrawalQualification,
|
||||
shop *model.Shop,
|
||||
before map[string]any,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
|
||||
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalQualificationStatusRejected,
|
||||
"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, "提现资料资格版本状态已变化")
|
||||
}
|
||||
version.Status = constants.WithdrawalQualificationStatusRejected
|
||||
version.DecidedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected",
|
||||
ActionCode: constants.AuditActionWithdrawalQualificationRejected,
|
||||
Summary: "企业微信未通过提现资料资格",
|
||||
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
|
||||
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
|
||||
})
|
||||
}
|
||||
240
internal/application/distributionwithdrawal/registration.go
Normal file
240
internal/application/distributionwithdrawal/registration.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
|
||||
type RegistrationResult struct {
|
||||
RegistrationID uint
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// RegistrationService 受理公开扫码注册。
|
||||
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
|
||||
type RegistrationService struct {
|
||||
db *gorm.DB
|
||||
verifier VerificationCodeVerifier
|
||||
approval approvalapp.Port
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewRegistrationService 创建公开扫码注册用例。
|
||||
func NewRegistrationService(
|
||||
db *gorm.DB,
|
||||
verifier VerificationCodeVerifier,
|
||||
approval approvalapp.Port,
|
||||
audit AuditWriter,
|
||||
) *RegistrationService {
|
||||
return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Register 创建待审批注册记录。
|
||||
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。
|
||||
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
|
||||
func (s *RegistrationService) Register(
|
||||
ctx context.Context,
|
||||
input distributiondomain.RegistrationInput,
|
||||
code string,
|
||||
) (*RegistrationResult, error) {
|
||||
if s == nil || s.db == nil || s.verifier == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
|
||||
}
|
||||
normalized, err := distributiondomain.ValidateRegistrationInput(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||
}
|
||||
var parent *model.Shop
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled).
|
||||
First(&parent).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
|
||||
}
|
||||
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。
|
||||
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
||||
}
|
||||
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
correlationID := "agent_distribution:registration:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeAgentDistribution,
|
||||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registration := &model.AgentDistributionRegistration{
|
||||
DistributionCode: normalized.DistributionCode, ParentShopID: parent.ID,
|
||||
Phone: normalized.Phone, PasswordHash: string(passwordHash),
|
||||
ShopName: normalized.ShopName, ShopCode: normalized.ShopCode, Username: normalized.Username,
|
||||
ContactName: normalized.ContactName, Province: normalized.Province,
|
||||
City: normalized.City, District: normalized.District, Address: normalized.Address,
|
||||
Status: constants.AgentDistributionRegistrationStatusPending,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
registrationApprovalForm(normalized, parent))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &RegistrationResult{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Create(registration).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败")
|
||||
}
|
||||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||||
constants.ApprovalBusinessTypeAgentDistribution, registration.ID, submitter.ID,
|
||||
submitterSnapshot, requestSnapshot, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachRegistrationInstance(ctx, tx, registration, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 公开注册提交不写审计:该链路在 personal.go 的 Use() 之前注册,不经任何认证中间件,
|
||||
// 因而没有可信的 actor/source(Append 会以「审计操作者或入口不符合动作注册规则」失败)。
|
||||
// tasks 1.7 只要求分销码生成、注册通过/驳回与资格相关审计,提交动作不在其列,
|
||||
// 故移除该非必需审计而不是伪造操作者身份。
|
||||
result.RegistrationID = registration.ID
|
||||
result.Status = registration.Status
|
||||
result.ApprovalInstanceID = reference.InstanceID
|
||||
result.ApprovalStatus = reference.Status
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
|
||||
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
|
||||
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
|
||||
func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
if err := db.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// registrationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
|
||||
constants.ApprovalFieldDistributionParentShopID: parent.ID,
|
||||
constants.ApprovalFieldDistributionParentShopName: parent.ShopName,
|
||||
constants.ApprovalFieldDistributionShopName: input.ShopName,
|
||||
constants.ApprovalFieldDistributionShopCode: input.ShopCode,
|
||||
constants.ApprovalFieldDistributionUsername: input.Username,
|
||||
constants.ApprovalFieldDistributionPhoneMasked: distributiondomain.MaskPhone(input.Phone),
|
||||
constants.ApprovalFieldDistributionContactName: input.ContactName,
|
||||
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
|
||||
input.Province + input.City + input.District + input.Address),
|
||||
}
|
||||
}
|
||||
|
||||
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
|
||||
func attachRegistrationInstance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
registration *model.AgentDistributionRegistration,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", registration.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联扫码注册审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "扫码注册审批实例关联已变化")
|
||||
}
|
||||
registration.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// registrationAuditSnapshot 生成注册记录审计快照,手机号按脱敏值记录,不含密码哈希。
|
||||
func registrationAuditSnapshot(registration *model.AgentDistributionRegistration) map[string]any {
|
||||
instanceID := uint(0)
|
||||
if registration.ApprovalInstanceID != nil {
|
||||
instanceID = *registration.ApprovalInstanceID
|
||||
}
|
||||
return map[string]any{
|
||||
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
|
||||
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
|
||||
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
|
||||
"username": registration.Username, "shop_code": registration.ShopCode,
|
||||
"status": registration.Status, "approval_instance_id": instanceID,
|
||||
}
|
||||
}
|
||||
|
||||
// uintText 将无符号整数转换为审计标识与键的十进制文本。
|
||||
func uintText(value uint) string {
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
|
||||
// intText 将整数转换为审计标识与键的十进制文本。
|
||||
func intText(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
||||
// composeAuditEventID 拼接审计事件标识,并约束在审计列宽内。
|
||||
func composeAuditEventID(parts ...string) (string, error) {
|
||||
eventID := strings.Join(parts, ":")
|
||||
if len(eventID) > 128 {
|
||||
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
|
||||
}
|
||||
return eventID, nil
|
||||
}
|
||||
|
||||
// lockRegistrationForUpdate 以行锁读取注册记录,未找到返回稳定不存在错误。
|
||||
func lockRegistrationForUpdate(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
id uint,
|
||||
) (*model.AgentDistributionRegistration, error) {
|
||||
var registration model.AgentDistributionRegistration
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(®istration, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
|
||||
}
|
||||
return ®istration, nil
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
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 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
|
||||
// 上级店铺必须仍然存在且启用;手机号或用户名已被并发注册占用时整体回滚,不留半套实体。
|
||||
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 级")
|
||||
}
|
||||
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
|
||||
}
|
||||
830
internal/application/distributionwithdrawal/withdrawal.go
Normal file
830
internal/application/distributionwithdrawal/withdrawal.go
Normal file
@@ -0,0 +1,830 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// WithdrawalPolicy 是提现申请使用的当前配置快照,由调用方从提现配置读取。
|
||||
type WithdrawalPolicy struct {
|
||||
MinAmount int64
|
||||
FeeRate int64
|
||||
DailyWithdrawalLimit int
|
||||
}
|
||||
|
||||
// WithdrawalInput 是提现申请或重提的规范化输入。
|
||||
type WithdrawalInput struct {
|
||||
Amount int64
|
||||
WithdrawalMethod string
|
||||
AccountName string
|
||||
AccountNumber string
|
||||
InvoiceKeys []string
|
||||
}
|
||||
|
||||
// WithdrawalResult 返回已原子保存的提现申请与审批尝试记录。
|
||||
type WithdrawalResult struct {
|
||||
RequestID uint
|
||||
WithdrawalNo string
|
||||
AttemptID uint
|
||||
AttemptNo int
|
||||
Amount int64
|
||||
Fee int64
|
||||
FeeRate int64
|
||||
ActualAmount int64
|
||||
Status int
|
||||
ApprovalInstanceID uint
|
||||
ApprovalStatus int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// WithdrawalService 创建与重提提现申请。
|
||||
// 申请、审批尝试记录、审批实例与佣金钱包冻结在同一事务完成;
|
||||
// 余额不足、资格无效或非本人代理时不创建申请、审批实例或任何冻结。
|
||||
type WithdrawalService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewWithdrawalService 创建提现申请用例。
|
||||
func NewWithdrawalService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *WithdrawalService {
|
||||
return &WithdrawalService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Create 为本人代理店铺创建提现申请。
|
||||
func (s *WithdrawalService) Create(
|
||||
ctx context.Context,
|
||||
shopID uint,
|
||||
policy WithdrawalPolicy,
|
||||
input WithdrawalInput,
|
||||
) (*WithdrawalResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
|
||||
}
|
||||
return s.submit(ctx, shopID, policy, nil, input)
|
||||
}
|
||||
|
||||
// Resubmit 由本人代理修改金额、收款信息与本次发票后重提已被企业微信驳回的提现申请。
|
||||
// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史快照与审批结果不被覆盖。
|
||||
func (s *WithdrawalService) Resubmit(
|
||||
ctx context.Context,
|
||||
requestID uint,
|
||||
policy WithdrawalPolicy,
|
||||
input WithdrawalInput,
|
||||
) (*WithdrawalResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requestID == 0 {
|
||||
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
||||
}
|
||||
return s.submit(ctx, 0, policy, &requestID, input)
|
||||
}
|
||||
|
||||
// ensureReady 校验依赖完整,缺失时失败关闭,避免绕过企业微信终审。
|
||||
func (s *WithdrawalService) ensureReady() error {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "提现申请能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// submit 在同一事务内完成资格校验、钱包加锁冻结、写申请与审批尝试记录、创建审批实例。
|
||||
func (s *WithdrawalService) submit(
|
||||
ctx context.Context,
|
||||
shopID uint,
|
||||
policy WithdrawalPolicy,
|
||||
resubmitRequestID *uint,
|
||||
input WithdrawalInput,
|
||||
) (*WithdrawalResult, error) {
|
||||
submitter, err := currentSubmitter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "提现金额必须大于 0")
|
||||
}
|
||||
if policy.MinAmount > 0 && input.Amount < policy.MinAmount {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "提现金额低于当前最低提现额度")
|
||||
}
|
||||
correlationID := "commission_withdrawal:" + uuid.NewString()
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeCommissionWithdrawal,
|
||||
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &WithdrawalResult{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var request *model.CommissionWithdrawalRequest
|
||||
if resubmitRequestID != nil {
|
||||
request, err = lockWithdrawalRequest(ctx, tx, *resubmitRequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if request.ShopID != submitter.ShopID {
|
||||
return errors.New(errors.CodeNotFound, "提现申请不存在")
|
||||
}
|
||||
if request.Status != constants.WithdrawalStatusRejected {
|
||||
return errors.New(errors.CodeConflict, "仅已被驳回的提现申请可重提")
|
||||
}
|
||||
if request.ApprovalInstanceID == nil {
|
||||
return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提")
|
||||
}
|
||||
// 重提先释放旧未结算尝试的冻结,避免产生第二笔冻结。
|
||||
if _, err := releaseUnsettledAttemptsForRequest(ctx, tx, request.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
shopID = request.ShopID
|
||||
}
|
||||
if err := ensureOwnAgentShopForShopID(ctx, submitter, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
qualification, err := loadValidQualification(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
|
||||
return err
|
||||
}
|
||||
wallet, err := lockCommissionWallet(ctx, tx, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fee := input.Amount * policy.FeeRate / 10000
|
||||
actualAmount := input.Amount - fee
|
||||
if err := freezeCommissionBalance(ctx, tx, wallet, input.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
accountInfo, err := marshalJSON(map[string]string{
|
||||
"account_name": input.AccountName, "account_number": input.AccountNumber,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invoiceKeys, err := marshalJSON(normalizeInvoiceKeys(input.InvoiceKeys))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if request == nil {
|
||||
request = &model.CommissionWithdrawalRequest{
|
||||
WithdrawalNo: generateWithdrawalNo(),
|
||||
ShopID: shopID,
|
||||
AgentID: submitter.ID,
|
||||
ApplicantID: submitter.ID,
|
||||
Amount: input.Amount,
|
||||
FeeRate: policy.FeeRate,
|
||||
Fee: fee,
|
||||
ActualAmount: actualAmount,
|
||||
WithdrawalMethod: input.WithdrawalMethod,
|
||||
AccountInfo: accountInfo,
|
||||
Status: constants.WithdrawalStatusPending,
|
||||
}
|
||||
request.Creator = submitter.ID
|
||||
request.Updater = submitter.ID
|
||||
if err := tx.WithContext(ctx).Create(request).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现申请失败")
|
||||
}
|
||||
} else {
|
||||
if err := updateWithdrawalRequestForResubmit(ctx, tx, request, input, policy, fee, actualAmount, accountInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
attemptNo, err := nextWithdrawalAttemptNo(ctx, tx, request.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt := &model.CommissionWithdrawalRequestAttempt{
|
||||
RequestID: request.ID, AttemptNo: attemptNo,
|
||||
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
|
||||
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
|
||||
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
withdrawalApprovalForm(request, attempt, shopName(ctx, tx, shopID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal, attempt.ID, submitter.ID,
|
||||
submitterSnapshot, requestSnapshot, correlationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachWithdrawalAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateWithdrawalLatest(ctx, tx, request, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
transaction, err := recordWithdrawalFreezeTransaction(ctx, tx, wallet, request, submitter.ID, input.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventID, err := composeAuditEventID(
|
||||
"commission-withdrawal", uintText(request.ID), "attempt", intText(attempt.AttemptNo), "submit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: eventID, ActionCode: constants.AuditActionCommissionWithdrawalAttemptSubmitted,
|
||||
Summary: withdrawalSubmitSummary(resubmitRequestID != nil), CorrelationID: correlationID,
|
||||
Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction,
|
||||
AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result.RequestID = request.ID
|
||||
result.WithdrawalNo = request.WithdrawalNo
|
||||
result.AttemptID = attempt.ID
|
||||
result.AttemptNo = attempt.AttemptNo
|
||||
result.Amount = attempt.Amount
|
||||
result.Fee = attempt.Fee
|
||||
result.FeeRate = attempt.FeeRate
|
||||
result.ActualAmount = attempt.ActualAmount
|
||||
result.Status = request.Status
|
||||
result.ApprovalInstanceID = reference.InstanceID
|
||||
result.ApprovalStatus = reference.Status
|
||||
result.CreatedAt = request.CreatedAt
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 失败审计必须可追溯:带上店铺(shopID 在手上),使审计恰好有一个主要资源。
|
||||
RecordFailure(ctx, s.db, s.audit, AuditChange{
|
||||
// 不手工构造 EventID:同一店铺的提现可被拒绝多次,手工 ID 会让后续拒绝被
|
||||
// event_id 唯一约束吞掉;由审计 Writer 生成唯一 evt_<uuid>。
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptRejected,
|
||||
Summary: "提交提现申请被拒绝", CorrelationID: correlationID,
|
||||
Shop: failureShopResolved(ctx, s.db, shopID, resubmitRequestID),
|
||||
}, err)
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// failureShopResolved 为失败审计解析店铺引用:优先用入参 shopID,
|
||||
// Resubmit 场景下 shopID 为空则从提现申请行回查店铺,保证拒绝事实有可追溯的店铺主资源。
|
||||
func failureShopResolved(ctx context.Context, db *gorm.DB, shopID uint, requestID *uint) *model.Shop {
|
||||
if shopID == 0 && requestID != nil {
|
||||
var request model.CommissionWithdrawalRequest
|
||||
if err := db.WithContext(ctx).Select("id", "shop_id").First(&request, *requestID).Error; err == nil {
|
||||
shopID = request.ShopID
|
||||
}
|
||||
}
|
||||
return failureShop(ctx, db, shopID)
|
||||
}
|
||||
|
||||
// failureShop 为失败审计解析店铺引用;店铺查询失败时退回仅含 ID 的最小引用,
|
||||
// 保证拒绝事实仍有可追溯的店铺主资源。
|
||||
func failureShop(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
|
||||
if shopID == 0 {
|
||||
return nil
|
||||
}
|
||||
if shop := loadShopOrNil(ctx, db, shopID); shop != nil {
|
||||
return shop
|
||||
}
|
||||
// gorm.Model 的 ID 是提升字段,无法在复合字面量中设置,这里显式赋值。
|
||||
minimal := &model.Shop{}
|
||||
minimal.ID = shopID
|
||||
return minimal
|
||||
}
|
||||
|
||||
// withdrawalSubmitter 是发起提现的真实操作者。
|
||||
type withdrawalSubmitter struct {
|
||||
ID uint
|
||||
Username string
|
||||
ShopID uint
|
||||
}
|
||||
|
||||
// currentSubmitter 从上下文取当前代理账号与其店铺,未认证时拒绝。
|
||||
func currentSubmitter(ctx context.Context) (withdrawalSubmitter, error) {
|
||||
accountID := middleware.GetUserIDFromContext(ctx)
|
||||
if accountID == 0 {
|
||||
return withdrawalSubmitter{}, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
if shopID == 0 {
|
||||
return withdrawalSubmitter{}, errors.New(errors.CodeForbidden, "代理账号缺少店铺信息")
|
||||
}
|
||||
return withdrawalSubmitter{
|
||||
ID: accountID, Username: middleware.GetUsernameFromContext(ctx), ShopID: shopID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureOwnAgentShopForShopID 复核代理身份与店铺归属,越权与不存在返回同一结果。
|
||||
func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmitter, shopID uint) error {
|
||||
if shopID == 0 || shopID != submitter.ShopID {
|
||||
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
|
||||
}
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
|
||||
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。
|
||||
func loadValidQualification(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (*model.WithdrawalQualification, error) {
|
||||
var qualification model.WithdrawalQualification
|
||||
err := tx.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
First(&qualification).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
|
||||
}
|
||||
if shop.Status != constants.ShopStatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效")
|
||||
}
|
||||
return &qualification, nil
|
||||
}
|
||||
|
||||
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
|
||||
func validateWithdrawalInvoice(qualification *model.WithdrawalQualification, invoiceKeys []string) error {
|
||||
keys := normalizeInvoiceKeys(invoiceKeys)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
if qualification.SubjectType != constants.WithdrawalQualificationSubjectTypeEnterprise {
|
||||
return errors.New(errors.CodeInvalidParam, "发票仅企业主体可提交")
|
||||
}
|
||||
if qualification.InvoiceSubjectCode == "" || qualification.InvoiceTitle == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "有效提现资料资格未登记发票资料")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeInvoiceKeys 归一化发票对象键列表,去除空串。
|
||||
func normalizeInvoiceKeys(keys []string) []string {
|
||||
result := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if trimmed := strings.TrimSpace(key); trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ensureDailyWithdrawalLimit 校验当日提现次数上限;重提不占用新的当日次数。
|
||||
func ensureDailyWithdrawalLimit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
limit int,
|
||||
countNewRequest bool,
|
||||
) error {
|
||||
if !countNewRequest || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59").
|
||||
Count(&todayCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询当日提现次数失败")
|
||||
}
|
||||
if int(todayCount) >= limit {
|
||||
return errors.New(errors.CodeInvalidParam, "今日提现次数已达上限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockWithdrawalRequest 以行锁读取提现申请,未找到返回稳定不存在错误。
|
||||
func lockWithdrawalRequest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
id uint,
|
||||
) (*model.CommissionWithdrawalRequest, error) {
|
||||
var request model.CommissionWithdrawalRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&request, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定提现申请失败")
|
||||
}
|
||||
return &request, nil
|
||||
}
|
||||
|
||||
// lockCommissionWallet 以行锁读取店铺佣金钱包。
|
||||
func lockCommissionWallet(ctx context.Context, tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeCommission).
|
||||
First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺佣金钱包失败")
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
// freezeCommissionBalance 以条件更新冻结可提现余额,影响行数不为 1 时判定余额不足。
|
||||
func freezeCommissionBalance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
amount int64,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND balance - frozen_balance >= ?",
|
||||
wallet.ID, constants.AgentWalletTypeCommission, amount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance + ?", amount),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结可提现余额失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "可提现余额不足或并发冲突,请稍后重试")
|
||||
}
|
||||
wallet.FrozenBalance += amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseCommissionBalance 以条件更新释放冻结余额并返回释放是否发生。
|
||||
// 释放金额取尝试记录事实,重复释放不会重复调整余额。
|
||||
func releaseCommissionBalance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
walletID uint,
|
||||
amount int64,
|
||||
) (bool, error) {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
|
||||
walletID, constants.AgentWalletTypeCommission, amount).
|
||||
Updates(map[string]any{
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "释放冻结余额失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// nextWithdrawalAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
|
||||
func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) (int, error) {
|
||||
var row struct {
|
||||
MaxAttemptNo int
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
|
||||
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
|
||||
Where("request_id = ?", requestID).Scan(&row).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试序号失败")
|
||||
}
|
||||
if row.MaxAttemptNo >= constants.WithdrawalAttemptMaxCount {
|
||||
return 0, errors.New(errors.CodeConflict, "提现重提次数已达上限,请联系平台处理")
|
||||
}
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// updateWithdrawalRequestForResubmit 以已驳回状态条件更新申请为最新尝试的镜像。
|
||||
func updateWithdrawalRequestForResubmit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
input WithdrawalInput,
|
||||
policy WithdrawalPolicy,
|
||||
fee int64,
|
||||
actualAmount int64,
|
||||
accountInfo []byte,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusRejected).
|
||||
Updates(map[string]any{
|
||||
"amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount,
|
||||
"withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo,
|
||||
"status": constants.WithdrawalStatusPending, "processed_at": nil,
|
||||
"reject_reason": "", "updater": request.ApplicantID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新提现申请重提内容失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化,请刷新后重试")
|
||||
}
|
||||
request.Amount = input.Amount
|
||||
request.Fee = fee
|
||||
request.FeeRate = policy.FeeRate
|
||||
request.ActualAmount = actualAmount
|
||||
request.WithdrawalMethod = input.WithdrawalMethod
|
||||
request.AccountInfo = accountInfo
|
||||
request.Status = constants.WithdrawalStatusPending
|
||||
request.ProcessedAt = nil
|
||||
request.RejectReason = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachWithdrawalAttemptInstance 回写尝试记录关联的审批实例,写入一次后不可修改。
|
||||
func attachWithdrawalAttemptInstance(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
instanceID uint,
|
||||
) error {
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
|
||||
Update("approval_instance_id", instanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现审批实例关联已变化")
|
||||
}
|
||||
attempt.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateWithdrawalLatest 回填申请的最新尝试与审批实例引用,仅用于列表投影。
|
||||
func updateWithdrawalLatest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
instanceID uint,
|
||||
) error {
|
||||
updates := map[string]any{
|
||||
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
|
||||
}
|
||||
if request.ApprovalInstanceID == nil {
|
||||
// 首次接入企业微信审批时记录稳定门禁标识,本地人工终审据此拒绝。
|
||||
updates["approval_instance_id"] = instanceID
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ?", request.ID).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填提现申请最新审批实例失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请最新审批实例回填已变化")
|
||||
}
|
||||
request.LatestAttemptID = attempt.ID
|
||||
request.LatestApprovalInstanceID = instanceID
|
||||
if request.ApprovalInstanceID == nil {
|
||||
request.ApprovalInstanceID = &instanceID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordWithdrawalFreezeTransaction 写入提现冻结钱包流水,金额为负且状态为处理中。
|
||||
func recordWithdrawalFreezeTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
operatorID uint,
|
||||
amount int64,
|
||||
) (*model.AgentWalletTransaction, error) {
|
||||
remark := "提现冻结,单号:" + request.WithdrawalNo
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
refID := request.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: operatorID,
|
||||
TransactionType: constants.AgentTransactionTypeWithdrawal,
|
||||
Amount: -amount,
|
||||
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - amount,
|
||||
Status: constants.TransactionStatusProcessing,
|
||||
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
|
||||
Creator: operatorID, ShopIDTag: request.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现冻结钱包流水失败")
|
||||
}
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// shopName 读取店铺名称用于审批表单展示,缺失时留空。
|
||||
func shopName(ctx context.Context, db *gorm.DB, shopID uint) string {
|
||||
var shop model.Shop
|
||||
if err := db.WithContext(ctx).Select("id", "shop_name").First(&shop, shopID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return shop.ShopName
|
||||
}
|
||||
|
||||
// withdrawalApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 收款账号按原值写入供审批人核验,其他敏感内容不写入。
|
||||
func withdrawalApprovalForm(
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
shopNameValue string,
|
||||
) map[string]any {
|
||||
accountName, accountNumber := decodeAccountInfo(attempt.AccountInfo)
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldWithdrawalNo: request.WithdrawalNo,
|
||||
constants.ApprovalFieldWithdrawalAttemptNo: attempt.AttemptNo,
|
||||
constants.ApprovalFieldWithdrawalShopID: request.ShopID,
|
||||
constants.ApprovalFieldWithdrawalShopName: shopNameValue,
|
||||
constants.ApprovalFieldWithdrawalAmount: formatAmountYuan(attempt.Amount),
|
||||
constants.ApprovalFieldWithdrawalAmountCent: attempt.Amount,
|
||||
constants.ApprovalFieldWithdrawalFee: formatAmountYuan(attempt.Fee),
|
||||
constants.ApprovalFieldWithdrawalActualAmount: formatAmountYuan(attempt.ActualAmount),
|
||||
constants.ApprovalFieldWithdrawalMethod: attempt.WithdrawalMethod,
|
||||
constants.ApprovalFieldWithdrawalAccountName: accountName,
|
||||
constants.ApprovalFieldWithdrawalAccountNumber: accountNumber,
|
||||
constants.ApprovalFieldWithdrawalInvoiceKey: decodeInvoiceKeys(attempt.InvoiceKeys),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeAccountInfo 解析收款账户信息快照,解析失败时留空。
|
||||
func decodeAccountInfo(payload []byte) (string, string) {
|
||||
var info map[string]string
|
||||
if err := sonic.Unmarshal(payload, &info); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return info["account_name"], info["account_number"]
|
||||
}
|
||||
|
||||
// decodeInvoiceKeys 解析发票对象键列表,解析失败时返回空列表。
|
||||
func decodeInvoiceKeys(payload []byte) []string {
|
||||
var keys []string
|
||||
if err := sonic.Unmarshal(payload, &keys); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// withdrawalAuditSnapshot 生成提现审计快照,不含收款账号与发票内容。
|
||||
func withdrawalAuditSnapshot(
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
wallet *model.AgentWallet,
|
||||
) map[string]any {
|
||||
snapshot := map[string]any{
|
||||
"id": request.ID, "withdrawal_no": request.WithdrawalNo, "shop_id": request.ShopID,
|
||||
"amount": attempt.Amount, "fee": attempt.Fee, "fee_rate": attempt.FeeRate,
|
||||
"actual_amount": attempt.ActualAmount, "withdrawal_method": attempt.WithdrawalMethod,
|
||||
"status": request.Status, "attempt_id": attempt.ID, "attempt_no": attempt.AttemptNo,
|
||||
"latest_approval_instance_id": request.LatestApprovalInstanceID,
|
||||
"anomaly_flag": request.AnomalyFlag,
|
||||
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
|
||||
}
|
||||
if wallet != nil {
|
||||
snapshot["wallet_id"] = wallet.ID
|
||||
snapshot["wallet_frozen_balance"] = wallet.FrozenBalance
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// withdrawalSubmitSummary 区分首次提交与重提的审计摘要。
|
||||
func withdrawalSubmitSummary(resubmit bool) string {
|
||||
if resubmit {
|
||||
return "重提佣金提现申请,已释放旧未结算冻结"
|
||||
}
|
||||
return "提交佣金提现申请并冻结可提现余额"
|
||||
}
|
||||
|
||||
// ReleaseUnsettledAttemptsInTx 幂等释放指定店铺全部未结算的提现审批尝试冻结。
|
||||
// 释放金额取 try.amount 事实,释放完成写入 released_at;已释放的尝试不会被重复释放。
|
||||
// 供后续佣金回溯在扣减佣金余额前先释放冻结,返回本次实际释放金额合计。
|
||||
func (s *WithdrawalService) ReleaseUnsettledAttemptsInTx(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (int64, error) {
|
||||
if s == nil || s.db == nil || tx == nil || shopID == 0 {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "提现冻结释放参数无效")
|
||||
}
|
||||
return releaseUnsettledAttempts(ctx, tx, shopID)
|
||||
}
|
||||
|
||||
// releaseUnsettledAttemptsForRequest 在重提事务内释放指定申请的全部未结算尝试冻结。
|
||||
func releaseUnsettledAttemptsForRequest(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
requestID uint,
|
||||
) (int64, error) {
|
||||
return releaseUnsettledForRequests(ctx, tx, []uint{requestID})
|
||||
}
|
||||
|
||||
// releaseUnsettledAttempts 释放指定店铺范围内未结算的提现审批尝试冻结。
|
||||
// 全局加锁顺序固定为「申请 → 尝试 → 钱包」:本函数先锁申请行,再交由释放原语锁尝试行。
|
||||
func releaseUnsettledAttempts(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (int64, error) {
|
||||
var requestIDs []uint
|
||||
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("shop_id = ?", shopID).Order("id ASC").Pluck("id", &requestIDs).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺提现申请失败")
|
||||
}
|
||||
return releaseUnsettledForRequests(ctx, tx, requestIDs)
|
||||
}
|
||||
|
||||
// releaseUnsettledForRequests 通过共享释放原语释放尝试冻结并解冻钱包。
|
||||
// 释放金额一律取尝试记录事实;原语的 released_at 条件更新保证重复调用不重复释放。
|
||||
func releaseUnsettledForRequests(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
requestIDs []uint,
|
||||
) (int64, error) {
|
||||
if len(requestIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
requests := make(map[uint]*model.CommissionWithdrawalRequest, len(requestIDs))
|
||||
for _, requestID := range requestIDs {
|
||||
request, err := lockWithdrawalRequest(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
requests[requestID] = request
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
amounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := int64(0)
|
||||
for _, requestID := range requestIDs {
|
||||
amount, exists := amounts[requestID]
|
||||
if !exists || amount == 0 {
|
||||
continue
|
||||
}
|
||||
request := requests[requestID]
|
||||
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, amount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
|
||||
}
|
||||
total += amount
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// generateWithdrawalNo 生成提现单号,格式:W + 时间戳 + 随机数。
|
||||
func generateWithdrawalNo() string {
|
||||
return "W" + time.Now().Format("20060102150405") + randomDigits(6)
|
||||
}
|
||||
|
||||
// randomDigits 生成指定位数的数字随机串,用于提现单号。
|
||||
func randomDigits(length int) string {
|
||||
const digits = "0123456789"
|
||||
buf := make([]byte, 0, length)
|
||||
limit := big.NewInt(int64(len(digits)))
|
||||
for range length {
|
||||
value, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return strings.Repeat("0", length)
|
||||
}
|
||||
buf = append(buf, digits[value.Int64()])
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
|
||||
func formatAmountYuan(amount int64) string {
|
||||
negative := amount < 0
|
||||
if negative {
|
||||
amount = -amount
|
||||
}
|
||||
value := distributiondomain.FormatCentYuan(amount)
|
||||
if negative {
|
||||
return "-" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"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"
|
||||
)
|
||||
|
||||
// WithdrawalApprovalHandler 将渠道无关企业微信终态应用到提现申请。
|
||||
// 通过时仅一次从冻结余额扣减并保持 WithdrawalStatusApproved=2,同时写入到账时间;
|
||||
// 驳回、撤销与删除仅一次释放本次尝试的冻结余额并记录释放时间;
|
||||
// 通过后撤销不回滚、不重新冻结、不自动重提,只写入正交异常标记与原因。
|
||||
type WithdrawalApprovalHandler struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
}
|
||||
|
||||
// NewWithdrawalApprovalHandler 创建佣金提现审批终态消费者。
|
||||
func NewWithdrawalApprovalHandler(db *gorm.DB, audit AuditWriter) *WithdrawalApprovalHandler {
|
||||
return &WithdrawalApprovalHandler{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// Handle 幂等消费标准审批终态。
|
||||
// 业务标识为提现审批尝试记录主键;先锁定尝试记录并校验审批实例一致,再按条件更新推进状态。
|
||||
func (h *WithdrawalApprovalHandler) 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.ApprovalBusinessTypeCommissionWithdrawal ||
|
||||
event.BusinessID == 0 || event.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "佣金提现审批终态参数无效")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
|
||||
})
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。
|
||||
// 因此先用不加锁读取得 request_id,再按序加锁,避免与退款回扣路径形成死锁环。
|
||||
var lookup model.CommissionWithdrawalRequestAttempt
|
||||
if err := tx.WithContext(ctx).Select("id", "request_id").
|
||||
First(&lookup, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
|
||||
}
|
||||
request, err := lockWithdrawalRequest(ctx, tx, lookup.RequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var attempt model.CommissionWithdrawalRequestAttempt
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&attempt, event.BusinessID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现审批尝试记录失败")
|
||||
}
|
||||
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
|
||||
return errors.New(errors.CodeConflict, "提现审批尝试记录关联的审批实例不一致")
|
||||
}
|
||||
if attempt.RequestID != request.ID {
|
||||
return errors.New(errors.CodeConflict, "提现审批尝试记录归属已变化")
|
||||
}
|
||||
if request.LatestAttemptID != attempt.ID {
|
||||
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
|
||||
return nil
|
||||
}
|
||||
switch event.Decision {
|
||||
case constants.ApprovalDecisionApproved:
|
||||
return h.applyApproved(ctx, tx, request, &attempt, event)
|
||||
case constants.ApprovalDecisionRejected,
|
||||
constants.ApprovalDecisionCancelled,
|
||||
constants.ApprovalDecisionDeleted:
|
||||
return h.applyClosed(ctx, tx, request, &attempt, event)
|
||||
case constants.ApprovalDecisionRevokedAfterApproved:
|
||||
return h.applyRevoked(ctx, tx, request, &attempt, event)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的提现申请审批终态")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// applyApproved 仅一次从冻结余额扣减,保持已通过状态并写入到账时间。
|
||||
// 幂等守卫为「申请仍待审核 + paid_at 为空 + 尝试未释放」的条件更新且影响行数为 1。
|
||||
func (h *WithdrawalApprovalHandler) applyApproved(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if attempt.ReleasedAt != nil {
|
||||
// 已结算的尝试不再扣减,避免重复扣款。
|
||||
return nil
|
||||
}
|
||||
if wallet.FrozenBalance < attempt.Amount {
|
||||
return errors.New(errors.CodeConflict, "冻结余额不足以完成提现扣减,请人工核对")
|
||||
}
|
||||
// 通过即视为已到账:先以 released_at IS NULL 条件更新标记本次冻结已结算,保证重复回调不重复扣减。
|
||||
settled, err := markAttemptReleased(ctx, tx, attempt, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settled {
|
||||
return nil
|
||||
}
|
||||
// 通过时保持状态 2 并写入到账时间,禁止使用已到账状态值 4。
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ? AND paid_at IS NULL", request.ID, constants.WithdrawalStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalStatusApproved,
|
||||
"paid_at": now,
|
||||
"processed_at": now,
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已通过失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化")
|
||||
}
|
||||
if err := deductFrozenBalance(ctx, tx, wallet, attempt.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
transaction, err := recordWithdrawalDeductTransaction(ctx, tx, wallet, request, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.ReleasedAt = &now
|
||||
before := map[string]any{"status": constants.WithdrawalStatusPending, "paid_at": nil, "frozen_balance": wallet.FrozenBalance + attempt.Amount}
|
||||
request.Status = constants.WithdrawalStatusApproved
|
||||
request.PaidAt = &now
|
||||
request.ProcessedAt = &now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":approved",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptApproved,
|
||||
Summary: "企业微信通过佣金提现,已从冻结余额扣减并记录到账时间",
|
||||
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
|
||||
Wallet: wallet, Transaction: transaction,
|
||||
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
|
||||
})
|
||||
}
|
||||
|
||||
// applyClosed 处理最终驳回、撤销与删除:仅一次释放本次尝试冻结并记录释放时间。
|
||||
// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。
|
||||
func (h *WithdrawalApprovalHandler) applyClosed(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
if attempt.ReleasedAt != nil {
|
||||
return nil
|
||||
}
|
||||
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
released, err := markAttemptReleased(ctx, tx, attempt, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !released {
|
||||
return nil
|
||||
}
|
||||
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, attempt.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
|
||||
}
|
||||
reason := rejectionReason(event.Decision)
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalStatusRejected,
|
||||
"processed_at": now,
|
||||
"reject_reason": reason,
|
||||
"updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已驳回失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化")
|
||||
}
|
||||
frozenBefore := wallet.FrozenBalance + attempt.Amount
|
||||
wallet.FrozenBalance = frozenBefore - attempt.Amount
|
||||
transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, frozenBefore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.ReleasedAt = &now
|
||||
before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore}
|
||||
request.Status = constants.WithdrawalStatusRejected
|
||||
request.ProcessedAt = &now
|
||||
request.RejectReason = reason
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":closed",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed,
|
||||
Summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额",
|
||||
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
|
||||
Wallet: wallet, Transaction: transaction,
|
||||
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
|
||||
})
|
||||
}
|
||||
|
||||
// applyRevoked 处理通过后撤销。
|
||||
// 已通过:不回滚已到账金额、不重新冻结、不自动重提,只写正交异常标记与原因。
|
||||
// 仍在待审核(渠道乱序投递):按驳回同等处理,释放本次尝试冻结并转驳回状态。
|
||||
func (h *WithdrawalApprovalHandler) applyRevoked(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
event approvalapp.TerminalDecisionEvent,
|
||||
) error {
|
||||
if request.Status == constants.WithdrawalStatusPending {
|
||||
return h.applyClosed(ctx, tx, request, attempt, event)
|
||||
}
|
||||
if request.Status != constants.WithdrawalStatusApproved {
|
||||
// 已驳回等终态不再改变事实。
|
||||
return nil
|
||||
}
|
||||
if request.AnomalyFlag == constants.WithdrawalAnomalyFlagRevokedAfterApproved {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
reason := "企业微信通过后撤销:" + rejectionReason(event.Decision)
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ? AND anomaly_flag = ?",
|
||||
request.ID, constants.WithdrawalStatusApproved, constants.WithdrawalAnomalyFlagNone).
|
||||
Updates(map[string]any{
|
||||
"anomaly_flag": constants.WithdrawalAnomalyFlagRevokedAfterApproved,
|
||||
"anomaly_reason": reason, "updater": 0,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现异常标记失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请异常标记已变化")
|
||||
}
|
||||
before := map[string]any{
|
||||
"status": request.Status, "anomaly_flag": constants.WithdrawalAnomalyFlagNone,
|
||||
"paid_at": request.PaidAt, "amount": attempt.Amount,
|
||||
}
|
||||
request.AnomalyFlag = constants.WithdrawalAnomalyFlagRevokedAfterApproved
|
||||
request.AnomalyReason = reason
|
||||
request.UpdatedAt = now
|
||||
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
|
||||
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":anomaly",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalAnomalyFlagged,
|
||||
Summary: "企业微信通过后撤销,已到账金额不回滚、不重新冻结,仅写入异常标记",
|
||||
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
|
||||
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, nil),
|
||||
})
|
||||
}
|
||||
|
||||
// markAttemptReleased 以未释放条件更新写入尝试释放时间,返回是否本次完成释放。
|
||||
func markAttemptReleased(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
now time.Time,
|
||||
) (bool, error) {
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
|
||||
Where("id = ? AND released_at IS NULL", attempt.ID).
|
||||
Update("released_at", now)
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现尝试释放时间失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// deductFrozenBalance 以冻结余额充足条件更新同时扣减余额与冻结余额。
|
||||
func deductFrozenBalance(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, amount int64) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
|
||||
wallet.ID, constants.AgentWalletTypeCommission, amount).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", amount),
|
||||
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "从冻结余额扣减提现金额失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "冻结余额不足或已被并发处理")
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
wallet.FrozenBalance -= amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordWithdrawalDeductTransaction 写入通过时的钱包流水,余额与冻结余额同时减少。
|
||||
func recordWithdrawalDeductTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
) (*model.AgentWalletTransaction, error) {
|
||||
remark := "企业微信终审通过,提现到账,单号:" + request.WithdrawalNo
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
refID := request.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
|
||||
TransactionType: constants.AgentTransactionTypeWithdrawal,
|
||||
Amount: -attempt.Amount,
|
||||
BalanceBefore: wallet.Balance + attempt.Amount, BalanceAfter: wallet.Balance,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
|
||||
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现到账钱包流水失败")
|
||||
}
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// recordWithdrawalReleaseTransaction 写入驳回时的钱包流水,仅冻结余额减少。
|
||||
func recordWithdrawalReleaseTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
wallet *model.AgentWallet,
|
||||
request *model.CommissionWithdrawalRequest,
|
||||
attempt *model.CommissionWithdrawalRequestAttempt,
|
||||
frozenBefore int64,
|
||||
) (*model.AgentWalletTransaction, error) {
|
||||
remark := "企业微信未通过,释放提现冻结,单号:" + request.WithdrawalNo
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
refID := request.ID
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
|
||||
TransactionType: constants.AgentTransactionTypeRefund,
|
||||
Amount: attempt.Amount,
|
||||
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
|
||||
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现释放钱包流水失败")
|
||||
}
|
||||
_ = frozenBefore
|
||||
return transaction, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"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"
|
||||
@@ -112,8 +113,9 @@ func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest
|
||||
}
|
||||
shop.Creator = operatorID
|
||||
shop.Updater = operatorID
|
||||
if err := tx.Create(shop).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
|
||||
// 分销码在创建时随机生成且唯一;冲突时重新生成并重试,不提供人工指定或编辑入口。
|
||||
if err := CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account := &model.Account{
|
||||
@@ -201,6 +203,8 @@ func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest
|
||||
func shopCreationData(shop *model.Shop) map[string]any {
|
||||
data := shopProfileData(shop)
|
||||
data["shop_code"] = shop.ShopCode
|
||||
// 分销码是本 Change 新增的建店事实,按脱敏值记录,口径与审批建店路径一致。
|
||||
data["distribution_code_masked"] = distributiondomain.MaskDistributionCode(shop.DistributionCode)
|
||||
data["parent_id"] = shop.ParentID
|
||||
data["level"] = shop.Level
|
||||
return data
|
||||
@@ -285,7 +289,8 @@ func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error
|
||||
|
||||
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
|
||||
return &dto.ShopResponse{
|
||||
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
|
||||
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
|
||||
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
|
||||
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
|
||||
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
|
||||
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
|
||||
|
||||
95
internal/application/shop/distribution_code.go
Normal file
95
internal/application/shop/distribution_code.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
|
||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// distributionCodeConstraint 是分销码条件唯一索引名,用于识别唯一冲突并重试。
|
||||
const distributionCodeConstraint = "uk_shop_distribution_code"
|
||||
|
||||
// distributionCodeSavepoint 是分销码冲突重试使用的保存点名称。
|
||||
const distributionCodeSavepoint = "shop_distribution_code_retry"
|
||||
|
||||
// CreateShopWithDistributionCode 在事务内为新店铺生成全局唯一随机分销码并创建店铺。
|
||||
//
|
||||
// 每次尝试都重新生成随机码;Create 命中分销码唯一约束时重新生成并重试,
|
||||
// 最多 constants.ShopDistributionCodeMaxAttempts 次。其他唯一冲突(店铺编号等)不重试,
|
||||
// 直接返回数据库错误。
|
||||
//
|
||||
// 重试依赖真实保存点:每条 Create 包在 GORM 的嵌套事务中执行,冲突时 GORM 自动
|
||||
// 回滚到内部保存点,外层事务因此仍可用(PostgreSQL 唯一冲突会中止整个事务,
|
||||
// 不回滚到保存点则后续语句必然 25P02,重试不可能生效)。这里刻意不使用裸
|
||||
// SavePoint/RollbackTo:GORM 的嵌套事务会自行处理 PrepareStmt 下的连接池切换。
|
||||
func CreateShopWithDistributionCode(ctx context.Context, tx *gorm.DB, shop *model.Shop) error {
|
||||
if tx == nil || shop == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "创建店铺参数无效")
|
||||
}
|
||||
// 失败关闭:必须在调用方的事务句柄内执行,否则嵌套事务会自行开启并提交一个新事务,
|
||||
// 破坏调用方的原子性(建店事务与注册审批通过事务均满足该前提)。
|
||||
if !inTransaction(tx) {
|
||||
return errors.New(errors.CodeInvalidStatus, "创建店铺必须传入事务句柄")
|
||||
}
|
||||
for range constants.ShopDistributionCodeMaxAttempts {
|
||||
code, err := distributiondomain.GenerateDistributionCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied, err := distributionCodeOccupied(ctx, tx, code); err != nil {
|
||||
return err
|
||||
} else if occupied {
|
||||
// 预检命中直接换码,避免把可预期的冲突交给数据库。
|
||||
continue
|
||||
}
|
||||
shop.DistributionCode = code
|
||||
shop.ID = 0
|
||||
createErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||
return inner.Create(shop).Error
|
||||
})
|
||||
if createErr == nil {
|
||||
return nil
|
||||
}
|
||||
if !isDistributionCodeConflict(createErr) {
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建店铺失败")
|
||||
}
|
||||
// 分销码冲突:GORM 已回滚到内部保存点,外层事务仍可继续,换码重试。
|
||||
}
|
||||
return errors.New(errors.CodeConflict, "生成分销码冲突,请重试")
|
||||
}
|
||||
|
||||
// inTransaction 判断句柄是否为已开启的事务,与 GORM 自身识别嵌套事务的方式一致。
|
||||
func inTransaction(tx *gorm.DB) bool {
|
||||
if tx == nil || tx.Statement == nil {
|
||||
return false
|
||||
}
|
||||
committer, ok := tx.Statement.ConnPool.(gorm.TxCommitter)
|
||||
return ok && committer != nil && !reflect.ValueOf(committer).IsNil()
|
||||
}
|
||||
|
||||
// distributionCodeOccupied 预检分销码是否已被未删除店铺占用。
|
||||
func distributionCodeOccupied(ctx context.Context, tx *gorm.DB, code string) (bool, error) {
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).Model(&model.Shop{}).
|
||||
Where("distribution_code = ?", code).Count(&count).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "校验分销码唯一性失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// isDistributionCodeConflict 判断错误是否为分销码条件唯一索引冲突。
|
||||
func isDistributionCodeConflict(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !stderrors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == "23505" && pgErr.ConstraintName == distributionCodeConstraint
|
||||
}
|
||||
@@ -16,8 +16,21 @@ import (
|
||||
|
||||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
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 创建店铺更新事务脚本。
|
||||
@@ -81,6 +94,8 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
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, "更新店铺失败")
|
||||
}
|
||||
@@ -110,6 +125,14 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
|
||||
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 {
|
||||
|
||||
@@ -353,6 +353,54 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeAgentDistribution:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
{Code: constants.ApprovalFieldDistributionCode, Name: "分销码", ValueType: constants.ApprovalFieldValueTypeString, Description: "注册使用的上级店铺分销码脱敏值,仅用于审批人核对来源,不代表新建店铺的码"},
|
||||
{Code: constants.ApprovalFieldDistributionParentShopID, Name: "上级店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "分销码所属上级店铺的系统 ID"},
|
||||
{Code: constants.ApprovalFieldDistributionParentShopName, Name: "上级店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "分销码所属上级店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldDistributionShopName, Name: "申请店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldDistributionShopCode, Name: "申请店铺编号", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺编号快照,通过时按既有唯一约束校验"},
|
||||
{Code: constants.ApprovalFieldDistributionUsername, Name: "代理账号用户名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的代理主账号用户名快照"},
|
||||
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
|
||||
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
|
||||
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeWithdrawalQualification:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
{Code: constants.ApprovalFieldQualificationShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "提现资料资格所属代理店铺的系统 ID"},
|
||||
{Code: constants.ApprovalFieldQualificationShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "提现资料资格所属代理店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldQualificationSubjectType, Name: "签约主体类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "签约主体类型中文名:企业或个人"},
|
||||
{Code: constants.ApprovalFieldQualificationSubjectCodeMasked, Name: "签约主体代码", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的统一社会信用代码或身份证号,禁止写入完整证件号"},
|
||||
{Code: constants.ApprovalFieldQualificationLegalPersonMasked, Name: "法人身份证号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的法人身份证号,禁止写入完整证件号"},
|
||||
{Code: constants.ApprovalFieldQualificationContractKey, Name: "合同附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "合同对象存储 Key 列表(单个对象)"},
|
||||
{Code: constants.ApprovalFieldQualificationIDCardFrontKey, Name: "法人身份证正面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证正面对象存储 Key 列表(单个对象)"},
|
||||
{Code: constants.ApprovalFieldQualificationIDCardBackKey, Name: "法人身份证反面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证反面对象存储 Key 列表(单个对象)"},
|
||||
{Code: constants.ApprovalFieldQualificationBusinessLicenseKey, Name: "营业执照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "营业执照对象存储 Key 列表(单个对象,可选)"},
|
||||
{Code: constants.ApprovalFieldQualificationShopFrontKey, Name: "门头照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "门头照对象存储 Key 列表(单个对象,可选)"},
|
||||
{Code: constants.ApprovalFieldQualificationInvoiceKey, Name: "发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "发票对象存储 Key 列表(单个对象,仅企业可选)"},
|
||||
{Code: constants.ApprovalFieldQualificationInvoiceTitle, Name: "发票抬头", ValueType: constants.ApprovalFieldValueTypeString, Description: "发票抬头,仅企业填写且必须与合同主体一致"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
case constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return []dto.WeComBusinessFieldResponse{
|
||||
{Code: constants.ApprovalFieldWithdrawalNo, Name: "提现单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提现申请单号"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAttemptNo, Name: "提交次序", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次为第几次提交,重提时递增"},
|
||||
{Code: constants.ApprovalFieldWithdrawalShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "发起提现的代理店铺系统 ID"},
|
||||
{Code: constants.ApprovalFieldWithdrawalShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "发起提现的代理店铺名称快照"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAmount, Name: "提现金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的提现金额"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAmountCent, Name: "提现金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的提现金额整数"},
|
||||
{Code: constants.ApprovalFieldWithdrawalFee, Name: "手续费", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次手续费"},
|
||||
{Code: constants.ApprovalFieldWithdrawalActualAmount, Name: "实际到账金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的实际到账金额"},
|
||||
{Code: constants.ApprovalFieldWithdrawalMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款方式名称快照"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAccountName, Name: "收款人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款人姓名"},
|
||||
{Code: constants.ApprovalFieldWithdrawalAccountNumber, Name: "收款账号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款账号,供审批人核验打款"},
|
||||
{Code: constants.ApprovalFieldWithdrawalInvoiceKey, Name: "申请级发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "本次申请级发票对象存储 Key 列表,无发票时为空数组"},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
@@ -376,7 +424,10 @@ func validApprovalBusinessType(businessType string) bool {
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection:
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -391,6 +442,12 @@ func approvalBusinessTypeName(businessType string) string {
|
||||
return "员工线下代充值审批"
|
||||
case constants.ApprovalBusinessTypeEmployeeCollection:
|
||||
return "员工代收款核销审批"
|
||||
case constants.ApprovalBusinessTypeAgentDistribution:
|
||||
return "代理扫码分销注册审批"
|
||||
case constants.ApprovalBusinessTypeWithdrawalQualification:
|
||||
return "提现资料资格审批"
|
||||
case constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return "佣金提现终审"
|
||||
default:
|
||||
return "未知审批业务类型"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user