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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
464 lines
20 KiB
Go
464 lines
20 KiB
Go
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
|
||
}
|