diff --git a/internal/handler/admin/iot_card_import.go b/internal/handler/admin/iot_card_import.go index 2ae11c0..ddb9f9a 100644 --- a/internal/handler/admin/iot_card_import.go +++ b/internal/handler/admin/iot_card_import.go @@ -7,7 +7,9 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model/dto" iotCardImportService "github.com/break/junhong_cmp_fiber/internal/service/iot_card_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" ) @@ -22,6 +24,11 @@ func NewIotCardImportHandler(service *iotCardImportService.Service) *IotCardImpo } func (h *IotCardImportHandler) Import(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅平台用户可导入IoT卡") + } + var req dto.ImportIotCardRequest if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") @@ -40,6 +47,11 @@ func (h *IotCardImportHandler) Import(c *fiber.Ctx) error { } func (h *IotCardImportHandler) 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.ListImportTaskRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") @@ -54,6 +66,11 @@ func (h *IotCardImportHandler) List(c *fiber.Ctx) error { } func (h *IotCardImportHandler) 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 { diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index e59ad3e..97a05d9 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -30,7 +30,9 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i Register(iotCards, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{ Summary: "批量导入IoT卡(ICCID+MSISDN)", - Description: `## ⚠️ 接口变更说明(BREAKING CHANGE) + Description: `仅平台用户可操作。 + +## ⚠️ 接口变更说明(BREAKING CHANGE) 本接口已从 ` + "`multipart/form-data`" + ` 改为 ` + "`application/json`" + `。 文件格式从 CSV 升级为 Excel (.xlsx),解决长数字被转为科学记数法的问题。 @@ -64,19 +66,21 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i }) Register(iotCards, doc, groupPath, "GET", "/import-tasks", importHandler.List, RouteSpec{ - Summary: "导入任务列表", - Tags: []string{"IoT卡管理"}, - Input: new(dto.ListImportTaskRequest), - Output: new(dto.ListImportTaskResponse), - Auth: true, + Summary: "导入任务列表", + Description: "仅平台用户可操作。", + Tags: []string{"IoT卡管理"}, + Input: new(dto.ListImportTaskRequest), + Output: new(dto.ListImportTaskResponse), + Auth: true, }) Register(iotCards, doc, groupPath, "GET", "/import-tasks/:id", importHandler.GetByID, RouteSpec{ - Summary: "导入任务详情", - Tags: []string{"IoT卡管理"}, - Input: new(dto.GetImportTaskRequest), - Output: new(dto.ImportTaskDetailResponse), - Auth: true, + Summary: "导入任务详情", + Description: "仅平台用户可操作。", + Tags: []string{"IoT卡管理"}, + Input: new(dto.GetImportTaskRequest), + Output: new(dto.ImportTaskDetailResponse), + Auth: true, }) Register(iotCards, doc, groupPath, "POST", "/standalone/allocate", handler.AllocateCards, RouteSpec{ diff --git a/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/.openspec.yaml b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/.openspec.yaml new file mode 100644 index 0000000..8b00a11 --- /dev/null +++ b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-02 diff --git a/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/design.md b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/design.md new file mode 100644 index 0000000..e177adb --- /dev/null +++ b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/design.md @@ -0,0 +1,54 @@ +## Context + +当前后台接口中,设备导入任务相关接口在 Handler 层做了“仅平台用户/超级管理员可访问”的用户类型校验(基于 `middleware.GetUserTypeFromContext` + `constants.UserTypeSuperAdmin/UserTypePlatform`)。 + +IoT 卡导入任务相关接口(提交导入、任务列表、任务详情)未做同等校验,导致权限边界与设备导入不一致。 + +本变更目标是将 IoT 卡导入任务相关接口的访问权限收敛为与设备导入一致:仅超级管理员与平台用户可访问。 + +## Goals / Non-Goals + +**Goals:** +- 对以下 3 个接口增加用户类型校验:仅允许超级管理员与平台用户访问。 +- 返回行为与现有模式一致:非允许用户类型直接返回 403(`errors.CodeForbidden`),错误消息使用中文。 +- OpenAPI 路由描述与实际权限一致。 +- 补齐/新增集成测试,覆盖非平台用户访问应被拒绝。 + +**Non-Goals:** +- 不调整导入任务的数据模型与存储逻辑。 +- 不引入新的 RBAC 权限点或权限表配置(保持“用户类型硬校验”的现有风格)。 +- 不改动其他 IoT 卡管理接口的权限策略。 + +## Decisions + +1) **在 Handler 层做用户类型校验(与设备导入对齐)** +- 方案:在 `internal/handler/admin/iot_card_import.go` 的 `Import` / `List` / `GetByID` 入口处增加与 `internal/handler/admin/device_import.go` 同风格的校验。 +- 理由: + - 与现有“设备导入”实现一致,减少认知负担。 + - 校验发生在参数解析与业务调用前,能最早拒绝无权限请求。 +- 备选方案: + - 路由层中间件:更集中,但需要在路由注册处引入新中间件组合,且当前设备导入并未采用该方式。 + - Service 层校验:更“业务化”,但会改变当前导入模块的职责边界(设备导入限制目前在 Handler 层)。 + +2) **错误码与错误消息遵循现有约定** +- 方案:使用 `errors.New(errors.CodeForbidden, "仅平台用户可...")` 风格,保持与设备导入一致。 +- 理由:该模块已有同类错误消息,避免引入新的错误码或文案风格。 + +3) **同步更新路由描述(OpenAPI)** +- 方案:在 `internal/routes/iot_card.go` 对相关接口补充 `Description: "仅平台用户可操作。"`(与设备导入相同语义)。 +- 理由:避免文档与实际行为不一致,减少前后端联调成本。 + +## Risks / Trade-offs + +- **[风险] 规格与实现不一致** → **缓解**:在本变更的 specs(delta spec)中明确将权限收敛为平台用户/超管,并在实现阶段对齐。 +- **[风险] 测试缺口导致回归** → **缓解**:新增集成测试覆盖非平台用户访问 403,并尽量复用现有测试基建(集成测试 env)。 + +## Migration Plan + +- 该变更为权限收敛,无数据迁移。 +- 发布后影响:非平台用户/超管将无法调用 IoT 卡导入与导入任务查询接口。 +- 回滚策略:如业务需要恢复原可见性,可回滚本变更提交(或通过后续变更重新放开)。 + +## Open Questions + +- 是否需要将错误消息文案统一为同一句(如“仅平台用户可操作”),还是分别保留更具体的文案(导入/列表/详情)?(实现阶段可按现有 device_import 文案对齐) diff --git a/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/proposal.md b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/proposal.md new file mode 100644 index 0000000..f1e1bae --- /dev/null +++ b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/proposal.md @@ -0,0 +1,37 @@ +# 限制 IoT 卡导入任务接口仅平台用户可访问 + +Feature ID: feature-iot-card-import-task-platform-only + +## Why + +目前设备导入任务列表/详情已限制为仅平台用户/超级管理员可访问,但 IoT 卡导入任务列表/详情/提交导入未做同等限制,存在权限边界不一致与潜在越权风险。 + +需要将 IoT 卡导入任务相关接口的访问权限收敛为与设备导入一致,避免非平台账号通过后台接口获取或操作导入任务。 + +## What Changes + +- **权限收敛**:IoT 卡导入相关接口仅允许超级管理员与平台用户访问;其他用户类型访问返回 403。 +- **接口范围**: + - `POST /api/admin/iot-cards/import` + - `GET /api/admin/iot-cards/import-tasks` + - `GET /api/admin/iot-cards/import-tasks/:id` +- **文档一致性**:补充路由描述,确保 OpenAPI 文档与实际权限一致。 +- **测试补齐**:新增/补充集成测试覆盖非平台用户访问上述接口应被拒绝。 + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `iot-card-import-task`: 将“导入任务列表/详情/提交导入”的访问权限从“按 shop_id 数据权限过滤”调整为“仅平台用户/超级管理员可访问”。 + +## Impact + +- 影响 API:`/api/admin/iot-cards/import`、`/api/admin/iot-cards/import-tasks`、`/api/admin/iot-cards/import-tasks/:id` +- 预期涉及代码: + - Handler:`internal/handler/admin/iot_card_import.go` + - Routes/OpenAPI:`internal/routes/iot_card.go` + - 集成测试:`tests/integration/iot_card_test.go`(或新增对应测试文件) diff --git a/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/specs/iot-card-import-task/spec.md b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/specs/iot-card-import-task/spec.md new file mode 100644 index 0000000..4cd2046 --- /dev/null +++ b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/specs/iot-card-import-task/spec.md @@ -0,0 +1,76 @@ +# iot-card-import-task Specification (Delta) + +本变更用于收敛 IoT 卡导入任务相关接口的访问权限:仅超级管理员/平台用户可访问。 + +## ADDED Requirements + +### Requirement: 导入任务创建权限控制 + +系统 SHALL 仅允许超级管理员与平台用户创建 IoT 卡导入任务。 + +#### Scenario: 平台用户创建导入任务 +- **WHEN** 平台用户请求创建导入任务 +- **THEN** 系统创建导入任务并返回任务信息 + +#### Scenario: 非平台用户创建导入任务被拒绝 +- **WHEN** 非平台用户(代理账号/企业账号等)请求创建导入任务 +- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden` + +## MODIFIED Requirements + +### Requirement: 导入任务列表查询 + +系统 SHALL 支持查询导入任务列表,用于管理和监控导入任务。 + +**查询条件**: +- 任务状态(status): 可选,1-待处理 2-处理中 3-已完成 4-失败 +- 运营商 ID(carrier_id): 可选 +- 批次号(batch_no): 可选,模糊匹配 +- 创建时间范围: 可选 + +**分页**: +- 默认每页 20 条,最大每页 100 条 +- 默认按创建时间倒序排列 + +**权限**: +- 仅超级管理员/平台用户可查询导入任务列表 + +#### Scenario: 查询所有导入任务 + +- **WHEN** 平台管理员查询导入任务列表 +- **THEN** 系统返回导入任务列表,包含任务编号、状态、运营商、总数、成功数、跳过数、失败数、创建时间 + +#### Scenario: 按状态筛选导入任务 + +- **WHEN** 平台管理员查询状态为 2(处理中) 的导入任务 +- **THEN** 系统返回所有正在处理的导入任务列表 + +#### Scenario: 非平台用户查询导入任务列表被拒绝 + +- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务列表 +- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden` + +### Requirement: 导入任务详情查询 + +系统 SHALL 支持查询单个导入任务的详细信息,包括跳过/失败记录详情。 + +**详情信息**: +- 任务基本信息: 任务编号、状态、运营商、批次号、文件名 +- 进度统计: 总数、成功数、跳过数、失败数 +- 时间信息: 创建时间、开始时间、完成时间 +- 跳过记录详情: 行号、ICCID、原因 +- 失败记录详情: 行号、ICCID、原因 +- 错误信息: 任务级错误(如有) + +**权限**: +- 仅超级管理员/平台用户可查询导入任务详情 + +#### Scenario: 查询导入任务详情 + +- **WHEN** 平台管理员查询导入任务(ID 为 1)的详情 +- **THEN** 系统返回任务完整信息,包括跳过和失败记录的详细列表 + +#### Scenario: 非平台用户查询导入任务详情被拒绝 + +- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务详情 +- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden` diff --git a/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/tasks.md b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/tasks.md new file mode 100644 index 0000000..c3bc93d --- /dev/null +++ b/openspec/changes/archive/2026-02-02-restrict-iot-card-import-task-to-platform/tasks.md @@ -0,0 +1,18 @@ +## 1. 权限校验收敛(IoT 卡导入任务) + +- [x] 1.1 在 `internal/handler/admin/iot_card_import.go` 的 `Import`/`List`/`GetByID` 增加用户类型校验:仅 `UserTypeSuperAdmin`/`UserTypePlatform` 允许访问,其余返回 `CodeForbidden` +- [x] 1.2 校验错误消息文案与设备导入保持同风格(中文、明确动作),并确认不会泄露底层错误细节 + +## 2. 路由描述与文档一致性 + +- [x] 2.1 在 `internal/routes/iot_card.go` 为 `POST /iot-cards/import`、`GET /iot-cards/import-tasks`、`GET /iot-cards/import-tasks/:id` 补充 `Description: "仅平台用户可操作。"` + +## 3. 测试补齐 + +- [x] 3.1 在集成测试中新增用例:非平台用户(至少覆盖代理账号)访问上述 3 个接口应返回 403(Forbidden) +- [x] 3.2 运行并通过相关测试:`go test -v ./tests/integration/...`(如存在既有失败,需明确区分是否由本变更引入) + +## 4. 本地验证 + +- [x] 4.1 对修改过的 Go 文件执行 `lsp_diagnostics` 确保无新增错误/告警 +- [x] 4.2 运行 `go test ./...` 做一次全量回归验证(如耗时可接受) diff --git a/openspec/specs/iot-card-import-task/spec.md b/openspec/specs/iot-card-import-task/spec.md index 1a962ed..b56395f 100644 --- a/openspec/specs/iot-card-import-task/spec.md +++ b/openspec/specs/iot-card-import-task/spec.md @@ -81,6 +81,20 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose --- +### Requirement: 导入任务创建权限控制 + +系统 SHALL 仅允许超级管理员与平台用户创建 IoT 卡导入任务。 + +#### Scenario: 平台用户创建导入任务 +- **WHEN** 平台用户请求创建导入任务 +- **THEN** 系统创建导入任务并返回任务信息 + +#### Scenario: 非平台用户创建导入任务被拒绝 +- **WHEN** 非平台用户(代理账号/企业账号等)请求创建导入任务 +- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden` + +--- + ### Requirement: 导入任务列表查询 系统 SHALL 支持查询导入任务列表,用于管理和监控导入任务。 @@ -95,20 +109,24 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - 默认每页 20 条,最大每页 100 条 - 默认按创建时间倒序排列 -**数据权限**: -- 基于 shop_id 自动应用数据权限过滤 -- 代理只能看到自己店铺及下级店铺发起的导入任务 +**权限**: +- 仅超级管理员/平台用户可查询导入任务列表 #### Scenario: 查询所有导入任务 -- **WHEN** 管理员查询导入任务列表 +- **WHEN** 平台管理员查询导入任务列表 - **THEN** 系统返回导入任务列表,包含任务编号、状态、运营商、总数、成功数、跳过数、失败数、创建时间 #### Scenario: 按状态筛选导入任务 -- **WHEN** 管理员查询状态为 2(处理中) 的导入任务 +- **WHEN** 平台管理员查询状态为 2(处理中) 的导入任务 - **THEN** 系统返回所有正在处理的导入任务列表 +#### Scenario: 非平台用户查询导入任务列表被拒绝 + +- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务列表 +- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden` + --- ### Requirement: 导入任务详情查询 @@ -123,11 +141,19 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose - 失败记录详情: 行号、ICCID、原因 - 错误信息: 任务级错误(如有) +**权限**: +- 仅超级管理员/平台用户可查询导入任务详情 + #### Scenario: 查询导入任务详情 -- **WHEN** 管理员查询导入任务(ID 为 1)的详情 +- **WHEN** 平台管理员查询导入任务(ID 为 1)的详情 - **THEN** 系统返回任务完整信息,包括跳过和失败记录的详细列表 +#### Scenario: 非平台用户查询导入任务详情被拒绝 + +- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务详情 +- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden` + #### Scenario: 查询导入任务的跳过记录 - **WHEN** 管理员查询导入任务(ID 为 1)的跳过记录 diff --git a/tests/integration/iot_card_test.go b/tests/integration/iot_card_test.go index 7ba4e7a..4c78e7f 100644 --- a/tests/integration/iot_card_test.go +++ b/tests/integration/iot_card_test.go @@ -11,6 +11,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm" "github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/tests/testutils/integ" @@ -184,6 +185,72 @@ func TestIotCard_ImportTaskList(t *testing.T) { }) } +func TestIotCard_ImportTask_PlatformOnly(t *testing.T) { + env := integ.NewIntegrationTestEnv(t) + + shop := env.CreateTestShop("权限测试店铺", 1, nil) + agentAccount := env.CreateTestAccount(fmt.Sprintf("agent_perm_%d", time.Now().UnixNano()), "password123", constants.UserTypeAgent, &shop.ID, nil) + + task := &model.IotCardImportTask{ + TaskNo: fmt.Sprintf("TEST_PERM_%d", time.Now().UnixNano()), + Status: model.ImportTaskStatusCompleted, + CarrierID: 1, + CarrierType: "CMCC", + CarrierName: "中国移动", + TotalCount: 1, + } + require.NoError(t, env.TX.Create(task).Error) + + t.Run("代理账号提交导入任务应返回403", func(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "carrier_id": 1, + "batch_no": "TEST_BATCH_PERM", + "file_key": "imports/test.xlsx", + }) + + resp, err := env.AsUser(agentAccount).Request("POST", "/api/admin/iot-cards/import", body) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 403, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, pkgerrors.CodeForbidden, result.Code) + assert.Contains(t, result.Message, "仅平台用户") + }) + + t.Run("代理账号访问导入任务列表应返回403", func(t *testing.T) { + resp, err := env.AsUser(agentAccount).Request("GET", "/api/admin/iot-cards/import-tasks?page=1&page_size=20", nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 403, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, pkgerrors.CodeForbidden, result.Code) + assert.Contains(t, result.Message, "仅平台用户") + }) + + t.Run("代理账号访问导入任务详情应返回403", func(t *testing.T) { + url := fmt.Sprintf("/api/admin/iot-cards/import-tasks/%d", task.ID) + resp, err := env.AsUser(agentAccount).Request("GET", url, nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 403, resp.StatusCode) + + var result response.Response + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, pkgerrors.CodeForbidden, result.Code) + assert.Contains(t, result.Message, "仅平台用户") + }) +} + func TestIotCard_ImportE2E(t *testing.T) { t.Skip("E2E测试:需要 Worker 服务运行处理异步导入任务")