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

@@ -152,6 +152,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
if err != nil {
return nil, err
}
// 退款原因必填只在创建与重提用例校验:历史申请与补发历史审批路径不受约束、不追溯。
refundReason := strings.TrimSpace(req.RefundReason)
if refundReason == "" {
return nil, errors.New(errors.CodeInvalidParam, "退款原因不能为空")
}
if err := validateRefundMethod(decision, req.Method); err != nil {
return nil, err
}
@@ -191,8 +196,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
Method: req.Method,
CustomerAccountInfo: req.CustomerAccountInfo,
RefundVoucherKey: refundVoucherKey,
RefundReason: req.RefundReason,
Status: model.RefundStatusPending,
RefundReason: refundReason,
// 来源支付事实在创建时冻结:线下订单没有线上支付记录,两个字段以空值保存且不阻断申请创建。
SourcePaymentNo: frozenSourcePaymentNo(decision.Payment),
OriginalChannelTradeNo: frozenOriginalChannelTradeNo(decision.Payment),
Status: model.RefundStatusPending,
}
refund.Creator = userID
refund.Updater = userID
@@ -203,6 +211,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
Refund: refund, Order: order, SubmitterAccountID: userID,
ChannelRefundRequestNo: buildChannelRefundRequestNo(refund.Method, decision.ChannelConfig),
ApplicantRemark: applicantRemark(req.Remark),
Material: s.loadRefundMaterial(ctx, refund, decision.Payment),
})
if err != nil {
failedRefund := *refund
@@ -290,7 +300,9 @@ func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResp
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID)
// 补发不追溯退款原因必填,但仍按既有展示口径解析审批材料,使新增控件的材料在补发路径同样完整。
payment, _ := s.loadPaidPackagePayment(ctx, refund.OrderID)
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID, s.loadRefundMaterial(ctx, refund, payment))
if err != nil {
return nil, err
}
@@ -832,6 +844,13 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
if req.RefundReason != nil {
refundReason = *req.RefundReason
}
// 重提同样强制退款原因必填(去空白后非空);不填时沿用原有原因,原有原因为空的存量单不允许重提。
refundReason = strings.TrimSpace(refundReason)
if refundReason == "" {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order,
errors.New(errors.CodeInvalidParam, "退款原因不能为空"))
return errors.New(errors.CodeInvalidParam, "退款原因不能为空")
}
customerAccountInfo := refund.CustomerAccountInfo
if req.CustomerAccountInfo != nil {
customerAccountInfo = *req.CustomerAccountInfo
@@ -840,8 +859,14 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
return err
}
// 申请人备注缺省沿用该退款单最近一次尝试冻结的备注,填写时替换;两者都只影响本次新增的尝试快照。
remark := s.latestAttemptRemark(ctx, refund)
if req.Remark != nil {
remark = applicantRemark(req.Remark)
}
// 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。
// 来源支付事实随本次重提一并重新冻结,与重新派生的冻结实收金额保持同一事实来源。
updated := *refund
updated.Method = method
updated.RequestedRefundAmount = requestedRefundAmount
@@ -850,6 +875,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
updated.RefundVoucherKey = refundVoucherKey
updated.RefundReason = refundReason
updated.CustomerAccountInfo = customerAccountInfo
updated.SourcePaymentNo = frozenSourcePaymentNo(decision.Payment)
updated.OriginalChannelTradeNo = frozenOriginalChannelTradeNo(decision.Payment)
updated.Creator = userID
attemptChannelRefundRequestNo := buildChannelRefundRequestNo(method, decision.ChannelConfig)
@@ -858,6 +885,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
}
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
Refund: &updated, ChannelRefundRequestNo: attemptChannelRefundRequestNo,
ApplicantRemark: remark,
Material: s.loadRefundMaterial(ctx, &updated, decision.Payment),
}); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
@@ -1268,20 +1297,13 @@ func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
// buildRefundResponse 将退款 Model 转换为 DTO 响应
func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
assetType := ""
if r.OrderType == model.OrderTypeSingleCard {
assetType = "card"
} else if r.OrderType == model.OrderTypeDevice {
assetType = "device"
}
resp := &dto.RefundResponse{
ID: r.ID,
RefundNo: r.RefundNo,
OrderID: r.OrderID,
OrderNo: r.OrderNo,
AssetIdentifier: r.AssetIdentifier,
AssetType: assetType,
AssetType: refundAssetType(r.OrderType),
IotCardID: r.IotCardID,
DeviceID: r.DeviceID,
PackageUsageID: r.PackageUsageID,
@@ -1303,6 +1325,10 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
ChannelRefundNo: r.ChannelRefundNo,
ChannelRefundRequestNo: r.ChannelRefundRequestNo,
ChannelRefundAmount: r.ChannelRefundAmount,
SourcePaymentNo: r.SourcePaymentNo,
OriginalChannelTradeNo: r.OriginalChannelTradeNo,
OfflineSettlementNo: r.OfflineSettlementNo,
OfflineSettledBy: r.OfflineSettledBy,
FailureReason: r.FailureReason,
FailureReasonName: constants.RefundFailureReasonName(r.FailureReason),
FailureMessage: r.FailureMessage,
@@ -1330,6 +1356,9 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
if r.ChannelRefundedAt != nil {
resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05")
}
if r.OfflineSettledAt != nil {
resp.OfflineSettledAt = r.OfflineSettledAt.Format("2006-01-02 15:04:05")
}
return resp
}