From 1f634eb465a171f2d898699aa00097cbee51a8d9 Mon Sep 17 00:00:00 2001 From: Break Date: Tue, 16 Jun 2026 15:15:50 +0800 Subject: [PATCH] init --- .codex/skills/export-datasource/SKILL.md | 198 +++++ .../export-datasource/agents/openai.yaml | 4 + .../references/export-scene-template.md | 222 +++++ docs/admin-openapi.yaml | 779 +++++++++++++++++- docs/agent-open-api/功能总结.md | 2 +- .../轮询配置二维条件重构计划.md | 94 +++ docs/脚本/奇成数据迁移方案.md | 93 ++- internal/exporter/order_scene.go | 599 ++++++++++++++ .../refactor-export-datasource/.openspec.yaml | 2 + .../refactor-export-datasource/design.md | 84 ++ .../refactor-export-datasource/proposal.md | 32 + .../specs/export-datasource/spec.md | 52 ++ .../specs/export-query-params/spec.md | 41 + .../specs/unified-export-task-system/spec.md | 61 ++ .../refactor-export-datasource/tasks.md | 52 ++ .../.openspec.yaml | 2 + .../design.md | 137 +++ .../proposal.md | 43 + .../specs/qicheng-migration-control/spec.md | 143 ++++ .../tasks.md | 55 ++ scripts/migration/config/mapping.yaml.kw | 122 +++ 21 files changed, 2776 insertions(+), 41 deletions(-) create mode 100644 .codex/skills/export-datasource/SKILL.md create mode 100644 .codex/skills/export-datasource/agents/openai.yaml create mode 100644 .codex/skills/export-datasource/references/export-scene-template.md create mode 100644 docs/polling-system/轮询配置二维条件重构计划.md create mode 100644 internal/exporter/order_scene.go create mode 100644 openspec/changes/refactor-export-datasource/.openspec.yaml create mode 100644 openspec/changes/refactor-export-datasource/design.md create mode 100644 openspec/changes/refactor-export-datasource/proposal.md create mode 100644 openspec/changes/refactor-export-datasource/specs/export-datasource/spec.md create mode 100644 openspec/changes/refactor-export-datasource/specs/export-query-params/spec.md create mode 100644 openspec/changes/refactor-export-datasource/specs/unified-export-task-system/spec.md create mode 100644 openspec/changes/refactor-export-datasource/tasks.md create mode 100644 openspec/changes/refactor-migration-ownership-package-flow/.openspec.yaml create mode 100644 openspec/changes/refactor-migration-ownership-package-flow/design.md create mode 100644 openspec/changes/refactor-migration-ownership-package-flow/proposal.md create mode 100644 openspec/changes/refactor-migration-ownership-package-flow/specs/qicheng-migration-control/spec.md create mode 100644 openspec/changes/refactor-migration-ownership-package-flow/tasks.md create mode 100644 scripts/migration/config/mapping.yaml.kw diff --git a/.codex/skills/export-datasource/SKILL.md b/.codex/skills/export-datasource/SKILL.md new file mode 100644 index 0000000..09cba2d --- /dev/null +++ b/.codex/skills/export-datasource/SKILL.md @@ -0,0 +1,198 @@ +--- +name: export-datasource +description: Project-specific guide for implementing, modifying, or reviewing export data sources in junhong_cmp_fiber. Use when Codex needs to add a new export scene, extend filters or dynamic columns, register an export scene, change export task query behavior, or explain/debug the DataSource-based export system. +--- + +# Export Datasource + +Use this skill to work on this project's DataSource-based export system. It covers developer-facing export scene implementation, not one-off manual export operations. + +## First Reads + +Before editing export code, read the relevant current files: + +- `internal/exporter/datasource.go` +- `internal/exporter/query_params.go` +- `internal/exporter/filter_helpers.go` +- `internal/exporter/registry.go` +- Existing scene closest to the new scene: + - `internal/exporter/device_scene.go` + - `internal/exporter/iot_card_scene.go` +- For request/scene validation: + - `internal/model/dto/export_task_dto.go` + - `pkg/constants/constants.go` +- For behavior across worker stages: + - `internal/task/export_dispatch.go` + - `internal/task/export_shard.go` + - `internal/task/export_finalize.go` + +Read `references/export-scene-template.md` when adding a new scene or when a concrete code skeleton is useful. + +## Mental Model + +The framework owns async execution, sharding, file generation, OSS upload, and download URLs. A scene implementation owns only data semantics: + +```go +type DataSource interface { + Scene() string + Count(ctx context.Context, params ExportParams) (int, error) + Headers(ctx context.Context, params ExportParams) ([]string, error) + Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) +} +``` + +Execution flow: + +1. Admin API creates `tb_export_task` and enqueues `export:dispatch`. +2. Dispatch parses `query_json.filters` plus permission snapshot into `ExportParams`. +3. Dispatch calls `Headers` once and stores `query_json.resolved_headers`. +4. Dispatch calls `Count` and creates `tb_export_shard_task` rows with `shard_offset` and `shard_limit`. +5. Shard calls `Fetch`, writes headerless CSV shard files, and uploads them. +6. Finalize downloads shard CSV files in shard order, writes one header row, uploads final CSV or converts CSV to XLSX. + +Do not reintroduce keyset cursor logic. New scenes must use offset/limit through `Fetch`. + +## Implementation Workflow + +1. Add a scene constant in `pkg/constants/constants.go`. +2. Update `internal/model/dto/export_task_dto.go` validation and descriptions for `scene`. +3. Implement `internal/exporter/_scene.go` with `DataSource`. +4. Register the source in `NewDefaultRegistry`. +5. Update `IsSupportedScene` if it is used by the current code path. +6. Add or update migrations only if the exported domain needs schema/index changes. Do not change export task tables unless the framework contract changes. +7. Build and manually verify. This repository forbids automated tests unless the user explicitly requests them. + +## DataSource Rules + +- `Scene` must return the constant, not a string literal. +- `Count` and `Fetch` must apply the same filters and permission scope. +- `Headers` defines the exact column contract for the whole task. Dispatch stores it once; shards and finalize reuse it. +- `Fetch` must return rows aligned to `Headers`; the framework pads/truncates as a fallback, but the source should be correct. +- `Fetch` must use stable ordering, normally `ORDER BY id ASC`. +- Return string values only. Format time as `2006-01-02 15:04:05` unless the surrounding scene establishes another convention. +- Use GORM only. Do not use `database/sql`. +- Keep SQL parameters bound through GORM placeholders. Do not concatenate user-controlled values into SQL. +- Keep comments, logs, errors, and documentation in Chinese per project rules. + +## Filters And Permissions + +Incoming task query shape: + +```json +{ + "filters": { + "status": 1, + "shop_id": 1 + } +} +``` + +`ParseExportParams` exposes: + +- `Filters`: `query_json.filters` +- `ScopeShopIDs`: shop permission snapshot captured at task creation +- `UserType`: creator user type snapshot + +Apply permission scope inside each DataSource: + +```go +query = applyExportShopScope(query, params, "shop_id") +``` + +For aliased queries: + +```go +query = applyExportShopScope(query, params, "o.shop_id") +``` + +Use helpers from `filter_helpers.go`: + +- `filterInt` +- `filterUint` +- `filterString` +- `filterBool` +- `filterTime` +- `formatOptionalUint` +- `formatOptionalTime` + +Do not read live permissions from context inside a DataSource. Export tasks must use the permission snapshot stored at creation time. + +## Dynamic Columns + +Use dynamic headers only when the task parameters or data require it. If `Headers` changes based on filters, `Fetch` must produce the same shape for every shard under the same `ExportParams`. + +Example pattern: + +```go +headers := []string{"ID", "ICCID", "状态"} +if filterBool(params.Filters, "with_package") { + headers = append(headers, "套餐名称", "套餐状态") +} +return headers, nil +``` + +## Join Queries + +JOIN-based exports are allowed. Keep these constraints: + +- Preserve one output row per intended exported entity unless the scene explicitly exports detail rows. +- If a JOIN can multiply rows, make `Count` match the exported row semantics exactly. +- Use table aliases consistently in filters and scope columns. +- Prefer explicit `Select` into a local row struct for multi-table exports. +- Keep `Order`, `Limit`, and `Offset` on the final query used by `Fetch`. + +## User-Facing API Notes + +Current API group: + +- `POST /api/admin/export-tasks` +- `GET /api/admin/export-tasks` +- `GET /api/admin/export-tasks/:id` +- `POST /api/admin/export-tasks/:id/cancel` + +Creation request: + +```json +{ + "scene": "iot_card", + "format": "csv", + "query": { + "filters": { + "shop_id": 1, + "with_package": true + } + } +} +``` + +Formats are `csv` and `xlsx`. Final download URLs are returned from task detail after completion. + +## Verification + +This project forbids automated tests and `_test.go` files unless the user explicitly asks for tests. Use manual verification: + +```bash +go build ./internal/exporter/... +go build ./internal/task/... +go build ./... +``` + +Then create an export task through the API and inspect: + +- `tb_export_task.query_json` contains original `filters` and generated `resolved_headers`. +- `tb_export_task.total_rows` matches the filtered query. +- `tb_export_shard_task.shard_offset` and `shard_limit` are filled. +- Shards reach success and final task reaches completed status. +- Downloaded file has one header row, expected row count, correct filtering, and valid CSV/XLSX format. + +Use PostgreSQL MCP/manual SQL for data validation when needed. + +## Common Mistakes + +- Updating only `Fetch` and forgetting `Count`, causing wrong shard planning. +- Returning dynamic rows whose column count does not match `Headers`. +- Filtering by requested `shop_id` without also applying `ScopeShopIDs`. +- Using context/user middleware inside worker DataSource logic. +- Registering the source but forgetting DTO `oneof`, so API rejects the new scene. +- Writing XLSX shard files. Shards should be CSV; finalize handles final format. +- Adding automated tests despite the repository ban. diff --git a/.codex/skills/export-datasource/agents/openai.yaml b/.codex/skills/export-datasource/agents/openai.yaml new file mode 100644 index 0000000..a7ac713 --- /dev/null +++ b/.codex/skills/export-datasource/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "导出数据源开发" + short_description: "指导新增和维护项目导出数据源场景" + default_prompt: "Use $export-datasource to add a new export scene for this project." diff --git a/.codex/skills/export-datasource/references/export-scene-template.md b/.codex/skills/export-datasource/references/export-scene-template.md new file mode 100644 index 0000000..07c4c19 --- /dev/null +++ b/.codex/skills/export-datasource/references/export-scene-template.md @@ -0,0 +1,222 @@ +# Export Scene Template + +Use this reference when adding a new export scene. + +## Minimal Checklist + +- Add `ExportTaskSceneXxx` in `pkg/constants/constants.go`. +- Add the scene to `CreateExportTaskRequest.Scene` and `ListExportTaskRequest.Scene` validation/description. +- Create `internal/exporter/_scene.go`. +- Register `NewXxxDataSource(db)` in `internal/exporter/registry.go`. +- Update `IsSupportedScene`. +- Run `gofmt` on changed Go files. +- Run `go build ./internal/exporter/...`, `go build ./internal/task/...`, and `go build ./...`. +- Manually create a task and validate database rows plus downloaded file. + +## Skeleton + +```go +package exporter + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// XxxDataSource Xxx 导出数据源。 +type XxxDataSource struct { + db *gorm.DB +} + +// NewXxxDataSource 创建 Xxx 导出数据源。 +func NewXxxDataSource(db *gorm.DB) *XxxDataSource { + return &XxxDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *XxxDataSource) Scene() string { + return constants.ExportTaskSceneXxx +} + +// Count 统计 Xxx 导出行数。 +func (s *XxxDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + var total int64 + query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Xxx{}), params) + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回 Xxx 导出表头。 +func (s *XxxDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { + return []string{"ID", "名称", "状态", "店铺ID", "创建时间"}, nil +} + +// Fetch 按 offset/limit 查询 Xxx 导出数据。 +func (s *XxxDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []model.Xxx + query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Xxx{}), params). + Order("id ASC"). + Limit(limit). + Offset(offset) + if err := query.Find(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + strconv.FormatUint(uint64(item.ID), 10), + item.Name, + strconv.Itoa(item.Status), + formatOptionalUint(item.ShopID), + item.CreatedAt.Format(exportTimeLayout), + }) + } + return rows, nil +} + +func (s *XxxDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { + query = applyExportShopScope(query, params, "shop_id") + + if status, ok := filterInt(params.Filters, "status"); ok { + query = query.Where("status = ?", status) + } + if shopID, ok := filterUint(params.Filters, "shop_id"); ok { + query = query.Where("shop_id = ?", shopID) + } + if start, ok := filterTime(params.Filters, "created_at_start"); ok { + query = query.Where("created_at >= ?", start) + } + if end, ok := filterTime(params.Filters, "created_at_end"); ok { + query = query.Where("created_at <= ?", end) + } + return query +} +``` + +## JOIN Skeleton + +Use this shape for multi-table exports: + +```go +type xxxExportRow struct { + ID uint + Name string + Status int + ShopID *uint + ExtraName string + CreatedAt time.Time +} + +func (s *XxxDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []xxxExportRow + query := s.applyFilters(s.db.WithContext(ctx).Table("tb_xxx AS x"), params, "x."). + Select(` + x.id, + x.name, + x.status, + x.shop_id, + COALESCE(e.name, '') AS extra_name, + x.created_at + `). + Joins("LEFT JOIN tb_extra AS e ON e.xxx_id = x.id AND e.deleted_at IS NULL"). + Where("x.deleted_at IS NULL"). + Order("x.id ASC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + strconv.FormatUint(uint64(item.ID), 10), + item.Name, + strconv.Itoa(item.Status), + formatOptionalUint(item.ShopID), + item.ExtraName, + item.CreatedAt.Format(exportTimeLayout), + }) + } + return rows, nil +} + +func (s *XxxDataSource) applyFilters(query *gorm.DB, params ExportParams, prefix string) *gorm.DB { + query = applyExportShopScope(query, params, prefix+"shop_id") + if status, ok := filterInt(params.Filters, "status"); ok { + query = query.Where(prefix+"status = ?", status) + } + return query +} +``` + +If the JOIN multiplies rows, update `Count` to count the same exported row set. Do not count only the base table unless `Fetch` also returns one row per base record. + +## Registry Patch + +```go +func NewDefaultRegistry(db *gorm.DB) *Registry { + return NewRegistry( + NewDeviceDataSource(db), + NewIotCardDataSource(db), + NewXxxDataSource(db), + ) +} + +func IsSupportedScene(scene string) bool { + switch scene { + case constants.ExportTaskSceneDevice, + constants.ExportTaskSceneIotCard, + constants.ExportTaskSceneXxx: + return true + default: + return false + } +} +``` + +## Manual API Smoke + +```bash +curl -X POST 'http://localhost:端口/api/admin/export-tasks' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "scene": "xxx", + "format": "csv", + "query": { + "filters": { + "status": 1 + } + } + }' +``` + +Check database: + +```sql +SELECT id, scene, format, status, total_rows, total_shards, query_json +FROM tb_export_task +WHERE id = ; + +SELECT shard_no, status, shard_offset, shard_limit, row_count, file_key +FROM tb_export_shard_task +WHERE task_id = +ORDER BY shard_no; +``` diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index fef489b..df2d4d6 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -204,7 +204,68 @@ components: description: 当前生效套餐已用流量(MB) type: integer card_no: - description: 卡标识 + description: 卡标识(原始传入值) + type: string + device_no: + description: 设备虚拟号(当入参被识别为设备维度时返回,表示该卡/标识关联的设备) + type: string + pending_packages: + description: 待生效套餐列表 + items: + $ref: '#/components/schemas/DtoAgentOpenAPIPackageTrafficItem' + nullable: true + type: array + type: object + DtoAgentOpenAPIDeviceOperationRequest: + properties: + device_no: + description: 设备标识(支持虚拟号、IMEI) + maxLength: 100 + minLength: 1 + type: string + required: + - device_no + type: object + DtoAgentOpenAPIDeviceSwitchCardRequest: + properties: + device_no: + description: 设备标识(支持虚拟号、IMEI) + maxLength: 100 + minLength: 1 + type: string + iccid: + description: 目标卡 ICCID + maxLength: 100 + minLength: 1 + type: string + required: + - device_no + - iccid + type: object + DtoAgentOpenAPIDeviceTrafficResponse: + properties: + active_expires_at: + description: 当前生效套餐最晚过期时间 + format: date-time + nullable: true + type: string + active_packages: + description: 当前生效套餐列表 + items: + $ref: '#/components/schemas/DtoAgentOpenAPIPackageTrafficItem' + nullable: true + type: array + active_remaining_flow_mb: + description: 当前生效套餐剩余流量(MB) + type: integer + active_total_flow_mb: + description: 当前生效套餐总流量(MB) + type: integer + active_used_flow_mb: + description: 当前生效套餐已用流量(MB) + type: integer + device_no: + description: 设备标识 type: string pending_packages: description: 待生效套餐列表 @@ -1269,7 +1330,7 @@ components: minimum: 0 type: integer paid_amount: - description: 购买实付金额(分),线下支付或无订单分配时为空 + description: 购买成本价(分),仅平台账号可见,非平台账号不返回此字段 nullable: true type: integer priority: @@ -1292,6 +1353,10 @@ components: refund_no: description: 退款单号快照(未退款时为空) type: string + retail_amount: + description: 购买零售价(分),无订单关联时为空 + nullable: true + type: integer status: description: 状态:0待生效 1生效中 2已用完 3已过期 4已失效 type: integer @@ -1679,7 +1744,7 @@ components: nullable: true type: string reference_type: - description: 关联业务类型:recharge 或 order(可空) + description: 关联业务类型:recharge、order 或 exchange(可空) nullable: true type: string remark: @@ -1687,10 +1752,10 @@ components: nullable: true type: string transaction_type: - description: 交易类型:recharge/deduct/refund + description: 交易类型:recharge/deduct/refund/exchange type: string transaction_type_text: - description: 交易类型文本:充值/扣款/退款 + description: 交易类型文本:充值/扣款/退款/换货迁移 type: string type: object DtoAssetWalletTransactionListResponse: @@ -2468,6 +2533,9 @@ components: exchange_reason: description: 换货原因 type: string + flow_type: + description: 换货流程类型 (shipping:物流换货, direct:直接换货) + type: string id: description: 换货单ID minimum: 0 @@ -2475,6 +2543,9 @@ components: status: description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) type: integer + status_name: + description: 换货状态名称(中文) + type: string status_text: description: 换货状态文本 type: string @@ -3148,6 +3219,21 @@ components: maxLength: 100 minLength: 1 type: string + flow_type: + description: 换货流程类型 (shipping:物流换货, direct:直接换货) + enum: + - shipping + - direct + type: string + migrate_data: + description: 是否执行全量迁移,direct 流程未传按 false 处理 + nullable: true + type: boolean + new_identifier: + description: 新资产标识符,direct 流程必填(ICCID/虚拟号/IMEI/SN) + maxLength: 100 + minLength: 1 + type: string old_asset_type: description: 旧资产类型 (iot_card:物联网卡, device:设备) type: string @@ -4895,6 +4981,11 @@ components: type: object DtoExchangeOrderResponse: properties: + completed_at: + description: 换货完成时间 + format: date-time + nullable: true + type: string created_at: description: 创建时间 format: date-time @@ -4920,6 +5011,12 @@ components: express_no: description: 快递单号 type: string + flow_type: + description: 换货流程类型 (shipping:物流换货, direct:直接换货) + type: string + flow_type_name: + description: 换货流程类型名称 + type: string id: description: 换货单ID minimum: 0 @@ -4967,6 +5064,11 @@ components: description: 备注 nullable: true type: string + shipped_at: + description: 发货时间 + format: date-time + nullable: true + type: string shop_id: description: 所属店铺ID minimum: 0 @@ -4975,6 +5077,9 @@ components: status: description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) type: integer + status_name: + description: 换货状态名称(中文) + type: string status_text: description: 换货状态文本 type: string @@ -8118,6 +8223,24 @@ components: nullable: true type: string type: object + DtoUpdateAssetPackageExpiresAtRequest: + properties: + expires_at: + description: 新的套餐过期时间,格式:YYYY-MM-DD HH:MM:SS 或 RFC3339 + type: string + required: + - expires_at + type: object + DtoUpdateAssetPackageUsageRequest: + properties: + data_usage_mb: + description: 新的套餐真实已用量(MB) + minimum: 0 + nullable: true + type: integer + required: + - data_usage_mb + type: object DtoUpdateAssetPollingStatusRequest: properties: enable_polling: @@ -10975,6 +11098,166 @@ paths: summary: 资产套餐列表 tags: - 资产管理 + /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at: + patch: + description: 修改指定资产下某条套餐使用记录的过期时间。仅账号ID为2的账号可调用。 + parameters: + - description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN) + in: path + name: identifier + required: true + schema: + description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN) + type: string + - description: 套餐使用记录ID + in: path + name: package_usage_id + required: true + schema: + description: 套餐使用记录ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUpdateAssetPackageExpiresAtRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 修改资产套餐过期时间 + tags: + - 资产管理 + /api/admin/assets/{identifier}/packages/{package_usage_id}/used-data: + patch: + description: 修改指定资产下某条套餐使用记录的真实已用量(MB)。仅账号ID为2的账号可调用。 + parameters: + - description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN) + in: path + name: identifier + required: true + schema: + description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN) + type: string + - description: 套餐使用记录ID + in: path + name: package_usage_id + required: true + schema: + description: 套餐使用记录ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUpdateAssetPackageUsageRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 修改资产套餐已用量 + tags: + - 资产管理 /api/admin/assets/{identifier}/polling-status: patch: description: 启用或禁用指定资产(IoT卡或设备)的定期状态轮询。 @@ -11490,11 +11773,11 @@ paths: schema: description: 每页数量,默认20,最大100 type: integer - - description: 交易类型过滤:recharge/deduct/refund + - description: 交易类型过滤:recharge/deduct/refund/exchange in: query name: transaction_type schema: - description: 交易类型过滤:recharge/deduct/refund + description: 交易类型过滤:recharge/deduct/refund/exchange nullable: true type: string - description: 开始时间(RFC3339) @@ -13399,6 +13682,13 @@ paths: description: 是否有生效中的套餐(true:有生效中主套餐, false:无生效中主套餐) nullable: true type: boolean + - description: 关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立) + in: query + name: keyword + schema: + description: 关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立) + maxLength: 100 + type: string responses: "200": content: @@ -15499,6 +15789,15 @@ paths: minimum: 1 nullable: true type: integer + - description: 换货流程类型 (shipping:物流换货, direct:直接换货) + in: query + name: flow_type + schema: + description: 换货流程类型 (shipping:物流换货, direct:直接换货) + enum: + - shipping + - direct + type: string - description: 资产标识符搜索(旧资产/新资产标识符模糊匹配) in: query name: identifier @@ -16772,6 +17071,13 @@ paths: description: 运营商名称(模糊查询) maxLength: 100 type: string + - description: 关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立) + in: query + name: keyword + schema: + description: 关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立) + maxLength: 100 + type: string responses: "200": content: @@ -26812,7 +27118,7 @@ paths: } ``` - 按 card_no 查询单卡流量、当前套餐和待生效套餐。card_no 支持 ICCID、虚拟号、MSISDN。 + 按 card_no 查询流量、当前套餐和待生效套餐。card_no 支持三种解析路径:1) ICCID/虚拟号/MSISDN 对应独立卡,查卡维度流量;2) 已绑定设备的卡,自动反查绑定设备,查设备维度流量(响应含 device_no);3) IMEI 或设备虚拟号,直接查设备维度流量(响应含 device_no)。 parameters: - description: 卡标识(支持 ICCID、虚拟号、MSISDN) in: query @@ -26880,7 +27186,462 @@ paths: AgentOpenAPIPassword: [] AgentOpenAPISign: [] AgentOpenAPITimestamp: [] - summary: 查询单卡流量 + summary: 查询单卡或设备流量 + tags: + - 代理开放接口 + /api/open/v1/devices/reboot: + post: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 device_no 远程重启设备。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAgentOpenAPIDeviceOperationRequest' + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 重启设备 + tags: + - 代理开放接口 + /api/open/v1/devices/reset: + post: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 device_no 远程恢复设备出厂设置。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAgentOpenAPIDeviceOperationRequest' + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 恢复出厂设置 + tags: + - 代理开放接口 + /api/open/v1/devices/switch-card: + post: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 device_no 将多卡设备切换到指定 ICCID 的卡。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAgentOpenAPIDeviceSwitchCardRequest' + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 切网(多卡设备切换 ICCID) + tags: + - 代理开放接口 + /api/open/v1/devices/traffic: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 device_no 查询设备套餐内流量、当前套餐和待生效套餐。device_no 支持虚拟号、IMEI。 + parameters: + - description: 设备标识(支持虚拟号、IMEI) + in: query + name: device_no + required: true + schema: + description: 设备标识(支持虚拟号、IMEI) + maxLength: 100 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPIDeviceTrafficResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询设备套餐内流量 tags: - 代理开放接口 /api/open/v1/packages: diff --git a/docs/agent-open-api/功能总结.md b/docs/agent-open-api/功能总结.md index 75f2d67..7817fc1 100644 --- a/docs/agent-open-api/功能总结.md +++ b/docs/agent-open-api/功能总结.md @@ -250,7 +250,7 @@ curl -G "https://open.example.com/api/open/v1/cards/status" \ - `total_flow_mb`:套餐总流量,单位 MB。 - `valid_days`:套餐有效天数。 -钱包购买接口一次只允许一个 `package_code`,`card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。 +钱包购买接口一次只允许一个 `package_code`,`card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。任一卡购买失败时,外层 `code`/`msg` 返回首条失败卡的错误码和原因,同时 `data.failed_cards` 保留失败明细,`data.orders` 保留已成功订单;接入方应以 `success_count`、`failed_count`、`orders`、`failed_cards` 判断单卡结果,避免整批重试造成重复下单。 ## 5. 错误码 diff --git a/docs/polling-system/轮询配置二维条件重构计划.md b/docs/polling-system/轮询配置二维条件重构计划.md new file mode 100644 index 0000000..e963a51 --- /dev/null +++ b/docs/polling-system/轮询配置二维条件重构计划.md @@ -0,0 +1,94 @@ +# 轮询配置二维条件重构计划 + +## 背景 + +当前轮询配置使用 `card_condition` 单字段表达卡的轮询条件,但业务上实际存在两个独立维度: + +- 实名状态:未实名、已实名 +- 卡状态:开机、停机 + +单字段枚举会把两个维度压成互斥状态,导致“未实名 + 停机”这类卡只能命中一个条件,配置语义不直观,也容易出现高低频策略互相覆盖。 + +## 目标 + +将轮询配置从单一 `card_condition` 改为二维条件匹配: + +- `realname_condition`:`any`、`not_real_name`、`real_name` +- `network_condition`:`any`、`online`、`offline` + +配置匹配规则调整为: + +```text +实名条件匹配 AND 卡状态条件匹配 AND 卡类型/运营商等条件匹配 +``` + +一张卡允许同时命中多条配置;每个任务类型仍按现有 `priority ASC` 选择第一条非空 interval。 + +## 推荐语义 + +| 条件字段 | 值 | 语义 | +| --- | --- | --- | +| `realname_condition` | `any` | 不限制实名状态 | +| `realname_condition` | `not_real_name` | `real_name_status != 1` | +| `realname_condition` | `real_name` | `real_name_status = 1` | +| `network_condition` | `any` | 不限制开停机状态 | +| `network_condition` | `online` | `network_status = 1` | +| `network_condition` | `offline` | `network_status = 0` | + +## 迁移映射 + +为了平滑迁移旧配置,可按以下规则回填新字段: + +| 旧 `card_condition` | 新 `realname_condition` | 新 `network_condition` | +| --- | --- | --- | +| 空 | `any` | `any` | +| `not_real_name` | `not_real_name` | `online` | +| `real_name` | `real_name` | `any` | +| `suspended` | `any` | `offline` | +| `activated` | `real_name` | `online` | + +## 示例配置 + +未实名卡高频实名: + +```text +realname_condition = not_real_name +network_condition = any +realname_check_interval = 120 +其他 interval = NULL +``` + +停机卡高频套餐和卡状态: + +```text +realname_condition = any +network_condition = offline +package_check_interval = 120 +card_status_check_interval = 120 +其他 interval = NULL +``` + +已实名开机卡低频实名、常规套餐和卡状态: + +```text +realname_condition = real_name +network_condition = online +realname_check_interval = 86400 +package_check_interval = 300 +card_status_check_interval = 3600 +``` + +## 实施步骤 + +1. 新增数据库字段 `realname_condition` 和 `network_condition`,保留旧字段用于迁移兼容。 +2. 回填历史配置的新字段值。 +3. 调整 DTO、Handler、Service、文档生成器和 OpenAPI 描述。 +4. 调整 `PollingConfigManager` 的匹配逻辑,按二维条件筛选配置。 +5. 验证典型组合:未实名开机、未实名停机、已实名开机、已实名停机。 +6. 确认线上配置完成迁移后,再考虑废弃旧 `card_condition` 字段。 + +## 风险 + +- `real_name` 条件生效后,现有灰度配置可能覆盖大量已实名卡,需要上线前确认优先级和 interval。 +- 多条配置同时命中是预期行为,但需要用 priority 明确每个任务类型的最终 interval。 +- 迁移期间需要保持旧配置兼容,避免配置未更新时轮询队列为空。 diff --git a/docs/脚本/奇成数据迁移方案.md b/docs/脚本/奇成数据迁移方案.md index d36b26e..2153520 100644 --- a/docs/脚本/奇成数据迁移方案.md +++ b/docs/脚本/奇成数据迁移方案.md @@ -1,7 +1,7 @@ # 奇成数据迁移方案 > 目标:把奇成(旧 MySQL `kyhl` 库)的存量卡和设备迁移到新系统(PostgreSQL),输出 SQL 脚本供线上手动执行。 -> 范围:当前态资产 + 当前生效套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细**。 +> 范围:当前态资产 + 当前生效正式套餐 + 未生效待生效正式套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细、已过期套餐和加油包**。 --- @@ -14,12 +14,10 @@ │ resources/devices.csv│ └──────────┬───────────┘ │ - 我们维护映射配置 + 我们维护唯一决策配置 ┌──────────▼───────────┐ - │ config/carrier.yaml │ - │ config/series.yaml │ - │ config/package.yaml │ - │ config/shop.yaml │ + │ config/mapping.yaml │ + │ 归属/槽位/套餐映射 │ └──────────┬───────────┘ │ ┌──────────▼───────────┐ @@ -45,9 +43,11 @@ ``` **关键约束** -- 两个脚本都**不直接写入**线上库,只生成 `.sql` 文件 +- 两个脚本都**不直接写入**线上库,只生成 `.sql` 文件和审核 CSV +- `config/mapping.yaml` 是归属、设备当前槽位、套餐来源槽位和套餐迁移范围的决策源;奇成只提供历史事实 - 脚本对奇成线上库**只 SELECT**,DB 连接层强制只读事务(`SET TRANSACTION READ ONLY`) - 所有 INSERT 使用 `ON CONFLICT DO NOTHING`,支持重跑 +- `errors.csv` 非空时不执行 SQL,先修正配置/输入后重跑 --- @@ -84,13 +84,14 @@ | `sim_iccid_2` | 否 | 插槽2的 iccid | `89860623...` | | `sim_iccid_3` | 否 | 插槽3的 iccid | | | `sim_iccid_4` | 否 | 插槽4的 iccid | | -| `current_slot` | 否 | 当前使用的插槽位置(1-4),用于 `tb_device_sim_binding.is_current` | `1` | -| `target_shop_code` | 否 | 目标店铺编码。**留空 = 进平台库存** | `SHOP20260428103424CZZQ` | +| `current_slot` | 否 | 当前使用的插槽位置(1-4),用于 `tb_device_sim_binding.is_current`。空值按 `mapping.yaml` 批量规则或覆盖项填充 | `2` | +| `package_source_slot` | 否 | 设备套餐来源槽位(1-4)。只迁该槽位卡的奇成正式套餐为设备套餐 | `2` | **注意** -- 设备上的卡,其 `cards.csv` 中 `target_shop_code` 必须留空(一致性校验由脚本检查) - `cards.csv` 必须先到位,`devices.csv` 的 `sim_iccid_*` 才能被校验 - 业务方分别准备这两份是合理的,奇成 `tbl_card_relate`(主卡+副卡1+副卡2)的关系业务方应已掌握 +- 槽位解析优先级:`devices.csv` 行级配置 > `mapping.yaml.overrides.devices` > `mapping.yaml.ownership_rules.device` 批量默认值 +- `current_slot` 或 `package_source_slot` 对应槽位无卡时写入 `errors.csv` 并阻断该设备相关 SQL --- @@ -123,9 +124,37 @@ ## 四、我们维护的映射配置 -迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐,然后填好以下映射。 +迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐,然后填好 `config/mapping.yaml`。该文件是本次迁移的唯一决策源。 -### 4.1 `config/carrier_mapping.yaml` +### 4.1 归属、槽位和套餐规则 + +```yaml +ownership_rules: + default_target_shop_code: KWTX + device: + mode: default_shop + current_slot: 2 + package_source_slot: 2 + standalone_card: + mode: default_shop + +package_rules: + migrate_statuses: [active, pending] + +overrides: + devices: + - virtual_no: "862639073940258" + target_shop_code: OTHER + current_slot: 1 + package_source_slot: 1 + cards: + - iccid: "89861590172420360956" + target_shop_code: OTHER +``` + +归属规则:独立卡按 `overrides.cards` 或 `ownership_rules.standalone_card` 决定;设备按 `overrides.devices` 或 `ownership_rules.device` 决定;设备上的卡跟随设备。奇成 `agent_id` 不再作为默认归属来源。 + +### 4.2 `config/mapping.yaml` 中的 carriers ```yaml # 奇成的运营商名称(业务方在 cards.csv 中填的)→ 新系统 carrier_id + type @@ -141,7 +170,7 @@ carriers: # ... 业务方提供的所有运营商都要列出 ``` -### 4.2 `config/series_mapping.yaml` +### 4.3 `config/mapping.yaml` 中的 series ```yaml # 奇成套餐系列名 → 新系统 series_id @@ -153,7 +182,7 @@ series: target_series_id: 6 ``` -### 4.3 `config/package_mapping.yaml` +### 4.4 `config/mapping.yaml` 中的 packages ```yaml # 奇成套餐名 → 新系统 package_id(cards.csv 的 package_name 直接对照这里) @@ -166,7 +195,7 @@ packages: > 注:奇成的 `tbl_set_meal` 套餐表非常大,**不需要全部映射**,只映射本次迁移涉及的套餐(脚本预扫描 cards.csv 收集 package_name 集合,缺失的列出来让我们补)。 -### 4.4 `config/shop_mapping.yaml`(可选别名) +### 4.5 店铺编码 ```yaml # 如果业务方愿意填 shop_code,本文件可以省略 @@ -318,16 +347,15 @@ FROM c; ``` 1. 读 cards.csv 得到 iccid 列表 2. 连奇成(只读事务)批量查: - - tbl_card_life WHERE iccid_mark IN (...) AND status=1 ORDER BY expire_date DESC - → 每张卡取最新生效的套餐:meal_id, meal_name, start_date, expire_date - - tbl_card WHERE iccid_mark IN (...) → total_bytes_cnt (GB) - - tbl_agent_commission_account → 按 agent_id 聚合 can_draw_amount, total_commision_amount - - tbl_agent,根据 cards.csv 中 agent 信息映射出 shop_id -3. 对每张卡: - a. 套餐映射:legacy_meal_name → package_id(从 yaml 或业务方 csv) - b. 生成伪订单 SQL(order_no = "MIG-CARD-" + iccid 后8位 + 时间戳) - c. 生成 tb_package_usage SQL(关联伪订单 + 计算 snapshot 字段) - d. 生成 UPDATE tb_iot_card SET data_usage_mb = ... (把 GB 转 MB) + - `tbl_card_life` 当前生效正式套餐和 `tbl_next_month_card_life` 次月待生效套餐,保留 legacy 套餐 ID、套餐名、类型、状态、开始时间、到期时间和稳定排序键 + - `tbl_card.total_bytes_cnt` → 反算新系统真用量 MB + - `tbl_agent_commission_account` / `tbl_agent` → 仅用于代理钱包初始化 +3. 对每个独立卡或设备套餐来源槽位: + a. 套餐映射:`legacy_meal_id` → `target_package_id`(来自 `mapping.yaml.packages`) + b. active 套餐写 `tb_package_usage.status=1`,pending 套餐写 `status=0` 并按稳定顺序写 `priority` + c. 生成伪订单 SQL(order_no = `MIG--`) + d. 生成 `tb_package_usage` SQL(关联伪订单 + 计算 snapshot 字段) + e. 生成 `UPDATE tb_iot_card SET data_usage_mb = ...`(真用量 MB) 4. 对每个 shop(按代理映射出来的): a. 生成 INSERT/UPDATE tb_agent_wallet(总值) b. 生成一条 tb_agent_wallet_transaction(type=initial_migration, amount=总值, remark=源奇成代理ID) @@ -343,7 +371,10 @@ output/ step2_03_card_data_usage_update.sql # 卡累计流量 UPDATE step2_04_agent_wallet_init.sql # 代理钱包总值 step2_05_agent_wallet_transactions.sql # 迁移流水 - warnings.csv # 警告:找不到当前生效套餐、无法映射的代理等 + step2_06_asset_series_update.sql # 资产套餐系列回填 + package_resolution.csv # 套餐迁移审核文件 + errors.csv # 套餐映射缺失、多 active、槽位缺失等阻断错误 + warnings.csv # 用量反算和代理钱包警告 summary.txt ``` @@ -357,7 +388,7 @@ WITH new_order AS ( total_amount, payment_method, payment_status, paid_at, source, generation, creator, updater, created_at, updated_at ) - SELECT 'MIG-CARD-' || RIGHT('89852000263338772439', 8) || '-' || TO_CHAR(NOW(), 'YYYYMMDDHH24MISS'), + SELECT 'MIG-89852000263338772439-1', 'single_card', 'personal', 0, c.id, 0, 'offline', 2, NOW(), 'migration', 1, 0, 0, NOW(), NOW() @@ -489,9 +520,9 @@ scripts/migration/ | 1 | 业务方填的 carrier_name / package_name 与奇成不一致 → 映射失败 | 脚本预扫描,把所有未匹配项写到 unmatched.csv 让业务方修正 | | 2 | 奇成的 iccid 有 18/21/22 位脏数据 | 脚本第三节规则剔除,进 errors.csv | | 3 | 同一 iccid 被业务方填多次 | 脚本去重 + 警告 | -| 4 | 设备上的卡在 cards.csv 里又写了 target_shop_code | 一致性校验,进 errors.csv | -| 5 | 奇成 tbl_card_life 一张卡有多条 status=1 记录 | 取 expire_date 最大的那条,warnings.csv 记录 | -| 6 | 套餐 ID 映射不全 | 脚本预扫描,缺的列出来,业务方/我们补全 yaml | +| 4 | 设备 `current_slot` 或 `package_source_slot` 指向空槽位 | 写入 errors.csv,阻断该设备资产或套餐 SQL | +| 5 | 同一资产存在多个 active 正式套餐 | 写入 errors.csv,阻断该资产套餐 SQL,人工裁决后修正 | +| 6 | 套餐 ID 映射不全 | `package_resolution.csv` 记录,errors.csv 阻断对应套餐 SQL | | 7 | tb_iot_card.is_standalone 由触发器维护 | 脚本写入时设 true,触发器会因为后续 binding 自动转 false | | 8 | 代理→shop 映射不明确 | 需要业务方提供 agent_id → shop_code 映射表,作为 config/agent_shop_mapping.yaml | | 9 | 线上库 sequence 与测试库不同 | 用 CTE + RETURNING 模式,不硬编码 id | @@ -526,7 +557,7 @@ mappings: | 字段 | 值 | 说明 | |------|-----|------| -| `order_no` | `MIG-CARD--<时间戳>` / `MIG-DEV--<时间戳>` | 前缀就是迁移数据的唯一识别标记 | +| `order_no` | `MIG--` | 前缀就是迁移数据的唯一识别标记 | | `source` | `'admin'`(复用现有) | 不新增 `migration` 取值 | | `buyer_type` | `'personal'` | 与 buyer_id=0 配对 | | `buyer_id` | `0` | 与赠送/平台自营一致 | diff --git a/internal/exporter/order_scene.go b/internal/exporter/order_scene.go new file mode 100644 index 0000000..b083904 --- /dev/null +++ b/internal/exporter/order_scene.go @@ -0,0 +1,599 @@ +package exporter + +import ( + "context" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +const ( + orderExportBaseHeaderCount = 11 + orderExportCardGroupSize = 2 + orderExportMinCardGroups = 1 +) + +// OrderDataSource 订单导出数据源。 +type OrderDataSource struct { + db *gorm.DB +} + +// NewOrderDataSource 创建订单导出数据源。 +func NewOrderDataSource(db *gorm.DB) *OrderDataSource { + return &OrderDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *OrderDataSource) Scene() string { + return constants.ExportTaskSceneOrder +} + +// Count 统计订单导出行数。 +func (s *OrderDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + query, err := s.applyFilters(ctx, s.baseQuery(ctx), params) + if err != nil { + return 0, err + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回订单导出表头。 +func (s *OrderDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { + cardGroups, err := s.resolveCardGroupCount(ctx, params) + if err != nil { + return nil, err + } + return buildOrderExportHeaders(cardGroups), nil +} + +// Fetch 按 offset/limit 查询订单导出数据。 +func (s *OrderDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + cardGroups := orderCardGroupCountFromHeaders(params.ResolvedHeaders) + if cardGroups <= 0 { + resolved, err := s.resolveCardGroupCount(ctx, params) + if err != nil { + return nil, err + } + cardGroups = resolved + } + + orders, err := s.fetchOrders(ctx, params, offset, limit) + if err != nil { + return nil, err + } + if len(orders) == 0 { + return [][]string{}, nil + } + + orderIDs := make([]uint, 0, len(orders)) + deviceIDs := make([]uint, 0, len(orders)) + for _, item := range orders { + orderIDs = append(orderIDs, item.ID) + if item.DeviceID != nil { + deviceIDs = append(deviceIDs, *item.DeviceID) + } + } + + itemMap, err := s.fetchOrderItems(ctx, orderIDs) + if err != nil { + return nil, err + } + cardMap, err := s.fetchBoundCards(ctx, deviceIDs) + if err != nil { + return nil, err + } + paymentMap, err := s.fetchPreferredPayments(ctx, orderIDs) + if err != nil { + return nil, err + } + + rows := make([][]string, 0, len(orders)) + for _, item := range orders { + var cards []orderExportCardRow + if item.DeviceID != nil { + cards = cardMap[*item.DeviceID] + } + rows = append(rows, buildOrderExportRow(item, itemMap[item.ID], paymentMap[item.ID], cards, cardGroups)) + } + return rows, nil +} + +func (s *OrderDataSource) baseQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx). + Table("tb_order AS o"). + Where("o.deleted_at IS NULL") +} + +// applyFilters 应用与 /api/admin/orders 当前实际行为一致的筛选条件。 +// is_expired 在接口 Service 中未进入 Store 筛选,这里按当前接口行为忽略。 +func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) (*gorm.DB, error) { + query = s.applyOrderScope(query, params) + + if params.UserType == constants.UserTypeAgent { + if params.CreatorShopID != nil { + query = query.Where("o.buyer_type = ? AND o.buyer_id = ?", model.BuyerTypeAgent, *params.CreatorShopID) + } else { + query = query.Where("o.buyer_type = ?", model.BuyerTypeAgent) + } + } + if paymentStatus, ok := filterInt(params.Filters, "payment_status"); ok { + query = query.Where("o.payment_status = ?", paymentStatus) + } + if paymentMethod, ok := filterString(params.Filters, "payment_method"); ok { + query = query.Where("o.payment_method = ?", paymentMethod) + } + if orderType, ok := filterString(params.Filters, "order_type"); ok { + query = query.Where("o.order_type = ?", orderType) + } + if orderNo, ok := filterString(params.Filters, "order_no"); ok { + query = query.Where("o.order_no = ?", orderNo) + } + if purchaseRole, ok := filterString(params.Filters, "purchase_role"); ok { + query = query.Where("o.purchase_role = ?", purchaseRole) + } + if sellerShopID, ok := filterUint(params.Filters, "seller_shop_id"); ok { + query = query.Where("o.seller_shop_id = ?", sellerShopID) + } + if start, ok := filterTime(params.Filters, "start_time"); ok { + query = query.Where("o.created_at >= ?", start) + } + if end, ok := filterTime(params.Filters, "end_time"); ok { + query = query.Where("o.created_at <= ?", end) + } + if buyerPhone, ok := filterString(params.Filters, "buyer_phone"); ok { + query = query.Where("o.buyer_phone = ?", buyerPhone) + } + if identifier, ok := filterString(params.Filters, "identifier"); ok { + iotCardID, deviceID, err := s.resolveOrderListAssetIdentifier(ctx, params, identifier) + if err != nil { + return nil, err + } + assetQuery := s.db.Where("o.asset_identifier = ?", identifier) + if iotCardID != nil { + assetQuery = assetQuery.Or("o.iot_card_id = ?", *iotCardID) + } + if deviceID != nil { + assetQuery = assetQuery.Or("o.device_id = ?", *deviceID) + } + query = query.Where(assetQuery) + } + return query, nil +} + +func (s *OrderDataSource) applyOrderScope(query *gorm.DB, params ExportParams) *gorm.DB { + switch params.UserType { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + return query + case constants.UserTypeAgent: + if len(params.ScopeShopIDs) == 0 { + return query.Where("1 = 0") + } + return query.Where( + s.db.Where("o.buyer_type = ? AND o.buyer_id IN ?", model.BuyerTypeAgent, params.ScopeShopIDs). + Or("o.seller_shop_id IN ?", params.ScopeShopIDs). + Or(s.db.Where("o.seller_shop_id IS NULL AND o.operator_type = ? AND o.operator_id IN ?", model.OperatorAccountTypeAgent, params.ScopeShopIDs)), + ) + default: + return query.Where("1 = 0") + } +} + +func (s *OrderDataSource) resolveCardGroupCount(ctx context.Context, params ExportParams) (int, error) { + filtered, err := s.applyFilters(ctx, s.baseQuery(ctx), params) + if err != nil { + return 0, err + } + + var count int64 + query := filtered. + Select("COALESCE(MAX(bound_cards.card_count), 0)"). + Joins(` + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS card_count + FROM tb_device_sim_binding AS b + WHERE b.device_id = o.device_id + AND b.bind_status = ? + AND b.deleted_at IS NULL + ) AS bound_cards ON TRUE + `, constants.BindStatusBound) + if err := query.Scan(&count).Error; err != nil { + return 0, err + } + if count < orderExportMinCardGroups { + return orderExportMinCardGroups, nil + } + return int(count), nil +} + +func (s *OrderDataSource) fetchOrders(ctx context.Context, params ExportParams, offset, limit int) ([]orderExportRow, error) { + var orders []orderExportRow + filtered, err := s.applyFilters(ctx, s.baseQuery(ctx), params) + if err != nil { + return nil, err + } + + query := filtered. + Joins("LEFT JOIN tb_shop AS sh ON sh.id = o.seller_shop_id"). + Select(` + o.id, + o.order_no, + o.asset_identifier, + o.seller_cost_price, + o.total_amount, + o.actual_paid_amount, + o.payment_status, + o.payment_method, + o.created_at, + o.device_id, + COALESCE(sh.shop_name, '') AS shop_name + `). + Order("o.id DESC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&orders).Error; err != nil { + return nil, err + } + return orders, nil +} + +func (s *OrderDataSource) fetchOrderItems(ctx context.Context, orderIDs []uint) (map[uint][]orderExportItemRow, error) { + result := make(map[uint][]orderExportItemRow, len(orderIDs)) + if len(orderIDs) == 0 { + return result, nil + } + + var items []orderExportItemRow + if err := s.db.WithContext(ctx). + Table("tb_order_item"). + Select("order_id, package_name, quantity"). + Where("order_id IN ?", orderIDs). + Where("deleted_at IS NULL"). + Order("order_id ASC, id ASC"). + Scan(&items).Error; err != nil { + return nil, err + } + + for _, item := range items { + result[item.OrderID] = append(result[item.OrderID], item) + } + return result, nil +} + +func (s *OrderDataSource) fetchBoundCards(ctx context.Context, deviceIDs []uint) (map[uint][]orderExportCardRow, error) { + result := make(map[uint][]orderExportCardRow, len(deviceIDs)) + deviceIDs = normalizeUintSlice(deviceIDs) + if len(deviceIDs) == 0 { + return result, nil + } + + var cards []orderExportCardRow + if err := s.db.WithContext(ctx). + Table("tb_device_sim_binding AS b"). + Select(` + b.device_id, + b.slot_position, + c.msisdn, + c.iccid + `). + Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL"). + Where("b.device_id IN ?", deviceIDs). + Where("b.bind_status = ?", constants.BindStatusBound). + Where("b.deleted_at IS NULL"). + Order("b.device_id ASC, b.slot_position ASC, b.id ASC"). + Scan(&cards).Error; err != nil { + return nil, err + } + + for _, card := range cards { + result[card.DeviceID] = append(result[card.DeviceID], card) + } + return result, nil +} + +func (s *OrderDataSource) fetchPreferredPayments(ctx context.Context, orderIDs []uint) (map[uint]orderExportPaymentRow, error) { + result := make(map[uint]orderExportPaymentRow, len(orderIDs)) + if len(orderIDs) == 0 { + return result, nil + } + + var payments []orderExportPaymentRow + if err := s.db.WithContext(ctx). + Table("tb_payment"). + Select("order_id, third_party_trade_no, status"). + Where("order_id IN ?", orderIDs). + Where("order_type = ?", model.PaymentOrderTypePackage). + Where("deleted_at IS NULL"). + Order("order_id ASC, CASE WHEN status = 1 THEN 0 ELSE 1 END ASC, created_at DESC, id DESC"). + Scan(&payments).Error; err != nil { + return nil, err + } + + for _, payment := range payments { + if _, exists := result[payment.OrderID]; exists { + continue + } + result[payment.OrderID] = payment + } + return result, nil +} + +func (s *OrderDataSource) resolveOrderListAssetIdentifier(ctx context.Context, params ExportParams, identifier string) (*uint, *uint, error) { + if identifier == "" { + return nil, nil, nil + } + + var registry orderExportAssetIdentifierRow + registryErr := s.db.WithContext(ctx). + Table("tb_asset_identifier"). + Select("asset_type, asset_id"). + Where("identifier = ?", identifier). + First(®istry).Error + if registryErr != nil && registryErr != gorm.ErrRecordNotFound { + return nil, nil, registryErr + } + if registryErr == nil { + switch registry.AssetType { + case model.AssetTypeIotCard: + if s.assetVisible(ctx, params, "tb_iot_card", registry.AssetID) { + cardID := registry.AssetID + return &cardID, nil, nil + } + case model.AssetTypeDevice: + if s.assetVisible(ctx, params, "tb_device", registry.AssetID) { + deviceID := registry.AssetID + return nil, &deviceID, nil + } + } + } + + if deviceID, err := s.resolveDeviceIDByIdentifier(ctx, params, identifier); err != nil { + return nil, nil, err + } else if deviceID != nil { + return nil, deviceID, nil + } + + cardID, err := s.resolveCardIDByIdentifier(ctx, params, identifier) + if err != nil { + return nil, nil, err + } + return cardID, nil, nil +} + +func (s *OrderDataSource) assetVisible(ctx context.Context, params ExportParams, table string, id uint) bool { + var count int64 + query := s.db.WithContext(ctx).Table(table).Where("id = ? AND deleted_at IS NULL", id) + if params.UserType == constants.UserTypeAgent { + if len(params.ScopeShopIDs) == 0 { + return false + } + query = query.Where("shop_id IN ?", params.ScopeShopIDs) + } + return query.Count(&count).Error == nil && count > 0 +} + +func (s *OrderDataSource) resolveDeviceIDByIdentifier(ctx context.Context, params ExportParams, identifier string) (*uint, error) { + var row struct { + ID uint `gorm:"column:id"` + } + query := s.db.WithContext(ctx). + Table("tb_device"). + Select("id"). + Where("deleted_at IS NULL"). + Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier) + if params.UserType == constants.UserTypeAgent { + if len(params.ScopeShopIDs) == 0 { + return nil, nil + } + query = query.Where("shop_id IN ?", params.ScopeShopIDs) + } + err := query.First(&row).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &row.ID, nil +} + +func (s *OrderDataSource) resolveCardIDByIdentifier(ctx context.Context, params ExportParams, identifier string) (*uint, error) { + conditions := []string{"virtual_no = ?", "msisdn = ?", "iccid = ?"} + args := []any{identifier, identifier, identifier} + if len(identifier) == 19 { + conditions = append(conditions, "iccid_19 = ?") + args = append(args, identifier) + } else if len(identifier) == 20 { + conditions = append(conditions, "iccid_20 = ?") + args = append(args, identifier) + } + + var row struct { + ID uint `gorm:"column:id"` + } + query := s.db.WithContext(ctx). + Table("tb_iot_card"). + Select("id"). + Where("deleted_at IS NULL"). + Where(strings.Join(conditions, " OR "), args...) + if params.UserType == constants.UserTypeAgent { + if len(params.ScopeShopIDs) == 0 { + return nil, nil + } + query = query.Where("shop_id IN ?", params.ScopeShopIDs) + } + err := query.First(&row).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &row.ID, nil +} + +type orderExportRow struct { + ID uint `gorm:"column:id"` + OrderNo string `gorm:"column:order_no"` + ShopName string `gorm:"column:shop_name"` + AssetIdentifier string `gorm:"column:asset_identifier"` + SellerCostPrice int64 `gorm:"column:seller_cost_price"` + TotalAmount int64 `gorm:"column:total_amount"` + ActualPaidAmount *int64 `gorm:"column:actual_paid_amount"` + PaymentStatus int `gorm:"column:payment_status"` + PaymentMethod string `gorm:"column:payment_method"` + CreatedAt time.Time `gorm:"column:created_at"` + DeviceID *uint `gorm:"column:device_id"` +} + +type orderExportItemRow struct { + OrderID uint `gorm:"column:order_id"` + PackageName string `gorm:"column:package_name"` + Quantity int `gorm:"column:quantity"` +} + +type orderExportPaymentRow struct { + OrderID uint `gorm:"column:order_id"` + ThirdPartyTradeNo string `gorm:"column:third_party_trade_no"` + Status int `gorm:"column:status"` +} + +type orderExportCardRow struct { + DeviceID uint `gorm:"column:device_id"` + SlotPosition int `gorm:"column:slot_position"` + MSISDN string `gorm:"column:msisdn"` + ICCID string `gorm:"column:iccid"` +} + +type orderExportAssetIdentifierRow struct { + AssetType string `gorm:"column:asset_type"` + AssetID uint `gorm:"column:asset_id"` +} + +func buildOrderExportHeaders(cardGroups int) []string { + if cardGroups < orderExportMinCardGroups { + cardGroups = orderExportMinCardGroups + } + + headers := []string{ + "订单号", + "店铺", + "虚拟号", + "套餐名称", + "成本价", + "下单金额", + "支付状态", + "支付方式", + "数量", + "下单时间", + "第三方单号", + } + for i := 1; i <= cardGroups; i++ { + headers = append(headers, + fmt.Sprintf("接入号%d", i), + fmt.Sprintf("ICCID%d", i), + ) + } + return headers +} + +func buildOrderExportRow(item orderExportRow, items []orderExportItemRow, payment orderExportPaymentRow, cards []orderExportCardRow, cardGroups int) []string { + if cardGroups < orderExportMinCardGroups { + cardGroups = orderExportMinCardGroups + } + + row := []string{ + item.OrderNo, + item.ShopName, + item.AssetIdentifier, + joinOrderPackageNames(items), + formatMoneyYuan(item.SellerCostPrice), + formatMoneyYuan(orderActualPaidAmount(item)), + constants.GetOrderPaymentStatusName(item.PaymentStatus), + formatOrderPaymentMethod(item.PaymentMethod), + formatOrderQuantity(items), + item.CreatedAt.Format(exportTimeLayout), + payment.ThirdPartyTradeNo, + } + for i := 0; i < cardGroups; i++ { + if i >= len(cards) { + row = append(row, "", "") + continue + } + row = append(row, cards[i].MSISDN, cards[i].ICCID) + } + return row +} + +func orderCardGroupCountFromHeaders(headers []string) int { + if len(headers) < orderExportBaseHeaderCount { + return 0 + } + cardColumnCount := len(headers) - orderExportBaseHeaderCount + if cardColumnCount <= 0 || cardColumnCount%orderExportCardGroupSize != 0 { + return 0 + } + return cardColumnCount / orderExportCardGroupSize +} + +func joinOrderPackageNames(items []orderExportItemRow) string { + if len(items) == 0 { + return "" + } + names := make([]string, 0, len(items)) + for _, item := range items { + if item.PackageName != "" { + names = append(names, item.PackageName) + } + } + return strings.Join(names, ",") +} + +func formatOrderQuantity(items []orderExportItemRow) string { + if len(items) == 0 { + return "0" + } + total := 0 + for _, item := range items { + total += item.Quantity + } + return fmt.Sprintf("%d", total) +} + +func orderActualPaidAmount(item orderExportRow) int64 { + if item.ActualPaidAmount != nil { + return *item.ActualPaidAmount + } + if item.PaymentStatus == model.PaymentStatusPaid { + return item.TotalAmount + } + return 0 +} + +func formatOrderPaymentMethod(method string) string { + switch method { + case model.PaymentMethodWallet: + return "钱包支付" + case model.PaymentMethodWechat: + return "微信支付" + case model.PaymentMethodAlipay: + return "支付宝支付" + case model.PaymentMethodOffline: + return "线下支付" + default: + return method + } +} diff --git a/openspec/changes/refactor-export-datasource/.openspec.yaml b/openspec/changes/refactor-export-datasource/.openspec.yaml new file mode 100644 index 0000000..c53ef21 --- /dev/null +++ b/openspec/changes/refactor-export-datasource/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/refactor-export-datasource/design.md b/openspec/changes/refactor-export-datasource/design.md new file mode 100644 index 0000000..752a162 --- /dev/null +++ b/openspec/changes/refactor-export-datasource/design.md @@ -0,0 +1,84 @@ +## Context + +现有导出系统在 2026-04-30 上线,核心架构是三段式异步任务(dispatch → shard → finalize)+ 场景策略插件(SceneStrategy 接口)。系统整体结构健全,但存在三个实现层面的缺陷: + +1. `query_json` 字段在 `service.CreateTask` 中被序列化存入数据库,但 `DeviceSceneStrategy` 和 `IotCardSceneStrategy` 的 `NextShardBoundary` 与 `QueryRows` 均不读取该字段,导致用户传入的所有筛选条件被静默丢弃。 +2. `export_finalize.go` 在 finalize 阶段重新调用各分片的 `QueryRows` 将数据全量加载到内存后再生成汇总文件,shard 阶段上传的 OSS 分片文件未被复用,等同于数据查了两遍。 +3. `SceneStrategy` 接口要求开发者手动实现 `NextShardBoundary`(Keyset 游标分页),新增场景需写约 80 行模板代码,且当 SQL 中含有 JOIN 时游标基于主表 ID 的假设可能被破坏(一对多 JOIN 导致结果行数与主表行数不一致)。 + +## Goals / Non-Goals + +**Goals:** +- 引入 `DataSource` 接口,将分片游标逻辑从场景实现中剥离,开发者只需实现 `Count / Headers / Fetch` +- 支持动态列:`Headers` 接收运行时参数,可在执行时决定列数(解决设备绑多卡动态列问题) +- `query_json` 中的筛选条件通过 `ExportParams` 传入每次 `Fetch` 调用,让过滤条件真正生效 +- finalize 阶段改为合并 shard OSS 分片文件,消除重查数据库 +- 迁移 `device` 和 `iot_card` 两个现有场景到新接口,补全筛选条件支持 +- 对外 API(4 个接口)签名不变,无数据库迁移 + +**Non-Goals:** +- 不做用户侧自定义导出(用户在前端自由组合列/表) +- 不支持跨租户数据合并导出 +- 不引入 YAML/DSL 配置驱动 +- 不删除旧 `SceneStrategy` 接口(保留作复杂场景逃生通道) + +## Decisions + +### 决策 1:分片改为 offset/limit 驱动,而非 Keyset 游标 + +**选择**:框架在 dispatch 阶段通过 `Count` 获取总行数,按 `ExportDefaultShardSize` 切分出 `(offset, limit)` 对,每个分片只记录 `offset` 和 `limit`,shard 阶段调用 `Fetch(ctx, params, offset, limit)`。 + +**放弃**:保留 Keyset 游标(当前实现),要求 DataSource 实现 `NextShardBoundary`。 + +**原因**:Keyset 游标强依赖"主表有单调递增 ID 且查询不含破坏游标的 JOIN"这一假设。实际业务查询(IoT 卡+套餐 LEFT JOIN、订单多表关联)中这个假设很容易不成立。offset/limit 对任意 SQL 均有效,代价是在超大数据量下存在 offset 深翻页性能问题——但导出任务本身是后台异步作业,对延迟不敏感,这个代价可接受。 + +**数据库侧应对**:`Fetch` 实现时在 SQL 末尾加 `ORDER BY id ASC` 保证结果稳定,避免不同分片间数据重复或遗漏。 + +### 决策 2:动态列由 Headers 在运行时决定,框架在 dispatch 阶段一次性固定 + +**选择**:dispatch 阶段调用 `DataSource.Headers(ctx, params)` 一次,将结果序列化存入主任务的 `query_json` 扩展字段(新增 `resolved_headers` 子键),shard 和 finalize 阶段均从该字段读取,不再重新调用 `Headers`。 + +**原因**:动态列场景(如设备绑多卡)的列数依赖全量数据扫描,如果每个 shard 独立调用 `Headers` 可能得到不同列数,导致各分片 CSV 列不对齐无法合并。在 dispatch 阶段固定一次,保证所有分片使用同一套表头。 + +**代价**:`Count` 和 `Headers` 会在 dispatch 阶段各执行一次额外查询,对于需要扫全表才能确定列数的动态列场景有一定开销,但因为是异步任务可以接受。 + +### 决策 3:finalize 合并分片 CSV 文件而非重查数据库 + +**选择**:shard 阶段写 CSV 时**不写表头**(仅数据行),finalize 阶段按 `shard_no` 升序从 OSS 下载各分片文件,顺序追加到本地临时文件,最后在文件头部写入表头,上传合并后的完整文件。 + +**放弃**:当前 finalize 重新调用 `QueryRows` 全量重查后在内存中合并。 + +**原因**:消除重复数据库查询,shard 产物得到复用。内存侧也从"全量数据 in-memory"变为"流式追加写文件",对大数据量更友好。 + +**注意点**:XLSX 格式的分片文件无法直接二进制拼接(每个 xlsx 是独立 zip 包),因此 XLSX 格式的 finalize 仍需重查数据库(或先把各分片作为 csv 合并后再转 xlsx)。为简化实现,**XLSX 格式的 finalize 走降级路径:各分片 csv 合并后用 excelize 流式写出 xlsx**,shard 阶段对 XLSX 任务同样生成 csv 分片(不生成 xlsx 分片)。 + +### 决策 4:ExportParams 结构统一传递,query_json 在 Service 层解析 + +**选择**:`service.GetTaskDetail`(Worker 调用路径)负责将 `task.QueryJSON` 解析为 `map[string]any`,连同权限快照一起构造 `ExportParams`,作为参数传入所有 `DataSource` 方法。 + +**原因**:解析逻辑集中在一处,DataSource 实现无需关心 JSON 解析,直接消费类型化字段。 + +### 决策 5:保留 SceneStrategy 接口作为逃生通道 + +对于无法用 `DataSource` 表达的场景(如多阶段聚合、需要跨库合并的报表),保留原 `SceneStrategy` 接口。Registry 同时支持两种注册方式,框架在执行时通过类型断言判断走哪条路径。 + +## Risks / Trade-offs + +| 风险 | 缓解措施 | +|------|----------| +| offset 深翻页在千万级数据下性能退化 | 导出任务为异步后台作业,P99 无硬性要求;若实测超时可对 DataSource 实现添加索引提示 | +| XLSX 格式降级路径导致 shard 文件与 CSV 格式行为不一致 | 在文档和注释中明确说明;XLSX 的 shard 文件统一命名为 `.csv.shard` 加以区分 | +| Headers 在 dispatch 阶段执行全表扫描(动态列场景)| DataSource 实现者可在 Headers 中只做轻量采样(如 LIMIT 1)而非全量扫描来决定动态列数 | +| 现有 device / iot_card 场景迁移引入回归 | 两个场景的 Fetch 输出与原 QueryRows 输出做 diff 验证,MCP 手动对比数据样本 | + +## Migration Plan + +1. 新接口与旧接口并存,不删除 `SceneStrategy` +2. dispatch/shard/finalize 三段 worker 修改为优先检测 `DataSource` 接口,降级检测 `SceneStrategy` +3. `device` 和 `iot_card` 场景实现新 `DataSource`,原 `SceneStrategy` 实现暂时保留,经线上验证后在下一个 change 中删除 +4. shard 写文件逻辑:新建任务走新路径(不含表头的 csv 分片);存量任务(已在执行中)因为 finalize 会检查 shard 文件是否可下载,若下载失败降级为重查数据库(加兜底逻辑) +5. 无数据库迁移,无 API 变更,可随时回滚(回滚只需重新部署旧版 worker) + +## Open Questions + +- 动态列场景中,dispatch 阶段固定 `resolved_headers` 后,若同一批次中不同分片的数据导致"实际需要的列数"不同(如某分片的设备绑了 5 张卡,另一分片最多 3 张),当前方案以 dispatch 阶段的扫描结果为准。这个"扫全表确定最大列数"的代价是否可接受,还是应该约定上限(如最多 N 列动态列)? diff --git a/openspec/changes/refactor-export-datasource/proposal.md b/openspec/changes/refactor-export-datasource/proposal.md new file mode 100644 index 0000000..c2159cd --- /dev/null +++ b/openspec/changes/refactor-export-datasource/proposal.md @@ -0,0 +1,32 @@ +## Why + +现有导出系统(2026-04-30 上线)存在三个影响实际可用性的缺陷:筛选条件被静默丢弃(query_json 存而不用)、finalize 阶段重查全量数据库(shard 产物废弃)、新增场景需要手写 Keyset 游标逻辑(难以支持多表 JOIN)。这三个问题共同导致"想加新导出就得开发介入、开发成本高、加完也无法按条件筛选"的现状。 + +## What Changes + +- **引入 DataSource 接口**:替代 `SceneStrategy` 的 `NextShardBoundary` + `QueryRows` 设计,新接口仅要求实现 `Count / Headers / Fetch`,分片游标逻辑全部由框架接管,开发者只管写业务查询(支持任意 SQL / JOIN) +- **支持动态列**:`Headers` 方法接收 `ExportParams`,可在运行时根据数据决定列数,解决"设备绑了几张卡就要几列 ICCID"问题 +- **query_json 真正生效**:`ExportParams` 将 `query_json` 解析结果和权限快照一并传入 DataSource,`Fetch` 和 `Count` 均可读取并应用筛选条件 +- **修复 finalize 重查问题**:finalize 改为按 shard_no 顺序下载分片 OSS 文件直接拼接,不再重查数据库,shard 产物得到复用 +- **迁移现有两个场景**:`device` 和 `iot_card` 迁移到新 DataSource 接口,同时补全 query_json 解析逻辑 +- **保留原 SceneStrategy 接口**:作为复杂自定义场景的逃生通道,不强制所有场景使用 DataSource + +## Capabilities + +### New Capabilities + +- `export-datasource`:DataSource 接口定义、ExportParams 类型、框架侧 offset/limit 分片驱动逻辑、动态列支持 +- `export-query-params`:query_json 的解析规范与 ExportParams 注入机制,让筛选条件从创建任务一路传递到数据查询 + +### Modified Capabilities + +- `unified-export-task-system`:finalize 合并策略从"重查数据库"改为"合并 shard OSS 文件";shard 阶段需保证分片 CSV 可被 finalize 独立拼接(不含表头) + +## Impact + +- **删除**:`internal/exporter/SceneStrategy` 接口中的 `NextShardBoundary` 方法(仅框架调用,外部无直接依赖) +- **修改**:`internal/task/export_dispatch.go`(游标分片逻辑改为 offset 驱动)、`internal/task/export_finalize.go`(改为合并分片文件)、`internal/task/export_shard.go`(写分片文件时不含表头) +- **新增**:`internal/exporter/datasource.go`(接口 + ExportParams 定义)、`internal/exporter/query_params.go`(query_json 解析) +- **迁移**:`device_scene.go` / `iot_card_scene.go` 实现新接口,补 query_json 过滤 +- **无 API 变更**:四个对外接口(创建/列表/详情/取消)签名不变,前端无感知 +- **无数据库迁移**:不新增表或字段 diff --git a/openspec/changes/refactor-export-datasource/specs/export-datasource/spec.md b/openspec/changes/refactor-export-datasource/specs/export-datasource/spec.md new file mode 100644 index 0000000..d9e01b3 --- /dev/null +++ b/openspec/changes/refactor-export-datasource/specs/export-datasource/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: DataSource 接口替代 SceneStrategy 游标逻辑 + +系统 SHALL 提供 `DataSource` 接口作为场景数据获取的标准契约,接口仅包含 `Count`、`Headers`、`Fetch` 三个方法,框架自动将 `Count` 结果按 `ExportDefaultShardSize` 切分为 `(offset, limit)` 对,开发者不感知游标分页。 + +#### Scenario: 框架根据 Count 自动切分分片 + +- **WHEN** dispatch 阶段调用 `DataSource.Count` 返回 5000 条,`ExportDefaultShardSize` 为 2000 +- **THEN** 框架生成 3 个分片:`(0,2000)` / `(2000,2000)` / `(4000,1000)`,不要求 DataSource 实现任何游标逻辑 + +#### Scenario: Fetch 按 offset/limit 返回数据行 + +- **WHEN** shard 阶段以 `offset=2000, limit=2000` 调用 `DataSource.Fetch` +- **THEN** DataSource 实现返回第 2001-4000 条记录,每条记录为 `[]string`,列数与 `Headers` 返回值一致 + +#### Scenario: DataSource 实现支持任意 SQL JOIN + +- **WHEN** 场景需要 `tb_iot_card LEFT JOIN tb_package_usage` 联表 +- **THEN** DataSource 实现可在 `Fetch` 中自由编写含 JOIN 的 GORM Raw SQL,框架不干涉查询内容 + +--- + +### Requirement: 动态列支持 + +`DataSource.Headers` SHALL 接收 `ExportParams` 参数并可在运行时返回动态列头;dispatch 阶段调用一次 `Headers` 后将结果存入任务元数据,所有分片均使用同一份固定列头。 + +#### Scenario: 设备绑多卡时动态扩展列 + +- **WHEN** 某批次设备中最多绑了 3 张卡,dispatch 阶段调用 `Headers` 时返回 `["设备号","IMEI","ICCID_1","ICCID_2","ICCID_3"]` +- **THEN** 所有分片的 CSV 文件均使用该 5 列结构,finalize 合并后列头一致 + +#### Scenario: 同一任务内各分片列数一致 + +- **WHEN** dispatch 阶段已固定 `resolved_headers` 为 5 列 +- **THEN** 所有 shard 任务的 `Fetch` 返回每行均为 5 个元素,列数不匹配时记录警告日志并补空字符串对齐 + +--- + +### Requirement: 旧 SceneStrategy 接口作为逃生通道保留 + +系统 SHALL 同时支持 `DataSource` 和 `SceneStrategy` 两种注册方式;Registry 在执行时 MUST 优先识别 `DataSource`,降级识别 `SceneStrategy`;两种接口可混合注册,互不影响。 + +#### Scenario: 同时注册两种策略类型 + +- **WHEN** Registry 中注册了实现 `DataSource` 的 `IotCardDataSource` 和实现 `SceneStrategy` 的 `LegacyDeviceStrategy` +- **THEN** 框架对 `iot_card` 走 offset/limit 路径,对 `device`(若未迁移)走原 Keyset 路径,两者并存正常工作 + +#### Scenario: 未注册场景被创建任务时拒绝 + +- **WHEN** 用户创建任务时传入 `scene=unknown_scene` +- **THEN** 系统返回参数错误,不创建任务,Registry 不需要知道该 scene 的任何信息 diff --git a/openspec/changes/refactor-export-datasource/specs/export-query-params/spec.md b/openspec/changes/refactor-export-datasource/specs/export-query-params/spec.md new file mode 100644 index 0000000..325ae78 --- /dev/null +++ b/openspec/changes/refactor-export-datasource/specs/export-query-params/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: query_json 筛选条件真正生效 + +系统 SHALL 将创建任务时传入的 `query.filters` 解析为 `ExportParams.Filters`,并在每次调用 `DataSource.Count` 和 `DataSource.Fetch` 时一并传入;DataSource 实现 MUST 读取 `Filters` 并应用到 SQL WHERE 条件中。 + +#### Scenario: 按店铺筛选导出 IoT 卡 + +- **WHEN** 用户创建任务时传入 `query: {"filters": {"shop_id": 5}}` +- **THEN** `Fetch` 调用收到 `ExportParams.Filters["shop_id"] = 5`,生成的 SQL 包含 `WHERE shop_id = 5`,导出结果仅含该店铺数据 + +#### Scenario: 按时间范围筛选 + +- **WHEN** 用户创建任务时传入 `query: {"filters": {"created_at_start": "2024-01-01", "created_at_end": "2024-12-31"}}` +- **THEN** `Fetch` 生成的 SQL 包含 `WHERE created_at >= '2024-01-01' AND created_at <= '2024-12-31'` + +#### Scenario: 不传 filters 时全量导出 + +- **WHEN** 用户创建任务时 `query` 字段为空或 `filters` 为空对象 +- **THEN** DataSource 不附加额外 WHERE 条件,仅应用数据权限过滤(`ScopeShopIDs`) + +#### Scenario: 筛选条件在整个任务生命周期内不变 + +- **WHEN** 任务创建后进入 dispatch → shard → finalize 各阶段 +- **THEN** 各阶段均从 `task.QueryJSON` 重新解析出相同的 `ExportParams.Filters`,筛选条件不发生漂移 + +--- + +### Requirement: ExportParams 包含权限快照 + +系统 SHALL 将任务创建时记录的 `scope_shop_ids`、`creator_user_type` 构造进 `ExportParams`,DataSource 实现通过 `ExportParams.ScopeShopIDs` 和 `ExportParams.UserType` 应用数据权限,不得通过 context 获取实时权限(避免权限变更影响已创建任务)。 + +#### Scenario: 代理账号导出时使用权限快照 + +- **WHEN** 代理账号创建任务时 `scope_shop_ids = [10, 11, 12]`,任务执行时该代理已被移除店铺 12 的权限 +- **THEN** DataSource 仍使用快照中的 `[10, 11, 12]` 过滤,不受权限变更影响 + +#### Scenario: 平台账号导出不附加店铺过滤 + +- **WHEN** `ExportParams.UserType` 为平台类型 +- **THEN** DataSource 不附加 `shop_id IN (...)` 条件,可导出全量数据 diff --git a/openspec/changes/refactor-export-datasource/specs/unified-export-task-system/spec.md b/openspec/changes/refactor-export-datasource/specs/unified-export-task-system/spec.md new file mode 100644 index 0000000..a61a39d --- /dev/null +++ b/openspec/changes/refactor-export-datasource/specs/unified-export-task-system/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: 导出任务异步分片执行 + +系统 SHALL 使用 Asynq 异步任务执行导出流程,并采用 `dispatch -> shard -> finalize` 三段式处理;大数据量导出 MUST 支持分片并行执行。dispatch 阶段 MUST 通过 `DataSource.Count` 获取总行数后按 `offset/limit` 切分分片,不再使用 Keyset 游标。 + +#### Scenario: 创建任务后异步执行 + +- **WHEN** 导出任务创建成功 +- **THEN** 系统将主任务入队,任务状态从"待处理"推进到"处理中" + +#### Scenario: dispatch 阶段通过 Count 切分分片 + +- **WHEN** dispatch 阶段调用 DataSource.Count 返回总行数 +- **THEN** 系统按 ExportDefaultShardSize 计算分片数,每个分片记录 offset 和 limit,不依赖主表 ID 游标 + +#### Scenario: 分片任务并行处理 + +- **WHEN** dispatch 阶段生成多个 offset/limit 分片 +- **THEN** 系统并行执行多个 shard 任务并按分片回写进度 + +#### Scenario: 分片失败触发重试 + +- **WHEN** 某个 shard 任务因临时错误失败 +- **THEN** 系统按 Asynq 重试策略重试该分片,且不影响其他分片状态 + +#### Scenario: finalize 合并分片文件而非重查数据库 + +- **WHEN** 所有分片任务完成且均为成功状态 +- **THEN** finalize 阶段按 shard_no 升序从 OSS 下载各分片 CSV 文件(不含表头),顺序追加写入本地临时文件,在文件头部写入表头后上传为最终产物,不重新查询数据库 + +#### Scenario: XLSX 格式 finalize 降级路径 + +- **WHEN** 导出格式为 xlsx,finalize 阶段合并分片 +- **THEN** 各分片在 shard 阶段生成不含表头的 CSV 中间文件(而非 xlsx),finalize 合并 CSV 后用 excelize 流式写出最终 xlsx 文件 + +--- + +### Requirement: 导出结果 OSS 交付与详情下载链接 + +系统 SHALL 将导出产物上传至 OSS,并在任务详情接口返回可直接下载的 `download_url`;该下载链接默认有效期 MUST 为 24 小时。分片产物 MUST 可被 finalize 阶段独立下载拼接(每个分片文件仅含数据行,不含表头)。 + +#### Scenario: 已完成任务详情返回下载链接 + +- **WHEN** 任务状态为"已完成"且产物上传成功 +- **THEN** 任务详情接口返回 `file_key` 与可直接下载的 `download_url` + +#### Scenario: 未完成任务不返回下载链接 + +- **WHEN** 任务状态为"待处理"或"处理中" +- **THEN** 任务详情接口不返回可用的 `download_url` + +#### Scenario: 下载链接过期后可重新获取 + +- **WHEN** 用户在 URL 过期后再次调用任务详情接口 +- **THEN** 系统重新生成新的 24 小时有效下载链接 + +#### Scenario: 分片文件不含表头 + +- **WHEN** shard 阶段生成分片文件并上传 OSS +- **THEN** 分片文件仅包含数据行,不包含表头行,使得 finalize 可直接顺序追加而无需跳过首行 diff --git a/openspec/changes/refactor-export-datasource/tasks.md b/openspec/changes/refactor-export-datasource/tasks.md new file mode 100644 index 0000000..e76f6dd --- /dev/null +++ b/openspec/changes/refactor-export-datasource/tasks.md @@ -0,0 +1,52 @@ +## 1. 新增 DataSource 接口与 ExportParams 类型,删除旧 SceneStrategy 实现 + +- [x] 1.1 在 `internal/exporter/datasource.go` 新建文件,定义 `DataSource` 接口(`Scene / Count / Headers / Fetch`)和 `ExportParams` 结构体(`Filters map[string]any / ScopeShopIDs []uint / UserType int`) +- [x] 1.2 在 `internal/exporter/query_params.go` 新建文件,实现 `ParseExportParams(task *model.ExportTask) ExportParams`:从 `task.QueryJSON` 解析 `filters`,从 `task.ScopeShopIDs` 和 `task.CreatorUserType` 填充权限字段 +- [x] 1.3 删除 `internal/exporter/device_scene.go` 和 `internal/exporter/iot_card_scene.go` 中的全部旧 `SceneStrategy` 实现代码(文件内容清空,后续第 5 组重写为 `DataSource` 实现) +- [x] 1.4 重写 `internal/exporter/registry.go`:`Registry` 只存储 `DataSource`,删除 `SceneStrategy` 相关类型和方法;`Get` 直接返回 `DataSource`;`NewDefaultRegistry` 暂时为空(第 5 组补充) +- [x] 1.5 删除 `internal/exporter/scope.go` 中 `applyShopScopeForTask` 函数(该逻辑后续由各 DataSource 实现内部通过 `ExportParams` 处理,不再作为公共函数) +- [x] 1.6 用 `go build ./internal/exporter/...` 确认编译无错误 + +## 2. 修改 dispatch:改为 offset/limit 切分分片 + +- [x] 2.1 修改 `internal/model/export_task.go` 中 `ExportShardTask`:将 `CursorStart / CursorEnd uint64` 字段语义改为 `Offset / Limit int`(字段名改为 `ShardOffset / ShardLimit`),同步更新 `gorm` 标签注释 +- [x] 2.2 新增数据库迁移文件 `migrations/000XXX_alter_export_shard_task_offset.up.sql`:在 `tb_export_shard_task` 中添加 `shard_offset bigint not null default 0` 和 `shard_limit int not null default 0` 列(保留原 `cursor_start / cursor_end` 列兼容旧任务) +- [x] 2.3 修改 `internal/task/export_dispatch.go`:`buildShards` 方法改为调用 `DataSource.Count` 获取总行数,按 `ExportDefaultShardSize` 计算 offset/limit 对,生成 `ExportShardTask` 列表(填 `ShardOffset / ShardLimit`);删除原 Keyset 游标逻辑 +- [x] 2.4 修改 `internal/task/export_dispatch.go`:dispatch 阶段调用 `DataSource.Headers` 一次,将结果序列化后存入 `task.QueryJSON` 的 `resolved_headers` 子键(通过新增 `ExportTaskStore.UpdateResolvedHeaders` 方法写入) +- [x] 2.5 修改 `internal/store/postgres/export_task_store.go`:新增 `UpdateResolvedHeaders(ctx, taskID, headers []string, updater uint) error` 方法 +- [x] 2.6 用 `go build ./internal/task/...` 确认编译无错误 + +## 3. 修改 shard:调用 DataSource.Fetch,分片文件不含表头 + +- [x] 3.1 修改 `internal/task/export_shard.go`:shard 阶段从 `task.QueryJSON` 的 `resolved_headers` 读取表头;调用 `DataSource.Fetch(ctx, params, shard.ShardOffset, shard.ShardLimit)` 获取数据行,删除原 `strategy.QueryRows` 调用 +- [x] 3.2 修改 `internal/task/export_common.go` 的 `writeExportFile`:新增 `withoutHeader bool` 参数,当为 `true` 时跳过写表头行;shard 阶段调用时传 `true`,finalize 写入表头 +- [x] 3.3 用 `go build ./internal/task/...` 确认编译无错误 + +## 4. 修改 finalize:合并分片 OSS 文件,不重查数据库 + +- [x] 4.1 在 `pkg/storage/types.go` 确认(或新增)`Provider` 接口有 `Download(ctx, key string) (io.ReadCloser, error)` 方法;若无则添加并在对应实现中实现 +- [x] 4.2 修改 `internal/task/export_finalize.go`:所有分片成功后,按 `shard_no` 升序循环从 OSS 下载各分片文件,流式追加写入本地临时 CSV 文件;写完后在文件头部插入 `resolved_headers` 行 +- [x] 4.3 修改 `internal/task/export_finalize.go`:XLSX 格式走降级路径——合并 CSV 后用 `excelize` 流式写出 xlsx,而非直接合并 xlsx 分片 +- [x] 4.4 用 `go build ./internal/task/...` 确认编译无错误 + +## 5. 迁移 device 和 iot_card 场景到 DataSource 接口 + +- [x] 5.1 重写 `internal/exporter/device_scene.go`:实现 `DataSource` 接口,`Count` 按权限快照计总数,`Headers` 返回固定列头(非动态),`Fetch` 用 GORM 加 `ORDER BY id ASC LIMIT ? OFFSET ?` 查询并应用 `ExportParams.Filters`(支持 `status / shop_id / created_at_start / created_at_end` 过滤) +- [x] 5.2 重写 `internal/exporter/iot_card_scene.go`:同上,支持 `status / shop_id / carrier_name / created_at_start / created_at_end` 过滤条件;`Fetch` 中用 `LEFT JOIN tb_package_usage` 补充套餐信息列(可选列,通过 `Filters["with_package"]` 开关控制) +- [x] 5.3 修改 `internal/exporter/registry.go` 的 `NewDefaultRegistry`:改为注册新的 `DataSource` 实现,删除旧 `SceneStrategy` 注册 +- [x] 5.4 用 MCP PostgreSQL 工具分别对 device 和 iot_card 场景各抽取一批数据,对比新 `Fetch` 输出与原 `QueryRows` 输出一致(重点验证权限过滤和筛选条件) +- [x] 5.5 用 `go build ./...` 确认全项目编译无错误 + +## 6. 执行数据库迁移并验证 + +- [x] 6.1 执行 `migrate -path migrations -database "..." up` 应用 shard 表字段迁移 +- [x] 6.2 用 MCP PostgreSQL 工具确认 `tb_export_shard_task` 表已新增 `shard_offset / shard_limit` 列 +- [x] 6.3 确认存量 shard 记录的 `cursor_start / cursor_end` 列数据未被破坏(迁移为纯新增列,无数据变更) + +## 7. 端到端验证 + +- [x] 7.1 通过 API 创建一个 `scene=iot_card, format=csv` 的导出任务,传入筛选条件 `{"filters": {"shop_id": 5}}`,用 MCP 查询 `tb_export_task` 确认 `query_json` 已记录筛选参数 +- [x] 7.2 等待任务完成,用 MCP 查询 `tb_export_shard_task` 确认各分片的 `shard_offset / shard_limit` 已按预期填写,`status = 3`(已成功) +- [x] 7.3 调用任务详情接口获取 `download_url`,下载文件验证:列头与 `Headers` 一致、数据行仅含 `shop_id=5` 的记录、无重复行、无缺失行 +- [x] 7.4 创建一个 `scene=device, format=xlsx` 的导出任务(不传筛选条件),验证全量导出正常,xlsx 文件可正常打开 +- [x] 7.5 创建导出任务后立即取消(`status=处理中`),验证取消流程正常,任务最终状态为已取消 diff --git a/openspec/changes/refactor-migration-ownership-package-flow/.openspec.yaml b/openspec/changes/refactor-migration-ownership-package-flow/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/refactor-migration-ownership-package-flow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/refactor-migration-ownership-package-flow/design.md b/openspec/changes/refactor-migration-ownership-package-flow/design.md new file mode 100644 index 0000000..eb18f1c --- /dev/null +++ b/openspec/changes/refactor-migration-ownership-package-flow/design.md @@ -0,0 +1,137 @@ +## Context + +当前奇成迁移脚本分为 `scan_legacy.py`、`migrate_assets.py`、`migrate_runtime.py`。脚本可以生成卡、设备、绑定、分配、伪订单、套餐使用记录和系列回填 SQL,但多个关键决策依赖奇成查询结果和脚本默认值: + +- 设备归属由 `sim_iccid_1` 对应卡的奇成 `agent_id` 推导。 +- 设备套餐来源由 `current_slot` 决定,但 CSV 未提供时默认 `1`。 +- 套餐迁移只解析一条当前正式套餐,无法覆盖“当前生效 + 未生效待生效”的队列。 +- 卡和设备共用一套套餐解析路径,导致设备绑定卡、独立卡、卡级系列和设备级系列边界不清。 +- 缺失代理映射或未命中奇成归属时,脚本会把资产留在平台库存,无法体现人工指定的迁移目标。 + +本次变更只涉及 `scripts/migration/` 下的离线迁移脚本和文档,不改变线上 Go API、Handler/Service/Store/Model 分层和业务数据库结构。 + +## Goals / Non-Goals + +**Goals:** +- 让 `mapping.yaml` 成为迁移决策源:资产给哪个店铺、设备当前槽位、套餐来源槽位、套餐迁移范围都由配置决定。 +- 支持万级迁移的批量规则:默认店铺、设备/独立卡分支规则、按槽位规则、少量资产级覆盖。 +- 拆分单卡迁移与设备迁移分支,避免独立卡和设备绑定卡共用不清晰逻辑。 +- 迁移当前生效和未生效待生效正式套餐,分别写入 `tb_package_usage.status=1` 和 `status=0`。 +- 为每次生成 SQL 输出可审计 CSV,让业务和开发能在执行前确认归属、套餐映射和跳过原因。 +- 保持奇成连接只读、SQL 幂等、人工执行和当前脚本目录内的轻量实现方式。 + +**Non-Goals:** +- 不新增后台 API 或前端页面。 +- 不新增数据库表或修改线上业务模型。 +- 不迁移历史已过期套餐、加油包、历史订单明细或历史流量日明细。 +- 不逐资产要求配置店铺;逐资产配置仅用于异常覆盖。 +- 不新增自动化测试或 `_test.go`。 + +## Decisions + +### 决策 1:使用“批量规则 + 覆盖项”的配置模型 + +**选择**:在 `mapping.yaml` 中新增 `ownership_rules`、`package_rules`、`overrides` 三类配置。 + +示例: + +```yaml +ownership_rules: + default_target_shop_code: KWTX + device: + mode: default_shop + current_slot: 2 + package_source_slot: 2 + standalone_card: + mode: default_shop + +package_rules: + migrate_statuses: + - active + - pending + packages: + - legacy_meal_id: D6695908379395072 + target_package_id: 40 + +overrides: + devices: + - virtual_no: "862639073940258" + target_shop_code: OTHER + current_slot: 1 + package_source_slot: 1 + cards: + - iccid: "89861590172420360956" + target_shop_code: OTHER +``` + +**理由**:单批上万资产不能逐条配置,默认规则覆盖 99% 场景,覆盖项只处理少量例外。 + +**替代方案**:继续使用 `mapping.agents` 从奇成代理推导店铺。否决原因:奇成代理不是本次迁移的权威归属,且无代理或代理未映射时会出现静默漏分配。 + +### 决策 2:拆分独立卡与设备套餐分支 + +**选择**: +- 独立卡:只为未绑定设备的卡生成 `usage_type='single_card'`。 +- 设备:只按设备的 `package_source_slot` 对应卡生成 `usage_type='device'`,其他槽位卡只做绑定和卡基础信息,不重复生成主套餐。 + +**理由**:新系统运行态按设备维度判断设备套餐和复机条件,设备内多卡不能重复生成并列主套餐。独立卡和设备套餐生命周期也需要不同的资产定位字段。 + +**替代方案**:继续逐卡生成套餐后再靠 `_runtime_asset_for_card()` 折叠。否决原因:折叠逻辑隐蔽,难以解释某张卡为什么迁或不迁。 + +### 决策 3:完整查询正式套餐生命周期并映射为 active/pending 队列 + +**选择**:新增查询函数读取每张来源卡在奇成 `tbl_card_life` 中所有正式套餐记录,排除 `type=1` 加油包和已过期记录,按当前时间和状态识别: +- 当前生效:写入 `status=1`,保留 `activated_at`、`expires_at` 和已用量。 +- 未生效待生效:写入 `status=0`,`data_usage_mb=0`,按开始/到期时间和队列顺序设置 `priority`。 + +**理由**:新系统已支持 `PackageUsageStatusPending=0` 和队列激活,能承接待生效套餐。当前脚本只取一条最大过期时间套餐,解释不了“20G/100G 只迁一个”这类问题。 + +**替代方案**:只迁当前生效套餐。否决原因:业务已确认必须迁移当前生效 + 未生效待生效套餐。 + +### 决策 4:生成审核 CSV 作为执行前合同 + +**选择**:每次生成 SQL 时输出: +- `ownership_resolution.csv`:资产类型、资产标识、来源卡、目标店铺、决策来源、是否分配、原因。 +- `package_resolution.csv`:资产类型、资产标识、来源卡、legacy 套餐、目标套餐、目标状态、优先级、决策、原因。 +- `summary.txt`:按资产类型、状态、错误类型汇总。 + +**理由**:迁移脚本是一次性高风险操作,执行前必须能从产物直接解释每个资产和套餐的处理结果。 + +**替代方案**:只写 `errors.csv` / `warnings.csv`。否决原因:只能看到异常,无法证明正常路径是否符合业务预期。 + +### 决策 5:关键缺失项阻断,不再静默降级 + +**选择**:以下情况必须写入 `errors.csv` 并跳过对应资产或套餐: +- 目标店铺码不存在或为空且规则要求分配。 +- 设备 `current_slot` / `package_source_slot` 不在 1-4 或对应槽位无卡。 +- legacy 套餐没有 `target_package_id`。 +- 目标套餐不存在、已删除或不是正式套餐。 +- 同一资产产生多个 active 主套餐且无法按规则裁决。 + +**理由**:静默进入平台库存或漏迁套餐会让后续人工排查成本远高于生成期阻断。 + +## Risks / Trade-offs + +- **[风险] 新配置结构与旧 `mapping.yaml` 不兼容** → 提供清晰错误和示例,必要时保留旧字段读取但输出升级提示。 +- **[风险] 奇成套餐状态字段与真实生效窗口不一致** → 审核 CSV 同时输出奇成原始 `status/type/start_date/expire_date`,便于人工确认。 +- **[风险] 待生效套餐优先级排序错误会影响自动激活顺序** → 按 `start_date ASC, expire_date ASC, legacy_life_id ASC` 生成稳定排序,并在 `package_resolution.csv` 输出 priority。 +- **[风险] 批量默认店铺误配会影响大量资产** → `ownership_resolution.csv` 必须在执行 SQL 前人工抽查;summary 输出目标店铺分布。 +- **[Trade-off] 不做 UI 化配置** → 先保持脚本和 YAML,满足迁移批次的速度和可审查性,后续如迁移频率升高再考虑管理界面。 + +## Migration Plan + +1. 扩展 `mapping.yaml.example` 和 `resources/README.md`,明确批量规则、槽位字段和审核流程。 +2. 增强配置加载器,新增结构化配置对象和校验错误。 +3. 扩展 CSV 加载器,支持 `current_slot` / `package_source_slot` 列;未填时从 `ownership_rules.device` 取默认值。 +4. 扩展奇成查询,读取完整正式套餐生命周期。 +5. 重构 SQL 构造逻辑,拆分独立卡和设备套餐生成。 +6. 新增审核 CSV 与阻断错误输出。 +7. 用当前 120 台设备样例和新增小样例手动生成 SQL,核对 120 台归属、4 类套餐迁移问题和待生效队列。 + +**回滚**:本变更只影响 SQL 生成脚本。若生成产物不符合预期,不执行输出 SQL 即可;已生成 SQL 可删除后用旧分支重新生成。 + +## Open Questions + +- 是否所有迁移批次默认都只有一个目标店铺,还是需要支持按资源文件分组指定多个默认店铺? +- 待生效套餐 `activated_at` 是否保留奇成 `start_date`,还是写 `NULL` 等待新系统激活时回填? +- 同一来源卡存在多个奇成 `status=1` 正式套餐时,是否按时间窗口裁决一个 active,其余转 pending,还是直接阻断人工处理? diff --git a/openspec/changes/refactor-migration-ownership-package-flow/proposal.md b/openspec/changes/refactor-migration-ownership-package-flow/proposal.md new file mode 100644 index 0000000..80dd1a1 --- /dev/null +++ b/openspec/changes/refactor-migration-ownership-package-flow/proposal.md @@ -0,0 +1,43 @@ +## Why + +当前奇成迁移脚本把奇成 `agent_id`、默认 `sim_iccid_1`、单一当前套餐查询结果当成迁移决策来源,导致批量设备店铺归属、设备主卡、套餐系列和套餐队列迁移不可控。随着单批上万到几万张卡迁移成为常态,脚本需要改为由 `mapping.yaml` 的批量规则驱动,奇成只提供历史事实,生成可审核的中间产物后再输出 SQL。 + +功能 ID:`feature-qicheng-migration-control` + +## What Changes + +- **重构迁移决策来源**:`mapping.yaml` 成为资产归属、设备当前槽位、套餐来源槽位、套餐迁移范围的唯一决策入口;奇成只用于读取运营商、套餐生命周期、流量和佣金等历史事实。 +- **新增批量归属规则**:支持默认店铺、按设备/独立卡分支配置、按槽位配置和少量资产级覆盖,避免万级迁移逐卡配置。 +- **拆分单卡迁移与设备迁移分支**:独立卡生成 `single_card` 套餐使用记录;设备绑定卡按设备维度生成 `device` 套餐使用记录,设备内卡不重复生成主套餐。 +- **支持当前生效 + 未生效待生效套餐迁移**:完整读取奇成正式套餐生命周期,当前生效套餐写为 `status=1`,后续待生效套餐写为 `status=0` 并按优先级排队。 +- **新增审核产物**:生成 `package_resolution.csv`、`ownership_resolution.csv` 和摘要文件,明确每个资产/套餐的决策、映射、跳过原因和缺失项。 +- **增强阻断规则**:关键映射缺失、目标店铺不存在、目标套餐无效、设备主卡槽位缺失等必须进入错误清单并阻断对应资产,不再静默进入平台库存或漏写系列。 +- **更新脚本文档**:同步更新 `scripts/migration/README.md`、`resources/README.md` 和奇成迁移方案文档,说明新配置模型、执行顺序和人工审核点。 + +## Capabilities + +### New Capabilities + +- `qicheng-migration-control`:定义奇成迁移脚本的批量归属决策、设备/单卡分支、套餐队列迁移、审核产物和阻断规则。 + +### Modified Capabilities + +无。 + +## Impact + +**脚本与配置**: +- `scripts/migration/config/mapping.yaml.example`:新增批量归属、槽位、套餐迁移范围和覆盖配置示例。 +- `scripts/migration/lib/mapping_loader.py`:加载并校验新的配置结构,保留旧配置兼容或提供明确升级错误。 +- `scripts/migration/lib/csv_loader.py`:支持设备 `current_slot`、`package_source_slot` 或由批量规则填充默认槽位。 +- `scripts/migration/lib/legacy_query.py`:新增读取完整正式套餐生命周期的查询。 +- `scripts/migration/lib/sql_builder.py`:拆分单卡/设备套餐生成逻辑,生成审核 CSV 和阻断错误。 +- `scripts/migration/scan_legacy.py`、`migrate_assets.py`、`migrate_runtime.py`:按新规则消费配置并输出 SQL。 + +**数据库与业务系统**: +- 不新增业务表,不修改 API,不引入外部依赖。 +- 继续输出人工执行 SQL,保持只读访问奇成、幂等插入和线上手动执行模式。 + +**验证**: +- 不新增自动化测试或 `_test.go`。 +- 通过样例 CSV、生成的 `errors.csv` / `warnings.csv` / 审核 CSV、SQL 内容核对和测试库手动 SQL 查询验证。 diff --git a/openspec/changes/refactor-migration-ownership-package-flow/specs/qicheng-migration-control/spec.md b/openspec/changes/refactor-migration-ownership-package-flow/specs/qicheng-migration-control/spec.md new file mode 100644 index 0000000..ff92b2e --- /dev/null +++ b/openspec/changes/refactor-migration-ownership-package-flow/specs/qicheng-migration-control/spec.md @@ -0,0 +1,143 @@ +## ADDED Requirements + +### Requirement: 批量归属决策 + +奇成迁移脚本 SHALL 使用 `mapping.yaml` 的批量归属规则决定卡和设备的目标店铺。奇成 `agent_id` 只能作为可选参考信息输出到审核文件,MUST NOT 作为默认归属决策来源。 + +#### Scenario: 默认店铺分配设备 + +- **WHEN** `ownership_rules.device.mode = default_shop` 且 `default_target_shop_code = KWTX` +- **THEN** 所有未被 `overrides.devices` 覆盖的设备迁移 SQL 使用 `KWTX` 对应的 `tb_shop.id` 作为 `shop_id` +- **AND** `ownership_resolution.csv` 记录每台设备的决策来源为 `default_shop` + +#### Scenario: 默认店铺分配独立卡 + +- **WHEN** `ownership_rules.standalone_card.mode = default_shop` 且 `default_target_shop_code = KWTX` +- **THEN** 所有未绑定设备且未被 `overrides.cards` 覆盖的卡迁移 SQL 使用 `KWTX` 对应的 `tb_shop.id` 作为 `shop_id` + +#### Scenario: 覆盖项优先于批量规则 + +- **WHEN** 某设备在 `overrides.devices` 中配置了 `target_shop_code = OTHER` +- **THEN** 该设备使用 `OTHER` 作为目标店铺 +- **AND** 审核文件记录决策来源为 `override` + +#### Scenario: 目标店铺缺失阻断 + +- **WHEN** 规则要求资产分配到某个 `target_shop_code`,但新库不存在该店铺 +- **THEN** 脚本 MUST 在 `errors.csv` 写入该资产的错误 +- **AND** 脚本 MUST NOT 为该资产生成店铺分配 SQL + +### Requirement: 显式设备主卡和套餐来源槽位 + +奇成迁移脚本 SHALL 支持在输入数据或 `mapping.yaml` 中显式指定设备 `current_slot` 和 `package_source_slot`。未逐行指定时,脚本 SHALL 使用 `ownership_rules.device.current_slot` 和 `ownership_rules.device.package_source_slot` 作为批量默认值。 + +#### Scenario: 使用批量默认槽位 + +- **WHEN** `devices.csv` 某设备未填写 `current_slot` 和 `package_source_slot` +- **AND** `ownership_rules.device.current_slot = 2` +- **AND** `ownership_rules.device.package_source_slot = 2` +- **THEN** 该设备的当前绑定卡和套餐来源卡均使用 `sim_iccid_2` + +#### Scenario: 使用设备行覆盖槽位 + +- **WHEN** `devices.csv` 某设备填写 `current_slot = 1` 和 `package_source_slot = 1` +- **THEN** 该设备使用第 1 槽作为当前槽位和套餐来源槽位 +- **AND** 该行配置优先于批量默认槽位 + +#### Scenario: 槽位无卡阻断设备套餐 + +- **WHEN** 某设备的 `package_source_slot = 2`,但 `sim_iccid_2` 为空 +- **THEN** 脚本 MUST 在 `errors.csv` 写入该设备的错误 +- **AND** 脚本 MUST NOT 为该设备生成套餐使用记录 + +### Requirement: 单卡与设备迁移分支分离 + +奇成迁移脚本 SHALL 分离独立卡套餐迁移和设备套餐迁移。独立卡套餐 MUST 写入 `usage_type = single_card` 并关联 `iot_card_id`;设备套餐 MUST 写入 `usage_type = device` 并关联 `device_id`。 + +#### Scenario: 独立卡生成单卡套餐 + +- **WHEN** 某卡未绑定设备且命中可迁移套餐 +- **THEN** 脚本为该卡生成 `tb_order.order_type = single_card` +- **AND** 脚本为该卡生成 `tb_package_usage.usage_type = single_card` + +#### Scenario: 设备绑定卡生成设备套餐 + +- **WHEN** 某卡位于设备 `package_source_slot` +- **THEN** 脚本为该设备生成 `tb_order.order_type = device` +- **AND** 脚本为该设备生成 `tb_package_usage.usage_type = device` + +#### Scenario: 设备非来源槽位不重复生成套餐 + +- **WHEN** 某卡绑定在设备非 `package_source_slot` 的槽位 +- **THEN** 脚本 MUST NOT 为该卡单独生成主套餐使用记录 +- **AND** 该卡仍 SHALL 生成基础卡资产和设备绑定 SQL + +### Requirement: 当前生效和待生效套餐迁移 + +奇成迁移脚本 SHALL 迁移正式套餐中的当前生效套餐和未生效待生效套餐。当前生效套餐写入 `tb_package_usage.status = 1`;待生效套餐写入 `tb_package_usage.status = 0` 并设置稳定 `priority`。 + +#### Scenario: 当前生效套餐迁移为 active + +- **WHEN** 奇成某正式套餐处于当前生效状态 +- **THEN** 脚本生成的 `tb_package_usage.status = 1` +- **AND** `activated_at` 和 `expires_at` 来源于奇成套餐生命周期时间 + +#### Scenario: 未生效套餐迁移为 pending + +- **WHEN** 奇成某正式套餐属于未生效待生效套餐 +- **THEN** 脚本生成的 `tb_package_usage.status = 0` +- **AND** `data_usage_mb = 0` +- **AND** `priority` 按待生效顺序稳定递增 + +#### Scenario: 已过期套餐不迁移 + +- **WHEN** 奇成某正式套餐已过期且不属于当前生效或待生效范围 +- **THEN** 脚本 MUST NOT 为该套餐生成 `tb_package_usage` +- **AND** `package_resolution.csv` 记录跳过原因 + +#### Scenario: 目标套餐映射缺失阻断 + +- **WHEN** 某可迁移 legacy 套餐没有配置 `target_package_id` +- **THEN** 脚本 MUST 在 `errors.csv` 或 `package_resolution.csv` 中记录缺失映射 +- **AND** 脚本 MUST NOT 为该套餐生成套餐使用记录 + +### Requirement: 迁移审核产物 + +奇成迁移脚本 SHALL 在生成 SQL 时同步输出可审计 CSV,说明每个资产和套餐的最终决策。 + +#### Scenario: 输出归属审核文件 + +- **WHEN** 执行 `migrate_assets.py` +- **THEN** 脚本输出 `ownership_resolution.csv` +- **AND** 文件至少包含资产类型、资产标识、来源卡、目标店铺、决策来源、是否生成分配、原因 + +#### Scenario: 输出套餐审核文件 + +- **WHEN** 执行 `migrate_runtime.py` +- **THEN** 脚本输出 `package_resolution.csv` +- **AND** 文件至少包含资产类型、资产标识、来源卡、legacy 套餐 ID、legacy 套餐名称、目标套餐 ID、目标状态、优先级、决策、原因 + +#### Scenario: 摘要显示关键数量 + +- **WHEN** 脚本生成完成 +- **THEN** `summary.txt` MUST 展示卡数、设备数、分配资产数、active 套餐数、pending 套餐数、错误数和跳过数 + +### Requirement: 迁移脚本只读和幂等边界 + +奇成迁移脚本 SHALL 保持对奇成库只读,并生成可重复执行的 PostgreSQL SQL 文件。脚本 MUST NOT 直接写新库业务数据。 + +#### Scenario: 奇成连接只读 + +- **WHEN** 脚本连接奇成库查询元数据和套餐生命周期 +- **THEN** 连接 MUST 使用只读事务 + +#### Scenario: 生成 SQL 幂等 + +- **WHEN** 同一批次 SQL 被重复执行 +- **THEN** 插入类语句 SHALL 使用唯一键或条件确保不会重复创建相同资产、订单、套餐使用记录和分配记录 + +#### Scenario: 不直接写新库 + +- **WHEN** 执行 Python 迁移脚本 +- **THEN** 脚本只生成 SQL 和审核文件 +- **AND** 脚本 MUST NOT 直接向新库写入业务表 diff --git a/openspec/changes/refactor-migration-ownership-package-flow/tasks.md b/openspec/changes/refactor-migration-ownership-package-flow/tasks.md new file mode 100644 index 0000000..7733e40 --- /dev/null +++ b/openspec/changes/refactor-migration-ownership-package-flow/tasks.md @@ -0,0 +1,55 @@ +## 1. 配置与文档 + +- [x] 1.1 更新 `scripts/migration/config/mapping.yaml.example`,新增 `ownership_rules`、`package_rules`、`overrides` 示例 +- [x] 1.2 更新 `scripts/migration/resources/README.md`,说明 `current_slot`、`package_source_slot`、批量默认槽位和覆盖优先级 +- [x] 1.3 更新 `scripts/migration/README.md`,说明新执行流程、审核 CSV、错误阻断规则和手动验证方法 +- [x] 1.4 更新 `docs/脚本/奇成数据迁移方案.md`,将范围改为当前生效 + 未生效待生效套餐,并说明 mapping.yaml 是决策源 + +## 2. 配置加载与输入解析 + +- [x] 2.1 扩展 `lib/mapping_loader.py`,新增归属规则、套餐规则、覆盖规则的数据结构 +- [x] 2.2 在 `mapping_loader` 中实现配置校验:店铺码格式、槽位范围、套餐规则、覆盖项唯一性 +- [x] 2.3 扩展 `lib/csv_loader.py`,支持读取设备行级 `current_slot` 和 `package_source_slot` +- [x] 2.4 实现设备槽位解析优先级:设备行配置 > `overrides.devices` > `ownership_rules.device` 默认值 +- [x] 2.5 保留或显式拒绝旧配置结构,输出中文错误提示和升级指引 + +## 3. 奇成套餐生命周期查询 + +- [x] 3.1 在 `lib/legacy_query.py` 新增正式套餐生命周期查询函数,读取当前生效和未生效待生效套餐所需字段 +- [x] 3.2 查询结果保留 legacy 套餐 ID、套餐名、类型、状态、开始时间、到期时间和稳定排序键 +- [x] 3.3 排除加油包和已过期套餐,并在审核产物中记录跳过原因 +- [x] 3.4 处理同一资产多个 active 主套餐的冲突:无法唯一裁决时写入错误并阻断该资产套餐生成 + +## 4. 归属决策与资产 SQL + +- [x] 4.1 新增归属解析模块或函数,统一输出资产目标店铺、决策来源和错误 +- [x] 4.2 修改卡资产生成逻辑:独立卡按 `ownership_rules.standalone_card` 和覆盖项确定店铺 +- [x] 4.3 修改设备资产生成逻辑:设备按 `ownership_rules.device`、槽位配置和覆盖项确定店铺 +- [x] 4.4 修改设备绑定逻辑:`is_current` 使用解析后的 `current_slot`,不再固定默认 slot1 +- [x] 4.5 修改分配记录生成逻辑:只按归属解析结果生成,不再依赖奇成 `agent_id` + +## 5. 套餐队列 SQL + +- [x] 5.1 拆分独立卡套餐生成路径,生成 `single_card` 订单和套餐使用记录 +- [x] 5.2 拆分设备套餐生成路径,按 `package_source_slot` 生成 `device` 订单和套餐使用记录 +- [x] 5.3 为 active 套餐写入 `status=1`、生效/到期时间和用量快照 +- [x] 5.4 为 pending 套餐写入 `status=0`、稳定 `priority`、到期时间和 0 用量 +- [x] 5.5 设备和卡的 `series_id` 从目标套餐 `tb_package.series_id` 回填,并覆盖已有资产漏写场景 +- [x] 5.6 目标套餐不存在、已删除或不是 formal 时生成 SQL 前置校验或错误阻断 + +## 6. 审核产物与错误输出 + +- [x] 6.1 生成 `output/ownership_resolution.csv` +- [x] 6.2 生成 `output/package_resolution.csv` +- [x] 6.3 更新 `output/summary.txt`,展示分配数、active/pending 套餐数、跳过数和错误数 +- [x] 6.4 更新 `errors.csv` / `warnings.csv` 结构或内容,覆盖店铺缺失、槽位缺失、套餐映射缺失、目标套餐无效等关键错误 +- [x] 6.5 确保关键错误阻断对应资产或套餐 SQL,不再静默进入平台库存 + +## 7. 手动验证 + +- [x] 7.1 使用当前 120 台设备样例生成 SQL,确认 `ownership_resolution.csv` 显示 120 台设备归属到预期店铺 +- [x] 7.2 使用包含 current + pending 套餐的小样例生成 SQL,确认 `package_resolution.csv` 显示 active 和 pending 队列 +- [x] 7.3 检查 `step2_02_package_usages.sql`,确认 active 写 `status=1`,pending 写 `status=0` 且 priority 稳定递增 +- [x] 7.4 检查设备套餐 SQL,确认只为 `package_source_slot` 生成 device 级套餐,非来源槽位不重复生成主套餐 +- [ ] 7.5 在测试库手动执行生成 SQL,查询 `tb_device.shop_id`、`tb_iot_card.shop_id`、`tb_package_usage.status`、`tb_package_usage.priority` 和资产 `series_id` +- [x] 7.6 记录未验证项和已知限制,不新增自动化测试或 `_test.go` diff --git a/scripts/migration/config/mapping.yaml.kw b/scripts/migration/config/mapping.yaml.kw new file mode 100644 index 0000000..56a0367 --- /dev/null +++ b/scripts/migration/config/mapping.yaml.kw @@ -0,0 +1,122 @@ +# 奇成迁移映射配置(由 scan_legacy.py 生成/增量补全) +# +# 使用方式: +# 1. cards.csv 必填;devices.csv 可选。没有 devices.csv 时就是纯卡迁移。 +# 2. legacy_* 字段来自奇成扫描,一般不要手工修改。 +# 3. target_* 字段需要人工填写,否则生成 SQL 时会写入 errors.csv 并阻断对应资产。 +# 4. 资产归属先看 overrides,再看 ownership_rules;只有显式 legacy_agent 模式才读取 agents。 + +# ============== 全局配置 ============== +# 迁移操作者账号 ID,写入 creator/updater/operator_id。线上建议使用后台超级管理员 ID。 +migration_user_id: 0 +# 本次迁移批次号。每批线上迁移保持唯一,方便收尾、验收和回滚定位。 +migration_batch_no: "MIGRATION-QICHENG-20260615" + +# ============== 资产归属与槽位规则 ============== +# default_shop 模式下的批量目标店铺编码;mode=legacy_agent/none 时可保持 null。 +ownership_rules: + default_target_shop_code: KWTX + # 设备规则只在存在 resources/devices.csv 时生效。 + device: + # default_shop=默认店铺;legacy_agent=按默认/行级旧套餐读取槽位绑定卡的奇成 agent_id 映射店铺;none=平台库存。 + mode: "legacy_agent" + # 默认当前卡槽。devices.csv 未填 current_slot 时使用这里;写入 tb_device_sim_binding.is_current。 + current_slot: null + # 默认旧套餐读取卡槽。devices.csv 未填 package_source_slot 时使用这里;新系统套餐仍挂设备;不填则回退 current_slot。 + package_source_slot: null + standalone_card: + # default_shop=默认店铺;legacy_agent=按奇成 agent_id 映射店铺;none=平台库存。 + mode: "legacy_agent" + +# ============== 套餐迁移规则 ============== +package_rules: + # active=当前生效正式套餐;pending=未生效待生效正式套餐。 + migrate_statuses: + - "active" + - "pending" + +# ============== 少量资产级覆盖 ============== +# 覆盖优先级:devices.csv 行级槽位 > overrides.devices 槽位 > ownership_rules.device 默认槽位。 +overrides: + # 设备级特殊处理。virtual_no 对应 devices.csv 的 virtual_no/imei 兜底值。 + devices: [] + # 独立卡特殊处理。iccid 可填 19 位或 20 位。 + cards: [] + +# ============== 运营商映射 ============== +# scan_legacy 按奇成 tbl_card.account_id/account_name 分组写入;只需要人工填写 target_*。 +# target_carrier_type 只能填 CMCC / CUCC / CTCC / CBN。 +carriers: + - legacy_account_id: "64E0F6057D6749ABB211E11D51C3F1AA" + legacy_account_name: "新移动15-1" + legacy_category: 126 + legacy_category_name: "GS移动" + target_carrier_id: 2 + target_carrier_type: CMCC + target_carrier_name: 移动15-BE20030985258 + - legacy_account_id: "811CA4BF74E24CD7AAEC3C54EF1BAE63" + legacy_account_name: "新移动15-2" + legacy_category: 126 + legacy_category_name: "GS移动" + target_carrier_id: 2 + target_carrier_type: CMCC + target_carrier_name: 移动15-BE20030985258 + - legacy_account_id: "6A6D70450525448780AD4C3CCE6D4B29" + legacy_account_name: "电信10-1" + legacy_category: 124 + legacy_category_name: "GS电信" + target_carrier_id: 7 + target_carrier_type: CTCC + target_carrier_name: 电信10-qiangukeji + - legacy_account_id: "B0C93CB44C184DFDA230DF0E574A395F" + legacy_account_name: "电信9" + legacy_category: 124 + legacy_category_name: "GS电信" + target_carrier_id: 4 + target_carrier_type: CTCC + target_carrier_name: 电信5-junhong004 + - legacy_account_id: "ED12247823374841934283AAD40CCA9D" + legacy_account_name: "联通15-1(3T)" + legacy_category: 125 + legacy_category_name: "GS联通" + target_carrier_id: 3 + target_carrier_type: CUCC + target_carrier_name: 联通31-710WLW17915117 + - legacy_account_id: "648D3C383B904D9686EC8650D5F20A7C" + legacy_account_name: "联通38" + legacy_category: 125 + legacy_category_name: "GS联通" + target_carrier_id: 3 + target_carrier_type: CUCC + target_carrier_name: 联通31-710WLW17915117 + +# ============== 套餐映射 ============== +# scan_legacy 按奇成 tbl_card_life.meal_id 分组写入;target_package_id 必须指向新库正式套餐。 +packages: + - legacy_meal_id: "D6695908379395072" + legacy_meal_name: "特惠畅享月卡套餐30G/月(1个月)" + target_package_id: 40 + - legacy_meal_id: "D6695911346816000" + legacy_meal_name: "特惠畅享月卡套餐100G/月(1个月)" + target_package_id: 39 + - legacy_meal_id: "D6695913990718464" + legacy_meal_name: "特惠畅享月卡套餐1000G/月(1个月)" + target_package_id: 37 + - legacy_meal_id: "D6695917828850688" + legacy_meal_name: "特惠畅享年卡套餐3000G/月(12个月)" + target_package_id: 34 + +# ============== 套餐系列快照(可选) ============== +# 资产 series_id 优先从 target_package_id 对应的 tb_package.series_id 反查;target_series_id 可作无套餐时的兜底。 +series: + - legacy_series_id: "61721B92E3FF4F53834AC16F4214667C" + legacy_series_name: "2026新春特惠套餐" + target_series_id: 5 + +# ============== 代理-店铺映射(legacy_agent 归属 + 代理钱包) ============== +# standalone_card/device mode=legacy_agent 时,资产按这里的 target_shop_code 分配。 +# 不使用 legacy_agent 时,这里只影响 migrate_runtime.py 的代理钱包初始化。 +agents: + - legacy_agent_id: "0072250EBB88422C88A573AB06870D17" + legacy_agent_name: "酷蛙通讯" + target_shop_code: KWTX