feat(退款): AUG26-006 退款方式选择与原路退款

按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
This commit is contained in:
2026-09-14 11:55:16 +08:00
parent 48c85a4916
commit ba0855d9eb
51 changed files with 5995 additions and 986 deletions

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"github.com/break/junhong_cmp_fiber/internal/model"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -520,13 +521,17 @@ func (q *Query) expandRecharges(ctx context.Context, refs *financeRefs) error {
}
// expandApprovals 只按审批业务类型关联退款或线下代理充值。
//
// 退款审批的业务标识在尝试模式下指向审批尝试记录、存量模式下指向退款申请,两者来自独立自增序列,
// 因此按退款单过滤时必须同时展开「尝试记录指向的退款单」与「退款单自身」两种审批实例,
// 命中后再经共享解析器还原真实退款单,避免把尝试记录主键当作退款单编号收集。
func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error {
conditions, args := make([]string, 0, 3), make([]any, 0, 3)
if len(refs.approvals) > 0 {
conditions, args = append(conditions, "id IN ?"), append(args, uintKeys(refs.approvals))
}
if len(refs.refunds) > 0 {
conditions, args = append(conditions, "business_type = ? AND business_id IN ?"), append(args, constants.ApprovalBusinessTypeRefund, uintKeys(refs.refunds))
conditions, args = append(conditions, refundApprovalBusinessCondition()), append(args, constants.ApprovalBusinessTypeRefund, uintKeys(refs.refunds), uintKeys(refs.refunds))
}
if len(refs.agentRecharges) > 0 {
conditions, args = append(conditions, "business_type = ? AND business_id IN ?"), append(args, constants.ApprovalBusinessTypeOfflineRecharge, uintKeys(refs.agentRecharges))
@@ -542,7 +547,11 @@ func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error {
addUint(refs.approvals, row.ID)
switch row.BusinessType {
case constants.ApprovalBusinessTypeRefund:
addUint(refs.refunds, row.BusinessID)
refundID, err := refundapproval.ResolveRefundIDInTx(ctx, q.db, row.BusinessID, row.ID)
if err != nil {
return err
}
addUint(refs.refunds, refundID)
case constants.ApprovalBusinessTypeOfflineRecharge:
addUint(refs.agentRecharges, row.BusinessID)
}
@@ -550,6 +559,12 @@ func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error {
return nil
}
// refundApprovalBusinessCondition 返回退款审批实例的过滤条件,覆盖尝试模式与存量模式两种业务标识语义。
// 三个占位符依次为业务类型、尝试记录主键、退款单主键。
func refundApprovalBusinessCondition() string {
return "business_type = ? AND (business_id IN ? OR business_id IN (SELECT refund_attempt.id FROM tb_refund_request_attempt AS refund_attempt WHERE refund_attempt.refund_id IN ?))"
}
// loadFinanceAuditRows 只读取具有资金资源的审计事件,并保留操作者权威。
func (q *Query) loadFinanceAuditRows(ctx context.Context, filter FinanceFilter, refs *financeRefs) ([]model.AuditEvent, int64, error) {
resourceTypes := []string{
@@ -1133,11 +1148,12 @@ func (q *Query) loadApprovalFinance(ctx context.Context, filter FinanceFilter, r
query = applyFinanceTime(query, filter, "status_changed_at")
conditions, args := make([]string, 0, 5), make([]any, 0, 5)
appendUintCondition(&conditions, &args, "id", refs.approvals)
appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeRefund, refs.refunds)
appendRefundApprovalBusinessCondition(&conditions, &args, refs.refunds)
appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeOfflineRecharge, refs.agentRecharges)
if filter.ShopID != 0 {
conditions = append(conditions, `(business_type = ? AND EXISTS (SELECT 1 FROM tb_refund_request r WHERE r.id = tb_approval_instance.business_id AND r.deleted_at IS NULL AND r.shop_id = ?)) OR (business_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_approval_instance.business_id AND ar.deleted_at IS NULL AND ar.shop_id = ?))`)
args = append(args, constants.ApprovalBusinessTypeRefund, filter.ShopID, constants.ApprovalBusinessTypeOfflineRecharge, filter.ShopID)
// 退款审批的店铺归属要同时覆盖尝试模式business_id 指向尝试记录与存量模式business_id 指向退款单)。
conditions = append(conditions, `(business_type = ? AND (EXISTS (SELECT 1 FROM tb_refund_request_attempt a JOIN tb_refund_request r ON r.id = a.refund_id AND r.deleted_at IS NULL WHERE a.id = tb_approval_instance.business_id AND r.shop_id = ?) OR EXISTS (SELECT 1 FROM tb_refund_request r WHERE r.id = tb_approval_instance.business_id AND r.deleted_at IS NULL AND r.shop_id = ?))) OR (business_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_approval_instance.business_id AND ar.deleted_at IS NULL AND ar.shop_id = ?))`)
args = append(args, constants.ApprovalBusinessTypeRefund, filter.ShopID, filter.ShopID, constants.ApprovalBusinessTypeOfflineRecharge, filter.ShopID)
}
appendAccountActorCondition(&conditions, &args, filter, "submitter_account_id")
if filter.CorrelationID != "" {
@@ -1152,7 +1168,11 @@ func (q *Query) loadApprovalFinance(ctx context.Context, filter FinanceFilter, r
for _, row := range rows {
refsView := ledgerRefs(constants.AuditResourceApprovalInstance, strconv.FormatUint(uint64(row.ID), 10), strconv.FormatUint(uint64(row.ID), 10), row.SubmitterAccountID)
refsView.CorrelationID = stringPointer(row.CorrelationID)
refsView.ResourceRefs = append(refsView.ResourceRefs, approvalBusinessRefs(row)...)
businessRefs, err := approvalBusinessRefs(ctx, q.db, row)
if err != nil {
return nil, 0, err
}
refsView.ResourceRefs = append(refsView.ResourceRefs, businessRefs...)
nodes = append(nodes, FinanceTimelineNode{
RecordSource: constants.AuditRecordSourceApprovalInstance, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.StatusChangedAt,
Code: row.BusinessType, Title: "审批实例", Result: strconv.Itoa(row.Status), ResultName: constants.GetApprovalStatusName(row.Status),
@@ -1259,6 +1279,17 @@ func appendApprovalBusinessCondition(conditions *[]string, args *[]any, business
*args = append(*args, businessType, uintKeys(values))
}
// appendRefundApprovalBusinessCondition 关联按退款单过滤的退款审批实例。
// 业务标识在尝试模式下指向审批尝试记录、存量模式下指向退款申请,因此两种语义都要命中。
func appendRefundApprovalBusinessCondition(conditions *[]string, args *[]any, values map[uint]struct{}) {
if len(values) == 0 {
return
}
refundIDs := uintKeys(values)
*conditions = append(*conditions, refundApprovalBusinessCondition())
*args = append(*args, constants.ApprovalBusinessTypeRefund, refundIDs, refundIDs)
}
func appendAccountActorCondition(conditions *[]string, args *[]any, filter FinanceFilter, columns ...string) {
if filter.ActorKind != constants.AuditActorAccount || filter.ActorID == "" {
return
@@ -1354,18 +1385,27 @@ func paymentBusinessRefs(row model.Payment) []InvestigationResourceRef {
}
// approvalBusinessRefs 按审批实例声明的业务类型生成稳定跳转。
func approvalBusinessRefs(row model.ApprovalInstance) []InvestigationResourceRef {
//
// 退款审批的业务标识在尝试模式下指向审批尝试记录,直接用 business_id 跳转会指向错误资源,
// 因此这里经共享解析器还原真实退款单存量模式business_id 即退款单主键)由解析器兜底命中。
func approvalBusinessRefs(ctx context.Context, db *gorm.DB, row model.ApprovalInstance) ([]InvestigationResourceRef, error) {
resourceType := ""
resourceID := row.BusinessID
switch row.BusinessType {
case constants.ApprovalBusinessTypeRefund:
resourceType = constants.AuditResourceRefund
refundID, err := refundapproval.ResolveRefundIDInTx(ctx, db, row.BusinessID, row.ID)
if err != nil {
return nil, err
}
resourceID = refundID
case constants.ApprovalBusinessTypeOfflineRecharge:
resourceType = constants.AuditResourceAgentRecharge
}
if resourceType == "" {
return nil
return nil, nil
}
return []InvestigationResourceRef{financeResourceRef(resourceType, row.BusinessID, "")}
return []InvestigationResourceRef{financeResourceRef(resourceType, resourceID, "")}, nil
}
func authoritativeAmount(table, field string) FinanceAmountAuthority {