feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
203
internal/service/refund/settlement.go
Normal file
203
internal/service/refund/settlement.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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(¤t, 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(¤t)
|
||||
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(¤t, id).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款申请登记后快照失败")
|
||||
}
|
||||
primary := audit.RefundResource(¤t, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.BeforeData = beforeState
|
||||
primary.AfterData = refundSettlementState(¤t)
|
||||
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 = ¤t
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user