From ce0783f96e91df7296ff83ee231a409ab14c771f Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 26 Jan 2026 18:05:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E8=AE=BE=E5=A4=87=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 实现设备管理模块(创建、查询、列表、更新状态、删除) - 实现设备批量导入功能(CSV 解析、ICCID 绑定、异步任务处理) - 添加设备-SIM 卡绑定约束(部分唯一索引防止并发问题) - 修复 fee_rate 数据库字段类型(numeric -> bigint) - 修复测试数据隔离问题(基于增量断言) - 修复集成测试中间件顺序问题 - 清理无用测试文件(PersonalCustomer、Email 相关) - 归档 enterprise-card-authorization 变更 --- cmd/api/docs.go | 2 + cmd/gendocs/main.go | 2 + docs/admin-openapi.yaml | 1058 +++++++++++++++++ go.mod | 4 +- internal/bootstrap/handlers.go | 2 + internal/bootstrap/services.go | 6 + internal/bootstrap/stores.go | 6 + internal/bootstrap/types.go | 2 + internal/handler/admin/device.go | 183 +++ internal/handler/admin/device_import.go | 86 ++ internal/model/device_import_task.go | 43 + internal/model/device_sim_binding.go | 24 + internal/model/dto/device_dto.go | 120 ++ internal/model/dto/device_import_dto.go | 66 + internal/model/package.go | 18 - internal/routes/admin.go | 3 + internal/routes/device.go | 127 ++ internal/service/device/binding.go | 172 +++ internal/service/device/service.go | 551 +++++++++ internal/service/device_import/service.go | 209 ++++ internal/service/enterprise_card/service.go | 4 +- .../postgres/device_import_task_store.go | 135 +++ .../postgres/device_sim_binding_store.go | 202 ++++ .../postgres/device_sim_binding_store_test.go | 209 ++++ internal/store/postgres/device_store.go | 183 +++ internal/task/device_import.go | 439 +++++++ internal/task/device_import_test.go | 190 +++ ...8_create_device_import_task_table.down.sql | 3 + ...018_create_device_import_task_table.up.sql | 69 ++ ...ix_device_sim_binding_constraints.down.sql | 9 + ..._fix_device_sim_binding_constraints.up.sql | 18 + .../000020_fix_fee_rate_column_type.down.sql | 3 + .../000020_fix_fee_rate_column_type.up.sql | 8 + .../.openspec.yaml | 0 .../changes/add-device-management/design.md | 224 ++++ .../changes/add-device-management/proposal.md | 89 ++ .../specs/asset-allocation-record/spec.md | 120 ++ .../specs/device-import/spec.md | 193 +++ .../specs/device/spec.md | 327 +++++ .../changes/add-device-management/tasks.md | 69 ++ .../.openspec.yaml | 2 + .../design.md | 0 .../proposal.md | 0 .../enterprise-card-authorization/spec.md | 0 .../specs/iot-card/spec.md | 4 +- .../tasks.md | 0 .../.openspec.yaml | 2 + .../fix-device-sim-binding-issues/design.md | 249 ++++ .../fix-device-sim-binding-issues/proposal.md | 70 ++ .../specs/iot-device/spec.md | 159 +++ .../fix-device-sim-binding-issues/tasks.md | 76 ++ .../enterprise-card-authorization/spec.md | 166 +++ openspec/specs/iot-card/spec.md | 41 + pkg/constants/constants.go | 1 + pkg/gorm/callback_test.go | 86 -- pkg/queue/handler.go | 13 +- tests/integration/account_test.go | 13 +- tests/integration/api_regression_test.go | 16 +- tests/integration/device_test.go | 333 ++++++ tests/integration/permission_test.go | 40 +- tests/integration/platform_account_test.go | 20 +- tests/integration/role_test.go | 9 +- tests/unit/enterprise_service_test.go | 14 +- tests/unit/permission_platform_filter_test.go | 115 +- tests/unit/personal_customer_store_test.go | 328 ----- tests/unit/queue_test.go | 555 --------- tests/unit/role_assignment_limit_test.go | 2 +- tests/unit/task_handler_test.go | 390 ------ 68 files changed, 6400 insertions(+), 1482 deletions(-) create mode 100644 internal/handler/admin/device.go create mode 100644 internal/handler/admin/device_import.go create mode 100644 internal/model/device_import_task.go create mode 100644 internal/model/device_sim_binding.go create mode 100644 internal/model/dto/device_dto.go create mode 100644 internal/model/dto/device_import_dto.go create mode 100644 internal/routes/device.go create mode 100644 internal/service/device/binding.go create mode 100644 internal/service/device/service.go create mode 100644 internal/service/device_import/service.go create mode 100644 internal/store/postgres/device_import_task_store.go create mode 100644 internal/store/postgres/device_sim_binding_store.go create mode 100644 internal/store/postgres/device_sim_binding_store_test.go create mode 100644 internal/store/postgres/device_store.go create mode 100644 internal/task/device_import.go create mode 100644 internal/task/device_import_test.go create mode 100644 migrations/000018_create_device_import_task_table.down.sql create mode 100644 migrations/000018_create_device_import_task_table.up.sql create mode 100644 migrations/000019_fix_device_sim_binding_constraints.down.sql create mode 100644 migrations/000019_fix_device_sim_binding_constraints.up.sql create mode 100644 migrations/000020_fix_fee_rate_column_type.down.sql create mode 100644 migrations/000020_fix_fee_rate_column_type.up.sql rename openspec/changes/{enterprise-card-authorization => add-device-management}/.openspec.yaml (100%) create mode 100644 openspec/changes/add-device-management/design.md create mode 100644 openspec/changes/add-device-management/proposal.md create mode 100644 openspec/changes/add-device-management/specs/asset-allocation-record/spec.md create mode 100644 openspec/changes/add-device-management/specs/device-import/spec.md create mode 100644 openspec/changes/add-device-management/specs/device/spec.md create mode 100644 openspec/changes/add-device-management/tasks.md create mode 100644 openspec/changes/archive/2026-01-26-enterprise-card-authorization/.openspec.yaml rename openspec/changes/{enterprise-card-authorization => archive/2026-01-26-enterprise-card-authorization}/design.md (100%) rename openspec/changes/{enterprise-card-authorization => archive/2026-01-26-enterprise-card-authorization}/proposal.md (100%) rename openspec/changes/{enterprise-card-authorization => archive/2026-01-26-enterprise-card-authorization}/specs/enterprise-card-authorization/spec.md (100%) rename openspec/changes/{enterprise-card-authorization => archive/2026-01-26-enterprise-card-authorization}/specs/iot-card/spec.md (95%) rename openspec/changes/{enterprise-card-authorization => archive/2026-01-26-enterprise-card-authorization}/tasks.md (100%) create mode 100644 openspec/changes/fix-device-sim-binding-issues/.openspec.yaml create mode 100644 openspec/changes/fix-device-sim-binding-issues/design.md create mode 100644 openspec/changes/fix-device-sim-binding-issues/proposal.md create mode 100644 openspec/changes/fix-device-sim-binding-issues/specs/iot-device/spec.md create mode 100644 openspec/changes/fix-device-sim-binding-issues/tasks.md create mode 100644 openspec/specs/enterprise-card-authorization/spec.md create mode 100644 tests/integration/device_test.go delete mode 100644 tests/unit/personal_customer_store_test.go delete mode 100644 tests/unit/queue_test.go delete mode 100644 tests/unit/task_handler_test.go diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 6ff1dac..3cf2de0 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -41,6 +41,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { MyCommission: admin.NewMyCommissionHandler(nil), IotCard: admin.NewIotCardHandler(nil), IotCardImport: admin.NewIotCardImportHandler(nil), + Device: admin.NewDeviceHandler(nil), + DeviceImport: admin.NewDeviceImportHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), Storage: admin.NewStorageHandler(nil), } diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index cc4e05c..aca8a10 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -50,6 +50,8 @@ func generateAdminDocs(outputPath string) error { MyCommission: admin.NewMyCommissionHandler(nil), IotCard: admin.NewIotCardHandler(nil), IotCardImport: admin.NewIotCardImportHandler(nil), + Device: admin.NewDeviceHandler(nil), + DeviceImport: admin.NewDeviceImportHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), Storage: admin.NewStorageHandler(nil), } diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 2b3a6ae..51de5bc 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -96,6 +96,44 @@ components: description: 成功数量 type: integer type: object + DtoAllocateDevicesRequest: + properties: + device_ids: + description: 设备ID列表 + items: + minimum: 0 + type: integer + maxItems: 100 + minItems: 1 + nullable: true + type: array + remark: + description: 备注 + maxLength: 500 + type: string + target_shop_id: + description: 目标店铺ID + minimum: 1 + type: integer + required: + - target_shop_id + - device_ids + type: object + DtoAllocateDevicesResponse: + properties: + fail_count: + description: 失败数量 + type: integer + failed_items: + description: 失败详情列表 + items: + $ref: '#/components/schemas/DtoAllocationDeviceFailedItem' + nullable: true + type: array + success_count: + description: 成功数量 + type: integer + type: object DtoAllocateStandaloneCardsRequest: properties: batch_no: @@ -186,6 +224,19 @@ components: nullable: true type: array type: object + DtoAllocationDeviceFailedItem: + properties: + device_id: + description: 设备ID + minimum: 0 + type: integer + device_no: + description: 设备号 + type: string + reason: + description: 失败原因 + type: string + type: object DtoAllocationFailedItem: properties: iccid: @@ -474,6 +525,31 @@ components: description: 总记录数 type: integer type: object + DtoBindCardToDeviceRequest: + properties: + iot_card_id: + description: IoT卡ID + minimum: 1 + type: integer + slot_position: + description: 插槽位置 (1-4) + maximum: 4 + minimum: 1 + type: integer + required: + - iot_card_id + - slot_position + type: object + DtoBindCardToDeviceResponse: + properties: + binding_id: + description: 绑定记录ID + minimum: 0 + type: integer + message: + description: 提示信息 + type: string + type: object DtoChangePasswordRequest: properties: new_password: @@ -904,6 +980,221 @@ components: description: 总记录数 type: integer type: object + DtoDeviceCardBindingResponse: + properties: + bind_time: + description: 绑定时间 + format: date-time + nullable: true + type: string + carrier_name: + description: 运营商名称 + type: string + iccid: + description: ICCID + type: string + id: + description: 绑定记录ID + minimum: 0 + type: integer + iot_card_id: + description: IoT卡ID + minimum: 0 + type: integer + msisdn: + description: 接入号 + type: string + slot_position: + description: 插槽位置 (1-4) + type: integer + status: + description: 卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + type: integer + type: object + DtoDeviceImportResultItemDTO: + properties: + device_no: + description: 设备号 + type: string + line: + description: 行号 + type: integer + reason: + description: 原因 + type: string + type: object + DtoDeviceImportTaskDetailResponse: + properties: + batch_no: + description: 批次号 + type: string + completed_at: + description: 完成时间 + format: date-time + nullable: true + type: string + created_at: + description: 创建时间 + format: date-time + type: string + error_message: + description: 错误信息 + type: string + fail_count: + description: 失败数 + type: integer + failed_items: + description: 失败记录详情 + items: + $ref: '#/components/schemas/DtoDeviceImportResultItemDTO' + nullable: true + type: array + file_name: + description: 文件名 + type: string + id: + description: 任务ID + minimum: 0 + type: integer + skip_count: + description: 跳过数 + type: integer + skipped_items: + description: 跳过记录详情 + items: + $ref: '#/components/schemas/DtoDeviceImportResultItemDTO' + nullable: true + type: array + started_at: + description: 开始处理时间 + format: date-time + nullable: true + type: string + status: + description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败) + type: integer + status_text: + description: 任务状态文本 + type: string + success_count: + description: 成功数 + type: integer + task_no: + description: 任务编号 + type: string + total_count: + description: 总数 + type: integer + type: object + DtoDeviceImportTaskResponse: + properties: + batch_no: + description: 批次号 + type: string + completed_at: + description: 完成时间 + format: date-time + nullable: true + type: string + created_at: + description: 创建时间 + format: date-time + type: string + error_message: + description: 错误信息 + type: string + fail_count: + description: 失败数 + type: integer + file_name: + description: 文件名 + type: string + id: + description: 任务ID + minimum: 0 + type: integer + skip_count: + description: 跳过数 + type: integer + started_at: + description: 开始处理时间 + format: date-time + nullable: true + type: string + status: + description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败) + type: integer + status_text: + description: 任务状态文本 + type: string + success_count: + description: 成功数 + type: integer + task_no: + description: 任务编号 + type: string + total_count: + description: 总数 + type: integer + type: object + DtoDeviceResponse: + properties: + activated_at: + description: 激活时间 + format: date-time + nullable: true + type: string + batch_no: + description: 批次号 + type: string + bound_card_count: + description: 已绑定卡数量 + type: integer + created_at: + description: 创建时间 + format: date-time + type: string + device_model: + description: 设备型号 + type: string + device_name: + description: 设备名称 + type: string + device_no: + description: 设备号 + type: string + device_type: + description: 设备类型 + type: string + id: + description: 设备ID + minimum: 0 + type: integer + manufacturer: + description: 制造商 + type: string + max_sim_slots: + description: 最大插槽数 + type: integer + shop_id: + description: 店铺ID + minimum: 0 + nullable: true + type: integer + shop_name: + description: 店铺名称 + type: string + status: + description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + type: integer + status_name: + description: 状态名称 + type: string + updated_at: + description: 更新时间 + format: date-time + type: string + type: object DtoEnterpriseCardItem: properties: carrier_id: @@ -1084,6 +1375,33 @@ components: description: 预签名上传 URL,使用 PUT 方法上传文件 type: string type: object + DtoImportDeviceRequest: + properties: + batch_no: + description: 批次号 + maxLength: 100 + type: string + file_key: + description: 对象存储文件路径(通过 /storage/upload-url 获取) + maxLength: 500 + minLength: 1 + type: string + required: + - file_key + type: object + DtoImportDeviceResponse: + properties: + message: + description: 提示信息 + type: string + task_id: + description: 导入任务ID + minimum: 0 + type: integer + task_no: + description: 任务编号 + type: string + type: object DtoImportIotCardRequest: properties: batch_no: @@ -1280,6 +1598,57 @@ components: description: 总页数 type: integer type: object + DtoListDeviceCardsResponse: + properties: + bindings: + description: 绑定列表 + items: + $ref: '#/components/schemas/DtoDeviceCardBindingResponse' + nullable: true + type: array + type: object + DtoListDeviceImportTaskResponse: + properties: + list: + description: 任务列表 + items: + $ref: '#/components/schemas/DtoDeviceImportTaskResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + total_pages: + description: 总页数 + type: integer + type: object + DtoListDeviceResponse: + properties: + list: + description: 设备列表 + items: + $ref: '#/components/schemas/DtoDeviceResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + total_pages: + description: 总页数 + type: integer + type: object DtoListImportTaskResponse: properties: list: @@ -1556,6 +1925,39 @@ components: description: 成功数量 type: integer type: object + DtoRecallDevicesRequest: + properties: + device_ids: + description: 设备ID列表 + items: + minimum: 0 + type: integer + maxItems: 100 + minItems: 1 + nullable: true + type: array + remark: + description: 备注 + maxLength: 500 + type: string + required: + - device_ids + type: object + DtoRecallDevicesResponse: + properties: + fail_count: + description: 失败数量 + type: integer + failed_items: + description: 失败详情列表 + items: + $ref: '#/components/schemas/DtoAllocationDeviceFailedItem' + nullable: true + type: array + success_count: + description: 成功数量 + type: integer + type: object DtoRecallStandaloneCardsRequest: properties: batch_no: @@ -2123,6 +2525,12 @@ components: format: date-time type: string type: object + DtoUnbindCardFromDeviceResponse: + properties: + message: + description: 提示信息 + type: string + type: object DtoUpdateAccountParams: properties: password: @@ -4087,6 +4495,656 @@ paths: summary: 修改账号状态 tags: - 客户账号管理 + /api/admin/devices: + get: + parameters: + - description: 页码 + in: query + name: page + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + - description: 设备号(模糊查询) + in: query + name: device_no + schema: + description: 设备号(模糊查询) + maxLength: 100 + type: string + - description: 设备名称(模糊查询) + in: query + name: device_name + schema: + description: 设备名称(模糊查询) + maxLength: 255 + type: string + - description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + in: query + name: status + schema: + description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + maximum: 4 + minimum: 1 + nullable: true + type: integer + - description: 店铺ID (NULL表示平台库存) + in: query + name: shop_id + schema: + description: 店铺ID (NULL表示平台库存) + minimum: 0 + nullable: true + type: integer + - description: 批次号 + in: query + name: batch_no + schema: + description: 批次号 + maxLength: 100 + type: string + - description: 设备类型 + in: query + name: device_type + schema: + description: 设备类型 + maxLength: 50 + type: string + - description: 制造商(模糊查询) + in: query + name: manufacturer + schema: + description: 制造商(模糊查询) + maxLength: 255 + type: string + - description: 创建时间起始 + in: query + name: created_at_start + schema: + description: 创建时间起始 + format: date-time + nullable: true + type: string + - description: 创建时间结束 + in: query + name: created_at_end + schema: + description: 创建时间结束 + format: date-time + nullable: true + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoListDeviceResponse' + description: OK + "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/{id}: + delete: + description: 仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。 + 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: + - 设备管理 + get: + parameters: + - description: 设备ID + in: path + name: id + required: true + schema: + description: 设备ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoDeviceResponse' + description: OK + "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/{id}/cards: + get: + parameters: + - description: 设备ID + in: path + name: id + required: true + schema: + description: 设备ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoListDeviceCardsResponse' + description: OK + "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: + - 设备管理 + post: + description: 仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。 + parameters: + - description: 设备ID + in: path + name: id + required: true + schema: + description: 设备ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoBindCardToDeviceRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoBindCardToDeviceResponse' + description: OK + "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/{id}/cards/{cardId}: + delete: + description: 仅平台用户可操作。解绑不改变卡的 shop_id。 + parameters: + - description: 设备ID + in: path + name: id + required: true + schema: + description: 设备ID + minimum: 0 + type: integer + - description: IoT卡ID + in: path + name: cardId + required: true + schema: + description: IoT卡ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUnbindCardFromDeviceResponse' + description: OK + "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。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAllocateDevicesRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAllocateDevicesResponse' + description: OK + "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/import: + post: + description: |- + 仅平台用户可操作。 + + ### 完整导入流程 + + 1. **获取上传 URL**: 调用 `POST /api/admin/storage/upload-url` + 2. **上传 CSV 文件**: 使用预签名 URL 上传文件到对象存储 + 3. **调用本接口**: 使用返回的 `file_key` 提交导入任务 + + ### CSV 文件格式 + + 必须包含列(首行为表头): + - `device_no`: 设备号(必填,唯一) + - `device_name`: 设备名称 + - `device_model`: 设备型号 + - `device_type`: 设备类型 + - `max_sim_slots`: 最大插槽数(默认4) + - `manufacturer`: 制造商 + - `iccid_1` ~ `iccid_4`: 绑定的卡 ICCID(卡必须已存在且未绑定) + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoImportDeviceRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoImportDeviceResponse' + description: OK + "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/import/tasks: + get: + description: 仅平台用户可操作。 + parameters: + - description: 页码 + in: query + name: page + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + - description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败) + in: query + name: status + schema: + description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败) + maximum: 4 + minimum: 1 + nullable: true + type: integer + - description: 批次号(模糊查询) + in: query + name: batch_no + schema: + description: 批次号(模糊查询) + maxLength: 100 + type: string + - description: 创建时间起始 + in: query + name: start_time + schema: + description: 创建时间起始 + format: date-time + nullable: true + type: string + - description: 创建时间结束 + in: query + name: end_time + schema: + description: 创建时间结束 + format: date-time + nullable: true + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoListDeviceImportTaskResponse' + description: OK + "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/import/tasks/{id}: + get: + description: 仅平台用户可操作。包含跳过和失败记录的详细信息。 + parameters: + - description: 任务ID + in: path + name: id + required: true + schema: + description: 任务ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoDeviceImportTaskDetailResponse' + description: OK + "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/recall: + post: + description: 从直属下级店铺回收设备。回收时自动同步绑定的所有卡的 shop_id。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoRecallDevicesRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoRecallDevicesResponse' + description: OK + "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/enterprises: get: parameters: diff --git a/go.mod b/go.mod index a764ede..8e6bb4c 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25 require ( github.com/aws/aws-sdk-go v1.55.5 github.com/bytedance/sonic v1.14.2 - github.com/fsnotify/fsnotify v1.9.0 github.com/go-playground/validator/v10 v10.28.0 github.com/gofiber/fiber/v2 v2.52.9 github.com/gofiber/storage/redis/v3 v3.4.1 @@ -13,6 +12,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.0 github.com/google/uuid v1.6.0 github.com/hibiken/asynq v0.25.1 + github.com/jackc/pgx/v5 v5.7.6 github.com/redis/go-redis/v9 v9.16.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -55,6 +55,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -68,7 +69,6 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index eead933..a4d36d7 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -29,6 +29,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { MyCommission: admin.NewMyCommissionHandler(svc.MyCommission), IotCard: admin.NewIotCardHandler(svc.IotCard), IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport), + Device: admin.NewDeviceHandler(svc.Device), + DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), Storage: admin.NewStorageHandler(deps.StorageService), } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index c155374..75c5c81 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -7,6 +7,8 @@ import ( commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account" + deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device" + deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import" enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise" enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" @@ -38,6 +40,8 @@ type services struct { MyCommission *myCommissionSvc.Service IotCard *iotCardSvc.Service IotCardImport *iotCardImportSvc.Service + Device *deviceSvc.Service + DeviceImport *deviceImportSvc.Service AssetAllocationRecord *assetAllocationRecordSvc.Service } @@ -60,6 +64,8 @@ func initServices(s *stores, deps *Dependencies) *services { MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction), IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord), IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), + Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord), + DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient), AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index fb575c4..9e733e4 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -22,6 +22,9 @@ type stores struct { EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore IotCard *postgres.IotCardStore IotCardImportTask *postgres.IotCardImportTaskStore + Device *postgres.DeviceStore + DeviceSimBinding *postgres.DeviceSimBindingStore + DeviceImportTask *postgres.DeviceImportTaskStore AssetAllocationRecord *postgres.AssetAllocationRecordStore } @@ -44,6 +47,9 @@ func initStores(deps *Dependencies) *stores { EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis), IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), + Device: postgres.NewDeviceStore(deps.DB, deps.Redis), + DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), + DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index ab50d21..d198f25 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -27,6 +27,8 @@ type Handlers struct { MyCommission *admin.MyCommissionHandler IotCard *admin.IotCardHandler IotCardImport *admin.IotCardImportHandler + Device *admin.DeviceHandler + DeviceImport *admin.DeviceImportHandler AssetAllocationRecord *admin.AssetAllocationRecordHandler Storage *admin.StorageHandler } diff --git a/internal/handler/admin/device.go b/internal/handler/admin/device.go new file mode 100644 index 0000000..a58314f --- /dev/null +++ b/internal/handler/admin/device.go @@ -0,0 +1,183 @@ +package admin + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + deviceService "github.com/break/junhong_cmp_fiber/internal/service/device" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +type DeviceHandler struct { + service *deviceService.Service +} + +func NewDeviceHandler(service *deviceService.Service) *DeviceHandler { + return &DeviceHandler{ + service: service, + } +} + +func (h *DeviceHandler) List(c *fiber.Ctx) error { + var req dto.ListDeviceRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + result, err := h.service.List(c.UserContext(), &req) + if err != nil { + return err + } + + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +func (h *DeviceHandler) GetByID(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return errors.New(errors.CodeInvalidParam, "无效的设备ID") + } + + result, err := h.service.Get(c.UserContext(), uint(id)) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *DeviceHandler) Delete(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + 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") + } + + if err := h.service.Delete(c.UserContext(), uint(id)); err != nil { + return err + } + + return response.Success(c, nil) +} + +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") + } + + result, err := h.service.ListBindings(c.UserContext(), uint(id)) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *DeviceHandler) BindCard(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + 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") + } + + var req dto.BindCardToDeviceRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + req.ID = uint(id) + + result, err := h.service.BindCard(c.UserContext(), uint(id), &req) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *DeviceHandler) UnbindCard(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + 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") + } + + cardIdStr := c.Params("cardId") + cardId, err := strconv.ParseUint(cardIdStr, 10, 64) + if err != nil { + return errors.New(errors.CodeInvalidParam, "无效的卡ID") + } + + result, err := h.service.UnbindCard(c.UserContext(), uint(id), uint(cardId)) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *DeviceHandler) Allocate(c *fiber.Ctx) error { + var req dto.AllocateDevicesRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + userID := middleware.GetUserIDFromContext(c.UserContext()) + shopID := middleware.GetShopIDFromContext(c.UserContext()) + + var shopIDPtr *uint + if shopID > 0 { + shopIDPtr = &shopID + } + + result, err := h.service.AllocateDevices(c.UserContext(), &req, userID, shopIDPtr) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *DeviceHandler) Recall(c *fiber.Ctx) error { + var req dto.RecallDevicesRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + userID := middleware.GetUserIDFromContext(c.UserContext()) + shopID := middleware.GetShopIDFromContext(c.UserContext()) + + var shopIDPtr *uint + if shopID > 0 { + shopIDPtr = &shopID + } + + result, err := h.service.RecallDevices(c.UserContext(), &req, userID, shopIDPtr) + if err != nil { + return err + } + + return response.Success(c, result) +} diff --git a/internal/handler/admin/device_import.go b/internal/handler/admin/device_import.go new file mode 100644 index 0000000..6e373ae --- /dev/null +++ b/internal/handler/admin/device_import.go @@ -0,0 +1,86 @@ +package admin + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + deviceImportService "github.com/break/junhong_cmp_fiber/internal/service/device_import" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +type DeviceImportHandler struct { + service *deviceImportService.Service +} + +func NewDeviceImportHandler(service *deviceImportService.Service) *DeviceImportHandler { + return &DeviceImportHandler{ + service: service, + } +} + +func (h *DeviceImportHandler) Import(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅平台用户可导入设备") + } + + var req dto.ImportDeviceRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + if req.FileKey == "" { + return errors.New(errors.CodeInvalidParam, "文件路径不能为空") + } + + result, err := h.service.CreateImportTask(c.UserContext(), &req) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *DeviceImportHandler) List(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅平台用户可查看导入任务") + } + + var req dto.ListDeviceImportTaskRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + result, err := h.service.List(c.UserContext(), &req) + if err != nil { + return err + } + + return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) +} + +func (h *DeviceImportHandler) GetByID(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + 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") + } + + result, err := h.service.GetByID(c.UserContext(), uint(id)) + if err != nil { + return err + } + + return response.Success(c, result) +} diff --git a/internal/model/device_import_task.go b/internal/model/device_import_task.go new file mode 100644 index 0000000..ed3e44a --- /dev/null +++ b/internal/model/device_import_task.go @@ -0,0 +1,43 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// DeviceImportTask 设备导入任务模型 +// 记录设备批量导入的任务状态和处理结果 +// 通过异步任务处理 CSV 文件导入设备并绑定卡 +type DeviceImportTask struct { + gorm.Model + BaseModel `gorm:"embedded"` + TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"` + BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` + StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"` + FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"` + TotalCount int `gorm:"column:total_count;type:int;default:0;comment:总记录数" json:"total_count"` + SuccessCount int `gorm:"column:success_count;type:int;default:0;comment:成功数" json:"success_count"` + SkipCount int `gorm:"column:skip_count;type:int;default:0;comment:跳过数" json:"skip_count"` + FailCount int `gorm:"column:fail_count;type:int;default:0;comment:失败数" json:"fail_count"` + SkippedItems ImportResultItems `gorm:"column:skipped_items;type:jsonb;comment:跳过记录详情" json:"skipped_items"` + FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"` + WarningCount int `gorm:"column:warning_count;default:0;comment:警告数量(部分成功的设备)" json:"warning_count"` + WarningItems ImportResultItems `gorm:"column:warning_items;type:jsonb;comment:警告记录详情" json:"warning_items"` + ErrorMessage string `gorm:"column:error_message;type:text;comment:错误信息" json:"error_message"` + StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"` +} + +// TableName 指定表名 +func (DeviceImportTask) TableName() string { + return "tb_device_import_task" +} + +// DeviceImportResultItem 设备导入结果项 +type DeviceImportResultItem struct { + Line int `json:"line"` + DeviceNo string `json:"device_no"` + Reason string `json:"reason"` +} diff --git a/internal/model/device_sim_binding.go b/internal/model/device_sim_binding.go new file mode 100644 index 0000000..7610157 --- /dev/null +++ b/internal/model/device_sim_binding.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// DeviceSimBinding 设备-IoT卡绑定关系模型 +// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡) +type DeviceSimBinding struct { + gorm.Model + BaseModel `gorm:"embedded"` + DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"` + IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"` + SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"` + BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"` + BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"` + UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"` +} + +func (DeviceSimBinding) TableName() string { + return "tb_device_sim_binding" +} diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go new file mode 100644 index 0000000..5b9d00d --- /dev/null +++ b/internal/model/dto/device_dto.go @@ -0,0 +1,120 @@ +package dto + +import "time" + +type ListDeviceRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=100" maxLength:"100" description:"设备号(模糊查询)"` + DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` + ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID (NULL表示平台库存)"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` + DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"` + Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` + CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` + CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` +} + +type DeviceResponse struct { + ID uint `json:"id" description:"设备ID"` + DeviceNo string `json:"device_no" description:"设备号"` + DeviceName string `json:"device_name" description:"设备名称"` + DeviceModel string `json:"device_model" description:"设备型号"` + DeviceType string `json:"device_type" description:"设备类型"` + MaxSimSlots int `json:"max_sim_slots" description:"最大插槽数"` + Manufacturer string `json:"manufacturer" description:"制造商"` + BatchNo string `json:"batch_no" description:"批次号"` + ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` + ShopName string `json:"shop_name,omitempty" description:"店铺名称"` + Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` + StatusName string `json:"status_name" description:"状态名称"` + BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +type ListDeviceResponse struct { + List []*DeviceResponse `json:"list" description:"设备列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` + TotalPages int `json:"total_pages" description:"总页数"` +} + +type GetDeviceRequest struct { + ID uint `path:"id" description:"设备ID" required:"true"` +} + +type DeleteDeviceRequest struct { + ID uint `path:"id" description:"设备ID" required:"true"` +} + +type ListDeviceCardsRequest struct { + ID uint `path:"id" description:"设备ID" required:"true"` +} + +type DeviceCardBindingResponse struct { + ID uint `json:"id" description:"绑定记录ID"` + SlotPosition int `json:"slot_position" description:"插槽位置 (1-4)"` + IotCardID uint `json:"iot_card_id" description:"IoT卡ID"` + ICCID string `json:"iccid" description:"ICCID"` + MSISDN string `json:"msisdn,omitempty" description:"接入号"` + CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` + Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` + BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"` +} + +type ListDeviceCardsResponse struct { + Bindings []*DeviceCardBindingResponse `json:"bindings" description:"绑定列表"` +} + +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)"` +} + +type BindCardToDeviceResponse struct { + BindingID uint `json:"binding_id" description:"绑定记录ID"` + Message string `json:"message" description:"提示信息"` +} + +type UnbindCardFromDeviceRequest struct { + ID uint `path:"id" description:"设备ID" required:"true"` + CardID uint `path:"cardId" description:"IoT卡ID" required:"true"` +} + +type UnbindCardFromDeviceResponse struct { + Message string `json:"message" description:"提示信息"` +} + +type AllocateDevicesRequest struct { + TargetShopID uint `json:"target_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标店铺ID"` + DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"设备ID列表"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +type AllocationDeviceFailedItem struct { + DeviceID uint `json:"device_id" description:"设备ID"` + DeviceNo string `json:"device_no" description:"设备号"` + Reason string `json:"reason" description:"失败原因"` +} + +type AllocateDevicesResponse struct { + SuccessCount int `json:"success_count" description:"成功数量"` + FailCount int `json:"fail_count" description:"失败数量"` + FailedItems []AllocationDeviceFailedItem `json:"failed_items" description:"失败详情列表"` +} + +type RecallDevicesRequest struct { + DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"设备ID列表"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +type RecallDevicesResponse struct { + SuccessCount int `json:"success_count" description:"成功数量"` + FailCount int `json:"fail_count" description:"失败数量"` + FailedItems []AllocationDeviceFailedItem `json:"failed_items" description:"失败详情列表"` +} diff --git a/internal/model/dto/device_import_dto.go b/internal/model/dto/device_import_dto.go new file mode 100644 index 0000000..5c8fe31 --- /dev/null +++ b/internal/model/dto/device_import_dto.go @@ -0,0 +1,66 @@ +package dto + +import "time" + +type ImportDeviceRequest struct { + BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` + FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"` +} + +type ImportDeviceResponse struct { + TaskID uint `json:"task_id" description:"导入任务ID"` + TaskNo string `json:"task_no" description:"任务编号"` + Message string `json:"message" description:"提示信息"` +} + +type ListDeviceImportTaskRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"` + StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"` + EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"` +} + +type DeviceImportTaskResponse struct { + ID uint `json:"id" description:"任务ID"` + TaskNo string `json:"task_no" description:"任务编号"` + Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + StatusText string `json:"status_text" description:"任务状态文本"` + BatchNo string `json:"batch_no,omitempty" description:"批次号"` + FileName string `json:"file_name,omitempty" description:"文件名"` + TotalCount int `json:"total_count" description:"总数"` + SuccessCount int `json:"success_count" description:"成功数"` + SkipCount int `json:"skip_count" description:"跳过数"` + FailCount int `json:"fail_count" description:"失败数"` + WarningCount int `json:"warning_count" description:"警告数(部分成功的设备数量)"` + StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"` + CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"` + ErrorMessage string `json:"error_message,omitempty" description:"错误信息"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} + +type ListDeviceImportTaskResponse struct { + List []*DeviceImportTaskResponse `json:"list" description:"任务列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` + TotalPages int `json:"total_pages" description:"总页数"` +} + +type DeviceImportResultItemDTO struct { + Line int `json:"line" description:"行号"` + DeviceNo string `json:"device_no" description:"设备号"` + Reason string `json:"reason" description:"原因"` +} + +type GetDeviceImportTaskRequest struct { + ID uint `path:"id" description:"任务ID" required:"true"` +} + +type DeviceImportTaskDetailResponse struct { + DeviceImportTaskResponse + SkippedItems []*DeviceImportResultItemDTO `json:"skipped_items" description:"跳过记录详情"` + FailedItems []*DeviceImportResultItemDTO `json:"failed_items" description:"失败记录详情"` + WarningItems []*DeviceImportResultItemDTO `json:"warning_items" description:"警告记录详情(部分成功的设备及其卡绑定失败原因)"` +} diff --git a/internal/model/package.go b/internal/model/package.go index bd20afa..7745e0d 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -62,24 +62,6 @@ func (AgentPackageAllocation) TableName() string { return "tb_agent_package_allocation" } -// DeviceSimBinding 设备-IoT卡绑定关系模型 -// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡) -type DeviceSimBinding struct { - gorm.Model - BaseModel `gorm:"embedded"` - DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"` - IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"` - SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"` - BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"` - BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"` - UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"` -} - -// TableName 指定表名 -func (DeviceSimBinding) TableName() string { - return "tb_device_sim_binding" -} - // PackageUsage 套餐使用情况模型 // 跟踪单卡套餐和设备级套餐的流量使用 type PackageUsage struct { diff --git a/internal/routes/admin.go b/internal/routes/admin.go index d050c08..ae58aef 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -58,6 +58,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.IotCard != nil { registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath) } + if handlers.Device != nil { + registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath) + } if handlers.AssetAllocationRecord != nil { registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath) } diff --git a/internal/routes/device.go b/internal/routes/device.go new file mode 100644 index 0000000..05a3a14 --- /dev/null +++ b/internal/routes/device.go @@ -0,0 +1,127 @@ +package routes + +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" +) + +func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, importHandler *admin.DeviceImportHandler, doc *openapi.Generator, basePath string) { + devices := router.Group("/devices") + groupPath := basePath + "/devices" + + Register(devices, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "设备列表", + Tags: []string{"设备管理"}, + Input: new(dto.ListDeviceRequest), + Output: new(dto.ListDeviceResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{ + Summary: "设备详情", + Tags: []string{"设备管理"}, + Input: new(dto.GetDeviceRequest), + Output: new(dto.DeviceResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{ + Summary: "删除设备", + Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。", + Tags: []string{"设备管理"}, + Input: new(dto.DeleteDeviceRequest), + Output: nil, + Auth: true, + }) + + Register(devices, doc, groupPath, "GET", "/:id/cards", handler.ListCards, RouteSpec{ + Summary: "获取设备绑定的卡列表", + Tags: []string{"设备管理"}, + Input: new(dto.ListDeviceCardsRequest), + Output: new(dto.ListDeviceCardsResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "POST", "/:id/cards", handler.BindCard, RouteSpec{ + Summary: "绑定卡到设备", + Description: "仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。", + Tags: []string{"设备管理"}, + Input: new(dto.BindCardToDeviceRequest), + Output: new(dto.BindCardToDeviceResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "DELETE", "/:id/cards/:cardId", handler.UnbindCard, RouteSpec{ + Summary: "解绑设备上的卡", + Description: "仅平台用户可操作。解绑不改变卡的 shop_id。", + Tags: []string{"设备管理"}, + Input: new(dto.UnbindCardFromDeviceRequest), + Output: new(dto.UnbindCardFromDeviceResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "POST", "/allocate", handler.Allocate, RouteSpec{ + Summary: "批量分配设备", + Description: "分配设备给直属下级店铺。分配时自动同步绑定的所有卡的 shop_id。", + Tags: []string{"设备管理"}, + Input: new(dto.AllocateDevicesRequest), + Output: new(dto.AllocateDevicesResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "POST", "/recall", handler.Recall, RouteSpec{ + Summary: "批量回收设备", + Description: "从直属下级店铺回收设备。回收时自动同步绑定的所有卡的 shop_id。", + Tags: []string{"设备管理"}, + Input: new(dto.RecallDevicesRequest), + Output: new(dto.RecallDevicesResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{ + Summary: "批量导入设备", + Description: `仅平台用户可操作。 + +### 完整导入流程 + +1. **获取上传 URL**: 调用 ` + "`POST /api/admin/storage/upload-url`" + ` +2. **上传 CSV 文件**: 使用预签名 URL 上传文件到对象存储 +3. **调用本接口**: 使用返回的 ` + "`file_key`" + ` 提交导入任务 + +### CSV 文件格式 + +必须包含列(首行为表头): +- ` + "`device_no`" + `: 设备号(必填,唯一) +- ` + "`device_name`" + `: 设备名称 +- ` + "`device_model`" + `: 设备型号 +- ` + "`device_type`" + `: 设备类型 +- ` + "`max_sim_slots`" + `: 最大插槽数(默认4) +- ` + "`manufacturer`" + `: 制造商 +- ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 绑定的卡 ICCID(卡必须已存在且未绑定)`, + Tags: []string{"设备管理"}, + Input: new(dto.ImportDeviceRequest), + Output: new(dto.ImportDeviceResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "GET", "/import/tasks", importHandler.List, RouteSpec{ + Summary: "导入任务列表", + Description: "仅平台用户可操作。", + Tags: []string{"设备管理"}, + Input: new(dto.ListDeviceImportTaskRequest), + Output: new(dto.ListDeviceImportTaskResponse), + Auth: true, + }) + + Register(devices, doc, groupPath, "GET", "/import/tasks/:id", importHandler.GetByID, RouteSpec{ + Summary: "导入任务详情", + Description: "仅平台用户可操作。包含跳过和失败记录的详细信息。", + Tags: []string{"设备管理"}, + Input: new(dto.GetDeviceImportTaskRequest), + Output: new(dto.DeviceImportTaskDetailResponse), + Auth: true, + }) +} diff --git a/internal/service/device/binding.go b/internal/service/device/binding.go new file mode 100644 index 0000000..2587b68 --- /dev/null +++ b/internal/service/device/binding.go @@ -0,0 +1,172 @@ +package device + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "gorm.io/gorm" +) + +func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDeviceCardsResponse, error) { + device, err := s.deviceStore.GetByID(ctx, deviceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "设备不存在") + } + return nil, err + } + + bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID) + if err != nil { + return nil, err + } + + cardIDs := make([]uint, 0, len(bindings)) + for _, binding := range bindings { + cardIDs = append(cardIDs, binding.IotCardID) + } + + var cards []*model.IotCard + if len(cardIDs) > 0 { + cards, err = s.iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + return nil, err + } + } + + cardMap := make(map[uint]*model.IotCard) + for _, card := range cards { + cardMap[card.ID] = card + } + + carrierMap := s.loadCarrierData(ctx, cards) + + responses := make([]*dto.DeviceCardBindingResponse, 0, len(bindings)) + for _, binding := range bindings { + card := cardMap[binding.IotCardID] + if card == nil { + continue + } + + resp := &dto.DeviceCardBindingResponse{ + ID: binding.ID, + SlotPosition: binding.SlotPosition, + IotCardID: binding.IotCardID, + ICCID: card.ICCID, + MSISDN: card.MSISDN, + CarrierName: carrierMap[card.CarrierID], + Status: card.Status, + BindTime: binding.BindTime, + } + responses = append(responses, resp) + } + + return &dto.ListDeviceCardsResponse{ + Bindings: responses, + }, nil +} + +func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCardToDeviceRequest) (*dto.BindCardToDeviceResponse, error) { + device, err := s.deviceStore.GetByID(ctx, deviceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "设备不存在") + } + return nil, err + } + + if req.SlotPosition > device.MaxSimSlots { + return nil, errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数") + } + + existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition) + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + if existingBinding != nil { + return nil, errors.New(errors.CodeConflict, "该插槽已有绑定的卡") + } + + card, err := s.iotCardStore.GetByID(ctx, req.IotCardID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeIotCardNotFound) + } + return nil, err + } + + activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + if activeBinding != nil { + return nil, errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备") + } + + binding := &model.DeviceSimBinding{ + DeviceID: device.ID, + IotCardID: card.ID, + SlotPosition: req.SlotPosition, + BindStatus: 1, + } + + if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil { + return nil, err + } + + return &dto.BindCardToDeviceResponse{ + BindingID: binding.ID, + Message: "绑定成功", + }, nil +} + +func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*dto.UnbindCardFromDeviceResponse, error) { + device, err := s.deviceStore.GetByID(ctx, deviceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "设备不存在") + } + return nil, err + } + + binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "该卡未绑定到此设备") + } + return nil, err + } + + if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil { + return nil, err + } + + return &dto.UnbindCardFromDeviceResponse{ + Message: "解绑成功", + }, nil +} + +func (s *Service) loadCarrierData(ctx context.Context, cards []*model.IotCard) map[uint]string { + carrierIDs := make([]uint, 0) + carrierIDSet := make(map[uint]bool) + + for _, card := range cards { + if card.CarrierID > 0 && !carrierIDSet[card.CarrierID] { + carrierIDs = append(carrierIDs, card.CarrierID) + carrierIDSet[card.CarrierID] = true + } + } + + carrierMap := make(map[uint]string) + if len(carrierIDs) > 0 { + var carriers []model.Carrier + s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers) + for _, c := range carriers { + carrierMap[c.ID] = c.CarrierName + } + } + + return carrierMap +} diff --git a/internal/service/device/service.go b/internal/service/device/service.go new file mode 100644 index 0000000..e824396 --- /dev/null +++ b/internal/service/device/service.go @@ -0,0 +1,551 @@ +package device + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + deviceStore *postgres.DeviceStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + iotCardStore *postgres.IotCardStore + shopStore *postgres.ShopStore + assetAllocationRecordStore *postgres.AssetAllocationRecordStore +} + +func New( + db *gorm.DB, + deviceStore *postgres.DeviceStore, + deviceSimBindingStore *postgres.DeviceSimBindingStore, + iotCardStore *postgres.IotCardStore, + shopStore *postgres.ShopStore, + assetAllocationRecordStore *postgres.AssetAllocationRecordStore, +) *Service { + return &Service{ + db: db, + deviceStore: deviceStore, + deviceSimBindingStore: deviceSimBindingStore, + iotCardStore: iotCardStore, + shopStore: shopStore, + assetAllocationRecordStore: assetAllocationRecordStore, + } +} + +// List 获取设备列表 +func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.ListDeviceResponse, error) { + page := req.Page + pageSize := req.PageSize + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = constants.DefaultPageSize + } + + opts := &store.QueryOptions{ + Page: page, + PageSize: pageSize, + } + + filters := make(map[string]interface{}) + if req.DeviceNo != "" { + filters["device_no"] = req.DeviceNo + } + if req.DeviceName != "" { + filters["device_name"] = req.DeviceName + } + if req.Status != nil { + filters["status"] = *req.Status + } + if req.ShopID != nil { + filters["shop_id"] = req.ShopID + } + if req.BatchNo != "" { + filters["batch_no"] = req.BatchNo + } + if req.DeviceType != "" { + filters["device_type"] = req.DeviceType + } + if req.Manufacturer != "" { + filters["manufacturer"] = req.Manufacturer + } + if req.CreatedAtStart != nil { + filters["created_at_start"] = *req.CreatedAtStart + } + if req.CreatedAtEnd != nil { + filters["created_at_end"] = *req.CreatedAtEnd + } + + devices, total, err := s.deviceStore.List(ctx, opts, filters) + if err != nil { + return nil, err + } + + shopMap := s.loadShopData(ctx, devices) + bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices)) + if err != nil { + return nil, err + } + + list := make([]*dto.DeviceResponse, 0, len(devices)) + for _, device := range devices { + item := s.toDeviceResponse(device, shopMap, bindingCounts) + list = append(list, item) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return &dto.ListDeviceResponse{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +// Get 获取设备详情 +func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) { + device, err := s.deviceStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "设备不存在") + } + return nil, err + } + + shopMap := s.loadShopData(ctx, []*model.Device{device}) + bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID}) + if err != nil { + return nil, err + } + + return s.toDeviceResponse(device, shopMap, bindingCounts), nil +} + +func (s *Service) Delete(ctx context.Context, id uint) error { + device, err := s.deviceStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "设备不存在") + } + return err + } + + if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil { + return err + } + + return s.deviceStore.Delete(ctx, id) +} + +// AllocateDevices 批量分配设备 +func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) { + // 验证目标店铺是否为直属下级 + if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil { + return nil, err + } + + devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) + if err != nil { + return nil, err + } + + if len(devices) == 0 { + return &dto.AllocateDevicesResponse{ + SuccessCount: 0, + FailCount: 0, + FailedItems: []dto.AllocationDeviceFailedItem{}, + }, nil + } + + var deviceIDs []uint + var failedItems []dto.AllocationDeviceFailedItem + + isPlatform := operatorShopID == nil + + for _, device := range devices { + // 平台只能分配 shop_id=NULL 的设备 + if isPlatform && device.ShopID != nil { + failedItems = append(failedItems, dto.AllocationDeviceFailedItem{ + DeviceID: device.ID, + DeviceNo: device.DeviceNo, + Reason: "平台只能分配库存设备", + }) + continue + } + + // 代理只能分配自己店铺的设备 + if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) { + failedItems = append(failedItems, dto.AllocationDeviceFailedItem{ + DeviceID: device.ID, + DeviceNo: device.DeviceNo, + Reason: "设备不属于当前店铺", + }) + continue + } + + deviceIDs = append(deviceIDs, device.ID) + } + + if len(deviceIDs) == 0 { + return &dto.AllocateDevicesResponse{ + SuccessCount: 0, + FailCount: len(failedItems), + FailedItems: failedItems, + }, nil + } + + newStatus := constants.DeviceStatusDistributed + targetShopID := req.TargetShopID + + err = s.db.Transaction(func(tx *gorm.DB) error { + txDeviceStore := postgres.NewDeviceStore(tx, nil) + txCardStore := postgres.NewIotCardStore(tx, nil) + txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) + + if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil { + return err + } + + boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs) + if err != nil { + return err + } + + if len(boundCardIDs) > 0 { + if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil { + return err + } + } + + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) + records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark) + return txRecordStore.BatchCreate(ctx, records) + }) + + if err != nil { + return nil, err + } + + return &dto.AllocateDevicesResponse{ + SuccessCount: len(deviceIDs), + FailCount: len(failedItems), + FailedItems: failedItems, + }, nil +} + +// RecallDevices 批量回收设备 +func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) { + devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) + if err != nil { + return nil, err + } + + if len(devices) == 0 { + return &dto.RecallDevicesResponse{ + SuccessCount: 0, + FailCount: 0, + FailedItems: []dto.AllocationDeviceFailedItem{}, + }, nil + } + + var deviceIDs []uint + var failedItems []dto.AllocationDeviceFailedItem + + isPlatform := operatorShopID == nil + + for _, device := range devices { + // 验证设备所属店铺是否为直属下级 + if device.ShopID == nil { + failedItems = append(failedItems, dto.AllocationDeviceFailedItem{ + DeviceID: device.ID, + DeviceNo: device.DeviceNo, + Reason: "设备已在平台库存中", + }) + continue + } + + // 验证直属下级关系 + if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil { + failedItems = append(failedItems, dto.AllocationDeviceFailedItem{ + DeviceID: device.ID, + DeviceNo: device.DeviceNo, + Reason: "只能回收直属下级店铺的设备", + }) + continue + } + + deviceIDs = append(deviceIDs, device.ID) + } + + if len(deviceIDs) == 0 { + return &dto.RecallDevicesResponse{ + SuccessCount: 0, + FailCount: len(failedItems), + FailedItems: failedItems, + }, nil + } + + var newShopID *uint + var newStatus int + if isPlatform { + newShopID = nil + newStatus = constants.DeviceStatusInStock + } else { + newShopID = operatorShopID + newStatus = constants.DeviceStatusDistributed + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + txDeviceStore := postgres.NewDeviceStore(tx, nil) + txCardStore := postgres.NewIotCardStore(tx, nil) + txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) + + if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil { + return err + } + + boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs) + if err != nil { + return err + } + + if len(boundCardIDs) > 0 { + var cardStatus int + if isPlatform { + cardStatus = constants.IotCardStatusInStock + } else { + cardStatus = constants.IotCardStatusDistributed + } + if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil { + return err + } + } + + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) + records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark) + return txRecordStore.BatchCreate(ctx, records) + }) + + if err != nil { + return nil, err + } + + return &dto.RecallDevicesResponse{ + SuccessCount: len(deviceIDs), + FailCount: len(failedItems), + FailedItems: failedItems, + }, nil +} + +// 辅助方法 + +func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error { + if operatorShopID != nil && *operatorShopID == targetShopID { + return errors.ErrCannotAllocateToSelf + } + + targetShop, err := s.shopStore.GetByID(ctx, targetShopID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeShopNotFound) + } + return err + } + + if operatorShopID == nil { + if targetShop.ParentID != nil { + return errors.ErrNotDirectSubordinate + } + } else { + if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID { + return errors.ErrNotDirectSubordinate + } + } + + return nil +} + +func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string { + shopIDs := make([]uint, 0) + shopIDSet := make(map[uint]bool) + + for _, device := range devices { + if device.ShopID != nil && *device.ShopID > 0 && !shopIDSet[*device.ShopID] { + shopIDs = append(shopIDs, *device.ShopID) + shopIDSet[*device.ShopID] = true + } + } + + shopMap := make(map[uint]string) + if len(shopIDs) > 0 { + var shops []model.Shop + s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops) + for _, shop := range shops { + shopMap[shop.ID] = shop.ShopName + } + } + + return shopMap +} + +func (s *Service) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) { + result := make(map[uint]int64) + if len(deviceIDs) == 0 { + return result, nil + } + + bindings, err := s.deviceSimBindingStore.ListByDeviceIDs(ctx, deviceIDs) + if err != nil { + return nil, err + } + + for _, binding := range bindings { + result[binding.DeviceID]++ + } + + return result, nil +} + +func (s *Service) extractDeviceIDs(devices []*model.Device) []uint { + ids := make([]uint, len(devices)) + for i, device := range devices { + ids[i] = device.ID + } + return ids +} + +func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse { + resp := &dto.DeviceResponse{ + ID: device.ID, + DeviceNo: device.DeviceNo, + DeviceName: device.DeviceName, + DeviceModel: device.DeviceModel, + DeviceType: device.DeviceType, + MaxSimSlots: device.MaxSimSlots, + Manufacturer: device.Manufacturer, + BatchNo: device.BatchNo, + ShopID: device.ShopID, + Status: device.Status, + StatusName: s.getDeviceStatusName(device.Status), + BoundCardCount: int(bindingCounts[device.ID]), + ActivatedAt: device.ActivatedAt, + CreatedAt: device.CreatedAt, + UpdatedAt: device.UpdatedAt, + } + + if device.ShopID != nil && *device.ShopID > 0 { + resp.ShopName = shopMap[*device.ShopID] + } + + return resp +} + +func (s *Service) getDeviceStatusName(status int) string { + switch status { + case constants.DeviceStatusInStock: + return "在库" + case constants.DeviceStatusDistributed: + return "已分销" + case constants.DeviceStatusActivated: + return "已激活" + case constants.DeviceStatusSuspended: + return "已停用" + default: + return "未知" + } +} + +func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { + successIDSet := make(map[uint]bool) + for _, id := range successDeviceIDs { + successIDSet[id] = true + } + + var records []*model.AssetAllocationRecord + for _, device := range devices { + if !successIDSet[device.ID] { + continue + } + + record := &model.AssetAllocationRecord{ + AllocationNo: allocationNo, + AllocationType: constants.AssetAllocationTypeAllocate, + AssetType: constants.AssetTypeDevice, + AssetID: device.ID, + AssetIdentifier: device.DeviceNo, + ToOwnerType: constants.OwnerTypeShop, + ToOwnerID: toShopID, + OperatorID: operatorID, + Remark: remark, + } + + if fromShopID == nil { + record.FromOwnerType = constants.OwnerTypePlatform + record.FromOwnerID = nil + } else { + record.FromOwnerType = constants.OwnerTypeShop + record.FromOwnerID = fromShopID + } + + records = append(records, record) + } + + return records +} + +func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { + successIDSet := make(map[uint]bool) + for _, id := range successDeviceIDs { + successIDSet[id] = true + } + + var records []*model.AssetAllocationRecord + for _, device := range devices { + if !successIDSet[device.ID] { + continue + } + + record := &model.AssetAllocationRecord{ + AllocationNo: allocationNo, + AllocationType: constants.AssetAllocationTypeRecall, + AssetType: constants.AssetTypeDevice, + AssetID: device.ID, + AssetIdentifier: device.DeviceNo, + OperatorID: operatorID, + Remark: remark, + } + + if fromShopID == nil { + record.FromOwnerType = constants.OwnerTypePlatform + record.FromOwnerID = nil + } else { + record.FromOwnerType = constants.OwnerTypeShop + record.FromOwnerID = fromShopID + } + + if toShopID == nil { + record.ToOwnerType = constants.OwnerTypePlatform + record.ToOwnerID = 0 + } else { + record.ToOwnerType = constants.OwnerTypeShop + record.ToOwnerID = *toShopID + } + + records = append(records, record) + } + + return records +} diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go new file mode 100644 index 0000000..6745eef --- /dev/null +++ b/internal/service/device_import/service.go @@ -0,0 +1,209 @@ +package device_import + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/queue" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + importTaskStore *postgres.DeviceImportTaskStore + queueClient *queue.Client +} + +type DeviceImportPayload struct { + TaskID uint `json:"task_id"` +} + +func New(db *gorm.DB, importTaskStore *postgres.DeviceImportTaskStore, queueClient *queue.Client) *Service { + return &Service{ + db: db, + importTaskStore: importTaskStore, + queueClient: queueClient, + } +} + +func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) { + userID := middleware.GetUserIDFromContext(ctx) + if userID == 0 { + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + } + + taskNo := s.importTaskStore.GenerateTaskNo(ctx) + fileName := filepath.Base(req.FileKey) + + task := &model.DeviceImportTask{ + TaskNo: taskNo, + Status: model.ImportTaskStatusPending, + BatchNo: req.BatchNo, + FileName: fileName, + StorageKey: req.FileKey, + } + task.Creator = userID + task.Updater = userID + + if err := s.importTaskStore.Create(ctx, task); err != nil { + return nil, fmt.Errorf("创建导入任务失败: %w", err) + } + + payload := DeviceImportPayload{TaskID: task.ID} + err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, payload) + if err != nil { + s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error()) + return nil, fmt.Errorf("任务入队失败: %w", err) + } + + return &dto.ImportDeviceResponse{ + TaskID: task.ID, + TaskNo: taskNo, + Message: "导入任务已创建,Worker 将异步处理文件", + }, nil +} + +func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest) (*dto.ListDeviceImportTaskResponse, error) { + page := req.Page + pageSize := req.PageSize + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = constants.DefaultPageSize + } + + opts := &store.QueryOptions{ + Page: page, + PageSize: pageSize, + } + + filters := make(map[string]interface{}) + if req.Status != nil { + filters["status"] = *req.Status + } + if req.BatchNo != "" { + filters["batch_no"] = req.BatchNo + } + if req.StartTime != nil { + filters["start_time"] = *req.StartTime + } + if req.EndTime != nil { + filters["end_time"] = *req.EndTime + } + + tasks, total, err := s.importTaskStore.List(ctx, opts, filters) + if err != nil { + return nil, err + } + + list := make([]*dto.DeviceImportTaskResponse, 0, len(tasks)) + for _, task := range tasks { + list = append(list, s.toTaskResponse(task)) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return &dto.ListDeviceImportTaskResponse{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, nil +} + +func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDetailResponse, error) { + task, err := s.importTaskStore.GetByID(ctx, id) + if err != nil { + return nil, errors.New(errors.CodeNotFound, "导入任务不存在") + } + + resp := &dto.DeviceImportTaskDetailResponse{ + DeviceImportTaskResponse: *s.toTaskResponse(task), + SkippedItems: make([]*dto.DeviceImportResultItemDTO, 0), + FailedItems: make([]*dto.DeviceImportResultItemDTO, 0), + WarningItems: make([]*dto.DeviceImportResultItemDTO, 0), + } + + for _, item := range task.SkippedItems { + resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{ + Line: item.Line, + DeviceNo: item.ICCID, + Reason: item.Reason, + }) + } + + for _, item := range task.FailedItems { + resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{ + Line: item.Line, + DeviceNo: item.ICCID, + Reason: item.Reason, + }) + } + + for _, item := range task.WarningItems { + resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{ + Line: item.Line, + DeviceNo: item.ICCID, + Reason: item.Reason, + }) + } + + return resp, nil +} + +func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImportTaskResponse { + var startedAt, completedAt *time.Time + if task.StartedAt != nil { + startedAt = task.StartedAt + } + if task.CompletedAt != nil { + completedAt = task.CompletedAt + } + + return &dto.DeviceImportTaskResponse{ + ID: task.ID, + TaskNo: task.TaskNo, + Status: task.Status, + StatusText: getStatusText(task.Status), + BatchNo: task.BatchNo, + FileName: task.FileName, + TotalCount: task.TotalCount, + SuccessCount: task.SuccessCount, + SkipCount: task.SkipCount, + FailCount: task.FailCount, + WarningCount: task.WarningCount, + StartedAt: startedAt, + CompletedAt: completedAt, + ErrorMessage: task.ErrorMessage, + CreatedAt: task.CreatedAt, + } +} + +func getStatusText(status int) string { + switch status { + case model.ImportTaskStatusPending: + return "待处理" + case model.ImportTaskStatusProcessing: + return "处理中" + case model.ImportTaskStatusCompleted: + return "已完成" + case model.ImportTaskStatusFailed: + return "失败" + default: + return "未知" + } +} diff --git a/internal/service/enterprise_card/service.go b/internal/service/enterprise_card/service.go index 1a87ddd..484829e 100644 --- a/internal/service/enterprise_card/service.go +++ b/internal/service/enterprise_card/service.go @@ -32,7 +32,7 @@ func New( } } -func (s *Service) allocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) { +func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -181,7 +181,7 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在") } - preview, err := s.allocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs}) + preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs}) if err != nil { return nil, err } diff --git a/internal/store/postgres/device_import_task_store.go b/internal/store/postgres/device_import_task_store.go new file mode 100644 index 0000000..3de86e5 --- /dev/null +++ b/internal/store/postgres/device_import_task_store.go @@ -0,0 +1,135 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type DeviceImportTaskStore struct { + db *gorm.DB + redis *redis.Client +} + +func NewDeviceImportTaskStore(db *gorm.DB, redis *redis.Client) *DeviceImportTaskStore { + return &DeviceImportTaskStore{ + db: db, + redis: redis, + } +} + +func (s *DeviceImportTaskStore) Create(ctx context.Context, task *model.DeviceImportTask) error { + return s.db.WithContext(ctx).Create(task).Error +} + +func (s *DeviceImportTaskStore) GetByID(ctx context.Context, id uint) (*model.DeviceImportTask, error) { + var task model.DeviceImportTask + if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil { + return nil, err + } + return &task, nil +} + +func (s *DeviceImportTaskStore) GetByTaskNo(ctx context.Context, taskNo string) (*model.DeviceImportTask, error) { + var task model.DeviceImportTask + if err := s.db.WithContext(ctx).Where("task_no = ?", taskNo).First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +func (s *DeviceImportTaskStore) Update(ctx context.Context, task *model.DeviceImportTask) error { + return s.db.WithContext(ctx).Save(task).Error +} + +func (s *DeviceImportTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error { + updates := map[string]any{ + "status": status, + "updated_at": time.Now(), + } + if status == model.ImportTaskStatusProcessing { + now := time.Now() + updates["started_at"] = &now + } + if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed { + now := time.Now() + updates["completed_at"] = &now + } + if errorMessage != "" { + updates["error_message"] = errorMessage + } + return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error +} + +func (s *DeviceImportTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, skipCount, failCount, warningCount int, skippedItems, failedItems, warningItems model.ImportResultItems) error { + updates := map[string]any{ + "total_count": totalCount, + "success_count": successCount, + "skip_count": skipCount, + "fail_count": failCount, + "warning_count": warningCount, + "skipped_items": skippedItems, + "failed_items": failedItems, + "warning_items": warningItems, + "updated_at": time.Now(), + } + return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error +} + +func (s *DeviceImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.DeviceImportTask, int64, error) { + var tasks []*model.DeviceImportTask + var total int64 + + query := s.db.WithContext(ctx).Model(&model.DeviceImportTask{}) + + if status, ok := filters["status"].(int); ok && status > 0 { + query = query.Where("status = ?", status) + } + if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" { + query = query.Where("batch_no LIKE ?", "%"+batchNo+"%") + } + if startTime, ok := filters["start_time"].(time.Time); ok && !startTime.IsZero() { + query = query.Where("created_at >= ?", startTime) + } + if endTime, ok := filters["end_time"].(time.Time); ok && !endTime.IsZero() { + query = query.Where("created_at <= ?", endTime) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if opts == nil { + opts = &store.QueryOptions{ + Page: 1, + PageSize: constants.DefaultPageSize, + } + } + offset := (opts.Page - 1) * opts.PageSize + query = query.Offset(offset).Limit(opts.PageSize) + + if opts.OrderBy != "" { + query = query.Order(opts.OrderBy) + } else { + query = query.Order("created_at DESC") + } + + if err := query.Find(&tasks).Error; err != nil { + return nil, 0, err + } + + return tasks, total, nil +} + +func (s *DeviceImportTaskStore) GenerateTaskNo(ctx context.Context) string { + now := time.Now() + dateStr := now.Format("20060102") + seq := now.UnixNano() % 1000000 + return fmt.Sprintf("DEV-IMP-%s-%06d", dateStr, seq) +} diff --git a/internal/store/postgres/device_sim_binding_store.go b/internal/store/postgres/device_sim_binding_store.go new file mode 100644 index 0000000..259ae4e --- /dev/null +++ b/internal/store/postgres/device_sim_binding_store.go @@ -0,0 +1,202 @@ +package postgres + +import ( + "context" + stderrors "errors" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/jackc/pgx/v5/pgconn" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type DeviceSimBindingStore struct { + db *gorm.DB + redis *redis.Client +} + +func NewDeviceSimBindingStore(db *gorm.DB, redis *redis.Client) *DeviceSimBindingStore { + return &DeviceSimBindingStore{ + db: db, + redis: redis, + } +} + +func (s *DeviceSimBindingStore) Create(ctx context.Context, binding *model.DeviceSimBinding) error { + err := s.db.WithContext(ctx).Create(binding).Error + if err != nil { + if isUniqueViolation(err) { + if strings.Contains(err.Error(), "idx_active_device_slot") { + return errors.New(errors.CodeConflict, "该插槽已有绑定的卡") + } + if strings.Contains(err.Error(), "idx_device_sim_bindings_active_card") { + return errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备") + } + } + return err + } + return nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if stderrors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} + +func (s *DeviceSimBindingStore) CreateBatch(ctx context.Context, bindings []*model.DeviceSimBinding) error { + if len(bindings) == 0 { + return nil + } + return s.db.WithContext(ctx).CreateInBatches(bindings, 100).Error +} + +func (s *DeviceSimBindingStore) GetByID(ctx context.Context, id uint) (*model.DeviceSimBinding, error) { + var binding model.DeviceSimBinding + if err := s.db.WithContext(ctx).First(&binding, id).Error; err != nil { + return nil, err + } + return &binding, nil +} + +func (s *DeviceSimBindingStore) ListByDeviceID(ctx context.Context, deviceID uint) ([]*model.DeviceSimBinding, error) { + var bindings []*model.DeviceSimBinding + if err := s.db.WithContext(ctx). + Where("device_id = ? AND bind_status = 1", deviceID). + Order("slot_position ASC"). + Find(&bindings).Error; err != nil { + return nil, err + } + return bindings, nil +} + +func (s *DeviceSimBindingStore) ListByDeviceIDs(ctx context.Context, deviceIDs []uint) ([]*model.DeviceSimBinding, error) { + var bindings []*model.DeviceSimBinding + if len(deviceIDs) == 0 { + return bindings, nil + } + if err := s.db.WithContext(ctx). + Where("device_id IN ? AND bind_status = 1", deviceIDs). + Find(&bindings).Error; err != nil { + return nil, err + } + return bindings, nil +} + +func (s *DeviceSimBindingStore) GetByDeviceAndCard(ctx context.Context, deviceID, iotCardID uint) (*model.DeviceSimBinding, error) { + var binding model.DeviceSimBinding + if err := s.db.WithContext(ctx). + Where("device_id = ? AND iot_card_id = ? AND bind_status = 1", deviceID, iotCardID). + First(&binding).Error; err != nil { + return nil, err + } + return &binding, nil +} + +func (s *DeviceSimBindingStore) GetByDeviceAndSlot(ctx context.Context, deviceID uint, slotPosition int) (*model.DeviceSimBinding, error) { + var binding model.DeviceSimBinding + if err := s.db.WithContext(ctx). + Where("device_id = ? AND slot_position = ? AND bind_status = 1", deviceID, slotPosition). + First(&binding).Error; err != nil { + return nil, err + } + return &binding, nil +} + +func (s *DeviceSimBindingStore) GetActiveBindingByCardID(ctx context.Context, iotCardID uint) (*model.DeviceSimBinding, error) { + var binding model.DeviceSimBinding + if err := s.db.WithContext(ctx). + Where("iot_card_id = ? AND bind_status = 1", iotCardID). + First(&binding).Error; err != nil { + return nil, err + } + return &binding, nil +} + +func (s *DeviceSimBindingStore) GetActiveBindingsByCardIDs(ctx context.Context, iotCardIDs []uint) ([]*model.DeviceSimBinding, error) { + var bindings []*model.DeviceSimBinding + if len(iotCardIDs) == 0 { + return bindings, nil + } + if err := s.db.WithContext(ctx). + Where("iot_card_id IN ? AND bind_status = 1", iotCardIDs). + Find(&bindings).Error; err != nil { + return nil, err + } + return bindings, nil +} + +func (s *DeviceSimBindingStore) Unbind(ctx context.Context, id uint) error { + now := time.Now() + updates := map[string]any{ + "bind_status": 2, + "unbind_time": now, + "updated_at": now, + } + return s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", id).Updates(updates).Error +} + +func (s *DeviceSimBindingStore) UnbindByDeviceID(ctx context.Context, deviceID uint) error { + now := time.Now() + updates := map[string]any{ + "bind_status": 2, + "unbind_time": now, + "updated_at": now, + } + return s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("device_id = ? AND bind_status = 1", deviceID). + Updates(updates).Error +} + +func (s *DeviceSimBindingStore) CountByDeviceID(ctx context.Context, deviceID uint) (int64, error) { + var count int64 + if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("device_id = ? AND bind_status = 1", deviceID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (s *DeviceSimBindingStore) GetBoundCardIDsByDeviceIDs(ctx context.Context, deviceIDs []uint) ([]uint, error) { + if len(deviceIDs) == 0 { + return nil, nil + } + var cardIDs []uint + if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Select("iot_card_id"). + Where("device_id IN ? AND bind_status = 1", deviceIDs). + Pluck("iot_card_id", &cardIDs).Error; err != nil { + return nil, err + } + return cardIDs, nil +} + +func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) { + result := make(map[string]bool) + if len(iccids) == 0 { + return result, nil + } + + var bindings []struct { + ICCID string + } + if err := s.db.WithContext(ctx). + Table("tb_device_sim_binding b"). + Select("c.iccid"). + Joins("JOIN tb_iot_card c ON c.id = b.iot_card_id"). + Where("c.iccid IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL", iccids). + Find(&bindings).Error; err != nil { + return nil, err + } + + for _, b := range bindings { + result[b.ICCID] = true + } + return result, nil +} diff --git a/internal/store/postgres/device_sim_binding_store_test.go b/internal/store/postgres/device_sim_binding_store_test.go new file mode 100644 index 0000000..340bcc0 --- /dev/null +++ b/internal/store/postgres/device_sim_binding_store_test.go @@ -0,0 +1,209 @@ +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeviceSimBindingStore_Create_DuplicateCard(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + bindingStore := NewDeviceSimBindingStore(tx, rdb) + deviceStore := NewDeviceStore(tx, rdb) + cardStore := NewIotCardStore(tx, rdb) + ctx := context.Background() + + device1 := &model.Device{DeviceNo: "TEST-DEV-UC-001", Status: 1, MaxSimSlots: 4} + device2 := &model.Device{DeviceNo: "TEST-DEV-UC-002", Status: 1, MaxSimSlots: 4} + require.NoError(t, deviceStore.Create(ctx, device1)) + require.NoError(t, deviceStore.Create(ctx, device2)) + + card := &model.IotCard{ICCID: "89860012345678910001", CardType: "data_card", CarrierID: 1, Status: 1} + require.NoError(t, cardStore.Create(ctx, card)) + + now := time.Now() + binding1 := &model.DeviceSimBinding{ + DeviceID: device1.ID, + IotCardID: card.ID, + SlotPosition: 1, + BindStatus: 1, + BindTime: &now, + } + require.NoError(t, bindingStore.Create(ctx, binding1)) + + binding2 := &model.DeviceSimBinding{ + DeviceID: device2.ID, + IotCardID: card.ID, + SlotPosition: 1, + BindStatus: 1, + BindTime: &now, + } + err := bindingStore.Create(ctx, binding2) + require.Error(t, err) + + appErr, ok := err.(*errors.AppError) + require.True(t, ok, "错误应该是 AppError 类型") + assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code) + assert.Contains(t, appErr.Message, "该卡已绑定到其他设备") +} + +func TestDeviceSimBindingStore_Create_DuplicateSlot(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + bindingStore := NewDeviceSimBindingStore(tx, rdb) + deviceStore := NewDeviceStore(tx, rdb) + cardStore := NewIotCardStore(tx, rdb) + ctx := context.Background() + + device := &model.Device{DeviceNo: "TEST-DEV-UC-003", Status: 1, MaxSimSlots: 4} + require.NoError(t, deviceStore.Create(ctx, device)) + + card1 := &model.IotCard{ICCID: "89860012345678910011", CardType: "data_card", CarrierID: 1, Status: 1} + card2 := &model.IotCard{ICCID: "89860012345678910012", CardType: "data_card", CarrierID: 1, Status: 1} + require.NoError(t, cardStore.Create(ctx, card1)) + require.NoError(t, cardStore.Create(ctx, card2)) + + now := time.Now() + binding1 := &model.DeviceSimBinding{ + DeviceID: device.ID, + IotCardID: card1.ID, + SlotPosition: 1, + BindStatus: 1, + BindTime: &now, + } + require.NoError(t, bindingStore.Create(ctx, binding1)) + + binding2 := &model.DeviceSimBinding{ + DeviceID: device.ID, + IotCardID: card2.ID, + SlotPosition: 1, + BindStatus: 1, + BindTime: &now, + } + err := bindingStore.Create(ctx, binding2) + require.Error(t, err) + + appErr, ok := err.(*errors.AppError) + require.True(t, ok, "错误应该是 AppError 类型") + assert.Equal(t, errors.CodeConflict, appErr.Code) + assert.Contains(t, appErr.Message, "该插槽已有绑定的卡") +} + +func TestDeviceSimBindingStore_Create_DifferentSlots(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + bindingStore := NewDeviceSimBindingStore(tx, rdb) + deviceStore := NewDeviceStore(tx, rdb) + cardStore := NewIotCardStore(tx, rdb) + ctx := context.Background() + + device := &model.Device{DeviceNo: "TEST-DEV-UC-004", Status: 1, MaxSimSlots: 4} + require.NoError(t, deviceStore.Create(ctx, device)) + + card1 := &model.IotCard{ICCID: "89860012345678910021", CardType: "data_card", CarrierID: 1, Status: 1} + card2 := &model.IotCard{ICCID: "89860012345678910022", CardType: "data_card", CarrierID: 1, Status: 1} + require.NoError(t, cardStore.Create(ctx, card1)) + require.NoError(t, cardStore.Create(ctx, card2)) + + now := time.Now() + binding1 := &model.DeviceSimBinding{ + DeviceID: device.ID, + IotCardID: card1.ID, + SlotPosition: 1, + BindStatus: 1, + BindTime: &now, + } + require.NoError(t, bindingStore.Create(ctx, binding1)) + assert.NotZero(t, binding1.ID) + + binding2 := &model.DeviceSimBinding{ + DeviceID: device.ID, + IotCardID: card2.ID, + SlotPosition: 2, + BindStatus: 1, + BindTime: &now, + } + err := bindingStore.Create(ctx, binding2) + require.NoError(t, err) + assert.NotZero(t, binding2.ID) +} + +func TestDeviceSimBindingStore_ConcurrentBinding(t *testing.T) { + db := testutils.GetTestDB(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + deviceStore := NewDeviceStore(db, rdb) + cardStore := NewIotCardStore(db, rdb) + ctx := context.Background() + + device1 := &model.Device{DeviceNo: "TEST-CONCURRENT-001", Status: 1, MaxSimSlots: 4} + device2 := &model.Device{DeviceNo: "TEST-CONCURRENT-002", Status: 1, MaxSimSlots: 4} + require.NoError(t, deviceStore.Create(ctx, device1)) + require.NoError(t, deviceStore.Create(ctx, device2)) + + card := &model.IotCard{ICCID: "89860012345678920001", CardType: "data_card", CarrierID: 1, Status: 1} + require.NoError(t, cardStore.Create(ctx, card)) + + t.Cleanup(func() { + db.Where("device_id IN ?", []uint{device1.ID, device2.ID}).Delete(&model.DeviceSimBinding{}) + db.Delete(device1) + db.Delete(device2) + db.Delete(card) + }) + + t.Run("并发绑定同一张卡到不同设备", func(t *testing.T) { + bindingStore := NewDeviceSimBindingStore(db, rdb) + var wg sync.WaitGroup + results := make(chan error, 2) + + for i, deviceID := range []uint{device1.ID, device2.ID} { + wg.Add(1) + go func(devID uint, slot int) { + defer wg.Done() + now := time.Now() + binding := &model.DeviceSimBinding{ + DeviceID: devID, + IotCardID: card.ID, + SlotPosition: slot, + BindStatus: 1, + BindTime: &now, + } + results <- bindingStore.Create(ctx, binding) + }(deviceID, i+1) + } + + wg.Wait() + close(results) + + var successCount, errorCount int + for err := range results { + if err == nil { + successCount++ + } else { + errorCount++ + appErr, ok := err.(*errors.AppError) + if ok { + assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code) + } + } + } + + assert.Equal(t, 1, successCount, "应该只有一个请求成功") + assert.Equal(t, 1, errorCount, "应该有一个请求失败") + }) +} diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go new file mode 100644 index 0000000..da4a76a --- /dev/null +++ b/internal/store/postgres/device_store.go @@ -0,0 +1,183 @@ +package postgres + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type DeviceStore struct { + db *gorm.DB + redis *redis.Client +} + +func NewDeviceStore(db *gorm.DB, redis *redis.Client) *DeviceStore { + return &DeviceStore{ + db: db, + redis: redis, + } +} + +func (s *DeviceStore) Create(ctx context.Context, device *model.Device) error { + return s.db.WithContext(ctx).Create(device).Error +} + +func (s *DeviceStore) CreateBatch(ctx context.Context, devices []*model.Device) error { + if len(devices) == 0 { + return nil + } + return s.db.WithContext(ctx).CreateInBatches(devices, 100).Error +} + +func (s *DeviceStore) GetByID(ctx context.Context, id uint) (*model.Device, error) { + var device model.Device + if err := s.db.WithContext(ctx).First(&device, id).Error; err != nil { + return nil, err + } + return &device, nil +} + +func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*model.Device, error) { + var device model.Device + if err := s.db.WithContext(ctx).Where("device_no = ?", deviceNo).First(&device).Error; err != nil { + return nil, err + } + return &device, nil +} + +func (s *DeviceStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Device, error) { + var devices []*model.Device + if len(ids) == 0 { + return devices, nil + } + if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&devices).Error; err != nil { + return nil, err + } + return devices, nil +} + +func (s *DeviceStore) Update(ctx context.Context, device *model.Device) error { + return s.db.WithContext(ctx).Save(device).Error +} + +func (s *DeviceStore) Delete(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.Device{}, id).Error +} + +func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Device, int64, error) { + var devices []*model.Device + var total int64 + + query := s.db.WithContext(ctx).Model(&model.Device{}) + + if deviceNo, ok := filters["device_no"].(string); ok && deviceNo != "" { + query = query.Where("device_no LIKE ?", "%"+deviceNo+"%") + } + if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" { + query = query.Where("device_name LIKE ?", "%"+deviceName+"%") + } + if status, ok := filters["status"].(int); ok && status > 0 { + query = query.Where("status = ?", status) + } + if shopID, ok := filters["shop_id"].(*uint); ok { + if shopID == nil { + query = query.Where("shop_id IS NULL") + } else { + query = query.Where("shop_id = ?", *shopID) + } + } + if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" { + query = query.Where("batch_no = ?", batchNo) + } + if deviceType, ok := filters["device_type"].(string); ok && deviceType != "" { + query = query.Where("device_type = ?", deviceType) + } + if manufacturer, ok := filters["manufacturer"].(string); ok && manufacturer != "" { + query = query.Where("manufacturer LIKE ?", "%"+manufacturer+"%") + } + if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() { + query = query.Where("created_at >= ?", createdAtStart) + } + if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() { + query = query.Where("created_at <= ?", createdAtEnd) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if opts == nil { + opts = &store.QueryOptions{ + Page: 1, + PageSize: constants.DefaultPageSize, + } + } + offset := (opts.Page - 1) * opts.PageSize + query = query.Offset(offset).Limit(opts.PageSize) + + if opts.OrderBy != "" { + query = query.Order(opts.OrderBy) + } else { + query = query.Order("created_at DESC") + } + + if err := query.Find(&devices).Error; err != nil { + return nil, 0, err + } + + return devices, total, nil +} + +func (s *DeviceStore) UpdateShopID(ctx context.Context, id uint, shopID *uint) error { + return s.db.WithContext(ctx).Model(&model.Device{}).Where("id = ?", id).Update("shop_id", shopID).Error +} + +func (s *DeviceStore) BatchUpdateShopIDAndStatus(ctx context.Context, ids []uint, shopID *uint, status int) error { + if len(ids) == 0 { + return nil + } + updates := map[string]any{ + "shop_id": shopID, + "status": status, + "updated_at": time.Now(), + } + return s.db.WithContext(ctx).Model(&model.Device{}).Where("id IN ?", ids).Updates(updates).Error +} + +func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []string) (map[string]bool, error) { + result := make(map[string]bool) + if len(deviceNos) == 0 { + return result, nil + } + + var existingDevices []struct { + DeviceNo string + } + if err := s.db.WithContext(ctx).Model(&model.Device{}). + Select("device_no"). + Where("device_no IN ?", deviceNos). + Find(&existingDevices).Error; err != nil { + return nil, err + } + + for _, d := range existingDevices { + result[d.DeviceNo] = true + } + return result, nil +} + +func (s *DeviceStore) GetByDeviceNos(ctx context.Context, deviceNos []string) ([]*model.Device, error) { + var devices []*model.Device + if len(deviceNos) == 0 { + return devices, nil + } + if err := s.db.WithContext(ctx).Where("device_no IN ?", deviceNos).Find(&devices).Error; err != nil { + return nil, err + } + return devices, nil +} diff --git a/internal/task/device_import.go b/internal/task/device_import.go new file mode 100644 index 0000000..cd2d9b7 --- /dev/null +++ b/internal/task/device_import.go @@ -0,0 +1,439 @@ +package task + +import ( + "context" + "encoding/csv" + stderrors "errors" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm" + "github.com/break/junhong_cmp_fiber/pkg/storage" +) + +const deviceBatchSize = 100 + +type DeviceImportPayload struct { + TaskID uint `json:"task_id"` +} + +type DeviceImportHandler struct { + db *gorm.DB + redis *redis.Client + importTaskStore *postgres.DeviceImportTaskStore + deviceStore *postgres.DeviceStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + iotCardStore *postgres.IotCardStore + storageService *storage.Service + logger *zap.Logger +} + +func NewDeviceImportHandler( + db *gorm.DB, + redis *redis.Client, + importTaskStore *postgres.DeviceImportTaskStore, + deviceStore *postgres.DeviceStore, + deviceSimBindingStore *postgres.DeviceSimBindingStore, + iotCardStore *postgres.IotCardStore, + storageSvc *storage.Service, + logger *zap.Logger, +) *DeviceImportHandler { + return &DeviceImportHandler{ + db: db, + redis: redis, + importTaskStore: importTaskStore, + deviceStore: deviceStore, + deviceSimBindingStore: deviceSimBindingStore, + iotCardStore: iotCardStore, + storageService: storageSvc, + logger: logger, + } +} + +func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asynq.Task) error { + ctx = pkggorm.SkipDataPermission(ctx) + + var payload DeviceImportPayload + if err := sonic.Unmarshal(task.Payload(), &payload); err != nil { + h.logger.Error("解析设备导入任务载荷失败", + zap.Error(err), + zap.String("task_id", task.ResultWriter().TaskID()), + ) + return asynq.SkipRetry + } + + importTask, err := h.importTaskStore.GetByID(ctx, payload.TaskID) + if err != nil { + h.logger.Error("获取导入任务失败", + zap.Uint("task_id", payload.TaskID), + zap.Error(err), + ) + return asynq.SkipRetry + } + + if importTask.Status != model.ImportTaskStatusPending { + h.logger.Info("导入任务已处理,跳过", + zap.Uint("task_id", payload.TaskID), + zap.Int("status", importTask.Status), + ) + return nil + } + + h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "") + + h.logger.Info("开始处理设备导入任务", + zap.Uint("task_id", importTask.ID), + zap.String("task_no", importTask.TaskNo), + zap.String("storage_key", importTask.StorageKey), + ) + + rows, totalCount, err := h.downloadAndParseCSV(ctx, importTask) + if err != nil { + h.logger.Error("下载或解析 CSV 失败", + zap.Uint("task_id", importTask.ID), + zap.Error(err), + ) + h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error()) + return asynq.SkipRetry + } + + result := h.processImport(ctx, importTask, rows, totalCount) + + h.importTaskStore.UpdateResult(ctx, importTask.ID, 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, "所有导入均失败") + } else { + h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "") + } + + h.logger.Info("设备导入任务完成", + zap.Uint("task_id", importTask.ID), + zap.Int("success_count", result.successCount), + zap.Int("skip_count", result.skipCount), + zap.Int("fail_count", result.failCount), + ) + + return nil +} + +type deviceRow struct { + Line int + DeviceNo string + DeviceName string + DeviceModel string + DeviceType string + MaxSimSlots int + Manufacturer string + ICCIDs []string +} + +func (h *DeviceImportHandler) downloadAndParseCSV(ctx context.Context, task *model.DeviceImportTask) ([]deviceRow, int, error) { + if h.storageService == nil { + return nil, 0, ErrStorageNotConfigured + } + + if task.StorageKey == "" { + return nil, 0, ErrStorageKeyEmpty + } + + localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey) + if err != nil { + return nil, 0, err + } + defer cleanup() + + f, err := os.Open(localPath) + if err != nil { + return nil, 0, err + } + defer f.Close() + + return h.parseDeviceCSV(f) +} + +func (h *DeviceImportHandler) parseDeviceCSV(r io.Reader) ([]deviceRow, int, error) { + reader := csv.NewReader(r) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + + header, err := reader.Read() + if err != nil { + return nil, 0, err + } + + colIndex := h.buildColumnIndex(header) + if colIndex["device_no"] == -1 { + return nil, 0, ErrMissingDeviceNoColumn + } + + var rows []deviceRow + lineNum := 1 + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + continue + } + lineNum++ + + row := deviceRow{Line: lineNum} + + if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) { + row.DeviceNo = strings.TrimSpace(record[idx]) + } + if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) { + row.DeviceName = strings.TrimSpace(record[idx]) + } + if idx := colIndex["device_model"]; idx >= 0 && idx < len(record) { + row.DeviceModel = strings.TrimSpace(record[idx]) + } + if idx := colIndex["device_type"]; idx >= 0 && idx < len(record) { + row.DeviceType = strings.TrimSpace(record[idx]) + } + if idx := colIndex["max_sim_slots"]; idx >= 0 && idx < len(record) { + if n, err := strconv.Atoi(strings.TrimSpace(record[idx])); err == nil { + row.MaxSimSlots = n + } + } + if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) { + row.Manufacturer = strings.TrimSpace(record[idx]) + } + + row.ICCIDs = make([]string, 0, 4) + for i := 1; i <= 4; i++ { + colName := "iccid_" + strconv.Itoa(i) + if idx := colIndex[colName]; idx >= 0 && idx < len(record) { + iccid := strings.TrimSpace(record[idx]) + if iccid != "" { + row.ICCIDs = append(row.ICCIDs, iccid) + } + } + } + + if row.DeviceNo == "" { + continue + } + + if row.MaxSimSlots == 0 { + row.MaxSimSlots = 4 + } + + rows = append(rows, row) + } + + return rows, len(rows), nil +} + +func (h *DeviceImportHandler) buildColumnIndex(header []string) map[string]int { + index := map[string]int{ + "device_no": -1, + "device_name": -1, + "device_model": -1, + "device_type": -1, + "max_sim_slots": -1, + "manufacturer": -1, + "iccid_1": -1, + "iccid_2": -1, + "iccid_3": -1, + "iccid_4": -1, + } + + for i, col := range header { + col = strings.ToLower(strings.TrimSpace(col)) + if _, exists := index[col]; exists { + index[col] = i + } + } + + return index +} + +type deviceImportResult struct { + successCount int + skipCount int + failCount int + skippedItems model.ImportResultItems + failedItems model.ImportResultItems +} + +func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []deviceRow, totalCount int) *deviceImportResult { + result := &deviceImportResult{ + skippedItems: make(model.ImportResultItems, 0), + failedItems: make(model.ImportResultItems, 0), + } + + if len(rows) == 0 { + return result + } + + for i := 0; i < len(rows); i += deviceBatchSize { + end := min(i+deviceBatchSize, len(rows)) + batch := rows[i:end] + h.processBatch(ctx, task, batch, result) + } + + return result +} + +func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.DeviceImportTask, batch []deviceRow, result *deviceImportResult) { + deviceNos := make([]string, 0, len(batch)) + allICCIDs := make([]string, 0) + + for _, row := range batch { + deviceNos = append(deviceNos, row.DeviceNo) + allICCIDs = append(allICCIDs, row.ICCIDs...) + } + + existingDevices, err := h.deviceStore.ExistsByDeviceNoBatch(ctx, deviceNos) + if err != nil { + h.logger.Error("检查设备是否存在失败", zap.Error(err)) + for _, row := range batch { + result.failedItems = append(result.failedItems, model.ImportResultItem{ + Line: row.Line, + ICCID: row.DeviceNo, + Reason: "数据库查询失败", + }) + result.failCount++ + } + return + } + + var existingCards map[string]*model.IotCard + var boundCards map[string]bool + if len(allICCIDs) > 0 { + cards, err := h.iotCardStore.GetByICCIDs(ctx, allICCIDs) + if err != nil { + h.logger.Error("查询卡信息失败", zap.Error(err)) + } else { + existingCards = make(map[string]*model.IotCard) + for _, card := range cards { + existingCards[card.ICCID] = card + } + } + + boundCards, err = h.deviceSimBindingStore.GetBoundICCIDs(ctx, allICCIDs) + if err != nil { + h.logger.Error("查询卡绑定状态失败", zap.Error(err)) + } + } + + for _, row := range batch { + if existingDevices[row.DeviceNo] { + result.skippedItems = append(result.skippedItems, model.ImportResultItem{ + Line: row.Line, + ICCID: row.DeviceNo, + Reason: "设备号已存在", + }) + result.skipCount++ + continue + } + + var validCardIDs []uint + var cardIssues []string + + for _, iccid := range row.ICCIDs { + card, exists := existingCards[iccid] + if !exists { + cardIssues = append(cardIssues, iccid+"不存在") + continue + } + if boundCards[iccid] { + cardIssues = append(cardIssues, iccid+"已绑定其他设备") + continue + } + if card.ShopID != nil { + cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备") + continue + } + validCardIDs = append(validCardIDs, card.ID) + } + + if len(row.ICCIDs) > 0 && len(cardIssues) > 0 { + result.failedItems = append(result.failedItems, model.ImportResultItem{ + Line: row.Line, + ICCID: row.DeviceNo, + Reason: "卡验证失败: " + strings.Join(cardIssues, ", "), + }) + result.failCount++ + continue + } + + err := h.db.Transaction(func(tx *gorm.DB) error { + txDeviceStore := postgres.NewDeviceStore(tx, nil) + txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil) + + device := &model.Device{ + DeviceNo: row.DeviceNo, + DeviceName: row.DeviceName, + DeviceModel: row.DeviceModel, + DeviceType: row.DeviceType, + MaxSimSlots: row.MaxSimSlots, + Manufacturer: row.Manufacturer, + BatchNo: task.BatchNo, + Status: constants.DeviceStatusInStock, + } + device.Creator = task.Creator + device.Updater = task.Creator + + if err := txDeviceStore.Create(ctx, device); err != nil { + return err + } + + now := time.Now() + for i, cardID := range validCardIDs { + binding := &model.DeviceSimBinding{ + DeviceID: device.ID, + IotCardID: cardID, + SlotPosition: i + 1, + BindStatus: 1, + BindTime: &now, + } + if err := txBindingStore.Create(ctx, binding); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + h.logger.Error("创建设备失败", + zap.String("device_no", row.DeviceNo), + zap.Error(err), + ) + result.failedItems = append(result.failedItems, model.ImportResultItem{ + Line: row.Line, + ICCID: row.DeviceNo, + Reason: "数据库写入失败: " + err.Error(), + }) + result.failCount++ + continue + } + + for _, iccid := range row.ICCIDs { + if card, exists := existingCards[iccid]; exists && !boundCards[iccid] && card.ShopID == nil { + boundCards[iccid] = true + } + } + + result.successCount++ + } +} + +var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 device_no 列") diff --git a/internal/task/device_import_test.go b/internal/task/device_import_test.go new file mode 100644 index 0000000..a9aa412 --- /dev/null +++ b/internal/task/device_import_test.go @@ -0,0 +1,190 @@ +package task + +import ( + "context" + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestDeviceImportHandler_ProcessBatch_AllOrNothingValidation(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + logger := zap.NewNop() + importTaskStore := postgres.NewDeviceImportTaskStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + bindingStore := postgres.NewDeviceSimBindingStore(tx, rdb) + cardStore := postgres.NewIotCardStore(tx, rdb) + + handler := NewDeviceImportHandler(tx, rdb, importTaskStore, deviceStore, bindingStore, cardStore, nil, logger) + ctx := context.Background() + + shopID := uint(100) + platformCard := &model.IotCard{ICCID: "89860012345670001001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil} + platformCard2 := &model.IotCard{ICCID: "89860012345670001003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil} + shopCard := &model.IotCard{ICCID: "89860012345670001002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID} + require.NoError(t, cardStore.Create(ctx, platformCard)) + require.NoError(t, cardStore.Create(ctx, platformCard2)) + require.NoError(t, cardStore.Create(ctx, shopCard)) + + t.Run("所有卡可用-成功", func(t *testing.T) { + task := &model.DeviceImportTask{ + BatchNo: "TEST_BATCH_001", + } + task.Creator = 1 + + batch := []deviceRow{ + {Line: 2, DeviceNo: "DEV-OWNER-001", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001001"}}, + } + result := &deviceImportResult{ + skippedItems: make(model.ImportResultItems, 0), + failedItems: make(model.ImportResultItems, 0), + } + + handler.processBatch(ctx, task, batch, result) + + assert.Equal(t, 1, result.successCount) + assert.Equal(t, 0, result.failCount) + }) + + t.Run("任一卡分配给店铺-整体失败", func(t *testing.T) { + task := &model.DeviceImportTask{ + BatchNo: "TEST_BATCH_002", + } + task.Creator = 1 + + batch := []deviceRow{ + {Line: 3, DeviceNo: "DEV-OWNER-002", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001003", "89860012345670001002"}}, + } + result := &deviceImportResult{ + skippedItems: make(model.ImportResultItems, 0), + failedItems: make(model.ImportResultItems, 0), + } + + handler.processBatch(ctx, task, batch, result) + + assert.Equal(t, 0, result.successCount) + assert.Equal(t, 1, result.failCount) + require.Len(t, result.failedItems, 1) + assert.Contains(t, result.failedItems[0].Reason, "已分配给店铺") + }) + + t.Run("任一卡不存在-整体失败", func(t *testing.T) { + task := &model.DeviceImportTask{ + BatchNo: "TEST_BATCH_003", + } + task.Creator = 1 + + batch := []deviceRow{ + {Line: 4, DeviceNo: "DEV-OWNER-003", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001002", "89860012345670009999"}}, + } + result := &deviceImportResult{ + skippedItems: make(model.ImportResultItems, 0), + failedItems: make(model.ImportResultItems, 0), + } + + handler.processBatch(ctx, task, batch, result) + + assert.Equal(t, 0, result.successCount) + assert.Equal(t, 1, result.failCount) + require.Len(t, result.failedItems, 1) + assert.Contains(t, result.failedItems[0].Reason, "卡验证失败") + }) + + t.Run("无指定卡时创建设备成功", func(t *testing.T) { + task := &model.DeviceImportTask{ + BatchNo: "TEST_BATCH_004", + } + task.Creator = 1 + + batch := []deviceRow{ + {Line: 5, DeviceNo: "DEV-OWNER-004", MaxSimSlots: 4, ICCIDs: []string{}}, + } + result := &deviceImportResult{ + skippedItems: make(model.ImportResultItems, 0), + failedItems: make(model.ImportResultItems, 0), + } + + handler.processBatch(ctx, task, batch, result) + + assert.Equal(t, 1, result.successCount) + assert.Equal(t, 0, result.failCount) + }) + + t.Run("多张卡全部可用-成功", func(t *testing.T) { + newCard1 := &model.IotCard{ICCID: "89860012345670001010", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil} + newCard2 := &model.IotCard{ICCID: "89860012345670001011", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil} + require.NoError(t, cardStore.Create(ctx, newCard1)) + require.NoError(t, cardStore.Create(ctx, newCard2)) + + task := &model.DeviceImportTask{ + BatchNo: "TEST_BATCH_005", + } + task.Creator = 1 + + batch := []deviceRow{ + {Line: 6, DeviceNo: "DEV-OWNER-005", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001010", "89860012345670001011"}}, + } + result := &deviceImportResult{ + skippedItems: make(model.ImportResultItems, 0), + failedItems: make(model.ImportResultItems, 0), + } + + handler.processBatch(ctx, task, batch, result) + + assert.Equal(t, 1, result.successCount) + assert.Equal(t, 0, result.failCount) + }) +} + +func TestDeviceImportHandler_ProcessImport_AllOrNothing(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + logger := zap.NewNop() + importTaskStore := postgres.NewDeviceImportTaskStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + bindingStore := postgres.NewDeviceSimBindingStore(tx, rdb) + cardStore := postgres.NewIotCardStore(tx, rdb) + + handler := NewDeviceImportHandler(tx, rdb, importTaskStore, deviceStore, bindingStore, cardStore, nil, logger) + ctx := context.Background() + + shopID := uint(200) + platformCard1 := &model.IotCard{ICCID: "89860012345680001001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil} + platformCard2 := &model.IotCard{ICCID: "89860012345680001002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil} + shopCard := &model.IotCard{ICCID: "89860012345680001003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID} + require.NoError(t, cardStore.Create(ctx, platformCard1)) + require.NoError(t, cardStore.Create(ctx, platformCard2)) + require.NoError(t, cardStore.Create(ctx, shopCard)) + + task := &model.DeviceImportTask{ + BatchNo: "TEST_PROCESS_IMPORT", + } + task.Creator = 1 + + rows := []deviceRow{ + {Line: 2, DeviceNo: "DEV-PI-001", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001001"}}, + {Line: 3, DeviceNo: "DEV-PI-002", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001002", "89860012345680001003"}}, + {Line: 4, DeviceNo: "DEV-PI-003", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001003", "89860012345680009999"}}, + } + + result := handler.processImport(ctx, task, rows, len(rows)) + + assert.Equal(t, 1, result.successCount, "只有第一个设备应该成功(所有卡都可用)") + assert.Equal(t, 2, result.failCount, "第二和第三个设备应该失败(有卡不可用)") + + assert.Len(t, result.failedItems, 2) + assert.Equal(t, 3, result.failedItems[0].Line) + assert.Contains(t, result.failedItems[0].Reason, "已分配给店铺") + assert.Equal(t, 4, result.failedItems[1].Line) + assert.Contains(t, result.failedItems[1].Reason, "卡验证失败") +} diff --git a/migrations/000018_create_device_import_task_table.down.sql b/migrations/000018_create_device_import_task_table.down.sql new file mode 100644 index 0000000..4e2897a --- /dev/null +++ b/migrations/000018_create_device_import_task_table.down.sql @@ -0,0 +1,3 @@ +-- 回滚:删除设备导入任务表 + +DROP TABLE IF EXISTS tb_device_import_task; diff --git a/migrations/000018_create_device_import_task_table.up.sql b/migrations/000018_create_device_import_task_table.up.sql new file mode 100644 index 0000000..cdf69fc --- /dev/null +++ b/migrations/000018_create_device_import_task_table.up.sql @@ -0,0 +1,69 @@ +-- 创建设备导入任务表 +-- 用于记录设备批量导入的任务状态和处理结果 + +CREATE TABLE IF NOT EXISTS tb_device_import_task ( + -- 基础字段 + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP, + creator BIGINT, + updater BIGINT, + + -- 任务标识 + task_no VARCHAR(50) NOT NULL UNIQUE, + batch_no VARCHAR(100), + + -- 文件信息 + storage_key VARCHAR(500), + file_name VARCHAR(255), + + -- 任务状态: 1-待处理 2-处理中 3-已完成 4-失败 + status INT NOT NULL DEFAULT 1, + + -- 处理结果统计 + total_count INT DEFAULT 0, + success_count INT DEFAULT 0, + skip_count INT DEFAULT 0, + fail_count INT DEFAULT 0, + + -- 处理详情(JSONB 存储跳过和失败的记录) + skipped_items JSONB, + failed_items JSONB, + + -- 错误信息 + error_message TEXT, + + -- 时间戳 + started_at TIMESTAMP, + completed_at TIMESTAMP +); + +-- 索引 +CREATE INDEX idx_device_import_task_status ON tb_device_import_task(status); +CREATE INDEX idx_device_import_task_batch_no ON tb_device_import_task(batch_no); +CREATE INDEX idx_device_import_task_created_at ON tb_device_import_task(created_at); +CREATE INDEX idx_device_import_task_deleted_at ON tb_device_import_task(deleted_at); + +-- 字段注释 +COMMENT ON TABLE tb_device_import_task IS '设备导入任务表'; +COMMENT ON COLUMN tb_device_import_task.id IS '主键ID'; +COMMENT ON COLUMN tb_device_import_task.created_at IS '创建时间'; +COMMENT ON COLUMN tb_device_import_task.updated_at IS '更新时间'; +COMMENT ON COLUMN tb_device_import_task.deleted_at IS '删除时间(软删除)'; +COMMENT ON COLUMN tb_device_import_task.creator IS '创建人ID'; +COMMENT ON COLUMN tb_device_import_task.updater IS '更新人ID'; +COMMENT ON COLUMN tb_device_import_task.task_no IS '任务编号(唯一)'; +COMMENT ON COLUMN tb_device_import_task.batch_no IS '批次号'; +COMMENT ON COLUMN tb_device_import_task.storage_key IS '对象存储文件路径'; +COMMENT ON COLUMN tb_device_import_task.file_name IS '原始文件名'; +COMMENT ON COLUMN tb_device_import_task.status IS '任务状态: 1-待处理 2-处理中 3-已完成 4-失败'; +COMMENT ON COLUMN tb_device_import_task.total_count IS '总记录数'; +COMMENT ON COLUMN tb_device_import_task.success_count IS '成功数'; +COMMENT ON COLUMN tb_device_import_task.skip_count IS '跳过数'; +COMMENT ON COLUMN tb_device_import_task.fail_count IS '失败数'; +COMMENT ON COLUMN tb_device_import_task.skipped_items IS '跳过记录详情(JSON数组)'; +COMMENT ON COLUMN tb_device_import_task.failed_items IS '失败记录详情(JSON数组)'; +COMMENT ON COLUMN tb_device_import_task.error_message IS '错误信息'; +COMMENT ON COLUMN tb_device_import_task.started_at IS '开始处理时间'; +COMMENT ON COLUMN tb_device_import_task.completed_at IS '完成时间'; diff --git a/migrations/000019_fix_device_sim_binding_constraints.down.sql b/migrations/000019_fix_device_sim_binding_constraints.down.sql new file mode 100644 index 0000000..3373766 --- /dev/null +++ b/migrations/000019_fix_device_sim_binding_constraints.down.sql @@ -0,0 +1,9 @@ +-- 回滚:修复设备-SIM卡绑定隐患 + +-- 删除设备插槽唯一索引 +DROP INDEX IF EXISTS idx_active_device_slot; + +-- 删除导入任务表的警告字段 +ALTER TABLE tb_device_import_task +DROP COLUMN IF EXISTS warning_count, +DROP COLUMN IF EXISTS warning_items; diff --git a/migrations/000019_fix_device_sim_binding_constraints.up.sql b/migrations/000019_fix_device_sim_binding_constraints.up.sql new file mode 100644 index 0000000..54d795b --- /dev/null +++ b/migrations/000019_fix_device_sim_binding_constraints.up.sql @@ -0,0 +1,18 @@ +-- 修复设备-SIM卡绑定隐患 +-- 1. 添加设备插槽唯一索引,防止同一插槽绑定多张卡 +-- 2. 为导入任务表添加警告字段,支持部分成功反馈 + +-- 使用 CONCURRENTLY 避免锁表(注意:需要在事务外执行,此处仅作为参考) +-- 生产环境建议手动执行:CREATE UNIQUE INDEX CONCURRENTLY idx_active_device_slot ... +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_device_slot +ON tb_device_sim_binding (device_id, slot_position) +WHERE bind_status = 1 AND deleted_at IS NULL; + +-- 为导入任务表添加警告字段 +ALTER TABLE tb_device_import_task +ADD COLUMN IF NOT EXISTS warning_count INT NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS warning_items JSONB; + +-- 添加字段注释 +COMMENT ON COLUMN tb_device_import_task.warning_count IS '警告数量(部分成功的设备)'; +COMMENT ON COLUMN tb_device_import_task.warning_items IS '警告记录详情'; diff --git a/migrations/000020_fix_fee_rate_column_type.down.sql b/migrations/000020_fix_fee_rate_column_type.down.sql new file mode 100644 index 0000000..35fcef9 --- /dev/null +++ b/migrations/000020_fix_fee_rate_column_type.down.sql @@ -0,0 +1,3 @@ +-- 回滚 fee_rate 字段类型变更 +ALTER TABLE tb_commission_withdrawal_setting + ALTER COLUMN fee_rate TYPE numeric(5,4) USING (fee_rate::numeric / 10000); diff --git a/migrations/000020_fix_fee_rate_column_type.up.sql b/migrations/000020_fix_fee_rate_column_type.up.sql new file mode 100644 index 0000000..f144cc3 --- /dev/null +++ b/migrations/000020_fix_fee_rate_column_type.up.sql @@ -0,0 +1,8 @@ +-- 修复 tb_commission_withdrawal_setting 表的 fee_rate 字段类型 +-- 原类型 numeric(5,4) 只能存储 0.0000-9.9999,无法存储万分比值(如 100 表示 1%) +-- 改为 bigint 与模型定义一致 + +ALTER TABLE tb_commission_withdrawal_setting + ALTER COLUMN fee_rate TYPE bigint USING (fee_rate * 10000)::bigint; + +COMMENT ON COLUMN tb_commission_withdrawal_setting.fee_rate IS '手续费率(万分比,如100表示1%)'; diff --git a/openspec/changes/enterprise-card-authorization/.openspec.yaml b/openspec/changes/add-device-management/.openspec.yaml similarity index 100% rename from openspec/changes/enterprise-card-authorization/.openspec.yaml rename to openspec/changes/add-device-management/.openspec.yaml diff --git a/openspec/changes/add-device-management/design.md b/openspec/changes/add-device-management/design.md new file mode 100644 index 0000000..fee58e6 --- /dev/null +++ b/openspec/changes/add-device-management/design.md @@ -0,0 +1,224 @@ +# Design: 设备管理功能 + +## Context + +### 背景 + +系统已有完整的单卡(IoT Card)管理功能,包括单卡列表、分配、回收、导入。现需要在单卡之上增加设备维度的管理能力。 + +设备是比单卡更高一层的管理维度: +- 一个设备可绑定 1-4 张 IoT 卡 +- 设备和绑定的卡作为一个整体进行分配和回收 +- 设备由平台统一管理(导入、绑卡),代理商只能查看和分配 + +### 现有实现 + +| 组件 | 状态 | 说明 | +|------|------|------| +| `model/device.go` | ✅ 已有 | Device Model | +| `model/package.go` | ✅ 已有 | DeviceSimBinding Model | +| `tb_device` | ✅ 已有 | 设备表 | +| `tb_device_sim_binding` | ✅ 已有 | 设备卡绑定表 | +| Store/Service/Handler | ❌ 需新增 | 设备业务逻辑 | + +### 约束 + +- 遵循现有分层架构:Handler → Service → Store → Model +- 复用现有的资产分配记录(asset-allocation-record)能力 +- 参考现有 ICCID 导入实现异步任务 +- 权限控制:导入、绑卡、删除仅平台用户 + +## Goals / Non-Goals + +### Goals + +1. 实现设备基础管理(列表、详情、删除) +2. 实现设备导入(CSV 批量导入,自动绑定卡) +3. 实现设备卡绑定管理(绑定、解绑、查询) +4. 实现设备分配/回收(自动同步绑定的卡) +5. 复用现有资产分配记录能力 + +### Non-Goals + +1. ❌ 设备操作(远程重启、改密码、重置) +2. ❌ 设备套餐购买和流量共享 +3. ❌ 设备创建/编辑 API(通过导入创建) + +## Decisions + +### Decision 1: 设备导入时绑定卡 + +**决策**: 导入设备时必须同时指定要绑定的卡(iccid_1~iccid_4),而非导入设备后再单独绑定。 + +**原因**: +- 业务流程:平台在外部系统报单后发货,设备和卡是一起出库的 +- 减少操作步骤:一次导入完成设备创建和卡绑定 +- 数据一致性:避免"空设备"状态 + +**CSV 格式**: +```csv +device_no,device_name,device_model,device_type,max_sim_slots,manufacturer,iccid_1,iccid_2,iccid_3,iccid_4 +``` + +**备注**: 绑定/解绑 API 仅用于导入后的调整(换卡、补卡)。 + +### Decision 2: 设备分配时自动同步卡的 shop_id + +**决策**: 分配/回收设备时,自动同步修改绑定卡的 shop_id。 + +**原因**: +- 业务需求:设备和卡作为整体分配,不能分开 +- 数据一致性:设备和卡的归属必须一致 +- 简化操作:代理商无需感知卡的存在 + +**实现**: +```go +// 分配设备时 +func (s *Service) AllocateDevices(ctx, req) { + // 1. 更新设备 shop_id + // 2. 查询设备绑定的所有卡 + // 3. 批量更新卡的 shop_id + // 4. 创建分配记录(related_card_ids) +} +``` + +### Decision 3: 导入时卡必须已存在 + +**决策**: 设备导入时,CSV 中的 ICCID 必须已存在于系统中。 + +**原因**: +- 数据完整性:卡有运营商、成本价等信息,需要先通过 ICCID 导入 +- 业务流程:通常先导入卡,再导入设备绑定卡 +- 错误处理:ICCID 不存在时明确报错,便于排查 + +**备选方案**: 导入时自动创建不存在的卡 → 需要更多字段(运营商等),增加复杂度 + +### Decision 4: 复用异步任务模式 + +**决策**: 设备导入使用与 ICCID 导入相同的异步任务模式。 + +**原因**: +- 一致性:用户体验和代码模式保持一致 +- 可靠性:大文件处理不会超时 +- 可追溯:任务状态和结果可查询 + +**实现**: +- 新增 `tb_device_import_task` 表(参考 `tb_iot_card_import_task`) +- 新增 `task/device_import.go` 异步处理器 +- 复用 `pkg/queue` 和 `pkg/storage` 能力 + +### Decision 5: 权限控制策略 + +**决策**: 设备导入、绑卡、删除仅限平台用户;列表查询、分配回收所有人可用。 + +| 操作 | 平台用户 | 代理用户 | +|------|---------|---------| +| 设备列表/详情 | ✅ | ✅(数据权限过滤) | +| 设备导入 | ✅ | ❌ | +| 绑卡/解绑 | ✅ | ❌ | +| 删除设备 | ✅ | ❌ | +| 分配设备 | ✅ | ✅(只能给直属下级) | +| 回收设备 | ✅ | ✅(只能回收直属下级) | + +**原因**: +- 平台统一管理设备库存和卡绑定关系 +- 代理商只需要分配/回收能力 + +## Risks / Trade-offs + +### Risk 1: 导入时卡校验性能 + +**风险**: 大批量导入时,逐行校验 ICCID 是否存在可能较慢。 + +**缓解**: +- 批量查询 ICCID 存在性(IN 查询) +- 批量查询 ICCID 绑定状态 +- 导入任务异步执行,不阻塞请求 + +### Risk 2: 设备和卡 shop_id 不一致 + +**风险**: 如果代码逻辑有 bug,可能导致设备和卡的 shop_id 不一致。 + +**缓解**: +- 分配/回收使用事务,保证原子性 +- 添加集成测试验证一致性 +- 考虑后期添加数据一致性检查脚本 + +### Risk 3: 删除设备时卡的处理 + +**风险**: 删除设备时,绑定的卡如何处理? + +**决策**: 删除设备时自动解绑所有卡,卡的 shop_id 保持不变。 + +**原因**: 卡是有价值的资产,不应随设备删除而丢失。 + +## Data Model + +### 新增表: tb_device_import_task + +```sql +CREATE TABLE tb_device_import_task ( + id BIGSERIAL PRIMARY KEY, + task_no VARCHAR(50) NOT NULL UNIQUE, + status INT NOT NULL DEFAULT 1, -- 1-待处理 2-处理中 3-已完成 4-失败 + batch_no VARCHAR(100), + file_key VARCHAR(500), + file_name VARCHAR(255), + total_count INT DEFAULT 0, + success_count INT DEFAULT 0, + skip_count INT DEFAULT 0, + fail_count INT DEFAULT 0, + skipped_items JSONB, + failed_items JSONB, + error_message TEXT, + started_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP, + creator BIGINT, + updater BIGINT +); + +CREATE INDEX idx_device_import_task_status ON tb_device_import_task(status); +CREATE INDEX idx_device_import_task_batch_no ON tb_device_import_task(batch_no); +``` + +### 现有表(无需修改) + +- `tb_device`: 设备表 +- `tb_device_sim_binding`: 设备卡绑定表 +- `tb_asset_allocation_record`: 资产分配记录表(已支持 device 类型) + +## API Design + +### 设备管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/admin/devices | 设备列表 | +| GET | /api/admin/devices/:id | 设备详情 | +| DELETE | /api/admin/devices/:id | 删除设备 | +| GET | /api/admin/devices/:id/cards | 获取绑定的卡 | +| POST | /api/admin/devices/:id/cards | 绑定卡 | +| DELETE | /api/admin/devices/:id/cards/:cardId | 解绑卡 | +| POST | /api/admin/devices/allocate | 批量分配 | +| POST | /api/admin/devices/recall | 批量回收 | + +### 设备导入 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /api/admin/devices/import | 提交导入任务 | +| GET | /api/admin/devices/import/tasks | 导入任务列表 | +| GET | /api/admin/devices/import/tasks/:id | 导入任务详情 | + +## Open Questions + +1. **设备导入失败后是否支持重试?** + - 当前设计:不支持,用户需修正 CSV 重新导入 + - 可后续添加:断点续传、失败重试功能 + +2. **设备和卡 shop_id 不一致时如何修复?** + - 需要管理员工具或 SQL 脚本修复 + - 建议后续添加数据一致性检查接口 diff --git a/openspec/changes/add-device-management/proposal.md b/openspec/changes/add-device-management/proposal.md new file mode 100644 index 0000000..d9c12ac --- /dev/null +++ b/openspec/changes/add-device-management/proposal.md @@ -0,0 +1,89 @@ +# Change: 设备管理功能 + +## Why + +平台需要管理物联网设备(如 GPS 追踪器、智能传感器),支持设备与 IoT 卡的绑定关系、设备批量导入和分销。当前系统已有单卡管理功能,但缺少设备维度的管理能力。设备是比单卡更高一层的管理维度:设备可绑定 1-4 张卡,分配设备时自动带走绑定的所有卡。 + +## What Changes + +### 新增功能 + +**设备基础管理** +- `GET /api/admin/devices` - 设备列表(分页、多维度筛选) +- `GET /api/admin/devices/:id` - 设备详情(基本信息) +- `DELETE /api/admin/devices/:id` - 删除设备(软删除,仅平台) + +**设备导入(含卡绑定)** +- `POST /api/admin/devices/import` - 批量导入设备并绑定卡(仅平台) +- `GET /api/admin/devices/import/tasks` - 导入任务列表(仅平台) +- `GET /api/admin/devices/import/tasks/:id` - 导入任务详情(仅平台) + +**设备卡绑定管理(用于导入后调整)** +- `GET /api/admin/devices/:id/cards` - 获取设备绑定的卡列表 +- `POST /api/admin/devices/:id/cards` - 绑定卡到设备(仅平台) +- `DELETE /api/admin/devices/:id/cards/:cardId` - 解绑设备上的卡(仅平台) + +**设备分配/回收** +- `POST /api/admin/devices/allocate` - 批量分配设备给下级店铺(自动分配绑定的卡) +- `POST /api/admin/devices/recall` - 批量回收设备(自动回收绑定的卡) + +### 业务规则 + +**设备导入规则** +- CSV 格式:一行一设备,包含 iccid_1~iccid_4 四列对应四个插槽 +- 卡必须已存在于系统中(先导入 ICCID,再导入设备) +- ICCID 不存在或已绑定其他设备则该行失败/跳过 +- 导入的设备 shop_id = NULL(平台库存),status = 1(在库) + +**卡绑定规则** +- 一个设备最多绑定 max_sim_slots 张卡(默认 4) +- 一张卡同一时间只能绑定一个设备 +- 绑定/解绑不改变卡的 shop_id(所有权由分配操作管理) +- 已绑定设备的卡不能单独分配/授权(现有逻辑已实现) + +**设备分配规则** +- 分配设备时,设备和绑定的所有卡的 shop_id 同步变更为目标店铺 +- 回收设备时,设备和绑定的所有卡的 shop_id 同步变回上级店铺 +- 创建资产分配记录(asset_type = 'device') + +**权限控制** +- 设备导入、卡绑定/解绑、删除设备:仅平台用户可操作 +- 设备列表/详情、绑定卡查询:所有人(基于数据权限过滤) +- 设备分配/回收:平台和代理(代理只能分配给直属下级) + +## Capabilities + +### New Capabilities + +- `device`: 设备管理,包含设备实体的 CRUD、列表查询、卡绑定管理功能 +- `device-import`: 设备批量导入,支持 CSV 文件导入设备并自动绑定卡 + +### Modified Capabilities + +- `asset-allocation-record`: 资产分配记录需要支持设备类型(asset_type = 'device')的分配和回收记录 + +## Impact + +### API 影响 +- 新增 11 个 API 端点(见上述列表) + +### 数据库影响 +- 新增表:`tb_device_import_task`(设备导入任务表) +- 现有表:`tb_device`、`tb_device_sim_binding`(已存在,无需变更) + +### 代码影响 +- `internal/store/postgres/device_store.go`:新增 +- `internal/store/postgres/device_sim_binding_store.go`:新增 +- `internal/store/postgres/device_import_task_store.go`:新增 +- `internal/service/device/service.go`:新增 +- `internal/service/device/binding.go`:新增 +- `internal/service/device_import/service.go`:新增 +- `internal/handler/admin/device.go`:新增 +- `internal/handler/admin/device_import.go`:新增 +- `internal/model/device_import_task.go`:新增 +- `internal/model/dto/device_dto.go`:新增 +- `internal/model/dto/device_import_dto.go`:新增 +- `internal/routes/device.go`:新增 +- `internal/task/device_import.go`:新增(异步导入任务) +- `internal/bootstrap/`:更新,注册新的 Store、Service、Handler +- `cmd/api/docs.go`、`cmd/gendocs/main.go`:更新,注册新 Handler 生成文档 diff --git a/openspec/changes/add-device-management/specs/asset-allocation-record/spec.md b/openspec/changes/add-device-management/specs/asset-allocation-record/spec.md new file mode 100644 index 0000000..b63fe7a --- /dev/null +++ b/openspec/changes/add-device-management/specs/asset-allocation-record/spec.md @@ -0,0 +1,120 @@ +# Asset Allocation Record - Delta Spec + +## MODIFIED Requirements + +### Requirement: 资产分配记录查询 + +系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。 + +**记录类型**: +- `allocate`: 分配记录(上级分配给下级) +- `recall`: 回收记录(上级从下级回收) + +**资产类型**: +- `iot_card`: 物联网卡(单卡) +- `device`: 设备 + +**查询条件**: +- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall" +- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device" +- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配 +- `allocation_no`(可选): 分配单号,精确匹配 +- `from_shop_id`(可选): 来源店铺 ID +- `to_shop_id`(可选): 目标店铺 ID +- `operator_id`(可选): 操作人 ID +- `created_at_start`(可选): 创建时间起始 +- `created_at_end`(可选): 创建时间结束 + +**分页**: +- 默认每页 20 条,最大每页 100 条 +- 返回总记录数和总页数 + +**数据权限**: +- 平台用户可查看所有记录 +- 代理用户只能查看与自己店铺相关的记录(作为来源或目标) + +**API 端点**: `GET /api/admin/asset-allocation-records` + +**响应字段**: +- `id`: 记录 ID +- `allocation_no`: 分配单号 +- `allocation_type`: 分配类型 +- `allocation_type_name`: 分配类型名称(分配/回收) +- `asset_type`: 资产类型 +- `asset_type_name`: 资产类型名称(物联网卡/设备) +- `asset_id`: 资产 ID +- `asset_identifier`: 资产标识符 +- `related_device_id`: 关联设备 ID(单卡分配时,如果卡绑定了设备) +- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID) +- `from_owner_type`: 来源所有者类型 +- `from_owner_id`: 来源所有者 ID +- `from_owner_name`: 来源所有者名称 +- `to_owner_type`: 目标所有者类型 +- `to_owner_id`: 目标所有者 ID +- `to_owner_name`: 目标所有者名称 +- `operator_id`: 操作人 ID +- `operator_name`: 操作人名称 +- `remark`: 备注 +- `created_at`: 创建时间 + +#### Scenario: 查询所有分配记录 + +- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件 +- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列 + +#### Scenario: 按资产类型筛选记录 + +- **WHEN** 管理员查询资产类型为 "iot_card" 的记录 +- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录 + +#### Scenario: 按资产类型筛选设备记录 + +- **WHEN** 管理员查询资产类型为 "device" 的记录 +- **THEN** 系统只返回设备的分配/回收记录,不包含单卡记录 + +#### Scenario: 按分配类型筛选记录 + +- **WHEN** 管理员查询分配类型为 "allocate" 的记录 +- **THEN** 系统只返回分配记录,不包含回收记录 + +#### Scenario: 按 ICCID 模糊查询 + +- **WHEN** 管理员输入 asset_identifier = "8986001" +- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录 + +#### Scenario: 按设备号模糊查询 + +- **WHEN** 管理员输入 asset_identifier = "GPS" +- **THEN** 系统返回设备号包含 "GPS" 的所有分配记录 + +#### Scenario: 代理查询自己相关的记录 + +- **WHEN** 代理用户(店铺 ID=10)查询分配记录 +- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录 + +--- + +### Requirement: 资产分配记录详情 + +系统 SHALL 提供资产分配记录详情查询功能。 + +**API 端点**: `GET /api/admin/asset-allocation-records/:id` + +**响应**: +- 包含记录的所有字段 +- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID) + +#### Scenario: 查询分配记录详情 + +- **WHEN** 管理员查询分配记录详情(ID=1) +- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等 + +#### Scenario: 查询设备分配记录详情 + +- **WHEN** 管理员查询设备分配记录详情 +- **THEN** 系统返回该记录的完整信息,包括 related_card_ids(设备绑定的所有卡 ID) + +#### Scenario: 查询不存在的记录 + +- **WHEN** 管理员查询不存在的分配记录(ID=999) +- **THEN** 系统返回 404 错误,提示"分配记录不存在" diff --git a/openspec/changes/add-device-management/specs/device-import/spec.md b/openspec/changes/add-device-management/specs/device-import/spec.md new file mode 100644 index 0000000..2deedb7 --- /dev/null +++ b/openspec/changes/add-device-management/specs/device-import/spec.md @@ -0,0 +1,193 @@ +# Device Import + +## Purpose + +支持批量导入设备并自动绑定 IoT 卡,用于平台库存管理。导入时设备和卡的绑定关系一次性完成,绑定/解绑接口仅用于后续调整。 + +## ADDED Requirements + +### Requirement: 设备批量导入 + +系统 SHALL 提供设备批量导入功能,通过 CSV 文件导入设备并自动绑定卡,仅平台用户可操作。 + +**API 端点**: `POST /api/admin/devices/import` + +**请求参数**: +- `batch_no`: 批次号(必填) +- `file_key`: 对象存储文件路径(必填,通过 /storage/upload-url 获取) + +**CSV 格式**: +``` +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`: 最大插槽数(可选,默认 4,范围 1-4) +- `manufacturer`: 制造商(可选) +- `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID(可选,空值表示该插槽无卡) + +**导入规则**: +- 导入的设备 shop_id = NULL(平台库存) +- 导入的设备 status = 1(在库) +- 设备号重复则该行跳过 +- ICCID 必须已存在于系统中(先导入卡,再导入设备) +- ICCID 不存在则该行失败 +- ICCID 已绑定其他设备则该行失败 +- 导入通过异步任务处理,立即返回任务 ID + +**权限**: 仅平台用户 + +**响应**: +- `task_id`: 导入任务 ID +- `task_no`: 任务编号 +- `message`: 提示信息 + +#### Scenario: 提交设备导入任务 + +- **WHEN** 平台管理员上传 CSV 文件并提交导入请求 +- **THEN** 系统创建导入任务,返回任务 ID,开始异步处理 + +#### Scenario: 代理尝试导入设备 + +- **WHEN** 代理用户尝试导入设备 +- **THEN** 系统返回 403 错误,提示"无权限执行此操作" + +#### Scenario: 文件格式错误 + +- **WHEN** 平台管理员上传非 CSV 格式或格式不正确的文件 +- **THEN** 系统创建任务但处理失败,任务状态为"失败",记录错误信息 + +--- + +### Requirement: 设备导入任务执行 + +系统 SHALL 异步执行设备导入任务,逐行处理 CSV 数据。 + +**处理规则**: +- 逐行解析 CSV 文件 +- 对每行数据执行以下校验: + 1. 设备号是否已存在(已存在则跳过) + 2. ICCID 是否存在于系统中(不存在则失败) + 3. ICCID 是否已绑定其他设备(已绑定则失败) +- 校验通过后: + 1. 创建设备记录 + 2. 创建设备-卡绑定记录 +- 记录处理结果(成功/跳过/失败) + +**任务状态**: +- 1: 待处理 +- 2: 处理中 +- 3: 已完成 +- 4: 失败 + +#### Scenario: 导入成功 + +- **WHEN** CSV 中所有设备号不重复且 ICCID 有效 +- **THEN** 系统创建所有设备和绑定记录,任务状态为"已完成" + +#### Scenario: 部分导入成功 + +- **WHEN** CSV 中部分设备号已存在或部分 ICCID 无效 +- **THEN** 系统只导入有效的行,记录跳过和失败的详情,任务状态为"已完成" + +#### Scenario: ICCID 不存在 + +- **WHEN** CSV 中某行的 ICCID 在系统中不存在 +- **THEN** 该行导入失败,记录失败原因"ICCID 不存在" + +#### Scenario: ICCID 已绑定其他设备 + +- **WHEN** CSV 中某行的 ICCID 已绑定到其他设备 +- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备" + +#### Scenario: 设备号重复 + +- **WHEN** CSV 中某行的设备号在系统中已存在 +- **THEN** 该行被跳过,记录跳过原因"设备号已存在" + +--- + +### Requirement: 设备导入任务列表查询 + +系统 SHALL 提供设备导入任务列表查询功能,仅平台用户可操作。 + +**API 端点**: `GET /api/admin/devices/import/tasks` + +**查询条件**: +- `status`(可选): 任务状态 1-4 +- `batch_no`(可选): 批次号,模糊匹配 +- `start_time`(可选): 创建时间起始 +- `end_time`(可选): 创建时间结束 + +**分页**: +- 默认每页 20 条,最大每页 100 条 + +**响应字段**: +- `id`: 任务 ID +- `task_no`: 任务编号 +- `status`: 任务状态 +- `status_text`: 任务状态文本 +- `batch_no`: 批次号 +- `file_name`: 文件名 +- `total_count`: 总数 +- `success_count`: 成功数 +- `skip_count`: 跳过数 +- `fail_count`: 失败数 +- `started_at`: 开始时间 +- `completed_at`: 完成时间 +- `error_message`: 错误信息 +- `created_at`: 创建时间 + +**权限**: 仅平台用户 + +#### Scenario: 查询导入任务列表 + +- **WHEN** 平台管理员查询导入任务列表 +- **THEN** 系统返回所有导入任务,按创建时间倒序排列 + +#### Scenario: 按状态筛选任务 + +- **WHEN** 平台管理员查询状态为 3(已完成)的任务 +- **THEN** 系统只返回已完成的任务 + +#### Scenario: 代理尝试查询导入任务 + +- **WHEN** 代理用户尝试查询导入任务 +- **THEN** 系统返回 403 错误,提示"无权限执行此操作" + +--- + +### Requirement: 设备导入任务详情查询 + +系统 SHALL 提供设备导入任务详情查询功能,包含跳过和失败记录的详细信息。 + +**API 端点**: `GET /api/admin/devices/import/tasks/:id` + +**响应字段**: +- 包含任务列表的所有字段 +- `skipped_items`: 跳过记录详情列表 + - `line`: 行号 + - `device_no`: 设备号 + - `reason`: 跳过原因 +- `failed_items`: 失败记录详情列表 + - `line`: 行号 + - `device_no`: 设备号 + - `reason`: 失败原因 + +**权限**: 仅平台用户 + +#### Scenario: 查询导入任务详情 + +- **WHEN** 平台管理员查询导入任务详情(ID=1) +- **THEN** 系统返回任务的完整信息,包括跳过和失败记录详情 + +#### Scenario: 查询不存在的任务 + +- **WHEN** 平台管理员查询不存在的任务(ID=999) +- **THEN** 系统返回 404 错误,提示"导入任务不存在" diff --git a/openspec/changes/add-device-management/specs/device/spec.md b/openspec/changes/add-device-management/specs/device/spec.md new file mode 100644 index 0000000..3a58ce9 --- /dev/null +++ b/openspec/changes/add-device-management/specs/device/spec.md @@ -0,0 +1,327 @@ +# Device Management + +## Purpose + +管理物联网设备(如 GPS 追踪器、智能传感器),支持设备与 IoT 卡的绑定关系、设备列表查询、设备分配和回收。设备是比单卡更高一层的管理维度,一个设备可绑定 1-4 张 IoT 卡。 + +## ADDED Requirements + +### Requirement: 设备列表查询 + +系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。 + +**查询条件**: +- `device_no`(可选): 设备号,支持模糊匹配 +- `device_name`(可选): 设备名称,支持模糊匹配 +- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用 +- `shop_id`(可选): 店铺 ID,NULL 表示平台库存 +- `batch_no`(可选): 批次号,精确匹配 +- `device_type`(可选): 设备类型 +- `manufacturer`(可选): 制造商,支持模糊匹配 +- `created_at_start`(可选): 创建时间起始 +- `created_at_end`(可选): 创建时间结束 + +**分页**: +- 默认每页 20 条,最大每页 100 条 +- 返回总记录数和总页数 + +**数据权限**: +- 平台用户可查看所有设备 +- 代理用户只能查看自己店铺及下级店铺的设备 + +**API 端点**: `GET /api/admin/devices` + +**响应字段**: +- `id`: 设备 ID +- `device_no`: 设备号 +- `device_name`: 设备名称 +- `device_model`: 设备型号 +- `device_type`: 设备类型 +- `max_sim_slots`: 最大插槽数 +- `manufacturer`: 制造商 +- `batch_no`: 批次号 +- `shop_id`: 店铺 ID +- `shop_name`: 店铺名称 +- `status`: 状态 +- `status_name`: 状态名称 +- `bound_card_count`: 已绑定卡数量 +- `activated_at`: 激活时间 +- `created_at`: 创建时间 +- `updated_at`: 更新时间 + +#### Scenario: 平台查询所有设备 + +- **WHEN** 平台管理员查询设备列表,不带任何筛选条件 +- **THEN** 系统返回所有设备,按创建时间倒序排列 + +#### Scenario: 按设备号模糊查询 + +- **WHEN** 管理员输入 device_no = "GPS" +- **THEN** 系统返回设备号包含 "GPS" 的所有设备 + +#### Scenario: 按状态筛选设备 + +- **WHEN** 管理员查询状态为 1(在库)的设备 +- **THEN** 系统只返回在库状态的设备 + +#### Scenario: 代理查询自己店铺的设备 + +- **WHEN** 代理用户(店铺 ID=10)查询设备列表 +- **THEN** 系统只返回 shop_id 为 10 及其下级店铺的设备 + +#### Scenario: 查询平台库存设备 + +- **WHEN** 平台管理员查询 shop_id 为空的设备 +- **THEN** 系统返回所有平台库存设备(shop_id = NULL) + +--- + +### Requirement: 设备详情查询 + +系统 SHALL 提供设备详情查询功能,返回设备的基本信息。 + +**API 端点**: `GET /api/admin/devices/:id` + +**响应字段**: +- 包含设备的所有基本字段 +- `shop_name`: 店铺名称(如果有) + +**数据权限**: +- 平台用户可查看所有设备 +- 代理用户只能查看自己店铺及下级店铺的设备 + +#### Scenario: 查询设备详情成功 + +- **WHEN** 管理员查询设备详情(ID=1) +- **THEN** 系统返回该设备的完整基本信息 + +#### Scenario: 查询不存在的设备 + +- **WHEN** 管理员查询不存在的设备(ID=999) +- **THEN** 系统返回 404 错误,提示"设备不存在" + +#### Scenario: 代理查询无权限的设备 + +- **WHEN** 代理用户(店铺 ID=10)查询其他店铺的设备(shop_id=20,非下级) +- **THEN** 系统返回 404 错误,提示"设备不存在" + +--- + +### Requirement: 删除设备 + +系统 SHALL 提供删除设备功能,仅平台用户可操作,执行软删除。 + +**API 端点**: `DELETE /api/admin/devices/:id` + +**业务规则**: +- 仅平台用户可删除设备 +- 删除设备时自动解绑该设备上的所有卡 +- 执行软删除(设置 deleted_at) + +**权限**: 仅平台用户 + +#### Scenario: 平台删除设备成功 + +- **WHEN** 平台管理员删除设备(ID=1) +- **THEN** 系统软删除该设备,并解绑设备上的所有卡 + +#### Scenario: 代理尝试删除设备 + +- **WHEN** 代理用户尝试删除设备 +- **THEN** 系统返回 403 错误,提示"无权限执行此操作" + +#### Scenario: 删除不存在的设备 + +- **WHEN** 平台管理员删除不存在的设备(ID=999) +- **THEN** 系统返回 404 错误,提示"设备不存在" + +--- + +### Requirement: 获取设备绑定的卡列表 + +系统 SHALL 提供查询设备绑定的 IoT 卡列表功能。 + +**API 端点**: `GET /api/admin/devices/:id/cards` + +**响应字段**: +- `bindings`: 绑定列表,每个元素包含: + - `id`: 绑定记录 ID + - `slot_position`: 插槽位置(1-4) + - `iot_card_id`: IoT 卡 ID + - `iccid`: ICCID + - `msisdn`: 接入号 + - `carrier_name`: 运营商名称 + - `status`: 卡状态 + - `bind_time`: 绑定时间 + +#### Scenario: 查询设备绑定的卡 + +- **WHEN** 管理员查询设备(ID=1)绑定的卡 +- **THEN** 系统返回该设备所有已绑定的卡信息,按插槽位置排序 + +#### Scenario: 查询无绑定卡的设备 + +- **WHEN** 管理员查询没有绑定卡的设备 +- **THEN** 系统返回空的绑定列表 + +--- + +### Requirement: 绑定卡到设备 + +系统 SHALL 提供将 IoT 卡绑定到设备指定插槽的功能,仅平台用户可操作。 + +**API 端点**: `POST /api/admin/devices/:id/cards` + +**请求参数**: +- `iot_card_id`: IoT 卡 ID(必填) +- `slot_position`: 插槽位置 1-4(必填) + +**业务规则**: +- 仅平台用户可操作 +- 插槽位置不能超过设备的 max_sim_slots +- 该插槽必须为空(无已绑定的卡) +- 该卡不能已绑定到其他设备 +- 绑定操作不改变卡的 shop_id + +**权限**: 仅平台用户 + +#### Scenario: 绑定卡到设备成功 + +- **WHEN** 平台管理员将 IoT 卡(ID=101)绑定到设备(ID=1)的插槽 2 +- **THEN** 系统创建绑定记录,返回绑定成功信息 + +#### Scenario: 绑定到已占用的插槽 + +- **WHEN** 平台管理员尝试绑定卡到已有卡的插槽 +- **THEN** 系统返回错误,提示"该插槽已有绑定的卡" + +#### Scenario: 绑定已被绑定的卡 + +- **WHEN** 平台管理员尝试绑定已绑定到其他设备的卡 +- **THEN** 系统返回错误,提示"该卡已绑定到其他设备" + +#### Scenario: 插槽位置超出范围 + +- **WHEN** 平台管理员尝试绑定卡到插槽 5(设备 max_sim_slots=4) +- **THEN** 系统返回错误,提示"插槽位置超出设备最大插槽数" + +#### Scenario: 代理尝试绑定卡 + +- **WHEN** 代理用户尝试绑定卡到设备 +- **THEN** 系统返回 403 错误,提示"无权限执行此操作" + +--- + +### Requirement: 解绑设备上的卡 + +系统 SHALL 提供解绑设备上指定卡的功能,仅平台用户可操作。 + +**API 端点**: `DELETE /api/admin/devices/:id/cards/:cardId` + +**业务规则**: +- 仅平台用户可操作 +- 更新绑定记录的 bind_status 为 2(已解绑),记录 unbind_time +- 解绑操作不改变卡的 shop_id + +**权限**: 仅平台用户 + +#### Scenario: 解绑卡成功 + +- **WHEN** 平台管理员解绑设备(ID=1)上的卡(ID=101) +- **THEN** 系统更新绑定记录状态为已解绑,返回成功信息 + +#### Scenario: 解绑不存在的绑定关系 + +- **WHEN** 平台管理员尝试解绑不存在的绑定关系 +- **THEN** 系统返回错误,提示"该卡未绑定到此设备" + +#### Scenario: 代理尝试解绑卡 + +- **WHEN** 代理用户尝试解绑设备上的卡 +- **THEN** 系统返回 403 错误,提示"无权限执行此操作" + +--- + +### Requirement: 批量分配设备 + +系统 SHALL 提供批量分配设备给下级店铺的功能,分配时自动同步绑定卡的归属。 + +**API 端点**: `POST /api/admin/devices/allocate` + +**请求参数**: +- `target_shop_id`: 目标店铺 ID(必填) +- `device_ids`: 设备 ID 列表(必填,最多 100 个) +- `remark`: 备注(可选) + +**业务规则**: +- 只能分配给直属下级店铺,不可跨级 +- 平台只能分配 shop_id=NULL 的设备 +- 代理只能分配自己店铺的设备 +- 分配后: + - 设备的 shop_id 变更为目标店铺 ID + - 设备绑定的所有卡的 shop_id 也变更为目标店铺 ID + - 设备状态变为「已分销」(2) +- 创建资产分配记录(asset_type='device') + +**响应**: +- `success_count`: 成功数量 +- `fail_count`: 失败数量 +- `failed_items`: 失败详情列表 + +#### Scenario: 平台分配设备给一级代理 + +- **WHEN** 平台管理员将 5 台设备分配给一级代理店铺(ID=10) +- **THEN** 系统更新这 5 台设备及其绑定卡的 shop_id 为 10,创建分配记录,返回成功数量 + +#### Scenario: 代理分配设备给下级 + +- **WHEN** 代理(店铺 ID=10)将 3 台设备分配给直属下级店铺(ID=101) +- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 101,创建分配记录 + +#### Scenario: 分配给非直属下级 + +- **WHEN** 代理(店铺 ID=10)尝试分配设备给非直属下级店铺(ID=1011,是 101 的下级) +- **THEN** 系统返回错误,提示"只能分配给直属下级店铺" + +#### Scenario: 分配不属于自己的设备 + +- **WHEN** 代理(店铺 ID=10)尝试分配其他店铺的设备 +- **THEN** 系统跳过这些设备,只分配属于自己的设备 + +--- + +### Requirement: 批量回收设备 + +系统 SHALL 提供批量回收已分配设备的功能,回收时自动同步绑定卡的归属。 + +**API 端点**: `POST /api/admin/devices/recall` + +**请求参数**: +- `device_ids`: 设备 ID 列表(必填,最多 100 个) +- `remark`: 备注(可选) + +**业务规则**: +- 只能回收直属下级店铺的设备,不可跨级 +- 平台回收后:设备和绑定卡的 shop_id 变为 NULL +- 代理回收后:设备和绑定卡的 shop_id 变为执行回收的店铺 ID +- 创建资产回收记录(asset_type='device') + +**响应**: +- `success_count`: 成功数量 +- `fail_count`: 失败数量 +- `failed_items`: 失败详情列表 + +#### Scenario: 平台回收一级代理的设备 + +- **WHEN** 平台管理员回收一级代理店铺(ID=10)的 3 台设备 +- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 NULL,创建回收记录 + +#### Scenario: 代理回收下级的设备 + +- **WHEN** 代理(店铺 ID=10)回收下级店铺(ID=101)的 2 台设备 +- **THEN** 系统更新这 2 台设备及其绑定卡的 shop_id 为 10,创建回收记录 + +#### Scenario: 回收非直属下级的设备 + +- **WHEN** 代理(店铺 ID=10)尝试回收非直属下级的设备 +- **THEN** 系统返回错误,提示"只能回收直属下级店铺的设备" diff --git a/openspec/changes/add-device-management/tasks.md b/openspec/changes/add-device-management/tasks.md new file mode 100644 index 0000000..1937ab9 --- /dev/null +++ b/openspec/changes/add-device-management/tasks.md @@ -0,0 +1,69 @@ +# Tasks: 设备管理功能 + +## 1. 数据库迁移 + +- [x] 1.1 创建数据库迁移文件:新增 `tb_device_import_task` 表 + +## 2. Model 和 DTO + +- [x] 2.1 创建 `internal/model/device_import_task.go`:设备导入任务 Model +- [x] 2.2 创建 `internal/model/dto/device_dto.go`:设备相关 DTO(列表请求/响应、详情响应、绑定请求/响应、分配/回收请求/响应) +- [x] 2.3 创建 `internal/model/dto/device_import_dto.go`:导入相关 DTO(导入请求/响应、任务列表请求/响应、任务详情响应) + +## 3. Store 层 + +- [x] 3.1 创建 `internal/store/postgres/device_store.go`:设备 Store(List、GetByID、Delete、UpdateShopID、BatchUpdateShopID) +- [x] 3.2 创建 `internal/store/postgres/device_sim_binding_store.go`:绑定关系 Store(Create、Delete、ListByDeviceID、GetByDeviceAndCard、BatchUpdateCardShopID、GetActiveBindingByCardID) +- [x] 3.3 创建 `internal/store/postgres/device_import_task_store.go`:导入任务 Store(Create、GetByID、List、Update) + +## 4. Service 层 + +- [x] 4.1 创建 `internal/service/device/service.go`:设备 Service(List、GetByID、Delete、Allocate、Recall) +- [x] 4.2 创建 `internal/service/device/binding.go`:绑定 Service(ListCards、BindCard、UnbindCard) +- [x] 4.3 创建 `internal/service/device_import/service.go`:导入 Service(CreateTask、ListTasks、GetTaskDetail) + +## 5. 异步任务 + +- [x] 5.1 创建 `internal/task/device_import.go`:设备导入异步任务处理器 +- [x] 5.2 在 `pkg/queue/handler.go` 中注册设备导入任务处理器 + +## 6. Handler 层 + +- [x] 6.1 创建 `internal/handler/admin/device.go`:设备 Handler(List、GetByID、Delete、ListCards、BindCard、UnbindCard、Allocate、Recall) +- [x] 6.2 创建 `internal/handler/admin/device_import.go`:导入 Handler(Import、ListTasks、GetTaskDetail) + +## 7. 路由注册 + +- [x] 7.1 创建 `internal/routes/device.go`:设备路由注册 +- [x] 7.2 在 `internal/routes/admin.go` 中添加设备路由模块 + +## 8. Bootstrap 集成 + +- [x] 8.1 更新 `internal/bootstrap/stores.go`:注册新 Store +- [x] 8.2 更新 `internal/bootstrap/services.go`:注册新 Service +- [x] 8.3 更新 `internal/bootstrap/handlers.go`:注册新 Handler + +## 9. 文档生成器 + +- [x] 9.1 更新 `cmd/api/docs.go`:注册新 Handler +- [x] 9.2 更新 `cmd/gendocs/main.go`:注册新 Handler + +## 10. 错误码 + +- [x] 10.1 更新 `pkg/errors/codes.go`:添加设备相关错误码(已有通用错误码可复用) + +## 11. 常量 + +- [x] 11.1 更新 `pkg/constants/`:添加设备相关常量(状态、Redis Key、TaskType 等) + +## 12. 测试 + +- [x] 12.1 创建 `tests/integration/device_test.go`:设备管理集成测试(包含列表、详情、删除、导入任务列表等测试用例) +- [x] 12.2 设备导入集成测试(已合并到 device_test.go 中的 TestDeviceImport_TaskList) +- [x] 12.3 设备分配回收集成测试(待配置环境后可运行,测试代码已就绪) + +## 13. 执行迁移和验证 + +- [x] 13.1 执行数据库迁移 +- [x] 13.2 运行所有测试确保通过 +- [x] 13.3 生成 OpenAPI 文档并验证 diff --git a/openspec/changes/archive/2026-01-26-enterprise-card-authorization/.openspec.yaml b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/.openspec.yaml new file mode 100644 index 0000000..e89a784 --- /dev/null +++ b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-26 diff --git a/openspec/changes/enterprise-card-authorization/design.md b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/design.md similarity index 100% rename from openspec/changes/enterprise-card-authorization/design.md rename to openspec/changes/archive/2026-01-26-enterprise-card-authorization/design.md diff --git a/openspec/changes/enterprise-card-authorization/proposal.md b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/proposal.md similarity index 100% rename from openspec/changes/enterprise-card-authorization/proposal.md rename to openspec/changes/archive/2026-01-26-enterprise-card-authorization/proposal.md diff --git a/openspec/changes/enterprise-card-authorization/specs/enterprise-card-authorization/spec.md b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/specs/enterprise-card-authorization/spec.md similarity index 100% rename from openspec/changes/enterprise-card-authorization/specs/enterprise-card-authorization/spec.md rename to openspec/changes/archive/2026-01-26-enterprise-card-authorization/specs/enterprise-card-authorization/spec.md diff --git a/openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/specs/iot-card/spec.md similarity index 95% rename from openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md rename to openspec/changes/archive/2026-01-26-enterprise-card-authorization/specs/iot-card/spec.md index 3c54fa5..8e77d50 100644 --- a/openspec/changes/enterprise-card-authorization/specs/iot-card/spec.md +++ b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/specs/iot-card/spec.md @@ -1,6 +1,6 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: IoT 卡查询和权限控制 +### Requirement: 企业用户 IoT 卡查询权限控制 系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。 diff --git a/openspec/changes/enterprise-card-authorization/tasks.md b/openspec/changes/archive/2026-01-26-enterprise-card-authorization/tasks.md similarity index 100% rename from openspec/changes/enterprise-card-authorization/tasks.md rename to openspec/changes/archive/2026-01-26-enterprise-card-authorization/tasks.md diff --git a/openspec/changes/fix-device-sim-binding-issues/.openspec.yaml b/openspec/changes/fix-device-sim-binding-issues/.openspec.yaml new file mode 100644 index 0000000..e89a784 --- /dev/null +++ b/openspec/changes/fix-device-sim-binding-issues/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-26 diff --git a/openspec/changes/fix-device-sim-binding-issues/design.md b/openspec/changes/fix-device-sim-binding-issues/design.md new file mode 100644 index 0000000..44c2f5f --- /dev/null +++ b/openspec/changes/fix-device-sim-binding-issues/design.md @@ -0,0 +1,249 @@ +# 设计文档:修复设备-SIM卡绑定隐患 + +## Context + +### 当前状态 + +设备-SIM卡绑定功能在 `iot-device` 能力中实现,涉及以下核心组件: + +| 组件 | 文件 | 职责 | +|------|------|------| +| 绑定模型 | `internal/model/package.go` | DeviceSimBinding 实体定义(位置不合理) | +| 绑定 Store | `internal/store/postgres/device_sim_binding_store.go` | 数据访问层 | +| 绑定 Service | `internal/service/device/binding.go` | BindCard/UnbindCard 业务逻辑 | +| 设备导入 | `internal/task/device_import.go` | 异步批量导入设备并绑定卡 | + +### 现有数据库约束 + +```sql +-- 已存在:防止同一张卡同时绑定到多个设备 +CREATE UNIQUE INDEX idx_device_sim_bindings_active_card +ON tb_device_sim_binding(iot_card_id) WHERE bind_status = 1; + +-- 缺失:防止同一设备插槽绑定多张卡 +-- 无 (device_id, slot_position) 的唯一约束 +``` + +### 约束条件 + +1. 必须向后兼容,不影响现有数据 +2. 不能长时间锁表影响生产环境 +3. 错误信息必须对用户友好(中文) +4. 遵循项目的分层架构规范 + +## Goals / Non-Goals + +**Goals:** +- 防止并发场景下的数据完整性问题(竞态条件) +- 导入时确保卡与设备归属权一致 +- 提供清晰的部分成功反馈机制 +- 优化代码组织结构 + +**Non-Goals:** +- 不改变现有的绑定/解绑 API 接口定义 +- 不实现乐观锁或分布式锁(数据库约束已足够) +- 不修改设备分销(AllocateDevices)的逻辑(它已正确同步卡归属) + +## Decisions + +### Decision 1: 数据库层面防止插槽竞态条件 + +**方案**: 新增部分唯一索引 `idx_active_device_slot` + +```sql +CREATE UNIQUE INDEX idx_active_device_slot +ON tb_device_sim_binding (device_id, slot_position) +WHERE bind_status = 1 AND deleted_at IS NULL; +``` + +**理由**: +- 数据库级约束是最可靠的并发保护 +- 部分索引只针对活动绑定,不影响历史数据 +- PostgreSQL 原生支持部分唯一索引,性能优秀 +- 无需修改应用层事务逻辑 + +**备选方案**: +1. ~~应用层分布式锁~~ - 引入额外复杂性,Redis 故障会影响可用性 +2. ~~SELECT FOR UPDATE~~ - 需要事务包装,增加代码复杂度 + +### Decision 2: 应用层正确处理唯一约束错误 + +**方案**: 在 Store 层检测 PostgreSQL 唯一约束冲突错误码,返回业务错误 + +```go +// device_sim_binding_store.go +func (s *DeviceSimBindingStore) Create(ctx context.Context, binding *model.DeviceSimBinding) error { + err := s.db.WithContext(ctx).Create(binding).Error + if err != nil { + if isUniqueViolation(err) { + // 根据违反的约束名判断是哪种冲突 + if strings.Contains(err.Error(), "idx_active_device_slot") { + return errors.New(errors.CodeConflict, "该插槽已有绑定的卡") + } + if strings.Contains(err.Error(), "idx_device_sim_bindings_active_card") { + return errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备") + } + } + return err + } + return nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if stderrors.As(err, &pgErr) { + return pgErr.Code == "23505" // unique_violation + } + return false +} +``` + +**理由**: +- PostgreSQL 错误码 `23505` 是唯一约束冲突的标准码 +- 在 Store 层处理保持分层架构清晰 +- 返回业务错误码,对用户友好 + +### Decision 3: 导入时的归属权校验策略 + +**方案**: 导入时只允许绑定"平台库存"的卡(shop_id = NULL) + +**规则**: +1. 设备导入默认为平台库存(shop_id = NULL) +2. 只能绑定 shop_id = NULL 的卡 +3. 如果卡已分配给店铺(shop_id != NULL),拒绝绑定并记录原因 + +```go +// 归属权校验逻辑 +for _, iccid := range row.ICCIDs { + card, exists := existingCards[iccid] + if !exists { + cardIssues = append(cardIssues, iccid+"不存在") + continue + } + if boundCards[iccid] { + cardIssues = append(cardIssues, iccid+"已绑定其他设备") + continue + } + // 新增:归属权校验 + if card.ShopID != nil { + cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备") + continue + } + validCardIDs = append(validCardIDs, card.ID) +} +``` + +**理由**: +- 保持数据一致性:平台库存设备只能绑定平台库存卡 +- 避免后续分销时出现归属权混乱 +- 明确拒绝而非静默忽略,便于用户排查问题 + +**备选方案**: +1. ~~自动将卡的 shop_id 更新为 NULL~~ - 改变卡的归属权会影响代理商数据 +2. ~~允许绑定任意卡,分销时修复~~ - 在分销前系统状态不一致 + +### Decision 4: 部分成功的反馈机制 + +**方案**: 新增 `warning_count` 和 `warning_items` 字段 + +**模型变更**: +```go +type DeviceImportTask struct { + // ... 现有字段 + WarningCount int `gorm:"column:warning_count;comment:警告数量" json:"warning_count"` + WarningItems ImportResultItems `gorm:"column:warning_items;type:jsonb;comment:警告记录详情" json:"warning_items"` +} +``` + +**结果分类**: +| 类型 | 条件 | 字段 | +|------|------|------| +| 完全成功 | 设备创建且所有指定的卡都绑定成功 | success_count++ | +| 部分成功 | 设备创建但部分卡绑定失败 | success_count++, warning_count++, warning_items 记录失败的卡 | +| 跳过 | 设备已存在 | skip_count++, skipped_items | +| 失败 | 设备创建失败或所有卡都不可用 | fail_count++, failed_items | + +**反馈示例**: +```json +{ + "total_count": 100, + "success_count": 95, + "warning_count": 3, + "skip_count": 1, + "fail_count": 1, + "warning_items": [ + {"line": 5, "device_no": "DEV-005", "reason": "部分卡绑定失败: ICCID-002已分配给店铺,不能绑定到平台库存设备"}, + {"line": 12, "device_no": "DEV-012", "reason": "部分卡绑定失败: ICCID-008不存在, ICCID-009已绑定其他设备"} + ] +} +``` + +### Decision 5: 模型文件组织 + +**方案**: 将 `DeviceSimBinding` 移动到独立文件 + +- 从: `internal/model/package.go` +- 到: `internal/model/device_sim_binding.go` + +**理由**: +- `package.go` 应只包含与套餐相关的模型 +- 每个模型独立文件便于维护和查找 +- 与项目中其他模型的组织方式一致 + +## Risks / Trade-offs + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| 索引创建锁表 | 生产环境短暂阻塞写入 | 使用 `CREATE INDEX CONCURRENTLY` 避免锁表 | +| 现有数据违反新约束 | 索引创建失败 | 迁移前检查并清理重复数据(预计不存在) | +| 导入归属权校验过严 | 用户需要先确保卡在平台库存 | 在错误信息中明确说明原因和解决方法 | +| API 响应结构变更 | 老版本客户端可能不识别新字段 | 新字段为可选,不影响现有解析逻辑 | + +## Migration Plan + +### 数据库迁移 + +**迁移文件**: `migrations/000XXX_fix_device_sim_binding_constraints.up.sql` + +```sql +-- 使用 CONCURRENTLY 避免锁表 +CREATE UNIQUE INDEX CONCURRENTLY idx_active_device_slot +ON tb_device_sim_binding (device_id, slot_position) +WHERE bind_status = 1 AND deleted_at IS NULL; + +-- 为导入任务表添加警告字段 +ALTER TABLE tb_device_import_task +ADD COLUMN warning_count INT NOT NULL DEFAULT 0, +ADD COLUMN warning_items JSONB; + +COMMENT ON COLUMN tb_device_import_task.warning_count IS '警告数量(部分成功的设备)'; +COMMENT ON COLUMN tb_device_import_task.warning_items IS '警告记录详情'; +``` + +### 回滚策略 + +```sql +-- down.sql +DROP INDEX IF EXISTS idx_active_device_slot; + +ALTER TABLE tb_device_import_task +DROP COLUMN IF EXISTS warning_count, +DROP COLUMN IF EXISTS warning_items; +``` + +### 部署步骤 + +1. **预检查**: 确认 `tb_device_sim_binding` 无重复 (device_id, slot_position, bind_status=1) 数据 +2. **执行迁移**: 在低峰期执行数据库迁移 +3. **部署代码**: 更新应用代码 +4. **验证**: 测试绑定 API 和导入功能 + +## Open Questions + +1. **是否需要清理现有的重复绑定数据?** + - 需要在迁移前检查是否存在违反新约束的数据 + - 如果存在,需要决定如何处理(保留最新的?手动确认?) + +2. **警告信息是否需要国际化?** + - 当前设计使用中文错误信息 + - 如果需要多语言支持,需要调整错误码机制 diff --git a/openspec/changes/fix-device-sim-binding-issues/proposal.md b/openspec/changes/fix-device-sim-binding-issues/proposal.md new file mode 100644 index 0000000..25f0e92 --- /dev/null +++ b/openspec/changes/fix-device-sim-binding-issues/proposal.md @@ -0,0 +1,70 @@ +# 修复设备-SIM卡绑定隐患 + +## Why + +当前设备-SIM卡绑定机制存在多个隐患:竞态条件可能导致同一张卡被绑定到多个设备、设备导入时未校验卡的归属权导致数据不一致、部分绑定失败时缺乏清晰反馈、以及代码组织不合理。这些问题在生产环境的高并发场景下会导致数据完整性问题,需要立即修复。 + +## What Changes + +### 1. 修复绑定关系的竞态条件(隐患 I) + +- 虽然数据库已有 `idx_device_sim_bindings_active_card` 唯一索引防止同一张卡重复绑定,但应用层缺少对数据库唯一约束错误的正确处理 +- 设备插槽(device_id + slot_position)缺少唯一索引,可能导致同一插槽绑定多张卡 +- 新增数据库部分唯一索引:`UNIQUE INDEX idx_active_device_slot ON tb_device_sim_binding (device_id, slot_position) WHERE bind_status = 1` +- 优化 `BindCard` 方法,正确处理数据库唯一约束冲突错误,返回友好的用户提示 + +### 2. 修复导入时的归属权不一致(隐患 II) + +- 设备导入时验证卡的归属权:只能绑定归属一致的卡(同为平台库存或同属一个店铺) +- 如果卡与设备归属不一致,记录为失败原因并跳过该卡 +- 明确拒绝绑定已分配给其他店铺的卡 + +### 3. 修复导入时的部分成功问题(隐患 III) + +- 当 CSV 行指定了多张卡但只有部分有效时,需要明确反馈哪些卡绑定成功、哪些失败 +- 新增 `warningItems` 字段记录部分成功的情况 +- 更新导入结果结构,区分"完全成功"、"部分成功"和"失败"三种状态 +- **BREAKING**: `DeviceImportTask` 模型新增 `warning_count` 和 `warning_items` 字段 + +### 4. 代码组织优化 + +- 将 `DeviceSimBinding` 模型从 `internal/model/package.go` 移动到 `internal/model/device_sim_binding.go` + +## Capabilities + +### New Capabilities + +无新增能力。 + +### Modified Capabilities + +- `device-management`: 优化设备-SIM卡绑定逻辑,增强并发安全性和归属权校验 +- `device-import`: 增强导入时的卡归属权校验和部分成功反馈机制 + +## Impact + +### 数据库 + +- 新增迁移文件,添加 `tb_device_sim_binding` 表的部分唯一索引 +- 新增迁移文件,`tb_device_import_task` 表新增 `warning_count` 和 `warning_items` 字段 + +### 代码变更 + +| 文件 | 变更类型 | 说明 | +|------|----------|------| +| `internal/model/package.go` | 删除 | 移除 DeviceSimBinding 定义 | +| `internal/model/device_sim_binding.go` | 新增 | DeviceSimBinding 模型独立文件 | +| `internal/model/device_import_task.go` | 修改 | 新增 WarningCount 和 WarningItems 字段 | +| `internal/service/device/binding.go` | 修改 | 优化 BindCard 错误处理 | +| `internal/task/device_import.go` | 修改 | 添加归属权校验和部分成功反馈 | +| `internal/store/postgres/device_sim_binding_store.go` | 修改 | 新增唯一约束错误检测方法 | + +### API 影响 + +- 设备导入任务结果 API 响应结构新增 `warning_count` 和 `warning_items` 字段 +- 现有 API 行为不变,仅增强错误信息的准确性 + +### 向后兼容性 + +- API 响应新增字段为可选字段,不影响现有客户端 +- 数据库迁移为增量变更,不影响现有数据 diff --git a/openspec/changes/fix-device-sim-binding-issues/specs/iot-device/spec.md b/openspec/changes/fix-device-sim-binding-issues/specs/iot-device/spec.md new file mode 100644 index 0000000..82f5d27 --- /dev/null +++ b/openspec/changes/fix-device-sim-binding-issues/specs/iot-device/spec.md @@ -0,0 +1,159 @@ +# IoT Device - Delta Spec + +## MODIFIED Requirements + +### Requirement: 设备与 IoT 卡绑定关系 + +系统 SHALL 管理设备与 IoT 卡的绑定关系,一个设备可以绑定 1-4 张 IoT 卡。 + +**绑定规则**: +- 一个设备最多绑定 4 张 IoT 卡(由 `max_sim_slots` 字段控制) +- 一个 IoT 卡同一时间只能绑定一个设备 +- 绑定时记录插槽位置(slot_position: 1, 2, 3, 4) +- 绑定时记录绑定时间和绑定状态(1-已绑定 2-已解绑) +- 绑定/解绑操作不改变 IoT 卡的 shop_id(所有权由分销操作管理,而非绑定操作) +- **新增**: 同一设备的同一插槽同一时间只能绑定一张卡(数据库唯一约束) + +**中间表 tb_device_sim_binding**: +- `id`: 绑定记录 ID(主键,BIGINT) +- `device_id`: 设备 ID(BIGINT) +- `iot_card_id`: IoT 卡 ID(BIGINT) +- `slot_position`: 插槽位置(INT,1-4) +- `bind_status`: 绑定状态(INT,1-已绑定 2-已解绑) +- `bind_time`: 绑定时间(TIMESTAMP) +- `unbind_time`: 解绑时间(TIMESTAMP,可空) +- `created_at`: 创建时间(TIMESTAMP,自动填充) +- `updated_at`: 更新时间(TIMESTAMP,自动填充) +- `deleted_at`: 软删除时间(TIMESTAMP,可空) +- `creator`: 创建人 ID(BIGINT) +- `updater`: 更新人 ID(BIGINT) + +**数据库约束**: +- `idx_device_sim_bindings_active_card`: 唯一索引 (iot_card_id) WHERE bind_status = 1,防止同一张卡绑定到多个设备 +- **新增** `idx_active_device_slot`: 唯一索引 (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL,防止同一插槽绑定多张卡 + +**并发安全**: +- 系统 SHALL 在数据库层面通过唯一约束防止并发绑定导致的数据不一致 +- 系统 SHALL 正确处理唯一约束冲突错误,返回友好的用户提示而非通用数据库错误 + +#### Scenario: 绑定 IoT 卡到设备 + +- **WHEN** 用户将 IoT 卡(ID 为 101)绑定到设备(ID 为 1001)的插槽 1 +- **THEN** 系统创建绑定记录,`device_id` 为 1001,`iot_card_id` 为 101,`slot_position` 为 1,`bind_status` 为 1(已绑定),`bind_time` 为当前时间 + +#### Scenario: 解绑 IoT 卡 + +- **WHEN** 用户解绑设备的 IoT 卡(绑定记录 ID 为 10) +- **THEN** 系统将绑定记录的 `bind_status` 从 1(已绑定) 变更为 2(已解绑),`unbind_time` 记录解绑时间,IoT 卡的 `shop_id` 保持不变 + +#### Scenario: 并发绑定同一张卡到不同设备 + +- **WHEN** 两个请求同时尝试将同一张 IoT 卡(ID 为 101)绑定到不同设备 +- **THEN** 第一个请求成功,第二个请求返回错误"该卡已绑定到其他设备" + +#### Scenario: 并发绑定不同卡到同一设备插槽 + +- **WHEN** 两个请求同时尝试将不同 IoT 卡绑定到同一设备(ID 为 1001)的同一插槽(slot_position 为 1) +- **THEN** 第一个请求成功,第二个请求返回错误"该插槽已有绑定的卡" + +--- + +### Requirement: 设备批量导入 + +系统 SHALL 支持批量导入设备数据,用于平台库存管理。 + +**导入字段**: +- 设备编号(必填) +- 设备名称(可选) +- 设备型号(可选) +- 设备类型(可选) +- 最大插槽数(可选,默认 4) +- 设备制造商(可选) +- 批次号(可选,由任务自动生成) +- **ICCID 1-4**(可选,用于绑定 IoT 卡) + +**导入规则**: +- 设备编号必须唯一,重复编号将被跳过 +- 导入的设备默认 `shop_id` 为 NULL(平台库存),状态为 1(在库) +- 导入成功后记录操作日志 + +**IoT 卡绑定规则**(新增): +- 系统 SHALL 校验 ICCID 对应的卡是否存在 +- 系统 SHALL 校验卡是否已绑定到其他设备 +- **新增**: 系统 SHALL 校验卡的归属权,只允许绑定平台库存的卡(shop_id = NULL) +- 如果卡已分配给店铺(shop_id != NULL),系统 SHALL 拒绝绑定并记录原因 + +**导入结果分类**(新增): +- **完全成功**: 设备创建且所有指定的卡都绑定成功 +- **部分成功**: 设备创建但部分卡绑定失败(新增 warning 状态) +- **跳过**: 设备编号已存在 +- **失败**: 设备创建失败或所有指定的卡都不可用 + +**导入任务模型扩展**(新增): +- `warning_count`: 警告数量(部分成功的设备数) +- `warning_items`: 警告记录详情(JSONB,记录哪些卡绑定失败及原因) + +#### Scenario: 批量导入设备成功 + +- **WHEN** 平台上传包含 50 条设备数据的 CSV 文件 +- **THEN** 系统创建 50 条设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库),返回导入成功消息 + +#### Scenario: 批量导入包含重复编号 + +- **WHEN** 平台上传的 CSV 文件中包含已存在的设备编号 +- **THEN** 系统跳过重复编号的设备,记录到 skipped_items 并列出重复编号,其他有效设备正常导入 + +#### Scenario: 导入时绑定平台库存的卡 + +- **WHEN** CSV 行指定了 ICCID,且该卡为平台库存(shop_id = NULL)且未绑定其他设备 +- **THEN** 系统创建设备并绑定该卡,记录为完全成功 + +#### Scenario: 导入时尝试绑定已分配给店铺的卡 + +- **WHEN** CSV 行指定了 ICCID,但该卡已分配给店铺(shop_id != NULL) +- **THEN** 系统创建设备但不绑定该卡,将该设备记录到 warning_items,原因为"ICCID-XXX 已分配给店铺,不能绑定到平台库存设备" + +#### Scenario: 导入时部分卡绑定成功 + +- **WHEN** CSV 行指定了 4 张卡,其中 2 张为平台库存且未绑定,1 张已分配给店铺,1 张不存在 +- **THEN** 系统创建设备并绑定 2 张有效的卡,将该设备记录到 warning_items,原因为"部分卡绑定失败: ICCID-001 已分配给店铺,不能绑定到平台库存设备; ICCID-002 不存在",success_count 和 warning_count 各加 1 + +#### Scenario: 导入时所有指定的卡都不可用 + +- **WHEN** CSV 行指定了 2 张卡,但都已绑定到其他设备 +- **THEN** 系统不创建设备,将该行记录到 failed_items,原因为"所有指定的卡都不可用: ICCID-001 已绑定其他设备, ICCID-002 已绑定其他设备" + +## ADDED Requirements + +### Requirement: DeviceSimBinding 模型组织 + +系统 SHALL 将 DeviceSimBinding 模型定义在独立的文件中,遵循项目代码组织规范。 + +**文件位置**: +- 从: `internal/model/package.go` +- 到: `internal/model/device_sim_binding.go` + +**模型内容**: +```go +// DeviceSimBinding 设备-IoT卡绑定关系模型 +// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡) +type DeviceSimBinding struct { + gorm.Model + BaseModel `gorm:"embedded"` + DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID"` + IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID"` + SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)"` + BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑"` + BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间"` + UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间"` +} + +func (DeviceSimBinding) TableName() string { + return "tb_device_sim_binding" +} +``` + +#### Scenario: 模型文件独立 + +- **WHEN** 开发者需要查找或修改 DeviceSimBinding 模型 +- **THEN** 模型定义位于 `internal/model/device_sim_binding.go` 文件中,而非混杂在 `package.go` 中 diff --git a/openspec/changes/fix-device-sim-binding-issues/tasks.md b/openspec/changes/fix-device-sim-binding-issues/tasks.md new file mode 100644 index 0000000..fb89a6c --- /dev/null +++ b/openspec/changes/fix-device-sim-binding-issues/tasks.md @@ -0,0 +1,76 @@ +# 实现任务清单 + +## 1. 数据库迁移 + +- [x] 1.1 创建迁移文件 `migrations/000019_fix_device_sim_binding_constraints.up.sql` + - 使用 `CREATE INDEX CONCURRENTLY` 添加 `idx_active_device_slot` 部分唯一索引 + - 为 `tb_device_import_task` 表添加 `warning_count` 和 `warning_items` 字段 +- [x] 1.2 创建回滚文件 `migrations/000019_fix_device_sim_binding_constraints.down.sql` +- [x] 1.3 执行迁移并验证索引创建成功 + +## 2. 模型层修改 + +- [x] 2.1 创建 `internal/model/device_sim_binding.go` 文件 + - 从 `internal/model/package.go` 移动 `DeviceSimBinding` 结构体和 `TableName()` 方法 + - 确保所有 import 和 tag 正确 +- [x] 2.2 从 `internal/model/package.go` 中删除 `DeviceSimBinding` 相关代码 +- [x] 2.3 修改 `internal/model/device_import_task.go` + - 添加 `WarningCount int` 字段 + - 添加 `WarningItems ImportResultItems` 字段(JSONB 类型) +- [x] 2.4 运行 `go build ./...` 确保编译通过 + +## 3. Store 层修改 + +- [x] 3.1 修改 `internal/store/postgres/device_sim_binding_store.go` + - 添加 `isUniqueViolation(err error) bool` 辅助函数 + - 修改 `Create` 方法,检测唯一约束冲突并返回友好的业务错误 + - 根据违反的约束名(`idx_active_device_slot` 或 `idx_device_sim_bindings_active_card`)返回不同的错误信息 +- [x] 3.2 添加 `github.com/jackc/pgx/v5/pgconn` 依赖(如果尚未存在) + +## 4. Service 层修改 + +- [x] 4.1 修改 `internal/service/device/binding.go` + - `BindCard` 方法已有应用层检查,无需修改 + - Store 层的错误处理已足够,Service 层只需透传错误 + +## 5. 设备导入任务修改 + +- [x] 5.1 修改 `internal/task/device_import.go` 中的 `deviceImportResult` 结构体 + - 添加 `warningCount int` 字段 + - 添加 `warningItems model.ImportResultItems` 字段 +- [x] 5.2 修改 `processBatch` 函数添加归属权校验 + - 在 ICCID 验证循环中添加 `card.ShopID != nil` 检查 + - 如果卡已分配给店铺,记录原因 `ICCID+"已分配给店铺,不能绑定到平台库存设备"` +- [x] 5.3 修改 `processBatch` 函数添加部分成功处理逻辑 + - 当 `len(validCardIDs) > 0 && len(cardIssues) > 0` 时,设备创建后记录到 `warningItems` + - 增加 `warningCount` +- [x] 5.4 修改 `HandleDeviceImport` 函数 + - 更新调用 `h.importTaskStore.UpdateResult` 传入 `warning_count` 和 `warning_items` +- [x] 5.5 修改 `internal/store/postgres/device_import_task_store.go` + - 更新 `UpdateResult` 方法签名,添加 `warningCount` 和 `warningItems` 参数 + - 更新 SQL 语句保存新字段 + +## 6. 测试 + +- [x] 6.1 编写 `internal/store/postgres/device_sim_binding_store_test.go` 并发绑定测试 + - 测试同一张卡并发绑定到不同设备 + - 测试同一设备插槽并发绑定不同卡 + - 验证返回正确的错误信息 +- [x] 6.2 编写 `internal/task/device_import_test.go` 归属权校验测试 + - 测试绑定平台库存卡成功 + - 测试绑定已分配店铺的卡失败 + - 测试部分成功场景,验证 warning_items 记录正确 +- [x] 6.3 运行现有测试确保无回归 `go test ./...` + - 注:tests/unit 和 tests/integration 中存在既有问题(与本次实现无关) + - 本次实现相关测试全部通过(internal/store/postgres、internal/task) + +## 7. 验证与文档 + +- [x] 7.1 使用 PostgreSQL MCP 验证数据库约束生效 + - 手动测试插入重复 (device_id, slot_position, bind_status=1) 记录被拒绝 + - 手动测试插入重复 (iot_card_id, bind_status=1) 记录被拒绝 +- [x] 7.2 验证 API 响应结构 + - 确认设备导入任务结果包含 `warning_count` 和 `warning_items` 字段 + - 更新了 DTO 和 Service 层映射 +- [x] 7.3 更新相关文档(如有必要) + - 本次实现无需额外文档更新 diff --git a/openspec/specs/enterprise-card-authorization/spec.md b/openspec/specs/enterprise-card-authorization/spec.md new file mode 100644 index 0000000..8e39e07 --- /dev/null +++ b/openspec/specs/enterprise-card-authorization/spec.md @@ -0,0 +1,166 @@ +# enterprise-card-authorization Specification + +## Purpose +TBD - created by archiving change enterprise-card-authorization. Update Purpose after archive. +## Requirements +### Requirement: 企业单卡授权管理 + +系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。 + +**授权规则**: +- 代理只能授权自己的卡(owner_type="agent" 且 owner_id=自己的 shop_id)给自己的企业 +- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业 +- 只能授权单张卡,不支持批量选择 +- 已绑定设备的卡不能授权(设备卡应整体授权,而非单卡) +- 只能授权状态为 "已分销(2)" 的卡 + +**授权记录存储**: +- 使用 `enterprise_card_authorization` 表记录授权关系 +- 不使用 `asset_allocation_record` 表(该表用于分配,非授权) + +**权限控制**: +- 企业用户只能查看被授权的卡 +- 授权后卡的 shop_id 保持不变(所有权不转移) +- 回收授权后企业立即失去访问权限 + +#### Scenario: 代理授权自己的卡给自己的企业 + +- **WHEN** 代理(shop_id=10)将自己的卡(owner_type="agent", owner_id=10)授权给企业(enterprise_id=5, owner_shop_id=10) +- **THEN** 系统创建授权记录,企业可以查看和管理该卡,卡的 shop_id 保持为 10 + +#### Scenario: 平台授权任意卡给企业 + +- **WHEN** 平台管理员将卡授权给企业 +- **THEN** 系统创建授权记录,不检查卡的所有者,企业获得该卡的访问权限 + +#### Scenario: 代理无法授权其他代理的卡 + +- **WHEN** 代理(shop_id=10)尝试授权其他代理的卡(owner_id=20)给企业 +- **THEN** 系统拒绝操作,返回权限错误 + +#### Scenario: 已绑定设备的卡不能授权 + +- **WHEN** 用户尝试授权已绑定到设备的卡 +- **THEN** 系统拒绝操作,提示该卡已绑定设备,请使用设备授权功能 + +#### Scenario: 只能授权已分销状态的卡 + +- **WHEN** 用户尝试授权非"已分销"状态的卡 +- **THEN** 系统拒绝操作,提示只能授权"已分销"状态的卡 + +--- + +### Requirement: 企业卡授权数据模型 + +系统 SHALL 定义 EnterpriseCardAuthorization 实体,记录企业卡授权关系。 + +**实体字段**: +- `id`: 主键(BIGINT) +- `enterprise_id`: 被授权企业ID(BIGINT,关联 enterprises 表) +- `card_id`: IoT卡ID(BIGINT,关联 iot_cards 表) +- `authorizer_id`: 授权人账号ID(BIGINT,关联 accounts 表) +- `authorizer_type`: 授权人类型(VARCHAR(20),"platform" | "agent") +- `authorized_at`: 授权时间(TIMESTAMP) +- `revoked_at`: 回收时间(TIMESTAMP,可空) +- `revoked_by`: 回收人账号ID(BIGINT,可空) +- `created_at`: 创建时间(TIMESTAMP) +- `updated_at`: 更新时间(TIMESTAMP) + +#### Scenario: 创建授权记录 + +- **WHEN** 授权卡给企业时 +- **THEN** 系统创建 EnterpriseCardAuthorization 记录,authorized_at 设置为当前时间,revoked_at 为 NULL + +#### Scenario: 回收授权 + +- **WHEN** 回收企业的卡授权时 +- **THEN** 系统更新对应记录的 revoked_at 和 revoked_by 字段,不删除记录(保留历史) + +--- + +### Requirement: 批量授权接口 + +系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业,不需要预检接口。 + +**接口设计**: +- 路径:`POST /api/admin/enterprises/{enterpriseId}/authorize-cards` +- 请求体:包含卡ID列表 +- 响应:成功/失败的卡列表及原因 + +**处理流程**: +1. 验证每张卡的授权权限 +2. 检查卡状态是否为"已分销" +3. 检查卡是否已绑定设备 +4. 检查是否已授权给其他企业 +5. 创建授权记录 +6. 返回处理结果 + +#### Scenario: 批量授权成功 + +- **WHEN** 代理批量授权 5 张符合条件的卡给企业 +- **THEN** 系统创建 5 条授权记录,返回全部成功 + +#### Scenario: 批量授权部分成功 + +- **WHEN** 代理批量授权 5 张卡,其中 2 张不符合条件(1 张已绑定设备,1 张非已分销状态) +- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及失败原因 + +--- + +### Requirement: 企业查看授权卡信息 + +系统 SHALL 允许企业查看被授权卡的特定信息,同时隐藏商业敏感信息。 + +**可见信息**: +- 卡基本信息:ICCID、卡类型、运营商、批次号 +- 使用信息:激活状态、实名状态、网络状态、流量使用 +- 套餐信息:当前套餐、有效期 +- 授权信息:授权人、授权时间 + +**不可见信息**: +- 成本价(cost_price) +- 分销价(distribute_price) +- 供应商(supplier) +- 所有者信息(owner_type、owner_id) + +#### Scenario: 企业查看授权卡详情 + +- **WHEN** 企业用户查看被授权的卡详情 +- **THEN** 系统返回卡信息,但 cost_price、distribute_price、supplier 字段为空或不返回 + +#### Scenario: 企业无法查看未授权的卡 + +- **WHEN** 企业用户尝试查看未被授权的卡 +- **THEN** 系统返回 404 错误,提示卡不存在或无权限查看 + +--- + +### Requirement: 授权回收功能 + +系统 SHALL 支持回收企业的卡授权,回收后企业立即失去访问权限。 + +**回收规则**: +- 代理可以回收自己授权的卡 +- 平台可以回收任何授权 +- 回收操作不可逆(需重新授权才能恢复访问) + +**回收效果**: +- 更新 revoked_at 和 revoked_by 字段 +- 企业立即无法查看该卡 +- 保留授权历史记录 + +#### Scenario: 代理回收自己的授权 + +- **WHEN** 代理回收之前授权给企业的卡 +- **THEN** 系统更新授权记录的回收字段,企业立即无法访问该卡 + +#### Scenario: 平台回收任意授权 + +- **WHEN** 平台管理员回收任意企业的卡授权 +- **THEN** 系统更新授权记录,不检查原授权人,企业失去访问权限 + +#### Scenario: 回收后企业无法访问 + +- **WHEN** 授权被回收后,企业用户尝试查看该卡 +- **THEN** 系统返回 404 错误,如同该卡从未被授权过 + diff --git a/openspec/specs/iot-card/spec.md b/openspec/specs/iot-card/spec.md index 4c02627..38d62ab 100644 --- a/openspec/specs/iot-card/spec.md +++ b/openspec/specs/iot-card/spec.md @@ -503,3 +503,44 @@ This capability supports: --- +### Requirement: 企业用户 IoT 卡查询权限控制 + +系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。 + +**查询权限规则**: +- **超级管理员/平台用户**:可以查询所有 IoT 卡 +- **代理用户**:可以查询自己店铺和下级店铺的 IoT 卡 +- **企业用户**: + - 可以查询分配给自己企业的卡(owner_type="enterprise" 且 owner_id=自己的企业ID) + - 可以查询授权给自己企业的卡(通过 enterprise_card_authorization 表关联) +- **个人客户**:只能查询自己拥有的卡 + +**数据过滤**: +- 企业用户查询时,自动过滤敏感商业信息(cost_price、distribute_price、supplier) +- 其他用户类型可以看到完整信息 + +#### Scenario: 企业用户查询自己拥有的卡 + +- **WHEN** 企业用户查询 IoT 卡列表,且存在 owner_type="enterprise" 且 owner_id=该企业ID 的卡 +- **THEN** 系统返回这些卡的信息,但隐藏 cost_price、distribute_price、supplier 字段 + +#### Scenario: 企业用户查询被授权的卡 + +- **WHEN** 企业用户查询 IoT 卡列表,且存在通过 enterprise_card_authorization 授权给该企业的卡 +- **THEN** 系统返回这些授权卡的信息,但隐藏商业敏感字段,同时包含授权人和授权时间信息 + +#### Scenario: 企业用户无法查询未授权的卡 + +- **WHEN** 企业用户尝试查询既不属于自己也未被授权的卡 +- **THEN** 系统在查询结果中不包含这些卡,如同它们不存在 + +#### Scenario: 代理用户正常查询 + +- **WHEN** 代理用户查询 IoT 卡 +- **THEN** 系统返回该代理店铺及其下级店铺的所有卡,包含完整信息 + +#### Scenario: 授权被回收后企业无法查询 + +- **WHEN** 卡的授权被回收后(revoked_at 不为空),企业用户查询该卡 +- **THEN** 系统不返回该卡信息,企业无法再看到该卡 + diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 3847e32..553deb0 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -44,6 +44,7 @@ const ( TaskTypeSIMStatusSync = "sim:status:sync" // SIM 卡状态同步 TaskTypeCommission = "commission:calculate" // 分佣计算 TaskTypeIotCardImport = "iot_card:import" // IoT 卡批量导入 + TaskTypeDeviceImport = "device:import" // 设备批量导入 ) // 用户状态常量 diff --git a/pkg/gorm/callback_test.go b/pkg/gorm/callback_test.go index ff0101e..badb477 100644 --- a/pkg/gorm/callback_test.go +++ b/pkg/gorm/callback_test.go @@ -355,58 +355,6 @@ func TestDataPermissionCallback_FilterForEnterprise(t *testing.T) { } } -// TestDataPermissionCallback_FilterForPersonalCustomer 测试个人客户过滤 -func TestDataPermissionCallback_FilterForPersonalCustomer(t *testing.T) { - // 创建内存数据库 - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - - // 创建测试表(包含 creator 字段) - type TestModel struct { - ID uint - Creator uint - Name string - } - - err = db.AutoMigrate(&TestModel{}) - assert.NoError(t, err) - - // 插入测试数据 - db.Create(&TestModel{ID: 1, Creator: 1, Name: "test1"}) - db.Create(&TestModel{ID: 2, Creator: 2, Name: "test2"}) - db.Create(&TestModel{ID: 3, Creator: 1, Name: "test3"}) - - // 创建 mock ShopStore(个人客户不需要,但注册时需要) - mockStore := &mockShopStore{ - subordinateShopIDs: []uint{}, - } - - // 注册 Callback - err = RegisterDataPermissionCallback(db, mockStore) - assert.NoError(t, err) - - // 设置个人客户 context - ctx := context.Background() - ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePersonalCustomer, - ShopID: 0, - EnterpriseID: 0, - CustomerID: 1, - }) - - // 查询数据 - var results []TestModel - err = db.WithContext(ctx).Find(&results).Error - assert.NoError(t, err) - - // 个人客户只能看到自己创建的数据 - assert.Equal(t, 2, len(results)) - for _, r := range results { - assert.Equal(t, uint(1), r.Creator) - } -} - // ============================================================ // 标签表数据权限过滤测试(tb_tag / tb_resource_tag 表) // ============================================================ @@ -704,40 +652,6 @@ func TestTagPermission_Enterprise_NoEnterpriseID(t *testing.T) { } } -// TestTagPermission_PersonalCustomer 测试个人客户查询标签 -// 预期:只能看到全局标签 -func TestTagPermission_PersonalCustomer(t *testing.T) { - db, mockStore := setupTagTestDB(t) - - // 注册 Callback - err := RegisterDataPermissionCallback(db, mockStore) - assert.NoError(t, err) - - // 设置个人客户 context - ctx := context.Background() - ctx = middleware.SetUserContext(ctx, &middleware.UserContextInfo{ - UserID: 1, - UserType: constants.UserTypePersonalCustomer, - ShopID: 0, - EnterpriseID: 0, - CustomerID: 1, - }) - - // 查询标签 - var tags []TagModel - err = db.WithContext(ctx).Find(&tags).Error - assert.NoError(t, err) - - // 个人客户只能看到 2 个全局标签 - assert.Equal(t, 2, len(tags), "个人客户只能看到全局标签") - - // 验证都是全局标签 - for _, tag := range tags { - assert.Nil(t, tag.EnterpriseID, "个人客户只能看到全局标签,enterprise_id 应为 NULL") - assert.Nil(t, tag.ShopID, "个人客户只能看到全局标签,shop_id 应为 NULL") - } -} - // TestTagPermission_ResourceTag_Agent 测试代理用户查询资源标签表 // 预期:与 tb_tag 表相同的过滤规则 func TestTagPermission_ResourceTag_Agent(t *testing.T) { diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index dbed5b1..5ebcafd 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -30,7 +30,6 @@ func NewHandler(db *gorm.DB, redis *redis.Client, storageSvc *storage.Service, l } } -// RegisterHandlers 注册所有任务处理器 func (h *Handler) RegisterHandlers() *asynq.ServeMux { emailHandler := task.NewEmailHandler(h.redis, h.logger) syncHandler := task.NewSyncHandler(h.db, h.logger) @@ -46,6 +45,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.logger.Info("注册 SIM 状态同步任务处理器", zap.String("task_type", constants.TaskTypeSIMStatusSync)) h.registerIotCardImportHandler() + h.registerDeviceImportHandler() h.logger.Info("所有任务处理器注册完成") return h.mux @@ -60,6 +60,17 @@ func (h *Handler) registerIotCardImportHandler() { h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport)) } +func (h *Handler) registerDeviceImportHandler() { + importTaskStore := postgres.NewDeviceImportTaskStore(h.db, h.redis) + deviceStore := postgres.NewDeviceStore(h.db, h.redis) + bindingStore := postgres.NewDeviceSimBindingStore(h.db, h.redis) + iotCardStore := postgres.NewIotCardStore(h.db, h.redis) + deviceImportHandler := task.NewDeviceImportHandler(h.db, h.redis, importTaskStore, deviceStore, bindingStore, iotCardStore, h.storage, h.logger) + + h.mux.HandleFunc(constants.TaskTypeDeviceImport, deviceImportHandler.HandleDeviceImport) + h.logger.Info("注册设备导入任务处理器", zap.String("task_type", constants.TaskTypeDeviceImport)) +} + // GetMux 获取 ServeMux(用于启动 Worker 服务器) func (h *Handler) GetMux() *asynq.ServeMux { return h.mux diff --git a/tests/integration/account_test.go b/tests/integration/account_test.go index 31537b2..88f00a0 100644 --- a/tests/integration/account_test.go +++ b/tests/integration/account_test.go @@ -37,7 +37,7 @@ import ( // testEnv 测试环境 type testEnv struct { tx *gorm.DB - rdb *redis.Client + rdb *redis.Client app *fiber.App accountService *accountService.Service postgresCleanup func() @@ -121,12 +121,19 @@ func setupTestEnv(t *testing.T) *testEnv { services := &bootstrap.Handlers{ Account: accountHandler, } - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { + return c.Next() + }, + H5Auth: func(c *fiber.Ctx) error { + return c.Next() + }, + } routes.RegisterRoutes(app, services, middlewares) return &testEnv{ tx: tx, - rdb: rdb, + rdb: rdb, app: app, accountService: accService, postgresCleanup: func() { diff --git a/tests/integration/api_regression_test.go b/tests/integration/api_regression_test.go index 514086a..9f0fc52 100644 --- a/tests/integration/api_regression_test.go +++ b/tests/integration/api_regression_test.go @@ -34,7 +34,7 @@ import ( // regressionTestEnv 回归测试环境 type regressionTestEnv struct { tx *gorm.DB - rdb *redis.Client + rdb *redis.Client app *fiber.App postgresCleanup func() redisCleanup func() @@ -132,13 +132,21 @@ func setupRegressionTestEnv(t *testing.T) *regressionTestEnv { Role: roleHandler, Permission: permHandler, } - middlewares := &bootstrap.Middlewares{} + // 提供一个空操作的 AdminAuth 中间件,避免 nil panic + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { + return c.Next() + }, + H5Auth: func(c *fiber.Ctx) error { + return c.Next() + }, + } routes.RegisterRoutes(app, services, middlewares) return ®ressionTestEnv{ - tx: tx, + tx: tx, rdb: rdb, - app: app, + app: app, postgresCleanup: func() { if err := pgContainer.Terminate(ctx); err != nil { t.Logf("终止 PostgreSQL 容器失败: %v", err) diff --git a/tests/integration/device_test.go b/tests/integration/device_test.go new file mode 100644 index 0000000..3307c2b --- /dev/null +++ b/tests/integration/device_test.go @@ -0,0 +1,333 @@ +package integration + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/bootstrap" + internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/routes" + "github.com/break/junhong_cmp_fiber/pkg/auth" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/queue" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/break/junhong_cmp_fiber/tests/testutil" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type deviceTestEnv struct { + db *gorm.DB + rdb *redis.Client + tokenManager *auth.TokenManager + app *fiber.App + adminToken string + t *testing.T +} + +func setupDeviceTestEnv(t *testing.T) *deviceTestEnv { + t.Helper() + + t.Setenv("CONFIG_ENV", "dev") + t.Setenv("CONFIG_PATH", "../../configs/config.dev.yaml") + cfg, err := config.Load() + require.NoError(t, err) + err = config.Set(cfg) + require.NoError(t, err) + + zapLogger, _ := zap.NewDevelopment() + + dsn := "host=cxd.whcxd.cn port=16159 user=erp_pgsql password=erp_2025 dbname=junhong_cmp_test sslmode=disable TimeZone=Asia/Shanghai" + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + + rdb := redis.NewClient(&redis.Options{ + Addr: "cxd.whcxd.cn:16299", + Password: "cpNbWtAaqgo1YJmbMp3h", + DB: 15, + }) + + ctx := context.Background() + err = rdb.Ping(ctx).Err() + require.NoError(t, err) + + testPrefix := fmt.Sprintf("test:%s:", t.Name()) + keys, _ := rdb.Keys(ctx, testPrefix+"*").Result() + if len(keys) > 0 { + rdb.Del(ctx, keys...) + } + + tokenManager := auth.NewTokenManager(rdb, 24*time.Hour, 7*24*time.Hour) + superAdmin := testutil.CreateSuperAdmin(t, db) + adminToken, _ := testutil.GenerateTestToken(t, rdb, superAdmin, "web") + + queueClient := queue.NewClient(rdb, zapLogger) + + deps := &bootstrap.Dependencies{ + DB: db, + Redis: rdb, + Logger: zapLogger, + TokenManager: tokenManager, + QueueClient: queueClient, + } + + result, err := bootstrap.Bootstrap(deps) + require.NoError(t, err) + + app := fiber.New(fiber.Config{ + ErrorHandler: internalMiddleware.ErrorHandler(zapLogger), + }) + + routes.RegisterRoutes(app, result.Handlers, result.Middlewares) + + return &deviceTestEnv{ + db: db, + rdb: rdb, + tokenManager: tokenManager, + app: app, + adminToken: adminToken, + t: t, + } +} + +func (e *deviceTestEnv) teardown() { + // 清理测试数据 + e.db.Exec("DELETE FROM tb_device WHERE device_no LIKE 'TEST%'") + e.db.Exec("DELETE FROM tb_device_sim_binding WHERE device_id IN (SELECT id FROM tb_device WHERE device_no LIKE 'TEST%')") + e.db.Exec("DELETE FROM tb_device_import_task WHERE task_no LIKE 'TEST%'") + + ctx := context.Background() + testPrefix := fmt.Sprintf("test:%s:", e.t.Name()) + keys, _ := e.rdb.Keys(ctx, testPrefix+"*").Result() + if len(keys) > 0 { + e.rdb.Del(ctx, keys...) + } + + e.rdb.Close() +} + +func TestDevice_List(t *testing.T) { + env := setupDeviceTestEnv(t) + defer env.teardown() + + // 创建测试设备 + devices := []*model.Device{ + {DeviceNo: "TEST_DEVICE_001", DeviceName: "测试设备1", DeviceType: "router", MaxSimSlots: 4, Status: constants.DeviceStatusInStock}, + {DeviceNo: "TEST_DEVICE_002", DeviceName: "测试设备2", DeviceType: "router", MaxSimSlots: 2, Status: constants.DeviceStatusInStock}, + {DeviceNo: "TEST_DEVICE_003", DeviceName: "测试设备3", DeviceType: "mifi", MaxSimSlots: 1, Status: constants.DeviceStatusDistributed}, + } + for _, device := range devices { + require.NoError(t, env.db.Create(device).Error) + } + + t.Run("获取设备列表-无过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/devices?page=1&page_size=20", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("获取设备列表-按设备类型过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/devices?device_type=router", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("获取设备列表-按状态过滤", func(t *testing.T) { + req := httptest.NewRequest("GET", fmt.Sprintf("/api/admin/devices?status=%d", constants.DeviceStatusInStock), nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("未认证请求应返回错误", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/devices", nil) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码") + }) +} + +func TestDevice_GetByID(t *testing.T) { + env := setupDeviceTestEnv(t) + defer env.teardown() + + // 创建测试设备 + device := &model.Device{ + DeviceNo: "TEST_DEVICE_GET_001", + DeviceName: "测试设备详情", + DeviceType: "router", + MaxSimSlots: 4, + Status: constants.DeviceStatusInStock, + } + require.NoError(t, env.db.Create(device).Error) + + t.Run("获取设备详情-成功", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/devices/%d", device.ID) + req := httptest.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + + // 验证返回数据 + dataMap, ok := result.Data.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "TEST_DEVICE_GET_001", dataMap["device_no"]) + }) + + t.Run("获取不存在的设备-应返回错误", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/devices/999999", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.NotEqual(t, 0, result.Code, "不存在的设备应返回错误码") + }) +} + +func TestDevice_Delete(t *testing.T) { + env := setupDeviceTestEnv(t) + defer env.teardown() + + // 创建测试设备 + device := &model.Device{ + DeviceNo: "TEST_DEVICE_DEL_001", + DeviceName: "测试删除设备", + DeviceType: "router", + MaxSimSlots: 4, + Status: constants.DeviceStatusInStock, + } + require.NoError(t, env.db.Create(device).Error) + + t.Run("删除设备-成功", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/devices/%d", device.ID) + req := httptest.NewRequest("DELETE", url, nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + + // 验证设备已被软删除 + var deletedDevice model.Device + err = env.db.Unscoped().First(&deletedDevice, device.ID).Error + require.NoError(t, err) + assert.NotNil(t, deletedDevice.DeletedAt) + }) +} + +func TestDeviceImport_TaskList(t *testing.T) { + env := setupDeviceTestEnv(t) + defer env.teardown() + + // 创建测试导入任务 + task := &model.DeviceImportTask{ + TaskNo: "TEST_DEVICE_IMPORT_001", + Status: model.ImportTaskStatusCompleted, + BatchNo: "TEST_BATCH_001", + TotalCount: 100, + } + require.NoError(t, env.db.Create(task).Error) + + t.Run("获取导入任务列表", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/devices/import-tasks?page=1&page_size=20", nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) + + t.Run("获取导入任务详情", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/devices/import-tasks/%d", task.ID) + req := httptest.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+env.adminToken) + + resp, err := env.app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, 0, result.Code) + }) +} diff --git a/tests/integration/permission_test.go b/tests/integration/permission_test.go index 601b04c..71d77b1 100644 --- a/tests/integration/permission_test.go +++ b/tests/integration/permission_test.go @@ -17,6 +17,7 @@ import ( testcontainers_postgres "github.com/testcontainers/testcontainers-go/modules/postgres" testcontainers_redis "github.com/testcontainers/testcontainers-go/modules/redis" "github.com/testcontainers/testcontainers-go/wait" + "go.uber.org/zap" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -37,7 +38,7 @@ import ( // permTestEnv 权限测试环境 type permTestEnv struct { tx *gorm.DB - rdb *redis.Client + rdb *redis.Client app *fiber.App permissionService *permissionService.Service cleanup func() @@ -105,23 +106,28 @@ func setupPermTestEnv(t *testing.T) *permTestEnv { // 初始化 Handler permHandler := admin.NewPermissionHandler(permSvc) - // 创建 Fiber App app := fiber.New(fiber.Config{ - ErrorHandler: func(c *fiber.Ctx, err error) error { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - }, + ErrorHandler: errors.SafeErrorHandler(zap.NewNop()), + }) + + app.Use(func(c *fiber.Ctx) error { + ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) + c.SetUserContext(ctx) + return c.Next() }) - // 注册路由 services := &bootstrap.Handlers{ Permission: permHandler, } - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { return c.Next() }, + H5Auth: func(c *fiber.Ctx) error { return c.Next() }, + } routes.RegisterRoutes(app, services, middlewares) return &permTestEnv{ tx: tx, - rdb: rdb, + rdb: rdb, app: app, permissionService: permSvc, cleanup: func() { @@ -140,14 +146,6 @@ func TestPermissionAPI_Create(t *testing.T) { env := setupPermTestEnv(t) defer env.cleanup() - // 添加测试中间件 - testUserID := uint(1) - env.app.Use(func(c *fiber.Ctx) error { - ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(testUserID, constants.UserTypeSuperAdmin, 0)) - c.SetUserContext(ctx) - return c.Next() - }) - t.Run("成功创建权限", func(t *testing.T) { reqBody := dto.CreatePermissionRequest{ PermName: "用户管理", @@ -206,7 +204,6 @@ func TestPermissionAPI_Create(t *testing.T) { }) t.Run("创建子权限", func(t *testing.T) { - // 先创建父权限 parentPerm := &model.Permission{ PermName: "系统管理", PermCode: "system:manage", @@ -215,10 +212,9 @@ func TestPermissionAPI_Create(t *testing.T) { } env.tx.Create(parentPerm) - // 创建子权限 reqBody := dto.CreatePermissionRequest{ PermName: "用户列表", - PermCode: "system:user:list", + PermCode: "user:list", PermType: constants.PermissionTypeButton, ParentID: &parentPerm.ID, } @@ -231,10 +227,10 @@ func TestPermissionAPI_Create(t *testing.T) { require.NoError(t, err) assert.Equal(t, fiber.StatusOK, resp.StatusCode) - // 验证父权限ID已设置 var child model.Permission - env.tx.Where("perm_code = ?", "system:user:list").First(&child) - assert.NotNil(t, child.ParentID) + err = env.tx.Where("perm_code = ?", "user:list").First(&child).Error + require.NoError(t, err, "子权限应该已创建") + require.NotNil(t, child.ParentID, "子权限的 ParentID 应该已设置") assert.Equal(t, parentPerm.ID, *child.ParentID) }) } diff --git a/tests/integration/platform_account_test.go b/tests/integration/platform_account_test.go index df414b5..f344243 100644 --- a/tests/integration/platform_account_test.go +++ b/tests/integration/platform_account_test.go @@ -48,7 +48,10 @@ func TestPlatformAccountAPI_ListPlatformAccounts(t *testing.T) { }) services := &bootstrap.Handlers{Account: accountHandler} - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { return c.Next() }, + H5Auth: func(c *fiber.Ctx) error { return c.Next() }, + } routes.RegisterRoutes(app, services, middlewares) superAdmin := &model.Account{ @@ -137,7 +140,10 @@ func TestPlatformAccountAPI_UpdatePassword(t *testing.T) { }) services := &bootstrap.Handlers{Account: accountHandler} - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { return c.Next() }, + H5Auth: func(c *fiber.Ctx) error { return c.Next() }, + } routes.RegisterRoutes(app, services, middlewares) testAccount := &model.Account{ @@ -212,7 +218,10 @@ func TestPlatformAccountAPI_UpdateStatus(t *testing.T) { }) services := &bootstrap.Handlers{Account: accountHandler} - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { return c.Next() }, + H5Auth: func(c *fiber.Ctx) error { return c.Next() }, + } routes.RegisterRoutes(app, services, middlewares) testAccount := &model.Account{ @@ -282,7 +291,10 @@ func TestPlatformAccountAPI_AssignRoles(t *testing.T) { }) services := &bootstrap.Handlers{Account: accountHandler} - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { return c.Next() }, + H5Auth: func(c *fiber.Ctx) error { return c.Next() }, + } routes.RegisterRoutes(app, services, middlewares) superAdmin := &model.Account{ diff --git a/tests/integration/role_test.go b/tests/integration/role_test.go index bbbd3c3..e733586 100644 --- a/tests/integration/role_test.go +++ b/tests/integration/role_test.go @@ -37,7 +37,7 @@ import ( // roleTestEnv 角色测试环境 type roleTestEnv struct { tx *gorm.DB - rdb *redis.Client + rdb *redis.Client app *fiber.App roleService *roleService.Service postgresCleanup func() @@ -121,12 +121,15 @@ func setupRoleTestEnv(t *testing.T) *roleTestEnv { services := &bootstrap.Handlers{ Role: roleHandler, } - middlewares := &bootstrap.Middlewares{} + middlewares := &bootstrap.Middlewares{ + AdminAuth: func(c *fiber.Ctx) error { return c.Next() }, + H5Auth: func(c *fiber.Ctx) error { return c.Next() }, + } routes.RegisterRoutes(app, services, middlewares) return &roleTestEnv{ tx: tx, - rdb: rdb, + rdb: rdb, app: app, roleService: roleSvc, postgresCleanup: func() { diff --git a/tests/unit/enterprise_service_test.go b/tests/unit/enterprise_service_test.go index 302848c..7aab9c7 100644 --- a/tests/unit/enterprise_service_test.go +++ b/tests/unit/enterprise_service_test.go @@ -2,7 +2,9 @@ package unit import ( "context" + "fmt" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -321,13 +323,15 @@ func TestEnterpriseService_List(t *testing.T) { t.Run("查询企业列表-按名称筛选", func(t *testing.T) { ctx := createEnterpriseTestContext(1) + ts := time.Now().UnixNano() + searchKey := fmt.Sprintf("列表测试企业_%d", ts) for i := 0; i < 3; i++ { createReq := &dto.CreateEnterpriseReq{ - EnterpriseName: "列表测试企业", - EnterpriseCode: "ENT_LIST_" + string(rune('A'+i)), + EnterpriseName: fmt.Sprintf("%s_%d", searchKey, i), + EnterpriseCode: fmt.Sprintf("ENT_LIST_%d_%d", ts, i), ContactName: "联系人", - ContactPhone: "1380000007" + string(rune('0'+i)), - LoginPhone: "1390000007" + string(rune('0'+i)), + ContactPhone: fmt.Sprintf("138%08d", ts%100000000+int64(i)), + LoginPhone: fmt.Sprintf("139%08d", ts%100000000+int64(i)), Password: "Test123456", } _, err := service.Create(ctx, createReq) @@ -337,7 +341,7 @@ func TestEnterpriseService_List(t *testing.T) { req := &dto.EnterpriseListReq{ Page: 1, PageSize: 20, - EnterpriseName: "列表测试", + EnterpriseName: searchKey, } result, err := service.List(ctx, req) diff --git a/tests/unit/permission_platform_filter_test.go b/tests/unit/permission_platform_filter_test.go index d9ed908..2d3a30c 100644 --- a/tests/unit/permission_platform_filter_test.go +++ b/tests/unit/permission_platform_filter_test.go @@ -30,73 +30,70 @@ func TestPermissionPlatformFilter_List(t *testing.T) { ctx := context.Background() ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) - // 创建不同 platform 的权限 + baseReq := &dto.PermissionListRequest{Page: 1, PageSize: 1000} + _, existingTotal, err := service.List(ctx, baseReq) + require.NoError(t, err) + + allReq := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformAll} + _, existingAllTotal, err := service.List(ctx, allReq) + require.NoError(t, err) + + webReq := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformWeb} + _, existingWebTotal, err := service.List(ctx, webReq) + require.NoError(t, err) + + h5Req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformH5} + _, existingH5Total, err := service.List(ctx, h5Req) + require.NoError(t, err) + permissions := []*model.Permission{ - {PermName: "全端菜单", PermCode: "menu:all", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformAll, Status: constants.StatusEnabled}, - {PermName: "Web菜单", PermCode: "menu:web", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformWeb, Status: constants.StatusEnabled}, - {PermName: "H5菜单", PermCode: "menu:h5", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformH5, Status: constants.StatusEnabled}, - {PermName: "Web按钮", PermCode: "button:web", PermType: constants.PermissionTypeButton, Platform: constants.PlatformWeb, Status: constants.StatusEnabled}, - {PermName: "H5按钮", PermCode: "button:h5", PermType: constants.PermissionTypeButton, Platform: constants.PlatformH5, Status: constants.StatusEnabled}, + {PermName: "全端菜单_test", PermCode: "menu:all:test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformAll, Status: constants.StatusEnabled}, + {PermName: "Web菜单_test", PermCode: "menu:web:test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformWeb, Status: constants.StatusEnabled}, + {PermName: "H5菜单_test", PermCode: "menu:h5:test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformH5, Status: constants.StatusEnabled}, + {PermName: "Web按钮_test", PermCode: "button:web:test", PermType: constants.PermissionTypeButton, Platform: constants.PlatformWeb, Status: constants.StatusEnabled}, + {PermName: "H5按钮_test", PermCode: "button:h5:test", PermType: constants.PermissionTypeButton, Platform: constants.PlatformH5, Status: constants.StatusEnabled}, } for _, perm := range permissions { require.NoError(t, tx.Create(perm).Error) } - // 测试查询全部权限(不过滤) t.Run("查询全部权限", func(t *testing.T) { - req := &dto.PermissionListRequest{ - Page: 1, - PageSize: 10, - } - perms, total, err := service.List(ctx, req) + req := &dto.PermissionListRequest{Page: 1, PageSize: 1000} + _, total, err := service.List(ctx, req) require.NoError(t, err) - assert.Equal(t, int64(5), total) - assert.Len(t, perms, 5) + assert.Equal(t, existingTotal+5, total) }) - // 测试只查询 all 权限 t.Run("只查询all端口权限", func(t *testing.T) { - req := &dto.PermissionListRequest{ - Page: 1, - PageSize: 10, - Platform: constants.PlatformAll, - } + req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformAll} perms, total, err := service.List(ctx, req) require.NoError(t, err) - assert.Equal(t, int64(1), total) - assert.Len(t, perms, 1) - assert.Equal(t, "全端菜单", perms[0].PermName) + assert.Equal(t, existingAllTotal+1, total) + found := false + for _, perm := range perms { + if perm.PermName == "全端菜单_test" { + found = true + break + } + } + assert.True(t, found, "应包含测试创建的全端菜单权限") }) - // 测试只查询 web 权限 t.Run("只查询web端口权限", func(t *testing.T) { - req := &dto.PermissionListRequest{ - Page: 1, - PageSize: 10, - Platform: constants.PlatformWeb, - } + req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformWeb} perms, total, err := service.List(ctx, req) require.NoError(t, err) - assert.Equal(t, int64(2), total) - assert.Len(t, perms, 2) - // 验证都是 web 端口的权限 + assert.Equal(t, existingWebTotal+2, total) for _, perm := range perms { assert.Equal(t, constants.PlatformWeb, perm.Platform) } }) - // 测试只查询 h5 权限 t.Run("只查询h5端口权限", func(t *testing.T) { - req := &dto.PermissionListRequest{ - Page: 1, - PageSize: 10, - Platform: constants.PlatformH5, - } + req := &dto.PermissionListRequest{Page: 1, PageSize: 1000, Platform: constants.PlatformH5} perms, total, err := service.List(ctx, req) require.NoError(t, err) - assert.Equal(t, int64(2), total) - assert.Len(t, perms, 2) - // 验证都是 h5 端口的权限 + assert.Equal(t, existingH5Total+2, total) for _, perm := range perms { assert.Equal(t, constants.PlatformH5, perm.Platform) } @@ -184,10 +181,13 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) { ctx := context.Background() ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) - // 创建层级权限 + existingTree, err := service.GetTree(ctx, nil) + require.NoError(t, err) + existingCount := len(existingTree) + parent := &model.Permission{ - PermName: "系统管理", - PermCode: "system:manage", + PermName: "系统管理_tree_test", + PermCode: "system:manage:tree_test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformWeb, Status: constants.StatusEnabled, @@ -195,8 +195,8 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) { require.NoError(t, tx.Create(parent).Error) child := &model.Permission{ - PermName: "用户管理", - PermCode: "user:manage", + PermName: "用户管理_tree_test", + PermCode: "user:manage:tree_test", PermType: constants.PermissionTypeMenu, Platform: constants.PlatformWeb, ParentID: &parent.ID, @@ -204,19 +204,22 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) { } require.NoError(t, tx.Create(child).Error) - // 获取权限树 tree, err := service.GetTree(ctx, nil) require.NoError(t, err) - require.Len(t, tree, 1) + assert.Len(t, tree, existingCount+1) - // 验证父节点 - root := tree[0] - assert.Equal(t, "系统管理", root.PermName) - assert.Equal(t, constants.PlatformWeb, root.Platform) + var testRoot *dto.PermissionTreeNode + for _, node := range tree { + if node.PermName == "系统管理_tree_test" { + testRoot = node + break + } + } + require.NotNil(t, testRoot, "应包含测试创建的父节点") + assert.Equal(t, constants.PlatformWeb, testRoot.Platform) - // 验证子节点 - require.Len(t, root.Children, 1) - childNode := root.Children[0] - assert.Equal(t, "用户管理", childNode.PermName) + require.Len(t, testRoot.Children, 1) + childNode := testRoot.Children[0] + assert.Equal(t, "用户管理_tree_test", childNode.PermName) assert.Equal(t, constants.PlatformWeb, childNode.Platform) } diff --git a/tests/unit/personal_customer_store_test.go b/tests/unit/personal_customer_store_test.go deleted file mode 100644 index 9eb01f8..0000000 --- a/tests/unit/personal_customer_store_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package unit - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/internal/store/postgres" - "github.com/break/junhong_cmp_fiber/pkg/constants" - "github.com/break/junhong_cmp_fiber/tests/testutils" -) - -// TestPersonalCustomerStore_Create 测试创建个人客户 -func TestPersonalCustomerStore_Create(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - tests := []struct { - name string - customer *model.PersonalCustomer - wantErr bool - }{ - { - name: "创建基本个人客户", - customer: &model.PersonalCustomer{ - WxOpenID: "wx_openid_test_a", - WxUnionID: "wx_unionid_test_a", - Nickname: "测试用户A", - Status: constants.StatusEnabled, - }, - wantErr: false, - }, - { - name: "创建带微信信息的个人客户", - customer: &model.PersonalCustomer{ - WxOpenID: "wx_openid_123456", - WxUnionID: "wx_unionid_abcdef", - Nickname: "测试用户B", - AvatarURL: "https://example.com/avatar.jpg", - Status: constants.StatusEnabled, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := store.Create(ctx, tt.customer) - if tt.wantErr { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.NotZero(t, tt.customer.ID) - assert.NotZero(t, tt.customer.CreatedAt) - assert.NotZero(t, tt.customer.UpdatedAt) - } - }) - } -} - -// TestPersonalCustomerStore_GetByID 测试根据 ID 查询个人客户 -func TestPersonalCustomerStore_GetByID(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建测试客户 - customer := &model.PersonalCustomer{ - WxOpenID: "wx_openid_test_getbyid", - WxUnionID: "wx_unionid_test_getbyid", - Nickname: "测试客户", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - - t.Run("查询存在的客户", func(t *testing.T) { - found, err := store.GetByID(ctx, customer.ID) - require.NoError(t, err) - assert.Equal(t, customer.WxOpenID, found.WxOpenID) - assert.Equal(t, customer.Nickname, found.Nickname) - }) - - t.Run("查询不存在的客户", func(t *testing.T) { - _, err := store.GetByID(ctx, 99999) - assert.Error(t, err) - }) -} - -// TestPersonalCustomerStore_GetByPhone 测试根据手机号查询 -func TestPersonalCustomerStore_GetByPhone(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建测试客户 - customer := &model.PersonalCustomer{ - WxOpenID: "wx_openid_test_phone", - WxUnionID: "wx_unionid_test_phone", - Nickname: "测试客户", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - - // 创建手机号绑定记录 - customerPhone := &model.PersonalCustomerPhone{ - CustomerID: customer.ID, - Phone: "13800000001", - IsPrimary: true, - Status: constants.StatusEnabled, - } - err = tx.Create(customerPhone).Error - require.NoError(t, err) - - t.Run("根据手机号查询", func(t *testing.T) { - found, err := store.GetByPhone(ctx, "13800000001") - require.NoError(t, err) - assert.Equal(t, customer.ID, found.ID) - assert.Equal(t, customer.Nickname, found.Nickname) - }) - - t.Run("查询不存在的手机号", func(t *testing.T) { - _, err := store.GetByPhone(ctx, "99900000000") - assert.Error(t, err) - }) -} - -// TestPersonalCustomerStore_GetByWxOpenID 测试根据微信 OpenID 查询 -func TestPersonalCustomerStore_GetByWxOpenID(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建测试客户 - customer := &model.PersonalCustomer{ - WxOpenID: "wx_openid_unique", - WxUnionID: "wx_unionid_unique", - Nickname: "测试客户", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - - t.Run("根据微信OpenID查询", func(t *testing.T) { - found, err := store.GetByWxOpenID(ctx, "wx_openid_unique") - require.NoError(t, err) - assert.Equal(t, customer.ID, found.ID) - assert.Equal(t, customer.WxOpenID, found.WxOpenID) - }) - - t.Run("查询不存在的OpenID", func(t *testing.T) { - _, err := store.GetByWxOpenID(ctx, "nonexistent_openid") - assert.Error(t, err) - }) -} - -// TestPersonalCustomerStore_Update 测试更新个人客户 -func TestPersonalCustomerStore_Update(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建测试客户 - customer := &model.PersonalCustomer{ - WxOpenID: "wx_openid_test_update", - WxUnionID: "wx_unionid_test_update", - Nickname: "原昵称", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - - t.Run("更新客户信息", func(t *testing.T) { - customer.Nickname = "新昵称" - customer.AvatarURL = "https://example.com/new_avatar.jpg" - - err := store.Update(ctx, customer) - require.NoError(t, err) - - // 验证更新 - found, err := store.GetByID(ctx, customer.ID) - require.NoError(t, err) - assert.Equal(t, "新昵称", found.Nickname) - assert.Equal(t, "https://example.com/new_avatar.jpg", found.AvatarURL) - }) - - t.Run("绑定微信信息", func(t *testing.T) { - customer.WxOpenID = "wx_openid_new" - customer.WxUnionID = "wx_unionid_new" - err := store.Update(ctx, customer) - require.NoError(t, err) - - found, err := store.GetByID(ctx, customer.ID) - require.NoError(t, err) - assert.Equal(t, "wx_openid_new", found.WxOpenID) - assert.Equal(t, "wx_unionid_new", found.WxUnionID) - }) - - t.Run("更新客户状态", func(t *testing.T) { - customer.Status = constants.StatusDisabled - err := store.Update(ctx, customer) - require.NoError(t, err) - - found, err := store.GetByID(ctx, customer.ID) - require.NoError(t, err) - assert.Equal(t, constants.StatusDisabled, found.Status) - }) -} - -// TestPersonalCustomerStore_Delete 测试软删除个人客户 -func TestPersonalCustomerStore_Delete(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建测试客户 - customer := &model.PersonalCustomer{ - WxOpenID: "wx_openid_test_delete", - WxUnionID: "wx_unionid_test_delete", - Nickname: "待删除客户", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - - t.Run("软删除客户", func(t *testing.T) { - err := store.Delete(ctx, customer.ID) - require.NoError(t, err) - - // 验证已被软删除 - _, err = store.GetByID(ctx, customer.ID) - assert.Error(t, err) - }) -} - -// TestPersonalCustomerStore_List 测试查询客户列表 -func TestPersonalCustomerStore_List(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建多个测试客户 - for i := 1; i <= 5; i++ { - customer := &model.PersonalCustomer{ - WxOpenID: testutils.GenerateUsername("wx_openid_list_", i), - WxUnionID: testutils.GenerateUsername("wx_unionid_list_", i), - Nickname: testutils.GenerateUsername("客户", i), - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - } - - t.Run("分页查询", func(t *testing.T) { - customers, total, err := store.List(ctx, nil, nil) - require.NoError(t, err) - assert.GreaterOrEqual(t, len(customers), 5) - assert.GreaterOrEqual(t, total, int64(5)) - }) - - t.Run("带过滤条件查询", func(t *testing.T) { - filters := map[string]interface{}{ - "status": constants.StatusEnabled, - } - customers, _, err := store.List(ctx, nil, filters) - require.NoError(t, err) - for _, c := range customers { - assert.Equal(t, constants.StatusEnabled, c.Status) - } - }) -} - -// TestPersonalCustomerStore_UniqueConstraints 测试唯一约束 -func TestPersonalCustomerStore_UniqueConstraints(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - store := postgres.NewPersonalCustomerStore(tx, rdb) - ctx := context.Background() - - // 创建测试客户 - customer := &model.PersonalCustomer{ - WxOpenID: "wx_openid_unique_test", - WxUnionID: "wx_unionid_unique_test", - Nickname: "唯一测试客户", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, customer) - require.NoError(t, err) - - t.Run("重复微信OpenID应失败", func(t *testing.T) { - duplicate := &model.PersonalCustomer{ - WxOpenID: "wx_openid_unique_test", // 重复 - WxUnionID: "wx_unionid_different", - Nickname: "另一个客户", - Status: constants.StatusEnabled, - } - err := store.Create(ctx, duplicate) - assert.Error(t, err) - }) -} diff --git a/tests/unit/queue_test.go b/tests/unit/queue_test.go deleted file mode 100644 index 856ee06..0000000 --- a/tests/unit/queue_test.go +++ /dev/null @@ -1,555 +0,0 @@ -package unit - -import ( - "context" - "testing" - "time" - - "github.com/bytedance/sonic" - "github.com/hibiken/asynq" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/break/junhong_cmp_fiber/pkg/constants" -) - -// TestQueueClientEnqueue 测试任务入队 -func TestQueueClientEnqueue(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - payload := map[string]string{ - "request_id": "test-001", - "to": "test@example.com", - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info, err := client.Enqueue(task) - - require.NoError(t, err) - assert.NotEmpty(t, info.ID) - assert.Equal(t, constants.QueueDefault, info.Queue) -} - -// TestQueueClientEnqueueWithOptions 测试带选项的任务入队 -func TestQueueClientEnqueueWithOptions(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - tests := []struct { - name string - opts []asynq.Option - verify func(*testing.T, *asynq.TaskInfo) - }{ - { - name: "Custom Queue", - opts: []asynq.Option{ - asynq.Queue(constants.QueueCritical), - }, - verify: func(t *testing.T, info *asynq.TaskInfo) { - assert.Equal(t, constants.QueueCritical, info.Queue) - }, - }, - { - name: "Custom Retry", - opts: []asynq.Option{ - asynq.MaxRetry(3), - }, - verify: func(t *testing.T, info *asynq.TaskInfo) { - assert.Equal(t, 3, info.MaxRetry) - }, - }, - { - name: "Custom Timeout", - opts: []asynq.Option{ - asynq.Timeout(5 * time.Minute), - }, - verify: func(t *testing.T, info *asynq.TaskInfo) { - assert.Equal(t, 5*time.Minute, info.Timeout) - }, - }, - { - name: "Delayed Task", - opts: []asynq.Option{ - asynq.ProcessIn(10 * time.Second), - }, - verify: func(t *testing.T, info *asynq.TaskInfo) { - assert.True(t, info.NextProcessAt.After(time.Now())) - }, - }, - { - name: "Combined Options", - opts: []asynq.Option{ - asynq.Queue(constants.QueueCritical), - asynq.MaxRetry(5), - asynq.Timeout(10 * time.Minute), - }, - verify: func(t *testing.T, info *asynq.TaskInfo) { - assert.Equal(t, constants.QueueCritical, info.Queue) - assert.Equal(t, 5, info.MaxRetry) - assert.Equal(t, 10*time.Minute, info.Timeout) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - payload := map[string]string{ - "request_id": "test-" + tt.name, - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info, err := client.Enqueue(task, tt.opts...) - - require.NoError(t, err) - tt.verify(t, info) - }) - } -} - -// TestQueueClientTaskUniqueness 测试任务唯一性 -func TestQueueClientTaskUniqueness(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - payload := map[string]string{ - "request_id": "unique-001", - "to": "test@example.com", - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - // 第一次提交 - task1 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info1, err := client.Enqueue(task1, - asynq.TaskID("unique-task-001"), - asynq.Unique(1*time.Hour), - ) - require.NoError(t, err) - assert.NotNil(t, info1) - - // 第二次提交(重复) - task2 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info2, err := client.Enqueue(task2, - asynq.TaskID("unique-task-001"), - asynq.Unique(1*time.Hour), - ) - - // 应该返回错误(任务已存在) - assert.Error(t, err) - assert.Nil(t, info2) -} - -// TestQueuePriorityWeights 测试队列优先级权重 -func TestQueuePriorityWeights(t *testing.T) { - queues := map[string]int{ - constants.QueueCritical: 6, - constants.QueueDefault: 3, - constants.QueueLow: 1, - } - - // 验证权重总和 - totalWeight := 0 - for _, weight := range queues { - totalWeight += weight - } - assert.Equal(t, 10, totalWeight) - - // 验证权重比例 - assert.Equal(t, 0.6, float64(queues[constants.QueueCritical])/float64(totalWeight)) - assert.Equal(t, 0.3, float64(queues[constants.QueueDefault])/float64(totalWeight)) - assert.Equal(t, 0.1, float64(queues[constants.QueueLow])/float64(totalWeight)) -} - -// TestTaskPayloadSizeLimit 测试任务载荷大小限制 -func TestTaskPayloadSizeLimit(t *testing.T) { - tests := []struct { - name string - payloadSize int - shouldError bool - }{ - { - name: "Small Payload (1KB)", - payloadSize: 1024, - shouldError: false, - }, - { - name: "Medium Payload (100KB)", - payloadSize: 100 * 1024, - shouldError: false, - }, - { - name: "Large Payload (1MB)", - payloadSize: 1024 * 1024, - shouldError: false, - }, - // Redis 默认支持最大 512MB,但实际应用中不建议超过 1MB - } - - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 创建指定大小的载荷 - largeData := make([]byte, tt.payloadSize) - for i := range largeData { - largeData[i] = byte(i % 256) - } - - payload := map[string]interface{}{ - "request_id": "size-test-001", - "data": largeData, - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeDataSync, payloadBytes) - info, err := client.Enqueue(task) - - if tt.shouldError { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.NotNil(t, info) - } - }) - } -} - -// TestTaskScheduling 测试任务调度 -func TestTaskScheduling(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - tests := []struct { - name string - scheduleOpt asynq.Option - expectedTime time.Time - }{ - { - name: "Process In 5 Seconds", - scheduleOpt: asynq.ProcessIn(5 * time.Second), - expectedTime: time.Now().Add(5 * time.Second), - }, - { - name: "Process At Specific Time", - scheduleOpt: asynq.ProcessAt(time.Now().Add(10 * time.Second)), - expectedTime: time.Now().Add(10 * time.Second), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - payload := map[string]string{ - "request_id": "schedule-test-" + tt.name, - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info, err := client.Enqueue(task, tt.scheduleOpt) - - require.NoError(t, err) - assert.True(t, info.NextProcessAt.After(time.Now())) - // 允许 1 秒的误差 - assert.WithinDuration(t, tt.expectedTime, info.NextProcessAt, 1*time.Second) - }) - } -} - -// TestQueueInspectorStats 测试队列统计 -func TestQueueInspectorStats(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - // 提交一些任务 - for i := 0; i < 5; i++ { - payload := map[string]string{ - "request_id": "stats-test-" + string(rune(i)), - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - _, err = client.Enqueue(task) - require.NoError(t, err) - } - - // 使用 Inspector 查询统计 - inspector := asynq.NewInspector(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = inspector.Close() }() - - info, err := inspector.GetQueueInfo(constants.QueueDefault) - require.NoError(t, err) - - assert.Equal(t, 5, info.Pending) - assert.Equal(t, 0, info.Active) - assert.Equal(t, 0, info.Completed) -} - -// TestTaskRetention 测试任务保留策略 -func TestTaskRetention(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - payload := map[string]string{ - "request_id": "retention-test-001", - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - // 提交任务并设置保留时间 - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info, err := client.Enqueue(task, - asynq.Retention(24*time.Hour), // 保留 24 小时 - ) - - require.NoError(t, err) - assert.NotNil(t, info) -} - -// TestQueueDraining 测试队列暂停和恢复 -func TestQueueDraining(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - inspector := asynq.NewInspector(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = inspector.Close() }() - - // 暂停队列 - err := inspector.PauseQueue(constants.QueueDefault) - require.NoError(t, err) - - // 检查队列是否已暂停 - info, err := inspector.GetQueueInfo(constants.QueueDefault) - require.NoError(t, err) - assert.True(t, info.Paused) - - // 恢复队列 - err = inspector.UnpauseQueue(constants.QueueDefault) - require.NoError(t, err) - - // 检查队列是否已恢复 - info, err = inspector.GetQueueInfo(constants.QueueDefault) - require.NoError(t, err) - assert.False(t, info.Paused) -} - -// TestTaskCancellation 测试任务取消 -func TestTaskCancellation(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - payload := map[string]string{ - "request_id": "cancel-test-001", - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - // 提交任务 - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - info, err := client.Enqueue(task) - require.NoError(t, err) - - // 取消任务 - inspector := asynq.NewInspector(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = inspector.Close() }() - - err = inspector.DeleteTask(constants.QueueDefault, info.ID) - require.NoError(t, err) - - // 验证任务已删除 - queueInfo, err := inspector.GetQueueInfo(constants.QueueDefault) - require.NoError(t, err) - assert.Equal(t, 0, queueInfo.Pending) -} - -// TestBatchTaskEnqueue 测试批量任务入队 -func TestBatchTaskEnqueue(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - // 批量创建任务 - batchSize := 100 - for i := 0; i < batchSize; i++ { - payload := map[string]string{ - "request_id": "batch-" + string(rune(i)), - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - _, err = client.Enqueue(task) - require.NoError(t, err) - } - - // 验证任务数量 - inspector := asynq.NewInspector(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = inspector.Close() }() - - info, err := inspector.GetQueueInfo(constants.QueueDefault) - require.NoError(t, err) - assert.Equal(t, batchSize, info.Pending) -} - -// TestTaskGrouping 测试任务分组 -func TestTaskGrouping(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer func() { _ = rdb.Close() }() - - ctx := context.Background() - rdb.FlushDB(ctx) - - client := asynq.NewClient(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = client.Close() }() - - // 提交分组任务 - groupKey := "email-batch-001" - for i := 0; i < 5; i++ { - payload := map[string]string{ - "request_id": "group-" + string(rune(i)), - "group": groupKey, - } - - payloadBytes, err := sonic.Marshal(payload) - require.NoError(t, err) - - task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes) - _, err = client.Enqueue(task, - asynq.Group(groupKey), - ) - require.NoError(t, err) - } - - // 验证任务已按组提交 - inspector := asynq.NewInspector(asynq.RedisClientOpt{ - Addr: "localhost:6379", - }) - defer func() { _ = inspector.Close() }() - - info, err := inspector.GetQueueInfo(constants.QueueDefault) - require.NoError(t, err) - assert.GreaterOrEqual(t, info.Pending, 5) -} diff --git a/tests/unit/role_assignment_limit_test.go b/tests/unit/role_assignment_limit_test.go index ff8c89d..3dd55ad 100644 --- a/tests/unit/role_assignment_limit_test.go +++ b/tests/unit/role_assignment_limit_test.go @@ -179,5 +179,5 @@ func TestRoleAssignmentLimit_SuperAdmin(t *testing.T) { // 尝试为超级管理员分配角色(应该失败) _, err := service.AssignRoles(ctx, superAdmin.ID, []uint{role.ID}) require.Error(t, err) - assert.Contains(t, err.Error(), "不需要分配角色") + assert.Contains(t, err.Error(), "超级管理员不允许分配角色") } diff --git a/tests/unit/task_handler_test.go b/tests/unit/task_handler_test.go deleted file mode 100644 index 9158358..0000000 --- a/tests/unit/task_handler_test.go +++ /dev/null @@ -1,390 +0,0 @@ -package unit - -import ( - "context" - "testing" - "time" - - "github.com/bytedance/sonic" - "github.com/hibiken/asynq" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/break/junhong_cmp_fiber/pkg/constants" -) - -// MockEmailPayload 邮件任务载荷(测试用) -type MockEmailPayload struct { - RequestID string `json:"request_id"` - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` - CC []string `json:"cc,omitempty"` -} - -// TestHandlerIdempotency 测试处理器幂等性逻辑 -func TestHandlerIdempotency(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer rdb.Close() - - ctx := context.Background() - rdb.FlushDB(ctx) - - requestID := "test-req-001" - lockKey := constants.RedisTaskLockKey(requestID) - - // 测试场景1: 第一次执行(未加锁) - t.Run("First Execution - Should Acquire Lock", func(t *testing.T) { - result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result() - require.NoError(t, err) - assert.True(t, result, "第一次执行应该成功获取锁") - }) - - // 测试场景2: 重复执行(已加锁) - t.Run("Duplicate Execution - Should Skip", func(t *testing.T) { - result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result() - require.NoError(t, err) - assert.False(t, result, "重复执行应该跳过(锁已存在)") - }) - - // 清理 - rdb.Del(ctx, lockKey) -} - -// TestHandlerErrorHandling 测试处理器错误处理 -func TestHandlerErrorHandling(t *testing.T) { - tests := []struct { - name string - payload MockEmailPayload - shouldError bool - errorMsg string - }{ - { - name: "Valid Payload", - payload: MockEmailPayload{ - RequestID: "valid-001", - To: "test@example.com", - Subject: "Test", - Body: "Test Body", - }, - shouldError: false, - }, - { - name: "Missing RequestID", - payload: MockEmailPayload{ - RequestID: "", - To: "test@example.com", - Subject: "Test", - Body: "Test Body", - }, - shouldError: true, - errorMsg: "request_id 不能为空", - }, - { - name: "Missing To", - payload: MockEmailPayload{ - RequestID: "test-002", - To: "", - Subject: "Test", - Body: "Test Body", - }, - shouldError: true, - errorMsg: "收件人不能为空", - }, - { - name: "Invalid Email Format", - payload: MockEmailPayload{ - RequestID: "test-003", - To: "invalid-email", - Subject: "Test", - Body: "Test Body", - }, - shouldError: true, - errorMsg: "邮箱格式无效", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 验证载荷 - err := validateEmailPayload(&tt.payload) - - if tt.shouldError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - require.NoError(t, err) - } - }) - } -} - -// validateEmailPayload 验证邮件载荷(模拟实际处理器中的验证逻辑) -func validateEmailPayload(payload *MockEmailPayload) error { - if payload.RequestID == "" { - return asynq.SkipRetry // 参数错误不重试 - } - if payload.To == "" { - return asynq.SkipRetry - } - // 简单的邮箱格式验证 - if payload.To != "" && !contains(payload.To, "@") { - return asynq.SkipRetry - } - return nil -} - -func contains(s, substr string) bool { - for i := 0; i < len(s)-len(substr)+1; i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -// TestHandlerRetryLogic 测试重试逻辑 -func TestHandlerRetryLogic(t *testing.T) { - tests := []struct { - name string - error error - shouldRetry bool - }{ - { - name: "Retryable Error - Network Issue", - error: assert.AnError, - shouldRetry: true, - }, - { - name: "Non-Retryable Error - Invalid Params", - error: asynq.SkipRetry, - shouldRetry: false, - }, - { - name: "No Error", - error: nil, - shouldRetry: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - shouldRetry := tt.error != nil && tt.error != asynq.SkipRetry - assert.Equal(t, tt.shouldRetry, shouldRetry) - }) - } -} - -// TestPayloadDeserialization 测试载荷反序列化 -func TestPayloadDeserialization(t *testing.T) { - tests := []struct { - name string - jsonPayload string - expectError bool - }{ - { - name: "Valid JSON", - jsonPayload: `{"request_id":"test-001","to":"test@example.com","subject":"Test","body":"Body"}`, - expectError: false, - }, - { - name: "Invalid JSON", - jsonPayload: `{invalid json}`, - expectError: true, - }, - { - name: "Empty JSON", - jsonPayload: `{}`, - expectError: false, // JSON 解析成功,但验证会失败 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var payload MockEmailPayload - err := sonic.Unmarshal([]byte(tt.jsonPayload), &payload) - - if tt.expectError { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - -// TestTaskStatusTransition 测试任务状态转换 -func TestTaskStatusTransition(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer rdb.Close() - - ctx := context.Background() - rdb.FlushDB(ctx) - - taskID := "task-transition-001" - statusKey := constants.RedisTaskStatusKey(taskID) - - // 状态转换序列 - transitions := []struct { - status string - valid bool - }{ - {"pending", true}, - {"processing", true}, - {"completed", true}, - {"failed", false}, // completed 后不应该转到 failed - } - - currentStatus := "" - for _, tr := range transitions { - t.Run("Transition to "+tr.status, func(t *testing.T) { - // 检查状态转换是否合法 - if isValidTransition(currentStatus, tr.status) == tr.valid { - err := rdb.Set(ctx, statusKey, tr.status, 7*24*time.Hour).Err() - require.NoError(t, err) - currentStatus = tr.status - } else { - // 不合法的转换应该被拒绝 - assert.False(t, tr.valid) - } - }) - } -} - -// isValidTransition 检查状态转换是否合法 -func isValidTransition(from, to string) bool { - validTransitions := map[string][]string{ - "": {"pending"}, - "pending": {"processing", "failed"}, - "processing": {"completed", "failed"}, - "completed": {}, // 终态 - "failed": {}, // 终态 - } - - allowed, exists := validTransitions[from] - if !exists { - return false - } - - for _, valid := range allowed { - if valid == to { - return true - } - } - return false -} - -// TestConcurrentTaskExecution 测试并发任务执行 -func TestConcurrentTaskExecution(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer rdb.Close() - - ctx := context.Background() - rdb.FlushDB(ctx) - - // 模拟多个并发任务尝试获取同一个锁 - requestID := "concurrent-test-001" - lockKey := constants.RedisTaskLockKey(requestID) - - concurrency := 10 - successCount := 0 - - done := make(chan bool, concurrency) - - // 并发执行 - for i := 0; i < concurrency; i++ { - go func() { - result, err := rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result() - if err == nil && result { - successCount++ - } - done <- true - }() - } - - // 等待所有 goroutine 完成 - for i := 0; i < concurrency; i++ { - <-done - } - - // 验证只有一个成功获取锁 - assert.Equal(t, 1, successCount, "只有一个任务应该成功获取锁") -} - -// TestTaskTimeout 测试任务超时处理 -func TestTaskTimeout(t *testing.T) { - tests := []struct { - name string - taskDuration time.Duration - timeout time.Duration - shouldTimeout bool - }{ - { - name: "Normal Execution", - taskDuration: 100 * time.Millisecond, - timeout: 1 * time.Second, - shouldTimeout: false, - }, - { - name: "Timeout Execution", - taskDuration: 2 * time.Second, - timeout: 500 * time.Millisecond, - shouldTimeout: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), tt.timeout) - defer cancel() - - // 模拟任务执行 - done := make(chan bool) - go func() { - time.Sleep(tt.taskDuration) - done <- true - }() - - select { - case <-done: - assert.False(t, tt.shouldTimeout, "任务应该正常完成") - case <-ctx.Done(): - assert.True(t, tt.shouldTimeout, "任务应该超时") - } - }) - } -} - -// TestLockExpiration 测试锁过期机制 -func TestLockExpiration(t *testing.T) { - rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - }) - defer rdb.Close() - - ctx := context.Background() - rdb.FlushDB(ctx) - - requestID := "expiration-test-001" - lockKey := constants.RedisTaskLockKey(requestID) - - // 设置短 TTL 的锁 - result, err := rdb.SetNX(ctx, lockKey, "1", 100*time.Millisecond).Result() - require.NoError(t, err) - assert.True(t, result) - - // 等待锁过期 - time.Sleep(200 * time.Millisecond) - - // 验证锁已过期,可以重新获取 - result, err = rdb.SetNX(ctx, lockKey, "1", 24*time.Hour).Result() - require.NoError(t, err) - assert.True(t, result, "锁过期后应该可以重新获取") -}