fix: 补上第1批 M8 提交失败决策写入(6298a906 遗漏)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m35s

- 企微提交被明确拒绝时,在同一事务内写 submission_failed 决策投递记录与终态事件
- Worker 两处 ApprovalContextRepository 注入决策写入器;未注入时提交失败直接报错,不再静默只改实例状态
- 终态决策消费入口单独放行 submission_failed,渠道回调仍不能推进到提交失败
- 6298a906 的编辑脚本在本文件锚点不唯一处中断,三处改动未落盘;本次补齐
- 第1批 ⑤ 5.4、① 5.2/5.4/5.5 测试环境验证完成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-12 09:09:58 +09:00
parent 0f1ea5bc3e
commit 868d969d8e
5 changed files with 58 additions and 12 deletions

View File

@@ -423,7 +423,9 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
) )
consumer := wecomInfra.NewApprovalSubmissionConsumer( 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.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, 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, approvalInfra.NewDecisionDeliveryStore(runtime.db), nil,
) )
decisionSync.SetAuditWriter(auditWriter) 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( detailHandler := wecomInfra.NewApprovalDetailTaskHandler(
wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
contexts, decisionSync, integrationRepository, contexts, decisionSync, integrationRepository,

View File

@@ -66,8 +66,11 @@ func (c *TerminalDecisionConsumer) Consume(ctx context.Context, envelope outbox.
event.BusinessType == "" || event.BusinessID == 0 || event.CorrelationID == "" { event.BusinessType == "" || event.BusinessID == 0 || event.CorrelationID == "" {
return errors.New(errors.CodeInvalidParam, "审批标准决策事件载荷不完整") return errors.New(errors.CodeInvalidParam, "审批标准决策事件载荷不完整")
} }
if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil { // submission_failed 只由审批核心在提交失败事务内发出,仅在此放行;StatusForDecision 不认该值,渠道回调仍不能推进到提交失败。
return err if event.Decision != constants.ApprovalDecisionSubmissionFailed {
if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil {
return err
}
} }
return c.dispatcher.Consume(ctx, event) return c.dispatcher.Consume(ctx, event)
} }

View File

@@ -36,9 +36,11 @@ type ApprovalRecoveryRecord struct {
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。 // ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
type ApprovalContextRepository struct { type ApprovalContextRepository struct {
db *gorm.DB db *gorm.DB
audit approvalapp.AuditWriter audit approvalapp.AuditWriter
now func() time.Time eventWriter approvalapp.TerminalEventWriter
deliveryWriter approvalapp.DecisionDeliveryWriter
now func() time.Time
} }
// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。 // NewApprovalContextRepository 创建企微审批渠道上下文 Repository。
@@ -50,6 +52,14 @@ func NewApprovalContextRepository(db *gorm.DB, audits ...approvalapp.AuditWriter
return &ApprovalContextRepository{db: db, audit: audit, now: time.Now} 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 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。 // ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。
func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) { func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) {
if r == nil || r.db == nil || instanceID == 0 { if r == nil || r.db == nil || instanceID == 0 {
@@ -467,6 +477,10 @@ func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, ins
if r.audit == nil { if r.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置") 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() now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}). contextResult := tx.Model(&model.WeComApprovalContext{}).
@@ -494,6 +508,12 @@ func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, ins
if err != nil { if err != nil {
return err return err
} }
outboxEventID := ""
if failed {
if outboxEventID, err = r.appendSubmissionFailedDecision(ctx, tx, instance); err != nil {
return err
}
}
beforeStatus := constants.ApprovalStatusSubmitting beforeStatus := constants.ApprovalStatusSubmitting
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{ return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus), EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus),
@@ -504,11 +524,30 @@ func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, ins
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus, CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus,
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker, ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
Source: constants.AuditSourceWorker, Result: auditResult, ErrorSummary: message, 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) { func loadApprovalAuditInstance(ctx context.Context, tx *gorm.DB, instanceID uint) (*model.ApprovalInstance, error) {
var instance model.ApprovalInstance var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil { if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {

View File

@@ -22,5 +22,5 @@
- [x] 5.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 - [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 自建的测试账号、企业与店铺,验证后清理。 - [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.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`。 - [ ] 5.5 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。

View File

@@ -24,9 +24,9 @@
## 5. 文档与测试环境验证 ## 5. 文档与测试环境验证
- [x] 5.1 对变更 Go 文件执行 `gofmt`,运行 `go build ./cmd/api ./cmd/worker` 与 `go run cmd/gendocs/main.go`,核对新字段与说明。 - [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 充值单不串;列表无逐行查询。 - [x] 5.3 订单:C 端微信、支付宝已支付及已退款订单三个字段与 `tb_payment` 一致;代理、钱包、线下、未支付为空串;构造“先微信未付、再支付宝已付”订单核对 `payments`;套餐订单与同 ID 充值单不串;列表无逐行查询。
- [ ] 5.4 预警:规则列表与详情不含 `real_data_mb`,无额度套餐仍被拒;列表与详情 `threshold_mb` 计算正确;新导出任务最后一列为阈值流量,旧任务列序不变。 - [x] 5.4 预警:规则列表与详情不含 `real_data_mb`,无额度套餐仍被拒;列表与详情 `threshold_mb` 计算正确;新导出任务最后一列为阈值流量,旧任务列序不变。
- [ ] 5.5 设备导入:混合文件、整份空 IMEI、仅空格、虚拟号与 IMEI 都为空四种情形按 delta spec 核对。 - [x] 5.5 设备导入:混合文件、整份空 IMEI、仅空格、虚拟号与 IMEI 都为空四种情形按 delta spec 核对。
- [ ] 5.6 通知测试环境前端去掉 `real_data_mb` 的使用、接入订单新字段;通知运营换新换绑模板、导入须填 IMEI。 - [ ] 5.6 通知测试环境前端去掉 `real_data_mb` 的使用、接入订单新字段;通知运营换新换绑模板、导入须填 IMEI。
- [ ] 5.7 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 - [ ] 5.7 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。