feat(iot-card-import): 为导入任务接口添加平台用户权限控制
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m10s

- 在 Import/List/GetByID 接口添加用户类型校验
- 仅超级管理员和平台用户可访问
- 同步更新 OpenAPI 路由描述
- 补充集成测试覆盖权限拒绝场景
This commit is contained in:
2026-02-02 10:25:03 +08:00
parent d81bd242a4
commit a30b3036bb
9 changed files with 318 additions and 17 deletions

View File

@@ -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 {

View File

@@ -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{

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-02

View File

@@ -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
- **[风险] 规格与实现不一致** → **缓解**:在本变更的 specsdelta spec中明确将权限收敛为平台用户/超管,并在实现阶段对齐。
- **[风险] 测试缺口导致回归** → **缓解**:新增集成测试覆盖非平台用户访问 403并尽量复用现有测试基建集成测试 env
## Migration Plan
- 该变更为权限收敛,无数据迁移。
- 发布后影响:非平台用户/超管将无法调用 IoT 卡导入与导入任务查询接口。
- 回滚策略:如业务需要恢复原可见性,可回滚本变更提交(或通过后续变更重新放开)。
## Open Questions
- 是否需要将错误消息文案统一为同一句(如“仅平台用户可操作”),还是分别保留更具体的文案(导入/列表/详情)?(实现阶段可按现有 device_import 文案对齐)

View File

@@ -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`(或新增对应测试文件)

View File

@@ -0,0 +1,76 @@
# iot-card-import-task Specification (Delta)
本变更用于收敛 IoT 卡导入任务相关接口的访问权限:仅超级管理员/平台用户可访问。
## ADDED Requirements
### Requirement: 导入任务创建权限控制
系统 SHALL 仅允许超级管理员与平台用户创建 IoT 卡导入任务。
#### Scenario: 平台用户创建导入任务
- **WHEN** 平台用户请求创建导入任务
- **THEN** 系统创建导入任务并返回任务信息
#### Scenario: 非平台用户创建导入任务被拒绝
- **WHEN** 非平台用户(代理账号/企业账号等)请求创建导入任务
- **THEN** 系统返回 403Forbidden并返回统一错误码 `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** 系统返回 403Forbidden并返回统一错误码 `CodeForbidden`
### Requirement: 导入任务详情查询
系统 SHALL 支持查询单个导入任务的详细信息,包括跳过/失败记录详情。
**详情信息**:
- 任务基本信息: 任务编号、状态、运营商、批次号、文件名
- 进度统计: 总数、成功数、跳过数、失败数
- 时间信息: 创建时间、开始时间、完成时间
- 跳过记录详情: 行号、ICCID、原因
- 失败记录详情: 行号、ICCID、原因
- 错误信息: 任务级错误(如有)
**权限**:
- 仅超级管理员/平台用户可查询导入任务详情
#### Scenario: 查询导入任务详情
- **WHEN** 平台管理员查询导入任务(ID 为 1)的详情
- **THEN** 系统返回任务完整信息,包括跳过和失败记录的详细列表
#### Scenario: 非平台用户查询导入任务详情被拒绝
- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务详情
- **THEN** 系统返回 403Forbidden并返回统一错误码 `CodeForbidden`

View File

@@ -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 个接口应返回 403Forbidden
- [x] 3.2 运行并通过相关测试:`go test -v ./tests/integration/...`(如存在既有失败,需明确区分是否由本变更引入)
## 4. 本地验证
- [x] 4.1 对修改过的 Go 文件执行 `lsp_diagnostics` 确保无新增错误/告警
- [x] 4.2 运行 `go test ./...` 做一次全量回归验证(如耗时可接受)

View File

@@ -81,6 +81,20 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
---
### Requirement: 导入任务创建权限控制
系统 SHALL 仅允许超级管理员与平台用户创建 IoT 卡导入任务。
#### Scenario: 平台用户创建导入任务
- **WHEN** 平台用户请求创建导入任务
- **THEN** 系统创建导入任务并返回任务信息
#### Scenario: 非平台用户创建导入任务被拒绝
- **WHEN** 非平台用户(代理账号/企业账号等)请求创建导入任务
- **THEN** 系统返回 403Forbidden并返回统一错误码 `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** 系统返回 403Forbidden并返回统一错误码 `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** 系统返回 403Forbidden并返回统一错误码 `CodeForbidden`
#### Scenario: 查询导入任务的跳过记录
- **WHEN** 管理员查询导入任务(ID 为 1)的跳过记录

View File

@@ -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 服务运行处理异步导入任务")