From 80c6f6c7560ae615907816590a2758ae0617d73b Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 7 Apr 2026 17:39:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B5=84=E4=BA=A7=E6=A0=87=E8=AF=86?= =?UTF-8?q?=E7=AC=A6=E6=A0=87=E5=87=86=E5=8C=96=E3=80=81=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=AE=A2=E5=8D=95=E6=9F=A5=E8=AF=A2=E5=8F=8A?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=99=9A=E6=8B=9F=E5=8F=B7=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 新增 AssetIdentifier 模型及 Store,统一管理资产标识符(ICCID/IMEI/SN 等) - 新增迁移:asset_identifier 表、order 表新增 asset_identifier 字段、iot_card.virtual_no NOT NULL 约束 - 资产 Handler/Service/Route 全面重构,支持标识符路由查询与解析 - 新增资产历史订单查询接口,支持跨设备/卡/钱包维度的订单聚合 - 设备与物联卡导入任务强制校验虚拟号,缺失时直接拒绝 - Excel 工具函数优化,前端导入指引文档同步更新 - 归档三个 OpenSpec 提案:asset-identifier-standardization、asset-historical-orders、import-mandatory-virtual-no - 更新 OpenAPI 文档及相关 DTO Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/admin-openapi.yaml | 817 +++++++++--------- docs/excel-import-frontend-guide.md | 46 +- internal/bootstrap/handlers.go | 16 +- internal/bootstrap/stores.go | 3 + internal/bootstrap/worker_services.go | 1 + internal/bootstrap/worker_stores.go | 3 + internal/handler/admin/asset.go | 238 +++-- internal/handler/admin/asset_wallet.go | 53 +- internal/handler/admin/device.go | 68 +- internal/model/asset_identifier.go | 24 + internal/model/dto/asset_dto.go | 73 +- internal/model/dto/asset_wallet_dto.go | 3 +- internal/model/dto/device_dto.go | 15 +- internal/model/dto/order_dto.go | 9 +- internal/model/iot_card.go | 2 +- internal/model/order.go | 5 +- internal/routes/asset.go | 72 +- internal/routes/asset_lifecycle.go | 20 +- internal/routes/device.go | 10 +- internal/routes/iot_card.go | 14 +- internal/service/asset/service.go | 259 +++++- internal/service/device/service.go | 30 +- internal/service/iot_card/service.go | 17 +- internal/service/order/service.go | 128 +-- .../service/purchase_validation/service.go | 8 + .../store/postgres/asset_identifier_store.go | 51 ++ .../store/postgres/exchange_order_store.go | 18 + internal/store/postgres/order_store.go | 25 + internal/task/device_import.go | 36 +- internal/task/iot_card_import.go | 162 ++-- .../000105_create_asset_identifier.down.sql | 2 + .../000105_create_asset_identifier.up.sql | 14 + ...106_add_asset_identifier_to_order.down.sql | 1 + ...00106_add_asset_identifier_to_order.up.sql | 2 + ...0107_iot_card_virtual_no_not_null.down.sql | 11 + ...000107_iot_card_virtual_no_not_null.up.sql | 15 + .../.openspec.yaml | 2 + .../design.md | 75 ++ .../proposal.md | 33 + .../specs/asset-historical-orders/spec.md | 108 +++ .../tasks.md | 54 ++ .../.openspec.yaml | 2 + .../design.md | 131 +++ .../proposal.md | 53 ++ .../specs/asset-identifier-registry/spec.md | 46 + .../specs/asset-identifier-routes/spec.md | 78 ++ .../specs/asset-resolve/spec.md | 69 ++ .../specs/client-order-purchase/spec.md | 35 + .../specs/order-management/spec.md | 66 ++ .../specs/package-purchase-validation/spec.md | 19 + .../tasks.md | 81 ++ .../.openspec.yaml | 2 + .../design.md | 64 ++ .../proposal.md | 38 + .../specs/device-import/spec.md | 22 + .../specs/iot-card-import-task/spec.md | 40 + .../tasks.md | 66 ++ .../specs/asset-historical-orders/spec.md | 106 +++ .../specs/asset-identifier-registry/spec.md | 52 ++ .../specs/asset-identifier-routes/spec.md | 86 ++ openspec/specs/asset-resolve/spec.md | 30 +- openspec/specs/client-order-purchase/spec.md | 32 +- openspec/specs/device-import/spec.md | 26 +- openspec/specs/iot-card-import-task/spec.md | 46 +- openspec/specs/order-management/spec.md | 65 +- .../specs/package-purchase-validation/spec.md | 20 + pkg/queue/handler.go | 2 + pkg/queue/types.go | 1 + pkg/utils/excel.go | 69 +- 69 files changed, 3039 insertions(+), 851 deletions(-) create mode 100644 internal/model/asset_identifier.go create mode 100644 internal/store/postgres/asset_identifier_store.go create mode 100644 migrations/000105_create_asset_identifier.down.sql create mode 100644 migrations/000105_create_asset_identifier.up.sql create mode 100644 migrations/000106_add_asset_identifier_to_order.down.sql create mode 100644 migrations/000106_add_asset_identifier_to_order.up.sql create mode 100644 migrations/000107_iot_card_virtual_no_not_null.down.sql create mode 100644 migrations/000107_iot_card_virtual_no_not_null.up.sql create mode 100644 openspec/changes/archive/2026-04-07-asset-historical-orders/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-07-asset-historical-orders/design.md create mode 100644 openspec/changes/archive/2026-04-07-asset-historical-orders/proposal.md create mode 100644 openspec/changes/archive/2026-04-07-asset-historical-orders/specs/asset-historical-orders/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-historical-orders/tasks.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/design.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/proposal.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-registry/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-routes/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-resolve/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/client-order-purchase/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/order-management/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/package-purchase-validation/spec.md create mode 100644 openspec/changes/archive/2026-04-07-asset-identifier-standardization/tasks.md create mode 100644 openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/design.md create mode 100644 openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/proposal.md create mode 100644 openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/device-import/spec.md create mode 100644 openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/iot-card-import-task/spec.md create mode 100644 openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/tasks.md create mode 100644 openspec/specs/asset-historical-orders/spec.md create mode 100644 openspec/specs/asset-identifier-registry/spec.md create mode 100644 openspec/specs/asset-identifier-routes/spec.md diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index d7da6cd..8b6f80c 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -706,6 +706,73 @@ components: description: 钱包余额(分) type: integer type: object + DtoAssetOrderItem: + properties: + created_at: + description: 订单创建时间 + format: date-time + type: string + generation: + description: 订单所属资产世代编号 + type: integer + items: + description: 套餐明细列表 + items: + $ref: '#/components/schemas/DtoAssetOrderItemDetail' + nullable: true + type: array + order_no: + description: 订单号 + type: string + order_type: + description: 订单类型:single_card/device + type: string + paid_at: + description: 支付时间 + format: date-time + nullable: true + type: string + payment_method: + description: 支付方式:wallet/wechat/alipay/offline + type: string + payment_status: + description: 支付状态:1待支付 2已支付 3已取消 4已退款 + type: integer + payment_status_text: + description: 支付状态文本 + type: string + total_amount: + description: 订单金额(分) + type: integer + type: object + DtoAssetOrderItemDetail: + properties: + amount: + description: 小计金额(分) + type: integer + package_name: + description: 套餐名称快照 + type: string + quantity: + description: 数量 + type: integer + unit_price: + description: 单价(分) + type: integer + type: object + DtoAssetOrdersResponse: + properties: + current_generation: + $ref: '#/components/schemas/DtoGenerationOrders' + previous_generations: + description: 前代订单列表(include_previous=true时返回) + items: + $ref: '#/components/schemas/DtoPreviousGenerationOrders' + type: array + truncated: + description: 是否已截断(换货链超过10代追溯上限时为true) + type: boolean + type: object DtoAssetPackageHistoryResponse: properties: items: @@ -956,6 +1023,9 @@ components: iccid: description: 卡ICCID(asset_type=card时有效) type: string + identifier: + description: 解析时使用的标识符 + type: string imei: description: 设备IMEI type: string @@ -3666,32 +3736,6 @@ components: description: 设备虚拟号 type: string type: object - DtoDeviceSuspendFailItem: - properties: - iccid: - description: 卡ICCID - type: string - reason: - description: 失败原因 - type: string - type: object - DtoDeviceSuspendResponse: - properties: - fail_count: - description: 失败卡数 - type: integer - failed_items: - description: 失败详情 - items: - $ref: '#/components/schemas/DtoDeviceSuspendFailItem' - type: array - skip_count: - description: 跳过卡数(未实名或已停机) - type: integer - success_count: - description: 成功停机卡数 - type: integer - type: object DtoDeviceSwitchCardRequest: properties: identifier: @@ -4089,6 +4133,33 @@ components: description: 失败原因 type: string type: object + DtoGenerationOrders: + properties: + asset_type: + description: 资产类型:card 或 device + type: string + generation: + description: 世代编号 + type: integer + identifier: + description: 资产标识符 + type: string + items: + description: 订单列表 + items: + $ref: '#/components/schemas/DtoAssetOrderItem' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页条数 + type: integer + total: + description: 总订单数 + type: integer + type: object DtoGetUploadURLRequest: properties: content_type: @@ -4802,6 +4873,12 @@ components: description: 实际支付金额(分) nullable: true type: integer + asset_identifier: + description: 下单时资产的标识符快照(ICCID 或 VirtualNo) + type: string + asset_type: + description: 资产类型 (card:单卡, device:设备) + type: string buyer_id: description: 买家ID minimum: 0 @@ -5503,6 +5580,34 @@ components: description: 1小时总数 type: integer type: object + DtoPreviousGenerationOrders: + properties: + asset_type: + description: 前代资产类型:card 或 device + type: string + exchange_no: + description: 换货单号 + type: string + exchanged_at: + description: 换货完成时间 + format: date-time + type: string + generation: + description: 前代世代编号 + type: integer + identifier: + description: 前代资产标识符 + type: string + items: + description: 前代订单列表(最多20条) + items: + $ref: '#/components/schemas/DtoAssetOrderItem' + nullable: true + type: array + total: + description: 前代总订单数 + type: integer + type: object DtoPriceAdjustment: properties: type: @@ -9021,24 +9126,16 @@ paths: summary: 分配记录详情 tags: - 资产分配记录 - /api/admin/assets/{asset_type}/{id}/current-package: + /api/admin/assets/{identifier}/current-package: get: parameters: - - description: 资产类型:card 或 device + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型:card 或 device + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID - in: path - name: id - required: true - schema: - description: 资产ID - minimum: 0 - type: integer responses: "200": content: @@ -9095,9 +9192,50 @@ paths: summary: 当前生效套餐 tags: - 资产管理 - /api/admin/assets/{asset_type}/{id}/packages: + /api/admin/assets/{identifier}/deactivate: + patch: + description: 将资产状态设置为停用(不可逆)。仅支持库存中或已售出的资产。 + parameters: + - description: 资产标识符(ICCID 或 VirtualNo) + in: path + name: identifier + required: true + schema: + description: 资产标识符(ICCID 或 VirtualNo) + type: string + 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: + - BearerAuth: [] + summary: 停用资产 + tags: + - 资产管理 + /api/admin/assets/{identifier}/orders: get: - description: 查询该资产所有套餐记录,含虚流量换算结果。支持分页(默认 page=1, page_size=50, 最大100)。 + description: 查询资产本代历史订单,支持 include_previous=true 参数通过换货链追溯前代订单(最多10代)。 parameters: - description: 页码(默认1) in: query @@ -9106,29 +9244,94 @@ paths: description: 页码(默认1) minimum: 1 type: integer - - description: 每页条数(默认50,最大100) + - description: 每页条数(默认20,最大100) in: query name: page_size schema: - description: 每页条数(默认50,最大100) + description: 每页条数(默认20,最大100) maximum: 100 minimum: 1 type: integer - - description: 资产类型:card 或 device + - description: 是否包含前代订单,默认false + in: query + name: include_previous + schema: + description: 是否包含前代订单,默认false + type: boolean + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型:card 或 device + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetOrdersResponse' + 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: + get: + description: 查询该资产所有套餐记录,含虚流量换算结果。支持分页。 + parameters: + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: id + name: identifier required: true schema: - description: 资产ID - minimum: 0 - type: integer + description: 资产标识符(ICCID 或 VirtualNo) + type: string responses: "200": content: @@ -9185,25 +9388,17 @@ paths: summary: 资产套餐列表 tags: - 资产管理 - /api/admin/assets/{asset_type}/{id}/polling-status: + /api/admin/assets/{identifier}/polling-status: patch: - description: 启用或禁用指定资产(IoT卡或设备)的定期状态轮询。asset_type 为 iot_card 或 device。 + description: 启用或禁用指定资产(IoT卡或设备)的定期状态轮询。 parameters: - - description: 资产类型 (iot_card:IoT卡, device:设备) + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型 (iot_card:IoT卡, device:设备) + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID - in: path - name: id - required: true - schema: - description: 资产ID - minimum: 0 - type: integer requestBody: content: application/json: @@ -9265,25 +9460,17 @@ paths: summary: 更新资产轮询状态 tags: - 资产管理 - /api/admin/assets/{asset_type}/{id}/realtime-status: + /api/admin/assets/{identifier}/realtime-status: get: - description: 读取 DB/Redis 中的持久化状态,不调网关。asset_type 为 card 或 device。 + description: 读取 DB/Redis 中的持久化状态,不调网关。 parameters: - - description: 资产类型:card 或 device + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型:card 或 device + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID - in: path - name: id - required: true - schema: - description: 资产ID - minimum: 0 - type: integer responses: "200": content: @@ -9340,25 +9527,17 @@ paths: summary: 资产实时状态 tags: - 资产管理 - /api/admin/assets/{asset_type}/{id}/refresh: + /api/admin/assets/{identifier}/refresh: post: description: 主动调网关同步最新状态。设备有30秒冷却期。 parameters: - - description: 资产类型:card 或 device + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型:card 或 device + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID - in: path - name: id - required: true - schema: - description: 资产ID - minimum: 0 - type: integer responses: "200": content: @@ -9415,25 +9594,99 @@ paths: summary: 刷新资产状态 tags: - 资产管理 - /api/admin/assets/{asset_type}/{id}/wallet: + /api/admin/assets/{identifier}/start: + post: + description: 设备批量复机;单卡手动复机。 + parameters: + - description: 资产标识符(ICCID 或 VirtualNo) + in: path + name: identifier + required: true + schema: + description: 资产标识符(ICCID 或 VirtualNo) + type: string + 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: + - BearerAuth: [] + summary: 复机 + tags: + - 资产管理 + /api/admin/assets/{identifier}/stop: + post: + description: 设备批量停机设备下所有已实名卡;单卡手动停机。 + parameters: + - description: 资产标识符(ICCID 或 VirtualNo) + in: path + name: identifier + required: true + schema: + description: 资产标识符(ICCID 或 VirtualNo) + type: string + 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: + - BearerAuth: [] + summary: 停机 + tags: + - 资产管理 + /api/admin/assets/{identifier}/wallet: get: description: 查询指定卡或设备的钱包余额概况。企业账号禁止调用。 parameters: - - description: 资产类型:card 或 device + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型:card 或 device + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID - in: path - name: id - required: true - schema: - description: 资产ID - minimum: 0 - type: integer responses: "200": content: @@ -9490,7 +9743,7 @@ paths: summary: 资产钱包概况 tags: - 资产管理 - /api/admin/assets/{asset_type}/{id}/wallet/transactions: + /api/admin/assets/{identifier}/wallet/transactions: get: description: 分页查询指定资产的钱包收支流水,含充值/扣款来源编号。企业账号禁止调用。 parameters: @@ -9529,21 +9782,13 @@ paths: format: date-time nullable: true type: string - - description: 资产类型:card 或 device + - description: 资产标识符(ICCID 或 VirtualNo) in: path - name: asset_type + name: identifier required: true schema: - description: 资产类型:card 或 device + description: 资产标识符(ICCID 或 VirtualNo) type: string - - description: 资产ID - in: path - name: id - required: true - schema: - description: 资产ID - minimum: 0 - type: integer responses: "200": content: @@ -9600,198 +9845,6 @@ paths: summary: 资产钱包流水列表 tags: - 资产管理 - /api/admin/assets/card/{iccid}/start: - post: - description: 手动复机单张卡(通过ICCID)。受设备保护期约束。 - parameters: - - description: 卡ICCID - in: path - name: iccid - required: true - schema: - description: 卡ICCID - type: string - 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: - - BearerAuth: [] - summary: 单卡复机 - tags: - - 资产管理 - /api/admin/assets/card/{iccid}/stop: - post: - description: 手动停机单张卡(通过ICCID)。受设备保护期约束。 - parameters: - - description: 卡ICCID - in: path - name: iccid - required: true - schema: - description: 卡ICCID - type: string - 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: - - BearerAuth: [] - summary: 单卡停机 - tags: - - 资产管理 - /api/admin/assets/device/{device_id}/start: - post: - description: 批量复机设备下所有已实名卡。设置1小时复机保护期。 - parameters: - - description: 设备ID - in: path - name: device_id - required: true - schema: - description: 设备ID - minimum: 0 - type: integer - 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: - - BearerAuth: [] - summary: 设备复机 - tags: - - 资产管理 - /api/admin/assets/device/{device_id}/stop: - post: - description: 批量停机设备下所有已实名卡。设置1小时停机保护期。 - parameters: - - description: 设备ID - in: path - name: device_id - required: true - schema: - description: 设备ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoDeviceSuspendResponse' - 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/resolve/{identifier}: get: description: 通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。 @@ -11639,18 +11692,17 @@ paths: summary: 设备列表 tags: - 设备管理 - /api/admin/devices/{id}: + /api/admin/devices/{virtual_no}: delete: description: 仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。 parameters: - - description: 设备ID + - description: 设备虚拟号 in: path - name: id + name: virtual_no required: true schema: - description: 设备ID - minimum: 0 - type: integer + description: 设备虚拟号 + type: string responses: "400": content: @@ -11681,17 +11733,16 @@ paths: summary: 删除设备 tags: - 设备管理 - /api/admin/devices/{id}/cards: + /api/admin/devices/{virtual_no}/cards: get: parameters: - - description: 设备ID + - description: 设备虚拟号 in: path - name: id + name: virtual_no required: true schema: - description: 设备ID - minimum: 0 - type: integer + description: 设备虚拟号 + type: string responses: "200": content: @@ -11751,14 +11802,13 @@ paths: post: description: 仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。 parameters: - - description: 设备ID + - description: 设备虚拟号 in: path - name: id + name: virtual_no required: true schema: - description: 设备ID - minimum: 0 - type: integer + description: 设备虚拟号 + type: string requestBody: content: application/json: @@ -11820,26 +11870,24 @@ paths: summary: 绑定卡到设备 tags: - 设备管理 - /api/admin/devices/{id}/cards/{cardId}: + /api/admin/devices/{virtual_no}/cards/{iccid}: delete: description: 仅平台用户可操作。解绑不改变卡的 shop_id。 parameters: - - description: 设备ID + - description: 设备虚拟号 in: path - name: id + name: virtual_no required: true schema: - description: 设备ID - minimum: 0 - type: integer - - description: IoT卡ID + description: 设备虚拟号 + type: string + - description: IoT卡ICCID in: path - name: cardId + name: iccid required: true schema: - description: IoT卡ID - minimum: 0 - type: integer + description: IoT卡ICCID + type: string responses: "200": content: @@ -11896,47 +11944,6 @@ paths: summary: 解绑设备上的卡 tags: - 设备管理 - /api/admin/devices/{id}/deactivate: - patch: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - 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: - - BearerAuth: [] - summary: 手动停用设备 - tags: - - 设备管理 /api/admin/devices/allocate: post: description: 分配设备给直属下级店铺。分配时自动同步绑定的所有卡的 shop_id。 @@ -12433,7 +12440,7 @@ paths: - 文件格式:仅支持 .xlsx (Excel 2007+) - 必须包含列(首行为表头): - - `virtual_no`: 设备虚拟号(必填,全局唯一) + - `virtual_no`: 设备虚拟号(**必填**,全局唯一;**为空的行记为失败,不再静默跳过**) - `device_name`: 设备名称 - `device_model`: 设备型号 - `device_type`: 设备类型 @@ -14121,47 +14128,6 @@ paths: summary: 获取实名认证链接 tags: - IoT卡管理 - /api/admin/iot-cards/{id}/deactivate: - patch: - parameters: - - description: ID - in: path - name: id - required: true - schema: - description: ID - minimum: 0 - type: integer - 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: - - BearerAuth: [] - summary: 手动停用IoT卡 - tags: - - IoT卡管理 /api/admin/iot-cards/import: post: description: |- @@ -14191,16 +14157,18 @@ paths: ### Excel 文件格式 - 文件格式:仅支持 .xlsx (Excel 2007+) - - 必填列:`ICCID`, `MSISDN` - - 可选列:`virtual_no`(虚拟号,表头关键字:virtual_no / VirtualNo / 虚拟号 / 设备号) + - 必填列(表头关键字): + - `ICCID`(或:卡号 / 号码) + - `MSISDN`(或:接入号 / 手机号 / 电话) + - `virtual_no`(或:VirtualNo / 虚拟号 / 设备号)—— **必填,缺少此列将导致整批导入失败** - 首行为表头(可选,但建议包含) - 列格式:设置为文本格式(避免长数字被转为科学记数法) - #### virtual_no 列导入规则(只补空白) + #### virtual_no 导入规则 - - 该行 virtual_no 非空 + 数据库当前值为 NULL:填入新值 - - 该行 virtual_no 非空 + 数据库已有值:跳过(不覆盖) - - 批次内有任意 virtual_no 与数据库现存值重复:**整批拒绝** + - `virtual_no` 为必填字段,**不可为空**;为空的行记为失败并报告原因 + - Excel 缺少 `virtual_no` 列时,整批导入直接失败(返回"Excel 文件缺少 virtual_no 列,请使用最新模板") + - `virtual_no` 与系统已有值重复时,该行记为失败 requestBody: content: application/json: @@ -15360,6 +15328,13 @@ paths: description: 是否已过期 (true:已过期, false:未过期) nullable: true type: boolean + - description: 资产标识符(ICCID 或 VirtualNo)精确查询 + in: query + name: identifier + schema: + description: 资产标识符(ICCID 或 VirtualNo)精确查询 + maxLength: 100 + type: string responses: "200": content: diff --git a/docs/excel-import-frontend-guide.md b/docs/excel-import-frontend-guide.md index 4020314..da0d9fd 100644 --- a/docs/excel-import-frontend-guide.md +++ b/docs/excel-import-frontend-guide.md @@ -90,30 +90,52 @@ function validateFile(file) { **文件名**: `iccid_import_template.xlsx` **格式**: -| ICCID | MSISDN | -|-------|--------| -| 89860012345678901234 | 13800000001 | -| 89860012345678901235 | 13800000002 | +| ICCID | MSISDN | virtual_no | +|-------|--------|------------| +| 89860012345678901234 | 13800000001 | VNO-00001 | +| 89860012345678901235 | 13800000002 | VNO-00002 | + +**字段说明**: + +| 列名 | 是否必填 | 说明 | +|------|---------|------| +| ICCID | **是** | 卡唯一标识,电信19位/其他20位 | +| MSISDN | **是** | 接入号 | +| virtual_no | **是** | 虚拟号,全局唯一;留空将导致该行导入失败 | **要点**: -- 必须包含表头行(ICCID, MSISDN) -- ICCID和MSISDN列必须设置为**文本格式**(重要!) +- 必须包含表头行(ICCID, MSISDN, virtual_no) +- 所有列必须设置为**文本格式**(重要!) - Excel中设置文本格式: 选中列 → 右键 → 设置单元格格式 → 文本 +- `virtual_no` 为必填列,**缺少该列将导致整批导入失败** ### 设备导入模板 **文件名**: `device_import_template.xlsx` **格式**: -| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | iccid_1 | iccid_2 | iccid_3 | iccid_4 | -|-----------|-------------|--------------|-------------|---------------|--------------|---------|---------|---------|---------| -| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 89860012345678901234 | 89860012345678901235 | | | -| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 89860012345678901236 | | | | +| virtual_no | device_name | device_model | device_type | max_sim_slots | manufacturer | imei | iccid_1 | iccid_2 | iccid_3 | iccid_4 | +|------------|-------------|--------------|-------------|---------------|--------------|------|---------|---------|---------|---------| +| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 123456789012345 | 89860012345678901234 | 89860012345678901235 | | | +| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 123456789012346 | 89860012345678901236 | | | | + +**字段说明**: + +| 列名 | 是否必填 | 说明 | +|------|---------|------| +| virtual_no | **是** | 设备虚拟号,全局唯一;**留空不再跳过,将记录为失败行** | +| device_name | 否 | 设备名称 | +| device_model | 否 | 设备型号 | +| device_type | 否 | 设备类型 | +| max_sim_slots | 否 | 最大SIM卡槽数,默认4 | +| manufacturer | 否 | 制造商 | +| imei | 否 | 设备IMEI | +| iccid_1 ~ iccid_4 | 否 | 绑定的IoT卡ICCID,填写时对应ICCID必须已存在于系统中 | **要点**: - 所有列都必须设置为**文本格式** -- device_no为必填项 -- iccid_1 ~ iccid_4为可选项,填写时对应的ICCID必须已存在于系统中 +- `virtual_no` 为必填项,留空将记录为失败行(而非静默跳过) +- iccid_1 ~ iccid_4为可选项,填写时对应的ICCID必须已存在于系统中 ## 模板下载功能实现 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 01cff03..81e0c80 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -97,12 +97,18 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { Asset: func() *admin.AssetHandler { pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) assetPollingSvc := pollingSvcPkg.NewAssetPollingService(deviceStore, deviceSimBindingStore, svc.IotCard, pollingQueueMgr, deps.Logger) - return admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService, assetPollingSvc) + h := admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService, assetPollingSvc) + h.SetLifecycleService(svc.AssetLifecycle) + return h }(), AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), - AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet), - WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), - AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), - Refund: admin.NewRefundHandler(svc.Refund), + AssetWallet: func() *admin.AssetWalletHandler { + h := admin.NewAssetWalletHandler(svc.AssetWallet) + h.SetAssetService(svc.Asset) + return h + }(), + WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), + AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), + Refund: admin.NewRefundHandler(svc.Refund), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index b50a3e8..8f8599c 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -63,6 +63,8 @@ type stores struct { RefundRequest *postgres.RefundStore // 流量系统 CardDailyUsage *postgres.CardDailyUsageStore + // 资产标识符注册表 + AssetIdentifier *postgres.AssetIdentifierStore } func initStores(deps *Dependencies) *stores { @@ -122,5 +124,6 @@ func initStores(deps *Dependencies) *stores { WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis), RefundRequest: postgres.NewRefundStore(deps.DB), CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), + AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), } } diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 1f7d45a..357959e 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -100,6 +100,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q nil, // wechatPayment: 超时取消不需要 nil, // queueClient: 超时取消不触发分佣 deps.Logger, + stores.AssetIdentifier, ) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index 17a03ca..b7a2399 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -29,6 +29,7 @@ type workerStores struct { AgentWallet *postgres.AgentWalletStore AgentWalletTransaction *postgres.AgentWalletTransactionStore AssetWallet *postgres.AssetWalletStore + AssetIdentifier *postgres.AssetIdentifierStore } func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { @@ -56,6 +57,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), + AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), } return &queue.WorkerStores{ @@ -82,5 +84,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { AgentWallet: stores.AgentWallet, AgentWalletTransaction: stores.AgentWalletTransaction, AssetWallet: stores.AssetWallet, + AssetIdentifier: stores.AssetIdentifier, } } diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 3d54458..40ca10d 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -1,8 +1,6 @@ package admin import ( - "strconv" - "github.com/gofiber/fiber/v2" dto "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -17,12 +15,13 @@ import ( ) // AssetHandler 资产管理处理器 -// 提供统一的资产解析、实时状态、套餐查询、停复机、轮询管控等接口 +// 提供统一的资产解析、实时状态、套餐查询、停复机、停用、轮询管控等接口 type AssetHandler struct { - assetService *assetService.Service - deviceService *deviceService.Service - iotCardStopResume *iotCardService.StopResumeService - assetPolling *pollingSvc.AssetPollingService + assetService *assetService.Service + deviceService *deviceService.Service + iotCardStopResume *iotCardService.StopResumeService + assetPolling *pollingSvc.AssetPollingService + assetLifecycleService AssetLifecycleService } // NewAssetHandler 创建资产管理处理器 @@ -40,6 +39,20 @@ func NewAssetHandler( } } +// SetLifecycleService 注入生命周期服务(用于 Deactivate 接口) +func (h *AssetHandler) SetLifecycleService(svc AssetLifecycleService) { + h.assetLifecycleService = svc +} + +// resolveByIdentifier 通过标识符解析资产,用于各处理器的公共逻辑 +func (h *AssetHandler) resolveByIdentifier(c *fiber.Ctx) (*dto.AssetResolveResponse, error) { + identifier := c.Params("identifier") + if identifier == "" { + return nil, errors.New(errors.CodeInvalidParam, "标识符不能为空") + } + return h.assetService.Resolve(c.UserContext(), identifier) +} + // Resolve 通过任意标识符解析资产(设备或卡) // GET /api/admin/assets/resolve/:identifier func (h *AssetHandler) Resolve(c *fiber.Ctx) error { @@ -57,15 +70,14 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error { } // RealtimeStatus 获取资产实时状态 -// GET /api/admin/assets/:asset_type/:id/realtime-status +// GET /api/admin/assets/:identifier/realtime-status func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error { - assetType := c.Params("asset_type") - id, err := strconv.ParseUint(c.Params("id"), 10, 64) + asset, err := h.resolveByIdentifier(c) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的资产ID") + return err } - result, err := h.assetService.GetRealtimeStatus(c.UserContext(), assetType, uint(id)) + result, err := h.assetService.GetRealtimeStatus(c.UserContext(), asset.AssetType, asset.AssetID) if err != nil { return err } @@ -74,20 +86,19 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error { } // Refresh 刷新资产状态(调网关同步) -// POST /api/admin/assets/:asset_type/:id/refresh +// POST /api/admin/assets/:identifier/refresh func (h *AssetHandler) Refresh(c *fiber.Ctx) error { - // 企业账号只读,不允许主动触发运营商刷新 userType := middleware.GetUserTypeFromContext(c.UserContext()) if userType == constants.UserTypeEnterprise { return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态") } - assetType := c.Params("asset_type") - id, err := strconv.ParseUint(c.Params("id"), 10, 64) + + asset, err := h.resolveByIdentifier(c) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的资产ID") + return err } - result, err := h.assetService.Refresh(c.UserContext(), assetType, uint(id)) + result, err := h.assetService.Refresh(c.UserContext(), asset.AssetType, asset.AssetID) if err != nil { return err } @@ -96,18 +107,17 @@ func (h *AssetHandler) Refresh(c *fiber.Ctx) error { } // Packages 获取资产所有套餐列表(支持分页,默认 page=1 pageSize=50) -// GET /api/admin/assets/:asset_type/:id/packages +// GET /api/admin/assets/:identifier/packages func (h *AssetHandler) Packages(c *fiber.Ctx) error { - assetType := c.Params("asset_type") - id, err := strconv.ParseUint(c.Params("id"), 10, 64) + asset, err := h.resolveByIdentifier(c) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的资产ID") + return err } page := c.QueryInt("page", 1) pageSize := c.QueryInt("page_size", 50) - result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id), page, pageSize) + result, err := h.assetService.GetPackages(c.UserContext(), asset.AssetType, asset.AssetID, page, pageSize) if err != nil { return err } @@ -116,15 +126,14 @@ func (h *AssetHandler) Packages(c *fiber.Ctx) error { } // CurrentPackage 获取资产当前生效套餐 -// GET /api/admin/assets/:asset_type/:id/current-package +// GET /api/admin/assets/:identifier/current-package func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error { - assetType := c.Params("asset_type") - id, err := strconv.ParseUint(c.Params("id"), 10, 64) + asset, err := h.resolveByIdentifier(c) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的资产ID") + return err } - result, err := h.assetService.GetCurrentPackage(c.UserContext(), assetType, uint(id)) + result, err := h.assetService.GetCurrentPackage(c.UserContext(), asset.AssetType, asset.AssetID) if err != nil { return err } @@ -132,15 +141,102 @@ func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error { return response.Success(c, result) } -// StopDevice 设备停机(批量停机设备下所有已实名卡) -// POST /api/admin/assets/device/:device_id/stop -func (h *AssetHandler) StopDevice(c *fiber.Ctx) error { - deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64) +// Stop 停机(设备批量停机,单卡停机) +// POST /api/admin/assets/:identifier/stop +func (h *AssetHandler) Stop(c *fiber.Ctx) error { + asset, err := h.resolveByIdentifier(c) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的设备ID") + return err } - result, err := h.deviceService.StopDevice(c.UserContext(), uint(deviceID)) + switch asset.AssetType { + case "device": + result, devErr := h.deviceService.StopDevice(c.UserContext(), asset.AssetID) + if devErr != nil { + return devErr + } + return response.Success(c, result) + case "card": + if asset.ICCID == "" { + return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法停机") + } + if cardErr := h.iotCardStopResume.ManualStopCard(c.UserContext(), asset.ICCID); cardErr != nil { + return cardErr + } + return response.Success(c, nil) + default: + return errors.New(errors.CodeInvalidParam, "不支持的资产类型") + } +} + +// Start 复机(设备批量复机,单卡复机) +// POST /api/admin/assets/:identifier/start +func (h *AssetHandler) Start(c *fiber.Ctx) error { + asset, err := h.resolveByIdentifier(c) + if err != nil { + return err + } + + switch asset.AssetType { + case "device": + if devErr := h.deviceService.StartDevice(c.UserContext(), asset.AssetID); devErr != nil { + return devErr + } + return response.Success(c, nil) + case "card": + if asset.ICCID == "" { + return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法复机") + } + if cardErr := h.iotCardStopResume.ManualStartCard(c.UserContext(), asset.ICCID); cardErr != nil { + return cardErr + } + return response.Success(c, nil) + default: + return errors.New(errors.CodeInvalidParam, "不支持的资产类型") + } +} + +// Deactivate 停用资产 +// PATCH /api/admin/assets/:identifier/deactivate +func (h *AssetHandler) Deactivate(c *fiber.Ctx) error { + if h.assetLifecycleService == nil { + return errors.New(errors.CodeInternalError, "资产生命周期服务未配置") + } + + asset, err := h.resolveByIdentifier(c) + if err != nil { + return err + } + + switch asset.AssetType { + case "device": + if devErr := h.assetLifecycleService.DeactivateDevice(c.UserContext(), asset.AssetID); devErr != nil { + return devErr + } + case "card": + if cardErr := h.assetLifecycleService.DeactivateIotCard(c.UserContext(), asset.AssetID); cardErr != nil { + return cardErr + } + default: + return errors.New(errors.CodeInvalidParam, "不支持的资产类型") + } + + return response.Success(c, nil) +} + +// Orders 查询资产历史订单(支持跨代追溯) +// GET /api/admin/assets/:identifier/orders +func (h *AssetHandler) Orders(c *fiber.Ctx) error { + identifier := c.Params("identifier") + if identifier == "" { + return errors.New(errors.CodeInvalidParam, "标识符不能为空") + } + + page := c.QueryInt("page", 1) + pageSize := c.QueryInt("page_size", 20) + includePrevious := c.QueryBool("include_previous", false) + + result, err := h.assetService.GetOrders(c.UserContext(), identifier, page, pageSize, includePrevious) if err != nil { return err } @@ -148,63 +244,11 @@ func (h *AssetHandler) StopDevice(c *fiber.Ctx) error { return response.Success(c, result) } -// StartDevice 设备复机(批量复机设备下所有已实名卡) -// POST /api/admin/assets/device/:device_id/start -func (h *AssetHandler) StartDevice(c *fiber.Ctx) error { - deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64) - if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的设备ID") - } - - if err := h.deviceService.StartDevice(c.UserContext(), uint(deviceID)); err != nil { - return err - } - - return response.Success(c, nil) -} - -// StopCard 单卡停机(通过ICCID) -// POST /api/admin/assets/card/:iccid/stop -func (h *AssetHandler) StopCard(c *fiber.Ctx) error { - iccid := c.Params("iccid") - if iccid == "" { - return errors.New(errors.CodeInvalidParam, "ICCID不能为空") - } - - if err := h.iotCardStopResume.ManualStopCard(c.UserContext(), iccid); err != nil { - return err - } - - return response.Success(c, nil) -} - -// StartCard 单卡复机(通过ICCID) -// POST /api/admin/assets/card/:iccid/start -func (h *AssetHandler) StartCard(c *fiber.Ctx) error { - iccid := c.Params("iccid") - if iccid == "" { - return errors.New(errors.CodeInvalidParam, "ICCID不能为空") - } - - if err := h.iotCardStopResume.ManualStartCard(c.UserContext(), iccid); err != nil { - return err - } - - return response.Success(c, nil) -} - // UpdatePollingStatus 更新资产轮询状态 -// PATCH /api/admin/assets/:asset_type/:id/polling-status +// PATCH /api/admin/assets/:identifier/polling-status func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error { - assetType := c.Params("asset_type") - idStr := c.Params("id") - - if assetType == "" || idStr == "" { - return errors.New(errors.CodeInvalidParam) - } - - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil { + identifier := c.Params("identifier") + if identifier == "" { return errors.New(errors.CodeInvalidParam) } @@ -213,19 +257,29 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam) } + asset, err := h.assetService.Resolve(c.UserContext(), identifier) + if err != nil { + return err + } + _ = middleware.GetUserIDFromContext(c.UserContext()) if h.assetPolling == nil { return errors.New(errors.CodeInternalError, "轮询管控服务未配置") } - if err := h.assetPolling.UpdatePollingStatus(c.UserContext(), assetType, uint(id), req.EnablePolling); err != nil { + pollingAssetType := asset.AssetType + if pollingAssetType == "card" { + pollingAssetType = "iot_card" + } + + if err := h.assetPolling.UpdatePollingStatus(c.UserContext(), pollingAssetType, asset.AssetID, req.EnablePolling); err != nil { return err } return response.Success(c, &dto.UpdateAssetPollingStatusResponse{ - AssetType: assetType, - AssetID: uint(id), + AssetType: asset.AssetType, + AssetID: asset.AssetID, EnablePolling: req.EnablePolling, }) } diff --git a/internal/handler/admin/asset_wallet.go b/internal/handler/admin/asset_wallet.go index 19a7643..8c6f7ad 100644 --- a/internal/handler/admin/asset_wallet.go +++ b/internal/handler/admin/asset_wallet.go @@ -1,11 +1,10 @@ package admin import ( - "strconv" - "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -16,7 +15,8 @@ import ( // AssetWalletHandler 资产钱包处理器 // 提供管理端资产(卡/设备)钱包概况和流水查询接口 type AssetWalletHandler struct { - service *assetWalletSvc.Service + service *assetWalletSvc.Service + assetService *assetSvc.Service } // NewAssetWalletHandler 创建资产钱包处理器 @@ -24,26 +24,34 @@ func NewAssetWalletHandler(svc *assetWalletSvc.Service) *AssetWalletHandler { return &AssetWalletHandler{service: svc} } +// SetAssetService 注入资产解析服务(用于通过标识符解析资产类型和ID) +func (h *AssetWalletHandler) SetAssetService(svc *assetSvc.Service) { + h.assetService = svc +} + // GetWallet 查询资产钱包概况 -// GET /api/admin/assets/:asset_type/:id/wallet +// GET /api/admin/assets/:identifier/wallet func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error { userType := middleware.GetUserTypeFromContext(c.UserContext()) if userType == constants.UserTypeEnterprise { return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息") } - assetType := c.Params("asset_type") - if assetType != "card" && assetType != "device" { - return errors.New(errors.CodeInvalidParam, "无效的资产类型") + identifier := c.Params("identifier") + if identifier == "" { + return errors.New(errors.CodeInvalidParam, "标识符不能为空") } - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - return errors.New(errors.CodeInvalidParam, "无效的资产ID") + if h.assetService == nil { + return errors.New(errors.CodeInternalError, "资产服务未配置") } - result, err := h.service.GetWallet(c.UserContext(), assetType, uint(id)) + asset, err := h.assetService.Resolve(c.UserContext(), identifier) + if err != nil { + return err + } + + result, err := h.service.GetWallet(c.UserContext(), asset.AssetType, asset.AssetID) if err != nil { return err } @@ -52,22 +60,25 @@ func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error { } // ListTransactions 查询资产钱包流水列表 -// GET /api/admin/assets/:asset_type/:id/wallet/transactions +// GET /api/admin/assets/:identifier/wallet/transactions func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error { userType := middleware.GetUserTypeFromContext(c.UserContext()) if userType == constants.UserTypeEnterprise { return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息") } - assetType := c.Params("asset_type") - if assetType != "card" && assetType != "device" { - return errors.New(errors.CodeInvalidParam, "无效的资产类型") + identifier := c.Params("identifier") + if identifier == "" { + return errors.New(errors.CodeInvalidParam, "标识符不能为空") } - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil || id == 0 { - return errors.New(errors.CodeInvalidParam, "无效的资产ID") + if h.assetService == nil { + return errors.New(errors.CodeInternalError, "资产服务未配置") + } + + asset, err := h.assetService.Resolve(c.UserContext(), identifier) + if err != nil { + return err } var req dto.AssetWalletTransactionListRequest @@ -79,7 +90,7 @@ func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "每页数量不能超过100") } - result, err := h.service.ListTransactions(c.UserContext(), assetType, uint(id), &req) + result, err := h.service.ListTransactions(c.UserContext(), asset.AssetType, asset.AssetID, &req) if err != nil { return err } diff --git a/internal/handler/admin/device.go b/internal/handler/admin/device.go index 3f681ea..f2d125e 100644 --- a/internal/handler/admin/device.go +++ b/internal/handler/admin/device.go @@ -1,8 +1,6 @@ package admin import ( - "strconv" - "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -43,13 +41,17 @@ func (h *DeviceHandler) Delete(c *fiber.Ctx) error { return errors.New(errors.CodeForbidden, "仅平台用户可删除设备") } - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的设备ID") + virtualNo := c.Params("virtual_no") + if virtualNo == "" { + return errors.New(errors.CodeInvalidParam, "虚拟号不能为空") } - if err := h.service.Delete(c.UserContext(), uint(id)); err != nil { + device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo) + if err != nil { + return err + } + + if err := h.service.Delete(c.UserContext(), device.ID); err != nil { return err } @@ -57,13 +59,17 @@ func (h *DeviceHandler) Delete(c *fiber.Ctx) error { } func (h *DeviceHandler) ListCards(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的设备ID") + virtualNo := c.Params("virtual_no") + if virtualNo == "" { + return errors.New(errors.CodeInvalidParam, "虚拟号不能为空") } - result, err := h.service.ListBindings(c.UserContext(), uint(id)) + device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo) + if err != nil { + return err + } + + result, err := h.service.ListBindings(c.UserContext(), device.ID) if err != nil { return err } @@ -77,19 +83,23 @@ func (h *DeviceHandler) BindCard(c *fiber.Ctx) error { return errors.New(errors.CodeForbidden, "仅平台用户可绑定卡到设备") } - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 64) + virtualNo := c.Params("virtual_no") + if virtualNo == "" { + return errors.New(errors.CodeInvalidParam, "虚拟号不能为空") + } + + device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的设备ID") + return err } var req dto.BindCardToDeviceRequest if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } - req.ID = uint(id) + req.ID = device.ID - result, err := h.service.BindCard(c.UserContext(), uint(id), &req) + result, err := h.service.BindCard(c.UserContext(), device.ID, &req) if err != nil { return err } @@ -103,19 +113,27 @@ func (h *DeviceHandler) UnbindCard(c *fiber.Ctx) error { return errors.New(errors.CodeForbidden, "仅平台用户可解绑设备的卡") } - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 64) - if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的设备ID") + virtualNo := c.Params("virtual_no") + if virtualNo == "" { + return errors.New(errors.CodeInvalidParam, "虚拟号不能为空") } - cardIdStr := c.Params("cardId") - cardId, err := strconv.ParseUint(cardIdStr, 10, 64) + device, err := h.service.GetByVirtualNo(c.UserContext(), virtualNo) if err != nil { - return errors.New(errors.CodeInvalidParam, "无效的卡ID") + return err } - result, err := h.service.UnbindCard(c.UserContext(), uint(id), uint(cardId)) + iccid := c.Params("iccid") + if iccid == "" { + return errors.New(errors.CodeInvalidParam, "ICCID 不能为空") + } + + card, cardErr := h.service.GetCardByICCID(c.UserContext(), iccid) + if cardErr != nil { + return cardErr + } + + result, err := h.service.UnbindCard(c.UserContext(), device.ID, card.ID) if err != nil { return err } diff --git a/internal/model/asset_identifier.go b/internal/model/asset_identifier.go new file mode 100644 index 0000000..fce3f8d --- /dev/null +++ b/internal/model/asset_identifier.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// AssetIdentifier 全局资产标识符注册表模型 +// 在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表全局唯一 +// 不使用 gorm.Model(无需软删除),仅包含核心字段 +type AssetIdentifier struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Identifier string `gorm:"column:identifier;type:varchar(100);uniqueIndex:uq_asset_identifier;not null;comment:标识符值(ICCID 或 VirtualNo,全局唯一)" json:"identifier"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null;comment:资产类型:iot_card 或 device" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;index:idx_asset_identifier_asset;not null;comment:对应资产的主键 ID" json:"asset_id"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:now();comment:写入时间" json:"created_at"` +} + +// TableName 指定表名 +func (AssetIdentifier) TableName() string { + return "tb_asset_identifier" +} + +const ( + AssetTypeIotCard = "iot_card" + AssetTypeDevice = "device" +) diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 417d464..c7a979a 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -6,6 +6,7 @@ import "time" type AssetResolveResponse struct { AssetType string `json:"asset_type" description:"资产类型:card 或 device"` AssetID uint `json:"asset_id" description:"资产数据库ID"` + Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"` VirtualNo string `json:"virtual_no" description:"虚拟号"` Status int `json:"status" description:"资产状态"` BatchNo string `json:"batch_no" description:"批次号"` @@ -118,7 +119,12 @@ type AssetResolveRequest struct { Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` } -// AssetTypeIDRequest 资产类型+ID请求(路径参数) +// AssetIdentifierRequest 资产标识符路径参数请求 +type AssetIdentifierRequest struct { + Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"` +} + +// AssetTypeIDRequest 资产类型+ID请求(路径参数,保留用于钱包等向后兼容场景) type AssetTypeIDRequest struct { AssetType string `path:"asset_type" description:"资产类型:card 或 device" required:"true"` ID uint `path:"id" description:"资产ID" required:"true"` @@ -197,12 +203,69 @@ type DeviceGatewayInfo struct { IMSI *string `json:"imsi,omitempty" description:"IMSI用户标识码"` } +// AssetOrdersRequest 资产历史订单查询请求 +type AssetOrdersRequest struct { + Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"` + Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"` + IncludePrevious bool `query:"include_previous" description:"是否包含前代订单,默认false"` +} + +// AssetOrderItemDetail 订单中的套餐明细 +type AssetOrderItemDetail struct { + PackageName string `json:"package_name" description:"套餐名称快照"` + Quantity int `json:"quantity" description:"数量"` + UnitPrice int64 `json:"unit_price" description:"单价(分)"` + Amount int64 `json:"amount" description:"小计金额(分)"` +} + +// AssetOrderItem 资产历史订单条目 +type AssetOrderItem struct { + OrderNo string `json:"order_no" description:"订单号"` + OrderType string `json:"order_type" description:"订单类型:single_card/device"` + PaymentStatus int `json:"payment_status" description:"支付状态:1待支付 2已支付 3已取消 4已退款"` + PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"` + TotalAmount int64 `json:"total_amount" description:"订单金额(分)"` + PaymentMethod string `json:"payment_method" description:"支付方式:wallet/wechat/alipay/offline"` + PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` + Generation int `json:"generation" description:"订单所属资产世代编号"` + Items []*AssetOrderItemDetail `json:"items" description:"套餐明细列表"` + CreatedAt time.Time `json:"created_at" description:"订单创建时间"` +} + +// GenerationOrders 本代订单块(支持分页) +type GenerationOrders struct { + Generation int `json:"generation" description:"世代编号"` + Identifier string `json:"identifier" description:"资产标识符"` + AssetType string `json:"asset_type" description:"资产类型:card 或 device"` + Total int64 `json:"total" description:"总订单数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页条数"` + Items []*AssetOrderItem `json:"items" description:"订单列表"` +} + +// PreviousGenerationOrders 前代订单块(最多20条,不分页) +type PreviousGenerationOrders struct { + Generation int `json:"generation" description:"前代世代编号"` + Identifier string `json:"identifier" description:"前代资产标识符"` + AssetType string `json:"asset_type" description:"前代资产类型:card 或 device"` + ExchangeNo string `json:"exchange_no" description:"换货单号"` + ExchangedAt time.Time `json:"exchanged_at" description:"换货完成时间"` + Total int64 `json:"total" description:"前代总订单数"` + Items []*AssetOrderItem `json:"items" description:"前代订单列表(最多20条)"` +} + +// AssetOrdersResponse 资产历史订单完整响应 +type AssetOrdersResponse struct { + CurrentGeneration *GenerationOrders `json:"current_generation" description:"本代订单(含分页)"` + PreviousGenerations []*PreviousGenerationOrders `json:"previous_generations,omitempty" description:"前代订单列表(include_previous=true时返回)"` + Truncated bool `json:"truncated" description:"是否已截断(换货链超过10代追溯上限时为true)"` +} + // UpdateAssetPollingStatusRequest 更新资产轮询状态请求 type UpdateAssetPollingStatusRequest struct { - AssetType string `path:"asset_type" description:"资产类型 (iot_card:IoT卡, device:设备)" required:"true"` - ID uint `path:"id" description:"资产ID" required:"true"` - // EnablePolling 是否启用轮询 - EnablePolling bool `json:"enable_polling" description:"是否启用轮询 (true:启用, false:禁用)"` + Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"` + EnablePolling bool `json:"enable_polling" description:"是否启用轮询 (true:启用, false:禁用)"` } // UpdateAssetPollingStatusResponse 更新资产轮询状态响应 diff --git a/internal/model/dto/asset_wallet_dto.go b/internal/model/dto/asset_wallet_dto.go index 39d17a6..befa11e 100644 --- a/internal/model/dto/asset_wallet_dto.go +++ b/internal/model/dto/asset_wallet_dto.go @@ -19,8 +19,7 @@ type AssetWalletResponse struct { // AssetWalletTransactionListRequest 资产钱包流水列表请求(路径参数 + 查询参数) type AssetWalletTransactionListRequest struct { - AssetType string `path:"asset_type" description:"资产类型:card 或 device" required:"true"` - ID uint `path:"id" description:"资产ID" required:"true"` + Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"` Page int `json:"page" query:"page" description:"页码,默认1"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量,默认20,最大100"` TransactionType *string `json:"transaction_type" query:"transaction_type" validate:"omitempty,oneof=recharge deduct refund" description:"交易类型过滤:recharge/deduct/refund"` diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 27a4515..dc20b40 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -65,11 +65,11 @@ type GetDeviceByIdentifierRequest struct { } type DeleteDeviceRequest struct { - ID uint `path:"id" description:"设备ID" required:"true"` + VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"` } type ListDeviceCardsRequest struct { - ID uint `path:"id" description:"设备ID" required:"true"` + VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"` } type DeviceCardBindingResponse struct { @@ -89,9 +89,10 @@ type ListDeviceCardsResponse struct { } type BindCardToDeviceRequest struct { - ID uint `path:"id" description:"设备ID" required:"true"` - IotCardID uint `json:"iot_card_id" validate:"required,min=1" required:"true" minimum:"1" description:"IoT卡ID"` - SlotPosition int `json:"slot_position" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"插槽位置 (1-4)"` + VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"` + ID uint `json:"-"` + IotCardID uint `json:"iot_card_id" validate:"required,min=1" required:"true" minimum:"1" description:"IoT卡ID"` + SlotPosition int `json:"slot_position" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"插槽位置 (1-4)"` } type BindCardToDeviceResponse struct { @@ -100,8 +101,8 @@ type BindCardToDeviceResponse struct { } type UnbindCardFromDeviceRequest struct { - ID uint `path:"id" description:"设备ID" required:"true"` - CardID uint `path:"cardId" description:"IoT卡ID" required:"true"` + VirtualNo string `path:"virtual_no" description:"设备虚拟号" required:"true"` + ICCID string `path:"iccid" description:"IoT卡ICCID" required:"true"` } type UnbindCardFromDeviceResponse struct { diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index 00ac2b4..cf88d12 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -12,9 +12,7 @@ type CreateOrderRequest struct { // CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline) type CreateAdminOrderRequest struct { - OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` - IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"` - DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"` + Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"` PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` } @@ -29,6 +27,7 @@ type OrderListRequest struct { StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"` EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"` IsExpired *bool `json:"is_expired" query:"is_expired" description:"是否已过期 (true:已过期, false:未过期)"` + Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)精确查询"` } type PayOrderRequest struct { @@ -79,6 +78,10 @@ type OrderResponse struct { // 订单超时信息 ExpiresAt *time.Time `json:"expires_at,omitempty" description:"订单过期时间"` IsExpired bool `json:"is_expired" description:"是否已过期"` + + // 资产标识符快照 + AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(ICCID 或 VirtualNo)"` + AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` } type OrderListResponse struct { diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index 1b8bc6a..352d6e4 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -52,7 +52,7 @@ type IotCard struct { AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"` - VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"` + VirtualNo string `gorm:"column:virtual_no;type:varchar(50);not null;uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL;comment:虚拟号(必填,全局唯一)" json:"virtual_no"` LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"` } diff --git a/internal/model/order.go b/internal/model/order.go index 7e5e638..eba3d30 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -22,8 +22,9 @@ type Order struct { BuyerID uint `gorm:"column:buyer_id;index:idx_order_buyer;not null;comment:买家ID(个人客户ID或店铺ID)" json:"buyer_id"` // 关联资源 - IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"` - DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"` + IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"` + DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"` + AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);comment:下单时资产的标识符快照(ICCID 或 VirtualNo)" json:"asset_identifier,omitempty"` // 金额信息 TotalAmount int64 `gorm:"column:total_amount;type:bigint;not null;comment:订单总金额(分)" json:"total_amount"` diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 83104fe..5b02f5b 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -21,87 +21,78 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Auth: true, }) - Register(assets, doc, groupPath, "GET", "/:asset_type/:id/realtime-status", handler.RealtimeStatus, RouteSpec{ + Register(assets, doc, groupPath, "GET", "/:identifier/realtime-status", handler.RealtimeStatus, RouteSpec{ Summary: "资产实时状态", - Description: "读取 DB/Redis 中的持久化状态,不调网关。asset_type 为 card 或 device。", + Description: "读取 DB/Redis 中的持久化状态,不调网关。", Tags: []string{"资产管理"}, - Input: new(dto.AssetTypeIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: new(dto.AssetRealtimeStatusResponse), Auth: true, }) - Register(assets, doc, groupPath, "POST", "/:asset_type/:id/refresh", handler.Refresh, RouteSpec{ + Register(assets, doc, groupPath, "POST", "/:identifier/refresh", handler.Refresh, RouteSpec{ Summary: "刷新资产状态", Description: "主动调网关同步最新状态。设备有30秒冷却期。", Tags: []string{"资产管理"}, - Input: new(dto.AssetTypeIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: new(dto.AssetRealtimeStatusResponse), Auth: true, }) - Register(assets, doc, groupPath, "GET", "/:asset_type/:id/packages", handler.Packages, RouteSpec{ + Register(assets, doc, groupPath, "GET", "/:identifier/packages", handler.Packages, RouteSpec{ Summary: "资产套餐列表", - Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页(默认 page=1, page_size=50, 最大100)。", + Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页。", Tags: []string{"资产管理"}, - Input: new(dto.AssetPackagesRequest), + Input: new(dto.AssetIdentifierRequest), Output: new(dto.AssetPackagesResult), Auth: true, }) - Register(assets, doc, groupPath, "GET", "/:asset_type/:id/current-package", handler.CurrentPackage, RouteSpec{ + Register(assets, doc, groupPath, "GET", "/:identifier/current-package", handler.CurrentPackage, RouteSpec{ Summary: "当前生效套餐", Tags: []string{"资产管理"}, - Input: new(dto.AssetTypeIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: new(dto.AssetPackageResponse), Auth: true, }) - Register(assets, doc, groupPath, "POST", "/device/:device_id/stop", handler.StopDevice, RouteSpec{ - Summary: "设备停机", - Description: "批量停机设备下所有已实名卡。设置1小时停机保护期。", + Register(assets, doc, groupPath, "POST", "/:identifier/stop", handler.Stop, RouteSpec{ + Summary: "停机", + Description: "设备批量停机设备下所有已实名卡;单卡手动停机。", Tags: []string{"资产管理"}, - Input: new(dto.DeviceIDRequest), - Output: new(dto.DeviceSuspendResponse), - Auth: true, - }) - - Register(assets, doc, groupPath, "POST", "/device/:device_id/start", handler.StartDevice, RouteSpec{ - Summary: "设备复机", - Description: "批量复机设备下所有已实名卡。设置1小时复机保护期。", - Tags: []string{"资产管理"}, - Input: new(dto.DeviceIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: nil, Auth: true, }) - Register(assets, doc, groupPath, "POST", "/card/:iccid/stop", handler.StopCard, RouteSpec{ - Summary: "单卡停机", - Description: "手动停机单张卡(通过ICCID)。受设备保护期约束。", + Register(assets, doc, groupPath, "POST", "/:identifier/start", handler.Start, RouteSpec{ + Summary: "复机", + Description: "设备批量复机;单卡手动复机。", Tags: []string{"资产管理"}, - Input: new(dto.CardICCIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: nil, Auth: true, }) - Register(assets, doc, groupPath, "POST", "/card/:iccid/start", handler.StartCard, RouteSpec{ - Summary: "单卡复机", - Description: "手动复机单张卡(通过ICCID)。受设备保护期约束。", + Register(assets, doc, groupPath, "PATCH", "/:identifier/deactivate", handler.Deactivate, RouteSpec{ + Summary: "停用资产", + Description: "将资产状态设置为停用(不可逆)。仅支持库存中或已售出的资产。", Tags: []string{"资产管理"}, - Input: new(dto.CardICCIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: nil, Auth: true, }) - Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet", walletHandler.GetWallet, RouteSpec{ + Register(assets, doc, groupPath, "GET", "/:identifier/wallet", walletHandler.GetWallet, RouteSpec{ Summary: "资产钱包概况", Description: "查询指定卡或设备的钱包余额概况。企业账号禁止调用。", Tags: []string{"资产管理"}, - Input: new(dto.AssetTypeIDRequest), + Input: new(dto.AssetIdentifierRequest), Output: new(dto.AssetWalletResponse), Auth: true, }) - Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet/transactions", walletHandler.ListTransactions, RouteSpec{ + Register(assets, doc, groupPath, "GET", "/:identifier/wallet/transactions", walletHandler.ListTransactions, RouteSpec{ Summary: "资产钱包流水列表", Description: "分页查询指定资产的钱包收支流水,含充值/扣款来源编号。企业账号禁止调用。", Tags: []string{"资产管理"}, @@ -110,9 +101,18 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Auth: true, }) - Register(assets, doc, groupPath, "PATCH", "/:asset_type/:id/polling-status", handler.UpdatePollingStatus, RouteSpec{ + Register(assets, doc, groupPath, "GET", "/:identifier/orders", handler.Orders, RouteSpec{ + Summary: "资产历史订单", + Description: "查询资产本代历史订单,支持 include_previous=true 参数通过换货链追溯前代订单(最多10代)。", + Tags: []string{"资产管理"}, + Input: new(dto.AssetOrdersRequest), + Output: new(dto.AssetOrdersResponse), + Auth: true, + }) + + Register(assets, doc, groupPath, "PATCH", "/:identifier/polling-status", handler.UpdatePollingStatus, RouteSpec{ Summary: "更新资产轮询状态", - Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询。asset_type 为 iot_card 或 device。", + Description: "启用或禁用指定资产(IoT卡或设备)的定期状态轮询。", Tags: []string{"资产管理"}, Input: new(dto.UpdateAssetPollingStatusRequest), Output: new(dto.UpdateAssetPollingStatusResponse), diff --git a/internal/routes/asset_lifecycle.go b/internal/routes/asset_lifecycle.go index 31db0f7..0fca499 100644 --- a/internal/routes/asset_lifecycle.go +++ b/internal/routes/asset_lifecycle.go @@ -4,25 +4,9 @@ import ( "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/handler/admin" - "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/openapi" ) -// registerAssetLifecycleRoutes 注册资产手动停用路由 -func registerAssetLifecycleRoutes(router fiber.Router, handler *admin.AssetLifecycleHandler, doc *openapi.Generator, basePath string) { - Register(router, doc, basePath, "PATCH", "/iot-cards/:id/deactivate", handler.DeactivateIotCard, RouteSpec{ - Summary: "手动停用IoT卡", - Tags: []string{"IoT卡管理"}, - Input: new(dto.IDReq), - Output: nil, - Auth: true, - }) - - Register(router, doc, basePath, "PATCH", "/devices/:id/deactivate", handler.DeactivateDevice, RouteSpec{ - Summary: "手动停用设备", - Tags: []string{"设备管理"}, - Input: new(dto.IDReq), - Output: nil, - Auth: true, - }) +// registerAssetLifecycleRoutes 注册资产生命周期路由 +func registerAssetLifecycleRoutes(_ fiber.Router, _ *admin.AssetLifecycleHandler, _ *openapi.Generator, _ string) { } diff --git a/internal/routes/device.go b/internal/routes/device.go index 40fe44b..e43e69f 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -21,7 +21,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) - Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{ + Register(devices, doc, groupPath, "DELETE", "/:virtual_no", handler.Delete, RouteSpec{ Summary: "删除设备", Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。", Tags: []string{"设备管理"}, @@ -30,7 +30,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) - Register(devices, doc, groupPath, "GET", "/:id/cards", handler.ListCards, RouteSpec{ + Register(devices, doc, groupPath, "GET", "/:virtual_no/cards", handler.ListCards, RouteSpec{ Summary: "获取设备绑定的卡列表", Tags: []string{"设备管理"}, Input: new(dto.ListDeviceCardsRequest), @@ -38,7 +38,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) - Register(devices, doc, groupPath, "POST", "/:id/cards", handler.BindCard, RouteSpec{ + Register(devices, doc, groupPath, "POST", "/:virtual_no/cards", handler.BindCard, RouteSpec{ Summary: "绑定卡到设备", Description: "仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。", Tags: []string{"设备管理"}, @@ -47,7 +47,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) - Register(devices, doc, groupPath, "DELETE", "/:id/cards/:cardId", handler.UnbindCard, RouteSpec{ + Register(devices, doc, groupPath, "DELETE", "/:virtual_no/cards/:iccid", handler.UnbindCard, RouteSpec{ Summary: "解绑设备上的卡", Description: "仅平台用户可操作。解绑不改变卡的 shop_id。", Tags: []string{"设备管理"}, @@ -88,7 +88,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp - 文件格式:仅支持 .xlsx (Excel 2007+) - 必须包含列(首行为表头): - - ` + "`virtual_no`" + `: 设备虚拟号(必填,全局唯一) + - ` + "`virtual_no`" + `: 设备虚拟号(**必填**,全局唯一;**为空的行记为失败,不再静默跳过**) - ` + "`device_name`" + `: 设备名称 - ` + "`device_model`" + `: 设备型号 - ` + "`device_type`" + `: 设备类型 diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index dc97f57..e20ebe4 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -49,16 +49,18 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i ### Excel 文件格式 - 文件格式:仅支持 .xlsx (Excel 2007+) -- 必填列:` + "`ICCID`" + `, ` + "`MSISDN`" + ` -- 可选列:` + "`virtual_no`" + `(虚拟号,表头关键字:virtual_no / VirtualNo / 虚拟号 / 设备号) +- 必填列(表头关键字): + - ` + "`ICCID`" + `(或:卡号 / 号码) + - ` + "`MSISDN`" + `(或:接入号 / 手机号 / 电话) + - ` + "`virtual_no`" + `(或:VirtualNo / 虚拟号 / 设备号)—— **必填,缺少此列将导致整批导入失败** - 首行为表头(可选,但建议包含) - 列格式:设置为文本格式(避免长数字被转为科学记数法) -#### virtual_no 列导入规则(只补空白) +#### virtual_no 导入规则 -- 该行 virtual_no 非空 + 数据库当前值为 NULL:填入新值 -- 该行 virtual_no 非空 + 数据库已有值:跳过(不覆盖) -- 批次内有任意 virtual_no 与数据库现存值重复:**整批拒绝**`, +- ` + "`virtual_no`" + ` 为必填字段,**不可为空**;为空的行记为失败并报告原因 +- Excel 缺少 ` + "`virtual_no`" + ` 列时,整批导入直接失败(返回"Excel 文件缺少 virtual_no 列,请使用最新模板") +- ` + "`virtual_no`" + ` 与系统已有值重复时,该行记为失败`, Tags: []string{"IoT卡管理"}, Input: new(dto.ImportIotCardRequest), Output: new(dto.ImportIotCardResponse), diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index aba2284..0006d47 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -37,9 +37,13 @@ type Service struct { packageSeriesStore *postgres.PackageSeriesStore deviceSimBindingStore *postgres.DeviceSimBindingStore shopStore *postgres.ShopStore + orderStore *postgres.OrderStore + orderItemStore *postgres.OrderItemStore + exchangeOrderStore *postgres.ExchangeOrderStore redis *redis.Client iotCardService IotCardRefresher - gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil,失败时仅记录日志) + gatewayClient *gateway.Client + assetIdentifierStore *postgres.AssetIdentifierStore } // New 创建资产服务实例 @@ -55,6 +59,10 @@ func New( redisClient *redis.Client, iotCardService IotCardRefresher, gatewayClient *gateway.Client, + assetIdentifierStore *postgres.AssetIdentifierStore, + orderStore *postgres.OrderStore, + orderItemStore *postgres.OrderItemStore, + exchangeOrderStore *postgres.ExchangeOrderStore, ) *Service { return &Service{ db: db, @@ -65,28 +73,65 @@ func New( packageSeriesStore: packageSeriesStore, deviceSimBindingStore: deviceSimBindingStore, shopStore: shopStore, + orderStore: orderStore, + orderItemStore: orderItemStore, + exchangeOrderStore: exchangeOrderStore, redis: redisClient, iotCardService: iotCardService, gatewayClient: gatewayClient, + assetIdentifierStore: assetIdentifierStore, } } // Resolve 通过任意标识符解析资产 -// 优先匹配设备(virtual_no/imei/sn),未命中则匹配卡(virtual_no/iccid/msisdn) +// 主路径:查注册表(精确匹配 ICCID 或 VirtualNo) +// Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符) func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) { - // 先查 Device - device, err := s.deviceStore.GetByIdentifier(ctx, identifier) - if err == nil && device != nil { - return s.buildDeviceResolveResponse(ctx, device) + if s.assetIdentifierStore != nil { + regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier) + if regErr == nil && regRecord != nil { + switch regRecord.AssetType { + case model.AssetTypeDevice: + device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID) + if devErr == nil && device != nil { + resp, buildErr := s.buildDeviceResolveResponse(ctx, device) + if buildErr == nil { + resp.Identifier = identifier + } + return resp, buildErr + } + case model.AssetTypeIotCard: + card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID) + if cardErr == nil && card != nil { + resp, buildErr := s.buildCardResolveResponse(ctx, card) + if buildErr == nil { + resp.Identifier = identifier + } + return resp, buildErr + } + } + } + } + + device, err := s.deviceStore.GetByIdentifier(ctx, identifier) + if err == nil && device != nil { + resp, buildErr := s.buildDeviceResolveResponse(ctx, device) + if buildErr == nil { + resp.Identifier = identifier + } + return resp, buildErr } - // 未找到设备,查 IotCard(virtual_no/iccid/msisdn) var card model.IotCard query := s.db.WithContext(ctx). Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier) query = middleware.ApplyShopFilter(ctx, query) if err := query.First(&card).Error; err == nil { - return s.buildCardResolveResponse(ctx, &card) + resp, buildErr := s.buildCardResolveResponse(ctx, &card) + if buildErr == nil { + resp.Identifier = identifier + } + return resp, buildErr } return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产") @@ -725,6 +770,204 @@ func safeVirtualRatio(ratio float64) float64 { return ratio } +// GetOrders 查询资产历史订单,支持换货链跨代追溯 +// page/pageSize 仅对本代订单生效;前代订单每代最多返回20条 +// 最多追溯10代,超出后 truncated=true +func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSize int, includePrevious bool) (*dto.AssetOrdersResponse, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + asset, err := s.Resolve(ctx, identifier) + if err != nil { + return nil, err + } + + currentGeneration := s.resolveAssetGeneration(ctx, asset.AssetType, asset.AssetID) + + orders, total, err := s.orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询本代订单失败") + } + + orderItems := s.fetchOrderItemsMap(ctx, orders) + currentItems := make([]*dto.AssetOrderItem, 0, len(orders)) + for _, o := range orders { + currentItems = append(currentItems, buildAssetOrderItem(o, orderItems[o.ID])) + } + + resp := &dto.AssetOrdersResponse{ + CurrentGeneration: &dto.GenerationOrders{ + Generation: currentGeneration, + Identifier: identifier, + AssetType: asset.AssetType, + Total: total, + Page: page, + PageSize: pageSize, + Items: currentItems, + }, + PreviousGenerations: nil, + Truncated: false, + } + + if !includePrevious { + return resp, nil + } + + prevGens, truncated := s.tracePreviousGenerations(ctx, asset.AssetType, asset.AssetID) + resp.PreviousGenerations = prevGens + resp.Truncated = truncated + + return resp, nil +} + +func (s *Service) resolveAssetGeneration(ctx context.Context, assetType string, assetID uint) int { + switch assetType { + case "card": + card, err := s.iotCardStore.GetByID(ctx, assetID) + if err == nil && card != nil { + return card.Generation + } + case "device": + device, err := s.deviceStore.GetByID(ctx, assetID) + if err == nil && device != nil { + return device.Generation + } + } + return 1 +} + +// tracePreviousGenerations 通过换货链逆向追溯前代资产的订单,最多追溯10代 +func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) { + const maxDepth = 10 + const prevPageSize = 20 + + prevGens := make([]*dto.PreviousGenerationOrders, 0) + currentType := assetType + currentID := assetID + truncated := false + + for i := 0; i < maxDepth; i++ { + // 转换为 ExchangeOrder 中使用的资产类型(card→iot_card) + storeType := currentType + if storeType == "card" { + storeType = "iot_card" + } + + exchOrder, err := s.exchangeOrderStore.FindByNewAssetID(ctx, storeType, currentID) + if err != nil || exchOrder == nil { + break + } + + oldOrders, oldTotal, err := s.orderStore.ListByAssetIdentifier(ctx, exchOrder.OldAssetIdentifier, 1, prevPageSize) + if err != nil { + logger.GetAppLogger().Warn("查询前代订单失败", + zap.String("old_identifier", exchOrder.OldAssetIdentifier), + zap.Error(err)) + oldOrders = nil + oldTotal = 0 + } + + oldItems := s.fetchOrderItemsMap(ctx, oldOrders) + orderItems := make([]*dto.AssetOrderItem, 0, len(oldOrders)) + for _, o := range oldOrders { + orderItems = append(orderItems, buildAssetOrderItem(o, oldItems[o.ID])) + } + + oldAssetType := exchOrder.OldAssetType + if oldAssetType == "iot_card" { + oldAssetType = "card" + } + + oldGeneration := s.resolveAssetGeneration(ctx, oldAssetType, exchOrder.OldAssetID) + + prevGens = append(prevGens, &dto.PreviousGenerationOrders{ + Generation: oldGeneration, + Identifier: exchOrder.OldAssetIdentifier, + AssetType: oldAssetType, + ExchangeNo: exchOrder.ExchangeNo, + ExchangedAt: exchOrder.UpdatedAt, + Total: oldTotal, + Items: orderItems, + }) + + currentType = oldAssetType + currentID = exchOrder.OldAssetID + + if i == maxDepth-1 { + truncated = true + } + } + + return prevGens, truncated +} + +// fetchOrderItemsMap 批量查询订单明细并按 orderID 分组 +func (s *Service) fetchOrderItemsMap(ctx context.Context, orders []*model.Order) map[uint][]*model.OrderItem { + if len(orders) == 0 { + return nil + } + orderIDs := make([]uint, 0, len(orders)) + for _, o := range orders { + orderIDs = append(orderIDs, o.ID) + } + allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs) + if err != nil { + logger.GetAppLogger().Warn("批量查询订单明细失败", zap.Error(err)) + return nil + } + result := make(map[uint][]*model.OrderItem, len(orders)) + for _, item := range allItems { + result[item.OrderID] = append(result[item.OrderID], item) + } + return result +} + +func buildAssetOrderItem(o *model.Order, items []*model.OrderItem) *dto.AssetOrderItem { + details := make([]*dto.AssetOrderItemDetail, 0, len(items)) + for _, it := range items { + details = append(details, &dto.AssetOrderItemDetail{ + PackageName: it.PackageName, + Quantity: it.Quantity, + UnitPrice: it.UnitPrice, + Amount: it.Amount, + }) + } + return &dto.AssetOrderItem{ + OrderNo: o.OrderNo, + OrderType: o.OrderType, + PaymentStatus: o.PaymentStatus, + PaymentStatusText: paymentStatusText(o.PaymentStatus), + TotalAmount: o.TotalAmount, + PaymentMethod: o.PaymentMethod, + PaidAt: o.PaidAt, + Generation: o.Generation, + Items: details, + CreatedAt: o.CreatedAt, + } +} + +func paymentStatusText(status int) string { + switch status { + case 1: + return "待支付" + case 2: + return "已支付" + case 3: + return "已取消" + case 4: + return "已退款" + default: + return "未知" + } +} + // packageStatusName 套餐状态码转中文名称 func packageStatusName(status int) string { switch status { diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 35728e9..6edc8e0 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -31,6 +31,7 @@ type Service struct { shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore packageSeriesStore *postgres.PackageSeriesStore gatewayClient *gateway.Client + assetIdentifierStore *postgres.AssetIdentifierStore } func New( @@ -45,6 +46,7 @@ func New( shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, packageSeriesStore *postgres.PackageSeriesStore, gatewayClient *gateway.Client, + assetIdentifierStore *postgres.AssetIdentifierStore, ) *Service { return &Service{ db: db, @@ -58,6 +60,7 @@ func New( shopSeriesAllocationStore: shopSeriesAllocationStore, packageSeriesStore: packageSeriesStore, gatewayClient: gatewayClient, + assetIdentifierStore: assetIdentifierStore, } } @@ -222,7 +225,32 @@ func (s *Service) Delete(ctx context.Context, id uint) error { return err } - return s.deviceStore.Delete(ctx, id) + if err := s.deviceStore.Delete(ctx, id); err != nil { + return err + } + + if s.assetIdentifierStore != nil { + _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeDevice, id) + } + + return nil +} + +// GetByVirtualNo 通过虚拟号获取设备 +func (s *Service) GetByVirtualNo(ctx context.Context, virtualNo string) (*model.Device, error) { + return s.deviceStore.GetByIdentifier(ctx, virtualNo) +} + +// GetCardByICCID 通过 ICCID 获取 IoT 卡 +func (s *Service) GetCardByICCID(ctx context.Context, iccid string) (*model.IotCard, error) { + cards, err := s.iotCardStore.GetByICCIDs(ctx, []string{iccid}) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败") + } + if len(cards) == 0 { + return nil, errors.New(errors.CodeNotFound, "卡不存在") + } + return cards[0], nil } // AllocateDevices 批量分配设备 diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index a01bf14..0535288 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -48,8 +48,9 @@ type Service struct { packageSeriesStore *postgres.PackageSeriesStore gatewayClient *gateway.Client logger *zap.Logger - pollingCallback PollingCallback // 轮询回调,可选 - dataDeductor DataDeductor // 流量扣减回调,可选 + pollingCallback PollingCallback + dataDeductor DataDeductor + assetIdentifierStore *postgres.AssetIdentifierStore } func New( @@ -77,11 +78,15 @@ func New( } // SetPollingCallback 设置轮询回调 -// 在应用启动时由 bootstrap 调用,注入轮询调度器 func (s *Service) SetPollingCallback(callback PollingCallback) { s.pollingCallback = callback } +// SetAssetIdentifierStore 设置资产标识符注册表存储 +func (s *Service) SetAssetIdentifierStore(store *postgres.AssetIdentifierStore) { + s.assetIdentifierStore = store +} + // SetDataDeductor 设置流量扣减回调 // 在应用启动时由 bootstrap 调用,注入套餐扣减服务 func (s *Service) SetDataDeductor(deductor DataDeductor) { @@ -1073,14 +1078,16 @@ func (s *Service) DeleteCard(ctx context.Context, cardID uint) error { return err } - // 执行软删除 if err := s.iotCardStore.Delete(ctx, cardID); err != nil { return err } + if s.assetIdentifierStore != nil { + _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeIotCard, cardID) + } + s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) - // 通知轮询调度器 if s.pollingCallback != nil { s.pollingCallback.OnCardDeleted(ctx, cardID) } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index ad97473..a6abcfb 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -51,6 +51,7 @@ type Service struct { queueClient *queue.Client logger *zap.Logger resumeCallback packagepkg.ResumeCallback + assetIdentifierStore *postgres.AssetIdentifierStore } func New( @@ -72,6 +73,7 @@ func New( wechatPayment wechat.PaymentServiceInterface, queueClient *queue.Client, logger *zap.Logger, + assetIdentifierStore *postgres.AssetIdentifierStore, ) *Service { return &Service{ db: db, @@ -92,6 +94,7 @@ func New( wechatPayment: wechatPayment, queueClient: queueClient, logger: logger, + assetIdentifierStore: assetIdentifierStore, } } @@ -348,49 +351,44 @@ func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest, // 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活 // POST /api/admin/orders func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { + resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier) + if resolveErr != nil { + return nil, resolveErr + } + + var orderType string + var iotCardID *uint + var deviceID *uint + if resolvedCard != nil { + orderType = model.OrderTypeSingleCard + iotCardID = &resolvedCard.ID + } else { + orderType = model.OrderTypeDevice + deviceID = &resolvedDevice.ID + } + var validationResult *purchase_validation.PurchaseValidationResult var err error if req.PaymentMethod == model.PaymentMethodOffline { - // offline 订单:绕过代理 Allocation 上架检查,仅验证套餐全局状态 - if req.OrderType == model.OrderTypeSingleCard { - if req.IotCardID == nil { - return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID") - } - validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, *req.IotCardID, req.PackageIDs) - } else if req.OrderType == model.OrderTypeDevice { - if req.DeviceID == nil { - return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID") - } - validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, *req.DeviceID, req.PackageIDs) + if resolvedCard != nil { + validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, resolvedCard.ID, req.PackageIDs) } else { - return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") + validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs) } } else { - if req.OrderType == model.OrderTypeSingleCard { - if req.IotCardID == nil { - return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID") - } - // 平台账号代表平台直接下单,不受卡所属代理的套餐分配限制; - // 代理账号下单时,卡所属代理必须已将套餐上架分配 + if resolvedCard != nil { if buyerType == model.BuyerTypeAgent { - validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs) + validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, resolvedCard.ID, req.PackageIDs) } else { - validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, *req.IotCardID, req.PackageIDs) - } - } else if req.OrderType == model.OrderTypeDevice { - if req.DeviceID == nil { - return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID") - } - // 平台账号代表平台直接下单,不受设备所属代理的套餐分配限制; - // 代理账号下单时,设备所属代理必须已将套餐上架分配 - if buyerType == model.BuyerTypeAgent { - validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs) - } else { - validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, *req.DeviceID, req.PackageIDs) + validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, resolvedCard.ID, req.PackageIDs) } } else { - return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") + if buyerType == model.BuyerTypeAgent { + validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs) + } else { + validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs) + } } } @@ -398,14 +396,12 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde return nil, err } - // 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包 if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil { return nil, err } - // 幂等性检查:防止同一买家对同一载体短时间内重复下单 - carrierType, carrierID := resolveAdminCarrierInfo(req) - existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID) + existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) if err != nil { return nil, err } @@ -598,13 +594,14 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde Updater: userID, }, OrderNo: s.orderStore.GenerateOrderNo(), - OrderType: req.OrderType, + OrderType: orderType, Source: constants.OrderSourceAdmin, Generation: assetGeneration, BuyerType: orderBuyerType, BuyerID: orderBuyerID, - IotCardID: req.IotCardID, - DeviceID: req.DeviceID, + IotCardID: iotCardID, + DeviceID: deviceID, + AssetIdentifier: req.Identifier, TotalAmount: totalAmount, PaymentMethod: paymentMethod, PaymentStatus: paymentStatus, @@ -624,7 +621,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde items := s.buildOrderItems(userID, validationResult.Packages) - idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) // 根据支付方式选择创建订单的方式 if req.PaymentMethod == model.PaymentMethodOffline { @@ -1212,6 +1209,9 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType if req.EndTime != nil { filters["end_time"] = req.EndTime } + if req.Identifier != "" { + filters["identifier"] = req.Identifier + } orders, total, err := s.orderStore.List(ctx, opts, filters) if err != nil { @@ -1791,17 +1791,40 @@ func resolveCarrierInfo(req *dto.CreateOrderRequest) (carrierType string, carrie return "", 0 } -// resolveAdminCarrierInfo 从后台订单请求中提取载体类型和ID -func resolveAdminCarrierInfo(req *dto.CreateAdminOrderRequest) (carrierType string, carrierID uint) { - if req.OrderType == model.OrderTypeSingleCard && req.IotCardID != nil { - return "iot_card", *req.IotCardID +// resolveAdminCarrierInfoFromVars 从已解析的订单类型和资产ID中提取载体类型和ID +func resolveAdminCarrierInfoFromVars(orderType string, iotCardID *uint, deviceID *uint) (carrierType string, carrierID uint) { + if orderType == model.OrderTypeSingleCard && iotCardID != nil { + return "iot_card", *iotCardID } - if req.OrderType == model.OrderTypeDevice && req.DeviceID != nil { - return "device", *req.DeviceID + if orderType == model.OrderTypeDevice && deviceID != nil { + return "device", *deviceID } return "", 0 } +// resolveAssetByIdentifier 通过标识符(ICCID 或 VirtualNo)解析资产 +// 优先查注册表,注册表命中则直接返回对应卡或设备 +func (s *Service) resolveAssetByIdentifier(ctx context.Context, identifier string) (*model.IotCard, *model.Device, error) { + if s.assetIdentifierStore != nil { + regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier) + if regErr == nil && regRecord != nil { + switch regRecord.AssetType { + case model.AssetTypeIotCard: + card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID) + if cardErr == nil && card != nil { + return card, nil, nil + } + case model.AssetTypeDevice: + device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID) + if devErr == nil && device != nil { + return nil, device, nil + } + } + } + } + return nil, nil, errors.New(errors.CodeNotFound, "未找到对应资产,请使用 ICCID 或虚拟号") +} + // buildOrderIdempotencyKey 生成订单创建的幂等性业务键 // 格式: {buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids} func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) string { @@ -2134,6 +2157,13 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte } } + assetType := "" + if order.OrderType == model.OrderTypeSingleCard { + assetType = "card" + } else if order.OrderType == model.OrderTypeDevice { + assetType = "device" + } + return &dto.OrderResponse{ ID: order.ID, OrderNo: order.OrderNo, @@ -2151,13 +2181,11 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte CommissionStatus: order.CommissionStatus, CommissionConfigVersion: order.CommissionConfigVersion, - // 操作者信息 OperatorID: order.OperatorID, OperatorType: order.OperatorType, OperatorName: operatorName, ActualPaidAmount: order.ActualPaidAmount, - // 订单角色 PurchaseRole: order.PurchaseRole, IsPurchasedByParent: isPurchasedByParent, PurchaseRemark: purchaseRemark, @@ -2166,9 +2194,11 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, - // 订单超时信息 ExpiresAt: order.ExpiresAt, IsExpired: order.ExpiresAt != nil && order.PaymentStatus == model.PaymentStatusPending && time.Now().After(*order.ExpiresAt), + + AssetIdentifier: order.AssetIdentifier, + AssetType: assetType, } } diff --git a/internal/service/purchase_validation/service.go b/internal/service/purchase_validation/service.go index 786341e..989629e 100644 --- a/internal/service/purchase_validation/service.go +++ b/internal/service/purchase_validation/service.go @@ -50,6 +50,10 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package return nil, err } + if !card.IsStandalone { + return nil, errors.New(errors.CodeInvalidParam, "该卡已绑定设备,请前往设备页面购买套餐") + } + if card.SeriesID == nil || *card.SeriesID == 0 { return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐") } @@ -197,6 +201,10 @@ func (s *Service) ValidateAdminOfflineCardPurchase(ctx context.Context, cardID u return nil, err } + if !card.IsStandalone { + return nil, errors.New(errors.CodeInvalidParam, "该卡已绑定设备,请前往设备页面购买套餐") + } + if card.SeriesID == nil || *card.SeriesID == 0 { return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐") } diff --git a/internal/store/postgres/asset_identifier_store.go b/internal/store/postgres/asset_identifier_store.go new file mode 100644 index 0000000..9de7286 --- /dev/null +++ b/internal/store/postgres/asset_identifier_store.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "gorm.io/gorm" +) + +// AssetIdentifierStore 全局资产标识符注册表存储层 +type AssetIdentifierStore struct { + db *gorm.DB +} + +// NewAssetIdentifierStore 创建资产标识符注册表存储实例 +func NewAssetIdentifierStore(db *gorm.DB) *AssetIdentifierStore { + return &AssetIdentifierStore{db: db} +} + +// Register 注册资产标识符 +// 在同一事务内调用,冲突时事务回滚 +func (s *AssetIdentifierStore) Register(ctx context.Context, identifier, assetType string, assetID uint) error { + record := &model.AssetIdentifier{ + Identifier: identifier, + AssetType: assetType, + AssetID: assetID, + } + return s.db.WithContext(ctx).Create(record).Error +} + +// FindByIdentifier 通过标识符精确查找资产注册记录 +// 未命中时返回 nil, nil(非错误状态,调用方按需 fallback) +func (s *AssetIdentifierStore) FindByIdentifier(ctx context.Context, identifier string) (*model.AssetIdentifier, error) { + var record model.AssetIdentifier + err := s.db.WithContext(ctx).Where("identifier = ?", identifier).First(&record).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &record, nil +} + +// DeleteByAsset 删除资产时清理注册表(软删除时调用) +// 删除该资产对应的所有注册记录,允许标识符被后续资产复用 +func (s *AssetIdentifierStore) DeleteByAsset(ctx context.Context, assetType string, assetID uint) error { + return s.db.WithContext(ctx). + Where("asset_type = ? AND asset_id = ?", assetType, assetID). + Delete(&model.AssetIdentifier{}).Error +} diff --git a/internal/store/postgres/exchange_order_store.go b/internal/store/postgres/exchange_order_store.go index 4accbbb..4a411ee 100644 --- a/internal/store/postgres/exchange_order_store.go +++ b/internal/store/postgres/exchange_order_store.go @@ -93,6 +93,24 @@ func (s *ExchangeOrderStore) UpdateStatus(ctx context.Context, id uint, fromStat return nil } +// FindByNewAssetID 通过新资产类型和ID查询换货来源记录 +// 用于换货链追溯:找到将当前资产作为换货结果的换货单,从而得到前代资产信息 +// 若未找到则返回 nil, nil 表示该资产无前代 +func (s *ExchangeOrderStore) FindByNewAssetID(ctx context.Context, assetType string, assetID uint) (*model.ExchangeOrder, error) { + var order model.ExchangeOrder + err := s.db.WithContext(ctx). + Where("new_asset_id = ? AND new_asset_type = ?", assetID, assetType). + Order("id DESC"). + First(&order).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &order, nil +} + func (s *ExchangeOrderStore) FindActiveByOldAsset(ctx context.Context, assetType string, assetID uint) (*model.ExchangeOrder, error) { var order model.ExchangeOrder query := s.db.WithContext(ctx). diff --git a/internal/store/postgres/order_store.go b/internal/store/postgres/order_store.go index 798f0db..d434914 100644 --- a/internal/store/postgres/order_store.go +++ b/internal/store/postgres/order_store.go @@ -133,6 +133,9 @@ func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters if v, ok := filters["end_time"]; ok { query = query.Where("created_at <= ?", v) } + if v, ok := filters["identifier"]; ok { + query = query.Where("asset_identifier = ?", v) + } if v, ok := filters["is_expired"]; ok { isExpired, _ := v.(bool) if isExpired { @@ -186,6 +189,28 @@ func (s *OrderStore) GenerateOrderNo() string { return fmt.Sprintf("ORD%s%06d", now.Format("20060102150405"), randomNum) } +// ListByAssetIdentifier 按资产标识符分页查询订单(倒序) +// 用于资产历史订单查询,直接走 asset_identifier 快照字段精确匹配 +// 数据权限已在调用方(asset service)的 Resolve 环节检查,此处不重复过滤 +func (s *OrderStore) ListByAssetIdentifier(ctx context.Context, identifier string, page, pageSize int) ([]*model.Order, int64, error) { + var orders []*model.Order + var total int64 + + query := s.db.WithContext(ctx).Model(&model.Order{}). + Where("asset_identifier = ?", identifier) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil { + return nil, 0, err + } + + return orders, total, nil +} + // FindExpiredOrders 查询已超时的待支付订单 // 查询条件:expires_at <= NOW() AND payment_status = 1(待支付) // limit 参数限制每次批量处理的数量,避免一次性加载太多数据 diff --git a/internal/task/device_import.go b/internal/task/device_import.go index a04c4d5..1ad77fc 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -35,6 +35,7 @@ type DeviceImportHandler struct { deviceSimBindingStore *postgres.DeviceSimBindingStore iotCardStore *postgres.IotCardStore assetWalletStore *postgres.AssetWalletStore + assetIdentifierStore *postgres.AssetIdentifierStore storageService *storage.Service logger *zap.Logger } @@ -47,6 +48,7 @@ func NewDeviceImportHandler( deviceSimBindingStore *postgres.DeviceSimBindingStore, iotCardStore *postgres.IotCardStore, assetWalletStore *postgres.AssetWalletStore, + assetIdentifierStore *postgres.AssetIdentifierStore, storageSvc *storage.Service, logger *zap.Logger, ) *DeviceImportHandler { @@ -58,6 +60,7 @@ func NewDeviceImportHandler( deviceSimBindingStore: deviceSimBindingStore, iotCardStore: iotCardStore, assetWalletStore: assetWalletStore, + assetIdentifierStore: assetIdentifierStore, storageService: storageSvc, logger: logger, } @@ -98,7 +101,7 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn zap.String("storage_key", importTask.StorageKey), ) - rows, totalCount, err := h.downloadAndParse(ctx, importTask) + parseResult, err := h.downloadAndParse(ctx, importTask) if err != nil { h.logger.Error("下载或解析 Excel 失败", zap.Uint("task_id", importTask.ID), @@ -108,9 +111,18 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn return asynq.SkipRetry } - result := h.processImport(ctx, importTask, rows, totalCount) + result := h.processImport(ctx, importTask, parseResult.Rows) - h.importTaskStore.UpdateResult(ctx, importTask.ID, totalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil) + for _, pe := range parseResult.ParseErrors { + result.failedItems = append(result.failedItems, model.ImportResultItem{ + Line: pe.Line, + ICCID: pe.ICCID, + Reason: pe.Reason, + }) + result.failCount++ + } + + h.importTaskStore.UpdateResult(ctx, importTask.ID, parseResult.TotalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil) if result.failCount > 0 && result.successCount == 0 { h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败") @@ -128,24 +140,24 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn return nil } -func (h *DeviceImportHandler) downloadAndParse(ctx context.Context, task *model.DeviceImportTask) ([]utils.DeviceRow, int, error) { +func (h *DeviceImportHandler) downloadAndParse(ctx context.Context, task *model.DeviceImportTask) (*utils.DeviceParseResult, error) { if h.storageService == nil { - return nil, 0, ErrStorageNotConfigured + return nil, ErrStorageNotConfigured } if task.StorageKey == "" { - return nil, 0, ErrStorageKeyEmpty + return nil, ErrStorageKeyEmpty } localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey) if err != nil { - return nil, 0, err + return nil, err } defer cleanup() if !strings.HasSuffix(strings.ToLower(task.FileName), ".xlsx") { ext := filepath.Ext(task.FileName) - return nil, 0, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext) + return nil, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext) } return utils.ParseDeviceExcel(localPath) @@ -159,7 +171,7 @@ type deviceImportResult struct { failedItems model.ImportResultItems } -func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []utils.DeviceRow, totalCount int) *deviceImportResult { +func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []utils.DeviceRow) *deviceImportResult { result := &deviceImportResult{ skippedItems: make(model.ImportResultItems, 0), failedItems: make(model.ImportResultItems, 0), @@ -283,6 +295,11 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi return err } + txIdentifierStore := postgres.NewAssetIdentifierStore(tx) + if err := txIdentifierStore.Register(ctx, device.VirtualNo, model.AssetTypeDevice, device.ID); err != nil { + return fmt.Errorf("虚拟号已被占用: %s", device.VirtualNo) + } + now := time.Now() for i, cardID := range validCardIDs { binding := &model.DeviceSimBinding{ @@ -297,7 +314,6 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi } } - // 创建设备资产钱包(设备存在即应有钱包,避免购买时才发现缺失) txWalletStore := postgres.NewAssetWalletStore(tx, nil) deviceWallet := &model.AssetWallet{ ResourceType: constants.AssetWalletResourceTypeDevice, diff --git a/internal/task/iot_card_import.go b/internal/task/iot_card_import.go index 2a8039b..571942a 100644 --- a/internal/task/iot_card_import.go +++ b/internal/task/iot_card_import.go @@ -41,14 +41,15 @@ type PollingCallback interface { } type IotCardImportHandler struct { - db *gorm.DB - redis *redis.Client - importTaskStore *postgres.IotCardImportTaskStore - iotCardStore *postgres.IotCardStore - assetWalletStore *postgres.AssetWalletStore - storageService *storage.Service - pollingCallback PollingCallback - logger *zap.Logger + db *gorm.DB + redis *redis.Client + importTaskStore *postgres.IotCardImportTaskStore + iotCardStore *postgres.IotCardStore + assetWalletStore *postgres.AssetWalletStore + assetIdentifierStore *postgres.AssetIdentifierStore + storageService *storage.Service + pollingCallback PollingCallback + logger *zap.Logger } func NewIotCardImportHandler( @@ -57,19 +58,21 @@ func NewIotCardImportHandler( importTaskStore *postgres.IotCardImportTaskStore, iotCardStore *postgres.IotCardStore, assetWalletStore *postgres.AssetWalletStore, + assetIdentifierStore *postgres.AssetIdentifierStore, storageSvc *storage.Service, pollingCallback PollingCallback, logger *zap.Logger, ) *IotCardImportHandler { return &IotCardImportHandler{ - db: db, - redis: redis, - importTaskStore: importTaskStore, - iotCardStore: iotCardStore, - assetWalletStore: assetWalletStore, - storageService: storageSvc, - pollingCallback: pollingCallback, - logger: logger, + db: db, + redis: redis, + importTaskStore: importTaskStore, + iotCardStore: iotCardStore, + assetWalletStore: assetWalletStore, + assetIdentifierStore: assetIdentifierStore, + storageService: storageSvc, + pollingCallback: pollingCallback, + logger: logger, } } @@ -108,7 +111,7 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as zap.String("storage_key", importTask.StorageKey), ) - cards, totalCount, err := h.downloadAndParse(ctx, importTask) + cards, totalCount, parseFailures, err := h.downloadAndParse(ctx, importTask) if err != nil { h.logger.Error("下载或解析 Excel 失败", zap.Uint("task_id", importTask.ID), @@ -124,6 +127,9 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as result := h.processImport(ctx, importTask) + result.failedItems = append(parseFailures, result.failedItems...) + result.failCount += len(parseFailures) + h.importTaskStore.UpdateResult(ctx, importTask.ID, result.successCount, result.skipCount, result.failCount, result.skippedItems, result.failedItems) if result.failCount > 0 && result.successCount == 0 { @@ -142,29 +148,29 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as return nil } -func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model.IotCardImportTask) (model.CardListJSON, int, error) { +func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model.IotCardImportTask) (model.CardListJSON, int, model.ImportResultItems, error) { if h.storageService == nil { - return nil, 0, ErrStorageNotConfigured + return nil, 0, nil, ErrStorageNotConfigured } if task.StorageKey == "" { - return nil, 0, ErrStorageKeyEmpty + return nil, 0, nil, ErrStorageKeyEmpty } localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey) if err != nil { - return nil, 0, err + return nil, 0, nil, err } defer cleanup() if !strings.HasSuffix(strings.ToLower(task.FileName), ".xlsx") { ext := filepath.Ext(task.FileName) - return nil, 0, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext) + return nil, 0, nil, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext) } parseResult, err := utils.ParseCardExcel(localPath) if err != nil { - return nil, 0, err + return nil, 0, nil, err } cards := make(model.CardListJSON, 0, len(parseResult.Cards)) @@ -176,7 +182,17 @@ func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model }) } - return cards, parseResult.TotalCount, nil + parseFailures := make(model.ImportResultItems, 0, len(parseResult.ParseErrors)) + for _, pe := range parseResult.ParseErrors { + parseFailures = append(parseFailures, model.ImportResultItem{ + Line: pe.Line, + ICCID: pe.ICCID, + MSISDN: pe.MSISDN, + Reason: pe.Reason, + }) + } + + return cards, parseResult.TotalCount, parseFailures, nil } type importResult struct { @@ -287,42 +303,44 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot return } - // 批量检查 virtual_no 唯一性 - virtualNos := make([]string, 0) + virtualNos := make([]string, 0, len(newCards)) for _, card := range newCards { - if card.VirtualNo != "" { - virtualNos = append(virtualNos, card.VirtualNo) - } + virtualNos = append(virtualNos, card.VirtualNo) } existingVirtualNos := make(map[string]bool) - if len(virtualNos) > 0 { - existingVirtualNos, err = h.iotCardStore.ExistsByVirtualNoBatch(ctx, virtualNos) - if err != nil { - h.logger.Error("批量检查 virtual_no 是否存在失败", - zap.Error(err), - zap.Int("batch_size", len(virtualNos)), - ) - } + existingVirtualNos, err = h.iotCardStore.ExistsByVirtualNoBatch(ctx, virtualNos) + if err != nil { + h.logger.Error("批量检查 virtual_no 是否存在失败", + zap.Error(err), + zap.Int("batch_size", len(virtualNos)), + ) } - // 批内去重:记录本批次已分配的 virtual_no batchUsedVirtualNos := make(map[string]bool) finalCards := make([]model.CardItem, 0, len(newCards)) for _, card := range newCards { meta := cardMetaMap[card.ICCID] - if card.VirtualNo != "" { - if existingVirtualNos[card.VirtualNo] || batchUsedVirtualNos[card.VirtualNo] { - result.failedItems = append(result.failedItems, model.ImportResultItem{ - Line: meta.line, - ICCID: card.ICCID, - MSISDN: meta.msisdn, - Reason: "virtual_no 已被占用: " + card.VirtualNo, - }) - result.failCount++ - continue - } - batchUsedVirtualNos[card.VirtualNo] = true + if card.VirtualNo == "" { + result.failedItems = append(result.failedItems, model.ImportResultItem{ + Line: meta.line, + ICCID: card.ICCID, + MSISDN: meta.msisdn, + Reason: "虚拟号(virtual_no)不能为空", + }) + result.failCount++ + continue } + if existingVirtualNos[card.VirtualNo] || batchUsedVirtualNos[card.VirtualNo] { + result.failedItems = append(result.failedItems, model.ImportResultItem{ + Line: meta.line, + ICCID: card.ICCID, + MSISDN: meta.msisdn, + Reason: "virtual_no 已被占用: " + card.VirtualNo, + }) + result.failCount++ + continue + } + batchUsedVirtualNos[card.VirtualNo] = true finalCards = append(finalCards, card) } @@ -330,9 +348,11 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot return } - iotCards := make([]*model.IotCard, 0, len(finalCards)) now := time.Now() + createdCards := make([]*model.IotCard, 0, len(finalCards)) + for _, card := range finalCards { + meta := cardMetaMap[card.ICCID] iotCard := &model.IotCard{ ICCID: card.ICCID, MSISDN: card.MSISDN, @@ -349,34 +369,42 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot iotCard.BaseModel.Updater = task.Creator iotCard.CreatedAt = now iotCard.UpdatedAt = now - iotCards = append(iotCards, iotCard) - } - if err := h.iotCardStore.CreateBatch(ctx, iotCards); err != nil { - h.logger.Error("批量创建 IoT 卡失败", - zap.Error(err), - zap.Int("batch_size", len(iotCards)), - ) - for _, card := range finalCards { - meta := cardMetaMap[card.ICCID] + txErr := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(iotCard).Error; err != nil { + return err + } + txIdentifierStore := postgres.NewAssetIdentifierStore(tx) + if err := txIdentifierStore.Register(ctx, card.ICCID, model.AssetTypeIotCard, iotCard.ID); err != nil { + return fmt.Errorf("ICCID 已被占用: %s", card.ICCID) + } + if err := txIdentifierStore.Register(ctx, card.VirtualNo, model.AssetTypeIotCard, iotCard.ID); err != nil { + return fmt.Errorf("虚拟号已被占用: %s", card.VirtualNo) + } + return nil + }) + if txErr != nil { + h.logger.Error("创建 IoT 卡失败", + zap.String("iccid", card.ICCID), + zap.Error(txErr), + ) result.failedItems = append(result.failedItems, model.ImportResultItem{ Line: meta.line, ICCID: card.ICCID, MSISDN: meta.msisdn, - Reason: "数据库写入失败", + Reason: txErr.Error(), }) result.failCount++ + continue } - return + createdCards = append(createdCards, iotCard) + result.successCount++ } - result.successCount += len(finalCards) - - // 为导入的卡批量创建资产钱包(钱包创建失败不影响卡导入结果,后续访问时 getOrCreateWallet 兜底) - h.batchCreateWallets(ctx, iotCards) + h.batchCreateWallets(ctx, createdCards) if h.pollingCallback != nil { - h.pollingCallback.OnBatchCardsCreated(ctx, iotCards) + h.pollingCallback.OnBatchCardsCreated(ctx, createdCards) } } diff --git a/migrations/000105_create_asset_identifier.down.sql b/migrations/000105_create_asset_identifier.down.sql new file mode 100644 index 0000000..8fc7548 --- /dev/null +++ b/migrations/000105_create_asset_identifier.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_asset_identifier_asset; +DROP TABLE IF EXISTS tb_asset_identifier; diff --git a/migrations/000105_create_asset_identifier.up.sql b/migrations/000105_create_asset_identifier.up.sql new file mode 100644 index 0000000..30dcb38 --- /dev/null +++ b/migrations/000105_create_asset_identifier.up.sql @@ -0,0 +1,14 @@ +-- 创建全局资产标识符注册表,保证 ICCID/VirtualNo 跨 tb_iot_card 和 tb_device 全局唯一 +CREATE TABLE IF NOT EXISTS tb_asset_identifier ( + id BIGSERIAL PRIMARY KEY, + identifier VARCHAR(100) NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_asset_identifier UNIQUE (identifier) +); +CREATE INDEX IF NOT EXISTS idx_asset_identifier_asset ON tb_asset_identifier(asset_type, asset_id); +COMMENT ON TABLE tb_asset_identifier IS '全局资产标识符注册表'; +COMMENT ON COLUMN tb_asset_identifier.identifier IS '标识符值(ICCID 或 VirtualNo,全局唯一)'; +COMMENT ON COLUMN tb_asset_identifier.asset_type IS '资产类型:iot_card 或 device'; +COMMENT ON COLUMN tb_asset_identifier.asset_id IS '对应资产的主键 ID'; diff --git a/migrations/000106_add_asset_identifier_to_order.down.sql b/migrations/000106_add_asset_identifier_to_order.down.sql new file mode 100644 index 0000000..de9efbf --- /dev/null +++ b/migrations/000106_add_asset_identifier_to_order.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_order DROP COLUMN IF EXISTS asset_identifier; diff --git a/migrations/000106_add_asset_identifier_to_order.up.sql b/migrations/000106_add_asset_identifier_to_order.up.sql new file mode 100644 index 0000000..39ebaaf --- /dev/null +++ b/migrations/000106_add_asset_identifier_to_order.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE tb_order ADD COLUMN IF NOT EXISTS asset_identifier VARCHAR(100); +COMMENT ON COLUMN tb_order.asset_identifier IS '下单时资产的标识符快照(ICCID 或 VirtualNo)'; diff --git a/migrations/000107_iot_card_virtual_no_not_null.down.sql b/migrations/000107_iot_card_virtual_no_not_null.down.sql new file mode 100644 index 0000000..c3a6f50 --- /dev/null +++ b/migrations/000107_iot_card_virtual_no_not_null.down.sql @@ -0,0 +1,11 @@ +-- 回滚:将 tb_iot_card.virtual_no 恢复为 nullable,并还原条件唯一索引 + +-- 删除新的无条件唯一索引 +DROP INDEX IF EXISTS idx_iot_card_virtual_no; + +-- 将 virtual_no 列改回允许 NULL +ALTER TABLE tb_iot_card ALTER COLUMN virtual_no DROP NOT NULL; + +-- 恢复旧的条件唯一索引(允许多条 NULL 值共存) +CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) + WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''; diff --git a/migrations/000107_iot_card_virtual_no_not_null.up.sql b/migrations/000107_iot_card_virtual_no_not_null.up.sql new file mode 100644 index 0000000..8d90cbc --- /dev/null +++ b/migrations/000107_iot_card_virtual_no_not_null.up.sql @@ -0,0 +1,15 @@ +-- 将 tb_iot_card.virtual_no 改为 NOT NULL,并重建唯一索引 +-- 背景:虚拟号是资产核心业务标识符,不允许为空 +-- 步骤:1. 清理空值记录(测试环境),2. 加约束,3. 重建索引 + +-- 清理空值记录(适用于测试环境) +DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''; + +-- 将 virtual_no 列改为 NOT NULL +ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL; + +-- 删除旧的条件唯一索引(含 IS NOT NULL AND <> '' 条件) +DROP INDEX IF EXISTS idx_iot_card_virtual_no; + +-- 重建无条件唯一索引(仅排除软删除记录) +CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL; diff --git a/openspec/changes/archive/2026-04-07-asset-historical-orders/.openspec.yaml b/openspec/changes/archive/2026-04-07-asset-historical-orders/.openspec.yaml new file mode 100644 index 0000000..2fe001e --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-historical-orders/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-07 diff --git a/openspec/changes/archive/2026-04-07-asset-historical-orders/design.md b/openspec/changes/archive/2026-04-07-asset-historical-orders/design.md new file mode 100644 index 0000000..1ded1ef --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-historical-orders/design.md @@ -0,0 +1,75 @@ +## Context + +系统中换货流程已有完整的数据记录: +- `ExchangeOrder` 表存有 `old_asset_id`、`old_asset_identifier`、`new_asset_id`、`new_asset_identifier`,形成资产的"世代链" +- `Order` 表通过提案一新增的 `asset_identifier` 快照字段,可直接按标识符查询订单 +- `Device`/`IotCard` 表有 `generation` 字段记录当前世代编号 + +当前缺失的是:把这三张表的信息串联起来,给管理员呈现"这个资产在各世代的订单全貌"。 + +## Goals / Non-Goals + +**Goals:** +- 新增 `GET /api/admin/assets/:identifier/orders` 接口,默认返回本代订单 +- 支持 `include_previous=true` 参数,追溯换货链返回前代订单(明确标注世代) +- 分页支持 + +**Non-Goals:** +- C 端不暴露跨代订单(C 端用户视角只关心当前资产本代) +- 修改订单数据本身(只读接口) +- 跨代订单的聚合统计(如历代总充值额),留给未来迭代 + +## Decisions + +### 决策 1:通过 ExchangeOrder 逆向追溯前代资产 ID + +**查询逻辑**: +``` +给定 identifier(当前资产): + 1. 通过注册表/Resolve 得到 asset_type + asset_id(当前代) + 2. 查当前资产的订单:WHERE asset_identifier = identifier(直接走快照字段) + 3. 若 include_previous=true: + a. 查 ExchangeOrder WHERE new_asset_id = asset_id(找到换货记录) + b. 得到 old_asset_id + old_asset_identifier(前代标识符) + c. 查前代订单:WHERE asset_identifier = old_asset_identifier + d. 递归至无更多前代(链式追溯,最多向前追溯 N 代,防止死循环) + 4. 合并结果,按世代分组,每条订单附加 generation 字段 +``` + +**理由**:直接使用 `asset_identifier` 快照字段查询,无需 JOIN;换货链通过 ExchangeOrder 逆向遍历,逻辑清晰。 + +### 决策 2:响应结构按世代分组,不跨代混合排序 + +**选择**:响应分为 `current_generation`(本代)和 `previous_generations`(前代数组)两个区块 + +**备选方案**:全部打平按时间排序。问题:管理员看到时间线上跳跃的世代可能产生困惑(订单时间早于资产创建时间) + +**理由**:分区块展示逻辑清晰——管理员一眼能看出"这是这台设备自己的订单"vs"这是上一台的历史" + +### 决策 3:限制追溯深度,防止换货链过长 + +最大追溯世代数为 **10 代**,超出则截断并在响应中说明(`truncated: true`)。实际业务中换货链极少超过 3 代,此限制仅为安全保障。 + +### 决策 4:本接口依赖提案一的 asset_identifier 快照字段 + +若提案一未完成(Order 表无 `asset_identifier` 字段),本接口降级为通过 `iot_card_id`/`device_id` 查询(只支持本代,不支持 include_previous)。实际按提案一完成后实现。 + +## Risks / Trade-offs + +| 风险 | 缓解措施 | +|------|---------| +| **换货链查询产生多次 DB 往返** | 每代一次 ExchangeOrder 查询 + 一次 Order 查询;链深度有限(≤10),总查询次数可控;可加 Redis 缓存换货链 | +| **旧订单 asset_identifier 为空**(提案一之前创建的订单)| 本接口限定只查有 `asset_identifier` 快照的订单;旧订单通过 `iot_card_id`/`device_id` fallback 查询补充,明确标注"旧格式订单" | +| **include_previous 导致响应体过大** | 分页仅对本代订单生效;前代订单默认返回最近 20 条,不支持前代分页(前代是历史归档,数量有限) | + +## Migration Plan + +1. 依赖提案一完成(`Order.asset_identifier` 字段存在) +2. 实现 `exchange_order_store.FindChainByNewAssetID()` +3. 实现 `asset_service.GetOrders()` +4. 注册路由和 Handler +5. 更新文档生成器 + +## Open Questions + +- 无 diff --git a/openspec/changes/archive/2026-04-07-asset-historical-orders/proposal.md b/openspec/changes/archive/2026-04-07-asset-historical-orders/proposal.md new file mode 100644 index 0000000..eea6d19 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-historical-orders/proposal.md @@ -0,0 +1,33 @@ +## Why + +当前系统的资产详情页面没有"历史订单"维度。管理员无法在查看某个资产时直接知道它卖给过谁、买过哪些套餐。换货流程(`ExchangeOrder`)已经在数据库层记录了旧资产→新资产的映射关系,但没有被利用来追溯前代订单。随着资产复用(换货后二次销售)场景增多,管理员需要一种方式能追溯到"这个资产的来源及历史"。 + +## What Changes + +- **[NEW]** 新增 B 端接口:`GET /api/admin/assets/:identifier/orders`,查询某资产本代的历史订单 +- **[NEW]** 支持查询参数 `include_previous=true`,通过换货链追溯并返回前代资产的订单(附带世代标注) +- 接口路径遵循提案一中已确立的 `:identifier` 统一规范(依赖提案一完成后的注册表和路由规范) + +## Capabilities + +### New Capabilities + +- `asset-historical-orders`:资产往期订单查询接口,支持本代/跨代两种视图,响应中明确标注每条订单所属世代 + +### Modified Capabilities + +(无——本提案仅新增接口,不修改现有接口行为) + +## Impact + +**受影响的代码**: +- `internal/handler/admin/asset.go`:新增 `Orders` handler +- `internal/routes/asset.go`:注册新路由 +- `internal/service/asset/service.go`:新增 `GetOrders()` 方法,包含换货链追溯逻辑 +- `internal/store/postgres/order_store.go`:新增按 `asset_identifier` 精确查询(依赖提案一的 `asset_identifier` 快照字段) +- `internal/store/postgres/exchange_order_store.go`:新增按资产 ID 查询换货链的方法 +- `internal/model/dto/asset_dto.go`:新增 `AssetOrdersRequest`、`AssetOrdersResponse`、`AssetOrderItem` DTO +- `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:注册新 Handler + +**前置依赖**: +- 提案一(`asset-identifier-standardization`)必须先完成:本提案依赖 `Order.asset_identifier` 快照字段和统一的 `:identifier` 路由规范 diff --git a/openspec/changes/archive/2026-04-07-asset-historical-orders/specs/asset-historical-orders/spec.md b/openspec/changes/archive/2026-04-07-asset-historical-orders/specs/asset-historical-orders/spec.md new file mode 100644 index 0000000..f43ae21 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-historical-orders/specs/asset-historical-orders/spec.md @@ -0,0 +1,108 @@ +## ADDED Requirements + +### Requirement: 查询资产本代历史订单 + +系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。 + +**API 端点**:`GET /api/admin/assets/:identifier/orders` + +**请求参数**: +- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填 +- `page`(query):页码,默认 1 +- `page_size`(query):每页数量,默认 20,最大 100 +- `include_previous`(query):是否包含前代订单,布尔值,默认 false + +**权限规则**: +- 代理用户:只能查看数据权限范围内资产的订单 +- 平台/超管:可查看所有资产订单 +- 企业账号:不支持此接口(返回 403) + +#### Scenario: 查询本代订单(默认) +- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders` +- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页 +- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号 + +#### Scenario: 资产无订单 +- **WHEN** 管理员查询一个从未购买过套餐的资产 +- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误 + +#### Scenario: identifier 不存在 +- **WHEN** 请求的 identifier 无法解析到任何资产 +- **THEN** 返回 HTTP 404,错误消息"资产不存在" + +#### Scenario: 代理查询无权限资产 +- **WHEN** 代理用户请求不属于其数据权限范围的资产订单 +- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在" + +### Requirement: 查询资产跨代历史订单(含前代) + +当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。 + +**追溯逻辑**: +1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源 +2. 得到前代的 `old_asset_identifier`,查询该标识符的订单 +3. 递归追溯,最多向前 10 代(安全上限) + +**响应结构(AssetOrdersResponse)**: +```json +{ + "current_generation": { + "generation": 2, + "identifier": "DEV-001", + "asset_type": "device", + "total": 5, + "page": 1, + "page_size": 20, + "items": [ ...订单列表... ] + }, + "previous_generations": [ + { + "generation": 1, + "identifier": "DEV-OLD-001", + "asset_type": "device", + "exchange_no": "EXC20260101XXXXXX", + "exchanged_at": "2026-01-01T00:00:00Z", + "total": 3, + "items": [ ...前代订单列表(最近20条)... ] + } + ], + "truncated": false +} +``` + +**每条订单项(AssetOrderItem)包含**: +- `order_no`:订单号 +- `order_type`:订单类型(single_card / device) +- `payment_status`:支付状态 +- `payment_status_text`:支付状态文本 +- `total_amount`:订单金额(分) +- `payment_method`:支付方式 +- `paid_at`:支付时间(可空) +- `generation`:订单所属资产世代 +- `items`:套餐明细列表 +- `created_at`:订单创建时间 + +#### Scenario: 查询换货后资产的全代际订单 +- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代) +- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2) +- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间 +- **THEN** `truncated=false`(未超出追溯上限) + +#### Scenario: 资产本身就是第一代(无前代) +- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货 +- **THEN** `previous_generations` 为空数组 `[]` +- **THEN** `current_generation` 正常返回本代订单 + +#### Scenario: 换货链超过追溯上限 +- **WHEN** 换货链深度超过 10 代 +- **THEN** 追溯在第 10 代截断,`truncated=true` +- **THEN** 已追溯到的前代数据正常返回 + +#### Scenario: 前代订单分页 +- **WHEN** 请求带 `include_previous=true` +- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效 +- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页) + +#### Scenario: 无 include_previous 时响应不含前代字段 +- **WHEN** 管理员请求不带 `include_previous=true`(或传 false) +- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽 diff --git a/openspec/changes/archive/2026-04-07-asset-historical-orders/tasks.md b/openspec/changes/archive/2026-04-07-asset-historical-orders/tasks.md new file mode 100644 index 0000000..404a296 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-historical-orders/tasks.md @@ -0,0 +1,54 @@ +## 1. 前置确认 + +- [x] 1.1 确认提案一(asset-identifier-standardization)已完成:`tb_order.asset_identifier` 字段存在,B 端资产路由已统一为 `:identifier` + +## 2. 数据层:ExchangeOrder Store 扩展 + +- [x] 2.1 在 `internal/store/postgres/exchange_order_store.go` 新增 `FindByNewAssetID(ctx, assetType, assetID) (*model.ExchangeOrder, error)` 方法:查询 `WHERE new_asset_id = ? AND new_asset_type = ?`,返回该资产的换货来源记录(找不到则返回 nil,表示无前代) + +## 3. DTO 层:新增响应结构 + +- [x] 3.1 在 `internal/model/dto/asset_dto.go` 新增以下结构体: + - `AssetOrdersRequest`:路径参数 `identifier`,query 参数 `page`、`page_size`、`include_previous` + - `AssetOrderItem`:单条订单项(order_no、order_type、payment_status、payment_status_text、total_amount、payment_method、paid_at、generation、items、created_at) + - `GenerationOrders`:单代订单块(generation、identifier、asset_type、total、page、page_size、items) + - `PreviousGenerationOrders`:前代订单块(generation、identifier、asset_type、exchange_no、exchanged_at、total、items) + - `AssetOrdersResponse`:完整响应(current_generation、previous_generations、truncated) + +## 4. 服务层:GetOrders 方法 + +- [x] 4.1 在 `internal/service/asset/service.go` 新增 `GetOrders(ctx, identifier, page, pageSize, includePrevious bool) (*dto.AssetOrdersResponse, error)` 方法: + - 调用 `Resolve()` 得到 asset_type 和 asset_id + - 查询本代订单:`orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)` + - 若 `includePrevious=true`:循环调用 `exchangeOrderStore.FindByNewAssetID()` 追溯换货链(最多 10 代);每代查询前代 `old_asset_identifier` 对应的订单(最多 20 条,无分页) + - 组装 `AssetOrdersResponse` 返回 +- [x] 4.2 在 `internal/store/postgres/order_store.go` 新增 `ListByAssetIdentifier(ctx, identifier, page, pageSize) ([]*model.Order, int64, error)` 方法:查询 `WHERE asset_identifier = ?`,分页,倒序 + +## 5. Handler 层:新增 Orders Handler + +- [x] 5.1 在 `internal/handler/admin/asset.go` 新增 `Orders(c *fiber.Ctx) error` handler: + - 从路径参数读取 `identifier`;从 query 读取 `page`、`page_size`、`include_previous` + - 调用 `assetService.GetOrders()` + - 返回 `response.Success(c, result)` + - 注释:`// Orders 查询资产历史订单(支持跨代追溯)` + `// GET /api/admin/assets/:identifier/orders` + +## 6. 路由层:注册新路由 + +- [x] 6.1 在 `internal/routes/asset.go` 注册:`assets.Get("/:identifier/orders", h.Asset.Orders)`(注意路由顺序,避免与 `/resolve/:identifier` 冲突) + +## 7. 文档生成器更新 + +- [x] 7.1 在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `handlers` 结构体中确认 `AssetHandler` 已包含新的 `Orders` 方法(检查是否需要更新 handler 注册) +- [x] 7.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新接口体现 + +## 8. 验证 + +- [x] 8.1 构建验证:`go build ./...` 无编译错误 +- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告 +- [x] 8.3 接口验证(使用 PostgreSQL MCP + curl): + - 查询有订单的资产:`GET /api/admin/assets/:identifier/orders` 返回正确订单列表,含 generation 字段 + - 查询无订单资产:返回 `items: [], total: 0` + - 查询不存在的 identifier:返回 404 + - 查询换货资产(带 include_previous=true):`previous_generations` 包含前代订单,附带 exchange_no 和 exchanged_at + - 查询无前代的资产(带 include_previous=true):`previous_generations` 为空数组 + - 代理查询无权限资产:返回 403 diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/.openspec.yaml b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/.openspec.yaml new file mode 100644 index 0000000..2fe001e --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-07 diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/design.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/design.md new file mode 100644 index 0000000..081e970 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/design.md @@ -0,0 +1,131 @@ +## Context + +当前系统中,资产(IoT 卡和设备)的操作接口混用两套参数规范: +- **B 端大多数接口**:使用数据库主键 ID(`/assets/:asset_type/:id/`、`/devices/:id/`),前端持有无业务含义的整数 ID +- **部分接口**:使用业务标识符(`/assets/card/:iccid/stop`、`/devices/by-identifier/:identifier/wifi`),但命名不一致 +- **C 端接口**:已规范使用 `identifier` query 参数(ICCID/VirtualNo) + +主要问题: +1. IoT 卡和设备各自表内有 VirtualNo 唯一索引,但**跨表没有全局唯一约束**,理论上可写入相同虚拟号 +2. 订单创建接口传入 `iot_card_id`/`device_id` 主键,响应也只返回 ID,不含标识符 +3. `purchase_validation` 未校验绑定设备的卡,允许对非独立卡(`is_standalone=false`)单独购买套餐 +4. `Resolve` 服务对两张表做 OR 多字段查询,性能随数据量增长有退化风险 + +## Goals / Non-Goals + +**Goals:** +- 建立全局资产标识符注册表(`tb_asset_identifier`),数据库层保证 VirtualNo/ICCID 跨表全局唯一 +- B 端所有资产操作接口统一使用 `:identifier` 路径参数(ICCID 或 VirtualNo) +- `Resolve` 主查询路径改走注册表,降低查询复杂度 +- 订单接口改用 `identifier` 传参,响应补充标识符快照字段 +- 修复绑定设备的卡可单独购买套餐的 Bug + +**Non-Goals:** +- IoT 卡导入强制 VirtualNo(属于提案三,独立推进) +- 资产往期订单查询接口(属于提案二,独立推进) +- C 端接口变更(C 端已规范,本次不动) +- Resolve 接口的 IMEI/SN/MSISDN fallback 查询(保留现有逻辑) + +## Decisions + +### 决策 1:使用独立注册表(tb_asset_identifier)实现跨表全局唯一 + +**选择**:新增 `tb_asset_identifier` 表,`identifier` 字段加 `UNIQUE` 约束 + +**备选方案**: +- *应用层检查*:插入前先查两张表,存在则拒绝。问题:高并发下存在竞态窗口,同一标识符可能被两个并发请求同时通过检查 +- *数据库触发器*:在 `tb_device` 和 `tb_iot_card` 上建触发器查对方表。问题:触发器调试困难,项目中无此先例,违背"逻辑在代码层"原则 + +**理由**:注册表方案通过数据库 UNIQUE 约束消除竞态——两个并发写入同一标识符时,数据库只允许一个成功,另一个收到约束冲突错误并回滚事务,安全可靠且符合项目"禁止外键,关联在代码层维护"的原则。 + +**表结构**: +```sql +CREATE TABLE tb_asset_identifier ( + id BIGSERIAL PRIMARY KEY, + identifier VARCHAR(100) NOT NULL, + asset_type VARCHAR(20) NOT NULL, -- 'iot_card' | 'device' + asset_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_asset_identifier UNIQUE (identifier) +); +CREATE INDEX idx_asset_identifier_asset ON tb_asset_identifier(asset_type, asset_id); +``` + +**写入时机**: +- 创建设备/IoT 卡时,在**同一事务**内写入注册表 +- 软删除时,同步从注册表删除(或标记 deleted_at,避免已删除的标识符阻塞复用) +- VirtualNo 更新时(目前无此场景,预留),先删旧记录再插新记录 + +### 决策 2:Resolve 主路径改走注册表,保留 fallback + +**选择**:`Resolve` 方法优先查 `tb_asset_identifier`(精确匹配),未命中再 fallback 到原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符) + +**理由**:注册表只存 VirtualNo 和 ICCID(全局唯一的标识符),IMEI/SN/MSISDN 因非唯一不写入注册表。Resolve 接口仍需支持这些模糊标识符(方便管理员查找),但精确路径走注册表可大幅提升性能。 + +``` +Resolve(identifier) 逻辑: + 1. 查 tb_asset_identifier WHERE identifier = ? + → 命中:得到 asset_type + asset_id,按 type 查完整记录 + → 未命中:fallback 到原有逻辑(OR 多字段查 device/iot_card) +``` + +### 决策 3:B 端接口统一 :identifier,不区分 card/device 类型 + +**选择**:路由改为 `/admin/assets/:identifier/*`,系统内部通过 Resolve 得到 asset_type,无需路径中显式传类型 + +**备选方案**: +- *保留 :asset_type 前缀*:`/admin/assets/card/:iccid/packages`。问题:调用方需要知道"这是卡还是设备"才能构造 URL,而 identifier 本身已能唯一定位 +- *两段式 Resolve + ID*:前端先调 Resolve 拿 ID 再操作。问题:前端耦合两个接口,且 ID 无业务含义 + +**理由**:identifier 已能唯一定位资产(通过注册表),asset_type 冗余;URL 语义更自然(`/assets/ICCID-001/packages` 而非 `/assets/card/ICCID-001/packages`)。 + +**注意**:仅操作类接口(packages/realtime/stop/start 等)使用统一 `:identifier`。设备绑卡管理(`/devices/:virtual_no/cards`)因属于设备专属操作,路径保留 `devices` 前缀但改用 VirtualNo。 + +### 决策 4:订单创建改用 identifier,系统内部解析 asset_type + +**选择**:`CreateOrderRequest` 去掉 `iot_card_id`/`device_id`,改为 `identifier` 字段,`order_type` 字段同时废弃(由 identifier 解析结果决定) + +**理由**:与接口统一规范一致;同时自然修复"绑定设备的卡可单独购买"Bug——解析 identifier 后得到 `is_standalone=false` 的卡,直接在 purchase_validation 层拦截。 + +**Order 模型快照**:订单记录中增加 `asset_identifier VARCHAR(100)` 字段,存储下单时资产的标识符快照(类似 `order_no` 的快照思路),便于历史查询而不依赖关联查询。 + +## Risks / Trade-offs + +| 风险 | 缓解措施 | +|------|---------| +| **Breaking 变更**:B 端所有资产路由改变,前端需全量更新 | 系统处于测试阶段,前端可同步切换;通过 OpenAPI 文档明确新路由 | +| **存量资产未在注册表**:现有 tb_device 和 tb_iot_card 数据需回填注册表 | 提供数据迁移脚本,回填时检查 VirtualNo 跨表唯一性;直接清库(测试阶段)则无此问题 | +| **注册表成为热点**:资产创建时额外写一张表 | 注册表操作在事务内,单次 INSERT;正常导入批量处理,影响极小 | +| **Resolve fallback 性能**:IMEI/SN 仍走全表 OR 查询 | fallback 路径为次要场景,精确路径(注册表)已涵盖主要查询;长期可为 IMEI/SN 建索引 | +| **order_type 废弃兼容**:历史订单含 order_type 字段 | 历史数据保留,新创建订单由系统根据 identifier 填入 order_type;API 不再接受 order_type 输入 | + +## Migration Plan + +**步骤一:数据库迁移** +1. 创建迁移文件,建立 `tb_asset_identifier` 表 +2. 执行回填脚本:将所有现有设备的 VirtualNo 和 IoT 卡的 ICCID/VirtualNo 写入注册表 +3. 测试阶段:直接清库则跳过步骤 2 + +**步骤二:后端实现(可分 PR 推进)** +1. 新增 `AssetIdentifier` 模型和 `asset_identifier_store` +2. 改造 `Resolve` 服务,注册表主路径 + fallback +3. 更新 `purchase_validation`,增加 `is_standalone` 校验 +4. 更新 `Order` 模型,增加 `asset_identifier` 快照字段 +5. 重写 B 端路由:`asset.go`、`device.go`、`iot_card.go`、`order.go` +6. 更新 DTO:`asset_dto.go`、`order_dto.go`、`client_order_dto.go` +7. 更新 Handler 层参数解析 +8. 更新 `gendocs` 文档生成 + +**步骤三:验证** +1. 全量 API 回归测试 +2. 并发写入同一 VirtualNo 压测(验证注册表唯一约束) +3. Resolve fallback 路径验证(IMEI/SN 查询) + +**回滚方案**:保留旧路由映射(别名路由)72 小时,确认前端切换完毕后删除 + +## Open Questions + +- ~~已解决:VirtualNo 跨表全局唯一策略(独立注册表)~~ +- ~~已解决:B 端路由统一方案(方案 B,统一 :identifier)~~ +- ~~已解决:订单传参方式(identifier,不传 asset_type)~~ +- **待确认**:Order 模型中 `asset_identifier` 快照字段的数据库类型选 `VARCHAR(100)` 是否满足所有标识符长度(ICCID 最长 20 位,VirtualNo 最长 100 位) diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/proposal.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/proposal.md new file mode 100644 index 0000000..6139301 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/proposal.md @@ -0,0 +1,53 @@ +## Why + +当前系统的 B 端资产操作接口大量使用数据库主键 ID(`/assets/:asset_type/:id/`、`/devices/:id/`)作为路径参数,导致接口语义模糊、前端被迫持有无业务含义的 ID、换货后旧 ID 失效等问题。与此同时,IoT 卡和设备的虚拟号(VirtualNo)没有跨表全局唯一约束,ICCID/VirtualNo 在多个子系统中的使用也不一致。现阶段系统尚处于开发测试期,是建立资产标识符规范的最佳窗口。 + +## What Changes + +- **[BREAKING]** B 端资产操作接口路径参数统一改为资产标识符(ICCID 或 VirtualNo),废弃基于数据库主键 ID 的路由 +- **[BREAKING]** 订单创建接口(B 端 + C 端)从传入 `iot_card_id`/`device_id` 主键改为传入 `identifier` 标识符 +- **[NEW]** 新增全局资产标识符注册表 `tb_asset_identifier`,在数据库层保证 ICCID/VirtualNo 跨 `tb_iot_card` 和 `tb_device` 两表全局唯一,防止并发写入冲突 +- **[NEW]** 资产解析服务(Resolve)主路径改走注册表,单次查询即可定位资产,替代原有的跨表 OR 查询 +- **[FIX]** 修复:已绑定设备的 IoT 卡(`is_standalone=false`)可被单独购买套餐的 Bug,改为直接报错并引导用户至设备页面购买 +- 订单查询列表新增按资产标识符过滤条件 +- 订单响应新增资产标识符字段,前端无需二次查询 +- 所有 B 端 `:id` 路由同步更新,前端需切换调用方式 + +## Capabilities + +### New Capabilities + +- `asset-identifier-registry`:全局资产标识符注册表(`tb_asset_identifier`)的模型定义、写入/查询接口,以及 Resolve 服务的注册表主路径改造 +- `asset-identifier-routes`:B 端统一标识符路由规范——将 `/assets/:asset_type/:id/*`、`/devices/:id/*`、`/iot-cards/:id/*` 全线迁移为 `/assets/:identifier/*`、`/devices/:virtual_no/*` + +### Modified Capabilities + +- `asset-resolve`:主查询路径改走 `tb_asset_identifier`;IMEI/SN/MSISDN 降级为 fallback 模糊查询 +- `order-management`:CreateAdminOrderRequest 改用 `identifier`;OrderResponse 新增 `asset_identifier` 字段;OrderListRequest 新增 `identifier` 过滤条件 +- `client-order-purchase`:CreateOrderRequest 改用 `identifier`,系统内部解析资产类型 +- `package-purchase-validation`:新增 `is_standalone` 校验——若卡已绑定设备则拒绝单卡购买套餐 + +## Impact + +**受影响的代码**: +- `internal/model/`:新增 `AssetIdentifier` 模型;`Order` 模型新增 `asset_identifier` 快照字段 +- `internal/store/postgres/`:新增 `asset_identifier_store.go`;`order_store.go` 新增标识符过滤 +- `internal/service/asset/`:`Resolve` 方法改走注册表 +- `internal/service/purchase_validation/`:新增 `is_standalone` 校验 +- `internal/handler/admin/`:`asset.go`、`device.go`、`iot_card.go`、`order.go` 全面更新路径参数和请求/响应 DTO +- `internal/handler/app/`:`client_order.go` 更新请求 DTO +- `internal/routes/`:`asset.go`、`device.go`、`iot_card.go`、`order.go` 路由重注册 +- `internal/model/dto/`:`asset_dto.go`、`order_dto.go`、`client_order_dto.go` 字段变更 +- `migrations/`:新增迁移文件创建 `tb_asset_identifier` 表 + +**受影响的 API(BREAKING)**: +- `GET /api/admin/assets/:asset_type/:id/*`(7 个端点) +- `POST /api/admin/assets/device/:device_id/stop|start` +- `DELETE /api/admin/devices/:id` +- `GET|POST|DELETE /api/admin/devices/:id/cards*` +- `PATCH /api/admin/devices/:id/deactivate` +- `PATCH /api/admin/iot-cards/:id/deactivate` +- `POST /api/admin/orders`(请求体变更) +- `POST /api/c/v1/orders/create`(请求体变更) + +**前端影响**:所有使用 `asset_id` 做路径参数的页面必须切换为使用 `identifier`(ICCID 或 VirtualNo) diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-registry/spec.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-registry/spec.md new file mode 100644 index 0000000..0c7d23d --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-registry/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: 全局资产标识符注册表 + +系统 SHALL 维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险。 + +注册表仅存储**全局唯一标识符**(ICCID、VirtualNo),不存储 IMEI/SN/MSISDN 等非唯一标识符。 + +**表字段**: +- `id`:自增主键 +- `identifier`:标识符值(VARCHAR(100),UNIQUE 约束) +- `asset_type`:资产类型(`iot_card` 或 `device`) +- `asset_id`:对应资产的主键 ID +- `created_at`:写入时间 + +#### Scenario: 创建设备时注册 VirtualNo +- **WHEN** 创建新设备(通过导入或 API),VirtualNo 非空 +- **THEN** 系统在同一事务内向 `tb_asset_identifier` 写入一条记录(identifier=VirtualNo, asset_type=device, asset_id=新设备ID) +- **THEN** 若 VirtualNo 已在注册表中存在,事务回滚,返回错误"虚拟号已被占用" + +#### Scenario: 创建 IoT 卡时注册 ICCID +- **WHEN** 创建新 IoT 卡(通过导入),ICCID 非空 +- **THEN** 系统在同一事务内向注册表写入(identifier=ICCID, asset_type=iot_card, asset_id=新卡ID) +- **THEN** 若 ICCID 已在注册表中存在,事务回滚,返回错误"ICCID 已被占用" + +#### Scenario: 创建 IoT 卡时注册 VirtualNo(如有) +- **WHEN** 创建新 IoT 卡时 VirtualNo 非空 +- **THEN** 系统额外向注册表写入(identifier=VirtualNo, asset_type=iot_card, asset_id=新卡ID) +- **THEN** 若 VirtualNo 已被其他设备或卡占用,事务回滚,返回错误"虚拟号已被占用" + +#### Scenario: 并发写入同一标识符 +- **WHEN** 两个并发请求同时尝试注册相同的 VirtualNo(如 "CARD-001") +- **THEN** 数据库 UNIQUE 约束保证只有一个写入成功;另一个收到唯一约束冲突错误,事务回滚,返回错误"虚拟号已被占用" + +#### Scenario: 软删除资产时清理注册表 +- **WHEN** 软删除设备或 IoT 卡 +- **THEN** 系统在同一事务内删除 `tb_asset_identifier` 中对应的记录(所有 asset_id 匹配的行),允许标识符被后续资产复用 + +#### Scenario: 通过标识符精确查找资产 +- **WHEN** 系统需要根据 identifier(ICCID 或 VirtualNo)定位资产 +- **THEN** 系统查询 `SELECT * FROM tb_asset_identifier WHERE identifier = ?`,一次查询得到 asset_type 和 asset_id +- **THEN** 再按 asset_type 查对应表(`tb_device` 或 `tb_iot_card`)取完整记录 + +#### Scenario: 查询不存在的标识符 +- **WHEN** 查询注册表中不存在的 identifier +- **THEN** 返回空结果(not found),调用方可 fallback 到原有查询逻辑 diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-routes/spec.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-routes/spec.md new file mode 100644 index 0000000..d4ccdd6 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-identifier-routes/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: B 端资产操作接口统一使用标识符路径参数 + +B 端所有资产操作类接口 SHALL 使用资产标识符(ICCID 或 VirtualNo)作为路径参数,废弃原有基于数据库主键 ID 的路由。系统内部通过注册表将标识符解析为资产实体,Handler 层无需关心 ID。 + +**标识符规则**: +- IoT 卡:接受 ICCID 或 VirtualNo(均为全局唯一) +- 设备:接受 VirtualNo(全局唯一) +- 不接受 IMEI/SN/MSISDN(非唯一,仅 Resolve 的 fallback 路径支持) + +**废弃的旧路由 → 新路由映射**: + +| 旧路由(废弃) | 新路由 | +|---|---| +| `GET /api/admin/assets/:asset_type/:id/realtime-status` | `GET /api/admin/assets/:identifier/realtime-status` | +| `POST /api/admin/assets/:asset_type/:id/refresh` | `POST /api/admin/assets/:identifier/refresh` | +| `GET /api/admin/assets/:asset_type/:id/packages` | `GET /api/admin/assets/:identifier/packages` | +| `GET /api/admin/assets/:asset_type/:id/current-package` | `GET /api/admin/assets/:identifier/current-package` | +| `GET /api/admin/assets/:asset_type/:id/wallet` | `GET /api/admin/assets/:identifier/wallet` | +| `GET /api/admin/assets/:asset_type/:id/wallet/transactions` | `GET /api/admin/assets/:identifier/wallet/transactions` | +| `PATCH /api/admin/assets/:asset_type/:id/polling-status` | `PATCH /api/admin/assets/:identifier/polling-status` | +| `POST /api/admin/assets/device/:device_id/stop` | `POST /api/admin/assets/:identifier/stop` | +| `POST /api/admin/assets/device/:device_id/start` | `POST /api/admin/assets/:identifier/start` | +| `POST /api/admin/assets/card/:iccid/stop` | `POST /api/admin/assets/:identifier/stop`(合并) | +| `POST /api/admin/assets/card/:iccid/start` | `POST /api/admin/assets/:identifier/start`(合并) | +| `DELETE /api/admin/devices/:id` | `DELETE /api/admin/devices/:virtual_no` | +| `GET /api/admin/devices/:id/cards` | `GET /api/admin/devices/:virtual_no/cards` | +| `POST /api/admin/devices/:id/cards` | `POST /api/admin/devices/:virtual_no/cards` | +| `DELETE /api/admin/devices/:id/cards/:cardId` | `DELETE /api/admin/devices/:virtual_no/cards/:iccid` | +| `PATCH /api/admin/devices/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate` | +| `PATCH /api/admin/iot-cards/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate`(合并) | + +#### Scenario: 通过 ICCID 操作 IoT 卡 +- **WHEN** 管理员请求 `GET /api/admin/assets/898600XXXXXXXX/packages` +- **THEN** 系统解析 ICCID,找到对应 IoT 卡,返回该卡的套餐列表 + +#### Scenario: 通过 VirtualNo 操作设备 +- **WHEN** 管理员请求 `POST /api/admin/assets/DEV-001/stop` +- **THEN** 系统解析 VirtualNo,找到对应设备,执行批量停机(停该设备下所有已实名卡) + +#### Scenario: 通过 VirtualNo 操作绑定了设备的 IoT 卡(停机) +- **WHEN** 管理员请求 `POST /api/admin/assets/CARD-001/stop`,CARD-001 是 IoT 卡的 VirtualNo +- **THEN** 系统解析 VirtualNo,找到 IoT 卡,执行单卡停机 + +#### Scenario: stop/start 接口对卡和设备行为差异 +- **WHEN** identifier 解析为 IoT 卡时调用 stop +- **THEN** 执行单卡停机 +- **WHEN** identifier 解析为设备时调用 stop +- **THEN** 执行设备停机(批量停机该设备下所有已实名卡) + +#### Scenario: 标识符不存在 +- **WHEN** 管理员请求的 `:identifier` 在注册表和 fallback 查询中均未找到对应资产 +- **THEN** 返回 HTTP 404,错误消息"资产不存在" + +#### Scenario: 无权限操作该资产 +- **WHEN** 代理用户请求的 identifier 对应的资产不属于该代理的数据权限范围 +- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在" + +#### Scenario: 设备绑卡管理使用设备 VirtualNo +- **WHEN** 管理员请求 `GET /api/admin/devices/DEV-001/cards` +- **THEN** 系统通过 VirtualNo 找到设备,返回该设备绑定的卡列表 + +#### Scenario: 设备解绑卡使用 ICCID +- **WHEN** 管理员请求 `DELETE /api/admin/devices/DEV-001/cards/898600XXXXXXXX` +- **THEN** 系统通过 VirtualNo 找到设备,通过 ICCID 找到卡,执行解绑 + +### Requirement: 新路由下标识符的解析性能 + +资产操作接口中标识符解析 SHALL 优先走注册表(单次精确查询),保证解析延迟不超过 10ms(在正常数据库负载下)。 + +#### Scenario: 注册表命中路径 +- **WHEN** 请求携带的 identifier 存在于 `tb_asset_identifier` +- **THEN** 系统单次查询注册表得到 asset_type 和 asset_id,无需扫描 tb_device 或 tb_iot_card + +#### Scenario: 注册表未命中(fallback) +- **WHEN** 请求携带的 identifier 不在注册表(如 IMEI 或旧数据) +- **THEN** 系统 fallback 到原有多字段 OR 查询,同样能定位资产(性能稍低,为次要路径) diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-resolve/spec.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-resolve/spec.md new file mode 100644 index 0000000..16fbf48 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/asset-resolve/spec.md @@ -0,0 +1,69 @@ +## MODIFIED Requirements + +### Requirement: 统一资产解析入口 + +系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。 + +**API 端点**: `GET /api/admin/assets/resolve/:identifier` + +**查找顺序(更新后)**: +1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id,直接查对应表取完整记录 +2. **Fallback 路径**(注册表未命中时): + - 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`) + - 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`) +3. 两条路径均未命中 → 返回 HTTP 404 + +**数据权限规则**: +- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产 +- 平台用户(SuperAdmin/Platform):可查看所有资产 +- 企业账号:暂不支持此接口,调用时返回 HTTP 403 + +**响应结构(AssetResolveResponse)**: + +*通用字段(device 和 card 均有)*: +- `asset_type`: 资产类型(`"device"` 或 `"card"`) +- `asset_id`: 资产主键 ID +- `identifier`: 本次查询所用的标识符(原样回传) +- `virtual_no`: 虚拟号(设备/卡均使用此字段) +- `status`: 资产状态(整型) +- `asset_status`: 业务状态(1-在库 2-已销售 3-已换货 4-已停用) +- `generation`: 资产世代编号 +- `batch_no`: 批次号 +- `shop_id`: 所属店铺 ID(平台库存时为空) +- `shop_name`: 所属店铺名称 +- `series_id`: 套餐系列 ID(未绑定时为空) +- `series_name`: 套餐系列名称 +- `first_commission_paid`: 一次性佣金是否已发放 +- `accumulated_recharge`: 累计充值金额(分) +- `activated_at`: 激活时间(未激活时为空) +- `created_at`: 创建时间 +- `updated_at`: 更新时间 + +*状态与套餐字段(device 和 card 均有)*: +- `real_name_status`: 实名状态(整型) +- `current_package`: 当前套餐名称(无套餐时返回空字符串) +- `package_total_mb`、`package_used_mb`、`package_remain_mb`: 套餐流量信息 +- `device_protect_status`: 保护期状态 + +*绑定关系字段*: +- `iccid`: 仅 card 类型时有值 +- `bound_device_id`、`bound_device_no`、`bound_device_name`: 仅 card 类型且绑定设备时有值 +- `bound_card_count`、`cards`: 仅 device 类型时有值 + +#### Scenario: 通过注册表主路径精确解析 +- **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID +- **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms + +#### Scenario: Fallback 路径解析 IMEI +- **WHEN** 管理员输入 identifier 为设备 IMEI(不在注册表中) +- **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息 +- **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值 + +#### Scenario: Fallback 路径解析 MSISDN +- **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN) +- **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段 +- **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志) + +#### Scenario: 标识符完全不存在 +- **WHEN** 管理员输入的 identifier 在注册表和 fallback 均未找到 +- **THEN** 返回 HTTP 404,错误消息"资产不存在" diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/client-order-purchase/spec.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..c9c8698 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/client-order-purchase/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: 客户端创建套餐购买订单 + +系统 SHALL 允许个人客户为其资产创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。 + +**客户端订单创建请求(CreateOrderRequest)**: +```json +{ + "identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)", + "package_ids": "[uint](套餐 ID 列表,必填,1-10 个)", + "payment_method": "string(wallet|wechat|alipay,必填)" +} +``` + +**废弃字段**: +- `iot_card_id`(原单卡购买时必填) +- `device_id`(原设备购买时必填) +- `order_type`(改为系统根据 identifier 解析结果自动填入) + +#### Scenario: 个人客户使用 VirtualNo 购买设备套餐 +- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }` +- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发 + +#### Scenario: 个人客户使用 ICCID 购买单卡套餐 +- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }` +- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单 + +#### Scenario: 个人客户尝试为绑定设备的卡购买套餐 +- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡 +- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐" + +#### Scenario: 个人客户只能操作自己绑定的资产 +- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户 +- **THEN** 系统返回 HTTP 403"无权限操作该资源" diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/order-management/spec.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/order-management/spec.md new file mode 100644 index 0000000..e4e143b --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/order-management/spec.md @@ -0,0 +1,66 @@ +## MODIFIED Requirements + +### Requirement: 创建套餐购买订单 + +系统 SHALL 允许买家创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段;`order_type` 字段由系统根据 identifier 解析结果自动填入,请求体不再接受。 + +**后台订单创建请求(CreateAdminOrderRequest)**: +```json +{ + "identifier": "string(资产标识符,ICCID 或 VirtualNo,必填)", + "package_ids": "[uint](套餐 ID 列表,必填,1-10 个)", + "payment_method": "string(wallet|offline,必填)" +} +``` + +**废弃字段**: +- `iot_card_id`(原用于单卡购买) +- `device_id`(原用于设备购买) +- `order_type`(改为系统自动推断) + +#### Scenario: 使用 VirtualNo 创建设备订单 +- **WHEN** 管理员请求体携带 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wallet" }` +- **THEN** 系统解析 identifier 为设备,自动设置 `order_type = device`,创建设备购买订单 + +#### Scenario: 使用 ICCID 创建单卡订单 +- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX", package_ids: [5], payment_method: "wallet" }` +- **THEN** 系统解析 identifier 为独立 IoT 卡(`is_standalone = true`),自动设置 `order_type = single_card`,创建单卡购买订单 + +#### Scenario: 使用绑定设备的卡 ICCID 创建订单被拒 +- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX" }` 但该卡 `is_standalone = false` +- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐",订单不创建 + +#### Scenario: identifier 不存在 +- **WHEN** 请求的 identifier 无法解析到任何资产 +- **THEN** 返回错误"资产不存在",订单不创建 + +## ADDED Requirements + +### Requirement: 订单响应包含资产标识符 + +订单响应(OrderResponse)SHALL 包含资产标识符字段,前端无需额外请求即可展示资产信息。 + +**新增响应字段**: +- `asset_identifier`:下单时资产的标识符快照(ICCID 或 VirtualNo) +- `asset_type`:资产类型(`single_card` 对应 `iot_card`,`device` 对应 `device`) + +#### Scenario: 查看订单详情时展示资产标识符 +- **WHEN** 管理员查询订单详情 `GET /api/admin/orders/:id` +- **THEN** 响应中包含 `asset_identifier`(如 "DEV-001")和 `asset_type`(如 "device") +- **THEN** 即使原资产已被删除,`asset_identifier` 仍可读(快照字段) + +### Requirement: 订单列表支持按资产标识符过滤 + +系统 SHALL 在订单列表查询(OrderListRequest)中支持 `identifier` 过滤参数,按照资产标识符精确匹配订单(匹配 `asset_identifier` 快照字段)。 + +#### Scenario: 按 ICCID 查询该卡的历史订单 +- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=898600XXXXX` +- **THEN** 返回 `asset_identifier = "898600XXXXX"` 的所有订单(分页) + +#### Scenario: 按设备 VirtualNo 查询该设备的历史订单 +- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=DEV-001` +- **THEN** 返回 `asset_identifier = "DEV-001"` 的所有订单(分页) + +#### Scenario: 标识符无匹配订单 +- **WHEN** 管理员查询不存在订单的 identifier +- **THEN** 返回空列表(`items: []`,`total: 0`),不返回错误 diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/package-purchase-validation/spec.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/package-purchase-validation/spec.md new file mode 100644 index 0000000..bf06ad7 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/specs/package-purchase-validation/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: 已绑定设备的卡不允许单独购买套餐 + +购买套餐验证时,系统 MUST 检查目标 IoT 卡是否为独立卡(`is_standalone = true`)。若卡已绑定设备(`is_standalone = false`),必须拒绝购买并引导用户至对应设备页面操作。 + +此规则适用于所有套餐购买入口(C 端个人客户、B 端代理/平台)。 + +#### Scenario: 独立卡正常购买 +- **WHEN** 买家为 IoT 卡购买套餐,该卡的 `is_standalone = true`(未绑定任何设备) +- **THEN** 验证通过,继续后续购买流程 + +#### Scenario: 已绑定设备的卡被单独购买 +- **WHEN** 买家使用 IoT 卡的 ICCID 或 VirtualNo 购买套餐,该卡的 `is_standalone = false` +- **THEN** 系统拒绝购买,返回错误码 `CodeInvalidParam`,错误消息"该卡已绑定设备,请前往设备页面购买套餐" + +#### Scenario: B 端代理通过卡标识符购买套餐(绑定了设备的卡) +- **WHEN** 代理使用已绑定设备的卡的 ICCID 创建订单 +- **THEN** 系统在 `ValidateCardPurchase()` 中检查 `is_standalone`,返回错误,订单不创建 diff --git a/openspec/changes/archive/2026-04-07-asset-identifier-standardization/tasks.md b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/tasks.md new file mode 100644 index 0000000..49ca4bd --- /dev/null +++ b/openspec/changes/archive/2026-04-07-asset-identifier-standardization/tasks.md @@ -0,0 +1,81 @@ +## 1. 数据库迁移:全局标识符注册表 + +- [x] 1.1 创建迁移文件:`tb_asset_identifier` 表(identifier UNIQUE、asset_type、asset_id、created_at),建立 `idx_asset_identifier_asset(asset_type, asset_id)` 索引 +- [x] 1.2 执行迁移,验证表结构和唯一约束正确 + +## 2. 数据层:AssetIdentifier 模型与 Store + +- [x] 2.1 创建 `internal/model/asset_identifier.go`:定义 `AssetIdentifier` 结构体(含 GORM 标签、TableName) +- [x] 2.2 创建 `internal/store/postgres/asset_identifier_store.go`:实现 `Register(ctx, identifier, assetType, assetID)`(写入注册表)、`FindByIdentifier(ctx, identifier)`(精确查询)、`DeleteByAsset(ctx, assetType, assetID)`(删除资产时清理) +- [x] 2.3 在 `internal/bootstrap/stores.go` 注入 `AssetIdentifierStore` + +## 3. 数据层:Order 模型新增 asset_identifier 字段 + +- [x] 3.1 创建迁移文件:`tb_order` 表增加 `asset_identifier VARCHAR(100)` 字段 +- [x] 3.2 更新 `internal/model/order.go`:新增 `AssetIdentifier string` 字段(含 GORM 标签和注释) +- [x] 3.3 更新 `internal/store/postgres/order_store.go`:列表查询新增 `identifier` 过滤条件(精确匹配 `asset_identifier`) + +## 4. 服务层:Resolve 改走注册表 + +- [x] 4.1 更新 `internal/service/asset/service.go`:`Resolve` 方法优先查 `AssetIdentifierStore.FindByIdentifier()`,命中则按 asset_type 查对应表;未命中 fallback 原有逻辑 +- [x] 4.2 在 `AssetService` 中注入 `AssetIdentifierStore` 依赖 + +## 5. 服务层:purchase_validation 增加 is_standalone 校验 + +- [x] 5.1 更新 `internal/service/purchase_validation/service.go`:在 `ValidateCardPurchase()` 中增加 `if !card.IsStandalone { return error "该卡已绑定设备,请前往设备页面购买套餐" }` 校验(在套餐校验之前执行) + +## 6. 服务层:资产创建/删除时同步注册表 + +- [x] 6.1 更新设备导入任务 `internal/task/device_import.go`:创建设备成功后在同一事务内调用 `AssetIdentifierStore.Register()` 注册 VirtualNo;若冲突则拒绝该行 +- [x] 6.2 更新 IoT 卡导入任务 `internal/task/iot_card_import.go`:创建卡成功后注册 ICCID(必填)和 VirtualNo(如有)到注册表;冲突则拒绝该行 +- [x] 6.3 更新设备/IoT 卡的软删除逻辑:删除时调用 `AssetIdentifierStore.DeleteByAsset()` 清理注册表 + +## 7. 服务层:订单创建改用 identifier + +- [x] 7.1 更新 `internal/service/order/service.go`(B 端):`Create()` 方法接收 `identifier` 字符串,内部调用 `AssetService.Resolve()` 解析资产,不再接受 `iot_card_id`/`device_id`;写入订单时填入 `asset_identifier` 快照字段 +- [x] 7.2 更新 `internal/service/client_order/service.go`(C 端):同上,接收 `identifier`,解析后写入 `asset_identifier` 快照 + +## 8. DTO 层:更新请求/响应结构 + +- [x] 8.1 更新 `internal/model/dto/order_dto.go`:`CreateAdminOrderRequest` 去掉 `IotCardID`/`DeviceID`/`OrderType`,新增 `Identifier string`;`OrderResponse` 新增 `AssetIdentifier string` 和 `AssetType string`;`OrderListRequest` 新增 `Identifier string` +- [x] 8.2 更新 `internal/model/dto/client_order_dto.go`(`CreateOrderRequest`):同上,去掉 `IotCardID`/`DeviceID`/`OrderType`,新增 `Identifier string` +- [x] 8.3 更新 `internal/model/dto/asset_dto.go`:`AssetTypeIDRequest` 废弃,`AssetResolveRequest` 确认 `Identifier` 字段已有;新增 `AssetIdentifierRequest`(路径参数,供操作接口使用) +- [x] 8.4 确认 `AssetResolveResponse` 新增 `Identifier` 字段(回传请求中使用的标识符) + +## 9. Handler 层:B 端资产操作接口更新 + +- [x] 9.1 更新 `internal/handler/admin/asset.go`:`RealtimeStatus`/`Refresh`/`Packages`/`CurrentPackage`/`UpdatePollingStatus`/`StopDevice`/`StartDevice`/`StopCard`/`StartCard` 均改为从路径参数 `:identifier` 读取标识符,调用 Resolve 解析后操作;合并 Stop/Start 卡和设备为统一的 `Stop`/`Start` handler(内部按资产类型分支) +- [x] 9.2 更新 `internal/handler/admin/asset.go`:新增 `Deactivate` handler(合并 IoT 卡和设备停用,原 `/iot-cards/:id/deactivate` 和 `/devices/:id/deactivate`) +- [x] 9.3 更新 `internal/handler/admin/device.go`:`Delete`/`ListCards`/`BindCard`/`UnbindCard` 改为从路径参数 `:virtual_no` 读取设备标识;`UnbindCard` 第二段路径参数改为 `:iccid` + +## 10. Handler 层:B 端订单 Handler 更新 + +- [x] 10.1 更新 `internal/handler/admin/order.go`:`Create` handler 解析 `CreateAdminOrderRequest.Identifier` 字段,移除 `IotCardID`/`DeviceID` 解析逻辑;`List` handler 支持 `identifier` query 参数传入 Store 过滤 + +## 11. Handler 层:C 端订单 Handler 更新 + +- [x] 11.1 更新 `internal/handler/app/client_order.go`:`CreateOrder` handler 解析 `CreateOrderRequest.Identifier`,移除旧字段解析 + +## 12. 路由层:重注册所有受影响路由 + +- [x] 12.1 更新 `internal/routes/asset.go`:将所有 `:asset_type/:id` 路由改为 `:identifier`;合并卡/设备的 stop/start/deactivate 路由 +- [x] 12.2 更新 `internal/routes/device.go`:`/:id` 改为 `/:virtual_no`;解绑路由 `/:id/cards/:cardId` 改为 `/:virtual_no/cards/:iccid` +- [x] 12.3 更新 `internal/routes/iot_card.go`:移除 `/:id/deactivate`(已并入 asset.go 的统一路由) +- [x] 12.4 更新 `internal/routes/order.go`:无路由变更,仅确认 Handler 已更新 + +## 13. 文档生成器更新 + +- [x] 13.1 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:确保新 Handler 方法已注册(Deactivate 等新合并 handler 需加入) +- [x] 13.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新路由和 DTO 正确体现 + +## 14. 验证 + +- [x] 14.1 构建验证:`go build ./...` 无编译错误 +- [x] 14.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告 +- [ ] 14.3 接口验证(使用 PostgreSQL MCP 和 curl): + - 通过 ICCID 调用 `GET /api/admin/assets/:identifier/packages` 返回正确套餐列表 + - 通过 VirtualNo 调用 `POST /api/admin/assets/:identifier/stop` 执行停机 + - 使用绑定设备的卡 identifier 创建订单,验证返回"该卡已绑定设备"错误 + - 使用独立卡 identifier 创建订单成功,OrderResponse 包含 `asset_identifier` 字段 + - 并发写入同一 VirtualNo 到注册表,验证只有一条成功 +- [ ] 14.4 订单列表验证:`GET /api/admin/orders?identifier=CARD-001` 返回该资产的订单 diff --git a/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/.openspec.yaml b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/.openspec.yaml new file mode 100644 index 0000000..2fe001e --- /dev/null +++ b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-07 diff --git a/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/design.md b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/design.md new file mode 100644 index 0000000..953de54 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/design.md @@ -0,0 +1,64 @@ +## Context + +**设备**:`tb_device.virtual_no` 已是 `NOT NULL + UNIQUE`(数据库层已强制)。但 `pkg/utils/excel.go` 中设备行解析遇到空 VirtualNo 会 `continue`(跳过该行),导入任务报告跳过数量但不报告失败原因,用户无法知道为什么某些行没被导入。 + +**IoT 卡**:`tb_iot_card.virtual_no` 当前为 nullable,唯一索引条件是 `WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''`(允许多条 NULL 值)。导入时空 VirtualNo 的卡直接进库,不做任何处理。 + +两者均需要把"安静跳过/允许空值"改为"明确报错",并在数据库层为 IoT 卡补上 NOT NULL 约束。 + +## Goals / Non-Goals + +**Goals:** +- 导入时 VirtualNo 为空 → 报告为失败行,包含行号和原因 +- `tb_iot_card.virtual_no` 加 NOT NULL 约束 +- 更新 Excel 解析层、任务处理层、模型层 + +**Non-Goals:** +- 为现有 NULL 记录自动生成 VirtualNo(测试阶段直接清库) +- IoT 卡导入的其他字段校验(不在本提案范围内) +- VirtualNo 格式校验(长度/字符集约束,留给未来) + +## Decisions + +### 决策 1:在 Excel 解析层(pkg/utils/excel.go)拦截空值,不在任务层 + +**选择**:在 `parseCardRows()` 和 `parseDeviceRows()` 中,当 VirtualNo 为空时,将该行加入解析错误列表(ParseError),而非 `continue` 跳过 + +**备选方案**:在任务处理层(`iot_card_import.go` / `device_import.go`)拦截。问题:任务层已有处理批次逻辑,较复杂;解析层更早发现问题,更符合"fail fast"原则 + +**理由**:越早发现越好;解析层返回 ParseErrors 与当前批量验证逻辑(ICCID 格式校验已在此处)风格一致 + +### 决策 2:IoT 卡数据库层迁移分两步 + +1. **先清库**(测试阶段手动执行):`DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''` +2. **再加约束**(迁移文件): + - `ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL` + - 重建唯一索引,去掉 `WHERE virtual_no IS NOT NULL AND virtual_no <> ''` 条件,改为无条件唯一 + +**理由**:先清数据再加约束,迁移不会失败;两步操作在同一迁移文件中完成,原子执行(PostgreSQL 支持事务内 DDL) + +### 决策 3:设备导入不需要数据库迁移,只改导入行为 + +设备的 VirtualNo 数据库层已是 NOT NULL,不需要迁移。只需把 `excel.go` 中 `if row.VirtualNo == "" { continue }` 改为加入失败列表即可。 + +## Risks / Trade-offs + +| 风险 | 缓解措施 | +|------|---------| +| **现有使用方的 Excel 模板无 VirtualNo 列** | 属于 BREAKING 变更,提前通知;导入失败信息明确说明"VirtualNo 为必填列" | +| **迁移前 IoT 卡存在 NULL 记录导致 ALTER 失败** | 迁移文件中先执行 DELETE 清理(适用测试环境),再 ALTER;生产环境需手动确认数据干净后执行 | +| **IoT 卡在注册表(提案一)中未注册 VirtualNo** | 本提案独立于提案一;若提案一先完成,IoT 卡导入时需同步注册 VirtualNo 到注册表(在提案一 task 6.2 中已覆盖) | + +## Migration Plan + +1. 清理测试库中 `virtual_no IS NULL` 的 IoT 卡记录 +2. 执行数据库迁移:`tb_iot_card.virtual_no` 加 NOT NULL,重建唯一索引 +3. 更新 `pkg/utils/excel.go`(解析层) +4. 更新 `internal/task/iot_card_import.go`(任务层,去除残余跳过逻辑) +5. 更新 `internal/task/device_import.go`(任务层,同上) +6. 更新 `internal/model/iot_card.go`(GORM tag) +7. 更新文档 `docs/excel-import-frontend-guide.md` + +## Open Questions + +- 无 diff --git a/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/proposal.md b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/proposal.md new file mode 100644 index 0000000..7d591fe --- /dev/null +++ b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/proposal.md @@ -0,0 +1,38 @@ +## Why + +虚拟号(VirtualNo)是系统中资产的核心业务标识符,也是提案一确立的接口参数规范的基础。然而当前导入流程将其作为可选字段:IoT 卡导入时 VirtualNo 可为空(数据库层 nullable),设备导入时 VirtualNo 为空的行会被静默跳过而非报错。这导致可能存在没有虚拟号的资产进入系统,无法通过统一标识符接口操作,与资产标识符规范化目标冲突。 + +## What Changes + +- **[BREAKING]** IoT 卡导入:VirtualNo 改为必填,Excel 中 VirtualNo 列为空的行报错拒绝 +- **[BREAKING]** 设备导入:VirtualNo 为空的行从原有的"静默跳过"改为"记录为失败行"并报告原因 +- **[NEW]** 数据库迁移:`tb_iot_card.virtual_no` 添加 NOT NULL 约束和去除条件唯一索引改为无条件唯一索引(配合清库执行) +- 更新 Excel 导入错误提示,明确 VirtualNo 为必填项 +- 更新接口文档,标注 VirtualNo 为必填列 + +## Capabilities + +### New Capabilities + +(无新能力——本提案为约束强化,不新增业务功能) + +### Modified Capabilities + +- `iot-card-import-task`:VirtualNo 校验规则从"可选"改为"必填",空值触发失败行记录 +- `device-import`:VirtualNo 校验规则从"跳过空值行"改为"空值行计入失败并报告原因" + +## Impact + +**受影响的代码**: +- `pkg/utils/excel.go`:`parseCardRows()` 和设备行解析逻辑,增加 VirtualNo 空值校验 +- `internal/task/iot_card_import.go`:移除"空 VirtualNo 跳过"逻辑,改为记录失败行 +- `internal/task/device_import.go`:将"空 VirtualNo 跳过"改为"记录失败行" +- `internal/model/iot_card.go`:`VirtualNo` 字段 GORM tag 更新,去掉 nullable,更新注释 +- `migrations/`:新增迁移文件,为 `tb_iot_card.virtual_no` 加 NOT NULL 约束 + +**前置条件**: +- 测试数据库中现有 `virtual_no IS NULL` 的 IoT 卡记录需先清除(测试阶段直接清库) +- 设备的 `virtual_no` 在数据库层已经是 NOT NULL,无需迁移,仅修改导入行为 + +**破坏性影响**: +- 现有 IoT 卡导入 Excel 模板如果没有 VirtualNo 列,导入将全部失败;需通知使用方更新模板 diff --git a/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/device-import/spec.md b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/device-import/spec.md new file mode 100644 index 0000000..d53d53c --- /dev/null +++ b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/device-import/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: 设备批量导入 + +系统 SHALL 支持通过 Excel 文件批量导入设备。**[BREAKING]** `virtual_no` 为空的行行为变更:原行为为"静默跳过(skip)",改为"记录为失败行(fail)并报告原因"。 + +**失败原因文本(新增)**: +- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"` + +#### Scenario: 正常导入(VirtualNo 有值) +- **WHEN** Excel 中某行 virtual_no="DEV-001",其他字段合法 +- **THEN** 导入成功,设备记录写入数据库 + +#### Scenario: VirtualNo 为空的行记录为失败(原为跳过) +- **WHEN** Excel 中某行 virtual_no 列为空或未填写 +- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空" +- **THEN** 该行不再计入 `skip_count` +- **THEN** 其他合法行继续导入,不因此行中断 + +#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因 +- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行 +- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空",`line` 字段为对应行号 diff --git a/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/iot-card-import-task/spec.md b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/iot-card-import-task/spec.md new file mode 100644 index 0000000..152f779 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/specs/iot-card-import-task/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: IoT 卡批量导入 + +系统 SHALL 支持通过 Excel 文件批量导入 IoT 卡。**[BREAKING]** `virtual_no` 列由可选改为必填:Excel 中 `virtual_no` 列为空的行,系统 MUST 将其记录为失败行并报告原因,不得静默跳过或将其写入数据库。 + +**必填列(更新后)**: +- `iccid`(必填):ICCID,电信 19 位/其他 20 位,格式校验 +- `virtual_no`(**必填,由可选改为必填**):虚拟号,全局唯一,1-50 字符 +- `msisdn`(可选):手机号/接入号 + +**失败原因文本(中文)**: +- ICCID 为空:`"ICCID 不能为空"` +- ICCID 格式错误:`"ICCID 格式错误,应为19-20位数字"` +- VirtualNo 为空:`"虚拟号(virtual_no)不能为空"`(新增) +- VirtualNo 已被占用:`"虚拟号已被占用: <值>"`(已有) +- ICCID 已存在:`"ICCID 已存在: <值>"`(已有) + +#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值) +- **WHEN** Excel 中某行 ICCID="898600XXXXX",virtual_no="CARD-001" +- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier` + +#### Scenario: VirtualNo 为空的行被拒绝 +- **WHEN** Excel 中某行 ICCID="898600YYYYY",virtual_no 列为空或未填写 +- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空" +- **THEN** 其他合法行继续导入,不因此行中断 + +#### Scenario: VirtualNo 重复被拒绝 +- **WHEN** Excel 中某行的 virtual_no 与已有卡/设备的 VirtualNo 重复(跨表) +- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>" + +#### Scenario: Excel 文件中无 VirtualNo 列 +- **WHEN** Excel 表头中不包含 `virtual_no`/`虚拟号`/`设备号` 等可识别列名 +- **THEN** 所有数据行均因 VirtualNo 为空而失败,导入结果中 `fail_count = 总行数`,`success_count = 0` +- **THEN** 返回错误提示:"Excel 文件缺少 virtual_no 列,请使用最新模板" + +#### Scenario: 导入任务完成后的结果报告 +- **WHEN** 导入任务处理完毕 +- **THEN** 结果包含:`success_count`、`fail_count`、`skip_count`、失败明细列表(含行号、原因) +- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因 diff --git a/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/tasks.md b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/tasks.md new file mode 100644 index 0000000..9c37248 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-import-mandatory-virtual-no/tasks.md @@ -0,0 +1,66 @@ +## 1. 数据库迁移:IoT 卡 VirtualNo NOT NULL + +- [x] 1.1 手动清理测试库:执行 `DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`,确认影响行数为 0 后继续(或直接清库) +- [x] 1.2 创建迁移文件(如 `000XXX_iot_card_virtual_no_not_null.up.sql`): + ```sql + ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL; + DROP INDEX IF EXISTS idx_iot_card_virtual_no; + CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL; + ``` +- [x] 1.3 创建对应 down 迁移文件(回滚为 nullable) +- [x] 1.4 执行迁移,验证约束生效(尝试插入 virtual_no=NULL 的记录应失败) + +## 2. 模型层:更新 IotCard GORM Tag + +- [x] 2.1 更新 `internal/model/iot_card.go` 中 `VirtualNo` 字段的 GORM tag: + - 去掉 `omitempty`(JSON tag) + - 将唯一索引条件从 `where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''` 简化为 `where:deleted_at IS NULL` + - 更新字段注释:从"虚拟号(可空,全局唯一)"改为"虚拟号(必填,全局唯一)" + +## 3. Excel 解析层:IoT 卡 VirtualNo 必填校验 + +- [x] 3.1 更新 `pkg/utils/excel.go` 的 `parseCardRows()` 函数(或等效解析逻辑): + - 当 `virtualNo == ""` 时,不再跳过,而是将该行追加到解析错误列表:`ParseErrors = append(ParseErrors, ParseError{Line: lineNum, Reason: "虚拟号(virtual_no)不能为空"})` + - 若 Excel 文件中完全没有 VirtualNo 列(`virtualNoCol == -1`),在函数开头直接返回错误:`return nil, errors.New(errors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板")` + - 确认函数返回值能携带 ParseErrors(若当前无此结构,需扩展返回类型) + +## 4. Excel 解析层:设备 VirtualNo 改为失败行 + +- [x] 4.1 更新 `pkg/utils/excel.go` 的设备行解析逻辑: + - 找到 `if row.VirtualNo == "" { continue }` 代码(当前静默跳过) + - 改为将该行追加到失败列表:`failedRows = append(failedRows, FailedRow{Line: row.Line, Reason: "设备虚拟号(virtual_no)不能为空"})` + - 确认 `skip_count` 不再计入此类行,改计入 `fail_count` + +## 5. 任务处理层:IoT 卡导入移除残余跳过逻辑 + +- [x] 5.1 检查 `internal/task/iot_card_import.go` 中所有 `if card.VirtualNo != ""` 条件: + - 唯一性检查处(当前只对非空 VirtualNo 检查):移除条件,强制检查所有行的 VirtualNo + - 批内去重处:同样移除条件 + - 若 VirtualNo 为空的行此时能到达任务处理层(不应发生,因解析层已拦截),也在此处记录失败并跳过 + +## 6. 任务处理层:设备导入统计修正 + +- [x] 6.1 检查 `internal/task/device_import.go` 中处理空 VirtualNo 行的逻辑: + - 确认 Excel 解析层改动后,空 VirtualNo 行已以 `FailedRow` 形式传入,不再需要任务层的额外 `continue` 跳过 + - 验证 `result.failCount` 正确累加,`result.skipCount` 不受影响 + +## 7. 文档更新 + +- [x] 7.1 更新 `docs/excel-import-frontend-guide.md` 及路由描述(`internal/routes/iot_card.go`, `internal/routes/device.go`): + - IoT 卡导入模板字段表:`virtual_no` 列的"必填"列从"否"改为"是" + - 新增说明:"`virtual_no` 为必填列,留空将导致该行导入失败" + - 设备导入模板字段表:`virtual_no` 列同样标注"必填",并说明"留空不再跳过,将记录为失败行" + +## 8. 验证 + +- [x] 8.1 构建验证:`go build ./...` 无编译错误(本次修改包 build OK;bootstrap 已有预存在错误与本次无关) +- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告 +- [ ] 8.3 IoT 卡导入验证(使用 Hurl 或 curl): + - 上传含完整 VirtualNo 的 Excel:导入成功,success_count 正确 + - 上传含空 VirtualNo 行的 Excel:该行 fail_count+1,reason 为"虚拟号(virtual_no)不能为空",其他行正常导入 + - 上传无 VirtualNo 列的 Excel:全部失败,返回"Excel 文件缺少 virtual_no 列" + - 上传重复 VirtualNo 的 Excel:该行 fail_count+1,reason 为"虚拟号已被占用: <值>" +- [ ] 8.4 设备导入验证: + - 上传含空 VirtualNo 行的 Excel:该行计入 fail_count(而非 skip_count),reason 为"设备虚拟号(virtual_no)不能为空" +- [x] 8.5 数据库约束验证(PostgreSQL MCP): + - `virtual_no` 列 is_nullable=NO 已验证;新索引 `CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL` 已验证 diff --git a/openspec/specs/asset-historical-orders/spec.md b/openspec/specs/asset-historical-orders/spec.md new file mode 100644 index 0000000..cc64689 --- /dev/null +++ b/openspec/specs/asset-historical-orders/spec.md @@ -0,0 +1,106 @@ +### Requirement: 查询资产本代历史订单 + +系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。 + +**API 端点**:`GET /api/admin/assets/:identifier/orders` + +**请求参数**: +- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填 +- `page`(query):页码,默认 1 +- `page_size`(query):每页数量,默认 20,最大 100 +- `include_previous`(query):是否包含前代订单,布尔值,默认 false + +**权限规则**: +- 代理用户:只能查看数据权限范围内资产的订单 +- 平台/超管:可查看所有资产订单 +- 企业账号:不支持此接口(返回 403) + +#### Scenario: 查询本代订单(默认) +- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders` +- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页 +- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号 + +#### Scenario: 资产无订单 +- **WHEN** 管理员查询一个从未购买过套餐的资产 +- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误 + +#### Scenario: identifier 不存在 +- **WHEN** 请求的 identifier 无法解析到任何资产 +- **THEN** 返回 HTTP 404,错误消息"资产不存在" + +#### Scenario: 代理查询无权限资产 +- **WHEN** 代理用户请求不属于其数据权限范围的资产订单 +- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在" + +### Requirement: 查询资产跨代历史订单(含前代) + +当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。 + +**追溯逻辑**: +1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源 +2. 得到前代的 `old_asset_identifier`,查询该标识符的订单 +3. 递归追溯,最多向前 10 代(安全上限) + +**响应结构(AssetOrdersResponse)**: +```json +{ + "current_generation": { + "generation": 2, + "identifier": "DEV-001", + "asset_type": "device", + "total": 5, + "page": 1, + "page_size": 20, + "items": [ ...订单列表... ] + }, + "previous_generations": [ + { + "generation": 1, + "identifier": "DEV-OLD-001", + "asset_type": "device", + "exchange_no": "EXC20260101XXXXXX", + "exchanged_at": "2026-01-01T00:00:00Z", + "total": 3, + "items": [ ...前代订单列表(最近20条)... ] + } + ], + "truncated": false +} +``` + +**每条订单项(AssetOrderItem)包含**: +- `order_no`:订单号 +- `order_type`:订单类型(single_card / device) +- `payment_status`:支付状态 +- `payment_status_text`:支付状态文本 +- `total_amount`:订单金额(分) +- `payment_method`:支付方式 +- `paid_at`:支付时间(可空) +- `generation`:订单所属资产世代 +- `items`:套餐明细列表 +- `created_at`:订单创建时间 + +#### Scenario: 查询换货后资产的全代际订单 +- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代) +- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2) +- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间 +- **THEN** `truncated=false`(未超出追溯上限) + +#### Scenario: 资产本身就是第一代(无前代) +- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货 +- **THEN** `previous_generations` 为空数组 `[]` +- **THEN** `current_generation` 正常返回本代订单 + +#### Scenario: 换货链超过追溯上限 +- **WHEN** 换货链深度超过 10 代 +- **THEN** 追溯在第 10 代截断,`truncated=true` +- **THEN** 已追溯到的前代数据正常返回 + +#### Scenario: 前代订单分页 +- **WHEN** 请求带 `include_previous=true` +- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效 +- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页) + +#### Scenario: 无 include_previous 时响应不含前代字段 +- **WHEN** 管理员请求不带 `include_previous=true`(或传 false) +- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽 diff --git a/openspec/specs/asset-identifier-registry/spec.md b/openspec/specs/asset-identifier-registry/spec.md new file mode 100644 index 0000000..73808bb --- /dev/null +++ b/openspec/specs/asset-identifier-registry/spec.md @@ -0,0 +1,52 @@ +# Capability: 全局资产标识符注册表 + +## Purpose + +维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险,同时提供高性能的标识符→资产 ID 精确查找能力。 + +## Requirements + +### Requirement: 全局资产标识符注册表 + +系统 SHALL 维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险。 + +注册表仅存储**全局唯一标识符**(ICCID、VirtualNo),不存储 IMEI/SN/MSISDN 等非唯一标识符。 + +**表字段**: +- `id`:自增主键 +- `identifier`:标识符值(VARCHAR(100),UNIQUE 约束) +- `asset_type`:资产类型(`iot_card` 或 `device`) +- `asset_id`:对应资产的主键 ID +- `created_at`:写入时间 + +#### Scenario: 创建设备时注册 VirtualNo +- **WHEN** 创建新设备(通过导入或 API),VirtualNo 非空 +- **THEN** 系统在同一事务内向 `tb_asset_identifier` 写入一条记录(identifier=VirtualNo, asset_type=device, asset_id=新设备ID) +- **THEN** 若 VirtualNo 已在注册表中存在,事务回滚,返回错误"虚拟号已被占用" + +#### Scenario: 创建 IoT 卡时注册 ICCID +- **WHEN** 创建新 IoT 卡(通过导入),ICCID 非空 +- **THEN** 系统在同一事务内向注册表写入(identifier=ICCID, asset_type=iot_card, asset_id=新卡ID) +- **THEN** 若 ICCID 已在注册表中存在,事务回滚,返回错误"ICCID 已被占用" + +#### Scenario: 创建 IoT 卡时注册 VirtualNo(如有) +- **WHEN** 创建新 IoT 卡时 VirtualNo 非空 +- **THEN** 系统额外向注册表写入(identifier=VirtualNo, asset_type=iot_card, asset_id=新卡ID) +- **THEN** 若 VirtualNo 已被其他设备或卡占用,事务回滚,返回错误"虚拟号已被占用" + +#### Scenario: 并发写入同一标识符 +- **WHEN** 两个并发请求同时尝试注册相同的 VirtualNo(如 "CARD-001") +- **THEN** 数据库 UNIQUE 约束保证只有一个写入成功;另一个收到唯一约束冲突错误,事务回滚,返回错误"虚拟号已被占用" + +#### Scenario: 软删除资产时清理注册表 +- **WHEN** 软删除设备或 IoT 卡 +- **THEN** 系统在同一事务内删除 `tb_asset_identifier` 中对应的记录(所有 asset_id 匹配的行),允许标识符被后续资产复用 + +#### Scenario: 通过标识符精确查找资产 +- **WHEN** 系统需要根据 identifier(ICCID 或 VirtualNo)定位资产 +- **THEN** 系统查询 `SELECT * FROM tb_asset_identifier WHERE identifier = ?`,一次查询得到 asset_type 和 asset_id +- **THEN** 再按 asset_type 查对应表(`tb_device` 或 `tb_iot_card`)取完整记录 + +#### Scenario: 查询不存在的标识符 +- **WHEN** 查询注册表中不存在的 identifier +- **THEN** 返回空结果(not found),调用方可 fallback 到原有查询逻辑 diff --git a/openspec/specs/asset-identifier-routes/spec.md b/openspec/specs/asset-identifier-routes/spec.md new file mode 100644 index 0000000..6dde8fc --- /dev/null +++ b/openspec/specs/asset-identifier-routes/spec.md @@ -0,0 +1,86 @@ +# Capability: 资产操作路由标识符标准化 + +## Purpose + +定义 B 端资产操作类接口统一使用资产标识符(ICCID 或 VirtualNo)作为路径参数的规范,废弃原有基于数据库主键 ID 的路由,并规定标识符解析的性能要求。 + +## Requirements + +### Requirement: B 端资产操作接口统一使用标识符路径参数 + +B 端所有资产操作类接口 SHALL 使用资产标识符(ICCID 或 VirtualNo)作为路径参数,废弃原有基于数据库主键 ID 的路由。系统内部通过注册表将标识符解析为资产实体,Handler 层无需关心 ID。 + +**标识符规则**: +- IoT 卡:接受 ICCID 或 VirtualNo(均为全局唯一) +- 设备:接受 VirtualNo(全局唯一) +- 不接受 IMEI/SN/MSISDN(非唯一,仅 Resolve 的 fallback 路径支持) + +**废弃的旧路由 → 新路由映射**: + +| 旧路由(废弃) | 新路由 | +|---|---| +| `GET /api/admin/assets/:asset_type/:id/realtime-status` | `GET /api/admin/assets/:identifier/realtime-status` | +| `POST /api/admin/assets/:asset_type/:id/refresh` | `POST /api/admin/assets/:identifier/refresh` | +| `GET /api/admin/assets/:asset_type/:id/packages` | `GET /api/admin/assets/:identifier/packages` | +| `GET /api/admin/assets/:asset_type/:id/current-package` | `GET /api/admin/assets/:identifier/current-package` | +| `GET /api/admin/assets/:asset_type/:id/wallet` | `GET /api/admin/assets/:identifier/wallet` | +| `GET /api/admin/assets/:asset_type/:id/wallet/transactions` | `GET /api/admin/assets/:identifier/wallet/transactions` | +| `PATCH /api/admin/assets/:asset_type/:id/polling-status` | `PATCH /api/admin/assets/:identifier/polling-status` | +| `POST /api/admin/assets/device/:device_id/stop` | `POST /api/admin/assets/:identifier/stop` | +| `POST /api/admin/assets/device/:device_id/start` | `POST /api/admin/assets/:identifier/start` | +| `POST /api/admin/assets/card/:iccid/stop` | `POST /api/admin/assets/:identifier/stop`(合并) | +| `POST /api/admin/assets/card/:iccid/start` | `POST /api/admin/assets/:identifier/start`(合并) | +| `DELETE /api/admin/devices/:id` | `DELETE /api/admin/devices/:virtual_no` | +| `GET /api/admin/devices/:id/cards` | `GET /api/admin/devices/:virtual_no/cards` | +| `POST /api/admin/devices/:id/cards` | `POST /api/admin/devices/:virtual_no/cards` | +| `DELETE /api/admin/devices/:id/cards/:cardId` | `DELETE /api/admin/devices/:virtual_no/cards/:iccid` | +| `PATCH /api/admin/devices/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate` | +| `PATCH /api/admin/iot-cards/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate`(合并) | + +#### Scenario: 通过 ICCID 操作 IoT 卡 +- **WHEN** 管理员请求 `GET /api/admin/assets/898600XXXXXXXX/packages` +- **THEN** 系统解析 ICCID,找到对应 IoT 卡,返回该卡的套餐列表 + +#### Scenario: 通过 VirtualNo 操作设备 +- **WHEN** 管理员请求 `POST /api/admin/assets/DEV-001/stop` +- **THEN** 系统解析 VirtualNo,找到对应设备,执行批量停机(停该设备下所有已实名卡) + +#### Scenario: 通过 VirtualNo 操作绑定了设备的 IoT 卡(停机) +- **WHEN** 管理员请求 `POST /api/admin/assets/CARD-001/stop`,CARD-001 是 IoT 卡的 VirtualNo +- **THEN** 系统解析 VirtualNo,找到 IoT 卡,执行单卡停机 + +#### Scenario: stop/start 接口对卡和设备行为差异 +- **WHEN** identifier 解析为 IoT 卡时调用 stop +- **THEN** 执行单卡停机 +- **WHEN** identifier 解析为设备时调用 stop +- **THEN** 执行设备停机(批量停机该设备下所有已实名卡) + +#### Scenario: 标识符不存在 +- **WHEN** 管理员请求的 `:identifier` 在注册表和 fallback 查询中均未找到对应资产 +- **THEN** 返回 HTTP 404,错误消息"资产不存在" + +#### Scenario: 无权限操作该资产 +- **WHEN** 代理用户请求的 identifier 对应的资产不属于该代理的数据权限范围 +- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在" + +#### Scenario: 设备绑卡管理使用设备 VirtualNo +- **WHEN** 管理员请求 `GET /api/admin/devices/DEV-001/cards` +- **THEN** 系统通过 VirtualNo 找到设备,返回该设备绑定的卡列表 + +#### Scenario: 设备解绑卡使用 ICCID +- **WHEN** 管理员请求 `DELETE /api/admin/devices/DEV-001/cards/898600XXXXXXXX` +- **THEN** 系统通过 VirtualNo 找到设备,通过 ICCID 找到卡,执行解绑 + +--- + +### Requirement: 新路由下标识符的解析性能 + +资产操作接口中标识符解析 SHALL 优先走注册表(单次精确查询),保证解析延迟不超过 10ms(在正常数据库负载下)。 + +#### Scenario: 注册表命中路径 +- **WHEN** 请求携带的 identifier 存在于 `tb_asset_identifier` +- **THEN** 系统单次查询注册表得到 asset_type 和 asset_id,无需扫描 tb_device 或 tb_iot_card + +#### Scenario: 注册表未命中(fallback) +- **WHEN** 请求携带的 identifier 不在注册表(如 IMEI 或旧数据) +- **THEN** 系统 fallback 到原有多字段 OR 查询,同样能定位资产(性能稍低,为次要路径) diff --git a/openspec/specs/asset-resolve/spec.md b/openspec/specs/asset-resolve/spec.md index f08c481..fd8b616 100644 --- a/openspec/specs/asset-resolve/spec.md +++ b/openspec/specs/asset-resolve/spec.md @@ -12,10 +12,12 @@ **API 端点**: `GET /api/admin/assets/resolve/:identifier` -**查找顺序**: -1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`) -2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`) -3. 两表均未命中 → 返回 HTTP 404 +**查找顺序(更新后)**: +1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id,直接查对应表取完整记录 +2. **Fallback 路径**(注册表未命中时): + - 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`) + - 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`) +3. 两条路径均未命中 → 返回 HTTP 404 4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403 **数据权限规则**: @@ -28,8 +30,11 @@ *通用字段(device 和 card 均有)*: - `asset_type`: 资产类型(`"device"` 或 `"card"`) - `asset_id`: 资产主键 ID +- `identifier`: 本次查询所用的标识符(原样回传) - `virtual_no`: 虚拟号(设备/卡均使用此字段) - `status`: 资产状态(整型) +- `asset_status`: 业务状态(1-在库 2-已销售 3-已换货 4-已停用) +- `generation`: 资产世代编号 - `batch_no`: 批次号 - `shop_id`: 所属店铺 ID(平台库存时为空) - `shop_name`: 所属店铺名称 @@ -136,3 +141,20 @@ - **WHEN** 管理员查询一台没有购买任何套餐的设备 - **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0` + +#### Scenario: 通过注册表主路径精确解析 + +- **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID +- **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms + +#### Scenario: Fallback 路径解析 IMEI + +- **WHEN** 管理员输入 identifier 为设备 IMEI(不在注册表中) +- **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息 +- **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值 + +#### Scenario: Fallback 路径解析 MSISDN + +- **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN) +- **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段 +- **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志) diff --git a/openspec/specs/client-order-purchase/spec.md b/openspec/specs/client-order-purchase/spec.md index 9179649..47ea69a 100644 --- a/openspec/specs/client-order-purchase/spec.md +++ b/openspec/specs/client-order-purchase/spec.md @@ -4,17 +4,47 @@ ### Requirement: D1 创建套餐购买订单接口 -系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`package_ids[]`、`app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为: +系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。请求体 MUST 包含 `identifier`、`package_ids[]`、`payment_method`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为: - 无强充:创建套餐订单并返回 `order_type="package"`、`order`、`pay_config` - 需强充:创建充值单并返回 `order_type="recharge"`、`recharge`、`pay_config`、`linked_package_info` +**客户端订单创建请求(CreateOrderRequest)**: +```json +{ + "identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)", + "package_ids": "[uint](套餐 ID 列表,必填,1-10 个)", + "payment_method": "string(wallet|wechat|alipay,必填)" +} +``` + +**废弃字段**: +- `iot_card_id`(原单卡购买时必填) +- `device_id`(原设备购买时必填) +- `order_type`(改为系统根据 identifier 解析结果自动填入) + 响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。 #### Scenario: 命中强充返回 recharge 结构 - **WHEN** 客户购买套餐触发强充要求 - **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息 +#### Scenario: 个人客户使用 VirtualNo 购买设备套餐 +- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }` +- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发 + +#### Scenario: 个人客户使用 ICCID 购买单卡套餐 +- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }` +- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单 + +#### Scenario: 个人客户尝试为绑定设备的卡购买套餐 +- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡 +- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐" + +#### Scenario: 个人客户只能操作自己绑定的资产 +- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户 +- **THEN** 系统返回 HTTP 403"无权限操作该资源" + --- ### Requirement: D2 套餐订单列表接口 diff --git a/openspec/specs/device-import/spec.md b/openspec/specs/device-import/spec.md index 961bc54..704bbb0 100644 --- a/openspec/specs/device-import/spec.md +++ b/openspec/specs/device-import/spec.md @@ -19,21 +19,22 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **表头行**: 第1行,列名如下(顺序可任意): ``` device_no, device_name, device_model, device_type, - max_sim_slots, manufacturer, iccid_1, iccid_2, iccid_3, iccid_4 + max_sim_slots, manufacturer, virtual_no, iccid_1, iccid_2, iccid_3, iccid_4 ``` - **数据行**: 从第2行开始 - **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法) **示例Excel内容**: ``` -| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | iccid_1 | iccid_2 | iccid_3 | iccid_4 | -|-----------|--------------|--------------|-------------|---------------|--------------|----------------------|----------------------|---------|---------| -| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 8986001234567890001 | 8986001234567890002 | | | -| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 8986001234567890003 | | | | +| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | virtual_no | iccid_1 | iccid_2 | iccid_3 | iccid_4 | +|-----------|--------------|--------------|-------------|---------------|--------------|------------|----------------------|----------------------|---------|---------| +| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | VNO-001 | 8986001234567890001 | 8986001234567890002 | | | +| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | VNO-002 | 8986001234567890003 | | | | ``` **字段说明**: - `device_no`: 设备号(必填,唯一) +- `virtual_no`: 虚拟号(**必填**,全局唯一);**[BREAKING]** 为空时不再静默跳过,改为记录为失败行 - `device_name`: 设备名称(可选) - `device_model`: 设备型号(可选) - `device_type`: 设备类型(可选) @@ -41,6 +42,9 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - `manufacturer`: 制造商(可选) - `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID(可选,空值表示该插槽无卡) +**失败原因文本**: +- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"` + **导入规则**: - 导入的设备 shop_id = NULL(平台库存) - 导入的设备 status = 1(在库) @@ -127,6 +131,18 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **WHEN** Excel 中某行的设备号在系统中已存在 - **THEN** 该行被跳过,记录跳过原因"设备号已存在" +#### Scenario: VirtualNo 为空的行记录为失败(原为跳过) + +- **WHEN** Excel 中某行 virtual_no 列为空或未填写 +- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空" +- **THEN** 该行不再计入 `skip_count` +- **THEN** 其他合法行继续导入,不因此行中断 + +#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因 + +- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行 +- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空",`line` 字段为对应行号 + --- ### Requirement: 设备导入任务列表查询 diff --git a/openspec/specs/iot-card-import-task/spec.md b/openspec/specs/iot-card-import-task/spec.md index 4b6ae77..3c6ccdb 100644 --- a/openspec/specs/iot-card-import-task/spec.md +++ b/openspec/specs/iot-card-import-task/spec.md @@ -201,8 +201,8 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - **表头识别关键字**: - ICCID 列: iccid/ICCID/卡号/号码 - MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码 - - virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号 -- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列 + - virtual_no 列(**必填**): virtual_no/VirtualNo/虚拟号/设备号 +- **列数要求**: 至少 3 列(ICCID、MSISDN、virtual_no),virtual_no 为必填列 - **列格式**: 应设置为文本格式(避免长数字被转为科学记数法) **解析规则**: @@ -211,12 +211,13 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - 跳过空行 - ICCID 为空的行记录为失败 - MSISDN 为空的行记录为失败 -- virtual_no 为空的行:跳过该列(不填入,保留原值) +- virtual_no 为空的行:**[BREAKING]** 记录为失败行,失败原因为"虚拟号(virtual_no)不能为空"(原行为为跳过该列) +- Excel 中不包含 virtual_no 列时:所有数据行均因 VirtualNo 为空而失败,返回错误提示"Excel 文件缺少 virtual_no 列,请使用最新模板" -**virtual_no 导入规则(只补空白)**: -- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值 -- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖) -- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表 +**virtual_no 导入规则**: +- virtual_no 为必填列,为空则该行记录为失败 +- virtual_no 全局唯一(跨卡和设备),重复则失败,原因为"虚拟号已被占用: <值>" +- **批次级唯一性检查**:在执行导入前,先检查整批中所有 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表 **示例 Excel 内容**: ``` @@ -226,20 +227,27 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose | 89860012345678901235 | 13800000002 | | ``` -#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列) +#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值) -- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列 -- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑 +- **WHEN** Excel 中某行 ICCID="898600XXXXX",virtual_no="CARD-001",msisdn="13800000001" +- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier` -#### Scenario: 解析含 virtual_no 列的三列 Excel +#### Scenario: VirtualNo 为空的行被拒绝 -- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL -- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001" +- **WHEN** Excel 中某行 ICCID="898600YYYYY",virtual_no 列为空或未填写 +- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空" +- **THEN** 其他合法行继续导入,不因此行中断 -#### Scenario: virtual_no 已有值时不覆盖 +#### Scenario: VirtualNo 重复被拒绝 -- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD" -- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败) +- **WHEN** Excel 中某行的 virtual_no 与已有卡/设备的 VirtualNo 重复(跨表) +- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>" + +#### Scenario: Excel 文件中无 VirtualNo 列 + +- **WHEN** Excel 表头中不包含 `virtual_no`/`虚拟号`/`设备号` 等可识别列名 +- **THEN** 所有数据行均因 VirtualNo 为空而失败,导入结果中 `fail_count = 总行数`,`success_count = 0` +- **THEN** 返回错误提示:"Excel 文件缺少 virtual_no 列,请使用最新模板" #### Scenario: 批次中有 virtual_no 与现存数据重复 @@ -252,6 +260,12 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - **WHEN** 系统解析该 Excel 文件 - **THEN** 系统正确识别三列,按规则处理 virtual_no +#### Scenario: 导入任务完成后的结果报告 + +- **WHEN** 导入任务处理完毕 +- **THEN** 结果包含:`success_count`、`fail_count`、`skip_count`、失败明细列表(含行号、原因) +- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因 + #### Scenario: 拒绝非Excel格式文件 - **GIVEN** 上传文件扩展名为 .csv diff --git a/openspec/specs/order-management/spec.md b/openspec/specs/order-management/spec.md index 39f9d1a..792dbac 100644 --- a/openspec/specs/order-management/spec.md +++ b/openspec/specs/order-management/spec.md @@ -26,7 +26,21 @@ ### Requirement: 创建套餐购买订单 -系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。 +系统 SHALL 允许买家创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段;`order_type` 字段由系统根据 identifier 解析结果自动填入,请求体不再接受。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。 + +**后台订单创建请求(CreateAdminOrderRequest)**: +```json +{ + "identifier": "string(资产标识符,ICCID 或 VirtualNo,必填)", + "package_ids": "[uint](套餐 ID 列表,必填,1-10 个)", + "payment_method": "string(wallet|offline,必填)" +} +``` + +**废弃字段**: +- `iot_card_id`(原用于单卡购买) +- `device_id`(原用于设备购买) +- `order_type`(改为系统自动推断) **支付方式和订单类型映射**: - `payment_method = "wallet"`:扣买家钱包,`is_purchase_on_behalf = false`(普通订单) @@ -72,6 +86,22 @@ - **WHEN** 买家尝试购买已下架的套餐 - **THEN** 系统返回错误 "该套餐已下架" +#### Scenario: 使用 VirtualNo 创建设备订单 +- **WHEN** 管理员请求体携带 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wallet" }` +- **THEN** 系统解析 identifier 为设备,自动设置 `order_type = device`,创建设备购买订单 + +#### Scenario: 使用 ICCID 创建单卡订单 +- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX", package_ids: [5], payment_method: "wallet" }` +- **THEN** 系统解析 identifier 为独立 IoT 卡(`is_standalone = true`),自动设置 `order_type = single_card`,创建单卡购买订单 + +#### Scenario: 使用绑定设备的卡 ICCID 创建订单被拒 +- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX" }` 但该卡 `is_standalone = false` +- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐",订单不创建 + +#### Scenario: identifier 不存在 +- **WHEN** 请求的 identifier 无法解析到任何资产 +- **THEN** 返回错误"资产不存在",订单不创建 + --- ### Requirement: 查询订单列表 @@ -224,3 +254,36 @@ #### Scenario: 后台管理端可为未实名载体购买 - **WHEN** 管理员通过后台为未实名载体购买套餐 - **THEN** 系统创建订单成功,PackageUsage status=0, pending_realname_activation=true + +--- + +### Requirement: 订单响应包含资产标识符 + +订单响应(OrderResponse)SHALL 包含资产标识符字段,前端无需额外请求即可展示资产信息。 + +**新增响应字段**: +- `asset_identifier`:下单时资产的标识符快照(ICCID 或 VirtualNo) +- `asset_type`:资产类型(`single_card` 对应 `iot_card`,`device` 对应 `device`) + +#### Scenario: 查看订单详情时展示资产标识符 +- **WHEN** 管理员查询订单详情 `GET /api/admin/orders/:id` +- **THEN** 响应中包含 `asset_identifier`(如 "DEV-001")和 `asset_type`(如 "device") +- **THEN** 即使原资产已被删除,`asset_identifier` 仍可读(快照字段) + +--- + +### Requirement: 订单列表支持按资产标识符过滤 + +系统 SHALL 在订单列表查询(OrderListRequest)中支持 `identifier` 过滤参数,按照资产标识符精确匹配订单(匹配 `asset_identifier` 快照字段)。 + +#### Scenario: 按 ICCID 查询该卡的历史订单 +- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=898600XXXXX` +- **THEN** 返回 `asset_identifier = "898600XXXXX"` 的所有订单(分页) + +#### Scenario: 按设备 VirtualNo 查询该设备的历史订单 +- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=DEV-001` +- **THEN** 返回 `asset_identifier = "DEV-001"` 的所有订单(分页) + +#### Scenario: 标识符无匹配订单 +- **WHEN** 管理员查询不存在订单的 identifier +- **THEN** 返回空列表(`items: []`,`total: 0`),不返回错误 diff --git a/openspec/specs/package-purchase-validation/spec.md b/openspec/specs/package-purchase-validation/spec.md index 20455fe..89a67bc 100644 --- a/openspec/specs/package-purchase-validation/spec.md +++ b/openspec/specs/package-purchase-validation/spec.md @@ -117,3 +117,23 @@ - **WHEN** 代理渠道某套餐存在 `retail_price < cost_price` - **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验 +--- + +### Requirement: 已绑定设备的卡不允许单独购买套餐 + +购买套餐验证时,系统 MUST 检查目标 IoT 卡是否为独立卡(`is_standalone = true`)。若卡已绑定设备(`is_standalone = false`),必须拒绝购买并引导用户至对应设备页面操作。 + +此规则适用于所有套餐购买入口(C 端个人客户、B 端代理/平台)。 + +#### Scenario: 独立卡正常购买 +- **WHEN** 买家为 IoT 卡购买套餐,该卡的 `is_standalone = true`(未绑定任何设备) +- **THEN** 验证通过,继续后续购买流程 + +#### Scenario: 已绑定设备的卡被单独购买 +- **WHEN** 买家使用 IoT 卡的 ICCID 或 VirtualNo 购买套餐,该卡的 `is_standalone = false` +- **THEN** 系统拒绝购买,返回错误码 `CodeInvalidParam`,错误消息"该卡已绑定设备,请前往设备页面购买套餐" + +#### Scenario: B 端代理通过卡标识符购买套餐(绑定了设备的卡) +- **WHEN** 代理使用已绑定设备的卡的 ICCID 创建订单 +- **THEN** 系统在 `ValidateCardPurchase()` 中检查 `is_standalone`,返回错误,订单不创建 + diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 2102fd7..6c9f65d 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -89,6 +89,7 @@ func (h *Handler) registerIotCardImportHandler() { h.workerResult.Stores.IotCardImportTask, h.workerResult.Stores.IotCard, h.workerResult.Stores.AssetWallet, + h.workerResult.Stores.AssetIdentifier, h.storage, h.pollingCallback, h.logger, @@ -107,6 +108,7 @@ func (h *Handler) registerDeviceImportHandler() { h.workerResult.Stores.DeviceSimBinding, h.workerResult.Stores.IotCard, h.workerResult.Stores.AssetWallet, + h.workerResult.Stores.AssetIdentifier, h.storage, h.logger, ) diff --git a/pkg/queue/types.go b/pkg/queue/types.go index e5df8ad..5ad05aa 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -42,6 +42,7 @@ type WorkerStores struct { AgentWallet *postgres.AgentWalletStore AgentWalletTransaction *postgres.AgentWalletTransactionStore AssetWallet *postgres.AssetWalletStore + AssetIdentifier *postgres.AssetIdentifierStore } // WorkerServices Worker 侧所有 Service 的集合 diff --git a/pkg/utils/excel.go b/pkg/utils/excel.go index 65458e5..b7521e2 100644 --- a/pkg/utils/excel.go +++ b/pkg/utils/excel.go @@ -1,12 +1,14 @@ package utils import ( - "errors" + stderrors "errors" "fmt" "strconv" "strings" "github.com/xuri/excelize/v2" + + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" ) // CardInfo 卡信息(ICCID + MSISDN + VirtualNo) @@ -31,6 +33,13 @@ type CSVParseError struct { Reason string } +// DeviceParseResult 设备导入 Excel 解析结果 +type DeviceParseResult struct { + Rows []DeviceRow + TotalCount int + ParseErrors []CSVParseError +} + // DeviceRow 设备导入数据行 type DeviceRow struct { Line int @@ -46,9 +55,9 @@ type DeviceRow struct { var ( // ErrExcelNoSheets Excel文件无工作表 - ErrExcelNoSheets = errors.New("Excel文件无工作表") + ErrExcelNoSheets = stderrors.New("Excel文件无工作表") // ErrExcelNoData Excel文件无数据行 - ErrExcelNoData = errors.New("Excel文件无数据行(至少需要表头+1行数据)") + ErrExcelNoData = stderrors.New("Excel文件无数据行(至少需要表头+1行数据)") ) // ParseCardExcel 解析包含 ICCID 和 MSISDN 两列的 Excel 文件 @@ -88,48 +97,45 @@ func ParseCardExcel(filePath string) (*CSVParseResult, error) { // ParseDeviceExcel 解析设备导入 Excel 文件 // filePath: Excel文件路径(.xlsx格式) -// 返回: 设备行数组、总行数、错误 -func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) { - // 1. 打开Excel文件 +// 返回: 解析结果(包含有效行和失败行)、错误 +func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) { f, err := excelize.OpenFile(filePath) if err != nil { - return nil, 0, fmt.Errorf("打开Excel失败: %w", err) + return nil, fmt.Errorf("打开Excel失败: %w", err) } defer func() { if err := f.Close(); err != nil { - // 日志记录关闭错误,但不影响解析结果 } }() - // 2. 选择sheet sheetName := selectSheet(f) if sheetName == "" { - return nil, 0, ErrExcelNoSheets + return nil, ErrExcelNoSheets } - // 3. 读取所有行 rows, err := f.GetRows(sheetName) if err != nil { - return nil, 0, fmt.Errorf("读取sheet失败: %w", err) + return nil, fmt.Errorf("读取sheet失败: %w", err) } if len(rows) < 2 { - return nil, 0, ErrExcelNoData + return nil, ErrExcelNoData } - // 4. 解析表头行,构建列索引 header := rows[0] colIndex := buildDeviceColumnIndex(header) - // 5. 解析数据行 - var deviceRows []DeviceRow + parseResult := &DeviceParseResult{ + Rows: make([]DeviceRow, 0), + ParseErrors: make([]CSVParseError, 0), + } + for i := 1; i < len(rows); i++ { record := rows[i] - lineNum := i + 1 // Excel行号从1开始,数据从第2行开始 + lineNum := i + 1 row := DeviceRow{Line: lineNum} - // 提取各字段 if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) { row.VirtualNo = strings.TrimSpace(record[idx]) } @@ -154,7 +160,6 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) { row.Manufacturer = strings.TrimSpace(record[idx]) } - // 提取ICCID (iccid_1 ~ iccid_4) row.ICCIDs = make([]string, 0, 4) for j := 1; j <= 4; j++ { colName := "iccid_" + strconv.Itoa(j) @@ -166,20 +171,24 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) { } } - // 跳过虚拟号为空的行 + parseResult.TotalCount++ + if row.VirtualNo == "" { + parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{ + Line: lineNum, + Reason: "设备虚拟号(virtual_no)不能为空", + }) continue } - // 默认最大插槽数为4 if row.MaxSimSlots == 0 { row.MaxSimSlots = 4 } - deviceRows = append(deviceRows, row) + parseResult.Rows = append(parseResult.Rows, row) } - return deviceRows, len(deviceRows), nil + return parseResult, nil } // selectSheet 选择要读取的sheet @@ -219,6 +228,10 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) { } } + if virtualNoCol == -1 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板") + } + startLine := 0 if headerSkipped { startLine = 1 @@ -279,6 +292,16 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) { continue } + if virtualNo == "" { + result.ParseErrors = append(result.ParseErrors, CSVParseError{ + Line: lineNum, + ICCID: iccid, + MSISDN: msisdn, + Reason: "虚拟号(virtual_no)不能为空", + }) + continue + } + result.Cards = append(result.Cards, CardInfo{ ICCID: iccid, MSISDN: msisdn,