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 }