From 93e072e1e22eb36eaa038c71446e88a7fc5cdd4e Mon Sep 17 00:00:00 2001 From: break Date: Mon, 14 Sep 2026 18:32:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8D=A2=E8=B4=A7):=20AUG26-005=20?= =?UTF-8?q?=E6=8D=A2=E8=B4=A7=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E7=8A=B6=E6=80=81=E4=B8=8E=E5=A4=B1=E8=B4=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增成对迁移 000222:tb_exchange_order 增加非空 migration_status 与 migration_failure_reason,按既有 migrate_data/migration_completed 回填历史, 并加四值 CHECK 约束,不新增索引 - 模型与常量定义四种迁移状态及中文名称,保留既有布尔字段兼容语义 - 物流换货创建恒 not_migrated,发货按请求落 pending/not_migrated, 完成成功写 migrated/not_migrated 并清空失败原因、同步兼容字段 - 直接换货创建即完成,任一步失败整体回滚,不持久化换货单、不产生 failed - 迁移失败回滚全部业务修改后,在独立短事务内条件更新 failed 与安全失败原因 并写失败审计,RowsAffected 为 0 时跳过状态写入但仍写审计 - failed 物流单重试仅限超级管理员或平台用户,授权以锁内 FOR UPDATE 判定为准, 重试从钱包余额起整表重跑;非 failed 单沿用既有完成门禁 - 列表与详情返回迁移状态与中文名称,仅 failed 返回失败原因;既有三字段保持兼容 - 换货导出在「状态」列后新增中文「迁移状态」列,不导出失败原因 - 同步 order-refund-exchange 主 spec 与验证证据,归档本 Change - 登记 KNOWN-ISSUE-001:既有标签复制 OnConflict 未声明部分索引谓词(42P10), 旧资产带标签时迁移最后一步失败,待另立变更修复 --- docs/engineering/工程约束.md | 16 +- .../entry-capability-requirement-matrix.json | 19 ++- .../context-reset/requirement-evidence.json | 96 +++++++++++ internal/exporter/exchange_scene.go | 5 +- internal/model/dto/exchange_dto.go | 71 +++++---- internal/model/exchange_order.go | 6 + internal/query/exchange/list.go | 9 +- internal/routes/exchange.go | 10 +- .../service/exchange/migration_failure.go | 149 ++++++++++++++++++ internal/service/exchange/service.go | 124 ++++++++++----- ...222_add_exchange_migration_status.down.sql | 9 ++ ...00222_add_exchange_migration_status.up.sql | 28 ++++ .../tasks.md | 18 --- .../.openspec.yaml | 0 .../design.md | 34 ++-- .../proposal.md | 8 +- .../specs/order-refund-exchange/spec.md | 11 ++ .../tasks.md | 19 +++ openspec/specs/order-refund-exchange/spec.md | 64 ++++++++ pkg/constants/constants.go | 26 +++ 20 files changed, 597 insertions(+), 125 deletions(-) create mode 100644 internal/service/exchange/migration_failure.go create mode 100644 migrations/000222_add_exchange_migration_status.down.sql create mode 100644 migrations/000222_add_exchange_migration_status.up.sql delete mode 100644 openspec/changes/add-exchange-data-migration-status/tasks.md rename openspec/changes/{add-exchange-data-migration-status => archive/2026-09-14-add-exchange-data-migration-status}/.openspec.yaml (100%) rename openspec/changes/{add-exchange-data-migration-status => archive/2026-09-14-add-exchange-data-migration-status}/design.md (52%) rename openspec/changes/{add-exchange-data-migration-status => archive/2026-09-14-add-exchange-data-migration-status}/proposal.md (81%) rename openspec/changes/{add-exchange-data-migration-status => archive/2026-09-14-add-exchange-data-migration-status}/specs/order-refund-exchange/spec.md (82%) create mode 100644 openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/tasks.md diff --git a/docs/engineering/工程约束.md b/docs/engineering/工程约束.md index e57adda..0b5d6fd 100644 --- a/docs/engineering/工程约束.md +++ b/docs/engineering/工程约束.md @@ -257,9 +257,9 @@ - **最小正例**:事务写事实和 Audit Writer;提交后由 Outbox 外发。 - **最小反例**:事务中等待第三方网络后再提交。 - **机械检查/人工原因**:逐用例人工核对 Transaction 闭包、Audit Writer 和外部调用位置。 -- **例外条件**:业务回滚后的 failed/denied 审计使用独立短事务。 +- **例外条件**:业务回滚后的 failed/denied 审计,以及随之记录的回滚后失败状态事实,使用独立短事务;该短事务 MUST NOT 与已回滚的主事务共用连接或事务,且 MUST 以业务单仍处于允许该失败事实的状态为条件更新。 - **Owner**:架构与审计负责人 -- **最后验证日期**:2026-08-07 +- **最后验证日期**:2026-09-14 - **更新触发条件**:高风险写或外部调用变化 ## ENG-AUDIT-001 @@ -326,3 +326,15 @@ - **Owner**:基础设施负责人 - **最后验证日期**:2026-09-08 - **更新触发条件**:测试库、Redis DB、部署分支、测试主机或验证授权变化 + +## KNOWN-ISSUE-001 +- **状态**:已知缺陷,待修复(当前不阻塞归档;标签功能未启用时无实际影响) +- **适用范围**:`internal/service/exchange/migration.go` 的标签复制步骤(换货业务数据迁移的「资产标签」迁移项) +- **问题**:标签复制使用 `clause.OnConflict{Columns: [resource_type, resource_id, tag_id], DoNothing: true}`,未声明 `tb_resource_tag` 上部分唯一索引 `idx_resource_tag_unique`(`... WHERE deleted_at IS NULL`)的谓词,PostgreSQL 返回 `42P10`。 +- **理由**:旧资产存在任意 `tb_resource_tag` 行且换货请求要求迁移时,标签步骤必然失败,导致换货完成整体回滚、迁移状态落 `failed`,「已迁移」在该情形不可达。记录于此以便由独立变更修复,避免在其它任务中顺手改动迁移项。 +- **证据**:2026-09-14 在 `junhong_cmp_test` 的 `tb_audit_event` 实测 4 条 `action_code=exchange.card.complete`、`result=failed`、`error_code=1206`,`error_summary` 含 `复制资产标签失败 ... SQLSTATE 42P10`;`tb_resource_tag` 当前 0 行,故静态库状态下不可观测。该文件自 `add-exchange-data-migration-status` 起未修改(md5 与 `git show HEAD` 一致)。 +- **例外条件**:标签功能未启用(`tb_resource_tag` 为空)时无实际影响;不影响钱包余额、有效套餐使用记录、累计充值字段、资产归属与个人客户—资产绑定,也不影响无标签资产的换货完成。 +- **修复方式**:为该 `OnConflict` 声明部分索引谓词(或调整索引),须另立 OpenSpec Change;当前按维护者决策暂不修复,仅登记待办。 +- **Owner**:数据负责人 +- **最后验证日期**:2026-09-14 +- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复 diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 793e69c..424bfa6 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -711,7 +711,8 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::换货业务数据迁移状态与失败恢复" ], "classification": "behavior" }, @@ -721,7 +722,8 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::换货业务数据迁移状态与失败恢复" ], "classification": "behavior" }, @@ -2077,7 +2079,8 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::换货业务数据迁移状态与失败恢复" ], "classification": "behavior" }, @@ -2097,7 +2100,9 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::换货业务数据迁移状态与失败恢复", + "order-refund-exchange::换货业务数据迁移范围" ], "classification": "behavior" }, @@ -2117,7 +2122,8 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::换货业务数据迁移状态与失败恢复" ], "classification": "behavior" }, @@ -2136,7 +2142,8 @@ "capability": "export-task", "requirements": [ "export-task::导出任务终态", - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "order-refund-exchange::换货导出业务数据迁移状态" ], "classification": "behavior" }, diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index ac85e75..e6e6ba9 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3566,5 +3566,101 @@ ], "exit_status": 0 } + }, + { + "capability": "order-refund-exchange", + "requirement": "换货业务数据迁移状态与失败恢复", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/exchanges", + "/api/admin/exchanges/{id}", + "/api/admin/exchanges/{id}/ship", + "/api/admin/exchanges/{id}/complete" + ], + "handler_consumer_job": [ + "internal/routes/exchange.go", + "internal/handler/admin/exchange.go" + ], + "application_service_query": [ + "internal/service/exchange/service.go", + "internal/service/exchange/migration_failure.go", + "internal/query/exchange/list.go" + ], + "domain_state_amount": [ + "internal/model/exchange_order.go", + "pkg/constants/constants.go" + ], + "store_migration_config": [ + "migrations/000222_add_exchange_migration_status.up.sql", + "migrations/000222_add_exchange_migration_status.down.sql", + "internal/store/postgres/exchange_order_store.go" + ], + "verification": { + "command": "grep -n '仅超级管理员或平台用户可重试迁移失败的换货单' internal/service/exchange/service.go", + "literal_output": [ + "254:\t\terr = errors.New(errors.CodeForbidden, \"仅超级管理员或平台用户可重试迁移失败的换货单\")", + "271:\t\t\treturn errors.New(errors.CodeForbidden, \"仅超级管理员或平台用户可重试迁移失败的换货单\")" + ], + "exit_status": 0 + } + }, + { + "capability": "order-refund-exchange", + "requirement": "换货业务数据迁移范围", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/exchanges/{id}/complete" + ], + "handler_consumer_job": [ + "internal/routes/exchange.go", + "internal/handler/admin/exchange.go" + ], + "application_service_query": [ + "internal/service/exchange/service.go", + "internal/service/exchange/migration.go", + "internal/service/customer_binding/service.go" + ], + "domain_state_amount": [ + "internal/model/exchange_order.go", + "internal/model/personal_customer_phone.go" + ], + "store_migration_config": [ + "migrations/000222_add_exchange_migration_status.up.sql" + ], + "verification": { + "command": "grep -n 'errors.Wrap(errors.CodeExchangeMigrationFailed' internal/service/exchange/migration.go", + "literal_output": [ + "23:\t\treturn nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, \"执行钱包迁移失败\")", + "27:\t\treturn nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, \"迁移套餐使用记录失败\")", + "30:\t\treturn nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, \"复制累计充值字段失败\")", + "33:\t\treturn nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, \"复制资产标签失败\")" + ], + "exit_status": 0 + } + }, + { + "capability": "order-refund-exchange", + "requirement": "换货导出业务数据迁移状态", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/export-tasks" + ], + "handler_consumer_job": [ + "internal/exporter/exchange_scene.go" + ], + "application_service_query": [ + "internal/service/export_task/service.go" + ], + "domain_state_amount": [ + "pkg/constants/constants.go" + ], + "store_migration_config": [], + "verification": { + "command": "grep -n '\"迁移状态\"' internal/exporter/exchange_scene.go", + "literal_output": [ + "41:\t\t\"收货人姓名\", \"收货人电话\", \"收货地址\", \"快递公司\", \"快递单号\", \"状态\", \"迁移状态\", \"创建人\", \"创建时间\"," + ], + "exit_status": 0 + } } ] diff --git a/internal/exporter/exchange_scene.go b/internal/exporter/exchange_scene.go index 1bfd8f8..6f4c9c4 100644 --- a/internal/exporter/exchange_scene.go +++ b/internal/exporter/exchange_scene.go @@ -38,7 +38,7 @@ func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (in func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) { return []string{ "换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符", - "收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间", + "收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "迁移状态", "创建人", "创建时间", }, nil } @@ -64,6 +64,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off e.express_company, e.express_no, e.status, + e.migration_status, e.created_at, COALESCE(ac.username, '') AS creator_name `). @@ -91,6 +92,7 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off item.ExpressCompany, item.ExpressNo, constants.GetExchangeStatusName(item.Status), + constants.GetExchangeMigrationStatusName(item.MigrationStatus), item.CreatorName, item.CreatedAt.Format(exportTimeLayout), }) @@ -171,6 +173,7 @@ type exchangeExportRow struct { ExpressCompany string `gorm:"column:express_company"` ExpressNo string `gorm:"column:express_no"` Status int `gorm:"column:status"` + MigrationStatus string `gorm:"column:migration_status"` CreatorName string `gorm:"column:creator_name"` CreatedAt time.Time `gorm:"column:created_at"` } diff --git a/internal/model/dto/exchange_dto.go b/internal/model/dto/exchange_dto.go index 23fd5aa..6c4764d 100644 --- a/internal/model/dto/exchange_dto.go +++ b/internal/model/dto/exchange_dto.go @@ -30,7 +30,7 @@ type ExchangeShipRequest struct { ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"` ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"` NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产输入标识,卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN;保存快照使用权威标识"` - MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"` + MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行,迁移状态置为 pending;false:不执行,迁移状态保持 not_migrated)"` } type ExchangeCancelRequest struct { @@ -68,39 +68,42 @@ type ClientShippingInfoParams struct { // ExchangeOrderResponse 换货单响应。 type ExchangeOrderResponse struct { - ID uint `json:"id" description:"换货单ID"` - ExchangeNo string `json:"exchange_no" description:"换货单号"` - FlowType string `json:"flow_type" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"` - FlowTypeName string `json:"flow_type_name" description:"换货流程类型名称"` - OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"` - OldAssetID uint `json:"old_asset_id" description:"旧资产ID"` - OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"` - NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"` - NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"` - NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"` - RecipientName string `json:"recipient_name" description:"收件人姓名"` - RecipientPhone string `json:"recipient_phone" description:"收件人电话"` - RecipientAddress string `json:"recipient_address" description:"收货地址"` - ExpressCompany string `json:"express_company" description:"快递公司"` - ExpressNo string `json:"express_no" description:"快递单号"` - MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"` - MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"` - MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"` - ShippedAt *time.Time `json:"shipped_at,omitempty" description:"发货时间"` - CompletedAt *time.Time `json:"completed_at,omitempty" description:"换货完成时间"` - ExchangeReason string `json:"exchange_reason" description:"换货原因"` - Remark *string `json:"remark,omitempty" description:"备注"` - Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"` - StatusName string `json:"status_name" description:"换货状态名称(中文)"` - StatusText string `json:"status_text" description:"换货状态文本"` - ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` - UpdatedAt time.Time `json:"updated_at" description:"更新时间"` - DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"` - SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` - SubmitterName string `json:"submitter_name" description:"提交人账号名称"` - Creator uint `json:"creator" description:"创建人ID"` - Updater uint `json:"updater" description:"更新人ID"` + ID uint `json:"id" description:"换货单ID"` + ExchangeNo string `json:"exchange_no" description:"换货单号"` + FlowType string `json:"flow_type" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"` + FlowTypeName string `json:"flow_type_name" description:"换货流程类型名称"` + OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"` + OldAssetID uint `json:"old_asset_id" description:"旧资产ID"` + OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"` + NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"` + NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"` + NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"` + RecipientName string `json:"recipient_name" description:"收件人姓名"` + RecipientPhone string `json:"recipient_phone" description:"收件人电话"` + RecipientAddress string `json:"recipient_address" description:"收货地址"` + ExpressCompany string `json:"express_company" description:"快递公司"` + ExpressNo string `json:"express_no" description:"快递单号"` + MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"` + MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"` + MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"` + MigrationStatus string `json:"migration_status" enum:"not_migrated,pending,migrated,failed" description:"业务数据迁移状态 (not_migrated:不迁移, pending:待迁移, migrated:已迁移, failed:迁移失败);迁移结果以本字段为准,勿再用布尔字段推断"` + MigrationStatusName string `json:"migration_status_name" description:"业务数据迁移状态名称(中文)"` + MigrationFailureReason string `json:"migration_failure_reason,omitempty" description:"最近一次业务数据迁移失败原因,仅迁移状态为 failed 时返回"` + ShippedAt *time.Time `json:"shipped_at,omitempty" description:"发货时间"` + CompletedAt *time.Time `json:"completed_at,omitempty" description:"换货完成时间"` + ExchangeReason string `json:"exchange_reason" description:"换货原因"` + Remark *string `json:"remark,omitempty" description:"备注"` + Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"` + StatusName string `json:"status_name" description:"换货状态名称(中文)"` + StatusText string `json:"status_text" description:"换货状态文本"` + ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"` + SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` + SubmitterName string `json:"submitter_name" description:"提交人账号名称"` + Creator uint `json:"creator" description:"创建人ID"` + Updater uint `json:"updater" description:"更新人ID"` } type ExchangeListResponse struct { diff --git a/internal/model/exchange_order.go b/internal/model/exchange_order.go index ecfaf56..322bab6 100644 --- a/internal/model/exchange_order.go +++ b/internal/model/exchange_order.go @@ -11,6 +11,8 @@ import ( // ExchangeOrder 换货单模型 // 承载物流换货与直接换货的完整生命周期:后台发起 → 可选客户填写收货信息 → 可选后台发货 → 确认完成(含可选全量迁移) → 旧资产可转新 // 状态机:shipping 为 1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成,1/2 时可取消 → 5-已取消;direct 创建即完成为 4 +// 迁移状态机:物流换货创建为 not_migrated;发货按 migrate_data 写 pending 或 not_migrated;确认完成成功写 migrated、失败写 failed(保持 3 可再次确认完成) +// 迁移结果以 migration_status 为准,migrate_data、migration_completed、migration_balance 仅保留兼容语义 type ExchangeOrder struct { gorm.Model BaseModel `gorm:"embedded"` @@ -45,6 +47,10 @@ type ExchangeOrder struct { MigrationCompleted bool `gorm:"column:migration_completed;type:boolean;default:false;comment:迁移是否已完成" json:"migration_completed"` MigrationBalance int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移转移金额(分)" json:"migration_balance"` + // 迁移状态(保留上面的布尔字段供兼容客户端与既有业务读取,迁移结果以本字段为准) + MigrationStatus string `gorm:"column:migration_status;type:varchar(20);not null;default:'not_migrated';comment:业务数据迁移状态 not_migrated-不迁移 pending-待迁移 migrated-已迁移 failed-迁移失败" json:"migration_status"` + MigrationFailureReason string `gorm:"column:migration_failure_reason;type:varchar(500);not null;default:'';comment:最近一次业务数据迁移失败的安全化原因,仅迁移状态为 failed 时非空" json:"migration_failure_reason"` + // 业务时间 ShippedAt *time.Time `gorm:"column:shipped_at;comment:发货时间,仅物流换货发货成功后写入" json:"shipped_at,omitempty"` CompletedAt *time.Time `gorm:"column:completed_at;comment:换货完成时间,物流换货和直接换货完成后写入" json:"completed_at,omitempty"` diff --git a/internal/query/exchange/list.go b/internal/query/exchange/list.go index 884759d..f90cf2d 100644 --- a/internal/query/exchange/list.go +++ b/internal/query/exchange/list.go @@ -126,6 +126,11 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse if order.DeletedAt.Valid { deletedAt = &order.DeletedAt.Time } + // 失败原因只在迁移失败时对外可见,其他状态一律不返回,避免把历史原因误读为当前状态。 + failureReason := "" + if order.MigrationStatus == constants.ExchangeMigrationStatusFailed { + failureReason = order.MigrationFailureReason + } return &dto.ExchangeOrderResponse{ ID: order.ID, ExchangeNo: order.ExchangeNo, FlowType: effectiveFlowType(order.FlowType), FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType), @@ -134,7 +139,9 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse RecipientName: order.RecipientName, RecipientPhone: order.RecipientPhone, RecipientAddress: order.RecipientAddress, ExpressCompany: order.ExpressCompany, ExpressNo: order.ExpressNo, MigrateData: order.MigrateData, MigrationCompleted: order.MigrationCompleted, MigrationBalance: order.MigrationBalance, - ShippedAt: order.ShippedAt, CompletedAt: order.CompletedAt, + MigrationStatus: order.MigrationStatus, MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus), + MigrationFailureReason: failureReason, + ShippedAt: order.ShippedAt, CompletedAt: order.CompletedAt, ExchangeReason: order.ExchangeReason, Remark: order.Remark, Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status), ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt, diff --git a/internal/routes/exchange.go b/internal/routes/exchange.go index 3139d7a..31bfa7e 100644 --- a/internal/routes/exchange.go +++ b/internal/routes/exchange.go @@ -42,10 +42,12 @@ func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHan Register(router, doc, basePath, "POST", "/exchanges/:id/complete", handler.Complete, RouteSpec{ Summary: "确认换货完成", - Tags: []string{"换货管理"}, - Input: new(dto.ExchangeIDRequest), - Output: nil, - Auth: true, + Description: "确认换货完成;仅超级管理员或平台用户可对迁移状态为 failed 的物流换货单重试," + + "其他账号对 failed 单返回 403/1005;非 failed 单沿用既有完成权限。", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeIDRequest), + Output: nil, + Auth: true, }) Register(router, doc, basePath, "POST", "/exchanges/:id/cancel", handler.Cancel, RouteSpec{ diff --git a/internal/service/exchange/migration_failure.go b/internal/service/exchange/migration_failure.go new file mode 100644 index 0000000..38429aa --- /dev/null +++ b/internal/service/exchange/migration_failure.go @@ -0,0 +1,149 @@ +package exchange + +import ( + "context" + stderrors "errors" + "strings" + "time" + + "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" + "gorm.io/gorm" +) + +const ( + // exchangeMigrationFailureReasonMaxRunes 是失败原因的最大字符数,与 tb_exchange_order.migration_failure_reason 列宽一致。 + exchangeMigrationFailureReasonMaxRunes = 500 + // exchangeMigrationInfrastructureFailureReason 是基础设施类迁移失败的固定安全摘要。 + // 数据库与内部错误的原文可能包含连接串、SQL 或载荷,不得进入可被列表和详情读取的字段。 + exchangeMigrationInfrastructureFailureReason = "迁移执行失败,请稍后重试或联系技术支持" +) + +// isExchangeMigrationFailure 判断错误链上是否带业务数据迁移失败码。 +// executeMigrationWithTx 的每个子步骤都以 CodeExchangeMigrationFailed 收口, +// 因此该判定等价于「本次确认完成在业务数据迁移步骤失败」;其他步骤失败不写迁移失败状态。 +// 与 asset_audit.BuildErrorInfo 一致地按标准库错误链接口穿透,链中出现非 AppError 包装时继续向上查找。 +func isExchangeMigrationFailure(err error) bool { + for err != nil { + if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeExchangeMigrationFailed { + return true + } + err = stderrors.Unwrap(err) + } + return false +} + +// isExchangeMigrationRetryAuthorized 判断当前调用者是否可以确认完成该换货单。 +// 只有迁移状态为 failed 的重试才要求超级管理员或平台用户;其他状态沿用既有完成门禁, +// 不削弱代理与企业账号既有的确认完成权限。 +func isExchangeMigrationRetryAuthorized(ctx context.Context, migrationStatus string) bool { + if migrationStatus != constants.ExchangeMigrationStatusFailed { + return true + } + userType := middleware.GetUserTypeFromContext(ctx) + return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform +} + +// exchangeMigrationFailureReason 生成可安全展示的迁移失败原因。 +// 只拼接 AppError 链上的中文 Message,丢弃非 AppError 的底层 cause; +// 数据库、内部错误等基础设施失败降级为固定安全摘要,禁止把数据库、外部服务或敏感载荷原文写入该字段。 +func exchangeMigrationFailureReason(err error) string { + var parts []string + for current := err; current != nil; current = stderrors.Unwrap(current) { + appErr, ok := current.(*errors.AppError) + if !ok { + // 丢弃非 AppError 的底层 cause,禁止把数据库/外部原文写入可展示字段。 + continue + } + if isExchangeMigrationInfrastructureCode(appErr.Code) { + return exchangeMigrationInfrastructureFailureReason + } + parts = append(parts, appErr.Message) + } + if len(parts) == 0 { + return exchangeMigrationInfrastructureFailureReason + } + return truncateExchangeMigrationFailureReason(strings.Join(parts, ":")) +} + +func isExchangeMigrationInfrastructureCode(code int) bool { + return code == errors.CodeDatabaseError || code == errors.CodeInternalError +} + +// truncateExchangeMigrationFailureReason 按 rune 截断失败原因,保留 499 个字符并以省略号结尾, +// 总长度不超过列宽 500,便于后端与前端识别原因已被裁剪。 +func truncateExchangeMigrationFailureReason(reason string) string { + runes := []rune(reason) + if len(runes) <= exchangeMigrationFailureReasonMaxRunes { + return reason + } + return string(runes[:exchangeMigrationFailureReasonMaxRunes-1]) + "…" +} + +// markExchangeMigrationFailedWithTx 在同一短事务内条件更新迁移失败状态与安全化失败原因。 +// 条件为换货单仍是物流换货的「已发货待确认」状态:并发确认已完成换货时命中 0 行, +// 此时不写状态,避免把已经 migrated 的换货单改回 failed;失败原因只随状态一并写入。 +func (s *Service) markExchangeMigrationFailedWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, businessErr error) error { + result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}). + Where("id = ? AND status = ?", order.ID, constants.ExchangeStatusShipped). + Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, constants.ExchangeFlowTypeShipping). + Updates(map[string]any{ + "migration_status": constants.ExchangeMigrationStatusFailed, + "migration_failure_reason": exchangeMigrationFailureReason(businessErr), + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新换货单迁移失败状态失败") + } + if result.RowsAffected == 0 { + return nil + } + order.MigrationStatus = constants.ExchangeMigrationStatusFailed + return nil +} + +// recordExchangeMigrationFailure 在主事务回滚后以独立短事务保存迁移失败状态、安全化原因与失败审计。 +// 该短事务不与已回滚的主事务共用连接或事务,符合 ENG-TX-001 的 failed 事实例外; +// 短事务自身异常复用既有换货失败审计的次级故障记录方式,不改变对外返回的原始迁移失败。 +// +// 本函数刻意镜像既有失败审计接缝(audit.go 的 recordCardExchangeFailure/recordCardExchangeAuditSecondaryFailure +// 与 device_audit.go 的 recordExchangeOrderFailure):后者只写审计、不写换货单状态,而本用例需要在同一短事务内 +// 同时落 failed 状态与失败原因,故未改动既有接缝。后续修改既有卡/设备失败审计接缝时须同步此处, +// 保持资产类型分派、动作码转换(deviceExchangeActionCode)、摘要改写与次级故障记录方式一致。 +func (s *Service) recordExchangeMigrationFailure(ctx context.Context, order *model.ExchangeOrder, businessErr error) { + if order == nil { + return + } + isDevice := order.OldAssetType == constants.ExchangeAssetTypeDevice + if !isDevice && order.OldAssetType != constants.ExchangeAssetTypeIotCard { + return + } + actionCode, summary, seamMessage := constants.AuditActionCardExchangeCompleted, "完成卡换货失败", "卡换货统一审计接缝未配置" + if isDevice { + actionCode = deviceExchangeActionCode(actionCode) + summary = strings.ReplaceAll(summary, "卡", "设备") + seamMessage = "设备换货统一审计接缝未配置" + } + if s.db == nil || s.auditWriter == nil { + recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, errors.New(errors.CodeInvalidStatus, seamMessage)) + return + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if updateErr := s.markExchangeMigrationFailedWithTx(ctx, tx, order, businessErr); updateErr != nil { + return updateErr + } + if isDevice { + oldDevice, newDevice := s.loadDeviceExchangeAuditDevices(ctx, order) + return s.appendDeviceExchangeAudit(ctx, tx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldDevice, newDevice, + nil, nil, nil, nil, nil, nil, nil, businessErr) + } + oldCard, newCard := s.loadCardExchangeAuditCards(ctx, order) + return s.appendCardExchangeAudit(ctx, tx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldCard, newCard, + nil, nil, nil, nil, nil, nil, nil, businessErr) + }); err != nil { + recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, err) + } +} diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 6af1bc1..e6c011f 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -100,7 +100,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (* MigrationCompleted: false, MigrationBalance: 0, MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData, - BaseModel: model.BaseModel{Creator: creator, Updater: creator}, + // 创建阶段不写迁移意图:物流换货的唯一写入点是发货,直接换货在同一事务内完成。 + MigrationStatus: constants.ExchangeMigrationStatusNotMigrated, + BaseModel: model.BaseModel{Creator: creator, Updater: creator}, } if asset.ShopID != nil { order.ShopID = asset.ShopID @@ -137,6 +139,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (* if directErr != nil { order.ID = 0 order.Status = constants.ExchangeStatusPendingInfo + order.MigrationStatus = constants.ExchangeMigrationStatusNotMigrated order.MigrationCompleted = false order.MigrationBalance = 0 order.CompletedAt = nil @@ -246,6 +249,12 @@ func (s *Service) Complete(ctx context.Context, id uint) error { s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err) return err } + // 事务外预读只做快速拒绝,授权判定以锁内读到的迁移状态为准。 + if !isExchangeMigrationRetryAuthorized(ctx, order.MigrationStatus) { + err = errors.New(errors.CodeForbidden, "仅超级管理员或平台用户可重试迁移失败的换货单") + s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err) + return err + } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id) @@ -258,10 +267,18 @@ func (s *Service) Complete(ctx context.Context, id uint) error { if lockedOrder.Status != constants.ExchangeStatusShipped { return errors.New(errors.CodeExchangeStatusInvalid) } + if !isExchangeMigrationRetryAuthorized(ctx, lockedOrder.MigrationStatus) { + return errors.New(errors.CodeForbidden, "仅超级管理员或平台用户可重试迁移失败的换货单") + } return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped) }) if err != nil { - s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err) + if isExchangeMigrationFailure(err) { + // 迁移失败已在主事务回滚,失败状态、安全化原因与失败审计在回滚后短事务内落库。 + s.recordExchangeMigrationFailure(ctx, order, err) + } else { + s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err) + } } return err } @@ -740,6 +757,11 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re } now := time.Now() + // 迁移意图在发货时确定:选择迁移进入待迁移,否则保持不迁移。 + migrationStatus := constants.ExchangeMigrationStatusNotMigrated + if req.MigrateData { + migrationStatus = constants.ExchangeMigrationStatusPending + } result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}). Where("id = ? AND status = ?", lockedOrder.ID, constants.ExchangeStatusPendingShip). Updates(map[string]any{ @@ -749,6 +771,7 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re "express_company": req.ExpressCompany, "express_no": req.ExpressNo, "migrate_data": req.MigrateData, + "migration_status": migrationStatus, "shipped_at": now, "status": constants.ExchangeStatusShipped, "updater": middleware.GetUserIDFromContext(ctx), @@ -764,6 +787,7 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re lockedOrder.NewAssetID = &newAsset.AssetID lockedOrder.NewAssetIdentifier = newAsset.Identifier lockedOrder.MigrateData = req.MigrateData + lockedOrder.MigrationStatus = migrationStatus lockedOrder.ShippedAt = &now lockedOrder.Status = constants.ExchangeStatusShipped return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShipped, "卡换货单已发货", constants.AuditResultSuccess, @@ -821,11 +845,19 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order } now := time.Now() + // 成功后迁移状态落到 migrated 或 not_migrated,并清空上一次失败原因。 + migrationStatus := constants.ExchangeMigrationStatusNotMigrated + if order.MigrateData { + migrationStatus = constants.ExchangeMigrationStatusMigrated + } + beforeMigrationStatus := order.MigrationStatus updates := map[string]any{ - "status": constants.ExchangeStatusCompleted, - "completed_at": now, - "updater": middleware.GetUserIDFromContext(ctx), - "updated_at": now, + "status": constants.ExchangeStatusCompleted, + "migration_status": migrationStatus, + "migration_failure_reason": "", + "completed_at": now, + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": now, } if order.MigrateData { updates["migration_completed"] = true @@ -840,9 +872,11 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } - orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance} + orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": beforeMigrationStatus} order.Status = constants.ExchangeStatusCompleted order.CompletedAt = &now + order.MigrationStatus = migrationStatus + order.MigrationFailureReason = "" if migration != nil { order.MigrationCompleted = true order.MigrationBalance = migration.Balance @@ -861,7 +895,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order } return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess, order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &oldDeviceAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &newDeviceAfter}, - orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}, + orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": order.MigrationStatus}, map[string]any{"asset_status": oldAsset.Device.AssetStatus, "shop_id": oldAsset.Device.ShopID}, map[string]any{"asset_status": oldDeviceAfter.AssetStatus, "shop_id": oldDeviceAfter.ShopID}, map[string]any{"asset_status": newAsset.Device.AssetStatus, "shop_id": newAsset.Device.ShopID}, map[string]any{"asset_status": newDeviceAfter.AssetStatus, "shop_id": newDeviceAfter.ShopID}, extra, nil) @@ -879,7 +913,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order } return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess, order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &oldCardAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &newCardAfter}, - orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}, + orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": order.MigrationStatus}, map[string]any{"asset_status": oldAsset.Card.AssetStatus, "shop_id": oldAsset.Card.ShopID}, map[string]any{"asset_status": oldCardAfter.AssetStatus, "shop_id": oldCardAfter.ShopID}, map[string]any{"asset_status": newAsset.Card.AssetStatus, "shop_id": newAsset.Card.ShopID}, map[string]any{"asset_status": newCardAfter.AssetStatus, "shop_id": newCardAfter.ShopID}, extra, nil) @@ -1222,39 +1256,47 @@ func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.Excha if order.DeletedAt.Valid { deletedAt = &order.DeletedAt.Time } + // 失败原因只在迁移失败时对外可见,其他状态一律不返回,避免把历史原因误读为当前状态。 + failureReason := "" + if order.MigrationStatus == constants.ExchangeMigrationStatusFailed { + failureReason = order.MigrationFailureReason + } return &dto.ExchangeOrderResponse{ - ID: order.ID, - ExchangeNo: order.ExchangeNo, - FlowType: effectiveExchangeFlowType(order.FlowType), - FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType), - OldAssetType: order.OldAssetType, - OldAssetID: order.OldAssetID, - OldAssetIdentifier: order.OldAssetIdentifier, - NewAssetType: order.NewAssetType, - NewAssetID: order.NewAssetID, - NewAssetIdentifier: order.NewAssetIdentifier, - RecipientName: order.RecipientName, - RecipientPhone: order.RecipientPhone, - RecipientAddress: order.RecipientAddress, - ExpressCompany: order.ExpressCompany, - ExpressNo: order.ExpressNo, - MigrateData: order.MigrateData, - MigrationCompleted: order.MigrationCompleted, - MigrationBalance: order.MigrationBalance, - ShippedAt: order.ShippedAt, - CompletedAt: order.CompletedAt, - ExchangeReason: order.ExchangeReason, - Remark: order.Remark, - Status: order.Status, - StatusName: constants.GetExchangeStatusName(order.Status), - StatusText: constants.GetExchangeStatusName(order.Status), - ShopID: order.ShopID, - CreatedAt: order.CreatedAt, - UpdatedAt: order.UpdatedAt, - DeletedAt: deletedAt, - SubmitterID: order.Creator, - Creator: order.Creator, - Updater: order.Updater, + ID: order.ID, + ExchangeNo: order.ExchangeNo, + FlowType: effectiveExchangeFlowType(order.FlowType), + FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType), + OldAssetType: order.OldAssetType, + OldAssetID: order.OldAssetID, + OldAssetIdentifier: order.OldAssetIdentifier, + NewAssetType: order.NewAssetType, + NewAssetID: order.NewAssetID, + NewAssetIdentifier: order.NewAssetIdentifier, + RecipientName: order.RecipientName, + RecipientPhone: order.RecipientPhone, + RecipientAddress: order.RecipientAddress, + ExpressCompany: order.ExpressCompany, + ExpressNo: order.ExpressNo, + MigrateData: order.MigrateData, + MigrationCompleted: order.MigrationCompleted, + MigrationBalance: order.MigrationBalance, + MigrationStatus: order.MigrationStatus, + MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus), + MigrationFailureReason: failureReason, + ShippedAt: order.ShippedAt, + CompletedAt: order.CompletedAt, + ExchangeReason: order.ExchangeReason, + Remark: order.Remark, + Status: order.Status, + StatusName: constants.GetExchangeStatusName(order.Status), + StatusText: constants.GetExchangeStatusName(order.Status), + ShopID: order.ShopID, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, + DeletedAt: deletedAt, + SubmitterID: order.Creator, + Creator: order.Creator, + Updater: order.Updater, } } diff --git a/migrations/000222_add_exchange_migration_status.down.sql b/migrations/000222_add_exchange_migration_status.down.sql new file mode 100644 index 0000000..f75049b --- /dev/null +++ b/migrations/000222_add_exchange_migration_status.down.sql @@ -0,0 +1,9 @@ +-- 回滚换货业务数据迁移状态与失败原因。 +-- 先删四值约束再删两列,与 up 的加列顺序成对;既有 migrate_data、migration_completed 语义不受影响。 + +ALTER TABLE tb_exchange_order + DROP CONSTRAINT IF EXISTS chk_exchange_order_migration_status; + +ALTER TABLE tb_exchange_order + DROP COLUMN IF EXISTS migration_failure_reason, + DROP COLUMN IF EXISTS migration_status; diff --git a/migrations/000222_add_exchange_migration_status.up.sql b/migrations/000222_add_exchange_migration_status.up.sql new file mode 100644 index 0000000..dfbd56c --- /dev/null +++ b/migrations/000222_add_exchange_migration_status.up.sql @@ -0,0 +1,28 @@ +-- 换货业务数据迁移状态与最近一次失败原因。 +-- 既有 migrate_data、migration_completed 两个布尔字段无法区分未迁移、待迁移、已迁移与迁移失败, +-- 也无法承载失败原因,后台列表与详情只能靠布尔组合推断。本迁移为 tb_exchange_order 增加可查询状态。 +-- 实施顺序:先带默认值加列(历史行取默认值)→ 按既有字段回填历史映射 → 加四值 CHECK 约束。 +-- 历史记录不推断失败原因,失败原因一律置空(not_migrated 以外的状态由应用写入时才有值)。 +-- 不新增索引:本期没有按迁移状态筛选或后台批处理查询,列表分页已直接投影换货单字段。 + +ALTER TABLE tb_exchange_order + ADD COLUMN IF NOT EXISTS migration_status VARCHAR(20) NOT NULL DEFAULT 'not_migrated', + ADD COLUMN IF NOT EXISTS migration_failure_reason VARCHAR(500) NOT NULL DEFAULT ''; + +-- 历史映射:未选择迁移 → not_migrated;已选择且迁移成功 → migrated;已选择但未成功完成 → pending。 +-- 只更新 migrate_data 为真的行(false 行已由列默认值落到 not_migrated),避免全表重写与多余 updated_at 变更。 +-- 注意:本回填只依据 migrate_data、migration_completed 推导,重新执行 up(含先 down 再 up)会把 failed 退化为 pending; +-- 应用回滚需先回滚到仍兼容新增列的版本,仅在确认没有依赖迁移状态的数据或功能后才执行 down。 +UPDATE tb_exchange_order +SET migration_status = CASE + WHEN migration_completed IS TRUE THEN 'migrated' + ELSE 'pending' + END +WHERE migrate_data IS TRUE; + +ALTER TABLE tb_exchange_order + ADD CONSTRAINT chk_exchange_order_migration_status + CHECK (migration_status IN ('not_migrated', 'pending', 'migrated', 'failed')); + +COMMENT ON COLUMN tb_exchange_order.migration_status IS '业务数据迁移状态 not_migrated-不迁移 pending-待迁移 migrated-已迁移 failed-迁移失败'; +COMMENT ON COLUMN tb_exchange_order.migration_failure_reason IS '最近一次业务数据迁移失败的安全化原因,仅迁移状态为 failed 时非空,最长500字符'; diff --git a/openspec/changes/add-exchange-data-migration-status/tasks.md b/openspec/changes/add-exchange-data-migration-status/tasks.md deleted file mode 100644 index 93385f9..0000000 --- a/openspec/changes/add-exchange-data-migration-status/tasks.md +++ /dev/null @@ -1,18 +0,0 @@ -## 1. 数据契约 - -- [ ] 1.1 新增一对当前根迁移,为 `tb_exchange_order` 添加迁移状态和失败原因字段,并按既有迁移布尔字段回填历史记录。 -- [ ] 1.2 在换货模型和常量中定义四种迁移状态及中文名称,保留现有布尔字段的兼容语义。 -- [ ] 1.3 扩展换货列表、详情 DTO 及两个读侧投影,返回迁移状态、中文名称及仅失败时的安全失败原因。 - -## 2. 换货完成与恢复 - -- [ ] 2.1 在创建、发货和成功完成的写路径维护不迁移、待迁移和已迁移状态,并在成功后清除失败原因。 -- [ ] 2.2 保持完整换货和业务数据迁移在同一 GORM 事务;迁移失败时回滚全部业务修改,再以条件短事务保存物流换货单的失败状态、经安全处理的失败原因和审计事实。 -- [ ] 2.3 限制迁移失败的物流换货重试仅由超级管理员或平台用户发起;重试须锁定换货单、重新执行全套迁移并防止并发重复完成。 -- [ ] 2.4 保持直接换货失败时整体回滚且不持久化失败换货单,确认迁移范围不包含手机号—资产关联。 - -## 3. 文档与验证 - -- [ ] 3.1 更新换货接口 OpenAPI 描述并运行 `go run cmd/gendocs/main.go`,核对状态枚举及失败原因的响应契约。 -- [ ] 3.2 在隔离数据库按 `scripts/migrate.sh` 使用显式 `DB_*` 参数验证新迁移 up/down/up、历史状态映射及回滚后的 Schema。 -- [ ] 3.3 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`openspec validate add-exchange-data-migration-status --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-exchange-data-migration-status/.openspec.yaml b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/.openspec.yaml similarity index 100% rename from openspec/changes/add-exchange-data-migration-status/.openspec.yaml rename to openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/.openspec.yaml diff --git a/openspec/changes/add-exchange-data-migration-status/design.md b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/design.md similarity index 52% rename from openspec/changes/add-exchange-data-migration-status/design.md rename to openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/design.md index 8dc7881..27e82a5 100644 --- a/openspec/changes/add-exchange-data-migration-status/design.md +++ b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/design.md @@ -14,7 +14,8 @@ **Non-Goals:** - 不改变直接换货创建失败即整体回滚、无换货单留存的现有行为。 - 不修改迁移项目、增加迁移明细表、迁移手机号—资产关联,或改变资产归属和个人客户—资产绑定的既有换货动作。 -- 不新增列表筛选、导出或路由。 +- 不新增列表筛选或路由;换货导出仅新增一列迁移状态,不导出失败原因。 +- 不提供迁移失败换货单的放弃或取消出口:`failed` 单只能通过重试成功离开,如需放弃能力须另立需求。 ## Decisions @@ -22,7 +23,9 @@ 在 `tb_exchange_order` 新增非空 `migration_status` 和非空 `migration_failure_reason`。状态使用字符串 `not_migrated`、`pending`、`migrated`、`failed`,中文名称仅在应用层投影;失败原因最长 500 字符且为空表示无失败原因。 -新字段表达完整结果,保留 `migrate_data`、`migration_completed` 和 `migration_balance` 供兼容客户端及既有业务使用。新建/发货时按是否选择迁移写入 `not_migrated` 或 `pending`;成功完成写入 `migrated` 并清空失败原因。 +新字段表达完整结果,保留 `migrate_data`、`migration_completed` 和 `migration_balance` 供兼容客户端及既有业务使用。物流换货创建即写 `not_migrated`(创建阶段不写 `migrate_data`);发货时按请求 `migrate_data` 写入 `pending` 或 `not_migrated`;成功完成写入 `migrated`、清空失败原因,并同步既有 `migration_completed` 与 `migration_balance`。 + +新增列带默认值,迁移按「ADD COLUMN(带默认值)→ 以既有字段回填 → 添加 `chk_exchange_order_migration_status` 约束校验四值」顺序执行;数据库层白名单与 `pkg/constants` 取值一致。 备选方案是在现有两个布尔字段上叠加前端规则。放弃原因是无法表达失败和失败原因,且容易把待迁移与失败混淆。 @@ -30,13 +33,15 @@ 业务数据迁移仍与换货完成共享原有 GORM 事务。任何一步失败均回滚资产状态、归属、客户绑定、钱包、套餐和标签的本次修改,确保重试从完整且未部分迁移的事实开始。 -外层识别到迁移失败后,另开短事务,以换货单仍处于可确认完成状态为条件更新 `migration_status=failed` 与经安全截断的失败原因,并写入对应失败审计。失败状态的持久化不得与已回滚的业务数据迁移共用事务。 +外层识别到迁移失败后,另开短事务,在同一个短事务内写入失败状态与失败审计;该短事务不得与已回滚的主事务共用连接或事务。条件更新为「换货单仍处于可确认完成状态」:`RowsAffected` 为 0 时跳过状态写入但仍写失败审计,避免并发确认下把已成功完成的换货单改回 `failed`。 + +失败原因按固定规则安全化:只拼接 AppError 链上的中文 `Message`,丢弃非 AppError 的底层 `cause`,按 rune 截断至不超过 500 字符;数据库等基础设施错误降级为固定安全摘要;业务类原因(例如旧资产钱包存在冻结余额)保留可见。禁止把数据库、外部服务或敏感载荷原文写入该字段。 备选方案是让迁移失败提交部分换货结果。放弃原因是会产生无法可靠补偿的钱包和套餐事实,且违背本期整套迁移原子执行的产品边界。 ### 3. 只为失败的物流换货增加受限重试 -保持既有确认完成入口。换货单为物流流程、业务状态仍为已发货待确认且迁移状态为 `failed` 时,仅超级管理员或平台用户可再次确认;用例在事务内重新锁定并验证状态,然后从钱包余额开始重新执行全部迁移。未失败的换货沿用现有可确认权限和状态门禁。 +保持既有确认完成入口。换货单为物流流程、业务状态仍为已发货待确认且迁移状态为 `failed` 时,仅超级管理员或平台用户可再次确认。授权以锁内判定为准:用例在 `FOR UPDATE` 锁定换货单后读取 `migration_status`;事务外预读只用于快速拒绝,不作为授权依据。授权通过后从钱包余额开始重新执行全部迁移。未失败的换货沿用现有可确认权限和状态门禁,代理既有完成权限不被削弱。 直接换货继续创建即完成;其迁移失败会回滚整笔创建,不留换货单或失败状态,避免为单一失败路径引入新的直接换货中间状态与重试接口。 @@ -50,34 +55,35 @@ ### 数据投影与历史映射 -- `tb_exchange_order` 新增 `migration_status varchar(20) NOT NULL`、`migration_failure_reason varchar(500) NOT NULL DEFAULT ''`;DTO 列表与详情新增 `migration_status`、`migration_status_name`,并仅在状态为 `failed` 时返回 `migration_failure_reason`。 +- `tb_exchange_order` 新增 `migration_status varchar(20) NOT NULL DEFAULT 'not_migrated'`、`migration_failure_reason varchar(500) NOT NULL DEFAULT ''`,并以 `chk_exchange_order_migration_status` 约束四值;DTO 列表与详情新增 `migration_status`、`migration_status_name`,并仅在状态为 `failed` 时返回 `migration_failure_reason`。 - 上线迁移将 `migrate_data=false` 映射 `not_migrated`,`migrate_data=true AND migration_completed=true` 映射 `migrated`,其余已存在 `migrate_data=true` 映射 `pending`;不推断历史失败原因。 ### 创建、发货与确认完成 -- `POST /exchanges`:沿用现有创建入参和权限。物流单创建时按 `migrate_data` 初始化 `not_migrated` 或 `pending`;直接换货在同一创建事务中执行完成和可选迁移,任一步失败则整个创建回滚,不返回换货单或 `failed` 状态。 -- `POST /exchanges/:id/ship`:沿用既有物流状态机和发货字段;不改变迁移状态,选择迁移的单仍为 `pending`。 -- `POST /exchanges/:id/complete`:先在同一事务锁定换货单并验证既有“已发货待确认”状态和数据范围。`not_migrated` 只执行固有资产归属及个人客户绑定;`pending` 执行钱包余额、有效套餐使用、累计充值、资产标签的完整迁移及固有动作。成功时写 `migrated`、清空失败原因、写完成时间和既有成功审计。 +- `POST /exchanges`:沿用现有创建入参和权限。物流单创建时恒为 `not_migrated`(创建阶段不写 `migrate_data`,迁移意图在发货时确定);直接换货仍在同一创建事务内完成,任一步失败则整个创建回滚,不持久化换货单、不产生 `failed` 状态。 +- `POST /exchanges/:id/ship`:沿用既有物流状态机和发货字段;按请求 `migrate_data` 写入 `pending` 或 `not_migrated`,不改变既有物流状态机。 +- `POST /exchanges/:id/complete`:先在同一事务锁定换货单并验证既有“已发货待确认”状态和数据范围。`not_migrated` 只执行固有资产归属及个人客户绑定;`pending` 执行钱包余额、有效套餐使用、累计充值、资产标签的完整迁移及固有动作。成功时写 `migrated`、清空失败原因、同步 `migration_completed` 与 `migration_balance`,并写完成时间和既有成功审计。 ### 失败与受限重试 -- 当 `pending` 迁移任一步失败时,主事务必须回滚资产归属、个人客户绑定、钱包、套餐、累计字段、标签和完成状态;外层另开短事务,条件为换货单仍是可确认完成状态,写 `failed`、安全截断至 500 字符的失败原因及失败审计。 -- 对 `migration_status=failed` 的物流单,`POST /exchanges/:id/complete` 仅超级管理员或平台用户可重试;锁定后从钱包余额开始重跑全部迁移,禁止仅重试某一子项。非平台账号返回无权,非失败单沿用既有完成状态门禁,不将完成接口变成通用重复执行入口。 +- 当 `pending` 迁移任一步失败时,主事务必须回滚资产归属、个人客户绑定、钱包、套餐、累计字段、标签和完成状态;外层另开短事务,在同一短事务内写 `failed`、按上述规则安全化的失败原因与失败审计,条件为换货单仍是可确认完成状态;`RowsAffected` 为 0 时跳过状态写入但仍写审计。该短事务不与已回滚的主事务共用连接或事务,符合 `ENG-TX-001` 的例外条件(回滚后的 failed/denied 事实与审计使用独立短事务,且以业务单仍处于允许该失败事实的状态为条件更新)。 +- 对 `migration_status=failed` 的物流单,`POST /exchanges/:id/complete` 仅超级管理员或平台用户可重试;授权以 `FOR UPDATE` 锁定后读到的 `migration_status` 为准,事务外预读只做快速拒绝。授权通过后从钱包余额开始重跑全部迁移,禁止仅重试某一子项。非超级管理员、非平台账号返回无权,非失败单沿用既有完成状态门禁、不削弱代理既有完成权限,不将完成接口变成通用重复执行入口。 - 手机号—资产关联永不在上述动作中读取、复制或删除;新资产后续按自身 H5 手机号绑定规则处理。 ### 读取行为 - `GET /exchanges` 与 `GET /exchanges/:id` 沿用既有换货数据范围,返回新状态字段;旧 `migrate_data`、`migration_completed`、`migration_balance` 保持原响应兼容,但调用方不得再以其组合判断迁移结果。 +- 换货导出沿用既有导出数据范围,在「状态」列之后新增一列迁移状态并使用中文名称;导出不输出失败原因,导出列不参与任何迁移、完成或数据范围规则。 ## Risks / Trade-offs -- [失败原因可能包含底层敏感或不稳定信息] → 使用稳定错误的安全摘要并限制长度,禁止直接返回数据库、外部服务或敏感载荷。 -- [失败状态更新与失败审计二次事务异常] → 复用既有换货失败审计的次级故障记录方式;状态更新与成功必达审计同事务,更新失败时返回原失败并保留诊断。 +- [失败原因可能包含底层敏感或不稳定信息] → 只拼接 AppError 链上的中文 `Message`、丢弃非 AppError 的 `cause` 并按 rune 截断至 500 字符;数据库等基础设施错误降级为固定安全摘要,业务类原因保留可见,禁止写入数据库、外部服务或敏感载荷原文。 +- [失败状态写入与失败审计的短事务二次异常] → 状态与审计同属一个回滚后短事务,`RowsAffected` 为 0 时跳过状态写入仍写审计;短事务异常复用既有换货失败审计的次级故障记录方式,并返回原失败、保留诊断。 - [并发确认造成重复迁移] → 重用换货单 `FOR UPDATE` 锁与预期业务状态更新;只有仍为 `failed` 的失败重试可以进入受限路径。 - [旧客户端只读取布尔字段] → 保持原字段及其成功语义,新字段只增不删。 ## Migration Plan -1. 在隔离数据库执行新迁移,核对历史映射、非空约束和 down 后 Schema。 -2. 发布同时包含迁移、写侧状态转换、列表/详情 DTO 投影和审计更新的版本。 +1. 在隔离数据库执行新迁移(ADD COLUMN 带默认值 → 按既有字段回填 → 添加 `chk_exchange_order_migration_status` 约束),核对历史映射、非空与四值约束以及 down 后 Schema。 +2. 发布同时包含迁移、写侧状态转换、列表/详情 DTO 投影、换货导出新列和审计更新的版本。 3. 发生应用回滚时,先回滚应用至仍兼容新增列的版本;仅在确认没有依赖新状态的数据或功能后执行 down 迁移。 \ No newline at end of file diff --git a/openspec/changes/add-exchange-data-migration-status/proposal.md b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/proposal.md similarity index 81% rename from openspec/changes/add-exchange-data-migration-status/proposal.md rename to openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/proposal.md index bd65dd5..f5e1756 100644 --- a/openspec/changes/add-exchange-data-migration-status/proposal.md +++ b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/proposal.md @@ -8,7 +8,7 @@ - 将换货业务数据迁移结果统一为不迁移、待迁移、已迁移、迁移失败四个可观察状态,并保留最近一次失败原因。 - 在换货完成的迁移失败场景中保留换货单可完成状态及失败事实;超级管理员或平台用户修复条件后可再次确认完成,并重新原子执行完整迁移。 -- 在换货列表和详情返回迁移状态及失败原因(仅迁移失败时),替代前端对现有布尔字段的推断。 +- 在换货列表和详情返回迁移状态及失败原因(仅迁移失败时),替代前端对现有布尔字段的推断;换货导出仅新增迁移状态列(中文名称),不导出失败原因。 - 将迁移范围明确限定为资产钱包余额、有效套餐使用记录、累计充值字段和资产标签;资产归属、个人客户—资产绑定及手机号—资产关联不属于该迁移范围。 ## Capabilities @@ -19,10 +19,10 @@ ### Modified Capabilities -- `order-refund-exchange`: 明确换货业务数据迁移的状态、失败恢复、范围和列表/详情可见性。 +- `order-refund-exchange`: 明确换货业务数据迁移的状态、失败恢复、范围和列表、详情及导出可见性。 ## Impact -- 影响 `internal/model/exchange_order.go`、换货完成写用例、换货列表 Query、换货 DTO 及既有换货审计。 +- 影响 `internal/model/exchange_order.go`、换货完成写用例、换货列表 Query、换货 DTO、换货导出场景及既有换货审计。 - 需要新增成对数据库迁移,以保存迁移状态和最近失败原因;不修改既有迁移。 -- 既有换货列表与详情接口将新增/明确迁移状态字段,前端应改按状态展示。 \ No newline at end of file +- 既有换货列表与详情接口将新增/明确迁移状态字段,前端应改按状态展示;换货导出新增一列迁移状态(中文名称),不导出失败原因。 \ No newline at end of file diff --git a/openspec/changes/add-exchange-data-migration-status/specs/order-refund-exchange/spec.md b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/specs/order-refund-exchange/spec.md similarity index 82% rename from openspec/changes/add-exchange-data-migration-status/specs/order-refund-exchange/spec.md rename to openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/specs/order-refund-exchange/spec.md index 55bdd58..e2e0d3b 100644 --- a/openspec/changes/add-exchange-data-migration-status/specs/order-refund-exchange/spec.md +++ b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/specs/order-refund-exchange/spec.md @@ -39,3 +39,14 @@ #### Scenario: 不选择业务数据迁移完成换货 - **WHEN** 换货单未选择业务数据迁移并确认完成 - **THEN** 系统仍完成资产归属与个人客户—资产绑定的固有换货动作,但不迁移钱包余额、套餐使用记录、累计充值字段或资产标签 + +### Requirement: 换货导出业务数据迁移状态 +换货导出 SHALL 包含业务数据迁移状态,取值使用中文状态名称(不迁移、待迁移、已迁移、迁移失败),并 MUST NOT 导出迁移失败原因。该列 MUST 仅用于展示与追溯,MUST NOT 改变换货完成、业务数据迁移、失败恢复或数据访问范围任何规则。 + +#### Scenario: 导出包含迁移状态 +- **WHEN** 调用方导出换货记录 +- **THEN** 每行在「状态」之后返回该记录对应的中文迁移状态 + +#### Scenario: 迁移失败的记录被导出 +- **WHEN** 换货记录的迁移状态为 `failed`,且该记录保存了最近一次失败原因 +- **THEN** 导出行的迁移状态为“迁移失败”,且导出不输出该失败原因 diff --git a/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/tasks.md b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/tasks.md new file mode 100644 index 0000000..6a1d5fd --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/tasks.md @@ -0,0 +1,19 @@ +## 1. 数据契约 + +- [x] 1.1 新增成对迁移 `migrations/000222_add_exchange_migration_status.up.sql` / `.down.sql`,为 `tb_exchange_order` 添加迁移状态和失败原因字段:ADD COLUMN(带默认值)→ 按既有 `migrate_data`、`migration_completed` 回填历史记录 → 添加 `chk_exchange_order_migration_status` 约束;实施时按 `migrations/` 根目录最大编号复核,保持 up/down 成对。 +- [x] 1.2 在换货模型和常量中定义四种迁移状态及中文名称,保留现有布尔字段的兼容语义。 +- [x] 1.3 扩展换货列表、详情 DTO 及两个读侧投影,返回迁移状态、中文名称及仅失败时的安全失败原因。 +- [x] 1.4 在 `internal/exporter/exchange_scene.go` 最小改动表头、SQL Select、行结构与行拼接四处,在「状态」列之后新增一列迁移状态并使用中文名称,不导出失败原因。 + +## 2. 换货完成与恢复 + +- [x] 2.1 在创建、发货和成功完成的写路径维护不迁移、待迁移和已迁移状态,并在成功后清除失败原因。 +- [x] 2.2 保持完整换货和业务数据迁移在同一 GORM 事务;迁移失败时回滚全部业务修改,再在同一个回滚后短事务内保存物流换货单的失败状态、安全化失败原因和审计事实。该短事务不与已回滚的主事务共用连接或事务,条件为「换货单仍处于可确认完成状态」,`RowsAffected` 为 0 时跳过状态写入但仍写审计;失败原因只拼接 AppError 链上的中文 `Message`、丢弃非 AppError 的 `cause`、按 rune 截断至不超过 500 字符,基础设施错误降级为固定安全摘要;符合 `ENG-TX-001` 例外条件。 +- [x] 2.3 限制迁移失败的物流换货重试仅由超级管理员或平台用户发起,授权以 `FOR UPDATE` 锁定后读到的 `migration_status` 为准(事务外预读只做快速拒绝);重试须重新执行全套迁移、禁止只重试子项,并防止并发重复完成;非 failed 单沿用既有完成门禁,不削弱代理既有权限。 +- [x] 2.4 保持直接换货失败时整体回滚且不持久化失败换货单,确认迁移范围不包含手机号—资产关联。 + +## 3. 文档与验证 + +- [x] 3.1 更新换货接口 OpenAPI 描述并运行 `go run cmd/gendocs/main.go`,核对状态枚举及失败原因的响应契约。 +- [x] 3.2 在隔离数据库按 `scripts/migrate.sh` 使用显式 `DB_*` 参数验证新迁移 up/down/up、历史状态映射及回滚后的 Schema。 +- [x] 3.3 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`openspec validate add-exchange-data-migration-status --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/specs/order-refund-exchange/spec.md b/openspec/specs/order-refund-exchange/spec.md index af2a27a..6f0dba1 100644 --- a/openspec/specs/order-refund-exchange/spec.md +++ b/openspec/specs/order-refund-exchange/spec.md @@ -283,6 +283,70 @@ - **WHEN** 系统解析并返回这两个展示字段 - **THEN** 退款金额校验、冻结实收金额、套餐失效、接续下一套餐、停机评估与佣金回溯行为均不发生变化 +### Requirement: 换货业务数据迁移状态与失败恢复 + +系统 SHALL 为每张已持久化的物流换货单返回业务数据迁移状态 `not_migrated`(不迁移)、`pending`(待迁移)、`migrated`(已迁移)或 `failed`(迁移失败),以及对应的中文状态名称。未选择业务数据迁移的换货单状态 MUST 为 `not_migrated`;选择迁移但尚未成功完成的换货单状态 MUST 为 `pending`;完整迁移成功后状态 MUST 为 `migrated`;迁移执行失败后状态 MUST 为 `failed`,并保存最近一次可安全展示的失败原因。 + +换货列表和详情 SHALL 返回迁移状态及中文名称;仅当状态为 `failed` 时返回最近一次失败原因。既有 `migrate_data`、`migration_completed` 和迁移余额字段 SHALL 保持兼容,但客户端不得再通过它们推断迁移结果。直接换货创建失败继续按既有原子性整体回滚,不产生可查询的失败换货单。 + +#### Scenario: 不迁移的换货单 + +- **WHEN** 创建或发货时未选择业务数据迁移 +- **THEN** 换货列表和详情返回 `not_migrated` 及“不迁移”,且不返回迁移失败原因 + +#### Scenario: 待迁移的换货单 + +- **WHEN** 换货单已选择业务数据迁移但尚未成功完成换货 +- **THEN** 换货列表和详情返回 `pending` 及“待迁移” + +#### Scenario: 成功完成业务数据迁移 + +- **WHEN** 换货完成时全部业务数据迁移成功 +- **THEN** 系统原子完成换货及业务数据迁移,列表和详情返回 `migrated` 及“已迁移”,并清除最近一次失败原因 + +#### Scenario: 迁移失败后保留可恢复事实 + +- **WHEN** 换货完成时任一业务数据迁移步骤失败 +- **THEN** 系统不得提交本次换货完成及任何部分迁移结果,换货单保持可确认完成状态,返回迁移失败,并在独立持久化事实中将迁移状态更新为 `failed` 和最近一次失败原因 + +#### Scenario: 管理员重试失败迁移 + +- **WHEN** 超级管理员或平台用户对处于可确认完成状态且迁移状态为 `failed` 的换货单再次确认完成 +- **THEN** 系统重新原子执行完整业务数据迁移;成功后将状态更新为 `migrated`,再次失败则保留 `failed` 并覆盖为最近一次失败原因 + +#### Scenario: 非平台账号重试失败迁移 + +- **WHEN** 非超级管理员且非平台用户尝试再次确认迁移状态为 `failed` 的换货单 +- **THEN** 系统拒绝该操作,换货单及迁移状态不变 + +### Requirement: 换货业务数据迁移范围 + +系统 SHALL 仅在选择业务数据迁移的换货完成中迁移旧资产的钱包余额、有效套餐使用记录、累计充值字段和资产标签。资产归属与个人客户—资产绑定 SHALL 继续作为换货完成固有动作,不受业务数据迁移选项控制;手机号—资产关联 MUST NOT 随换货或业务数据迁移转移,新资产首次访问时按其适用的手机号绑定规则处理。 + +#### Scenario: 选择业务数据迁移完成换货 + +- **WHEN** 换货单选择业务数据迁移并成功确认完成 +- **THEN** 系统迁移钱包余额、有效套餐使用记录、累计充值字段和资产标签,且不迁移手机号—资产关联 + +#### Scenario: 不选择业务数据迁移完成换货 + +- **WHEN** 换货单未选择业务数据迁移并确认完成 +- **THEN** 系统仍完成资产归属与个人客户—资产绑定的固有换货动作,但不迁移钱包余额、套餐使用记录、累计充值字段或资产标签 + +### Requirement: 换货导出业务数据迁移状态 + +换货导出 SHALL 包含业务数据迁移状态,取值使用中文状态名称(不迁移、待迁移、已迁移、迁移失败),并 MUST NOT 导出迁移失败原因。该列 MUST 仅用于展示与追溯,MUST NOT 改变换货完成、业务数据迁移、失败恢复或数据访问范围任何规则。 + +#### Scenario: 导出包含迁移状态 + +- **WHEN** 调用方导出换货记录 +- **THEN** 每行在「状态」之后返回该记录对应的中文迁移状态 + +#### Scenario: 迁移失败的记录被导出 + +- **WHEN** 换货记录的迁移状态为 `failed`,且该记录保存了最近一次失败原因 +- **THEN** 导出行的迁移状态为“迁移失败”,且导出不输出该失败原因 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index cfb4f92..8a98a39 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -477,6 +477,15 @@ const ( ExchangeAssetTypeDevice = "device" // 设备 ) +// 换货业务数据迁移状态常量 +// 与 tb_exchange_order 的 chk_exchange_order_migration_status 取值白名单保持一致。 +const ( + ExchangeMigrationStatusNotMigrated = "not_migrated" // 不迁移(未选择业务数据迁移) + ExchangeMigrationStatusPending = "pending" // 待迁移(已选择迁移但尚未成功完成) + ExchangeMigrationStatusMigrated = "migrated" // 已迁移(完整迁移成功) + ExchangeMigrationStatusFailed = "failed" // 迁移失败(最近一次迁移执行失败,可再次确认完成重试) +) + // GetStatusName 获取通用启用/禁用状态名称 // 用于 status 字段:0=禁用, 1=启用 func GetStatusName(status int) string { @@ -560,3 +569,20 @@ func GetExchangeFlowTypeName(flowType string) string { return "未知" } } + +// GetExchangeMigrationStatusName 获取换货业务数据迁移状态名称 +// 用于 migration_status 字段 +func GetExchangeMigrationStatusName(status string) string { + switch status { + case ExchangeMigrationStatusNotMigrated: + return "不迁移" + case ExchangeMigrationStatusPending: + return "待迁移" + case ExchangeMigrationStatusMigrated: + return "已迁移" + case ExchangeMigrationStatusFailed: + return "迁移失败" + default: + return "未知" + } +}