diff --git a/openspec/changes/add-open-api-device-endpoints/.openspec.yaml b/openspec/changes/add-open-api-device-endpoints/.openspec.yaml new file mode 100644 index 0000000..a2168c3 --- /dev/null +++ b/openspec/changes/add-open-api-device-endpoints/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-01 diff --git a/openspec/changes/add-open-api-device-endpoints/design.md b/openspec/changes/add-open-api-device-endpoints/design.md new file mode 100644 index 0000000..009356a --- /dev/null +++ b/openspec/changes/add-open-api-device-endpoints/design.md @@ -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 + +(无) diff --git a/openspec/changes/add-open-api-device-endpoints/proposal.md b/openspec/changes/add-open-api-device-endpoints/proposal.md new file mode 100644 index 0000000..048f246 --- /dev/null +++ b/openspec/changes/add-open-api-device-endpoints/proposal.md @@ -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 代理管辖店铺`,与现有卡权限逻辑一致 diff --git a/openspec/changes/add-open-api-device-endpoints/specs/open-api-device-operations/spec.md b/openspec/changes/add-open-api-device-endpoints/specs/open-api-device-operations/spec.md new file mode 100644 index 0000000..cc0de2c --- /dev/null +++ b/openspec/changes/add-open-api-device-endpoints/specs/open-api-device-operations/spec.md @@ -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`,代理无权操作平台库存设备 diff --git a/openspec/changes/add-open-api-device-endpoints/specs/open-api-device-traffic/spec.md b/openspec/changes/add-open-api-device-endpoints/specs/open-api-device-traffic/spec.md new file mode 100644 index 0000000..83e1ca1 --- /dev/null +++ b/openspec/changes/add-open-api-device-endpoints/specs/open-api-device-traffic/spec.md @@ -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`,消息为"无权限操作该资源或资源不存在" diff --git a/openspec/changes/add-open-api-device-endpoints/tasks.md b/openspec/changes/add-open-api-device-endpoints/tasks.md new file mode 100644 index 0000000..0b495c4 --- /dev/null +++ b/openspec/changes/add-open-api-device-endpoints/tasks.md @@ -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 方法(GET,QueryParser 解析 `AgentOpenAPIDeviceQueryRequest`) +- [x] 3.2 新增 `SwitchDeviceCard` Handler 方法(POST,BodyParser 解析 `AgentOpenAPIDeviceSwitchCardRequest`) +- [x] 3.3 新增 `RebootDevice` Handler 方法(POST,BodyParser 解析 `AgentOpenAPIDeviceOperationRequest`) +- [x] 3.4 新增 `ResetDevice` Handler 方法(POST,BodyParser 解析 `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 ./...` 确认无编译错误