diff --git a/cmd/worker/main.go b/cmd/worker/main.go index b92af31b..70a513a8 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -431,6 +431,12 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con appLogger.Fatal("注册企业微信审批提交 Outbox 消费者失败", zap.String("event_type", constants.OutboxEventTypeApprovalSubmissionRequested), zap.Error(err)) } + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalUnknownRecoveryRequested, wecomInfra.NewUnknownRecoveryConsumer(runtime.db, runtime.outboxQueueClient)); err != nil { + appLogger.Fatal("注册企业微信未知审批恢复消费者失败", zap.Error(err)) + } + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalManualSyncRequested, wecomInfra.NewManualSyncConsumer(runtime.db, runtime.outboxQueueClient)); err != nil { + appLogger.Fatal("注册企业微信手动同步消费者失败", zap.Error(err)) + } owner := cfg.Worker.InstanceName if owner == "" { owner = fmt.Sprintf("worker-%d", os.Getpid()) diff --git a/docs/integrations/wecom/README.md b/docs/integrations/wecom/README.md index 1af634bd..8c04b557 100644 --- a/docs/integrations/wecom/README.md +++ b/docs/integrations/wecom/README.md @@ -30,6 +30,17 @@ 业务以本地审批实例和企业微信审批单号去重,终态只推进一次;`errcode/errmsg` 保存到集成日志并映射项目错误,不能直接暴露凭证或底层报文。 +## 退款审批模板上线前置 + +退款审批场景上线前,维护者 MUST 在当前企业微信模板中完成并核对以下控件映射: + +- 固定必需:退款方式中文名称(单行文本),用于展示“原路退款”“客户收款信息退款”“退回资产钱包”“退回代理主钱包”。 +- 条件必需:客户收款信息(多行文本)与客户收款凭证(附件)。仅当本次审批尝试冻结的退款方式为 `customer_account` 时,二者必须同时配置且本次值非空;其他退款方式不要求、不提交空白占位材料。 + +保存场景时系统会校验固定退款方式映射,并校验已配置条件控件的业务字段与控件类型;提交时按不可变审批尝试快照再次校验。模板缺少客户收款条件控件时,客户收款退款会明确失败并指出缺失字段/控件,不会创建第二张审批单;修复映射后可通过 `POST /api/admin/refunds/{id}/recover-approval` 恢复原审批实例。已有 `sp_no` 只查询同步,结果未知只确认,明确提交失败且无 `sp_no` 才恢复原提交事件。 + +本次发布不执行真实企业微信或支付调用;上线验收需由维护者在指定测试企业覆盖四种退款方式、条件材料缺失、模板修复后的原实例恢复、结果未知与重复/并发恢复,并核对审批尝试数、实例数和业务资金事实不变。 + ## 安全与验证 Secret、access_token、回调 AES Key、成员敏感字段、审批正文与附件 URL 必须脱敏。可信 IP、应用可见范围、模板、回调 URL 和素材权限属于部署侧人工配置。 diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 2efdaafa..c52a8d87 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4647,8 +4647,7 @@ "operations-report::每日报表快照与不回填历史" ], "classification": "behavior" - } - , + }, { "entry_type": "cli", "entry": "docs/deployment/production-runbook.md", @@ -4657,5 +4656,33 @@ "asset-device::存量当前卡标识的核对与受控修复" ], "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/refunds/{id}/recover-approval", + "capability": "order-refund-exchange", + "requirements": [ + "identity-access::数据范围拒绝", + "order-refund-exchange::退款原审批实例可受控恢复" + ], + "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.OutboxEventTypeApprovalManualSyncRequested", + "capability": "order-refund-exchange", + "requirements": [ + "order-refund-exchange::退款原审批实例可受控恢复" + ], + "classification": "route_index_or_infrastructure" + }, + { + "entry_type": "async", + "entry": "constants.OutboxEventTypeApprovalUnknownRecoveryRequested", + "capability": "order-refund-exchange", + "requirements": [ + "order-refund-exchange::退款原审批实例可受控恢复" + ], + "classification": "route_index_or_infrastructure" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 23fab3f2..7052c588 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -5652,8 +5652,7 @@ ], "exit_status": 0 } - } - , + }, { "capability": "asset-device", "requirement": "单槽单卡设备绑定时初始化当前卡标识", @@ -5741,5 +5740,45 @@ ], "exit_status": 0 } + }, + { + "capability": "order-refund-exchange", + "requirement": "退款原审批实例可受控恢复", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "POST /api/admin/refunds/{id}/recover-approval", + "GET /api/admin/refunds", + "GET /api/admin/refunds/{id}", + "constants.OutboxEventTypeApprovalManualSyncRequested", + "constants.OutboxEventTypeApprovalUnknownRecoveryRequested" + ], + "handler_consumer_job": [ + "internal/routes/refund.go", + "internal/handler/admin/refund.go", + "cmd/worker/main.go", + "internal/infrastructure/wecom/manual_sync_consumer.go", + "internal/infrastructure/wecom/unknown_recovery_consumer.go" + ], + "application_service_query": [ + "internal/application/refundapproval/recovery.go", + "internal/infrastructure/approval/recovery.go", + "internal/service/refund/service.go" + ], + "domain_state_amount": [ + "internal/model/refund.go", + "internal/model/wecom_approval_context.go", + "internal/model/approval_instance.go" + ], + "store_migration_config": [ + "internal/model/outbox_event.go", + "migrations/000189_add_wecom_approval_recovery_timestamps.up.sql" + ], + "verification": { + "command": "gofmt -w internal/application/refundapproval/recovery.go internal/infrastructure/approval/recovery.go internal/infrastructure/wecom/manual_sync_consumer.go internal/infrastructure/wecom/unknown_recovery_consumer.go internal/service/refund/service.go cmd/worker/main.go && git diff --check && GOMODCACHE=/tmp/junhong-go-mod-cache GOCACHE=/tmp/junhong-go-build-cache go build ./cmd/api ./cmd/worker", + "literal_output": [ + "gofmt completed; git diff --check completed; go build ./cmd/api ./cmd/worker completed" + ], + "exit_status": 0 + } } ] diff --git a/internal/application/approval/port.go b/internal/application/approval/port.go index ad5510c9..65c34024 100644 --- a/internal/application/approval/port.go +++ b/internal/application/approval/port.go @@ -3,11 +3,14 @@ package approval import ( "context" + stderrors "errors" "time" "gorm.io/gorm" ) +// 审批恢复适配器方法见文件末尾。 + // PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。 type PrepareRequest struct { BusinessType string @@ -79,3 +82,35 @@ type SubmissionRequestedEvent struct { type SubmissionEventWriter interface { Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error } + +type RecoveryPort interface { + EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error + EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error + RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error) +} + +// RecoveryAdapter 将恢复动作委托给基础设施实现。 +type RecoveryAdapter struct { + SyncFunc func(context.Context, *gorm.DB, uint) error + ConfirmFunc func(context.Context, *gorm.DB, uint) error + RecoverFunc func(context.Context, *gorm.DB, uint) (bool, error) +} + +func (a RecoveryAdapter) SyncSubmitted(ctx context.Context, tx *gorm.DB, id uint) error { + if a.SyncFunc == nil { + return stderrors.New("审批原实例同步接缝未配置") + } + return a.SyncFunc(ctx, tx, id) +} +func (a RecoveryAdapter) ConfirmUnknown(ctx context.Context, tx *gorm.DB, id uint) error { + if a.ConfirmFunc == nil { + return stderrors.New("审批结果确认接缝未配置") + } + return a.ConfirmFunc(ctx, tx, id) +} +func (a RecoveryAdapter) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, id uint) (bool, error) { + if a.RecoverFunc == nil { + return false, stderrors.New("审批提交事件恢复接缝未配置") + } + return a.RecoverFunc(ctx, tx, id) +} diff --git a/internal/application/refundapproval/creation.go b/internal/application/refundapproval/creation.go index df85751b..47c1c849 100644 --- a/internal/application/refundapproval/creation.go +++ b/internal/application/refundapproval/creation.go @@ -161,8 +161,7 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint, if err := tx.WithContext(ctx).Create(attempt).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败") } - - submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, "", material) + submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, attempt, account, material) if err != nil { return err } @@ -226,10 +225,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (* if err != nil { return nil, err } - submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account, command.ApplicantRemark, command.Material) - if err != nil { - return nil, err - } + var submitterSnapshot, requestSnapshot []byte var approvalStatus int var attempt *model.RefundRequestAttempt err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -255,6 +251,10 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (* if err := tx.WithContext(ctx).Create(attempt).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败") } + submitterSnapshot, requestSnapshot, err = refundSnapshots(command.Refund, attempt, account, command.Material) + if err != nil { + return err + } reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund, BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID, @@ -359,7 +359,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R if err != nil { return err } - submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, command.ApplicantRemark, command.Material) + submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, attempt, account, command.Material) if err != nil { return err } @@ -478,6 +478,7 @@ func buildAttempt( if err != nil { return nil, err } + customerAccountInfo, customerVoucherKeys := refundCustomerMaterial(refund) return &model.RefundRequestAttempt{ RefundID: refund.ID, AttemptNo: attemptNo, @@ -486,14 +487,22 @@ func buildAttempt( FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount, RefundReason: refund.RefundReason, Remark: strings.TrimSpace(applicantRemark), - CustomerAccountInfo: refund.CustomerAccountInfo, - CustomerVoucherKeys: refund.RefundVoucherKey, + CustomerAccountInfo: customerAccountInfo, + CustomerVoucherKeys: customerVoucherKeys, PackageUsageSnapshot: snapshot, ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo), SubmittedByAccountID: refund.Creator, }, nil } +// refundCustomerMaterial 只为客户收款信息退款冻结客户收款材料;其它退款方式保持空材料,避免伪造占位内容。 +func refundCustomerMaterial(refund *model.RefundRequest) (string, model.StringJSONBArray) { + if refund == nil || refund.Method != constants.RefundMethodCustomerAccount { + return "", model.StringJSONBArray{} + } + return strings.TrimSpace(refund.CustomerAccountInfo), append(model.StringJSONBArray(nil), refund.RefundVoucherKey...) +} + // nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。 func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) { var row struct { @@ -593,36 +602,28 @@ func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRe return nil } -// refundSnapshots 冻结提交人与审批业务快照。 -// 审批业务快照是企微表单构建的数据源,因此本变更新增的审批材料字段必须在此出现, -// 且即使不可解析也要以空值或零值写入:控件映射已配置时缺失键会被表单构建明确拒绝。 -func refundSnapshots(refund *model.RefundRequest, account *model.Account, applicantRemark string, material RefundMaterial) ([]byte, []byte, error) { - submitterSnapshot, err := sonic.Marshal(map[string]any{ - "account_id": account.ID, "account_name": account.Username, "user_type": account.UserType, - }) +// refundSnapshots 冻结提交人与审批业务快照,业务可变字段全部取不可变审批尝试。 +func refundSnapshots(refund *model.RefundRequest, attempt *model.RefundRequestAttempt, account *model.Account, material RefundMaterial) ([]byte, []byte, error) { + if refund == nil || attempt == nil || account == nil { + return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批快照参数不完整") + } + submitterSnapshot, err := sonic.Marshal(map[string]any{"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType}) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败") } + customerAccountInfo, customerVoucherKeys := refundCustomerMaterialFromAttempt(attempt) requestSnapshot, err := sonic.Marshal(map[string]any{ - constants.ApprovalFieldRefundNo: refund.RefundNo, - constants.ApprovalFieldOrderID: refund.OrderID, - constants.ApprovalFieldOrderNo: refund.OrderNo, - constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier, - constants.ApprovalFieldAssetType: refund.OrderType, - constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount), - constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount), - constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey), - constants.ApprovalFieldRefundReason: refund.RefundReason, - constants.ApprovalFieldPackageUsageID: refund.PackageUsageID, - constants.ApprovalFieldRefundAssetType: material.AssetType, - constants.ApprovalFieldRefundDeviceType: material.DeviceType, - constants.ApprovalFieldRefundDeviceModel: material.DeviceModel, - constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB, - constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB, - constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo, - constants.ApprovalFieldRefundApplicantRemark: strings.TrimSpace(applicantRemark), - constants.ApprovalFieldSubmitterID: account.ID, - constants.ApprovalFieldSubmitterName: account.Username, + constants.ApprovalFieldRefundNo: refund.RefundNo, constants.ApprovalFieldOrderID: refund.OrderID, constants.ApprovalFieldOrderNo: refund.OrderNo, + constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier, constants.ApprovalFieldAssetType: refund.OrderType, + constants.ApprovalFieldActualReceivedAmount: formatCentAmount(attempt.FrozenActualReceivedAmount), constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(attempt.RefundAmount), + constants.ApprovalFieldRefundVoucherKey: []string(attempt.CustomerVoucherKeys), constants.ApprovalFieldRefundReason: attempt.RefundReason, + constants.ApprovalFieldPackageUsageID: refund.PackageUsageID, constants.ApprovalFieldRefundAssetType: material.AssetType, + constants.ApprovalFieldRefundDeviceType: material.DeviceType, constants.ApprovalFieldRefundDeviceModel: material.DeviceModel, + constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB, constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB, + constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo, constants.ApprovalFieldRefundApplicantRemark: attempt.Remark, + constants.ApprovalFieldRefundMethodCode: attempt.Method, constants.ApprovalFieldRefundMethod: constants.RefundMethodName(attempt.Method), + constants.ApprovalFieldCustomerAccountInfo: customerAccountInfo, constants.ApprovalFieldCustomerVoucherKey: []string(customerVoucherKeys), + constants.ApprovalFieldSubmitterID: account.ID, constants.ApprovalFieldSubmitterName: account.Username, }) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败") @@ -630,6 +631,13 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account, applic return submitterSnapshot, requestSnapshot, nil } +func refundCustomerMaterialFromAttempt(attempt *model.RefundRequestAttempt) (string, model.StringJSONBArray) { + if attempt == nil || attempt.Method != constants.RefundMethodCustomerAccount { + return "", model.StringJSONBArray{} + } + return strings.TrimSpace(attempt.CustomerAccountInfo), append(model.StringJSONBArray(nil), attempt.CustomerVoucherKeys...) +} + func formatCentAmount(amount int64) string { return fmt.Sprintf("%d.%02d", amount/100, amount%100) } diff --git a/internal/application/refundapproval/recovery.go b/internal/application/refundapproval/recovery.go new file mode 100644 index 00000000..36c9f6aa --- /dev/null +++ b/internal/application/refundapproval/recovery.go @@ -0,0 +1,144 @@ +package refundapproval + +import ( + "context" + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/bytedance/sonic" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type RecoveryAuditWriter interface { + WriteSnapshotRecovery(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, fields []string, actorID uint) error + WriteRecoveryRequest(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, actorID uint, branch string) error +} + +type RecoveryService struct { + db *gorm.DB + recovery approvalapp.RecoveryPort + audit RecoveryAuditWriter +} + +func NewRecoveryService(db *gorm.DB, recovery approvalapp.RecoveryPort, audit RecoveryAuditWriter) *RecoveryService { + return &RecoveryService{db: db, recovery: recovery, audit: audit} +} + +func (s *RecoveryService) Execute(ctx context.Context, refundID uint) error { + actorID := middleware.GetUserIDFromContext(ctx) + if actorID == 0 { + return errors.New(errors.CodeUnauthorized, "未认证的恢复操作") + } + if s == nil || s.db == nil || s.recovery == nil || refundID == 0 { + return errors.New(errors.CodeServiceUnavailable, "退款审批恢复能力未配置") + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var refund model.RefundRequest + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, refundID).Error; err != nil { + return errors.New(errors.CodeNotFound, "退款申请不存在") + } + if refund.Status != model.RefundStatusPending || refund.LatestAttemptID == 0 || refund.LatestApprovalInstanceID == 0 || refund.ChannelRefundStatus == constants.RefundChannelStatusProcessing || refund.Status == model.RefundStatusChannelFailed { + return errors.New(errors.CodeConflict, "当前退款状态不允许恢复审批") + } + var attempt model.RefundRequestAttempt + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND refund_id = ?", refund.LatestAttemptID, refund.ID).First(&attempt).Error; err != nil { + return errors.New(errors.CodeConflict, "退款审批尝试关联不一致") + } + if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != refund.LatestApprovalInstanceID { + return errors.New(errors.CodeConflict, "退款审批实例关联不一致") + } + var instance model.ApprovalInstance + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", refund.LatestApprovalInstanceID, constants.ApprovalBusinessTypeRefund, attempt.ID).First(&instance).Error; err != nil { + return errors.New(errors.CodeConflict, "退款审批实例业务关联不一致") + } + if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusSubmissionFailed && instance.Status != constants.ApprovalStatusSubmissionUnknown && instance.Status != constants.ApprovalStatusPending { + return errors.New(errors.CodeConflict, "审批终态不允许恢复") + } + fields, err := fillMissingSnapshot(&instance, &attempt) + if err != nil { + return err + } + if len(fields) > 0 { + if err := tx.Model(&model.ApprovalInstance{}).Where("id = ?", instance.ID).Update("request_snapshot", datatypes.JSON(instance.RequestSnapshot)).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "补齐退款审批快照失败") + } + if s.audit != nil { + if err := s.audit.WriteSnapshotRecovery(ctx, tx, &instance, fields, actorID); err != nil { + return err + } + } + } + var context model.WeComApprovalContext + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instance.ID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil { + return errors.New(errors.CodeConflict, "企业微信审批上下文不存在或业务类型不一致") + } + branch := "active" + if context.SPNo != "" { + branch = "sp_no" + if s.audit != nil { + if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil { + return err + } + } + return s.recovery.EnqueueSubmittedSync(ctx, tx, instance.ID) + } + if context.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown { + branch = "unknown" + if s.audit != nil { + if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil { + return err + } + } + return s.recovery.EnqueueUnknownConfirm(ctx, tx, instance.ID) + } + if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || context.SubmissionStatus == constants.WeComSubmissionStatusReady || context.SubmissionStatus == constants.WeComSubmissionStatusSending { + if s.audit != nil { + if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil { + return err + } + } + return nil + } + if instance.Status != constants.ApprovalStatusSubmissionFailed || context.SubmissionStatus != constants.WeComSubmissionStatusFailed { + return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复") + } + branch = "replay" + if s.audit != nil { + if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil { + return err + } + } + _, err = s.recovery.RecoverSubmissionEvent(ctx, tx, instance.ID) + return err + }) +} +func fillMissingSnapshot(instance *model.ApprovalInstance, attempt *model.RefundRequestAttempt) ([]string, error) { + var snapshot map[string]any + if err := sonic.Unmarshal(instance.RequestSnapshot, &snapshot); err != nil { + return nil, errors.Wrap(errors.CodeInvalidStatus, err, "退款审批快照无效") + } + if snapshot == nil { + snapshot = map[string]any{} + } + values := map[string]any{constants.ApprovalFieldRefundMethodCode: attempt.Method, constants.ApprovalFieldRefundMethod: constants.RefundMethodName(attempt.Method), constants.ApprovalFieldCustomerAccountInfo: attempt.CustomerAccountInfo, constants.ApprovalFieldCustomerVoucherKey: []string(attempt.CustomerVoucherKeys)} + fields := make([]string, 0, len(values)) + for key, value := range values { + if _, ok := snapshot[key]; ok { + continue + } + snapshot[key] = value + fields = append(fields, key) + } + if len(fields) > 0 { + encoded, err := sonic.Marshal(snapshot) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批快照失败") + } + instance.RequestSnapshot = encoded + } + return fields, nil +} diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index 74afe210..a59c15d5 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -132,6 +132,10 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射") return nil, err } + if err := validateRefundConditionalMappings(businessType, request.ControlMapping, definition.Controls); err != nil { + s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法退款条件材料控件映射") + return nil, err + } request.ControlMapping = normalizeSceneMapping(request.ControlMapping) mappingJSON, err := sonic.Marshal(request.ControlMapping) if err != nil { @@ -305,19 +309,14 @@ func validateSceneMapping(businessType string, mapping []dto.WeComControlMapping return nil } -// RequiredSceneField 描述本变更新增、必须配置控件映射的业务字段。 +// RequiredSceneField 描述审批字段映射的固定或条件必需规则。 type RequiredSceneField struct { - // Code 是 control_mapping.business_field 应填写的稳定字段编码。 - Code string - // Name 是字段中文名称,用于在缺失时指出具体缺哪个字段/控件。 - Name string + Code string + Name string + RequiredMapping bool } -// RequiredSceneFields 返回指定业务类型下必须配置控件映射的业务字段,顺序与场景白名单一致。 -// -// 这是「必须映射字段」的唯一判定来源:场景映射校验(保存时)与表单构建(提交时)都从 -// 这里取集合,MUST NOT 再维护第二份清单。既有未标注必须映射的可选字段不在返回结果中, -// 因此其缺失映射仍保持既有静默跳过行为。 +// RequiredSceneFields 返回场景级固定必需字段。 func RequiredSceneFields(businessType string) []RequiredSceneField { fields, ok := sceneBusinessFields(businessType) if !ok { @@ -326,16 +325,31 @@ func RequiredSceneFields(businessType string) []RequiredSceneField { result := make([]RequiredSceneField, 0, len(fields)) for _, field := range fields { if field.RequiredMapping { - result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name}) + result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name, RequiredMapping: true}) } } return result } +// RequiredRefundFields 返回退款提交固定与条件必需字段;快照方式编码缺失或未知时由表单构建拒绝。 +func RequiredRefundFields(snapshot map[string]any) []RequiredSceneField { + methodCode, ok := snapshot[constants.ApprovalFieldRefundMethodCode].(string) + methodCode = strings.TrimSpace(methodCode) + if !ok || methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) { + return []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码"}} + } + result := []RequiredSceneField{{Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", RequiredMapping: true}} + if methodCode == constants.RefundMethodCustomerAccount { + result = append(result, RequiredSceneField{Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息"}, RequiredSceneField{Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证"}) + } + return result +} + // approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。 func approvalBusinessTypes() []string { return []string{ constants.ApprovalBusinessTypeRefund, + constants.ApprovalBusinessTypeOfflineRecharge, constants.ApprovalBusinessTypeEmployeeCollection, constants.ApprovalBusinessTypeAgentDistribution, @@ -344,6 +358,30 @@ func approvalBusinessTypes() []string { } } +// validateRefundConditionalMappings 校验退款条件字段使用正确的企微控件类型。 +func validateRefundConditionalMappings(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error { + if businessType != constants.ApprovalBusinessTypeRefund { + return nil + } + types := make(map[string]string, len(controls)) + for _, control := range controls { + types[control.ID] = strings.ToLower(strings.TrimSpace(control.Type)) + } + for _, item := range mapping { + switch item.BusinessField { + case constants.ApprovalFieldCustomerAccountInfo: + if types[item.ControlID] != "textarea" { + return errors.New(errors.CodeInvalidParam, "客户收款信息必须映射到多行文本控件") + } + case constants.ApprovalFieldCustomerVoucherKey: + if types[item.ControlID] != "file" { + return errors.New(errors.CodeInvalidParam, "客户收款凭证必须映射到附件控件") + } + } + } + return nil +} + func allowedSceneBusinessField(businessType, businessField string) bool { fields, ok := sceneBusinessFields(businessType) if !ok { @@ -394,6 +432,10 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse, {Code: constants.ApprovalFieldRefundPackageTotalMB, Name: "当前退款套餐总量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐总量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true}, {Code: constants.ApprovalFieldRefundChannelTradeNo, Name: "原支付渠道交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "创建申请时冻结的原成功支付记录渠道交易流水号;线下订单为空字符串", RequiredMapping: true}, {Code: constants.ApprovalFieldRefundApplicantRemark, Name: "申请人备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提交填写的申请人备注快照;未填写时为空字符串。与审批备注不是同一字段", RequiredMapping: true}, + {Code: constants.ApprovalFieldRefundMethodCode, Name: "退款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "仅用于按本次审批快照计算条件材料,不要求配置模板控件"}, + {Code: constants.ApprovalFieldRefundMethod, Name: "退款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次审批尝试冻结的退款方式中文名称", RequiredMapping: true}, + {Code: constants.ApprovalFieldCustomerAccountInfo, Name: "客户收款信息", ValueType: constants.ApprovalFieldValueTypeString, Description: "客户收款信息退款的多行收款文本;其它退款方式为空"}, + {Code: constants.ApprovalFieldCustomerVoucherKey, Name: "客户收款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "客户收款信息退款的凭证列表;其它退款方式为空"}, {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, }, true diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index e9a5ce84..df209b2b 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -406,9 +406,10 @@ func initServices(s *stores, deps *Dependencies) *services { refundService.SetEmployeeCollectionRefundOffset( employeecollectionApp.NewRefundOffsetService(auditWriter), ) - refundService.SetRefundApprovalCreationService( - refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter), - ) + recoveryPort := approvalInfra.NewRecoveryPort(deps.DB, deps.QueueClient) + refundApprovalUseCase := refundapprovalApp.NewRecoveryService(deps.DB, recoveryPort, auditWriter) + refundService.SetRefundApprovalRecoveryService(recoveryPort) + refundService.SetRefundApprovalRecoveryUseCase(refundApprovalUseCase) roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole) roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter) permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis) diff --git a/internal/handler/admin/refund.go b/internal/handler/admin/refund.go index 540bed05..5faa7902 100644 --- a/internal/handler/admin/refund.go +++ b/internal/handler/admin/refund.go @@ -83,6 +83,20 @@ func (h *RefundHandler) TriggerApproval(c *fiber.Ctx) error { return response.Success(c, result) } +// RecoverApproval 恢复退款最新审批实例的原提交事实。 +// POST /api/admin/refunds/:id/recover-approval +func (h *RefundHandler) RecoverApproval(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的退款申请ID") + } + result, err := h.service.RecoverApproval(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, result) +} + // Approve 审批通过退款申请 // POST /api/admin/refunds/:id/approve func (h *RefundHandler) Approve(c *fiber.Ctx) error { diff --git a/internal/infrastructure/approval/recovery.go b/internal/infrastructure/approval/recovery.go new file mode 100644 index 00000000..e5061f1b --- /dev/null +++ b/internal/infrastructure/approval/recovery.go @@ -0,0 +1,89 @@ +package approval + +import ( + "context" + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "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" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "strconv" + "time" +) + +// RecoveryPort 将恢复请求限制为本地任务入队与事件状态转换。 +type RecoveryPort struct{ outbox *outbox.Repository } + +func NewRecoveryPort(_ *gorm.DB, _ ...any) *RecoveryPort { + return &RecoveryPort{outbox: outbox.NewRepository()} +} +func (r *RecoveryPort) EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error { + return r.enqueueSync(ctx, tx, instanceID) +} +func (r *RecoveryPort) EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error { + return r.enqueueUnknown(ctx, tx, instanceID) +} +func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID uint) error { + var instance model.ApprovalInstance + if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil { + return gorm.ErrInvalidData + } + var context model.WeComApprovalContext + if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted { + return gorm.ErrInvalidData + } + id := strconv.FormatUint(uint64(instanceID), 10) + payload := map[string]any{"instance_id": instanceID, "application_id": context.ApplicationID, "sp_no": context.SPNo} + _, err := r.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: "approval:" + id + ":manual-sync", EventType: constants.OutboxEventTypeApprovalManualSyncRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: id, ResourceType: instance.BusinessType, ResourceID: strconv.FormatUint(uint64(instance.BusinessID), 10), Payload: payload}) + return err +} +func (r *RecoveryPort) enqueueUnknown(ctx context.Context, tx *gorm.DB, instanceID uint) error { + var instance model.ApprovalInstance + if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown { + return gorm.ErrInvalidData + } + var context model.WeComApprovalContext + if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" { + return gorm.ErrInvalidData + } + id := strconv.FormatUint(uint64(instanceID), 10) + _, err := r.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: "approval:" + id + ":unknown-recovery", EventType: constants.OutboxEventTypeApprovalUnknownRecoveryRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: id, ResourceType: instance.BusinessType, ResourceID: strconv.FormatUint(uint64(instance.BusinessID), 10), Payload: map[string]any{"instance_id": instanceID}}) + return err +} +func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error) { + if tx == nil || instanceID == 0 { + return false, gorm.ErrInvalidData + } + var instance model.ApprovalInstance + if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil { + return false, err + } + if instance.Status != constants.ApprovalStatusSubmissionFailed { + return false, gorm.ErrInvalidData + } + var context model.WeComApprovalContext + if err := tx.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" { + return false, gorm.ErrInvalidData + } + eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":submission" + var event model.OutboxEvent + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&event).Error; err != nil { + return false, err + } + id := strconv.FormatUint(uint64(instanceID), 10) + rid := strconv.FormatUint(uint64(instance.BusinessID), 10) + if event.EventType != constants.OutboxEventTypeApprovalSubmissionRequested || event.AggregateType != "approval" || event.AggregateID != id || event.ResourceType != instance.BusinessType || event.ResourceID != rid { + return false, gorm.ErrInvalidData + } + if event.Status == constants.OutboxStatusPending || event.Status == constants.OutboxStatusDelivering { + return false, nil + } + if event.Status != constants.OutboxStatusFailed && event.Status != constants.OutboxStatusDelivered { + return false, nil + } + result := tx.WithContext(ctx).Model(&model.OutboxEvent{}).Where("id = ? AND status IN ?", event.ID, []int{constants.OutboxStatusFailed, constants.OutboxStatusDelivered}).Updates(map[string]any{"status": constants.OutboxStatusPending, "next_attempt_at": time.Now().UTC(), "last_error_code": "", "last_error_summary": "", "lease_owner": nil, "lease_expires_at": nil, "delivered_at": nil, "updated_at": time.Now().UTC()}) + return result.RowsAffected == 1, result.Error +} + +var _ approvalapp.RecoveryPort = (*RecoveryPort)(nil) diff --git a/internal/infrastructure/approval/submission_event.go b/internal/infrastructure/approval/submission_event.go index 7a865e88..6317cc9b 100644 --- a/internal/infrastructure/approval/submission_event.go +++ b/internal/infrastructure/approval/submission_event.go @@ -36,3 +36,17 @@ func (w *SubmissionEventWriter) Append(ctx context.Context, tx *gorm.DB, event a }) return err } + +type UnknownRecoveryEventWriter struct{ outbox *outbox.Repository } + +func NewUnknownRecoveryEventWriter(repository *outbox.Repository) *UnknownRecoveryEventWriter { + return &UnknownRecoveryEventWriter{outbox: repository} +} +func (w *UnknownRecoveryEventWriter) Append(ctx context.Context, tx *gorm.DB, instanceID uint) error { + if w == nil || w.outbox == nil || instanceID == 0 { + return errors.New(errors.CodeInternalError, "审批未知恢复 Outbox Writer 未配置") + } + eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":unknown-recovery" + _, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: eventID, EventType: constants.OutboxEventTypeApprovalUnknownRecoveryRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(instanceID), 10), ResourceType: "approval", ResourceID: strconv.FormatUint(uint64(instanceID), 10), BusinessKey: eventID, Payload: map[string]any{"instance_id": instanceID}}) + return err +} diff --git a/internal/infrastructure/audit/approval.go b/internal/infrastructure/audit/approval.go index 445ec9b9..86b74115 100644 --- a/internal/infrastructure/audit/approval.go +++ b/internal/infrastructure/audit/approval.go @@ -37,6 +37,24 @@ func (w *Writer) WriteApproval(ctx context.Context, tx *gorm.DB, change approval }) } +func (w *Writer) WriteSnapshotRecovery(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, fields []string, actorID uint) error { + if instance == nil || instance.ID == 0 || len(fields) == 0 || actorID == 0 { + return errors.New(errors.CodeInvalidParam, "退款审批快照恢复审计资源不完整") + } + id := strconv.FormatUint(uint64(instance.ID), 10) + actor := strconv.FormatUint(uint64(actorID), 10) + return w.Append(ctx, tx, AppendInput{EventID: "approval:" + id + ":audit:snapshot_recovered", ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "补齐退款审批快照字段", Actor: ActorInput{Kind: constants.AuditActorAccount, ID: actor}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Metadata: map[string]any{"fields": fields}, Resources: []ResourceInput{{Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "fields": fields}}}}) +} + +func (w *Writer) WriteRecoveryRequest(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, actorID uint, branch string) error { + if instance == nil || instance.ID == 0 || actorID == 0 || strings.TrimSpace(branch) == "" { + return errors.New(errors.CodeInvalidParam, "退款审批恢复请求审计资源不完整") + } + id := strconv.FormatUint(uint64(instance.ID), 10) + actor := strconv.FormatUint(uint64(actorID), 10) + return w.Append(ctx, tx, AppendInput{EventID: "approval:" + id + ":audit:recovery_request:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "请求恢复退款审批原提交", Actor: ActorInput{Kind: constants.AuditActorAccount, ID: actor}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Metadata: map[string]any{"branch": branch}, Resources: []ResourceInput{{Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "status": instance.Status}}}}) +} + func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) ([]ResourceInput, error) { instanceID := strconv.FormatUint(uint64(change.InstanceID), 10) resources := []ResourceInput{{ diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 61b32825..b622a3ff 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -375,6 +375,7 @@ func NewRegistry() *Registry { approvalSubmissionRecovered := approvalAction(constants.AuditActionApprovalSubmissionRecovered, "恢复审批提交结果", []ActionOrigin{ {Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler}, {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, + {Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}, }) approvalDecisionSynced := approvalAction(constants.AuditActionApprovalDecisionSynced, "同步审批权威终态", []ActionOrigin{ {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, diff --git a/internal/infrastructure/wecom/approval_context_repository.go b/internal/infrastructure/wecom/approval_context_repository.go index cc32baf6..6706b5d2 100644 --- a/internal/infrastructure/wecom/approval_context_repository.go +++ b/internal/infrastructure/wecom/approval_context_repository.go @@ -371,6 +371,17 @@ func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationI } return r.Get(ctx, channelContext.ApprovalInstanceID) } +func (r *ApprovalContextRepository) GetRecoveryRecord(ctx context.Context, instanceID uint) (*ApprovalRecoveryRecord, error) { + var record ApprovalRecoveryRecord + err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc").Select("wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at").Where("wc.approval_instance_id = ?", instanceID).Scan(&record).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批恢复记录失败") + } + if record.InstanceID == 0 { + return nil, nil + } + return &record, nil +} // SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。 func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error { diff --git a/internal/infrastructure/wecom/approval_form.go b/internal/infrastructure/wecom/approval_form.go index ad7ce8c4..25e66fb6 100644 --- a/internal/infrastructure/wecom/approval_form.go +++ b/internal/infrastructure/wecom/approval_form.go @@ -51,32 +51,51 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID, for _, control := range template.Controls { requiredControls[control.ID] = control.Required } - // 本变更新增字段必须配置控件映射:场景里没有该字段的映射条目时,下面的遍历永远不会看到它, - // 该字段会被静默丢弃。因此在组装前先针对本次提交使用的映射逐一确认必须字段有条目, - // 缺失即明确失败并指出缺哪个字段/控件;本判定与场景映射校验共用同一份 RequiredSceneFields。 - // 既有未标注必须映射的可选字段不在其中,缺失映射时保持既有静默跳过行为。 - mappingFields := make(map[string]struct{}, len(mappings)) + mappingFields := make(map[string]dto.WeComControlMappingItem, len(mappings)) for _, item := range mappings { - mappingFields[strings.TrimSpace(item.BusinessField)] = struct{}{} + mappingFields[strings.TrimSpace(item.BusinessField)] = item } - for _, field := range wecomapp.RequiredSceneFields(businessType) { - if _, exists := mappingFields[field.Code]; !exists { - return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, - "企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+"),请先配置该控件映射后再提交") + requiredFields := wecomapp.RequiredSceneFields(businessType) + if businessType == constants.ApprovalBusinessTypeRefund { + requiredFields = append(requiredFields, wecomapp.RequiredRefundFields(snapshot)...) + } + for _, field := range requiredFields { + mapping, exists := mappingFields[field.Code] + if !exists { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+")") } - } - // 本变更新增字段缺失快照值时同样明确失败:映射已配置却拿不到值,静默跳过会让审批材料 - // 与本地事实不一致。该校验必须在循环之前完成——循环内的既有文件类控件会经 - // attachments.Upload 触达外部系统,若把校验留在循环内,失败可能晚于外部调用(ENG-TX-001 - // 要求事务内不得持有不可回滚的长外部 I/O,同理失败必须发生在任何外呼之前)。 - // 既有未映射(或映射到既有可选字段)的控件保持既有静默跳过行为。 - requiredFields := make(map[string]string) - for _, field := range wecomapp.RequiredSceneFields(businessType) { - requiredFields[field.Code] = field.Name if _, exists := lookupSnapshotValue(snapshot, field.Code); !exists { - return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, - "审批业务快照缺少必须提交的字段: "+field.Name+"(业务字段 "+field.Code+"),请确认已配置对应控件与业务字段映射") + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+field.Name) } + if businessType == constants.ApprovalBusinessTypeRefund && field.Code == constants.ApprovalFieldRefundMethodCode { + methodCode, _ := snapshot[field.Code].(string) + methodCode = strings.TrimSpace(methodCode) + if methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少有效退款方式编码") + } + } + if field.Code == constants.ApprovalFieldCustomerAccountInfo { + value, _ := snapshot[field.Code].(string) + if strings.TrimSpace(value) == "" { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少非空客户收款信息") + } + if !strings.EqualFold(mapping.ControlType, "textarea") { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款信息必须映射到多行文本控件") + } + } + if field.Code == constants.ApprovalFieldCustomerVoucherKey { + refs, err := parseApprovalFileReferences(snapshot[field.Code]) + if err != nil || len(refs) == 0 { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少客户收款凭证") + } + if !strings.EqualFold(mapping.ControlType, "file") { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款凭证必须映射到附件控件") + } + } + } + fieldNames := make(map[string]string, len(requiredFields)) + for _, field := range requiredFields { + fieldNames[field.Code] = field.Name } contents := make([]map[string]any, 0, len(mappings)) attachmentCount := 0 @@ -86,7 +105,7 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID, if requiredControls[mapping.ControlID] { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField) } - if name, required := requiredFields[strings.TrimSpace(mapping.BusinessField)]; required { + if name, required := fieldNames[strings.TrimSpace(mapping.BusinessField)]; required { return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+name+",请确认已配置对应控件与业务字段映射") } continue diff --git a/internal/infrastructure/wecom/approval_recovery_task.go b/internal/infrastructure/wecom/approval_recovery_task.go index f28c3f7e..d1f1ff0c 100644 --- a/internal/infrastructure/wecom/approval_recovery_task.go +++ b/internal/infrastructure/wecom/approval_recovery_task.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/bytedance/sonic" "github.com/hibiken/asynq" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -25,12 +26,21 @@ func NewApprovalRecoveryTaskHandler(contexts *ApprovalContextRepository, infos * return &ApprovalRecoveryTaskHandler{contexts: contexts, infos: infos, queue: queueClient, now: time.Now} } -// Handle 扫描未终态和结果未知记录;结果未知只查询关联,绝不重新调用 applyevent。 -func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error { +// Handle 支持全局扫描任务与按实例人工任务,二者共享同一 unknown 确认算法。 +func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error { if h == nil || h.contexts == nil || h.infos == nil || h.queue == nil { return errors.New(errors.CodeServiceUnavailable, "企业微信审批主动恢复任务未配置") } + var payload struct { + InstanceID uint `json:"instance_id"` + } + if task != nil && len(task.Payload()) > 0 { + _ = sonic.Unmarshal(task.Payload(), &payload) + } now := h.now().UTC() + if payload.InstanceID > 0 { + return h.recoverUnknownInstance(ctx, payload.InstanceID, now) + } if err := h.contexts.PromoteStaleSendingToUnknown(ctx, now.Add(-constants.WeComApprovalSendingLease)); err != nil { return err } @@ -53,48 +63,58 @@ func (h *ApprovalRecoveryTaskHandler) enqueuePendingSync(ctx context.Context, no return nil } +func (h *ApprovalRecoveryTaskHandler) recoverUnknownInstance(ctx context.Context, instanceID uint, now time.Time) error { + record, err := h.contexts.GetRecoveryRecord(ctx, instanceID) + if err != nil || record == nil { + return err + } + claimed, err := h.contexts.ClaimUnknownRecovery(ctx, instanceID, now.Add(-constants.WeComApprovalPollingInterval)) + if err != nil || !claimed { + return err + } + return h.recoverUnknownRecord(ctx, *record, now) +} + +func (h *ApprovalRecoveryTaskHandler) recoverUnknownRecord(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) error { + spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID) + if err != nil { + return err + } + integrationIDs := []string{submissionIntegrationID} + if spNo == "" { + var ids []string + spNo, ids, err = h.findUniqueSPNo(ctx, record, now) + if err != nil { + return err + } + integrationIDs = append(integrationIDs, ids...) + } + if spNo == "" { + return nil + } + recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo, integrationIDs, constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler) + if err != nil || !recovered { + return err + } + return h.enqueueDetailSync(ctx, record.ApplicationID, spNo) +} + func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now time.Time) error { - cutoff := now.Add(-constants.WeComApprovalPollingInterval) - records, err := h.contexts.ListUnknownRecovery(ctx, cutoff, constants.WeComApprovalUnknownRecoveryBatchSize) + records, err := h.contexts.ListUnknownRecovery(ctx, now.Add(-constants.WeComApprovalPollingInterval), constants.WeComApprovalUnknownRecoveryBatchSize) if err != nil { return err } for _, record := range records { - claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, cutoff) + claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, now.Add(-constants.WeComApprovalPollingInterval)) if err != nil { return err } if !claimed { continue } - spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID) - if err != nil { + if err := h.recoverUnknownRecord(ctx, record, now); err != nil { return err } - integrationIDs := []string{submissionIntegrationID} - if spNo == "" { - var recoveryIntegrationIDs []string - spNo, recoveryIntegrationIDs, err = h.findUniqueSPNo(ctx, record, now) - if err != nil { - return err - } - integrationIDs = append(integrationIDs, recoveryIntegrationIDs...) - } - if spNo == "" { - continue - } - recovered, err := h.contexts.RecoverSubmitted( - ctx, record.InstanceID, spNo, integrationIDs, - constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler, - ) - if err != nil { - return err - } - if recovered { - if err := h.enqueueDetailSync(ctx, record.ApplicationID, spNo); err != nil { - return err - } - } } return nil } diff --git a/internal/infrastructure/wecom/manual_sync_consumer.go b/internal/infrastructure/wecom/manual_sync_consumer.go new file mode 100644 index 00000000..3c58b1ab --- /dev/null +++ b/internal/infrastructure/wecom/manual_sync_consumer.go @@ -0,0 +1,53 @@ +package wecom + +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/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/queue" +) + +type ManualSyncConsumer struct { + db *gorm.DB + queue *queue.Client +} + +func NewManualSyncConsumer(db *gorm.DB, client *queue.Client) *ManualSyncConsumer { + return &ManualSyncConsumer{db: db, queue: client} +} + +func (c *ManualSyncConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + if c == nil || c.db == nil || c.queue == nil { + return errors.New(errors.CodeServiceUnavailable, "审批手动同步消费者未配置") + } + var payload struct { + InstanceID uint `json:"instance_id"` + ApplicationID uint `json:"application_id"` + SPNo string `json:"sp_no"` + } + if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil || payload.InstanceID == 0 || payload.ApplicationID == 0 || payload.SPNo == "" { + return errors.New(errors.CodeInvalidParam, "审批手动同步事件载荷无效") + } + id := strconv.FormatUint(uint64(payload.InstanceID), 10) + var instance model.ApprovalInstance + if err := c.db.WithContext(ctx).Where("id = ?", payload.InstanceID).First(&instance).Error; err != nil { + return errors.New(errors.CodeConflict, "审批手动同步实例不存在") + } + if envelope.EventType != constants.OutboxEventTypeApprovalManualSyncRequested || envelope.AggregateType != "approval" || envelope.AggregateID != id || envelope.ResourceType != instance.BusinessType || envelope.ResourceID != strconv.FormatUint(uint64(instance.BusinessID), 10) { + return errors.New(errors.CodeInvalidParam, "审批手动同步事件身份无效") + } + var channelContext model.WeComApprovalContext + if err := c.db.WithContext(ctx).Where("approval_instance_id = ? AND application_id = ? AND sp_no = ? AND submission_status = ?", payload.InstanceID, payload.ApplicationID, payload.SPNo, constants.WeComSubmissionStatusSubmitted).First(&channelContext).Error; err != nil { + return errors.New(errors.CodeConflict, "审批手动同步上下文不一致") + } + return c.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{ApplicationID: payload.ApplicationID, SPNo: payload.SPNo, Source: constants.ApprovalSyncSourceManual}) +} + +var _ outbox.EventConsumer = (*ManualSyncConsumer)(nil) diff --git a/internal/infrastructure/wecom/unknown_recovery_consumer.go b/internal/infrastructure/wecom/unknown_recovery_consumer.go new file mode 100644 index 00000000..7796f399 --- /dev/null +++ b/internal/infrastructure/wecom/unknown_recovery_consumer.go @@ -0,0 +1,51 @@ +package wecom + +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/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/queue" +) + +type UnknownRecoveryConsumer struct { + db *gorm.DB + queue *queue.Client +} + +func NewUnknownRecoveryConsumer(db *gorm.DB, client *queue.Client) *UnknownRecoveryConsumer { + return &UnknownRecoveryConsumer{db: db, queue: client} +} + +func (c *UnknownRecoveryConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + if c == nil || c.db == nil || c.queue == nil { + return errors.New(errors.CodeServiceUnavailable, "审批未知恢复消费者未配置") + } + var payload struct { + InstanceID uint `json:"instance_id"` + } + if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil || payload.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "审批未知恢复事件载荷无效") + } + id := strconv.FormatUint(uint64(payload.InstanceID), 10) + var instance model.ApprovalInstance + if err := c.db.WithContext(ctx).Where("id = ?", payload.InstanceID).First(&instance).Error; err != nil { + return errors.New(errors.CodeConflict, "审批未知恢复实例不存在") + } + if envelope.EventType != constants.OutboxEventTypeApprovalUnknownRecoveryRequested || envelope.AggregateType != "approval" || envelope.AggregateID != id || envelope.ResourceType != instance.BusinessType || envelope.ResourceID != strconv.FormatUint(uint64(instance.BusinessID), 10) { + return errors.New(errors.CodeInvalidParam, "审批未知恢复事件身份无效") + } + var approvalContext model.WeComApprovalContext + if err := c.db.WithContext(ctx).Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", payload.InstanceID, constants.WeComSubmissionStatusUnknown).First(&approvalContext).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown { + return errors.New(errors.CodeConflict, "审批未知恢复上下文不一致") + } + return c.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalRecovery, map[string]any{"instance_id": payload.InstanceID}) +} + +var _ outbox.EventConsumer = (*UnknownRecoveryConsumer)(nil) diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index afba6083..4a034ad3 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -97,35 +97,40 @@ type RefundResponse struct { ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"` ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"` // 结算标识:前两项是冻结的来源支付事实,第三项是财务补录的线下处理流水号,语义互不相同。 - SourcePaymentNo string `json:"source_payment_no" description:"冻结的来源支付单号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"` - OriginalChannelTradeNo string `json:"original_channel_trade_no" description:"冻结的原支付渠道交易流水号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"` - OfflineSettlementNo string `json:"offline_settlement_no" description:"线下退款处理流水号或凭证编号,由授权账号补录或更正;与渠道退款流水号语义不同"` - OfflineSettledAt string `json:"offline_settled_at,omitempty" description:"线下退款处理流水号最近一次登记或更正时间"` - OfflineSettledBy uint `json:"offline_settled_by" description:"线下退款处理流水号最近一次登记或更正的操作账号ID,0 表示未登记"` - 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:"更新时间"` + SourcePaymentNo string `json:"source_payment_no" description:"冻结的来源支付单号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"` + OriginalChannelTradeNo string `json:"original_channel_trade_no" description:"冻结的原支付渠道交易流水号,取创建申请时的原成功支付记录;线下订单无线上支付记录时为空字符串"` + OfflineSettlementNo string `json:"offline_settlement_no" description:"线下退款处理流水号或凭证编号,由授权账号补录或更正;与渠道退款流水号语义不同"` + OfflineSettledAt string `json:"offline_settled_at,omitempty" description:"线下退款处理流水号最近一次登记或更正时间"` + OfflineSettledBy uint `json:"offline_settled_by" description:"线下退款处理流水号最近一次登记或更正的操作账号ID,0 表示未登记"` + 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:"审批状态"` + ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"` + ApprovalSubmissionStatus *int `json:"submission_status,omitempty" description:"企业微信审批提交状态"` + ApprovalSubmissionStatusName string `json:"submission_status_name,omitempty" description:"企业微信审批提交状态名称(中文)"` + ApprovalRecoverable bool `json:"approval_recoverable" description:"是否允许恢复原审批提交"` + ApprovalFailureSummary string `json:"approval_failure_summary,omitempty" description:"脱敏审批失败摘要"` + ApprovalLastRecoveryAt string `json:"approval_last_recovery_at,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 退款申请列表分页响应 diff --git a/internal/routes/refund.go b/internal/routes/refund.go index 15d4a589..a29e8447 100644 --- a/internal/routes/refund.go +++ b/internal/routes/refund.go @@ -70,6 +70,10 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc Auth: true, }) + Register(refund, doc, groupPath, "POST", "/:id/recover-approval", handler.RecoverApproval, RouteSpec{ + Summary: "恢复退款原审批提交", Description: "仅恢复最新退款审批实例的原提交事件,不创建新审批实例,不直接调用企业微信接口;审批提交结果未知时必须先由恢复任务确认。", Tags: []string{"退款管理"}, Input: new(dto.RefundIDRequest), Output: new(dto.RefundResponse), Auth: true, + }) + Register(refund, doc, groupPath, "POST", "/:id/approve", handler.Approve, RouteSpec{ Summary: "审批通过退款申请", Tags: []string{"退款管理"}, diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 1721141c..bf3fc3e1 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" @@ -55,6 +56,8 @@ type Service struct { deviceStore *postgres.DeviceStore assetWalletStore *postgres.AssetWalletStore agentWalletRefundService *walletapp.RefundService + refundApprovalRecovery approvalapp.RecoveryPort + refundApprovalUseCase *refundapprovalapp.RecoveryService refundApprovalCreation *refundapprovalapp.CreationService notificationOutbox *outbox.Repository auditWriter *audit.Writer @@ -122,6 +125,15 @@ func (s *Service) SetNotificationOutbox(repository *outbox.Repository) { s.notificationOutbox = repository } +// SetRefundApprovalRecoveryService 注入通用审批恢复接缝。 +func (s *Service) SetRefundApprovalRecoveryService(recovery approvalapp.RecoveryPort) { + s.refundApprovalRecovery = recovery +} + +func (s *Service) SetRefundApprovalRecoveryUseCase(recovery *refundapprovalapp.RecoveryService) { + s.refundApprovalUseCase = recovery +} + // SetLifecycleAudit 注入退款完整业务链统一审计 Writer。 func (s *Service) SetLifecycleAudit(writer *audit.Writer) { s.auditWriter = writer @@ -314,6 +326,21 @@ func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResp return resp, nil } +// RecoverApproval 只负责按退款列表/详情可见范围定位并委托退款审批恢复用例;HTTP 不执行企微外呼。 +func (s *Service) RecoverApproval(ctx context.Context, id uint) (*dto.RefundResponse, error) { + if s == nil || s.refundApprovalUseCase == nil || id == 0 { + return nil, errors.New(errors.CodeServiceUnavailable, "退款审批恢复能力未配置") + } + // 恢复是对原可见退款的管理操作:沿用详情读取 scope,不能使用按 creator 限制的写 scope。 + if _, err := s.refundStore.GetByID(ctx, id); err != nil { + return nil, errors.New(errors.CodeNotFound, "退款申请不存在") + } + if err := s.refundApprovalUseCase.Execute(ctx, id); err != nil { + return nil, err + } + return s.GetByID(ctx, id) +} + func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) { refund, err := s.refundStore.GetByID(ctx, id) if err != nil { @@ -1364,47 +1391,106 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { } type approvalSummary struct { - Provider string - Status int + Provider string + Status int + SubmissionStatus int + FailureSummary string + LastRecoveryAt *time.Time + Recoverable bool + SPNo string + EventStatus int +} + +func appendUniqueUint(values []uint, seen map[uint]struct{}, value uint) []uint { + if value == 0 { + return values + } + if _, ok := seen[value]; ok { + return values + } + seen[value] = struct{}{} + return append(values, value) } func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.RefundRequest) (map[uint]approvalSummary, error) { ids := make([]uint, 0, len(refunds)) seen := make(map[uint]struct{}, len(refunds)) + refundIDs := make([]uint, 0, len(refunds)) for _, refund := range refunds { - if refund == nil || refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID == 0 { + if refund == nil { continue } - id := *refund.ApprovalInstanceID - if _, exists := seen[id]; exists { - continue + refundIDs = append(refundIDs, refund.ID) + ids = appendUniqueUint(ids, seen, refund.LatestApprovalInstanceID) + if refund.ApprovalInstanceID != nil { + ids = appendUniqueUint(ids, seen, *refund.ApprovalInstanceID) } - seen[id] = struct{}{} - ids = append(ids, id) } - summaries := make(map[uint]approvalSummary, len(ids)) + var latest, first []struct { + RefundID uint + ApprovalInstanceID uint + } + if len(refundIDs) > 0 { + if err := s.db.WithContext(ctx).Raw(`SELECT DISTINCT ON (refund_id) refund_id, approval_instance_id FROM tb_refund_request_attempt WHERE refund_id IN ? AND approval_instance_id IS NOT NULL ORDER BY refund_id, attempt_no DESC, id DESC`, refundIDs).Scan(&latest).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款最新审批实例失败") + } + if err := s.db.WithContext(ctx).Raw(`SELECT DISTINCT ON (refund_id) refund_id, approval_instance_id FROM tb_refund_request_attempt WHERE refund_id IN ? AND approval_instance_id IS NOT NULL ORDER BY refund_id, attempt_no ASC, id ASC`, refundIDs).Scan(&first).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款首个审批实例失败") + } + } + for _, row := range latest { + ids = appendUniqueUint(ids, seen, row.ApprovalInstanceID) + } + for _, row := range first { + ids = appendUniqueUint(ids, seen, row.ApprovalInstanceID) + } + result := make(map[uint]approvalSummary, len(ids)) if len(ids) == 0 { - return summaries, nil + return result, nil } - var instances []model.ApprovalInstance - if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批状态失败") + var rows []struct { + ID uint + Provider string + Status int + LastError string + LastRecoveryAt *time.Time + SPNo string + SubmissionStatus int + EventStatus int } - for _, instance := range instances { - summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status} + query := `SELECT ai.id, ai.provider, ai.status, COALESCE(wc.last_error, '') AS last_error, wc.last_recovery_at, COALESCE(wc.sp_no, '') AS sp_no, COALESCE(wc.submission_status, 0) AS submission_status, COALESCE(oe.status, 0) AS event_status FROM tb_approval_instance ai LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = ai.id LEFT JOIN tb_outbox_event oe ON oe.event_id = ('approval:' || ai.id::text || ':submission') WHERE ai.id IN ?` + if err := s.db.WithContext(ctx).Raw(query, ids).Scan(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批投影失败") } - return summaries, nil + for _, row := range rows { + summary := approvalSummary{Provider: row.Provider, Status: row.Status, SubmissionStatus: row.SubmissionStatus, FailureSummary: sanitizeApprovalFailure(row.LastError), LastRecoveryAt: row.LastRecoveryAt, SPNo: row.SPNo, EventStatus: row.EventStatus} + summary.Recoverable = row.Status == constants.ApprovalStatusSubmissionFailed && row.SubmissionStatus == constants.WeComSubmissionStatusFailed && row.SPNo == "" && (row.EventStatus == constants.OutboxStatusFailed || row.EventStatus == constants.OutboxStatusDelivered) + result[row.ID] = summary + } + return result, nil +} + +func sanitizeApprovalFailure(message string) string { + if strings.TrimSpace(message) == "" { + return "" + } + // 失败摘要只能返回稳定白名单文案,禁止透传渠道原文、内部事件键或任意外部文本。 + return "审批提交失败,请检查审批模板与渠道状态" } // applyApprovalSummary 用审批实例摘要回填响应的审批状态。 -// 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。 func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) { if response == nil || refund == nil { return } - summary, exists := summaries[refund.LatestApprovalInstanceID] + id := refund.LatestApprovalInstanceID + if id == 0 && refund.ApprovalInstanceID != nil { + id = *refund.ApprovalInstanceID + } + summary, exists := summaries[id] if !exists && refund.ApprovalInstanceID != nil { - summary, exists = summaries[*refund.ApprovalInstanceID] + id = *refund.ApprovalInstanceID + summary, exists = summaries[id] } if !exists { return @@ -1413,6 +1499,30 @@ func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]appro response.ApprovalProvider = summary.Provider response.ApprovalStatus = &status response.ApprovalStatusName = constants.GetApprovalStatusName(status) + response.ApprovalSubmissionStatus = &summary.SubmissionStatus + response.ApprovalSubmissionStatusName = wecomSubmissionStatusName(summary.SubmissionStatus) + response.ApprovalRecoverable = summary.Recoverable && refund.Status == model.RefundStatusPending && refund.ChannelRefundStatus != constants.RefundChannelStatusProcessing && refund.Status != model.RefundStatusChannelFailed + response.ApprovalFailureSummary = summary.FailureSummary + if summary.LastRecoveryAt != nil { + response.ApprovalLastRecoveryAt = summary.LastRecoveryAt.UTC().Format(time.RFC3339) + } +} + +func wecomSubmissionStatusName(status int) string { + switch status { + case constants.WeComSubmissionStatusReady: + return "待提交" + case constants.WeComSubmissionStatusSending: + return "提交中" + case constants.WeComSubmissionStatusSubmitted: + return "已提交" + case constants.WeComSubmissionStatusFailed: + return "提交失败" + case constants.WeComSubmissionStatusUnknown: + return "结果未知" + default: + return "未知" + } } func refundSubmitterIDs(requests []*model.RefundRequest) []uint { @@ -1436,7 +1546,6 @@ func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint] } return names, nil } - func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string { names, err := s.loadSubmitterNames(ctx, []uint{id}) if err != nil { diff --git a/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/.openspec.yaml b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/.openspec.yaml new file mode 100644 index 00000000..cbd245e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/design.md b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/design.md new file mode 100644 index 00000000..2824a15b --- /dev/null +++ b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/design.md @@ -0,0 +1,87 @@ +## Context + +退款主表和每次不可变审批尝试已经保存 `method`、`customer_account_info` 与 `customer_voucher_keys`,但通用审批请求快照目前未写入这些键,企业微信退款场景白名单也没有对应字段。现有必需映射机制只支持场景级固定必需字段,无法表达“仅客户收款信息退款需要收款文本和凭证”。 + +通用审批提交已具备稳定事件键、企业微信提交状态、`sp_no`、结果未知恢复和 `last_recovery_at`,因此本变更只增加业务级恢复编排,不重建可靠提交基础设施。历史失败实例的关联审批尝试已经保存所需材料,可作为唯一补齐来源。 + +## Goals / Non-Goals + +**Goals:** + +- 让审批人始终明确看到退款方式,并仅在客户收款信息退款时看到完整收款文本和凭证。 +- 以单一规则来源表达退款场景的固定必需字段与按快照值生效的条件必需字段。 +- 让模板修复或提交异常后的退款恢复原审批实例,避免重复审批。 +- 在不改写历史材料的前提下补齐上线前失败实例缺少的快照键。 + +**Non-Goals:** + +- 不把客户收款自由文本拆为收款人、账号、开户行等新 Schema,也不修改退款创建前端的数据结构。 +- 不要求原路退款或钱包退款配置客户收款控件,不向这些方式提交空白占位材料。 +- 不提供后台人工通过、驳回或强制重建审批,不改变渠道退款恢复。 +- 不新增数据库迁移或第三方依赖。 + +## Decisions + +### 审批快照同时保存方式编码、名称和条件材料 + +新建、历史补发和重提路径生成的请求快照增加稳定键:退款方式编码、退款方式中文名称、客户收款信息和客户凭证列表。方式名称统一复用退款领域既有映射,避免表单层维护第二套名称。 + +方式编码用于条件规则判断,中文名称用于企微展示。客户字段即使在非客户收款方式下也以空值或空数组存在于新快照中,但表单不要求映射也不提交占位值。审批材料始终从当次审批尝试冻结值组装,而不是从退款主表临时读取。 + +备选是把方式和收款文本拼接成单一说明字段;放弃,因为附件仍需独立控件,且字段语义与错误定位不清晰。 + +### 必需字段规则扩展为固定与条件两类 + +企业微信业务字段规则保持单一注册来源: + +- 固定必需:退款方式中文名称,保存场景映射和构建表单时均强制。 +- 条件必需:当快照方式编码为 `customer_account` 时,客户收款信息和客户凭证必须存在映射且值非空。 + +场景配置保存阶段只强制固定字段;条件字段若已映射则校验业务字段与控件类型。提交阶段读取请求快照,计算本次实际必需字段,再统一校验映射、快照键和值。错误明确指出具体字段和控件。其他退款方式不进入条件集合。 + +选择在表单构建层统一判定,而不是退款 Handler 或 Worker 分支判断,因为场景保存与提交必须共享字段定义,且其他审批场景未来可复用条件规则模型。 + +### 历史失败快照从不可变尝试受控补齐 + +恢复用例锁定退款、最新尝试、通用审批实例和企业微信上下文,并要求退款仍待审批、最新引用一致、尝试的 `approval_instance_id` 等于目标实例。 + +若请求快照缺少新增键,则从该尝试读取方式、客户收款信息和凭证,只添加缺失键,不覆盖任何已有值。退款方式中文名称由尝试冻结的方式编码确定。补齐后的请求快照更新与恢复提交事实在同一事务完成,并写入只记录字段名集合、不记录敏感值的审计。 + +不得从退款主表补齐:主表可能已被后续操作更新,只有审批尝试是该实例的不可变材料来源。 + +### 退款恢复入口编排既有审批恢复能力 + +新增 `POST /api/admin/refunds/{id}/recover-approval`,请求只携带路径 ID。权限沿用现有退款管理门禁和数据范围:超级管理员、平台用户及代理可在原范围内操作,企业账号拒绝,越权与不存在不可区分。 + +恢复顺序固定为: + +1. 校验退款状态为待审批且最新尝试、实例一致。 +2. 必要时补齐历史请求快照。 +3. 已有 `sp_no` 时触发原审批详情查询。 +4. 提交结果未知时触发既有确认流程,禁止直接重提。 +5. 明确失败且确认未受理时恢复 `approval:{instance_id}:submission` 稳定事件。 +6. 活动重试或已在审批中时幂等返回。 +7. 审批终态、原路处理中或渠道失败时返回状态冲突。 + +HTTP 请求不直接调用企业微信创建审批接口,外部提交或查询继续由 Worker 执行,避免 HTTP 超时制造新的结果未知窗口。 + +### 查询投影复用既有审批与企微事实 + +退款列表和详情增加提交状态、状态名、可恢复标记、安全失败摘要和最近恢复时间。投影按页批量读取最新审批实例、企微上下文和提交事件,禁止逐条查询。最近恢复时间使用既有 `last_recovery_at`;人工恢复动作与操作者由审计表达,不新增业务列。 + +失败摘要仅使用脱敏的上下文 `last_error` 或稳定分类,不返回企业微信响应原文、完整客户收款信息、附件对象键或内部 Outbox 键。 + +## Risks / Trade-offs + +- [条件映射机制影响通用企微表单构建] → 保持默认规则为空,现有场景行为不变;只为退款注册条件规则并覆盖固定、条件和非条件三类 smoke。 +- [历史实例快照与尝试关联异常] → 关联不一致立即返回冲突,禁止猜测退款主表材料或恢复错误实例。 +- [模板只配置固定字段导致客户退款提交失败] → 这是有意的明确失败;错误指出缺少的客户收款字段,维护者补齐后恢复原实例。 +- [恢复与 Worker 正常重试竞争] → 通过实例锁和稳定事件条件更新收敛,活动事件只返回当前状态。 +- [列表联查增加成本] → 按页批量读取相关实例、上下文和事件,避免 N+1。 + +## Migration Plan + +1. 不执行数据库迁移;先发布支持完整快照、条件字段规则、历史补齐和恢复入口的 API/Worker。 +2. 发布前由维护者在退款企微模板配置固定“退款方式”控件,并为客户收款退款配置“客户收款信息”多行文本与“客户收款凭证”附件控件。 +3. 在维护者指定测试环境分别提交四种退款方式,验证条件映射、明确失败和模板修复后原实例恢复。 +4. 回滚时关闭新恢复入口并回退二进制;已补齐的请求快照保留新增键,旧版本忽略未知键,不删除审批、退款或审计事实。 diff --git a/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/proposal.md b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/proposal.md new file mode 100644 index 00000000..ea40b627 --- /dev/null +++ b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/proposal.md @@ -0,0 +1,27 @@ +## Why + +退款申请已经冻结退款方式、客户收款信息和客户凭证,但企业微信审批快照与可映射字段未包含这些材料,审批人无法判断具体退款路径,客户收款信息退款还可能因模板无字段可选而提交失败。模板或提交异常修复后也缺少业务级安全恢复入口,导致在途退款无法继续审批。 + +## What Changes + +- 所有新建和重提退款审批材料新增退款方式,并按固定中文名称展示原路退款、客户收款信息退款、退回资产钱包或退回代理主钱包。 +- 仅客户收款信息退款在企业微信材料中提交多行客户收款信息与客户凭证附件;其他退款方式不要求或伪造收款材料。 +- 扩展企业微信退款场景字段白名单与表单构建规则:退款方式始终必须映射,客户收款信息和凭证按退款方式条件必需,缺失时明确指出需配置的控件。 +- 为提交失败、结果未知或已有企业微信审批单号但本地未确认的退款申请新增原审批恢复入口,复用最新审批尝试、原审批实例和稳定提交事实。 +- 对本变更上线前已失败且请求快照缺少新增材料的原审批实例,从其关联的不可变退款审批尝试补齐缺失键后恢复;不得从退款主表取值、覆盖已有快照或创建第二张审批单。 +- 退款列表与详情返回审批提交状态、可恢复标记、安全失败摘要和最近恢复时间。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `order-refund-exchange`: 补齐企业微信退款审批方式与条件收款材料,并增加原审批实例的受控恢复、状态展示和权限行为。 + +## Impact + +- 影响退款审批快照构建、退款审批尝试读取、企业微信业务字段白名单、条件映射校验、表单构建、通用审批恢复接缝、退款查询投影、Handler、路由和 OpenAPI。 +- 复用现有退款审批尝试、通用审批实例、企业微信上下文、Outbox、Asynq 与审计事实;不新增第三方依赖或数据库迁移。 diff --git a/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/specs/order-refund-exchange/spec.md b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/specs/order-refund-exchange/spec.md new file mode 100644 index 00000000..e383a437 --- /dev/null +++ b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/specs/order-refund-exchange/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: 企业微信退款审批展示方式与条件收款材料 +系统 SHALL 在每次新建或重提退款审批的不可变材料中保存并向企业微信展示退款方式,固定取值对应为:`original_route` 显示“原路退款”、`customer_account` 显示“客户收款信息退款”、`asset_wallet` 显示“退回资产钱包”、`agent_wallet` 显示“退回代理主钱包”。退款方式控件映射 MUST 为退款审批场景的固定必需配置,缺失时系统 MUST 明确拒绝保存场景或提交审批。 + +仅当退款方式为 `customer_account` 时,系统 MUST 向企业微信提交当次审批尝试冻结的客户收款信息多行文本和客户凭证附件,并 MUST 要求两类材料均非空且存在对应控件映射。其他退款方式 MUST NOT 要求客户收款信息或客户凭证控件,不得因这两个条件控件未映射而拒绝提交,也不得使用空白占位或其它退款资料伪造客户收款材料。 + +审批材料 MUST 取自当次不可变退款审批尝试,不得因退款主表后续修改或新一次重提而改变历史审批实例的材料。缺少当前退款方式所需的映射或材料时,错误 MUST 指出缺失的具体业务字段或控件。 + +#### Scenario: 客户收款信息退款提交完整材料 +- **GIVEN** 退款方式为客户收款信息退款,且当次尝试冻结了非空客户收款信息和至少一个客户凭证 +- **WHEN** 系统构建企业微信退款审批表单 +- **THEN** 表单展示“客户收款信息退款”、完整多行客户收款信息和客户凭证附件 + +#### Scenario: 客户收款信息退款缺少条件控件 +- **WHEN** 客户收款信息退款对应场景缺少客户收款信息或客户凭证控件映射 +- **THEN** 系统明确拒绝提交并指出缺失控件,不静默丢弃材料且不创建第二张审批单 + +#### Scenario: 原路退款不要求客户收款控件 +- **WHEN** 退款方式为原路退款且场景未配置客户收款信息与客户凭证控件 +- **THEN** 系统仍可提交审批,表单明确展示“原路退款”且不提交伪造的客户收款材料 + +#### Scenario: 钱包退款展示具体方式 +- **WHEN** 退款方式为资产钱包或代理主钱包退款 +- **THEN** 企业微信材料分别展示“退回资产钱包”或“退回代理主钱包”,且不要求客户收款材料 + +#### Scenario: 重提不改写历史审批材料 +- **GIVEN** 同一退款申请以不同材料重提并产生新的审批尝试 +- **WHEN** 查询或同步任一历史审批实例 +- **THEN** 每个实例仍使用其关联尝试冻结的退款方式、客户收款信息与客户凭证,不被最新退款主表或后续尝试覆盖 + +### Requirement: 退款原审批实例可受控恢复 +系统 SHALL 提供 `POST /api/admin/refunds/{id}/recover-approval`,允许具有既有退款管理访问权限的超级管理员、平台用户和代理在其数据范围内恢复仍处于待审批的退款申请。恢复对象 MUST 固定为退款申请最新审批尝试与最新审批实例,不接受调用方传入审批实例或新材料;企业账号 MUST 被拒绝,无权操作与退款不存在 MUST 不可区分。 + +恢复 MUST 复用原审批实例和同一稳定提交事实,不得新增审批尝试、审批实例或第二张企业微信审批单,不得修改退款方式、金额、客户收款信息、凭证或其它冻结材料,也不得改变退款业务状态、套餐权益、订单状态或触发渠道退款。明确提交失败且可确认企业微信未受理时,系统 SHALL 恢复同一提交事实;提交结果未知时 MUST 优先确认原提交结果;已有企业微信审批单号时 MUST 查询同步原审批单;正常重试或已经处于企业微信审批中的请求 SHALL 幂等返回当前状态;审批终态、原路退款处理中或渠道退款失败状态 MUST 拒绝审批提交恢复。 + +对本能力上线前已创建、原请求快照缺少退款方式或条件收款材料的待恢复实例,系统 SHALL 仅从该实例关联的不可变退款审批尝试补齐缺失键。补齐 MUST 校验尝试与审批实例关联一致,只添加缺失键且不得覆盖已有快照,不得从退款主表取值;快照补齐与提交恢复 MUST 原子完成。 + +退款列表与详情 SHALL 返回审批提交状态、可恢复标记、安全失败摘要和最近恢复时间。恢复与历史快照补齐 MUST 写入脱敏审计,不得记录完整客户收款信息、附件对象键、企业微信原始响应或内部队列键。 + +#### Scenario: 模板修复后恢复原审批 +- **GIVEN** 待审批退款因模板字段缺失而明确提交失败,且维护者已修复映射 +- **WHEN** 授权账号请求恢复审批 +- **THEN** 系统恢复同一审批实例与稳定提交事实,不新增审批尝试或审批单,并保持退款材料和业务状态不变 + +#### Scenario: 历史失败实例补齐原尝试材料 +- **GIVEN** 原审批实例的请求快照缺少新增字段,但其关联退款审批尝试已冻结退款方式和客户收款材料 +- **WHEN** 授权账号恢复该审批 +- **THEN** 系统从关联尝试补齐缺失键并恢复原实例,不覆盖已有快照且不从退款主表读取材料 + +#### Scenario: 提交结果未知时禁止重提 +- **GIVEN** 最新退款审批实例的提交结果未知 +- **WHEN** 授权账号请求恢复 +- **THEN** 系统进入原提交结果确认流程,在无法证明未受理前不再次创建审批 + +#### Scenario: 已有企微审批单号时只查询同步 +- **GIVEN** 最新审批实例已有企业微信审批单号但本地状态未确认 +- **WHEN** 授权账号请求恢复 +- **THEN** 系统查询同步原审批单,不恢复创建提交且不生成新实例 + +#### Scenario: 重复和并发恢复保持幂等 +- **WHEN** 多个授权账号重复或并发恢复同一退款审批 +- **THEN** 系统至多一次改变原提交事实,其余请求返回相同当前状态,审批尝试数和审批实例数不增加 + +#### Scenario: 非审批提交阶段拒绝恢复 +- **WHEN** 退款审批已终态,或退款已进入原路退款处理中或渠道退款失败状态 +- **THEN** 系统返回状态冲突,不改变退款、订单、权益、审批或渠道退款事实 diff --git a/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/tasks.md b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/tasks.md new file mode 100644 index 00000000..8eb7b562 --- /dev/null +++ b/openspec/changes/archive/2026-09-20-complete-refund-approval-materials-and-recovery/tasks.md @@ -0,0 +1,34 @@ +## 1. 退款审批材料快照 + +- [x] 1.1 新增稳定审批业务字段编码:退款方式编码、退款方式中文名称、客户收款信息和客户凭证,并登记到退款企微场景字段白名单及正确控件类型。 +- [x] 1.2 扩展新建、历史补发与重提的退款审批快照,从当次不可变审批尝试写入方式编码、统一中文名称、客户收款文本和凭证列表;非客户收款方式写空条件值但不伪造材料。 +- [x] 1.3 核对退款方式名称只复用既有领域映射,历史审批实例仍读取各自请求快照,不因退款主表或后续重提变化。 + +## 2. 企业微信条件映射规则 + +- [x] 2.1 将企微必需字段规则扩展为固定必需与按请求快照生效的条件必需两类,默认不改变其它审批场景行为。 +- [x] 2.2 配置保存时固定强制退款方式映射,并对已配置的客户收款信息多行文本与客户凭证附件映射校验控件类型。 +- [x] 2.3 表单构建时按退款方式编码计算本次必需字段:`customer_account` 强制客户收款文本与凭证映射和值,其他方式不要求;缺失时返回指出具体字段和控件的错误。 +- [x] 2.4 确认原路退款、资产钱包退款和代理钱包退款在未配置客户收款控件时仍可提交,且企微材料明确展示对应退款方式。 + +## 3. 历史快照补齐与审批恢复 + +- [x] 3.1 在通用审批 Application/Infrastructure 边界提供按原实例查询同步、确认结果未知和恢复稳定提交事件的接缝,复用 `approval:{instance_id}:submission`,活动事件幂等返回。 +- [x] 3.2 实现退款历史快照受控补齐:锁定退款、最新尝试和原实例,校验关联一致,只从不可变尝试添加缺失的新键,不覆盖已有快照、不读取退款主表,并写脱敏审计。 +- [x] 3.3 实现退款恢复用例:仅待审批且最新尝试、实例一致时允许;已有 `sp_no` 只查询,结果未知只确认,明确未受理失败恢复原事件,正常重试返回当前状态。 +- [x] 3.4 保证重复或并发恢复至多一次改变提交事实;恢复不新增尝试或实例,不修改退款材料和状态,不触发套餐、订单、资金或渠道退款动作。 + +## 4. 查询投影与 HTTP 入口 + +- [x] 4.1 扩展退款列表与详情 DTO,返回审批提交状态、状态名、可恢复标记、安全失败摘要和最近恢复时间。 +- [x] 4.2 在退款 Query 中按页批量读取最新审批实例、企微上下文和提交事件计算恢复投影,避免逐条查询;敏感收款信息、附件键、企微原文和内部事件键不得进入失败摘要。 +- [x] 4.3 新增 `POST /api/admin/refunds/{id}/recover-approval` Handler 与 RouteSpec,只接受路径退款 ID;同步可执行路由、`cmd/api/docs.go`、`cmd/gendocs/main.go` 和 OpenAPI 生成要求。 +- [x] 4.4 复用退款管理权限和数据范围:超级管理员、平台用户与代理仅操作原可见退款,企业账号拒绝,越权与不存在不可区分;审批终态、原路处理中和渠道失败返回状态冲突。 + +## 5. 验证、配置与规格同步 + +- [ ] 5.1 在维护者指定测试环境分别提交四种退款方式,核对企微材料中的中文方式;客户收款退款包含多行文本和附件,其他方式不要求客户收款控件。 +- [ ] 5.2 验证客户收款退款缺文本、凭证、映射或控件时明确失败;修复模板后从原尝试补齐历史快照并恢复同一审批实例。 +- [ ] 5.3 验证结果未知、已有 `sp_no`、正常重试、重复与并发恢复,以及终态、渠道阶段和越权拒绝;核对尝试数、实例数、退款材料、订单、权益和资金事实不变。 +- [x] 5.4 核对列表与详情投影、批量查询和审计脱敏,运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 +- [x] 5.5 更新退款企微模板上线前置说明、主 Spec 可达操作索引和证据矩阵,运行 `openspec validate complete-refund-approval-materials-and-recovery --strict`、`openspec doctor --json`、`openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/openspec/specs/order-refund-exchange/spec.md b/openspec/specs/order-refund-exchange/spec.md index 676fc94b..13915112 100644 --- a/openspec/specs/order-refund-exchange/spec.md +++ b/openspec/specs/order-refund-exchange/spec.md @@ -428,6 +428,16 @@ - **WHEN** 提交退款审批 - **THEN** 系统明确失败并指出缺失控件,不静默丢弃该字段 +### Requirement: 退款原审批实例可受控恢复 + +系统 SHALL 提供 `POST /api/admin/refunds/{id}/recover-approval`,允许既有退款管理范围内的超级管理员、平台用户和代理恢复仍处于待审批的退款最新审批实例;企业账号拒绝,越权与不存在不可区分。恢复 MUST 复用原审批实例及 `approval:{instance_id}:submission` 稳定提交事实,不得新增审批尝试/实例、修改冻结退款材料或触发退款、订单、套餐与资金动作。已有 `sp_no` 只查询同步,结果未知只确认,明确提交失败且无 `sp_no` 才恢复原提交事件;审批终态、原路退款处理中或渠道退款失败 MUST 返回状态冲突。历史快照缺失新增键时,只能从关联不可变审批尝试补齐缺失值并写脱敏审计。 + +#### Scenario: 恢复入口只处理原审批事实 + +- **WHEN** 授权账号请求恢复待审批退款 +- **THEN** 系统按最新尝试与实例关联选择恢复分支,列表/详情返回提交状态、可恢复标记、安全失败摘要和最近恢复时间,重复或并发请求至多一次改变提交事实。 + + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 @@ -438,7 +448,7 @@ ### 退款管理 -`GET /api/admin/refunds`(退款申请列表);`POST /api/admin/refunds`(创建退款申请);`GET /api/admin/refunds/{id}`(退款申请详情);`POST /api/admin/refunds/{id}/trigger-approval`(补发历史退款审批);`POST /api/admin/refunds/{id}/approve`(审批通过退款申请);`POST /api/admin/refunds/{id}/reject`(审批拒绝退款申请);`POST /api/admin/refunds/{id}/resubmit`(重新提交退款申请);`POST /api/admin/refunds/{id}/return`(退回退款申请);`GET /api/admin/refunds/order-options`(按来源订单查询可选退款方式);`POST /api/admin/refunds/{id}/offline-settlement`(登记或更正线下退款处理流水号)。 +`GET /api/admin/refunds`(退款申请列表,返回审批提交状态、状态名、可恢复标记、安全失败摘要和最近恢复时间);`POST /api/admin/refunds`(创建退款申请);`GET /api/admin/refunds/{id}`(退款申请详情,返回同一审批恢复投影);`POST /api/admin/refunds/{id}/trigger-approval`(补发历史退款审批);`POST /api/admin/refunds/{id}/recover-approval`(恢复最新退款审批原提交:已有 `sp_no` 查询同步、结果未知确认、明确失败且无 `sp_no` 恢复稳定 submission Outbox);`POST /api/admin/refunds/{id}/approve`(审批通过退款申请);`POST /api/admin/refunds/{id}/reject`(审批拒绝退款申请);`POST /api/admin/refunds/{id}/resubmit`(重新提交退款申请);`POST /api/admin/refunds/{id}/return`(退回退款申请);`GET /api/admin/refunds/order-options`(按来源订单查询可选退款方式);`POST /api/admin/refunds/{id}/offline-settlement`(登记或更正线下退款处理流水号)。 ### 换货管理 diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index 174de398..f2168a99 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -83,6 +83,17 @@ const ( ApprovalFieldRefundChannelTradeNo = "refund_channel_trade_no" // ApprovalFieldRefundApplicantRemark 表示本次提交的申请人备注业务字段,未填写时为空串。 ApprovalFieldRefundApplicantRemark = "refund_applicant_remark" + // ApprovalFieldRefundMethodCode 表示本次退款方式稳定编码业务字段。 + ApprovalFieldRefundMethodCode = "refund_method_code" + // ApprovalFieldRefundMethod 表示本次退款方式中文名称业务字段。 + ApprovalFieldRefundMethod = "refund_method" + // ApprovalFieldCustomerAccountInfo 表示本次客户收款信息多行文本业务字段。 + ApprovalFieldCustomerAccountInfo = "customer_account_info" + // ApprovalFieldCustomerVoucherKey 表示本次客户收款凭证对象存储 Key 列表业务字段。 + ApprovalFieldCustomerVoucherKey = "customer_voucher_key" +) + +const ( // ApprovalFieldCollectionApplicationID 表示员工代收款核销申请 ID 业务字段。 ApprovalFieldCollectionApplicationID = "collection_application_id" // ApprovalFieldCollectionPaymentMethod 表示员工代收款线下收款方式名称业务字段。 @@ -260,6 +271,10 @@ const ( OutboxEventTypeApprovalTerminalDecision = "approval.terminal_decision.recorded" // OutboxEventTypeApprovalSubmissionRequested 表示通用审批实例等待渠道提交。 OutboxEventTypeApprovalSubmissionRequested = "approval.submission.requested" + // OutboxEventTypeApprovalManualSyncRequested 表示后台按原审批单号触发详情同步。 + OutboxEventTypeApprovalManualSyncRequested = "approval.manual_sync.requested" + // OutboxEventTypeApprovalUnknownRecoveryRequested 表示结果未知按实例恢复。 + OutboxEventTypeApprovalUnknownRecoveryRequested = "approval.unknown_recovery.requested" // ApprovalTerminalDecisionPayloadVersionV1 表示标准终态事件载荷第一版。 ApprovalTerminalDecisionPayloadVersionV1 = 1 // ApprovalSubmissionPayloadVersionV1 表示审批申请提交事件载荷第一版。