feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
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:
2026-09-18 15:34:29 +08:00
parent 5e78809b93
commit 5ed6b39deb
142 changed files with 7878 additions and 964 deletions

View 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(&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
}