diff --git a/CLAUDE.md b/CLAUDE.md index 35e05fb..0691df9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,11 +31,25 @@ Keep this managed block so 'openspec update' can refresh the instructions. |---------|-----------|------| | 创建/修改 DTO 文件 | `dto-standards` | description 标签、枚举字段、验证标签规范 | | 创建/修改 Model 模型 | `model-standards` | GORM 模型结构、字段标签、TableName 规范 | -| 注册 API 路由 | `api-routing` | Register() 函数、RouteSpec 必填项 | +| 注册 API 路由 / **新增 Handler** | `api-routing` | Register() 函数、RouteSpec、**文档生成器更新** | | 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 | | 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 | | 维护规范文档 | `doc-management` | 规范文档流程和维护规则 | +### ⚠️ 新增 Handler 时必须同步更新文档生成器 + +新增 Handler 后,接口不会自动出现在 OpenAPI 文档中。**必须手动更新以下两个文件**: + +```go +// cmd/api/docs.go 和 cmd/gendocs/main.go +handlers := &bootstrap.Handlers{ + // ... 添加新 Handler + NewHandler: admin.NewXxxHandler(nil), +} +``` + +**完整检查清单**: 参见 [`docs/api-documentation-guide.md`](docs/api-documentation-guide.md#新增-handler-检查清单) + --- ## 语言要求 @@ -137,6 +151,58 @@ Handler → Service → Store → Model - 使用 table-driven tests - 单元测试 < 100ms,集成测试 < 1s +### ⚠️ 测试真实性原则(严格遵守) + +**测试必须真正验证功能,禁止绕过核心逻辑:** + +| 规则 | 说明 | +|------|------| +| ❌ 禁止传递 nil 绕过依赖 | 如果功能依赖外部服务(如对象存储、第三方 API),测试必须验证该依赖的调用 | +| ❌ 禁止只测试部分流程 | 如果功能包含 A → B → C 三步,不能只测试 B 而跳过 A 和 C | +| ❌ 禁止声称"测试通过"但未验证核心逻辑 | 测试通过必须意味着功能真正可用 | +| ❌ 禁止擅自使用 Mock | 尽量使用真实服务进行集成测试,如需使用 Mock 必须先询问用户并获得同意 | +| ✅ 必须验证端到端流程 | 新增功能必须有完整的集成测试覆盖整个调用链 | +| ✅ 缺少配置时必须询问 | 如果测试需要的配置(如 API Key、环境变量)缺失,必须询问用户而非跳过测试 | + +**反面案例**: +```go +// ❌ 错误:传递 nil 绕过 storageService,只测试了 processImport +handler := NewIotCardImportHandler(db, redis, store1, store2, nil, logger) +result := handler.processImport(ctx, task) // 跳过了 downloadAndParseCSV + +// ✅ 正确:使用真实服务测试完整流程 +handler := NewIotCardImportHandler(db, redis, store1, store2, realStorageService, logger) +handler.HandleIotCardImport(ctx, asynqTask) // 测试完整流程,验证真实上传/下载 +``` + +**测试超时 = 生产超时**: +- 集成测试超时意味着生产环境也可能超时 +- 发现超时必须排查原因,不能简单跳过或增加超时时间 + +### 测试连接管理(必读) + +**详细规范**: [docs/testing/test-connection-guide.md](docs/testing/test-connection-guide.md) + +**标准模板**: +```go +func TestXxx(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + store := postgres.NewXxxStore(tx, rdb) + // 测试代码... +} +``` + +**核心函数**: +- `NewTestTransaction(t)`: 创建测试事务,自动回滚 +- `GetTestRedis(t)`: 获取全局 Redis 连接 +- `CleanTestRedisKeys(t, rdb)`: 自动清理测试 Redis 键 + +**禁止使用(已移除)**: +- ❌ `SetupTestDB` / `TeardownTestDB` / `SetupTestDBWithStore` + ## 性能要求 - API P95 响应时间 < 200ms @@ -179,4 +245,20 @@ Handler → Service → Store → Model 8. ✅ 文档更新计划 9. ✅ 中文优先 +### ⚠️ 任务执行规范(必须遵守) + +**提案中的 tasks.md 是契约,不可擅自变更:** + +| 规则 | 说明 | +|------|------| +| ❌ 禁止跳过任务 | 每个任务都是经过规划的,不能因为"简单"或"显而易见"而跳过 | +| ❌ 禁止简化任务 | 不能将多个任务合并或简化执行,除非获得明确许可 | +| ❌ 禁止自作主张优化 | 发现可以优化的地方,必须先询问是否可以调整 | +| ✅ 必须逐项完成 | 按照 tasks.md 中的顺序逐一执行并标记完成 | +| ✅ 必须询问后变更 | 如需调整任务(简化/跳过/合并/优化),先询问用户确认 | + +**询问示例**: +> "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?" +> "任务 3.1 在当前实现中可能不需要,是否可以跳过?" + **详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md` diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 51de5bc..1683190 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -1085,6 +1085,15 @@ components: total_count: description: 总数 type: integer + warning_count: + description: 警告数(部分成功的设备数量) + type: integer + warning_items: + description: 警告记录详情(部分成功的设备及其卡绑定失败原因) + items: + $ref: '#/components/schemas/DtoDeviceImportResultItemDTO' + nullable: true + type: array type: object DtoDeviceImportTaskResponse: properties: @@ -1136,6 +1145,9 @@ components: total_count: description: 总数 type: integer + warning_count: + description: 警告数(部分成功的设备数量) + type: integer type: object DtoDeviceResponse: properties: @@ -1577,6 +1589,83 @@ components: description: 总数 type: integer type: object + DtoIotCardDetailResponse: + properties: + activated_at: + description: 激活时间 + format: date-time + nullable: true + type: string + activation_status: + description: 激活状态 (0:未激活, 1:已激活) + type: integer + batch_no: + description: 批次号 + type: string + card_category: + description: 卡业务类型 (normal:普通卡, industry:行业卡) + type: string + card_type: + description: 卡类型 + type: string + carrier_id: + description: 运营商ID + minimum: 0 + type: integer + carrier_name: + description: 运营商名称 + type: string + cost_price: + description: 成本价(分) + type: integer + created_at: + description: 创建时间 + format: date-time + type: string + data_usage_mb: + description: 累计流量使用(MB) + type: integer + distribute_price: + description: 分销价(分) + type: integer + iccid: + description: ICCID + type: string + id: + description: 卡ID + minimum: 0 + type: integer + imsi: + description: IMSI + type: string + msisdn: + description: 卡接入号 + type: string + network_status: + description: 网络状态 (0:停机, 1:开机) + type: integer + real_name_status: + description: 实名状态 (0:未实名, 1:已实名) + 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 + supplier: + description: 供应商 + type: string + updated_at: + description: 更新时间 + format: date-time + type: string + type: object DtoListAssetAllocationRecordResponse: properties: list: @@ -4904,6 +4993,52 @@ paths: summary: 批量分配设备 tags: - 设备管理 + /api/admin/devices/by-imei/{imei}: + get: + parameters: + - description: 设备号(IMEI) + in: path + name: imei + required: true + schema: + description: 设备号(IMEI) + type: string + 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/import: post: description: |- @@ -5709,6 +5844,52 @@ paths: summary: 启用/禁用企业 tags: - 企业客户管理 + /api/admin/iot-cards/by-iccid/{iccid}: + get: + parameters: + - description: ICCID + in: path + name: iccid + required: true + schema: + description: ICCID + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DtoIotCardDetailResponse' + 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: 通过ICCID查询单卡详情 + tags: + - IoT卡管理 /api/admin/iot-cards/import: post: description: |- diff --git a/internal/handler/admin/device.go b/internal/handler/admin/device.go index a58314f..eadeb03 100644 --- a/internal/handler/admin/device.go +++ b/internal/handler/admin/device.go @@ -52,6 +52,20 @@ func (h *DeviceHandler) GetByID(c *fiber.Ctx) error { return response.Success(c, result) } +func (h *DeviceHandler) GetByIMEI(c *fiber.Ctx) error { + imei := c.Params("imei") + if imei == "" { + return errors.New(errors.CodeInvalidParam, "设备号不能为空") + } + + result, err := h.service.GetByDeviceNo(c.UserContext(), imei) + 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 { diff --git a/internal/handler/admin/iot_card.go b/internal/handler/admin/iot_card.go index df9319f..86aba58 100644 --- a/internal/handler/admin/iot_card.go +++ b/internal/handler/admin/iot_card.go @@ -33,6 +33,20 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error { return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) } +func (h *IotCardHandler) GetByICCID(c *fiber.Ctx) error { + iccid := c.Params("iccid") + if iccid == "" { + return errors.New(errors.CodeInvalidParam, "ICCID不能为空") + } + + result, err := h.service.GetByICCID(c.UserContext(), iccid) + if err != nil { + return err + } + + return response.Success(c, result) +} + func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error { var req dto.AllocateStandaloneCardsRequest if err := c.BodyParser(&req); err != nil { diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 5b9d00d..9e9b1a6 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -47,6 +47,10 @@ type GetDeviceRequest struct { ID uint `path:"id" description:"设备ID" required:"true"` } +type GetDeviceByIMEIRequest struct { + DeviceNo string `path:"imei" description:"设备号(IMEI)" required:"true"` +} + type DeleteDeviceRequest struct { ID uint `path:"id" description:"设备ID" required:"true"` } diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 48b27dc..e6bc3ea 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -116,3 +116,11 @@ type ImportTaskDetailResponse struct { type GetImportTaskRequest struct { ID uint `path:"id" description:"任务ID" required:"true"` } + +type GetIotCardByICCIDRequest struct { + ICCID string `path:"iccid" description:"ICCID" required:"true"` +} + +type IotCardDetailResponse struct { + StandaloneIotCardResponse +} diff --git a/internal/routes/device.go b/internal/routes/device.go index 05a3a14..fa9bb8e 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -28,6 +28,14 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) + Register(devices, doc, groupPath, "GET", "/by-imei/:imei", handler.GetByIMEI, RouteSpec{ + Summary: "通过设备号查询设备详情", + Tags: []string{"设备管理"}, + Input: new(dto.GetDeviceByIMEIRequest), + Output: new(dto.DeviceResponse), + Auth: true, + }) + Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{ Summary: "删除设备", Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。", diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index ea0eead..0d165c5 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -20,6 +20,14 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i Auth: true, }) + Register(iotCards, doc, groupPath, "GET", "/by-iccid/:iccid", handler.GetByICCID, RouteSpec{ + Summary: "通过ICCID查询单卡详情", + Tags: []string{"IoT卡管理"}, + Input: new(dto.GetIotCardByICCIDRequest), + Output: new(dto.IotCardDetailResponse), + Auth: true, + }) + Register(iotCards, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{ Summary: "批量导入IoT卡(ICCID+MSISDN)", Description: `## ⚠️ 接口变更说明(BREAKING CHANGE) diff --git a/internal/service/device/service.go b/internal/service/device/service.go index e824396..9a36a7d 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -134,6 +134,25 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) return s.toDeviceResponse(device, shopMap, bindingCounts), nil } +// GetByDeviceNo 通过设备号获取设备详情 +func (s *Service) GetByDeviceNo(ctx context.Context, deviceNo string) (*dto.DeviceResponse, error) { + device, err := s.deviceStore.GetByDeviceNo(ctx, deviceNo) + 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 { diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index dd79403..cd49521 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -110,6 +110,24 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot }, nil } +// GetByICCID 通过 ICCID 获取单卡详情 +func (s *Service) GetByICCID(ctx context.Context, iccid string) (*dto.IotCardDetailResponse, error) { + card, err := s.iotCardStore.GetByICCID(ctx, iccid) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "IoT卡不存在") + } + return nil, err + } + + carrierMap, shopMap := s.loadRelatedData(ctx, []*model.IotCard{card}) + standaloneResp := s.toStandaloneResponse(card, carrierMap, shopMap) + + return &dto.IotCardDetailResponse{ + StandaloneIotCardResponse: *standaloneResp, + }, nil +} + func (s *Service) loadRelatedData(ctx context.Context, cards []*model.IotCard) (map[uint]string, map[uint]string) { carrierIDs := make([]uint, 0) shopIDs := make([]uint, 0) diff --git a/openspec/changes/add-device-management/.openspec.yaml b/openspec/changes/archive/2026-01-27-add-device-management/.openspec.yaml similarity index 100% rename from openspec/changes/add-device-management/.openspec.yaml rename to openspec/changes/archive/2026-01-27-add-device-management/.openspec.yaml diff --git a/openspec/changes/add-device-management/design.md b/openspec/changes/archive/2026-01-27-add-device-management/design.md similarity index 100% rename from openspec/changes/add-device-management/design.md rename to openspec/changes/archive/2026-01-27-add-device-management/design.md diff --git a/openspec/changes/add-device-management/proposal.md b/openspec/changes/archive/2026-01-27-add-device-management/proposal.md similarity index 100% rename from openspec/changes/add-device-management/proposal.md rename to openspec/changes/archive/2026-01-27-add-device-management/proposal.md diff --git a/openspec/changes/add-device-management/specs/asset-allocation-record/spec.md b/openspec/changes/archive/2026-01-27-add-device-management/specs/asset-allocation-record/spec.md similarity index 100% rename from openspec/changes/add-device-management/specs/asset-allocation-record/spec.md rename to openspec/changes/archive/2026-01-27-add-device-management/specs/asset-allocation-record/spec.md diff --git a/openspec/changes/add-device-management/specs/device-import/spec.md b/openspec/changes/archive/2026-01-27-add-device-management/specs/device-import/spec.md similarity index 100% rename from openspec/changes/add-device-management/specs/device-import/spec.md rename to openspec/changes/archive/2026-01-27-add-device-management/specs/device-import/spec.md diff --git a/openspec/changes/add-device-management/specs/device/spec.md b/openspec/changes/archive/2026-01-27-add-device-management/specs/device/spec.md similarity index 100% rename from openspec/changes/add-device-management/specs/device/spec.md rename to openspec/changes/archive/2026-01-27-add-device-management/specs/device/spec.md diff --git a/openspec/changes/add-device-management/tasks.md b/openspec/changes/archive/2026-01-27-add-device-management/tasks.md similarity index 100% rename from openspec/changes/add-device-management/tasks.md rename to openspec/changes/archive/2026-01-27-add-device-management/tasks.md diff --git a/openspec/changes/fix-device-sim-binding-issues/.openspec.yaml b/openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/.openspec.yaml similarity index 100% rename from openspec/changes/fix-device-sim-binding-issues/.openspec.yaml rename to openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/.openspec.yaml diff --git a/openspec/changes/fix-device-sim-binding-issues/design.md b/openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/design.md similarity index 100% rename from openspec/changes/fix-device-sim-binding-issues/design.md rename to openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/design.md diff --git a/openspec/changes/fix-device-sim-binding-issues/proposal.md b/openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/proposal.md similarity index 100% rename from openspec/changes/fix-device-sim-binding-issues/proposal.md rename to openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/proposal.md diff --git a/openspec/changes/fix-device-sim-binding-issues/specs/iot-device/spec.md b/openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/specs/iot-device/spec.md similarity index 100% rename from openspec/changes/fix-device-sim-binding-issues/specs/iot-device/spec.md rename to openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/specs/iot-device/spec.md diff --git a/openspec/changes/fix-device-sim-binding-issues/tasks.md b/openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/tasks.md similarity index 100% rename from openspec/changes/fix-device-sim-binding-issues/tasks.md rename to openspec/changes/archive/2026-01-27-fix-device-sim-binding-issues/tasks.md diff --git a/openspec/specs/asset-allocation-record/spec.md b/openspec/specs/asset-allocation-record/spec.md index 7cdc7a6..6f7b077 100644 --- a/openspec/specs/asset-allocation-record/spec.md +++ b/openspec/specs/asset-allocation-record/spec.md @@ -3,9 +3,7 @@ ## Purpose 管理资产(IoT 卡、设备)在平台与代理商之间的流转记录,支持分配和回收操作的完整追溯。 - ## Requirements - ### Requirement: 资产分配记录查询 系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。 @@ -16,7 +14,7 @@ **资产类型**: - `iot_card`: 物联网卡(单卡) -- `device`: 设备(未来扩展) +- `device`: 设备 **查询条件**: - `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall" @@ -48,6 +46,8 @@ - `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`: 来源所有者名称 @@ -69,6 +69,11 @@ - **WHEN** 管理员查询资产类型为 "iot_card" 的记录 - **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录 +#### Scenario: 按资产类型筛选设备记录 + +- **WHEN** 管理员查询资产类型为 "device" 的记录 +- **THEN** 系统只返回设备的分配/回收记录,不包含单卡记录 + #### Scenario: 按分配类型筛选记录 - **WHEN** 管理员查询分配类型为 "allocate" 的记录 @@ -79,6 +84,11 @@ - **WHEN** 管理员输入 asset_identifier = "8986001" - **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录 +#### Scenario: 按设备号模糊查询 + +- **WHEN** 管理员输入 asset_identifier = "GPS" +- **THEN** 系统返回设备号包含 "GPS" 的所有分配记录 + #### Scenario: 代理查询自己相关的记录 - **WHEN** 代理用户(店铺 ID=10)查询分配记录 @@ -94,14 +104,20 @@ **响应**: - 包含记录的所有字段 -- 关联卡 ID 列表(如果是设备分配,包含设备下的所有卡 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/specs/device-import/spec.md b/openspec/specs/device-import/spec.md new file mode 100644 index 0000000..4c29811 --- /dev/null +++ b/openspec/specs/device-import/spec.md @@ -0,0 +1,191 @@ +# device-import Specification + +## Purpose +TBD - created by archiving change add-device-management. Update Purpose after archive. +## 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/specs/device/spec.md b/openspec/specs/device/spec.md new file mode 100644 index 0000000..122587a --- /dev/null +++ b/openspec/specs/device/spec.md @@ -0,0 +1,325 @@ +# device Specification + +## Purpose +TBD - created by archiving change add-device-management. Update Purpose after archive. +## 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/specs/iot-device/spec.md b/openspec/specs/iot-device/spec.md index bea61fc..f6ac730 100644 --- a/openspec/specs/iot-device/spec.md +++ b/openspec/specs/iot-device/spec.md @@ -102,8 +102,9 @@ This capability supports: - 绑定时记录插槽位置(slot_position: 1, 2, 3, 4) - 绑定时记录绑定时间和绑定状态(1-已绑定 2-已解绑) - 绑定/解绑操作不改变 IoT 卡的 shop_id(所有权由分销操作管理,而非绑定操作) +- **新增**: 同一设备的同一插槽同一时间只能绑定一张卡(数据库唯一约束) -**中间表 device_sim_bindings**: +**中间表 tb_device_sim_binding**: - `id`: 绑定记录 ID(主键,BIGINT) - `device_id`: 设备 ID(BIGINT) - `iot_card_id`: IoT 卡 ID(BIGINT) @@ -113,6 +114,17 @@ This capability supports: - `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 卡到设备 @@ -124,6 +136,16 @@ This capability supports: - **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: 设备套餐购买和流量共享 @@ -217,29 +239,64 @@ This capability supports: **导入字段**: - 设备编号(必填) -- 设备名称(必填) -- 设备型号(必填) -- 设备类型(必填) +- 设备名称(可选) +- 设备型号(可选) +- 设备类型(可选) - 最大插槽数(可选,默认 4) - 设备制造商(可选) -- 批次号(必填) +- 批次号(可选,由任务自动生成) +- **ICCID 1-4**(可选,用于绑定 IoT 卡) **导入规则**: -- 设备编号必须唯一,重复编号将被拒绝 -- 导入的设备默认 `owner_type` 为 "platform",`owner_id` 为 0,状态为 1(未激活) +- 设备编号必须唯一,重复编号将被跳过 +- 导入的设备默认 `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 条设备记录,`owner_type` 为 "platform",`owner_id` 为 0,状态为 1(未激活),返回导入成功消息 +- **THEN** 系统创建 50 条设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库),返回导入成功消息 #### Scenario: 批量导入包含重复编号 - **WHEN** 平台上传的 CSV 文件中包含已存在的设备编号 -- **THEN** 系统拒绝重复编号的设备,返回错误信息并列出重复编号,其他有效设备正常导入 +- **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 已绑定其他设备" ### Requirement: 设备查询和筛选 @@ -299,3 +356,36 @@ This capability supports: - **WHEN** 用户创建设备,设备编号为已存在的 "DEV-001" - **THEN** 系统拒绝创建,返回错误信息"设备编号已存在" +### 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/tests/integration/device_test.go b/tests/integration/device_test.go index 3307c2b..ae0c0bf 100644 --- a/tests/integration/device_test.go +++ b/tests/integration/device_test.go @@ -40,8 +40,17 @@ type deviceTestEnv struct { func setupDeviceTestEnv(t *testing.T) *deviceTestEnv { t.Helper() - t.Setenv("CONFIG_ENV", "dev") - t.Setenv("CONFIG_PATH", "../../configs/config.dev.yaml") + // 设置测试环境变量 + t.Setenv("JUNHONG_DATABASE_HOST", "cxd.whcxd.cn") + t.Setenv("JUNHONG_DATABASE_PORT", "16159") + t.Setenv("JUNHONG_DATABASE_USER", "erp_pgsql") + t.Setenv("JUNHONG_DATABASE_PASSWORD", "erp_2025") + t.Setenv("JUNHONG_DATABASE_DBNAME", "junhong_cmp_test") + t.Setenv("JUNHONG_REDIS_ADDRESS", "cxd.whcxd.cn") + t.Setenv("JUNHONG_REDIS_PORT", "16299") + t.Setenv("JUNHONG_REDIS_PASSWORD", "cpNbWtAaqgo1YJmbMp3h") + t.Setenv("JUNHONG_JWT_SECRET_KEY", "test_secret_key_for_integration_tests") + cfg, err := config.Load() require.NoError(t, err) err = config.Set(cfg) @@ -331,3 +340,69 @@ func TestDeviceImport_TaskList(t *testing.T) { assert.Equal(t, 0, result.Code) }) } + +func TestDevice_GetByIMEI(t *testing.T) { + env := setupDeviceTestEnv(t) + defer env.teardown() + + // 创建测试设备 + device := &model.Device{ + DeviceNo: "TEST_IMEI_001", + DeviceName: "测试IMEI查询设备", + DeviceType: "router", + MaxSimSlots: 4, + Status: constants.DeviceStatusInStock, + } + require.NoError(t, env.db.Create(device).Error) + + t.Run("通过IMEI查询设备详情-成功", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/devices/by-imei/%s", device.DeviceNo) + 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_IMEI_001", dataMap["device_no"]) + assert.Equal(t, "测试IMEI查询设备", dataMap["device_name"]) + }) + + t.Run("通过不存在的IMEI查询-应返回错误", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/devices/by-imei/NONEXISTENT_IMEI", 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, "不存在的IMEI应返回错误码") + }) + + t.Run("未认证请求-应返回错误", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/devices/by-imei/%s", device.DeviceNo) + req := httptest.NewRequest("GET", url, 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, "未认证请求应返回错误码") + }) +} diff --git a/tests/integration/iot_card_test.go b/tests/integration/iot_card_test.go index b4e9368..9492542 100644 --- a/tests/integration/iot_card_test.go +++ b/tests/integration/iot_card_test.go @@ -42,8 +42,17 @@ type iotCardTestEnv struct { func setupIotCardTestEnv(t *testing.T) *iotCardTestEnv { t.Helper() - t.Setenv("CONFIG_ENV", "dev") - t.Setenv("CONFIG_PATH", "../../configs/config.dev.yaml") + // 设置测试环境变量 + t.Setenv("JUNHONG_DATABASE_HOST", "cxd.whcxd.cn") + t.Setenv("JUNHONG_DATABASE_PORT", "16159") + t.Setenv("JUNHONG_DATABASE_USER", "erp_pgsql") + t.Setenv("JUNHONG_DATABASE_PASSWORD", "erp_2025") + t.Setenv("JUNHONG_DATABASE_DBNAME", "junhong_cmp_test") + t.Setenv("JUNHONG_REDIS_ADDRESS", "cxd.whcxd.cn") + t.Setenv("JUNHONG_REDIS_PORT", "16299") + t.Setenv("JUNHONG_REDIS_PASSWORD", "cpNbWtAaqgo1YJmbMp3h") + t.Setenv("JUNHONG_JWT_SECRET_KEY", "test_secret_key_for_integration_tests") + cfg, err := config.Load() require.NoError(t, err) err = config.Set(cfg) @@ -565,3 +574,81 @@ func startTestWorker(t *testing.T, db *gorm.DB, rdb *redis.Client, logger *zap.L t.Logf("测试 Worker 服务器已启动") return workerServer } + +func TestIotCard_GetByICCID(t *testing.T) { + env := setupIotCardTestEnv(t) + defer env.teardown() + + // 创建测试运营商 + carrier := &model.Carrier{ + CarrierCode: "TEST001", + CarrierName: "测试运营商", + CarrierType: "CMCC", + Status: 1, + } + require.NoError(t, env.db.Create(carrier).Error) + + // 创建测试 IoT 卡 + card := &model.IotCard{ + ICCID: "TEST_ICCID_001", + CarrierID: carrier.ID, + MSISDN: "13800000001", + CardType: "physical", + CardCategory: "normal", + CostPrice: 1000, + DistributePrice: 1500, + Status: 1, + } + require.NoError(t, env.db.Create(card).Error) + + t.Run("通过ICCID查询单卡详情-成功", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/iot-cards/by-iccid/%s", card.ICCID) + 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_ICCID_001", dataMap["iccid"]) + assert.Equal(t, "13800000001", dataMap["msisdn"]) + }) + + t.Run("通过不存在的ICCID查询-应返回错误", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/admin/iot-cards/by-iccid/NONEXISTENT_ICCID", 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, "不存在的ICCID应返回错误码") + }) + + t.Run("未认证请求-应返回错误", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/iot-cards/by-iccid/%s", card.ICCID) + req := httptest.NewRequest("GET", url, 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, "未认证请求应返回错误码") + }) +}