diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 70a513a8..10069d19 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -423,7 +423,9 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, ) consumer := wecomInfra.NewApprovalSubmissionConsumer( - wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter), + wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter).SetDecisionWriters( + approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), approvalInfra.NewDecisionDeliveryStore(runtime.db), + ), wecomInfra.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), ) @@ -542,7 +544,9 @@ func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg approvalInfra.NewDecisionDeliveryStore(runtime.db), nil, ) decisionSync.SetAuditWriter(auditWriter) - contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter) + contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter).SetDecisionWriters( + approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), approvalInfra.NewDecisionDeliveryStore(runtime.db), + ) detailHandler := wecomInfra.NewApprovalDetailTaskHandler( wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), contexts, decisionSync, integrationRepository, diff --git a/internal/infrastructure/approval/terminal_event.go b/internal/infrastructure/approval/terminal_event.go index 6cbe6168..bb3c0441 100644 --- a/internal/infrastructure/approval/terminal_event.go +++ b/internal/infrastructure/approval/terminal_event.go @@ -66,8 +66,11 @@ func (c *TerminalDecisionConsumer) Consume(ctx context.Context, envelope outbox. event.BusinessType == "" || event.BusinessID == 0 || event.CorrelationID == "" { return errors.New(errors.CodeInvalidParam, "审批标准决策事件载荷不完整") } - if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil { - return err + // submission_failed 只由审批核心在提交失败事务内发出,仅在此放行;StatusForDecision 不认该值,渠道回调仍不能推进到提交失败。 + if event.Decision != constants.ApprovalDecisionSubmissionFailed { + if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil { + return err + } } return c.dispatcher.Consume(ctx, event) } diff --git a/internal/infrastructure/wecom/approval_context_repository.go b/internal/infrastructure/wecom/approval_context_repository.go index 6706b5d2..ccfbbd00 100644 --- a/internal/infrastructure/wecom/approval_context_repository.go +++ b/internal/infrastructure/wecom/approval_context_repository.go @@ -36,9 +36,11 @@ type ApprovalRecoveryRecord struct { // ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。 type ApprovalContextRepository struct { - db *gorm.DB - audit approvalapp.AuditWriter - now func() time.Time + db *gorm.DB + audit approvalapp.AuditWriter + eventWriter approvalapp.TerminalEventWriter + deliveryWriter approvalapp.DecisionDeliveryWriter + now func() time.Time } // NewApprovalContextRepository 创建企微审批渠道上下文 Repository。 @@ -50,6 +52,14 @@ func NewApprovalContextRepository(db *gorm.DB, audits ...approvalapp.AuditWriter return &ApprovalContextRepository{db: db, audit: audit, now: time.Now} } +// SetDecisionWriters 注入提交失败决策所需的投递记录与终态事件写入器。 +// 提交被明确拒绝时必须在同一事务内写两者,缺任何一个都会让业务单永远停在审批中。 +func (r *ApprovalContextRepository) SetDecisionWriters(eventWriter approvalapp.TerminalEventWriter, deliveryWriter approvalapp.DecisionDeliveryWriter) *ApprovalContextRepository { + r.eventWriter = eventWriter + r.deliveryWriter = deliveryWriter + return r +} + // ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。 func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) { if r == nil || r.db == nil || instanceID == 0 { @@ -467,6 +477,10 @@ func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, ins if r.audit == nil { return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置") } + failed := approvalStatus == constants.ApprovalStatusSubmissionFailed + if failed && (r.eventWriter == nil || r.deliveryWriter == nil) { + return errors.New(errors.CodeServiceUnavailable, "审批提交失败决策写入器未配置") + } now := r.now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { contextResult := tx.Model(&model.WeComApprovalContext{}). @@ -494,6 +508,12 @@ func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, ins if err != nil { return err } + outboxEventID := "" + if failed { + if outboxEventID, err = r.appendSubmissionFailedDecision(ctx, tx, instance); err != nil { + return err + } + } beforeStatus := constants.ApprovalStatusSubmitting return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{ EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus), @@ -504,11 +524,30 @@ func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, ins CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus, ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker, Source: constants.AuditSourceWorker, Result: auditResult, ErrorSummary: message, - IntegrationIDs: []string{integrationID}, + IntegrationIDs: []string{integrationID}, OutboxEventID: outboxEventID, }) }) } +// appendSubmissionFailedDecision 在提交失败事务内写决策投递记录与终态事件,通知业务模块终止业务单。 +// 事件号沿用终态决策格式 approval:{实例ID}:submission_failed;失败原因由业务消费者按实例读取企微上下文。 +func (r *ApprovalContextRepository) appendSubmissionFailedDecision(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance) (string, error) { + event := approvalapp.TerminalDecisionEvent{ + EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":" + constants.ApprovalDecisionSubmissionFailed, + InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID, + SubmitterAccountID: instance.SubmitterAccountID, Decision: constants.ApprovalDecisionSubmissionFailed, + Source: constants.ApprovalSyncSourceSubmission, CorrelationID: instance.CorrelationID, + OccurredAt: instance.StatusChangedAt, + } + if err := r.deliveryWriter.Create(ctx, tx, event); err != nil { + return "", err + } + if err := r.eventWriter.Append(ctx, tx, event); err != nil { + return "", err + } + return event.EventID, nil +} + func loadApprovalAuditInstance(ctx context.Context, tx *gorm.DB, instanceID uint) (*model.ApprovalInstance, error) { var instance model.ApprovalInstance if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil { diff --git a/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md b/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md index 84447631..8693e865 100644 --- a/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md +++ b/openspec/changes/fix-august-iteration-export-and-password-hints/tasks.md @@ -22,5 +22,5 @@ - [x] 5.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 - [x] 5.2 按 ENG-TEST-001(测试库 `junhong_cmp_test`、Redis DB 6):修改密码接口验证四种提示且失败不改密码;七个入口各用 `Abc12345` 成功、`abcd1234` 返回 `1016`、7 位与 13 位返回 `1001`、含空格返回 `1016`;用规则上线前的 6 位旧密码账号登录照常成功。只使用本 Change 自建的测试账号、企业与店铺,验证后清理。 - [ ] 5.3 通知测试环境前端同步七个入口的密码提示与前端校验(8~12 位、四类至少三类)。 -- [ ] 5.4 测试环境为订单、IoT 卡、设备各建一个导出任务,下载核对新表头、值来源、MB/GB 换算与列数;再用变更前创建的任务验证旧表头与行宽不变;只清理本 Change 自建的任务与对象。 +- [x] 5.4 测试环境为订单、IoT 卡、设备各建一个导出任务,下载核对新表头、值来源、MB/GB 换算与列数;再用变更前创建的任务验证旧表头与行宽不变;只清理本 Change 自建的任务与对象。 - [ ] 5.5 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md b/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md index e41833f1..cfaf10fe 100644 --- a/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md +++ b/openspec/changes/fix-august-iteration-follow-up-gaps/tasks.md @@ -24,9 +24,9 @@ ## 5. 文档与测试环境验证 - [x] 5.1 对变更 Go 文件执行 `gofmt`,运行 `go build ./cmd/api ./cmd/worker` 与 `go run cmd/gendocs/main.go`,核对新字段与说明。 -- [ ] 5.2 按 ENG-TEST-001 在 `junhong_cmp_test` 创建并清理专用账号与店铺:用新表头 CSV 验证启用平台账号手机号换绑成功且审计前后值正确;格式不合法、用户名、不存在、非平台、已禁用分别按 delta spec 失败且原负责人不变;旧表头整批失败。 +- [x] 5.2 按 ENG-TEST-001 在 `junhong_cmp_test` 创建并清理专用账号与店铺:用新表头 CSV 验证启用平台账号手机号换绑成功且审计前后值正确;格式不合法、用户名、不存在、非平台、已禁用分别按 delta spec 失败且原负责人不变;旧表头整批失败。 - [x] 5.3 订单:C 端微信、支付宝已支付及已退款订单三个字段与 `tb_payment` 一致;代理、钱包、线下、未支付为空串;构造“先微信未付、再支付宝已付”订单核对 `payments`;套餐订单与同 ID 充值单不串;列表无逐行查询。 -- [ ] 5.4 预警:规则列表与详情不含 `real_data_mb`,无额度套餐仍被拒;列表与详情 `threshold_mb` 计算正确;新导出任务最后一列为阈值流量,旧任务列序不变。 -- [ ] 5.5 设备导入:混合文件、整份空 IMEI、仅空格、虚拟号与 IMEI 都为空四种情形按 delta spec 核对。 +- [x] 5.4 预警:规则列表与详情不含 `real_data_mb`,无额度套餐仍被拒;列表与详情 `threshold_mb` 计算正确;新导出任务最后一列为阈值流量,旧任务列序不变。 +- [x] 5.5 设备导入:混合文件、整份空 IMEI、仅空格、虚拟号与 IMEI 都为空四种情形按 delta spec 核对。 - [ ] 5.6 通知测试环境前端去掉 `real_data_mb` 的使用、接入订单新字段;通知运营换新换绑模板、导入须填 IMEI。 - [ ] 5.7 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。