This commit is contained in:
Break
2026-06-02 16:56:18 +08:00
parent a6cd550b94
commit 36e68e6672
6 changed files with 269 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,67 @@
## Context
现有代理开放接口(`internal/service/agent_open_api/service.go`)已实现卡维度的流量查询、状态查询、实名查询和钱包购买。设备维度的 Gateway 操作(切网、重启、恢复出厂)已在 `internal/service/device/gateway_service.go` 中实现,设备级套餐流量查询通过 `PackageUsageStore.ListByCarrier(ctx, "device", deviceID, ...)` 支持。
本次变更在现有 `agent_open_api` service 中扩展设备维度能力,复用已有的 device service 和 package usage store不引入新的数据模型或迁移。
## Goals / Non-Goals
**Goals:**
- 新增 4 个代理开放接口:设备流量查询、切网、重启、恢复出厂
- 复用现有 device service 的 Gateway 调用逻辑,不重复实现
- 权限校验与卡接口保持一致(`shop_id IN SubordinateShopIDs`
- 设备标识符支持虚拟号和 IMEI
**Non-Goals:**
- 不新增数据库表或迁移
- 不修改现有卡接口
- 不支持 SN 作为设备标识符(开放接口只暴露虚拟号/IMEI
- 不支持批量设备操作
## Decisions
### 决策一agent_open_api service 注入 device.Service 依赖
**选择**:在 `agent_open_api.Service` 结构体中新增 `deviceService *device.Service` 字段,通过构造函数注入。
**理由**device service 已封装了 Gateway 调用、审计日志和设备查询逻辑,直接复用避免重复实现。相比新建独立函数,注入 service 更符合项目分层规范。
**替代方案**:在 agent_open_api service 中直接注入 deviceStore 和 gatewayClient自行实现权限校验和 Gateway 调用。缺点是重复了 device service 中已有的审计日志和错误处理逻辑。
### 决策二:设备权限校验方式
**选择**:查询设备后,手动检查 `device.ShopID != nil && SubordinateShopIDs 包含 *device.ShopID`
**理由**`ApplyShopFilter` 作用于 GORM query而 device service 的 `GetByIdentifier` 内部已有自己的查询逻辑。为避免侵入 device service在 agent_open_api service 层做显式权限校验,与卡接口的 `resolveOpenAPICard` 模式一致。
**实现**:新增 `resolveOpenAPIDevice(ctx, deviceNo)` 私有方法,复用 `device.Service.GetDeviceByIdentifier`,然后校验 shop_id。
### 决策三:设备流量查询复用 PackageUsageStore
**选择**:直接在 `agent_open_api.Service` 中调用已注入的 `packageUsageStore.ListByCarrier(ctx, "device", device.ID, &status)`
**理由**`packageUsageStore` 已在 agent_open_api service 中注入(用于卡流量查询),`ListByCarrier` 支持 `"device"` 类型,无需额外改动 store 层。
### 决策四:切网接口调用 device.Service.GatewaySwitchCard
**选择**agent_open_api service 调用 `deviceService.GatewaySwitchCard(ctx, identifier, &dto.SwitchCardRequest{ICCID: req.ICCID})`
**理由**`GatewaySwitchCard` 已包含审计日志记录,开放接口复用可保证操作可追溯。
**注意**`GatewaySwitchCard` 内部通过 `getGatewayDevice` 再次查询设备,存在两次查询。考虑到操作频率低,接受此开销,不做优化。
## Risks / Trade-offs
- **[风险] device service 审计日志中的操作者信息**`GatewaySwitchCard` 等方法通过 `middleware.GetUserIDFromContext` 获取操作者 ID。开放接口认证中间件已将代理账号 ID 写入 `ContextKeyUserID`,审计日志可正确记录操作者。→ 无需额外处理。
- **[风险] 设备标识符两次查询**`resolveOpenAPIDevice` 查一次,`GatewaySwitchCard` 内部再查一次。→ 接受,操作类接口频率低,影响可忽略。
- **[Trade-off] 不支持 SN**admin 接口支持虚拟号/IMEI/SN开放接口只支持虚拟号/IMEI。→ 简化外部接口SN 是内部管理标识,不适合对外暴露。
## Migration Plan
无数据库变更,无需迁移。直接部署新版本即可。
## Open Questions
(无)

View File

@@ -0,0 +1,29 @@
## Why
现有代理开放接口仅支持单卡维度的查询和操作,无法满足代理对设备维度的管理需求。代理需要通过开放接口查询设备套餐内流量、对多卡设备执行切网,以及对设备执行重启和恢复出厂操作。
## What Changes
- **新增** `GET /api/open/v1/devices/traffic` — 查询设备套餐内流量,返回结构与 `/cards/traffic` 一致
- **新增** `POST /api/open/v1/devices/switch-card` — 切网(多卡设备切换到指定 ICCID
- **新增** `POST /api/open/v1/devices/reboot` — 重启设备
- **新增** `POST /api/open/v1/devices/reset` — 恢复出厂设置
## Capabilities
### New Capabilities
- `open-api-device-traffic`: 代理开放接口设备套餐内流量查询,按设备标识符(虚拟号/IMEI查询设备级 PackageUsage返回生效/待生效套餐流量信息
- `open-api-device-operations`: 代理开放接口设备操作包括切网switch-card、重启reboot、恢复出厂reset复用 device service 现有 Gateway 调用,增加代理权限校验
### Modified Capabilities
(无现有 spec 级别的需求变更)
## Impact
- `internal/handler/openapi/handler.go` — 新增 4 个 Handler 方法
- `internal/routes/open.go` — 注册 4 条新路由
- `internal/service/agent_open_api/service.go` — 新增设备流量查询和设备操作业务逻辑,注入 device service 依赖
- `cmd/api/docs.go``cmd/gendocs/main.go` — 同步更新文档生成器
- 权限校验:`device.ShopID IN 代理管辖店铺`,与现有卡权限逻辑一致

View File

@@ -0,0 +1,87 @@
# open-api-device-operations Specification
## ADDED Requirements
### Requirement: 设备切网开放接口
系统 SHALL 提供 `POST /api/open/v1/devices/switch-card` 接口,允许代理对多卡设备执行切网操作(切换到指定 ICCID。请求 body MUST 包含 `device_no`(虚拟号或 IMEI`iccid`(目标卡 ICCID
系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内,否则返回 `CodeForbidden`。权限校验通过后,系统 MUST 复用 `device.Service.GatewaySwitchCard` 执行切网,底层调用 Gateway 接口。
响应 data MUST 为空对象(操作成功即可)。
#### Scenario: 切网成功
- **WHEN** 代理对管辖范围内的多卡设备传入有效目标 ICCID 执行切网
- **THEN** 系统调用 Gateway 切换设备到目标 ICCID返回成功
#### Scenario: 无权限设备切网
- **WHEN** 代理对不在自己管辖店铺范围内的设备执行切网
- **THEN** 系统返回 `CodeForbidden`,不调用 Gateway
#### Scenario: 设备标识符不存在
- **WHEN** 代理传入的 `device_no` 无法解析为任何设备
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"
---
### Requirement: 设备重启开放接口
系统 SHALL 提供 `POST /api/open/v1/devices/reboot` 接口,允许代理对设备执行重启操作。请求 body MUST 包含 `device_no`(虚拟号或 IMEI
系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内,否则返回 `CodeForbidden`。权限校验通过后,系统 MUST 复用 `device.Service.GatewayRebootDevice` 执行重启。
响应 data MUST 为空对象。
#### Scenario: 重启成功
- **WHEN** 代理对管辖范围内的设备执行重启
- **THEN** 系统调用 Gateway 重启设备,返回成功
#### Scenario: 无权限设备重启
- **WHEN** 代理对不在自己管辖店铺范围内的设备执行重启
- **THEN** 系统返回 `CodeForbidden`,不调用 Gateway
---
### Requirement: 设备恢复出厂开放接口
系统 SHALL 提供 `POST /api/open/v1/devices/reset` 接口,允许代理对设备执行恢复出厂设置操作。请求 body MUST 包含 `device_no`(虚拟号或 IMEI
系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内,否则返回 `CodeForbidden`。权限校验通过后,系统 MUST 复用 `device.Service.GatewayResetDevice` 执行恢复出厂。
响应 data MUST 为空对象。
#### Scenario: 恢复出厂成功
- **WHEN** 代理对管辖范围内的设备执行恢复出厂
- **THEN** 系统调用 Gateway 恢复设备出厂设置,返回成功
#### Scenario: 无权限设备恢复出厂
- **WHEN** 代理对不在自己管辖店铺范围内的设备执行恢复出厂
- **THEN** 系统返回 `CodeForbidden`,不调用 Gateway
---
### Requirement: 设备操作开放接口权限校验
系统 SHALL 对所有设备操作开放接口switch-card、reboot、reset统一执行权限校验。校验逻辑 MUST 为:通过设备标识符(虚拟号或 IMEI查询设备若设备不存在或 `device.ShopID` 不在当前代理的 `SubordinateShopIDs` 中,则返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在",不区分"不存在"与"无权限"以防止信息泄露。
#### Scenario: 设备属于代理管辖店铺
- **WHEN** 设备的 `shop_id` 在代理的 `SubordinateShopIDs`
- **THEN** 系统允许执行操作
#### Scenario: 设备不属于代理管辖店铺
- **WHEN** 设备的 `shop_id` 不在代理的 `SubordinateShopIDs`
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"
#### Scenario: 设备 shop_id 为 NULL平台库存
- **WHEN** 设备的 `shop_id` 为 NULL平台库存设备
- **THEN** 系统返回 `CodeForbidden`,代理无权操作平台库存设备

View File

@@ -0,0 +1,53 @@
# open-api-device-traffic Specification
## ADDED Requirements
### Requirement: 设备套餐内流量查询开放接口
系统 SHALL 提供 `GET /api/open/v1/devices/traffic` 接口,按设备标识符查询设备级套餐内流量。请求参数 `device_no` MUST 支持虚拟号或 IMEI 中任一种可解析为设备的标识。系统 MUST 校验该设备的 `shop_id` 在当前代理管辖店铺范围内(`device.ShopID IN SubordinateShopIDs`),否则返回 `CodeForbidden`
接口 MUST 只返回 `usage_type='device'` 的套餐使用记录,不返回单卡套餐信息。
响应 data MUST 包含:
- `device_no`:请求传入的设备标识符
- `active_total_flow_mb`:当前生效套餐总流量汇总
- `active_used_flow_mb`:当前生效套餐已用流量汇总
- `active_remaining_flow_mb`:当前生效套餐剩余流量汇总
- `active_expires_at`:当前生效套餐中最晚过期时间
- `active_packages`:当前生效套餐列表
- `pending_packages`:待生效套餐列表
流量口径 MUST 与 `/cards/traffic` 保持一致:
- 总流量使用 `data_limit_mb`
- 已用流量使用 `min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)`
- 剩余流量使用 `max(data_limit_mb - used_flow_mb, 0)`
- 响应 MUST NOT 包含虚流量、停机阈值等内部字段
`active_packages` 每项 MUST 包含:`package_code``total_flow_mb``used_flow_mb``remaining_flow_mb``expires_at``start_at``package_name``series_name``package_type``package_type_name`
`pending_packages` 每项 MUST 包含:`package_code``total_flow_mb``package_name``series_name``package_type``package_type_name``valid_days``priority`
#### Scenario: 查询有生效套餐的设备流量
- **WHEN** 代理查询自己管辖范围内的设备,且该设备存在生效中的设备级套餐
- **THEN** 系统返回生效套餐列表、总流量、已用流量、剩余流量和最晚过期时间
#### Scenario: 查询待生效套餐
- **WHEN** 代理查询设备流量,且该设备存在待生效的设备级套餐
- **THEN** 系统在 `pending_packages` 中返回套餐编码、真总流量、套餐名称、系列名称、套餐类型、有效天数和生效优先级
#### Scenario: 查询无套餐的设备
- **WHEN** 代理查询有权限但当前无生效或待生效设备级套餐的设备
- **THEN** 系统返回空套餐列表,流量数值为 0
#### Scenario: 无权限设备
- **WHEN** 代理查询不在自己管辖店铺范围内的设备
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"
#### Scenario: 设备标识符不存在
- **WHEN** 代理传入的 `device_no` 无法解析为任何设备
- **THEN** 系统返回 `CodeForbidden`,消息为"无权限操作该资源或资源不存在"

View File

@@ -0,0 +1,31 @@
## 1. DTO 定义
- [x] 1.1 在 `internal/model/dto/` 新增设备开放接口请求/响应 DTO`AgentOpenAPIDeviceQueryRequest`(含 `device_no` 字段)、`AgentOpenAPIDeviceSwitchCardRequest`(含 `device_no``iccid` 字段)、`AgentOpenAPIDeviceOperationRequest`(含 `device_no` 字段,用于 reboot/reset`AgentOpenAPIDeviceTrafficResponse`(结构与 `AgentOpenAPICardTrafficResponse` 一致,`card_no` 换成 `device_no`
## 2. Service 层
- [x] 2.1 在 `agent_open_api.Service` 结构体中新增 `deviceService *device.Service` 字段,更新 `New()` 构造函数签名,在 `internal/bootstrap/services.go``agentOpenAPISvc.New(...)` 调用处传入 `s.Device`
- [x] 2.2 在 `agent_open_api` service 中新增私有方法 `resolveOpenAPIDevice(ctx, deviceNo)`:调用 `deviceService.GetDeviceByIdentifier`,校验 `device.ShopID IN SubordinateShopIDs`,不存在或无权限统一返回 `CodeForbidden`
- [x] 2.3 实现 `GetDeviceTraffic(ctx, req)`:调用 `resolveOpenAPIDevice` 获取设备,通过 `packageUsageStore.ListByCarrier(ctx, "device", device.ID, &activeStatus)``ListByCarrier(ctx, "device", device.ID, &pendingStatus)` 查询套餐,复用 `loadUsagePackageContext``buildTrafficItem`,组装 `AgentOpenAPIDeviceTrafficResponse`
- [x] 2.4 实现 `SwitchDeviceCard(ctx, req)`:调用 `resolveOpenAPIDevice` 校验权限,再调用 `deviceService.GatewaySwitchCard(ctx, req.DeviceNo, &dto.SwitchCardRequest{ICCID: req.ICCID})`
- [x] 2.5 实现 `RebootDevice(ctx, req)`:调用 `resolveOpenAPIDevice` 校验权限,再调用 `deviceService.GatewayRebootDevice(ctx, req.DeviceNo)`
- [x] 2.6 实现 `ResetDevice(ctx, req)`:调用 `resolveOpenAPIDevice` 校验权限,再调用 `deviceService.GatewayResetDevice(ctx, req.DeviceNo)`
## 3. Handler 层
- [x] 3.1 在 `internal/handler/openapi/handler.go` 新增 `GetDeviceTraffic` Handler 方法GETQueryParser 解析 `AgentOpenAPIDeviceQueryRequest`
- [x] 3.2 新增 `SwitchDeviceCard` Handler 方法POSTBodyParser 解析 `AgentOpenAPIDeviceSwitchCardRequest`
- [x] 3.3 新增 `RebootDevice` Handler 方法POSTBodyParser 解析 `AgentOpenAPIDeviceOperationRequest`
- [x] 3.4 新增 `ResetDevice` Handler 方法POSTBodyParser 解析 `AgentOpenAPIDeviceOperationRequest`
## 4. 路由注册
- [x] 4.1 在 `internal/routes/open.go``RegisterOpenAPIRoutes` 中注册 4 条新路由:`GET /devices/traffic``POST /devices/switch-card``POST /devices/reboot``POST /devices/reset`,补充 Summary、Description含 authDescription、Input/Output、Tags、Auth、SecurityScheme
## 5. 文档生成器更新
- [x] 5.1 在 `cmd/api/docs.go``cmd/gendocs/main.go``bootstrap.Handlers{}` 初始化中,将 `AgentOpenAPI` 字段更新为 `openapiHandler.NewHandler(nil, nil)`(或对应的空值初始化),确保新增的 4 个 Handler 方法被文档生成器扫描到
## 6. 编译验证
- [x] 6.1 运行 `go build ./...` 确认无编译错误