feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
@@ -101,7 +101,7 @@ func (s *RegistrationService) Register(
|
||||
Status: constants.AgentDistributionRegistrationStatusPending,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
registrationApprovalForm(normalized, parent))
|
||||
registrationApprovalForm(normalized, parent, s.parentBusinessOwnerName(ctx, parent)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,9 +252,14 @@ func (s *RegistrationService) findDistributionParent(ctx context.Context, distri
|
||||
}
|
||||
|
||||
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
|
||||
// 限流错误码必须原样保留:失败次数达到上限后的锁定必须让调用方看到「稍后再试」,
|
||||
// 其余失败仍按验证码无效统一提示。
|
||||
func verificationFailure(err error) error {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
if appErr.Code == errors.CodeTooManyRequests {
|
||||
return appErr
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
@@ -278,7 +283,16 @@ func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID
|
||||
|
||||
// registrationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
|
||||
//
|
||||
// 业务员取上级店铺当前业务员的名称快照:上级无业务员时以空值提交并显式标记为「无」,
|
||||
// MUST NOT 以提交人、上级店铺主账号或其它账号填充。该字段是只读快照,审批通过时写入新店铺的
|
||||
// 初始业务员仍按既有规则取上级业务员当时值,不因本快照与通过时值不同而改写通过结果。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop, businessOwnerName string) map[string]any {
|
||||
owner := strings.TrimSpace(businessOwnerName)
|
||||
absenceMark := ""
|
||||
if owner == "" {
|
||||
absenceMark = agentDistributionBusinessOwnerAbsenceMark
|
||||
}
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
|
||||
constants.ApprovalFieldDistributionParentShopID: parent.ID,
|
||||
@@ -290,9 +304,34 @@ func registrationApprovalForm(input distributiondomain.RegistrationInput, parent
|
||||
constants.ApprovalFieldDistributionContactName: input.ContactName,
|
||||
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
|
||||
input.Province + input.City + input.District + input.Address),
|
||||
constants.ApprovalFieldDistributionBusinessOwner: owner,
|
||||
constants.ApprovalFieldDistributionBusinessOwnerAbsence: absenceMark,
|
||||
}
|
||||
}
|
||||
|
||||
// agentDistributionBusinessOwnerAbsenceMark 是注册审批材料中「上级店铺无业务员」的显式标记。
|
||||
// 与「字段未填」区分开,使审批人看到的是确认无业务员而非漏填。
|
||||
const agentDistributionBusinessOwnerAbsenceMark = "无"
|
||||
|
||||
// parentBusinessOwnerName 读取上级店铺当前业务员的名称快照。
|
||||
// 判定与店铺业务员投影的可用口径一致:账号存在、为平台账号、启用且未删除;
|
||||
// 任一条件不满足时返回空串,由审批材料显式标记为无,MUST NOT 用其它账号填充。
|
||||
func (s *RegistrationService) parentBusinessOwnerName(ctx context.Context, parent *model.Shop) string {
|
||||
if parent == nil || parent.BusinessOwnerAccountID == nil || *parent.BusinessOwnerAccountID == 0 {
|
||||
return ""
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).
|
||||
Select("id", "username", "user_type", "status").
|
||||
First(&account, *parent.BusinessOwnerAccountID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
if account.UserType != constants.UserTypePlatform || account.Status != constants.StatusEnabled || account.DeletedAt.Valid {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(account.Username)
|
||||
}
|
||||
|
||||
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
|
||||
func attachRegistrationInstance(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user