Files
junhong_cmp_fiber/internal/application/distributionwithdrawal/audit.go
break e8ab1f471e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s
fix(代理分销注册): 验证码改为落库成功后消费并细化失败原因
公开扫码注册原先在审批准备前就消费短信验证码,落库前的任何失败都会烧掉验证码,
客户重试只能得到统一的“分销码不可用”,掩盖了真实失败原因。

- 验证码校验与消费拆分为 CheckCode 与 ConsumeCode,注册记录与审批实例落库成功后才原子消费;
  校验不消费、消费一次性,落库前失败时同一验证码可直接重试
- 分销码无效、所属店铺已停用、上级店铺缺少启用的主账号、验证码错误分别返回各自错误码与提示
- 注册必填字段缺失与请求参数校验失败提示定位到具体字段
- 同步公开接口描述与 agent-distribution-withdrawal 主 Spec 行为契约
2026-09-17 18:01:47 +08:00

158 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package distributionwithdrawal 收口代理分销注册、提现资料资格与提现企业微信终审的用例。
// 三者都以审批尝试/资料版本/注册记录主键作为通用审批业务标识,终态消费幂等且可重放。
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 是公开扫码注册复用的短信验证码接缝。
// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
type VerificationCodeVerifier interface {
CheckCode(ctx context.Context, phone string, code string) error
ConsumeCode(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,
})
}