Files
junhong_cmp_fiber/internal/service/refund/settlement.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

204 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package refund
import (
"context"
stderrors "errors"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// latestAttemptRemark 读取该退款单最近一次审批尝试冻结的申请人备注。
// 重提时未填写备注则沿用该值,使申请人不必重复填写;读取失败按未填写处理(备注不参与金额与状态判定)。
func (s *Service) latestAttemptRemark(ctx context.Context, refund *model.RefundRequest) string {
if refund == nil || refund.ID == 0 {
return ""
}
var attempt model.RefundRequestAttempt
err := s.db.WithContext(ctx).
Select("id", "remark").
Where("refund_id = ?", refund.ID).
Order("attempt_no DESC, id DESC").
First(&attempt).Error
if err != nil {
if err != gorm.ErrRecordNotFound {
s.logMaterialFailure(ctx, refund.ID, "applicant_remark", err)
}
return ""
}
return strings.TrimSpace(attempt.Remark)
}
// OrderOptions 按来源订单查询可选退款方式。
//
// 判定复用创建与重提使用的方式判定实现decideRefundMethods其中原路可退的凭证判定
// 复用执行前预检所用的同一凭证判定来源refundchannel.RefundCredentialIssue
// MUST NOT 引入第二套判定或独立开关。审批提交只消费已冻结材料,本查询不产生任何副作用。
//
// 判定所需事实缺失(无原成功支付记录、金额非正、支付方式不支持退款)时返回不可用原因而非报错;
// 数据库或能力未配置等基础设施故障仍按错误返回。查询受订单数据范围约束,越权与订单不存在不可区分。
func (s *Service) OrderOptions(ctx context.Context, req *dto.RefundOrderOptionsRequest) (*dto.RefundOrderOptionsResponse, error) {
if req == nil || req.OrderID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "订单ID不能为空")
}
order, err := s.orderStore.GetByID(ctx, req.OrderID)
if err != nil {
// 数据范围外的订单与不存在的订单返回同一结果,避免形成可枚举差异。
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
response := &dto.RefundOrderOptionsResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
OrderPaymentType: order.PaymentMethod,
Methods: []dto.RefundMethodOptionResponse{},
RefundCapability: dto.RefundCapabilityResponse{},
}
decision, err := s.decideRefundMethods(ctx, order)
if err != nil {
var appErr *errors.AppError
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeInvalidParam {
// 判定事实缺失:以不可用原因返回,不让只读查询报错。
response.UnavailableReason = appErr.Message
response.RefundCapability = dto.RefundCapabilityResponse{Available: false, Unavailable: appErr.Message}
return response, nil
}
return nil, err
}
response.Methods = make([]dto.RefundMethodOptionResponse, 0, len(decision.Options))
for _, option := range decision.Options {
response.Methods = append(response.Methods, dto.RefundMethodOptionResponse{
Method: option.Method, MethodName: option.Name,
Available: option.Available, Unavailable: option.Reason,
})
}
response.RefundCapability = refundCapabilityProjection(decision.Options)
if decision.Payment != nil {
response.OriginalChannelTradeNo = strings.TrimSpace(decision.Payment.ThirdPartyTradeNo)
response.MerchantID = decision.Payment.MerchantID
response.MerchantName = strings.TrimSpace(decision.Payment.MerchantNameSnapshot)
}
return response, nil
}
// refundCapabilityProjection 从方式判定结果投影原路退款能力校验结果。
// 订单不含原路方式时能力不可用,原因取该订单方式矩阵的说明,避免展示与方式集合不一致的结论。
func refundCapabilityProjection(options []refundMethodOption) dto.RefundCapabilityResponse {
for _, option := range options {
if option.Method != constants.RefundMethodOriginalRoute {
continue
}
return dto.RefundCapabilityResponse{Available: option.Available, Unavailable: option.Reason}
}
return dto.RefundCapabilityResponse{Available: false, Unavailable: "该订单的退款方式矩阵不包含原路退款"}
}
// RegisterOfflineSettlement 登记或更正线下退款处理流水号。
//
// 仅客户收款信息退款(线下到账方式)允许登记;重复调用即更正,历史值由审计留存。
// 登记在同一事务内写审计(操作者、时间、前后值),且 MUST NOT 改变退款状态、实收金额、
// 套餐失效与佣金回溯规则ENG-TX-001事实与要求成功必达的审计同事务
func (s *Service) RegisterOfflineSettlement(ctx context.Context, id uint, req *dto.OfflineSettlementRequest) (*dto.RefundResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if id == 0 || req == nil {
return nil, errors.New(errors.CodeInvalidParam, "退款申请ID不能为空")
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款统一审计接缝未配置")
}
settlementNo := strings.TrimSpace(req.OfflineSettlementNo)
if settlementNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "线下退款处理流水号不能为空")
}
if len(settlementNo) > 128 {
return nil, errors.New(errors.CodeInvalidParam, "线下退款处理流水号长度不能超过 128")
}
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
// 数据范围外的退款申请与不存在返回同一结果。
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
if refund.Method != constants.RefundMethodCustomerAccount {
return nil, errors.New(errors.CodeInvalidStatus, "仅客户收款信息退款方式可登记线下退款处理流水号")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
settledAt := time.Now().UTC()
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, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if current.Method != constants.RefundMethodCustomerAccount {
return errors.New(errors.CodeInvalidStatus, "仅客户收款信息退款方式可登记线下退款处理流水号")
}
beforeState := refundSettlementState(&current)
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ?", id).
Updates(map[string]any{
"offline_settlement_no": settlementNo,
"offline_settled_at": settledAt,
"offline_settled_by": userID,
"updater": userID,
"updated_at": settledAt,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "登记线下退款处理流水号失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
if err := tx.WithContext(ctx).First(&current, id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款申请登记后快照失败")
}
primary := audit.RefundResource(&current, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
primary.BeforeData = beforeState
primary.AfterData = refundSettlementState(&current)
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = "登记线下退款处理流水号"
if err := s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionRefundOfflineSettled, Summary: "登记线下退款处理流水号",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: current.RefundNo,
Metadata: map[string]any{"operator_id": userID, "settled_at": settledAt},
Resources: []audit.ResourceInput{primary},
}); err != nil {
return err
}
refund = &current
return nil
})
if err != nil {
return nil, err
}
return buildRefundResponse(refund), nil
}
// refundSettlementState 投影线下退款处理流水号的审计前后值。
// 只包含本用例改动的字段MUST NOT 夹带状态、实收金额等未改动事实,使审计前后值即登记差异。
func refundSettlementState(refund *model.RefundRequest) map[string]any {
state := map[string]any{
"offline_settlement_no": refund.OfflineSettlementNo,
"offline_settled_by": refund.OfflineSettledBy,
}
if refund.OfflineSettledAt != nil {
state["offline_settled_at"] = refund.OfflineSettledAt
}
return state
}