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

@@ -4,9 +4,12 @@ package refundapproval
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -21,6 +24,8 @@ type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
// Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识。
Attempt *model.RefundRequestAttempt
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
@@ -29,6 +34,12 @@ type ApplicationAudit struct {
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
Attempt *model.RefundRequestAttempt
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
// 使同一次重提的审计事件在该尝试上保持幂等。
Action string
EventID string
}
// AuditWriter 接收退款申请事务内审计事实。
@@ -39,11 +50,16 @@ type AuditWriter interface {
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
SubmitterName string
ApprovalStatus int
}
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
//
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
// 其既有 approval_instance_id 语义与唯一约束保持不变。
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
@@ -55,8 +71,8 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
@@ -91,12 +107,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
var result *CreateResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, refundID).Error; err != nil {
@@ -113,47 +125,61 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
if err := tx.WithContext(ctx).First(&currentOrder, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
attempt, err := buildAttempt(ctx, tx, &current, &currentOrder)
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: current.ID, SubmitterAccountID: current.Creator,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account,
})
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account, Attempt: attempt,
}); err != nil {
return err
}
result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
return nil
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return result, nil
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
if command.Refund == nil || command.Order == nil || command.Attempt == nil ||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -179,33 +205,34 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
}
var activeCount int64
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()).
Count(&activeCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
}
if activeCount > 0 {
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
return errors.New(errors.CodeConflict, "该订单已存在活动退款申请")
}
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
}
command.Attempt.RefundID = command.Refund.ID
if err := tx.WithContext(ctx).Create(command.Attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
BusinessID: command.Attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: command.Refund.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
if err := updateRefundLatest(ctx, tx, command.Refund, command.Attempt, reference.InstanceID); err != nil {
return err
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
@@ -214,13 +241,165 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: command.Attempt,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
return &CreateResult{Refund: command.Refund, Attempt: command.Attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
// ResubmitCommand 描述重提时的材料变更。
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收),
// Attempt 是本次新增的不可变审批尝试记录。
type ResubmitCommand struct {
Refund *model.RefundRequest
Attempt *model.RefundRequestAttempt
}
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
//
// 仅已拒绝、已退回或原路退款失败且无审批异常的申请可重提;已成功、待审批、原路处理中或
// 存在审批异常的申请返回状态冲突。每次重提新增不可变尝试记录与独立审批实例,
// 历史材料与审批结果不被覆盖,退款单只更新为最新尝试引用。
func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command ResubmitCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if refundID == 0 || command.Refund == nil || command.Attempt == nil || command.Refund.Creator == 0 {
return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整")
}
account, err := s.loadSubmitter(ctx, command.Refund.Creator)
if err != nil {
return nil, err
}
var created *CreateResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(refundID)).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请重提边界失败")
}
var current model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, refundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if !isResubmittable(&current) {
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
var order model.Order
if err := tx.WithContext(ctx).First(&order, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
// 材料已在调用方校验,这里把新值并入当前事实后冻结快照。
current.Method = command.Refund.Method
current.RequestedRefundAmount = command.Refund.RequestedRefundAmount
current.FrozenActualReceivedAmount = command.Refund.FrozenActualReceivedAmount
current.RefundReason = command.Refund.RefundReason
current.RefundVoucherKey = command.Refund.RefundVoucherKey
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
attempt, err := buildAttempt(ctx, tx, &current, &order)
if err != nil {
return err
}
attempt.SubmittedByAccountID = current.Creator
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&current, account)
if err != nil {
return err
}
// 同一事务内回写材料、回到待审批并创建新的审批实例。
updates := map[string]any{
"status": model.RefundStatusPending,
"method": current.Method,
"requested_refund_amount": current.RequestedRefundAmount,
"frozen_actual_received_amount": current.FrozenActualReceivedAmount,
"refund_reason": current.RefundReason,
"refund_voucher_key": current.RefundVoucherKey,
"customer_account_info": current.CustomerAccountInfo,
"failure_reason": "",
"failure_message": "",
"channel_refund_status": constants.RefundChannelStatusNone,
"reject_reason": "",
"processor_id": nil,
"processed_at": nil,
"updater": current.Creator,
"updated_at": time.Now().UTC(),
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
current.Status = model.RefundStatusPending
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateRefundLatest(ctx, tx, &current, attempt, reference.InstanceID); err != nil {
return err
}
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt,
Action: constants.AuditActionRefundResubmitted,
EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10),
}); err != nil {
return err
}
created = &CreateResult{Refund: &current, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。
func isResubmittable(refund *model.RefundRequest) bool {
if refund == nil || refund.AnomalyFlag != 0 {
return false
}
for _, status := range model.RefundResubmittableStatuses() {
if refund.Status == status {
return true
}
}
return false
}
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
@@ -234,6 +413,104 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
return &account, nil
}
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (*model.RefundRequestAttempt, error) {
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
if err != nil {
return nil, err
}
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order)
if err != nil {
return nil, err
}
return &model.RefundRequestAttempt{
RefundID: refund.ID,
AttemptNo: attemptNo,
Method: refund.Method,
RefundAmount: refund.RequestedRefundAmount,
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
RefundReason: refund.RefundReason,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
PackageUsageSnapshot: snapshot,
SubmittedByAccountID: refund.Creator,
}, nil
}
// nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。
func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("refund_id = ?", refundID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试序号失败")
}
return row.MaxAttemptNo + 1, nil
}
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) {
snapshot := map[string]any{
"order_type": order.OrderType,
"asset_identifier": order.AssetIdentifier,
}
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
var usage model.PackageUsage
if err := tx.WithContext(ctx).First(&usage, *refund.PackageUsageID).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐使用记录失败")
}
} else {
snapshot["package_usage"] = map[string]any{
"id": usage.ID, "package_id": usage.PackageID, "package_name": usage.PackageName,
"usage_type": usage.UsageType, "status": usage.Status,
"data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB,
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
}
}
}
encoded, err := sonic.Marshal(snapshot)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款套餐使用快照失败")
}
return datatypes.JSON(encoded), nil
}
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.RefundRequestAttempt, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.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, "退款审批尝试实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
// 既有 approval_instance_id 在该函数外单独回写,保持「首次接入企业微信审批的实例」语义不变。
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID,
"latest_approval_instance_id": instanceID,
"updated_at": time.Now().UTC(),
}
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败")
}
refund.LatestAttemptID = attempt.ID
refund.LatestApprovalInstanceID = instanceID
return nil
}
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
@@ -247,7 +524,7 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
constants.ApprovalFieldOrderNo: refund.OrderNo,
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
constants.ApprovalFieldAssetType: refund.OrderType,
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
constants.ApprovalFieldRefundReason: refund.RefundReason,

View File

@@ -0,0 +1,101 @@
package refundapproval
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ResolveRefundInTx 按审批业务标识解析出退款申请与本次审批尝试记录。
//
// 退款审批的业务标识在审批尝试模式下取尝试记录主键;本能力上线前的存量申请取退款申请主键。
// 尝试记录与退款申请来自两个独立序列,必然存在同值,因此不能只按 businessID 判定归属:
// 必须同时匹配 approval_instance_id才能唯一确定是尝试记录还是退款申请。
//
// 解析顺序固定为「尝试记录优先、退款申请兜底」:
// 1. tb_refund_request_attempt 中 id = businessID 且 approval_instance_id = instanceID
// 2. tb_refund_request 中 id = businessID 且 approval_instance_id = instanceID
// 3. 两者均不匹配返回稳定冲突错误,绝不回落到任一候选业务单。
//
// attempt 在存量兼容路径下为 nil。
func ResolveRefundInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
if tx == nil || businessID == 0 || instanceID == 0 {
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
}
var attempt model.RefundRequestAttempt
err := tx.WithContext(ctx).
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
First(&attempt).Error
switch {
case err == nil:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
return &refund, &attempt, nil
case err != gorm.ErrRecordNotFound:
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
}
var refund model.RefundRequest
err = tx.WithContext(ctx).
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
First(&refund).Error
switch {
case err == nil:
return &refund, nil, nil
case err == gorm.ErrRecordNotFound:
return nil, nil, errors.New(errors.CodeConflict, "退款申请的关联审批实例不一致")
default:
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
}
// ResolveRefundIDInTx 只解析退款申请标识,供审计资源构造与查询关联使用。
func ResolveRefundIDInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (uint, error) {
refund, _, err := ResolveRefundInTx(ctx, tx, businessID, instanceID)
if err != nil {
return 0, err
}
return refund.ID, nil
}
// ResolveRefundForApprovalRequestInTx 解析「审批申请已建立但审批实例尚未回写到业务记录」时刻的业务归属。
//
// 通用审批创建用例在同一事务内先写审批实例并写审批申请审计,业务侧随后才把实例 ID 回写到
// 审批尝试记录。该审计时刻尝试记录已存在但其 approval_instance_id 仍为空,因此按实例一致性
// 校验的常规解析必然不命中。本函数只承认这一种在途形态:
//
// attempt.id = businessID AND attempt.approval_instance_id IS NULL
//
// 其余情况一律返回不存在,由调用方按常规解析的错误失败关闭,不得放宽为任意未回写记录。
func ResolveRefundForApprovalRequestInTx(ctx context.Context, tx *gorm.DB, businessID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
if tx == nil || businessID == 0 {
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
}
var attempt model.RefundRequestAttempt
err := tx.WithContext(ctx).
Where("id = ? AND approval_instance_id IS NULL", businessID).
First(&attempt).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeNotFound, "退款审批尝试记录未回写审批实例")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询在途退款审批尝试记录失败")
}
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
}
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
}
return &refund, &attempt, nil
}

View File

@@ -0,0 +1,32 @@
package refundchannel
import (
"context"
stderrors "errors"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AuditWriter 写退款渠道调用与恢复的可审计事实。
// 实现必须与业务更新在同一事务内写入,且摘要不得包含凭证或渠道报文原文。
type AuditWriter interface {
WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action string, message string) error
}
// CompletionNotifier 在渠道明确退款成功时补写退款完成通知事实。
// 通知载荷由退款能力拥有,本包只负责在正确的时点与事务内触发。
type CompletionNotifier interface {
AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error
}
// appErrorCode 读取应用错误码;非应用错误返回 0。
func appErrorCode(err error) int {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr.Code
}
return 0
}

View File

@@ -0,0 +1,75 @@
package refundchannel
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// EventRefundChannelRefund 是退款进入渠道原路处理中后的执行事件。
const EventRefundChannelRefund = "refund.channel.refund.requested"
// refundChannelPayloadVersion 是渠道原路退款事件的载荷版本。
const refundChannelPayloadVersion = 1
// Payload 是渠道原路退款事件的载荷。
type Payload struct {
RefundID uint `json:"refund_id"`
OrderID uint `json:"order_id"`
}
// AppendRefundChannelRefund 在企微通过事务内幂等写入渠道原路退款执行事件。
// 同一退款申请使用稳定事件 ID重复投递不会重复创建事实。
func AppendRefundChannelRefund(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, refundID, orderID uint) error {
if repository == nil {
return gorm.ErrInvalidDB
}
value := strconv.FormatUint(uint64(refundID), 10)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(EventRefundChannelRefund+":", value),
EventType: EventRefundChannelRefund,
PayloadVersion: refundChannelPayloadVersion,
AggregateType: "refund", AggregateID: value,
ResourceType: "refund", ResourceID: value,
BusinessKey: EventRefundChannelRefund + ":" + value,
Payload: Payload{RefundID: refundID, OrderID: orderID},
})
return err
}
// Consumer 把渠道原路退款事件转成一次性资金动作。
type Consumer struct {
service *Service
}
// NewConsumer 创建渠道原路退款事件消费者。
func NewConsumer(service *Service) *Consumer {
return &Consumer{service: service}
}
// Consume 幂等执行渠道原路退款;重复投递由退款申请状态与渠道请求号共同兜住。
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
var payload Payload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return outbox.Permanent(err)
}
if envelope.EventType != EventRefundChannelRefund ||
envelope.PayloadVersion != refundChannelPayloadVersion || payload.RefundID == 0 {
return outbox.Permanent(gorm.ErrInvalidData)
}
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款执行能力未配置")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
return c.service.Execute(ctx, payload.RefundID)
}
// 编译期断言:渠道原路退款消费者满足公共 Outbox 的消费边界。
var _ outbox.EventConsumer = (*Consumer)(nil)

View File

@@ -0,0 +1,71 @@
package refundchannel
import (
"crypto/rand"
"strconv"
"strings"
"time"
)
// 渠道退款请求号生成规则参数。
const (
// channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。
channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。
channelRefundRequestNoRandomLen = 18
// channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。
channelRefundRequestNoLength = 30
// channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0超过取前 4 位。
channelRefundRequestNoPrefixLen = 4
)
// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。
var shanghaiLocation = time.FixedZone("CST", 8*3600)
// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。
//
// 规则与富友流水号完全一致(本包不引入渠道 SDK因此在此独立实现同一规则
// 前缀规整为 4 位(不足左侧补 0超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母
// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传
// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。
func BuildChannelRefundRequestNo(prefix string, now time.Time) string {
var builder strings.Builder
builder.Grow(channelRefundRequestNoLength)
builder.WriteString(normalizeChannelRefundPrefix(prefix))
builder.WriteString(now.In(shanghaiLocation).Format("20060102"))
buffer := make([]byte, channelRefundRequestNoRandomLen)
if _, err := rand.Read(buffer); err != nil {
// 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。
builder.WriteString(fallbackRandomSegment(now))
return builder.String()
}
for _, value := range buffer {
builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)])
}
return builder.String()
}
// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0超过取前 4 位。
func normalizeChannelRefundPrefix(prefix string) string {
normalized := strings.TrimSpace(prefix)
if len(normalized) >= channelRefundRequestNoPrefixLen {
return normalized[:channelRefundRequestNoPrefixLen]
}
return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized
}
// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。
func fallbackRandomSegment(now time.Time) string {
segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36))
segment = strings.Map(func(char rune) rune {
if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') {
return char
}
return 'X'
}, segment)
if len(segment) >= channelRefundRequestNoRandomLen {
return segment[:channelRefundRequestNoRandomLen]
}
return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment))
}

View File

@@ -0,0 +1,193 @@
package refundchannel
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Stats 是一次恢复扫描的可观察结果。
//
// Scanned 为本次扫描到的申请数Confirmed 为回填为渠道明确成功的申请数;
// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口
// 超期后终止本次渠道执行Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败);
// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。
type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int }
// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) {
stats := Stats{}
if err := s.requireReady(); err != nil {
return stats, err
}
var refunds []model.RefundRequest
if err := s.db.WithContext(ctx).
// 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。
Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?",
model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0).
Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil {
return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败")
}
stats.Scanned = len(refunds)
if len(refunds) == 0 {
return stats, nil
}
payments, err := s.loadPaidPayments(ctx, refunds)
if err != nil {
return stats, err
}
now := s.now().UTC()
var firstErr error
for index := range refunds {
if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil {
stats.Skipped++
s.logger.Warn("渠道原路退款恢复单条处理失败",
zap.Uint("refund_id", refunds[index].ID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。
func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error {
target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID])
if err != nil {
return err
}
if failureReason != "" {
// 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。
stats.Pending++
s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询",
zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason))
return nil
}
if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window {
return s.flagQueryWindowExpired(ctx, refund, reason, now, stats)
}
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
defer cancel()
result, callErr := s.refunder.Query(callCtx, target)
if callErr != nil {
// 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。
stats.Pending++
return nil
}
applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now)
if err != nil {
return err
}
if !applied {
stats.Skipped++
return nil
}
switch result.State {
case StateSuccess:
stats.Confirmed++
case StateFailed:
stats.Failed++
default:
stats.Pending++
}
return nil
}
// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。
// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。
//
// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束;
// - 微信 v2受理响应不含退款状态、渠道侧无查询时限此处按本地阈值放弃轮询并转人工
// 避免一笔未知结果被无限重试。
func queryWindowPolicy(providerType string) (time.Duration, string) {
switch providerType {
case model.ProviderTypeFuiou:
return fuiouQueryWindow, anomalyReasonFuiouQueryWindow
case model.ProviderTypeWechatV2:
return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow
default:
return 0, ""
}
}
// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。
//
// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的
// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。
// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定),
// 而 status 只表达该尝试的渠道路径已终止。
//
// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次
// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。
func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error {
stats.Failed++
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0",
refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing).
// UpdateColumns 不隐式推进 updated_at窗口起算点必须保留在进入原路处理中的时刻
// 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。
UpdateColumns(map[string]any{
"status": model.RefundStatusChannelFailed,
"channel_refund_status": constants.RefundChannelStatusFailed,
"failure_reason": constants.RefundFailureTimeoutUnknown,
"anomaly_flag": 1,
"anomaly_reason": reason,
})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败")
}
if updated.RowsAffected != 1 {
return nil
}
refund.Status = model.RefundStatusChannelFailed
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
refund.FailureReason = constants.RefundFailureTimeoutUnknown
refund.AnomalyFlag = 1
refund.AnomalyReason = reason
return s.audit.WriteRefundChannelResult(ctx, tx, refund,
constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对")
})
if err != nil {
if appErrorCode(err) != 0 {
return err
}
return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败")
}
return nil
}
// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。
// 结果未知的回写不会推进 updated_at因此窗口始终从进入原路处理中的时点起算。
func refundWindowStart(refund *model.RefundRequest) time.Time {
if refund.ChannelRefundedAt != nil {
return refund.ChannelRefundedAt.UTC()
}
return refund.UpdatedAt.UTC()
}
// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。
func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) {
orderIDs := make([]uint, 0, len(refunds))
for index := range refunds {
orderIDs = append(orderIDs, refunds[index].OrderID)
}
var payments []model.Payment
if err := s.db.WithContext(ctx).
Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id ASC").Find(&payments).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败")
}
latest := make(map[uint]*model.Payment, len(payments))
for index := range payments {
latest[payments[index].OrderID] = &payments[index]
}
return latest, nil
}

View File

@@ -0,0 +1,701 @@
// Package refundchannel 执行与恢复渠道原路退款。
//
// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、
// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的
// Refunder 实现,本包不依赖任何渠道 SDK也绝不在数据库事务内发起渠道调用。
package refundchannel
import (
"context"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// State 是渠道调用的稳定结果状态。
type State string
const (
StateSuccess State = "success" // 渠道明确成功
StateFailed State = "failed" // 渠道明确失败
StateUnknown State = "unknown" // 超时或结果未确认,可恢复
)
// 渠道原路退款的固定运行参数。
const (
// recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。
recoveryBatchSize = 50
// fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。
fuiouQueryWindow = 72 * time.Hour
// wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。
// 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限,
// 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。
wechatV2QueryWindow = 7 * 24 * time.Hour
// channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。
channelCallTimeout = 30 * time.Second
// fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。
// 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK因此在此固定回传该冻结值。
fuiouOrderTypeWechat = "WECHAT"
// anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。
anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对"
// anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。
anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对"
// failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。
failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复"
// failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。
failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款"
// failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。
failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款"
// failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。
failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款"
// failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。
failureMessageMaxRunes = 480
// providerTypeAlipay 是支付宝商户的 provider_type 取值model 未定义该常量,
// 取值与商户凭证管理保持的 "alipay" 完全一致。
providerTypeAlipay = "alipay"
)
// Target 是执行一次渠道原路退款所需的全部冻结事实。
type Target struct {
RefundID uint
RefundNo string
OrderID uint
OrderNo string
ProviderType string // model.ProviderType*
Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志
PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no
ChannelTradeNo string // 原支付单渠道交易流水
ChannelOrderType string // 富友原交易 order_type
PaidAt *time.Time
PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值
RefundAmount int64
FrozenActualReceivedAmount int64
ChannelRefundRequestNo string
}
// Result 是渠道调用或查询的映射结果。
type Result struct {
State State
ChannelRefundNo string // 渠道退款流水号
ChannelRefundAmount int64 // 渠道退款金额(分)
SettledAt string // 渠道结算日期原文,可空
FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值
FailureMessage string // 安全摘要,不得含凭证或报文原文
}
// Refunder 是渠道原路退款 Port由基础设施层按服务商类型实现。
type Refunder interface {
// Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。
Refund(ctx context.Context, target Target) (Result, error)
// Query 只查询渠道退款状态,不得发起资金动作。
Query(ctx context.Context, target Target) (Result, error)
}
// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。
type MerchantLoader interface {
LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error)
LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error)
}
// Service 执行与恢复原路退款。
type Service struct {
db *gorm.DB
loader MerchantLoader
refunder Refunder
audit AuditWriter
notifier CompletionNotifier
logger *zap.Logger
now func() time.Time
}
// NewService 创建渠道原路退款用例。
func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service {
return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now}
}
// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。
func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service {
if s == nil {
return s
}
s.notifier = notifier
return s
}
// SetLogger 注入渠道原路退款运行日志。
func (s *Service) SetLogger(logger *zap.Logger) *Service {
if s == nil {
return s
}
if logger == nil {
logger = zap.NewNop()
}
s.logger = logger
return s
}
// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。
//
// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用,
// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。
func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
if s == nil || tx == nil || refund == nil || refund.ID == 0 {
return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效")
}
if refund.Method != constants.RefundMethodOriginalRoute {
return nil
}
requestNo := ""
if attempt != nil {
requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo)
}
if requestNo == "" {
// 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。
requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now())
}
now := s.now().UTC()
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusChannelProcessing,
"channel_refund_status": constants.RefundChannelStatusProcessing,
"channel_refund_request_no": requestNo,
"updated_at": now,
})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败")
}
if updated.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中")
}
if attempt != nil && attempt.ID != 0 {
write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
Where("id = ?", attempt.ID).
Update("channel_refund_request_no", requestNo)
if write.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败")
}
if write.RowsAffected != 1 {
s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID))
}
attempt.ChannelRefundRequestNo = requestNo
}
if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败")
}
refund.Status = model.RefundStatusChannelProcessing
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
refund.ChannelRefundRequestNo = requestNo
return nil
}
// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。
//
// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证:
// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund
// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放),
// 此时只查询渠道结果并回填,绝不再次提交资金动作。
func (s *Service) Execute(ctx context.Context, refundID uint) error {
if err := s.requireReady(); err != nil {
return err
}
if refundID == 0 {
return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识")
}
facts, proceed, err := s.loadExecutionFacts(ctx, refundID)
if err != nil {
return err
}
if !proceed {
return nil
}
payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID)
if err != nil {
return err
}
target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment)
if err != nil {
return err
}
now := s.now().UTC()
if failureReason != "" {
// 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。
if _, err := s.writeback(ctx, facts.refund, target, Result{
State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage,
}, constants.AuditActionRefundChannelCalled, now); err != nil {
return err
}
return nil
}
// 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。
claimed, err := s.claimChannelSubmission(ctx, refundID, now)
if err != nil {
return err
}
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
defer cancel()
if !claimed {
// 已提交过:只查询渠道结果,绝不再次提交资金动作。
result, callErr := s.refunder.Query(callCtx, target)
if callErr != nil {
// 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。
return nil
}
s.logger.Info("渠道退款请求已提交过,本次仅查询结果",
zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo))
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now)
return err
}
result, callErr := s.refunder.Refund(callCtx, target)
if callErr != nil {
// 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。
result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown}
}
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now)
return err
}
// claimChannelSubmission 以条件更新认领本次渠道退款提交权。
//
// 返回 true 表示调用方获得提交权、可以调用渠道退款接口false 表示该尝试在此之前
// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中
// 为谓词,因此并发执行也至多有一次认领成功。
func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) {
claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND channel_submitted_at IS NULL",
refundID, model.RefundStatusChannelProcessing).
UpdateColumn("channel_submitted_at", now)
if claimed.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败")
}
return claimed.RowsAffected == 1, nil
}
// executionFacts 是一次渠道执行所需的本地冻结事实。
type executionFacts struct {
refund *model.RefundRequest
attempt *model.RefundRequestAttempt
}
// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。
// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。
func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) {
facts := &executionFacts{}
proceed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refund model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
facts.refund = &refund
if refund.Method != constants.RefundMethodOriginalRoute {
s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method))
return nil
}
// 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。
if refund.Status != model.RefundStatusChannelProcessing ||
refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded {
return nil
}
attempt, err := loadAttempt(ctx, tx, &refund)
if err != nil {
return err
}
facts.attempt = attempt
proceed = true
return nil
})
if err != nil {
return nil, false, err
}
return facts, proceed, nil
}
// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。
func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) {
var attempt model.RefundRequestAttempt
query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{})
if refund.LatestAttemptID != 0 {
if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
}
return &attempt, nil
}
if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
}
return &attempt, nil
}
// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。
func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) {
var payment model.Payment
if err := s.db.WithContext(ctx).
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id DESC").First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败")
}
return &payment, nil
}
// buildTarget 在事务外组装渠道调用目标。
// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。
func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) {
target := Target{
RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo,
RefundAmount: resolveRefundAmount(refund, attempt),
FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt),
ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt),
}
if target.ChannelRefundRequestNo == "" {
// 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。
return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil
}
if payment == nil {
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
}
target.PaymentNo = strings.TrimSpace(payment.PaymentNo)
target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
target.PaidAt = payment.PaidAt
target.PaidAmount = payment.Amount
if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 ||
target.RefundAmount > target.FrozenActualReceivedAmount {
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
}
config, providerType, err := s.loadChannelConfig(ctx, payment)
if err != nil {
if !credentialFailure(err) {
return target, "", "", err
}
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
}
if !credentialComplete(providerType, config) {
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
}
target.ProviderType = providerType
target.Config = config
if providerType == model.ProviderTypeFuiou {
target.ChannelOrderType = fuiouOrderTypeWechat
}
return target, "", "", nil
}
// loadChannelConfig 加载原支付单实际收款商户的当前凭证。
// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权merchant_id 为空仅表示数据留存期内的
// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。
func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) {
if payment.MerchantID != nil {
merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, "", err
}
if merchant == nil {
return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在")
}
// 仅微信直连v3/v2需要全局微信授权配置中的 AppID其他服务商传 nil 避免无谓失败。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.loader.LoadAuthorization(ctx)
if err != nil {
return nil, "", err
}
}
config, err := merchantpayment.MerchantConfig(merchant, authorization)
if err != nil {
return nil, "", err
}
return config, merchant.ProviderType, nil
}
if payment.PaymentConfigID == nil {
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
var legacy model.WechatConfig
if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
}
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
}
return &legacy, legacy.ProviderType, nil
}
// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。
// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。
// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。
//
// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与
// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund请求需要双向
// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为
// 不可用,补录证书后即可用。判定结果不提供人工开关。
func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {
if config == nil {
return failureMessageCredential
}
switch providerType {
case model.ProviderTypeWechat:
if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent,
config.WxKeyContent, config.WxSerialNo) {
return "冻结微信商户退款凭证不完整"
}
case model.ProviderTypeWechatV2:
// v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。
if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) {
return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)"
}
case model.ProviderTypeFuiou:
if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey,
config.FyPublicKey, config.FyAPIURL) {
return "冻结富友商户退款凭证不完整"
}
case providerTypeAlipay:
if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) {
return "冻结支付宝商户退款凭证不完整"
}
default:
return "冻结商户不支持原路退款"
}
return ""
}
// credentialComplete 判断该服务商类型的退款必需凭证是否完整。
func credentialComplete(providerType string, config *model.WechatConfig) bool {
return RefundCredentialIssue(providerType, config) == ""
}
func completeFields(values ...string) bool {
for _, value := range values {
if strings.TrimSpace(value) == "" {
return false
}
}
return true
}
// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。
func credentialFailure(err error) bool {
switch appErrorCode(err) {
case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable:
return true
default:
return false
}
}
// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。
// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号,
// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。
func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string {
if attempt != nil {
if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" {
return requestNo
}
}
return strings.TrimSpace(refund.ChannelRefundRequestNo)
}
// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。
func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 {
return *refund.ApprovedRefundAmount
}
if refund.RequestedRefundAmount > 0 {
return refund.RequestedRefundAmount
}
if attempt != nil {
return attempt.RefundAmount
}
return 0
}
// resolveFrozenAmount 取本次原路退款的冻结实收金额。
func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
if refund.FrozenActualReceivedAmount > 0 {
return refund.FrozenActualReceivedAmount
}
if attempt != nil {
return attempt.FrozenActualReceivedAmount
}
return 0
}
// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。
// applied 为 false 表示记录已被并发推进,本次不改动任何状态。
func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
applied := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
applied, err = s.applyResult(ctx, tx, refund, target, result, action, now)
return err
})
if err != nil {
return false, err
}
if !applied {
s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进",
zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State)))
}
return applied, nil
}
// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。
// 所有状态流转都以 status = 原路处理中 为谓词RowsAffected 为 0 表示并发已推进该记录。
func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
update := map[string]any{}
syncRefund := func() {}
orderRefunded := false
reason := result.FailureReason
message := ""
switch result.State {
case StateSuccess:
amount := result.ChannelRefundAmount
if amount <= 0 {
amount = target.RefundAmount
}
update["status"] = model.RefundStatusApproved
update["channel_refund_status"] = constants.RefundChannelStatusSucceeded
update["channel_refund_no"] = result.ChannelRefundNo
update["channel_refund_amount"] = amount
update["channel_refunded_at"] = now
update["processed_at"] = now
update["failure_reason"] = ""
update["failure_message"] = ""
update["updated_at"] = now
orderRefunded = true
message = "渠道原路退款明确成功"
syncRefund = func() {
refund.Status = model.RefundStatusApproved
refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded
refund.ChannelRefundNo = result.ChannelRefundNo
refund.ChannelRefundAmount = amount
refund.ChannelRefundedAt = &now
refund.ProcessedAt = &now
refund.FailureReason = ""
refund.FailureMessage = ""
}
case StateFailed:
if reason == "" {
reason = constants.RefundFailureChannelRejected
}
message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason)
failureMessage := safeMessage(result.FailureMessage, message)
update["status"] = model.RefundStatusChannelFailed
update["channel_refund_status"] = constants.RefundChannelStatusFailed
update["failure_reason"] = reason
update["failure_message"] = failureMessage
update["updated_at"] = now
syncRefund = func() {
refund.Status = model.RefundStatusChannelFailed
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
refund.FailureReason = reason
refund.FailureMessage = failureMessage
}
default:
// 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。
// 不修改 updated_at使富友查询窗口从进入原路处理中的时点起算。
reason = constants.RefundFailureTimeoutUnknown
message = "渠道原路退款结果未确认,保持处理中"
failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown)
update["channel_refund_status"] = constants.RefundChannelStatusProcessing
update["failure_reason"] = reason
update["failure_message"] = failureMessage
syncRefund = func() {
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
refund.FailureReason = reason
refund.FailureMessage = failureMessage
}
}
// UpdateColumns 不会隐式推进 updated_at结果未知时必须保留进入原路处理中的时点
// 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing).
UpdateColumns(update)
if updated.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败")
}
if updated.RowsAffected != 1 {
return false, nil
}
syncRefund()
if orderRefunded {
if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil {
return false, err
}
// 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同:
// 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。
if s.notifier != nil {
if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil {
return false, err
}
}
}
if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败")
}
return true, nil
}
// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。
// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。
func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error {
updated := tx.WithContext(ctx).Model(&model.Order{}).
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败")
}
if updated.RowsAffected == 1 {
return nil
}
var order model.Order
if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败")
}
if order.PaymentStatus != model.PaymentStatusRefunded {
s.logger.Warn("订单支付状态未置为已退款",
zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus))
}
return nil
}
// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。
func safeMessage(message, fallback string) string {
text := strings.TrimSpace(message)
if text == "" {
text = fallback
}
runes := []rune(text)
if len(runes) > failureMessageMaxRunes {
text = string(runes[:failureMessageMaxRunes])
}
return text
}
// requireReady 校验渠道原路退款的全部依赖已配置。
func (s *Service) requireReady() error {
if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置")
}
return nil
}

View File

@@ -13,6 +13,7 @@ import (
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
@@ -332,6 +333,16 @@ func initServices(s *stores, deps *Dependencies) *services {
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
refundService.SetLifecycleAudit(auditWriter)
// 渠道原路退款的登记与执行共用同一用例API 侧只登记待执行事实与可靠事件,
// 真正的渠道调用由 Worker 消费该事件执行。
refundService.SetChannelRefundService(
refundchannelApp.NewService(
deps.DB,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger),
auditWriter,
).SetLogger(deps.Logger).SetCompletionNotifier(refundService),
)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
exchangeService.SetAccessAudit(auditWriter)

View File

@@ -39,7 +39,9 @@ func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int,
func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注",
"实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式",
"冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记",
"退款原因", "审批备注",
"审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证",
}, nil
}
@@ -61,6 +63,13 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
r.requested_refund_amount,
r.approved_refund_amount,
r.status,
r.method,
r.frozen_actual_received_amount,
r.channel_refund_status,
r.channel_refund_no,
r.channel_refund_amount,
r.failure_reason,
r.anomaly_flag,
r.refund_reason,
r.remark,
r.commission_deducted,
@@ -109,6 +118,13 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse
formatMoneyYuan(item.RequestedRefundAmount),
formatOptionalMoneyYuan(item.ApprovedRefundAmount),
constants.GetRefundStatusName(item.Status),
constants.RefundMethodName(item.Method),
formatMoneyYuan(item.FrozenActualReceivedAmount),
constants.RefundChannelStatusName(item.ChannelRefundStatus),
item.ChannelRefundNo,
formatMoneyYuan(item.ChannelRefundAmount),
constants.RefundFailureReasonName(item.FailureReason),
formatRefundAnomalyFlag(item.AnomalyFlag),
item.RefundReason,
item.Remark,
formatRefundApprovalSource(item.ApprovalProvider),
@@ -145,28 +161,35 @@ func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *go
}
type refundExportRow struct {
RefundNo string `gorm:"column:refund_no"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
OriginalAmount *int64 `gorm:"column:original_amount"`
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
RefundableAmount *int64 `gorm:"column:refundable_amount"`
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
Status int `gorm:"column:status"`
RefundReason string `gorm:"column:refund_reason"`
Remark string `gorm:"column:remark"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
CommissionDeducted bool `gorm:"column:commission_deducted"`
AssetReset bool `gorm:"column:asset_reset"`
CreatedAt time.Time `gorm:"column:created_at"`
ProcessedAt *time.Time `gorm:"column:processed_at"`
SubmitterName string `gorm:"column:submitter_name"`
VoucherKeys string `gorm:"column:voucher_keys"`
RefundNo string `gorm:"column:refund_no"`
ShopName string `gorm:"column:shop_name"`
OrderNo string `gorm:"column:order_no"`
OrderType string `gorm:"column:order_type"`
AssetIdentifier string `gorm:"column:asset_identifier"`
PackageName string `gorm:"column:package_name"`
OriginalAmount *int64 `gorm:"column:original_amount"`
ActualReceivedAmount int64 `gorm:"column:actual_received_amount"`
RefundableAmount *int64 `gorm:"column:refundable_amount"`
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"`
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"`
Status int `gorm:"column:status"`
Method string `gorm:"column:method"`
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount"`
ChannelRefundStatus int `gorm:"column:channel_refund_status"`
ChannelRefundNo string `gorm:"column:channel_refund_no"`
ChannelRefundAmount int64 `gorm:"column:channel_refund_amount"`
FailureReason string `gorm:"column:failure_reason"`
AnomalyFlag int `gorm:"column:anomaly_flag"`
RefundReason string `gorm:"column:refund_reason"`
Remark string `gorm:"column:remark"`
ApprovalProvider *string `gorm:"column:approval_provider"`
ApprovalStatus *int `gorm:"column:approval_status"`
CommissionDeducted bool `gorm:"column:commission_deducted"`
AssetReset bool `gorm:"column:asset_reset"`
CreatedAt time.Time `gorm:"column:created_at"`
ProcessedAt *time.Time `gorm:"column:processed_at"`
SubmitterName string `gorm:"column:submitter_name"`
VoucherKeys string `gorm:"column:voucher_keys"`
}
func formatRefundAssetType(orderType string) string {
@@ -200,7 +223,19 @@ func formatRefundProcessingStatus(status int, commissionDeducted, assetReset boo
return "已完成"
}
return "处理中"
case model.RefundStatusChannelProcessing:
return "原路退款处理中"
case model.RefundStatusChannelFailed:
return "原路退款失败待人工处理"
default:
return "未知"
}
}
// formatRefundAnomalyFlag 将异常标记转为导出用中文描述。
func formatRefundAnomalyFlag(flag int) string {
if flag == 0 {
return "无异常"
}
return "有异常"
}

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -54,7 +55,7 @@ func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.Audi
if err != nil {
return nil, err
}
resources = append(resources, business)
resources = append(resources, business...)
resources = append(resources, approvalSubmitterResource(change))
seenIntegrationIDs := make(map[string]struct{}, len(change.IntegrationIDs))
for _, integrationID := range change.IntegrationIDs {
@@ -82,30 +83,66 @@ func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.Audi
return resources, nil
}
func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) (ResourceInput, error) {
// approvalBusinessResource 构造审批关联的业务资源。
//
// 退款审批的业务标识在尝试模式下指向退款审批尝试记录、存量模式下指向退款申请本身,
// 因此该分支统一经 refundapproval.ResolveRefundInTx 解析业务归属:主资源固定为解析出的
// 退款单,尝试记录存在时再追加一条引用资源,使两种语义在同一审计事件内都可追溯。
func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) ([]ResourceInput, error) {
id := strconv.FormatUint(uint64(businessID), 10)
switch businessType {
case constants.ApprovalBusinessTypeRefund:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联退款单失败")
refund, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, businessID, instanceID)
if err != nil {
// 提交事务内的「审批申请」审计先于 attachAttemptInstance 执行:此刻尝试记录已写入,
// 但 approval_instance_id 仍为空,共享解析器的实例一致性校验必然不命中。
// 这里只补一条尚未回写实例的尝试记录解析,其余不一致仍按解析器的冲突错误失败关闭。
refund, attempt, err = resolvePendingRefundAttempt(ctx, tx, businessID, err)
if err != nil {
return nil, err
}
}
return ResourceInput{
Type: constants.AuditResourceRefund, ID: &id, Key: refund.RefundNo, DisplayName: refund.RefundNo,
refundID := strconv.FormatUint(uint64(refund.ID), 10)
resources := []ResourceInput{{
Type: constants.AuditResourceRefund, ID: &refundID, Key: refund.RefundNo, DisplayName: refund.RefundNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID, "order_no": refund.OrderNo,
"order_type": refund.OrderType, "asset_identifier": refund.AssetIdentifier, "shop_id": refund.ShopID,
"requested_refund_amount": refund.RequestedRefundAmount, "actual_received_amount": refund.ActualReceivedAmount,
"method": refund.Method, "frozen_actual_received_amount": refund.FrozenActualReceivedAmount,
"latest_attempt_id": refund.LatestAttemptID, "channel_refund_status": refund.ChannelRefundStatus,
"failure_reason": refund.FailureReason, "anomaly_flag": refund.AnomalyFlag,
"approval_instance_id": instanceID, "status": refund.Status,
},
}, nil
}}
if attempt == nil {
return resources, nil
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
// 客户收款信息是自由文本、凭证是对象存储标识,两者都不进审计快照,只记录存在性与凭证数量。
resources = append(resources, ResourceInput{
Type: constants.AuditResourceRefundAttempt, ID: &attemptID, Key: attemptID, DisplayName: "退款审批尝试 " + attemptID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "refund_id": attempt.RefundID, "attempt_no": attempt.AttemptNo,
"method": attempt.Method, "refund_amount": attempt.RefundAmount,
"frozen_actual_received_amount": attempt.FrozenActualReceivedAmount,
"approval_instance_id": instanceID,
"channel_refund_request_no": attempt.ChannelRefundRequestNo,
"submitted_by_account_id": attempt.SubmittedByAccountID,
"customer_account_info_present": attempt.CustomerAccountInfo != "",
"customer_voucher_count": len(attempt.CustomerVoucherKeys),
"created_at": attempt.CreatedAt,
},
})
return resources, nil
case constants.ApprovalBusinessTypeOfflineRecharge:
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).First(&recharge, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败")
}
return ResourceInput{
return []ResourceInput{{
Type: constants.AuditResourceAgentRecharge, ID: &id, Key: recharge.RechargeNo, DisplayName: recharge.RechargeNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
@@ -114,13 +151,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"payment_method": recharge.PaymentMethod, "payment_channel": recharge.PaymentChannel,
"approval_instance_id": instanceID, "status": recharge.Status,
},
}, nil
}}, nil
case constants.ApprovalBusinessTypeEmployeeCollection:
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败")
}
return ResourceInput{
return []ResourceInput{{
Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &id,
Key: id, DisplayName: "审批尝试 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
@@ -128,13 +165,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo,
"paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID,
},
}, nil
}}, nil
case constants.ApprovalBusinessTypeAgentDistribution:
var registration model.AgentDistributionRegistration
if err := tx.WithContext(ctx).First(&registration, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败")
}
return ResourceInput{
return []ResourceInput{{
Type: constants.AuditResourceAgentDistributionRegistration, ID: &id,
Key: id, DisplayName: "扫码注册记录 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
@@ -142,13 +179,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"status": registration.Status, "approval_instance_id": instanceID,
},
}, nil
}}, nil
case constants.ApprovalBusinessTypeWithdrawalQualification:
var qualification model.WithdrawalQualification
if err := tx.WithContext(ctx).First(&qualification, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败")
}
return ResourceInput{
return []ResourceInput{{
Type: constants.AuditResourceWithdrawalQualification, ID: &id,
Key: id, DisplayName: "提现资料资格版本 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
@@ -157,13 +194,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"subject_type": qualification.SubjectType, "status": qualification.Status,
"approval_instance_id": instanceID,
},
}, nil
}}, nil
case constants.ApprovalBusinessTypeCommissionWithdrawal:
var attempt model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败")
}
return ResourceInput{
return []ResourceInput{{
Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id,
Key: id, DisplayName: "提现审批尝试 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
@@ -171,12 +208,26 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo,
"amount": attempt.Amount, "approval_instance_id": instanceID,
},
}, nil
}}, nil
default:
return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
return nil, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
}
}
// resolvePendingRefundAttempt 解析尚未回写审批实例的退款审批尝试记录。
//
// 只在共享解析器返回冲突时调用:审批申请审计与「尝试记录回写审批实例」同事务,
// 但审计先执行,因此 business_id 指向的尝试记录此刻 approval_instance_id 仍为空。
// 该形态由 refundapproval.ResolveRefundForApprovalRequestInTx 单独承认,其余情况
// 原样返回解析器的冲突错误,不放宽为任意未回写记录。
func resolvePendingRefundAttempt(ctx context.Context, tx *gorm.DB, businessID uint, cause error) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
refund, attempt, err := refundapproval.ResolveRefundForApprovalRequestInTx(ctx, tx, businessID)
if err != nil {
return nil, nil, cause
}
return refund, attempt, nil
}
func approvalSubmitterResource(change approvalapp.AuditChange) ResourceInput {
accountID := strconv.FormatUint(uint64(change.SubmitterAccountID), 10)
identity := map[string]any{"id": change.SubmitterAccountID}

View File

@@ -0,0 +1,105 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteRefundChannelResult 在调用方事务内写入渠道原路退款的调用、恢复与异常事实。
//
// 主资源固定为退款单,渠道原路退款事实作为引用资源;快照只记录业务标识、金额、状态与
// 结构化失败分类,不记录商户密钥、渠道报文或客户收款信息原文。摘要由调用方提供,
// 必须为不含凭证与渠道报文的简短中文说明。
//
// 渠道调用与恢复都由后台任务触发,因此操作者固定为系统任务;审计上下文提供了更具体的
// 操作者标识时沿用,避免任务未装配审计上下文时审计被拒绝并连带回滚业务事务。
func (w *Writer) WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action, message string) error {
if refund == nil || refund.ID == 0 || refund.RefundNo == "" {
return errors.New(errors.CodeInvalidParam, "退款渠道审计资源不完整")
}
summary := message
if summary == "" {
summary = "渠道原路退款结果更新"
}
refundID := strconv.FormatUint(uint64(refund.ID), 10)
actorID, actorName := constants.AuditActorIDRefundChannel, "退款渠道原路退款任务"
if linkage := auditcontext.From(ctx); linkage.ActorID != "" {
actorID = linkage.ActorID
if linkage.ActorName != "" {
actorName = linkage.ActorName
}
}
resources := refundChannelResources(refund, refundID, summary)
// 用 AppendAndGet 而非 Append渠道调用与恢复的审计属于「要求成功必达」的事实
// 必须与业务事实同事务原子提交,失败时向调用方显式返回错误。
if _, err := w.AppendAndGet(ctx, tx, AppendInput{
ActionCode: action, Summary: summary,
Actor: ActorInput{Kind: constants.AuditActorSystemTask, ID: actorID, Name: actorName},
Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: refund.RefundNo, Resources: resources,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款渠道审计失败")
}
return nil
}
// refundChannelResources 构造退款渠道审计资源:退款单为主资源,渠道原路退款事实为引用资源。
func refundChannelResources(refund *model.RefundRequest, refundID, summary string) []ResourceInput {
return []ResourceInput{
{
Type: constants.AuditResourceRefund, ID: &refundID, Key: refund.RefundNo, DisplayName: refund.RefundNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRefundTarget,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "method": refund.Method,
"frozen_actual_received_amount": refund.FrozenActualReceivedAmount,
"requested_refund_amount": refund.RequestedRefundAmount,
"approved_refund_amount": refund.ApprovedRefundAmount,
"status": refund.Status,
"channel_refund_status": refund.ChannelRefundStatus,
"channel_refund_no": refund.ChannelRefundNo,
"channel_refund_request_no": refund.ChannelRefundRequestNo,
"channel_refund_amount": refund.ChannelRefundAmount,
"failure_reason": refund.FailureReason,
"anomaly_flag": refund.AnomalyFlag,
},
AfterData: channelRefundState(refund),
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
},
{
Type: constants.AuditResourceRefundChannelRefund, ID: &refundID, Key: refund.RefundNo,
DisplayName: "渠道原路退款 " + refund.RefundNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundChannelRefund,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo,
"channel_refund_status": refund.ChannelRefundStatus,
"channel_refund_no": refund.ChannelRefundNo,
"channel_refund_request_no": refund.ChannelRefundRequestNo,
"channel_refund_amount": refund.ChannelRefundAmount,
"failure_reason": refund.FailureReason,
"anomaly_flag": refund.AnomalyFlag,
},
},
}
}
// channelRefundState 构造渠道原路退款状态快照,只包含状态与结构化失败分类。
func channelRefundState(refund *model.RefundRequest) map[string]any {
state := map[string]any{
"status": refund.Status,
"channel_refund_status": refund.ChannelRefundStatus,
"channel_refund_amount": refund.ChannelRefundAmount,
"failure_reason": refund.FailureReason,
"anomaly_flag": refund.AnomalyFlag,
}
if refund.ChannelRefundedAt != nil {
state["channel_refunded_at"] = *refund.ChannelRefundedAt
}
return state
}

View File

@@ -310,6 +310,14 @@ func NewRegistry() *Registry {
refundResubmitted := refundAction(constants.AuditActionRefundResubmitted, "重新提交退款申请", false)
refundCommissionInvalidated := refundSystemAction(constants.AuditActionRefundCommissionInvalidated, "退款失效佣金")
refundAssetProcessed := refundSystemAction(constants.AuditActionRefundAssetProcessed, "完成退款资产后处理")
// 退款审批尝试由后台账号提交与重提;终态与异常标记由企业微信审批消费任务写入(复用 refundAction 的 Worker 入口)。
refundAttemptSubmitted := refundAction(constants.AuditActionRefundAttemptSubmitted, "提交退款审批尝试", false)
refundAttemptApproved := refundAction(constants.AuditActionRefundAttemptApproved, "通过退款审批尝试", true)
refundAttemptClosed := refundAction(constants.AuditActionRefundAttemptClosed, "关闭退款审批尝试", true)
refundAnomalyFlagged := refundAction(constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常", true)
// 渠道原路退款的调用与恢复都由 Worker 触发,与既有的退款系统动作入口一致。
refundChannelCalled := refundSystemAction(constants.AuditActionRefundChannelCalled, "发起渠道原路退款")
refundChannelRecovered := refundSystemAction(constants.AuditActionRefundChannelRecovered, "恢复渠道原路退款结果")
approvalRequested := approvalAction(constants.AuditActionApprovalRequested, "提交通用审批申请", []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
})
@@ -631,6 +639,12 @@ func NewRegistry() *Registry {
constants.AuditActionRefundResubmitted: refundResubmitted,
constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated,
constants.AuditActionRefundAssetProcessed: refundAssetProcessed,
constants.AuditActionRefundAttemptSubmitted: refundAttemptSubmitted,
constants.AuditActionRefundAttemptApproved: refundAttemptApproved,
constants.AuditActionRefundAttemptClosed: refundAttemptClosed,
constants.AuditActionRefundAnomalyFlagged: refundAnomalyFlagged,
constants.AuditActionRefundChannelCalled: refundChannelCalled,
constants.AuditActionRefundChannelRecovered: refundChannelRecovered,
constants.AuditActionApprovalRequested: approvalRequested,
constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced,
constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered,
@@ -791,7 +805,16 @@ func NewRegistry() *Registry {
},
constants.AuditResourceRefund: {
Type: constants.AuditResourceRefund, Name: "退款单",
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "approval_instance_id", "status", "commission_deducted", "asset_reset"},
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "method", "frozen_actual_received_amount", "latest_attempt_id", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag", "approval_instance_id", "status", "commission_deducted", "asset_reset"},
},
// 退款审批尝试记录只登记冻结金额、方式与凭证数量,客户收款信息原文与凭证内容不进审计快照。
constants.AuditResourceRefundAttempt: {
Type: constants.AuditResourceRefundAttempt, Name: "退款审批尝试记录",
IdentityFields: []string{"id", "refund_id", "attempt_no", "method", "refund_amount", "frozen_actual_received_amount", "approval_instance_id", "channel_refund_request_no", "submitted_by_account_id", "customer_account_info_present", "customer_voucher_count", "created_at"},
},
constants.AuditResourceRefundChannelRefund: {
Type: constants.AuditResourceRefundChannelRefund, Name: "渠道原路退款事实",
IdentityFields: []string{"id", "refund_no", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag"},
},
constants.AuditResourceEnterprise: {
Type: constants.AuditResourceEnterprise, Name: "企业",

View File

@@ -0,0 +1,462 @@
package payment
import (
"context"
"strconv"
"strings"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
"go.uber.org/zap"
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// fuiouShanghaiLocation 富友要求按上海时区回传原交易日期。
var fuiouShanghaiLocation = time.FixedZone("CST", 8*3600)
// RefundAdapter 按冻结商户的服务商类型分派渠道原路退款与退款查询。
//
// 本适配器只做渠道原始调用与结果映射:不生成请求号、不写数据库、不写审计。
// 渠道适配按官方契约实现,且不向任何渠道传递退款结果通知地址——退款终态只由
// 同步响应与主动退款查询确认。
type RefundAdapter struct {
cache kernel.CacheInterface
logger *zap.Logger
}
// NewRefundAdapter 创建渠道原路退款适配器。
func NewRefundAdapter(cache kernel.CacheInterface, logger *zap.Logger) *RefundAdapter {
return &RefundAdapter{cache: cache, logger: logger}
}
var _ refundchannel.Refunder = (*RefundAdapter)(nil)
// Refund 按服务商类型提交一次渠道退款请求。
// 凭证取自 Target.Config冻结商户的当前凭证只用于构造渠道客户端绝不记录或持久化。
// 渠道明确表态时返回结果且不报错;传输或解析失败返回 error由调用方按结果未知处理。
func (a *RefundAdapter) Refund(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
switch target.ProviderType {
case model.ProviderTypeWechat:
return a.refundWechatV3(ctx, target)
case model.ProviderTypeWechatV2:
return a.refundWechatV2(ctx, target)
case model.ProviderTypeFuiou:
return a.refundFuiou(target)
case "alipay":
return a.refundAlipay(ctx, target)
default:
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureCredentialInvalid,
FailureMessage: "该商户支付渠道的退款凭证不完整,无法执行原路退款",
}, nil
}
}
// Query 按服务商类型查询渠道退款状态,绝不发起资金动作。
func (a *RefundAdapter) Query(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
switch target.ProviderType {
case model.ProviderTypeWechat:
service, err := a.wechatService(target.Config)
if err != nil {
return refundchannel.Result{}, err
}
result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo)
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatRefundResult(result), nil
case model.ProviderTypeWechatV2:
service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger)
if err != nil {
return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款查询配置不可用")
}
result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo)
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatV2RefundResult(result, target.RefundAmount), nil
case model.ProviderTypeFuiou:
client, err := newFuiouClient(target.Config, a.logger)
if err != nil {
return refundchannel.Result{}, err
}
resp, err := client.RefundQuery(target.ChannelRefundRequestNo)
if err != nil {
return refundchannel.Result{}, err
}
return mapFuiouRefundQuery(resp), nil
case "alipay":
result, err := alipay.QueryRefund(ctx, target.Config, alipay.RefundRequest{
OutTradeNo: target.PaymentNo,
TradeNo: target.ChannelTradeNo,
OutRequestNo: target.ChannelRefundRequestNo,
RefundAmount: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapAlipayRefundResult(result, target.RefundAmount), nil
default:
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureCredentialInvalid,
FailureMessage: "该商户支付渠道的退款凭证不完整,无法查询退款结果",
}, nil
}
}
// refundWechatV3 执行微信支付 v3 原路退款。
func (a *RefundAdapter) refundWechatV3(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
service, err := a.wechatService(target.Config)
if err != nil {
return refundchannel.Result{}, err
}
result, err := service.RefundOrder(ctx, wechatpay.RefundOrderRequest{
OutTradeNo: target.PaymentNo,
OutRefundNo: target.ChannelRefundRequestNo,
Amount: target.FrozenActualReceivedAmount,
Refund: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatRefundResult(result), nil
}
// wechatService 用商户当前凭证构造微信 v3 支付服务。
func (a *RefundAdapter) wechatService(config *model.WechatConfig) (*wechatpay.PaymentService, error) {
if config == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用")
}
app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信支付退款配置不可用")
}
return wechatpay.NewPaymentService(app, a.logger), nil
}
// refundFuiou 执行富友原路退款。
// 回传原交易日期才能覆盖 30 天以上的原交易;不回传仅支持 30 天内,因此始终回传。
func (a *RefundAdapter) refundFuiou(target refundchannel.Target) (refundchannel.Result, error) {
client, err := newFuiouClient(target.Config, a.logger)
if err != nil {
return refundchannel.Result{}, err
}
origiDt := ""
if target.PaidAt != nil {
origiDt = target.PaidAt.In(fuiouShanghaiLocation).Format("20060102")
}
resp, err := client.CommonRefund(&fuiou.CommonRefundRequest{
OrderType: target.ChannelOrderType,
MchntOrderNo: target.PaymentNo,
RefundOrderNo: target.ChannelRefundRequestNo,
TotalAmt: strconv.FormatInt(target.FrozenActualReceivedAmount, 10),
RefundAmt: strconv.FormatInt(target.RefundAmount, 10),
ReservedOrigiDt: origiDt,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapFuiouRefundResponse(resp), nil
}
// refundAlipay 执行支付宝原路退款。
func (a *RefundAdapter) refundAlipay(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
result, err := alipay.Refund(ctx, target.Config, alipay.RefundRequest{
OutTradeNo: target.PaymentNo,
TradeNo: target.ChannelTradeNo,
OutRequestNo: target.ChannelRefundRequestNo,
RefundAmount: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapAlipayRefundResult(result, target.RefundAmount), nil
}
// refundWechatV2 执行微信支付 v2 原路退款(双向证书接口)。
//
// 渠道契约规定申请接口的返回仅代表受理情况,退款是否成功必须由退款查询确认,
// 因此受理成功在此按「结果未知」返回,由恢复任务查询收敛;渠道明确拒绝时按失败分类返回。
func (a *RefundAdapter) refundWechatV2(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) {
service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger)
if err != nil {
return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款配置不可用")
}
result, err := service.RefundOrderV2(ctx, wechatpay.V2RefundRequest{
OutTradeNo: target.PaymentNo,
OutRefundNo: target.ChannelRefundRequestNo,
TotalFee: target.FrozenActualReceivedAmount,
RefundFee: target.RefundAmount,
})
if err != nil {
return refundchannel.Result{}, err
}
return mapWechatV2RefundResult(result, target.RefundAmount), nil
}
// mapWechatV2RefundResult 映射微信 v2 退款申请或退款查询结果。
// Accepted 为真表示渠道已受理但结果待查询确认,按结果未知处理;受理接口不返回退款状态。
func mapWechatV2RefundResult(result *wechatpay.V2RefundResult, requestedAmount int64) refundchannel.Result {
if result == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if result.Success {
amount := result.RefundFee
if amount <= 0 {
amount = requestedAmount
}
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: result.RefundID,
ChannelRefundAmount: amount,
}
}
if result.Accepted {
// 渠道已受理:终态必须由退款查询确认,不得在此标记成功。
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "微信 v2 已受理退款申请,等待退款查询确认结果",
}
}
if result.ErrCode != "" {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyWechatRejection(result.ErrCode),
FailureMessage: safeChannelMessage(result.ErrCode + " " + result.Message),
}
}
switch strings.ToUpper(strings.TrimSpace(result.Status)) {
case "REFUNDCLOSE", "CHANGE":
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureChannelRejected,
FailureMessage: safeChannelMessage(result.Message),
}
default:
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: safeChannelMessage(result.Message),
}
}
}
// mapWechatRefundResult 映射微信 v3 退款结果。
// 渠道业务错误码表示退款单未被受理,按错误码分类;处理中或状态为空表示渠道尚未给出终态,
// 保持结果未知交由恢复任务查询。
func mapWechatRefundResult(result *wechatpay.RefundOrderResult) refundchannel.Result {
if result == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if result.Success {
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: result.RefundID,
ChannelRefundAmount: result.RefundFee,
}
}
if result.ChannelCode != "" {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyWechatRejection(result.ChannelCode),
FailureMessage: safeChannelMessage(result.Message),
}
}
switch strings.ToUpper(strings.TrimSpace(result.Status)) {
case "CLOSED", "ABNORMAL":
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureChannelRejected,
FailureMessage: safeChannelMessage(result.Message),
}
default:
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: safeChannelMessage(result.Message),
}
}
}
// classifyWechatRejection 把微信拒绝类错误码映射为稳定失败分类。
// 系统异常与限频不代表渠道已明确拒绝,保持结果未知交恢复任务查询。
func classifyWechatRejection(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "SYSTEM_ERROR", "FREQUENCY_LIMITED", "RATELIMIT_EXCEED":
return constants.RefundFailureTimeoutUnknown
case "NOT_ENOUGH", "BALANCE_NOT_ENOUGH":
return constants.RefundFailureInsufficientBalance
case "NO_AUTH", "SIGN_ERROR":
return constants.RefundFailureCredentialInvalid
default:
return constants.RefundFailureChannelRejected
}
}
// mapFuiouRefundResponse 映射富友退款申请响应。
func mapFuiouRefundResponse(resp *fuiou.CommonRefundResponse) refundchannel.Result {
if resp == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if resp.ResultCode != fuiou.ResultCodeSuccess {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyFuiouRejection(resp.ResultMsg),
FailureMessage: safeChannelMessage(resp.ResultMsg),
}
}
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: resp.RefundId,
ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt),
SettledAt: resp.ReservedFySettleDt,
}
}
// mapFuiouRefundQuery 映射富友退款查询响应。
// trans_stat 只取 SUCCESS 或 PAYERRORPAYERROR 视为渠道明确失败,其余保持未知。
func mapFuiouRefundQuery(resp *fuiou.RefundQueryResponse) refundchannel.Result {
if resp == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if resp.ResultCode != fuiou.ResultCodeSuccess {
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyFuiouRejection(resp.ResultMsg),
FailureMessage: safeChannelMessage(resp.ResultMsg),
}
}
switch strings.ToUpper(strings.TrimSpace(resp.TransStat)) {
case fuiou.TransStatSuccess:
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: resp.RefundId,
ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt),
SettledAt: resp.ReservedFySettleDt,
}
case fuiou.TransStatPayError:
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: constants.RefundFailureChannelRejected,
FailureMessage: "富友退款交易状态为失败",
}
default:
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "富友退款交易仍在办理中",
}
}
}
// classifyFuiouRejection 按富友错误文案粗分类;无法识别时归为渠道明确拒绝。
func classifyFuiouRejection(message string) string {
text := strings.TrimSpace(message)
switch {
case strings.Contains(text, "余额") || strings.Contains(text, "不足"):
return constants.RefundFailureInsufficientBalance
case strings.Contains(text, "签名") || strings.Contains(text, "验签") || strings.Contains(text, "密钥"):
return constants.RefundFailureCredentialInvalid
default:
return constants.RefundFailureChannelRejected
}
}
// mapAlipayRefundResult 映射支付宝退款或退款查询结果。
func mapAlipayRefundResult(result *alipay.RefundResult, requestedAmount int64) refundchannel.Result {
if result == nil {
return refundchannel.Result{
State: refundchannel.StateUnknown,
FailureReason: constants.RefundFailureTimeoutUnknown,
FailureMessage: "渠道返回空响应,退款结果未知",
}
}
if result.Success {
amount := result.RefundFee
if amount == 0 {
amount = requestedAmount
}
return refundchannel.Result{
State: refundchannel.StateSuccess,
ChannelRefundNo: result.TradeNo,
ChannelRefundAmount: amount,
}
}
return refundchannel.Result{
State: refundchannel.StateFailed,
FailureReason: classifyAlipayRejection(result.Message),
FailureMessage: safeChannelMessage(result.Message),
}
}
// classifyAlipayRejection 按支付宝错误码粗分类;无法识别时归为渠道明确拒绝。
func classifyAlipayRejection(message string) string {
text := strings.ToUpper(strings.TrimSpace(message))
switch {
case strings.Contains(text, "BALANCE_NOT_ENOUGH") || strings.Contains(text, "余额不足"):
return constants.RefundFailureInsufficientBalance
case strings.Contains(text, "SIGN") || strings.Contains(text, "AUTH"):
return constants.RefundFailureCredentialInvalid
case strings.Contains(text, "SYSTEM_ERROR"):
return constants.RefundFailureTimeoutUnknown
default:
return constants.RefundFailureChannelRejected
}
}
// safeChannelMessage 截断渠道文案并去掉换行,避免把渠道报文原文写入失败摘要。
func safeChannelMessage(message string) string {
text := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(message, "\n", " "), "\r", " "))
if len(text) > 200 {
return text[:200]
}
return text
}
// parseFuiouAmount 把富友返回的金额字符串精确转换为分;空值或不可解析时按 0 处理。
func parseFuiouAmount(amount string) int64 {
text := strings.TrimSpace(amount)
if text == "" {
return 0
}
value, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return 0
}
return value
}
// newFuiouClient 用商户当前凭证构造富友客户端。
func newFuiouClient(config *model.WechatConfig, logger *zap.Logger) (*fuiou.Client, error) {
if config == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用")
}
return fuiou.NewClient(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyAPIURL,
config.FyNotifyURL, config.FyPrivateKey, config.FyPublicKey, logger)
}

View File

@@ -0,0 +1,39 @@
package payment
import (
"context"
"github.com/hibiken/asynq"
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundChannelRecoveryTaskHandler 执行渠道原路退款结果恢复任务。
type RefundChannelRecoveryTaskHandler struct {
service *refundchannel.Service
}
// NewRefundChannelRecoveryTaskHandler 创建渠道原路退款结果恢复任务 Handler。
func NewRefundChannelRecoveryTaskHandler(service *refundchannel.Service) *RefundChannelRecoveryTaskHandler {
return &RefundChannelRecoveryTaskHandler{service: service}
}
// Handle 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
func (h *RefundChannelRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款恢复任务未配置")
}
taskType := constants.TaskTypeRefundChannelRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "渠道原路退款结果恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.ProcessBatch(ctx)
return err
}

View File

@@ -3,9 +3,11 @@ package dto
// CreateRefundRequest 创建退款申请请求
type CreateRefundRequest struct {
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"`
Method string `json:"method" validate:"required,oneof=original_route customer_account asset_wallet agent_wallet" required:"true" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"`
CustomerAccountInfo string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表至少1个最多5个通过/storage/upload-url上传图片后获得"`
RefundVoucherKey []string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表最多5个,仅客户收款信息退款方式必填,通过/storage/upload-url上传图片后获得"`
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID可选"`
}
@@ -25,9 +27,11 @@ type RejectRefundRequest struct {
// 退款单被退回后,可修改部分字段后重新提交
type ResubmitRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"`
Method *string `json:"method" validate:"omitempty,oneof=original_route customer_account asset_wallet agent_wallet" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),不填沿用原有方式"`
CustomerAccountInfo *string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"`
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表重新提交时可替换历史记录缺失时必填最多5个"`
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表重新提交时可替换客户收款信息退款方式必填最多5个"`
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
}
@@ -48,7 +52,7 @@ type ReturnRefundRequest struct {
type RefundListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=6" minimum:"1" maximum:"6" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"`
OrderID *uint `json:"order_id" query:"order_id" validate:"omitempty" description:"关联订单ID"`
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识精确检索ICCID 或 设备虚拟号,非空时精确匹配)"`
@@ -56,40 +60,58 @@ type RefundListRequest struct {
// RefundResponse 退款申请详情响应
type RefundResponse struct {
ID uint `json:"id" description:"退款申请ID"`
RefundNo string `json:"refund_no" description:"退款单号"`
OrderID uint `json:"order_id" description:"关联订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID设备优先使用 VirtualNo缺失时使用 IMEI"`
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表最多5个"`
RefundReason string `json:"refund_reason" description:"退款原因"`
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
Remark string `json:"remark,omitempty" description:"审批备注"`
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道企业微信为wecom"`
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
ID uint `json:"id" description:"退款申请ID"`
RefundNo string `json:"refund_no" description:"退款单号"`
OrderID uint `json:"order_id" description:"关联订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID设备优先使用 VirtualNo缺失时使用 IMEI"`
AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"`
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表最多5个"`
RefundReason string `json:"refund_reason" description:"退款原因"`
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),空表示未接入方式的存量申请"`
MethodName string `json:"method_name" description:"退款方式中文名称"`
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"系统派生并冻结的权威实收金额(分),作为可退金额上限"`
CustomerAccountInfo string `json:"customer_account_info" description:"客户收款信息自由文本快照,仅客户收款信息退款方式有值"`
ChannelRefundStatus int `json:"channel_refund_status" description:"渠道原路退款状态 (0:未发起, 1:处理中, 2:已成功, 3:已失败)0 表示未发起渠道退款或不适用该退款方式"`
ChannelRefundStatusName string `json:"channel_refund_status_name" description:"渠道原路退款状态中文名称"`
ChannelRefundNo string `json:"channel_refund_no" description:"渠道退款流水号,渠道明确成功或失败后回填"`
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"渠道退款请求号快照,用于幂等与对账"`
ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"`
ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"`
FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"`
FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"`
FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"`
AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"`
AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"`
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID仅用于展示"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"`
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
Remark string `json:"remark,omitempty" description:"审批备注"`
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"`
SubmitterID uint `json:"submitter_id" description:"提交人账号ID"`
SubmitterName string `json:"submitter_name" description:"提交人账号名称"`
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"`
ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道企业微信为wecom"`
ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"`
Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// RefundListResponse 退款申请列表分页响应
@@ -99,3 +121,22 @@ type RefundListResponse struct {
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// RefundAttemptResponse 退款审批尝试响应,材料为本次提交的冻结快照。
type RefundAttemptResponse struct {
ID uint `json:"id" description:"审批尝试记录ID同时是通用审批业务ID"`
AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"`
Method string `json:"method" description:"本次冻结的退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"`
MethodName string `json:"method_name" description:"本次冻结的退款方式中文名称"`
RefundAmount int64 `json:"refund_amount" description:"本次提交冻结的申请退款金额(分)"`
FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"本次提交冻结的权威实收金额(分)"`
RefundReason string `json:"refund_reason" description:"本次提交的退款原因"`
CustomerAccountInfo string `json:"customer_account_info" description:"本次冻结的客户收款信息快照,非客户收款信息退款方式为空"`
CustomerVoucherKey []string `json:"customer_voucher_key" description:"客户收款凭证对象存储file_key列表仅返回对象键引用"`
ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"本次提交使用的渠道退款请求号快照,用于幂等与对账"`
SubmittedByAccountID uint `json:"submitted_by_account_id" description:"本次实际提交账号ID"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID0 表示未关联"`
ApprovalStatus *int `json:"approval_status,omitempty" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
CreatedAt string `json:"created_at" description:"创建时间"`
}

View File

@@ -28,6 +28,9 @@ type CreateWechatConfigRequest struct {
WxKeyContent string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"`
WxSerialNo string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"`
WxNotifyURL string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"`
// v2 退款接口为双向证书接口,支付与查单不依赖该证书。
WxClientCertContent string `json:"wx_client_cert_content" validate:"omitempty" description:"微信支付API客户端证书内容(PEM格式v2 退款双向证书所需)"`
WxClientKeyContent string `json:"wx_client_key_content" validate:"omitempty" description:"微信支付API客户端证书私钥内容(PEM格式v2 退款双向证书所需)"`
FyInsCd string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"`
FyMchntCd string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"`
@@ -69,6 +72,9 @@ type UpdateWechatConfigRequest struct {
WxKeyContent *string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"`
WxSerialNo *string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"`
WxNotifyURL *string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"`
// v2 退款接口为双向证书接口,支付与查单不依赖该证书。
WxClientCertContent *string `json:"wx_client_cert_content" validate:"omitempty" description:"微信支付API客户端证书内容(PEM格式v2 退款双向证书所需)"`
WxClientKeyContent *string `json:"wx_client_key_content" validate:"omitempty" description:"微信支付API客户端证书私钥内容(PEM格式v2 退款双向证书所需)"`
FyInsCd *string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"`
FyMchntCd *string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"`
@@ -119,13 +125,15 @@ type WechatConfigResponse struct {
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret(已脱敏)"`
WxMchID string `json:"wx_mch_id" description:"微信商户号"`
WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"`
WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"`
WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"`
WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"`
WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"`
WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"`
WxMchID string `json:"wx_mch_id" description:"微信商户号"`
WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"`
WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"`
WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"`
WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"`
WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"`
WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"`
WxClientCertContent string `json:"wx_client_cert_content" description:"微信支付API客户端证书内容(配置状态v2 退款双向证书所需)"`
WxClientKeyContent string `json:"wx_client_key_content" description:"微信支付API客户端证书私钥内容(配置状态v2 退款双向证书所需)"`
FyInsCd string `json:"fy_ins_cd" description:"富友机构号"`
FyMchntCd string `json:"fy_mchnt_cd" description:"富友商户号"`

View File

@@ -3,6 +3,7 @@ package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@@ -42,6 +43,30 @@ type RefundRequest struct {
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"`
Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"`
// 退款方式、冻结实收与当前材料快照
Method string `gorm:"column:method;type:varchar(20);not null;default:'';comment:退款方式,空表示未接入方式的存量申请" json:"method"`
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null;default:0;comment:冻结的权威实收金额(分)" json:"frozen_actual_received_amount"`
CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:'';comment:客户收款信息自由文本快照" json:"customer_account_info"`
// 审批尝试引用,仅用于列表与详情展示
LatestAttemptID uint `gorm:"column:latest_attempt_id;type:bigint;not null;default:0;comment:最新审批尝试记录ID仅用于展示" json:"latest_attempt_id"`
LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;type:bigint;not null;default:0;comment:最新通用审批实例ID仅用于展示" json:"latest_approval_instance_id"`
// 渠道原路退款结果与结构化失败分类
ChannelRefundStatus int `gorm:"column:channel_refund_status;type:smallint;not null;default:0;comment:渠道退款状态 0-未发起或不适用 1-处理中 2-明确成功 3-明确失败" json:"channel_refund_status"`
ChannelRefundNo string `gorm:"column:channel_refund_no;type:varchar(64);not null;default:'';comment:渠道退款流水号" json:"channel_refund_no"`
ChannelRefundRequestNo string `gorm:"column:channel_refund_request_no;type:varchar(64);not null;default:'';comment:渠道退款请求号快照" json:"channel_refund_request_no"`
ChannelRefundAmount int64 `gorm:"column:channel_refund_amount;type:bigint;not null;default:0;comment:渠道退款金额快照(分)" json:"channel_refund_amount"`
ChannelRefundedAt *time.Time `gorm:"column:channel_refunded_at;comment:渠道明确退款成功时间" json:"channel_refunded_at,omitempty"`
// ChannelSubmittedAt 非空表示该尝试已向渠道提交过退款请求;重投只允许查询,不得再次提交。
ChannelSubmittedAt *time.Time `gorm:"column:channel_submitted_at;comment:渠道退款请求提交认领时间" json:"channel_submitted_at,omitempty"`
FailureReason string `gorm:"column:failure_reason;type:varchar(32);not null;default:'';comment:结构化失败分类稳定编码" json:"failure_reason"`
FailureMessage string `gorm:"column:failure_message;type:varchar(500);not null;default:'';comment:失败安全摘要" json:"failure_message"`
// 正交异常标记:企业微信通过后撤销、渠道结果永久未知等情况转人工处理
AnomalyFlag int `gorm:"column:anomaly_flag;type:smallint;not null;default:0;comment:异常标记 0-无异常 1-有异常" json:"anomaly_flag"`
AnomalyReason string `gorm:"column:anomaly_reason;type:varchar(500);not null;default:'';comment:异常原因" json:"anomaly_reason"`
// 后处理标记
CommissionDeducted bool `gorm:"column:commission_deducted;not null;default:false;comment:佣金是否已回扣" json:"commission_deducted"`
AssetReset bool `gorm:"column:asset_reset;not null;default:false;comment:退款后资产处理是否完成" json:"asset_reset"`
@@ -52,10 +77,51 @@ func (RefundRequest) TableName() string {
return "tb_refund_request"
}
// RefundRequestAttempt 退款审批尝试记录。
// 每次提交或重提新增一条不可变记录,冻结当次方式、金额、冻结实收、原因、客户收款信息、
// 凭证与套餐使用快照;主键同时作为通用审批业务标识,使同一退款单每次提交持有独立审批实例。
type RefundRequestAttempt struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
RefundID uint `gorm:"column:refund_id;not null;index" json:"refund_id"`
AttemptNo int `gorm:"column:attempt_no;type:int;not null" json:"attempt_no"`
Method string `gorm:"column:method;type:varchar(20);not null" json:"method"`
RefundAmount int64 `gorm:"column:refund_amount;type:bigint;not null" json:"refund_amount"`
FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null" json:"frozen_actual_received_amount"`
RefundReason string `gorm:"column:refund_reason;type:text;not null;default:''" json:"refund_reason"`
CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:''" json:"customer_account_info"`
CustomerVoucherKeys StringJSONBArray `gorm:"column:customer_voucher_keys;type:jsonb;not null" json:"customer_voucher_keys"`
PackageUsageSnapshot datatypes.JSON `gorm:"column:package_usage_snapshot;type:jsonb;not null" json:"package_usage_snapshot"`
ChannelRefundRequestNo string `gorm:"column:channel_refund_request_no;type:varchar(64);not null;default:''" json:"channel_refund_request_no"`
SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;not null" json:"submitted_by_account_id"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 指定审批尝试记录表名。
func (RefundRequestAttempt) TableName() string {
return "tb_refund_request_attempt"
}
// 退款状态常量
const (
RefundStatusPending = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusApproved = 2 // 已通过(退款已完成)
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回
// RefundStatusChannelProcessing 表示企业微信已通过、原路渠道结果尚未确认。
RefundStatusChannelProcessing = 5
// RefundStatusChannelFailed 表示原路渠道明确失败或超时可恢复失败。
RefundStatusChannelFailed = 6
)
// RefundActiveStatuses 返回会阻止同一订单创建新退款申请的状态集合。
// 该集合必须与迁移中 uk_refund_request_active_order 的谓词保持一致。
func RefundActiveStatuses() []int {
return []int{RefundStatusPending, RefundStatusChannelProcessing, RefundStatusChannelFailed}
}
// RefundResubmittableStatuses 返回允许修改材料并重提的状态集合。
func RefundResubmittableStatuses() []int {
return []int{RefundStatusRejected, RefundStatusReturned, RefundStatusChannelFailed}
}

View File

@@ -43,6 +43,9 @@ type WechatConfig struct {
WxKeyContent string `gorm:"column:wx_key_content;type:text;default:'';comment:微信支付密钥内容" json:"wx_key_content"`
WxSerialNo string `gorm:"column:wx_serial_no;type:varchar(200);default:'';comment:微信证书序列号" json:"wx_serial_no"`
WxNotifyURL string `gorm:"column:wx_notify_url;type:varchar(500);default:'';comment:微信支付回调地址" json:"wx_notify_url"`
// v2 退款接口(/secapi/pay/refund请求需要双向证书仅 APIv2 密钥不足以退款。
WxClientCertContent string `gorm:"column:wx_client_cert_content;type:text;default:'';comment:微信支付API客户端证书内容apiclient_cert.pem" json:"wx_client_cert_content"`
WxClientKeyContent string `gorm:"column:wx_client_key_content;type:text;default:'';comment:微信支付API客户端证书私钥内容apiclient_key.pem" json:"wx_client_key_content"`
// 支付-富友
FyInsCd string `gorm:"column:fy_ins_cd;type:varchar(50);default:'';comment:富友机构号" json:"fy_ins_cd"`

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 {

View File

@@ -30,8 +30,9 @@ const paymentMerchantCredentialDoc = `商户凭证 credentials 为扁平 JSON
- payment_method=wechat、provider_type=wechat_v2wx_mch_id、wx_api_v2_key、wx_notify_url
- payment_method=wechat、provider_type=fuioufy_mchnt_cd、fy_ins_cd、fy_term_id、fy_private_key、fy_public_key、fy_api_url、fy_notify_url
- payment_method=alipay、provider_type=alipayali_app_id、ali_private_key、ali_public_key、ali_notify_url、ali_return_url
可选键:微信商户可附 wx_api_v2_key支付宝商户可附 ali_production布尔是否生产环境与 ali_pay_expire_minutes整数支付过期分钟数
可选键:微信商户可附 wx_api_v2_key微信 v2 商户可附 wx_client_cert_content、wx_client_key_contentAPI 客户端证书与私钥,内容为 PEM 文本);支付宝商户可附 ali_production布尔是否生产环境与 ali_pay_expire_minutes整数支付过期分钟数
除 ali_production 与 ali_pay_expire_minutes 外凭证值必须为字符串merchant_identity 必须分别等于 wx_mch_id、fy_mchnt_cd 或 ali_app_id。
微信 v2 商户的支付与查单只需要 wx_api_v2_key原路退款接口为双向证书接口缺少 wx_client_cert_content 与 wx_client_key_content 时该商户的原路退款按凭证不完整判定为不可用,补录后即可用。
商户凭证为敏感信息,仅超级管理员与平台用户可读可写,日志、审计与支付快照不保存凭证内容。`
func registerPaymentMerchantRoutes(router fiber.Router, handler *admin.PaymentMerchantHandler, doc *openapi.Generator, basePath string) {

View File

@@ -26,11 +26,12 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc
groupPath := basePath + "/refunds"
Register(refund, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建退款申请",
Tags: []string{"退款管理"},
Input: new(dto.CreateRefundRequest),
Output: new(dto.RefundResponse),
Auth: true,
Summary: "创建退款申请",
Description: "实收金额由系统从原成功支付记录或订单实际收款派生并冻结,请求体中的 actual_received_amount 已废弃并被忽略。退款方式必填:原路退款、退回资产钱包、退回代理主钱包按各自资金路径执行,客户收款信息退款需同时提供 customer_account_info 与退款凭证;非客户收款信息方式的客户收款信息与凭证不参与校验。",
Tags: []string{"退款管理"},
Input: new(dto.CreateRefundRequest),
Output: new(dto.RefundResponse),
Auth: true,
})
Register(refund, doc, groupPath, "GET", "", handler.List, RouteSpec{
@@ -82,10 +83,11 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc
})
Register(refund, doc, groupPath, "POST", "/:id/resubmit", handler.Resubmit, RouteSpec{
Summary: "重新提交退款申请",
Tags: []string{"退款管理"},
Input: new(dto.ResubmitRefundRequest),
Output: nil,
Auth: true,
Summary: "重新提交退款申请",
Description: "仅已拒绝、已退回或原路退款失败且无审批异常的退款申请可重提;重提时实收金额仍由系统重新派生冻结并忽略请求中的 actual_received_amount每次重提新增一条审批尝试记录并创建新的企业微信审批实例历史尝试材料与审批结果不被覆盖。",
Tags: []string{"退款管理"},
Input: new(dto.ResubmitRefundRequest),
Output: nil,
Auth: true,
})
}

View File

@@ -10,6 +10,7 @@ import (
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
refundapproval "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -30,28 +31,76 @@ func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecision
case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted:
return s.applyClosedDecision(ctx, event)
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
return s.applyRevokedAfterApproved(ctx, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的退款审批终态")
}
}
// applyRevokedAfterApproved 处理企业微信通过后撤销。
//
// 不回滚已失效的套餐权益、不取消已提交的渠道退款、也不恢复订单退款状态:这些事实在通过时
// 已经成立。系统只标记审批异常并禁止后续自动重提,交由超级管理员线下处理。
func (s *Service) applyRevokedAfterApproved(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
var refund model.RefundRequest
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
target, _, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if refund.AnomalyFlag == 1 {
return nil
}
beforeRefund := refundAuditState(&refund)
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND anomaly_flag = 0", refund.ID).
Updates(map[string]any{
"anomaly_flag": 1,
"anomaly_reason": "企业微信通过后撤销,需超级管理员线下处理",
"failure_reason": constants.RefundFailureRevokedAfterApproved,
"updated_at": event.OccurredAt,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记退款审批异常失败")
}
if result.RowsAffected != 1 {
return nil
}
refund.AnomalyFlag = 1
refund.AnomalyReason = "企业微信通过后撤销,需超级管理员线下处理"
refund.FailureReason = constants.RefundFailureRevokedAfterApproved
return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常",
"refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":revoked", beforeRefund, nil, "退款审批通过后撤销")
})
if err != nil {
return err
}
return nil
}
func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
var refund model.RefundRequest
var order model.Order
changed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil {
// 业务标识解析:审批尝试记录优先,退款申请兜底(兼容尚未接入尝试模式的存量申请)。
target, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致")
}
if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved {
if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved &&
refund.Status != model.RefundStatusChannelProcessing {
return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过")
}
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
@@ -66,13 +115,21 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
// 原路退款只登记待执行事实:退款单转入原路处理中,订单在渠道明确成功前保持已支付。
// 客户收款信息与退回原钱包在审批通过时即完成,订单同时置为已退款。
originalRoute := refund.Method == constants.RefundMethodOriginalRoute
if refund.Status == model.RefundStatusPending {
changed = true
targetStatus := model.RefundStatusApproved
if originalRoute {
targetStatus = model.RefundStatusChannelProcessing
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusApproved, "processed_at": event.OccurredAt,
"status": targetStatus, "processed_at": event.OccurredAt,
"approved_refund_amount": approvedAmount, "remark": "企业微信审批通过",
"failure_reason": "", "failure_message": "",
"updated_at": event.OccurredAt,
})
if result.Error != nil {
@@ -81,23 +138,33 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
refund.Status = targetStatus
}
switch order.PaymentStatus {
case model.PaymentStatusPaid:
changed = true
result := tx.WithContext(ctx).Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid).
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败")
if !originalRoute {
switch order.PaymentStatus {
case model.PaymentStatusPaid:
changed = true
result := tx.WithContext(ctx).Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid).
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "订单退款状态已变化")
}
order.PaymentStatus = model.PaymentStatusRefunded
case model.PaymentStatusRefunded:
default:
return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "订单退款状态已变化")
} else {
if order.PaymentStatus != model.PaymentStatusPaid && order.PaymentStatus != model.PaymentStatusRefunded {
return errors.New(errors.CodeInvalidStatus, "订单状态不允许原路退款")
}
if err := s.prepareChannelRefundInTx(ctx, tx, &refund, attempt); err != nil {
return err
}
order.PaymentStatus = model.PaymentStatusRefunded
case model.PaymentStatusRefunded:
default:
return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款")
}
if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil {
return err
@@ -112,8 +179,10 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
}); err != nil {
return err
}
if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil {
return err
if !originalRoute {
if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil {
return err
}
}
if err := commissiondelivery.AppendRefundCommissionDeduct(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
return err
@@ -134,6 +203,17 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
return nil
}
// prepareChannelRefundInTx 在企微通过事务内登记原路退款的待执行事实。
//
// 只写本地事实与可靠事件:请求号在提交时已冻结到审批尝试记录,这里把它落到退款单并转入
// 原路处理中真正的渠道调用由可靠事件驱动的事务外消费者执行ENG-TX-001
func (s *Service) prepareChannelRefundInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
if s.channelRefund == nil {
return errors.New(errors.CodeInternalError, "渠道原路退款能力未配置")
}
return s.channelRefund.PrepareInTx(ctx, tx, refund, attempt)
}
func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
reason := map[string]string{

View File

@@ -0,0 +1,87 @@
package refund
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// loadAttemptResponses 批量读取退款申请的审批尝试历史,并补齐每次尝试的审批实例状态。
//
// 尝试记录按提交次序升序返回,历史材料与审批结果不被覆盖;未接入尝试模式的存量申请返回空切片。
func (s *Service) loadAttemptResponses(ctx context.Context, refunds []*model.RefundRequest) (map[uint][]dto.RefundAttemptResponse, error) {
result := make(map[uint][]dto.RefundAttemptResponse, len(refunds))
refundIDs := make([]uint, 0, len(refunds))
for _, refund := range refunds {
if refund == nil || refund.ID == 0 {
continue
}
refundIDs = append(refundIDs, refund.ID)
result[refund.ID] = []dto.RefundAttemptResponse{}
}
if len(refundIDs) == 0 {
return result, nil
}
var attempts []model.RefundRequestAttempt
if err := s.db.WithContext(ctx).
Where("refund_id IN ?", refundIDs).
Order("refund_id ASC, attempt_no ASC, id ASC").
Find(&attempts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
}
if len(attempts) == 0 {
return result, nil
}
instanceIDs := make([]uint, 0, len(attempts))
for index := range attempts {
if attempts[index].ApprovalInstanceID != nil && *attempts[index].ApprovalInstanceID > 0 {
instanceIDs = append(instanceIDs, *attempts[index].ApprovalInstanceID)
}
}
statuses := map[uint]int{}
if len(instanceIDs) > 0 {
var instances []model.ApprovalInstance
if err := s.db.WithContext(ctx).Select("id", "status").Where("id IN ?", instanceIDs).Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批尝试实例状态失败")
}
for _, instance := range instances {
statuses[instance.ID] = instance.Status
}
}
for index := range attempts {
attempt := &attempts[index]
item := dto.RefundAttemptResponse{
ID: attempt.ID,
AttemptNo: attempt.AttemptNo,
Method: attempt.Method,
MethodName: constants.RefundMethodName(attempt.Method),
RefundAmount: attempt.RefundAmount,
FrozenActualReceivedAmount: attempt.FrozenActualReceivedAmount,
RefundReason: attempt.RefundReason,
CustomerAccountInfo: attempt.CustomerAccountInfo,
CustomerVoucherKey: []string(attempt.CustomerVoucherKeys),
ChannelRefundRequestNo: attempt.ChannelRefundRequestNo,
SubmittedByAccountID: attempt.SubmittedByAccountID,
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
}
if len(item.CustomerVoucherKey) == 0 {
item.CustomerVoucherKey = []string{}
}
if attempt.ApprovalInstanceID != nil && *attempt.ApprovalInstanceID > 0 {
item.ApprovalInstanceID = *attempt.ApprovalInstanceID
if status, exists := statuses[*attempt.ApprovalInstanceID]; exists {
value := status
item.ApprovalStatus = &value
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
}
}
result[attempt.RefundID] = append(result[attempt.RefundID], item)
}
return result, nil
}

View File

@@ -0,0 +1,273 @@
package refund
import (
"context"
"strings"
"time"
"gorm.io/gorm"
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// refundMethodOption 描述一种可选退款方式及其不可用的原因。
type refundMethodOption struct {
Method string
Name string
Available bool
// Reason 在方式不可用时说明具体原因,供前端禁用并展示。
Reason string
}
// refundMethodDecision 是一次退款方式判定的完整结果。
type refundMethodDecision struct {
Options []refundMethodOption
// FrozenActualReceivedAmount 是系统派生的权威实收金额(分),提交人不可填写或修改。
FrozenActualReceivedAmount int64
// Payment 是派生实收金额时使用的原成功支付记录,线上订单才有值。
Payment *model.Payment
// ChannelConfig 是原路退款实际使用的冻结商户当前凭证,非线上订单或原路不可用时为 nil。
ChannelConfig *model.WechatConfig
}
// decideRefundMethods 派生权威实收金额并按来源实际支付方式生成可选退款方式。
//
// 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单
// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代。
func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) {
if order == nil || order.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "退款来源订单无效")
}
decision := &refundMethodDecision{}
switch order.PaymentMethod {
case model.PaymentMethodWechat, model.PaymentMethodAlipay:
payment, err := s.loadPaidPackagePayment(ctx, order.ID)
if err != nil {
return nil, err
}
if payment.Amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "原成功支付记录金额非正,无法确定权威实收金额")
}
decision.Payment = payment
decision.FrozenActualReceivedAmount = payment.Amount
originalRoute, reason, config := s.originalRouteAvailability(ctx, order, payment)
if originalRoute {
decision.ChannelConfig = config
}
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodOriginalRoute, Name: constants.RefundMethodName(constants.RefundMethodOriginalRoute), Available: originalRoute, Reason: reason},
{Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true},
}
case model.PaymentMethodWallet:
amount, err := orderActualPaidAmount(order)
if err != nil {
return nil, err
}
decision.FrozenActualReceivedAmount = amount
switch order.BuyerType {
case model.BuyerTypeAgent:
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodAgentWallet, Name: constants.RefundMethodName(constants.RefundMethodAgentWallet), Available: true},
}
case model.BuyerTypePersonal:
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodAssetWallet, Name: constants.RefundMethodName(constants.RefundMethodAssetWallet), Available: true},
}
default:
return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款")
}
case model.PaymentMethodOffline:
amount, err := orderActualPaidAmount(order)
if err != nil {
return nil, err
}
decision.FrozenActualReceivedAmount = amount
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true},
}
default:
return nil, errors.New(errors.CodeInvalidParam, "该订单的支付方式不支持退款")
}
return decision, nil
}
// orderActualPaidAmount 取订单实际收款或实际扣款金额作为权威实收金额。
func orderActualPaidAmount(order *model.Order) (int64, error) {
if order.ActualPaidAmount == nil || *order.ActualPaidAmount <= 0 {
return 0, errors.New(errors.CodeInvalidParam, "订单实际收款金额缺失或非正,无法确定权威实收金额")
}
return *order.ActualPaidAmount, nil
}
// loadPaidPackagePayment 读取该订单原成功的线上支付记录。
func (s *Service) loadPaidPackagePayment(ctx context.Context, orderID uint) (*model.Payment, error) {
var payment model.Payment
err := s.db.WithContext(ctx).
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id DESC").
First(&payment).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "未找到该订单的原成功支付记录,无法确定权威实收金额")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付记录失败")
}
return &payment, nil
}
// originalRouteAvailability 预检线上订单的原路退款可退性,并返回实际使用的商户凭证。
//
// 条件全部本地可判定:存在成功支付记录、可定位实际收款商户、该商户具备其服务商类型的
// 退款能力与必需凭证、且原交易未超出渠道可退时限。实际调用渠道后的结果才是退款资格的
// 最终判断;预检只用于在申请阶段禁用原路并说明原因。
func (s *Service) originalRouteAvailability(ctx context.Context, order *model.Order, payment *model.Payment) (bool, string, *model.WechatConfig) {
if payment == nil {
return false, "缺少原成功支付记录", nil
}
if strings.TrimSpace(payment.ThirdPartyTradeNo) == "" {
return false, "原支付记录缺少渠道交易流水号", nil
}
if s.paymentMerchantRuntime == nil {
return false, "商户凭证加载能力未配置", nil
}
var config *model.WechatConfig
if payment.MerchantID != nil {
merchant, err := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return false, "实际收款商户不可用", nil
}
// 商户停用不阻断历史单的原路退款,因此只校验凭证完整性,不校验商户状态。
config, err = merchantRefundConfig(merchant)
if err != nil {
return false, err.Error(), nil
}
} else {
if payment.PaymentConfigID == nil {
return false, "历史支付单缺少支付配置", nil
}
legacy, err := loadLegacyPaymentConfig(ctx, s.db, *payment.PaymentConfigID)
if err != nil {
return false, err.Error(), nil
}
config = legacy
}
if reason := channelRefundCredentialIssue(config); reason != "" {
return false, reason, nil
}
if reason := channelRefundWindowIssue(config.ProviderType, payment.PaidAt); reason != "" {
return false, reason, nil
}
return true, "", config
}
// channelRefundWindowIssue 判定原交易是否超出渠道可退时限。
// 富友按是否回传原交易日期区分:不回传仅支持 30 天内,回传可退 360 天内。
// 系统始终回传原交易日期,因此按 360 天判定;微信与支付宝不设本地时限。
func channelRefundWindowIssue(providerType string, paidAt *time.Time) string {
if providerType != model.ProviderTypeFuiou {
return ""
}
if paidAt == nil {
return "富友原交易缺少支付时间,无法回传原交易日期"
}
if time.Since(*paidAt) > 360*24*time.Hour {
return "富友原交易已超出渠道可退时限360 天)"
}
return ""
}
// requestChannelRefundNo 按冻结商户生成渠道退款请求号,并在提交时冻结到审批尝试记录。
// 重提会生成新值;同一尝试的渠道重试复用该值,因此渠道侧以它作为幂等标识。
func requestChannelRefundNo(config *model.WechatConfig, now time.Time) string {
return refundchannel.BuildChannelRefundRequestNo(merchantChannelPrefix(config), now)
}
// merchantChannelPrefix 取商户标识中的数字段作为请求号前缀。
// 富友要求前缀为其机构码;其余渠道只要求请求号在其长度与字符集约束内唯一。
func merchantChannelPrefix(config *model.WechatConfig) string {
if config == nil {
return "0000"
}
if config.ProviderType == model.ProviderTypeFuiou && strings.TrimSpace(config.FyInsCd) != "" {
return config.FyInsCd
}
return config.WxMchID + config.AliAppID
}
// validateRefundMethod 校验提交的退款方式属于该订单的可选方式集合。
func validateRefundMethod(decision *refundMethodDecision, method string) error {
method = strings.TrimSpace(method)
if method == "" {
return errors.New(errors.CodeInvalidParam, "退款方式不能为空")
}
for _, option := range decision.Options {
if option.Method != method {
continue
}
if !option.Available {
return errors.New(errors.CodeInvalidParam, "该订单当前不支持此退款方式:"+option.Reason)
}
return nil
}
return errors.New(errors.CodeInvalidParam, "该订单不支持此退款方式")
}
// validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。
// 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。
func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error {
if method != constants.RefundMethodCustomerAccount {
return nil
}
if strings.TrimSpace(customerAccountInfo) == "" {
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息")
}
hasVoucher := false
for _, key := range voucherKeys {
if strings.TrimSpace(key) != "" {
hasVoucher = true
break
}
}
if !hasVoucher {
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须至少上传一个客户收款凭证")
}
return nil
}
// validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。
func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error {
if amount <= 0 {
return errors.New(errors.CodeInvalidParam, "退款金额必须为正分")
}
if frozenActualReceivedAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请")
}
if amount > frozenActualReceivedAmount {
return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额")
}
return nil
}
// buildAttemptFromDecision 依据方式判定结果构造一条不可变审批尝试记录。
func buildAttemptFromDecision(decision *refundMethodDecision, refund *model.RefundRequest, config *model.WechatConfig, now time.Time) *model.RefundRequestAttempt {
attempt := &model.RefundRequestAttempt{
RefundID: refund.ID,
Method: refund.Method,
RefundAmount: refund.RequestedRefundAmount,
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
RefundReason: refund.RefundReason,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
SubmittedByAccountID: refund.Creator,
}
if refund.Method == constants.RefundMethodOriginalRoute {
attempt.ChannelRefundRequestNo = requestChannelRefundNo(config, now)
}
return attempt
}

View File

@@ -7,6 +7,7 @@ import (
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -16,10 +17,10 @@ func (s *Service) SetPaymentMerchantRuntime(runtime *merchantpayment.RuntimeLoad
s.paymentMerchantRuntime = runtime
}
// preparePaymentRefundCredentials 只为既有套餐订单退款流程装载校验支付凭证;本 Change
// 不发起任何渠道退款请求。钱包支付和线下支付没有收款商户凭证,直接放行。
// merchant_id 为空仅留存期内的历史线上支付,独立 Change 清理后才可删除按
// payment_config_id 读取兼容路径,绝不能按当前商户池推断商户。
// preparePaymentRefundCredentials 在企微通过事务内装载校验原路退款所需凭证。
//
// 钱包支付和线下支付没有收款商户凭证,直接放行。merchant_id 为空仅表示留存期内的历史
// 线上支付,仍按 payment_config_id 读取兼容配置;绝不能按当前商户池推断历史商户。
func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.DB, orderID uint) error {
var payment model.Payment
err := tx.WithContext(ctx).
@@ -38,7 +39,7 @@ func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.
}
if payment.MerchantID != nil {
if s.paymentMerchantRuntime == nil {
return errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
return errors.New(errors.CodeServiceUnavailable, "商户凭证加载能力未配置")
}
merchant, loadErr := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if loadErr != nil {
@@ -59,36 +60,58 @@ func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.
return nil
}
// validateFrozenMerchantRefundCredentials 判断既有渠道流程所需凭证是否完整
// 当前系统没有任何渠道原路退款 Adapter因此返回前不调用微信、支付宝或富友。
// validateFrozenMerchantRefundCredentials 判断冻结商户是否具备原路退款能力
//
// 能力只由服务商类型与该服务商类型退款所需凭证的完整性决定,不提供人工开关:
// - 微信直连 v3具备 v3 密钥、证书、私钥与证书序列号时可调用 v3 退款接口;
// - 微信 v2其退款接口需要 API 客户端证书,而商户凭证键集合不含该证书,故判定不可用;
// - 富友:凭证键集合已含退款与退款查询所需参数,能力可用;
// - 支付宝:具备 AppID、应用私钥与支付宝公钥时可签名退款请求。
func validateFrozenMerchantRefundCredentials(merchant *model.PaymentMerchant) error {
config, err := merchantpayment.MerchantConfig(merchant, nil)
config, err := merchantRefundConfig(merchant)
if err != nil {
return err
}
switch config.ProviderType {
case model.ProviderTypeWechat:
if blank(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, config.WxKeyContent, config.WxSerialNo, config.WxNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
}
case model.ProviderTypeWechatV2:
if blank(config.WxMchID, config.WxAPIV2Key, config.WxNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
}
case model.ProviderTypeFuiou:
if blank(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, config.FyPublicKey, config.FyAPIURL, config.FyNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结富友商户退款凭证不完整")
}
case "alipay":
if blank(config.AliAppID, config.AliPrivateKey, config.AliPublicKey, config.AliNotifyURL, config.AliReturnURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结支付宝商户退款凭证不完整")
}
default:
return errors.New(errors.CodeNoPaymentConfig, "冻结商户不支持现有退款流程")
if reason := channelRefundCredentialIssue(config); reason != "" {
return errors.New(errors.CodeNoPaymentConfig, reason)
}
return nil
}
// merchantRefundConfig 把冻结商户凭证适配为渠道配置,供退款能力判定与渠道调用使用。
func merchantRefundConfig(merchant *model.PaymentMerchant) (*model.WechatConfig, error) {
if merchant == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在")
}
return merchantpayment.MerchantConfig(merchant, nil)
}
// loadLegacyPaymentConfig 按历史支付配置标识读取兼容配置。
func loadLegacyPaymentConfig(ctx context.Context, db *gorm.DB, configID uint) (*model.WechatConfig, error) {
if db == nil || configID == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
}
var legacy model.WechatConfig
if err := db.WithContext(ctx).Unscoped().First(&legacy, configID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
}
return &legacy, nil
}
// channelRefundCredentialIssue 返回该商户凭证不满足原路退款要求的原因;凭证完整时返回空串。
//
// 判定规则只有一处来源refundchannel.RefundCredentialIssue。申请阶段的方式预检与执行阶段的
// 凭证校验必须完全一致,否则会出现「申请时可选原路、执行时凭证不完整」的不一致。
func channelRefundCredentialIssue(config *model.WechatConfig) string {
if config == nil {
return "商户支付凭证不可用"
}
return refundchannelapp.RefundCredentialIssue(config.ProviderType, config)
}
func blank(values ...string) bool {
for _, value := range values {
if strings.TrimSpace(value) == "" {

View File

@@ -19,6 +19,7 @@ import (
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
@@ -60,6 +61,12 @@ type Service struct {
logger *zap.Logger
paymentMerchantRuntime *merchantpayment.RuntimeLoader
refundOffset *employeecollectionapp.RefundOffsetService
channelRefund *refundchannelapp.Service
}
// SetChannelRefundService 注入渠道原路退款用例。
func (s *Service) SetChannelRefundService(service *refundchannelapp.Service) {
s.channelRefund = service
}
// SetEmployeeCollectionRefundOffset 注入员工代收款账单退款冲销用例。
@@ -121,7 +128,8 @@ func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
}
// Create 创建退款申请
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
// 校验订单存在且已支付、派生并冻结权威实收金额、判定可选退款方式,随后原子创建退款申请、
// 审批尝试记录与企业微信审批实例。实收金额由系统派生,提交人填写无效。
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
@@ -139,13 +147,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
}
return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款")
}
if err := validateRequestedRefundAmountByOrder(req.RequestedRefundAmount, order); err != nil {
decision, err := s.decideRefundMethods(ctx, order)
if err != nil {
return nil, err
}
if err := validateRefundMethod(decision, req.Method); err != nil {
return nil, err
}
if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
return nil, err
}
refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey)
if err != nil {
return nil, err
}
if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil {
return nil, err
}
// 从订单获取 shop_id优先使用 SellerShopID代理商买家使用 BuyerID
var shopID *uint
@@ -156,20 +175,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
}
refund := &model.RefundRequest{
RefundNo: generateRefundNo(),
OrderID: req.OrderID,
OrderNo: order.OrderNo,
OrderType: order.OrderType,
AssetIdentifier: order.AssetIdentifier,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
PackageUsageID: req.PackageUsageID,
ShopID: shopID,
ActualReceivedAmount: req.ActualReceivedAmount,
RequestedRefundAmount: req.RequestedRefundAmount,
RefundVoucherKey: refundVoucherKey,
RefundReason: req.RefundReason,
Status: model.RefundStatusPending,
RefundNo: generateRefundNo(),
OrderID: req.OrderID,
OrderNo: order.OrderNo,
OrderType: order.OrderType,
AssetIdentifier: order.AssetIdentifier,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
PackageUsageID: req.PackageUsageID,
ShopID: shopID,
// 实收金额与冻结额一律取系统派生值,忽略提交人传入的金额。
ActualReceivedAmount: decision.FrozenActualReceivedAmount,
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
RequestedRefundAmount: req.RequestedRefundAmount,
Method: req.Method,
CustomerAccountInfo: req.CustomerAccountInfo,
RefundVoucherKey: refundVoucherKey,
RefundReason: req.RefundReason,
Status: model.RefundStatusPending,
}
refund.Creator = userID
refund.Updater = userID
@@ -177,8 +200,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
attempt := buildAttemptFromDecision(decision, refund, decision.ChannelConfig, time.Now())
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
Refund: refund, Order: order, SubmitterAccountID: userID,
Refund: refund, Order: order, SubmitterAccountID: userID, Attempt: attempt,
})
if err != nil {
failedRefund := *refund
@@ -229,12 +253,17 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
if err != nil {
return nil, err
}
attemptResponses, err := s.loadAttemptResponses(ctx, requests)
if err != nil {
return nil, err
}
items := make([]dto.RefundResponse, 0, len(requests))
for _, r := range requests {
item := buildRefundResponse(r)
item.SubmitterName = submitterNames[r.Creator]
applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID)
applyApprovalSummary(item, approvalSummaries, r)
item.Attempts = attemptResponses[r.ID]
items = append(items, *item)
}
@@ -279,7 +308,12 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er
if err != nil {
return nil, err
}
applyApprovalSummary(resp, approvalSummaries, refund.ApprovalInstanceID)
applyApprovalSummary(resp, approvalSummaries, refund)
attemptResponses, err := s.loadAttemptResponses(ctx, []*model.RefundRequest{refund})
if err != nil {
return nil, err
}
resp.Attempts = attemptResponses[refund.ID]
return resp, nil
}
@@ -334,8 +368,10 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
beforeRefund := refundAuditState(refund)
beforeOrder := map[string]any{"payment_status": order.PaymentStatus}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 条件更新同时约束 approval_instance_id IS NULL已关联审批实例的申请由企业微信决定
// 本地人工通过一律拒绝,并与 Reject/Return 的守卫保持一致ENG-CONC-001
result := tx.Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusPending).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusApproved,
"processor_id": userID,
@@ -349,7 +385,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
return errors.Wrap(errors.CodeInternalError, result.Error, "审批退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
return errors.New(errors.CodeInvalidStatus, "退款申请由企业微信审批决定,或状态已变更,不能人工审批")
}
orderResult := tx.Model(&model.Order{}).
@@ -391,6 +427,12 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
return nil
}
// AppendCompletedNotification 在同一事务内补写退款完成通知事实。
// 供渠道原路退款在渠道明确成功时调用:该方式的退款完成时点晚于企微通过。
func (s *Service) AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
return s.appendCompletedNotification(ctx, tx, refund)
}
// appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。
func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
if refund == nil || refund.ShopID == nil {
@@ -717,6 +759,11 @@ func ensureRefundProcessor(ctx context.Context) error {
// Resubmit 重新提交退款申请
// 条件更新 WHERE status=4已退回修改部分字段后重新进入待审批状态
// Resubmit 修改并重提未成功退款申请
// POST /api/admin/refunds/:id/resubmit
// 仅已拒绝、已退回或原路退款失败且不存在审批异常的申请可修改重提;每次重提新增一条不可变
// 审批尝试记录与一个新的企业微信审批实例,历史材料与审批结果不被覆盖。金额与方式的变更
// 必须重新审批,不得沿用旧实例。
func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
@@ -725,19 +772,43 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
if refund.Status != model.RefundStatusReturned {
businessErr := errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
if err := s.resubmitPrecheck(refund); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
return err
}
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
if err != nil {
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
return businessErr
}
decision, err := s.decideRefundMethods(ctx, order)
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
// 方式缺省沿用原方式,金额缺省沿用原金额。
method := refund.Method
if req.Method != nil && strings.TrimSpace(*req.Method) != "" {
method = *req.Method
}
if err := validateRefundMethod(decision, method); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
requestedRefundAmount := refund.RequestedRefundAmount
if req.RequestedRefundAmount != nil {
requestedRefundAmount = *req.RequestedRefundAmount
}
if err := validateFrozenRefundAmount(requestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
refundVoucherKey := refund.RefundVoucherKey
if req.RefundVoucherKey != nil {
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
@@ -747,54 +818,58 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
}
refundVoucherKey = normalized
}
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
if err != nil {
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
return businessErr
refundReason := refund.RefundReason
if req.RefundReason != nil {
refundReason = *req.RefundReason
}
if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
customerAccountInfo := refund.CustomerAccountInfo
if req.CustomerAccountInfo != nil {
customerAccountInfo = *req.CustomerAccountInfo
}
if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
return err
}
now := time.Now()
updates := map[string]any{
"status": model.RefundStatusPending,
"updater": userID,
"updated_at": now,
}
if req.ActualReceivedAmount != nil {
updates["actual_received_amount"] = *req.ActualReceivedAmount
}
if req.RequestedRefundAmount != nil {
updates["requested_refund_amount"] = *req.RequestedRefundAmount
}
if req.RefundVoucherKey != nil {
updates["refund_voucher_key"] = refundVoucherKey
}
if req.RefundReason != nil {
updates["refund_reason"] = *req.RefundReason
}
// 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。
updated := *refund
updated.Method = method
updated.RequestedRefundAmount = requestedRefundAmount
updated.FrozenActualReceivedAmount = decision.FrozenActualReceivedAmount
updated.ActualReceivedAmount = decision.FrozenActualReceivedAmount
updated.RefundVoucherKey = refundVoucherKey
updated.RefundReason = refundReason
updated.CustomerAccountInfo = customerAccountInfo
updated.Creator = userID
beforeRefund := refundAuditState(refund)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusReturned).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
}
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundResubmitted, "重新提交退款申请", "", beforeRefund, nil, "退款申请已重新提交")
})
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
attempt := buildAttemptFromDecision(decision, &updated, decision.ChannelConfig, time.Now())
if s.refundApprovalCreation == nil {
return errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
return err
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
Refund: &updated, Attempt: attempt,
}); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
return nil
}
// resubmitPrecheck 校验退款申请是否处于可重提状态且不存在审批异常。
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由超级管理员线下处理。
func (s *Service) resubmitPrecheck(refund *model.RefundRequest) error {
if refund == nil {
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
if refund.AnomalyFlag != 0 {
return errors.New(errors.CodeInvalidStatus, "该退款申请存在审批异常,需超级管理员线下处理,不支持重提")
}
for _, status := range model.RefundResubmittableStatuses() {
if refund.Status == status {
return nil
}
}
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
// deductAllCommission 幂等回扣该订单所有已入账佣金。
@@ -1340,40 +1415,60 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
}
resp := &dto.RefundResponse{
ID: r.ID,
RefundNo: r.RefundNo,
OrderID: r.OrderID,
OrderNo: r.OrderNo,
AssetIdentifier: r.AssetIdentifier,
AssetType: assetType,
IotCardID: r.IotCardID,
DeviceID: r.DeviceID,
PackageUsageID: r.PackageUsageID,
ShopID: r.ShopID,
ShopName: r.ShopName,
ActualReceivedAmount: r.ActualReceivedAmount,
RequestedRefundAmount: r.RequestedRefundAmount,
ApprovedRefundAmount: r.ApprovedRefundAmount,
RefundVoucherKey: []string(r.RefundVoucherKey),
RefundReason: r.RefundReason,
Status: r.Status,
StatusName: constants.GetRefundStatusName(r.Status),
ProcessorID: r.ProcessorID,
RejectReason: r.RejectReason,
Remark: r.Remark,
CommissionDeducted: r.CommissionDeducted,
AssetReset: r.AssetReset,
SubmitterID: r.Creator,
ApprovalInstanceID: r.ApprovalInstanceID,
Creator: r.Creator,
Updater: r.Updater,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
ID: r.ID,
RefundNo: r.RefundNo,
OrderID: r.OrderID,
OrderNo: r.OrderNo,
AssetIdentifier: r.AssetIdentifier,
AssetType: assetType,
IotCardID: r.IotCardID,
DeviceID: r.DeviceID,
PackageUsageID: r.PackageUsageID,
ShopID: r.ShopID,
ShopName: r.ShopName,
ActualReceivedAmount: r.ActualReceivedAmount,
RequestedRefundAmount: r.RequestedRefundAmount,
ApprovedRefundAmount: r.ApprovedRefundAmount,
RefundVoucherKey: []string(r.RefundVoucherKey),
RefundReason: r.RefundReason,
Status: r.Status,
StatusName: constants.GetRefundStatusName(r.Status),
Method: r.Method,
MethodName: constants.RefundMethodName(r.Method),
FrozenActualReceivedAmount: r.FrozenActualReceivedAmount,
CustomerAccountInfo: r.CustomerAccountInfo,
ChannelRefundStatus: r.ChannelRefundStatus,
ChannelRefundStatusName: constants.RefundChannelStatusName(r.ChannelRefundStatus),
ChannelRefundNo: r.ChannelRefundNo,
ChannelRefundRequestNo: r.ChannelRefundRequestNo,
ChannelRefundAmount: r.ChannelRefundAmount,
FailureReason: r.FailureReason,
FailureReasonName: constants.RefundFailureReasonName(r.FailureReason),
FailureMessage: r.FailureMessage,
AnomalyFlag: r.AnomalyFlag,
AnomalyReason: r.AnomalyReason,
LatestAttemptID: r.LatestAttemptID,
LatestApprovalInstanceID: r.LatestApprovalInstanceID,
Attempts: []dto.RefundAttemptResponse{},
ProcessorID: r.ProcessorID,
RejectReason: r.RejectReason,
Remark: r.Remark,
CommissionDeducted: r.CommissionDeducted,
AssetReset: r.AssetReset,
SubmitterID: r.Creator,
ApprovalInstanceID: r.ApprovalInstanceID,
Creator: r.Creator,
Updater: r.Updater,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if r.ProcessedAt != nil {
resp.ProcessedAt = r.ProcessedAt.Format("2006-01-02 15:04:05")
}
if r.ChannelRefundedAt != nil {
resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05")
}
return resp
}
@@ -1411,11 +1506,16 @@ func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.Re
return summaries, nil
}
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, instanceID *uint) {
if response == nil || instanceID == nil {
// applyApprovalSummary 用审批实例摘要回填响应的审批状态。
// 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) {
if response == nil || refund == nil {
return
}
summary, exists := summaries[*instanceID]
summary, exists := summaries[refund.LatestApprovalInstanceID]
if !exists && refund.ApprovalInstanceID != nil {
summary, exists = summaries[*refund.ApprovalInstanceID]
}
if !exists {
return
}

View File

@@ -87,31 +87,33 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest
}
config := &model.WechatConfig{
Name: req.Name,
Description: desc,
ProviderType: req.ProviderType,
IsActive: false,
OaAppID: req.OaAppID,
OaAppSecret: req.OaAppSecret,
OaToken: req.OaToken,
OaAesKey: req.OaAesKey,
OaOAuthRedirectURL: req.OaOAuthRedirectURL,
MiniappAppID: req.MiniappAppID,
MiniappAppSecret: req.MiniappAppSecret,
WxMchID: req.WxMchID,
WxAPIV3Key: req.WxAPIV3Key,
WxAPIV2Key: req.WxAPIV2Key,
WxCertContent: req.WxCertContent,
WxKeyContent: req.WxKeyContent,
WxSerialNo: req.WxSerialNo,
WxNotifyURL: req.WxNotifyURL,
FyInsCd: req.FyInsCd,
FyMchntCd: req.FyMchntCd,
FyTermID: req.FyTermID,
FyPrivateKey: req.FyPrivateKey,
FyPublicKey: req.FyPublicKey,
FyAPIURL: req.FyAPIURL,
FyNotifyURL: req.FyNotifyURL,
Name: req.Name,
Description: desc,
ProviderType: req.ProviderType,
IsActive: false,
OaAppID: req.OaAppID,
OaAppSecret: req.OaAppSecret,
OaToken: req.OaToken,
OaAesKey: req.OaAesKey,
OaOAuthRedirectURL: req.OaOAuthRedirectURL,
MiniappAppID: req.MiniappAppID,
MiniappAppSecret: req.MiniappAppSecret,
WxMchID: req.WxMchID,
WxAPIV3Key: req.WxAPIV3Key,
WxAPIV2Key: req.WxAPIV2Key,
WxCertContent: req.WxCertContent,
WxKeyContent: req.WxKeyContent,
WxSerialNo: req.WxSerialNo,
WxNotifyURL: req.WxNotifyURL,
WxClientCertContent: req.WxClientCertContent,
WxClientKeyContent: req.WxClientKeyContent,
FyInsCd: req.FyInsCd,
FyMchntCd: req.FyMchntCd,
FyTermID: req.FyTermID,
FyPrivateKey: req.FyPrivateKey,
FyPublicKey: req.FyPublicKey,
FyAPIURL: req.FyAPIURL,
FyNotifyURL: req.FyNotifyURL,
AliAppID: req.AliAppID,
AliPrivateKey: req.AliPrivateKey,
@@ -233,6 +235,8 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf
s.mergeSensitiveField(&config.WxKeyContent, req.WxKeyContent)
s.mergeStringField(&config.WxSerialNo, req.WxSerialNo)
s.mergeStringField(&config.WxNotifyURL, req.WxNotifyURL)
s.mergeSensitiveField(&config.WxClientCertContent, req.WxClientCertContent)
s.mergeSensitiveField(&config.WxClientKeyContent, req.WxClientKeyContent)
// 富友支付
s.mergeStringField(&config.FyInsCd, req.FyInsCd)