feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
2026-09-18 15:34:29 +08:00
parent 5e78809b93
commit 5ed6b39deb
142 changed files with 7878 additions and 964 deletions

View File

@@ -164,7 +164,10 @@ func (s *WithdrawalService) submit(
if err != nil {
return err
}
if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil {
if !qualification.Passed {
return qualificationCheckError(qualification)
}
if err := validateWithdrawalInvoice(qualification.Qualification, input.InvoiceKeys); err != nil {
return err
}
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
@@ -222,6 +225,10 @@ func (s *WithdrawalService) submit(
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
// 当次资料资格校验结果随尝试冻结:资格被替换或失效后,历史尝试仍返回提交时的版本与结果。
QualificationVersionID: qualification.VersionID,
QualificationCheckedAt: &qualification.CheckedAt,
QualificationPassed: qualificationPassedFlag(qualification.Passed),
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
@@ -348,30 +355,86 @@ func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmit
return nil
}
// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请
// qualificationCheck 是本次提现依据的资料资格校验结果
// 校验结果随当次审批尝试冻结:通过时记录版本标识与校验时间,未通过时给出稳定原因编码,
// 供提现详情查询与审计核对;历史尝试不被改写。
type qualificationCheck struct {
// Qualification 仅在通过时非空。
Qualification *model.WithdrawalQualification
Passed bool
VersionID uint
CheckedAt time.Time
// FailureReason 是未通过时的稳定原因编码(资格不存在/已失效/已停用/资料未通过审批),通过时为空。
FailureReason string
}
// qualificationCheckError 把未通过的稳定原因转成对外的稳定业务错误。
// 提示文案同时携带稳定原因编码与中文名称,使拒绝结果与审计记录都能定位到具体原因(本变更要求
// 拒绝结果与审计记录包含稳定原因)。
func qualificationCheckError(check qualificationCheck) error {
name := constants.GetWithdrawalQualificationFailureReasonName(check.FailureReason)
detail := check.FailureReason + "/" + name
if check.FailureReason == constants.WithdrawalQualificationFailureShopDisabled {
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):店铺已停用,请先恢复店铺后重新提交")
}
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):请先完成资料审批后重新提交")
}
// loadValidQualification 读取当前有效的提现资料资格;缺失、失效、未通过审批或店铺停用时返回未通过结果。
//
// 校验顺序沿用既有实现:先按资料资格判定,再判定店铺启停。稳定原因取值域与迁移
// 000237 的列语义一致(资格不存在、已失效、已停用、资料未通过审批)。
// 仅在数据库访问失败时返回错误,判定不通过一律以结果表达,使调用方能写审计并回传稳定原因。
func loadValidQualification(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (*model.WithdrawalQualification, error) {
var qualification model.WithdrawalQualification
) (qualificationCheck, error) {
check := qualificationCheck{CheckedAt: time.Now().UTC()}
var latest model.WithdrawalQualification
err := tx.WithContext(ctx).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
First(&qualification).Error
Where("shop_id = ?", shopID).
Order("id DESC").
First(&latest).Error
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
check.FailureReason = constants.WithdrawalQualificationFailureMissing
return check, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败")
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
}
check.VersionID = latest.ID
if latest.Status != constants.WithdrawalQualificationStatusApproved {
if latest.Status == constants.WithdrawalQualificationStatusInvalidated {
check.FailureReason = constants.WithdrawalQualificationFailureInvalidated
} else {
check.FailureReason = constants.WithdrawalQualificationFailureNotApproved
}
return check, nil
}
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 err == gorm.ErrRecordNotFound {
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
return check, nil
}
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺状态失败")
}
if shop.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效")
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
return check, nil
}
return &qualification, nil
check.Qualification = &latest
check.Passed = true
return check, nil
}
// qualificationPassedFlag 把校验结果转为尝试表的 0/1 标记,取值与迁移 000237 的 CHECK 一致。
func qualificationPassedFlag(passed bool) int {
if passed {
return constants.WithdrawalQualificationCheckPassed
}
return constants.WithdrawalQualificationCheckNotPassed
}
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
@@ -696,6 +759,9 @@ func withdrawalAuditSnapshot(
"latest_approval_instance_id": request.LatestApprovalInstanceID,
"anomaly_flag": request.AnomalyFlag,
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
// 当次资料资格校验留痕:版本标识与是否通过,未通过时的稳定原因不在成功快照中重复。
"qualification_version_id": attempt.QualificationVersionID,
"qualification_passed": attempt.QualificationPassed,
}
if wallet != nil {
snapshot["wallet_id"] = wallet.ID