feat: 实现企业设备授权功能并归档 OpenSpec 变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m39s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m39s
- 新增企业设备授权模块(Model、DTO、Service、Handler、Store) - 实现设备授权的创建、查询、更新、删除等完整业务逻辑 - 添加企业卡授权与设备授权的关联关系 - 新增 2 个数据库迁移脚本 - 同步 OpenSpec delta specs 到 main specs - 归档 add-enterprise-device-authorization 变更 - 更新 API 文档和路由配置 - 新增完整的集成测试和单元测试覆盖
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
@@ -0,0 +1,181 @@
|
||||
## Context
|
||||
|
||||
当前系统支持"单卡授权企业用户"功能,通过 `tb_enterprise_card_authorization` 表记录卡与企业的授权关系。但现有实现存在以下问题:
|
||||
|
||||
1. **设备维度缺失**:企业用户只能看到卡列表,无法以设备为单位管理资产
|
||||
2. **逻辑不一致**:单卡授权入口支持 DeviceBundle(确认后授权设备下所有卡),但没有独立的设备授权概念
|
||||
3. **记录关联缺失**:无法追踪卡授权是通过单独授权还是设备授权创建的
|
||||
|
||||
现有相关模块:
|
||||
- `Device` 模型:设备表 `tb_device`,通过 `shop_id` 标识所有权
|
||||
- `DeviceSimBinding` 模型:设备-卡绑定关系表,一设备最多绑定 4 张卡
|
||||
- `EnterpriseCardAuthorization` 模型:卡授权表
|
||||
- 设备分销功能:`AllocateDevices` 将设备分销给代理店铺(修改 `shop_id`)
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 支持以设备为单位授权给企业,自动授权设备下所有已绑定的卡
|
||||
- 一个设备同一时间只能授权给一个企业(唯一性约束)
|
||||
- 授权设备时自动创建卡授权记录,回收时同步回收
|
||||
- 卡授权记录关联设备授权,支持追溯授权来源
|
||||
- 企业端可以查看设备列表、设备详情及其绑定的卡
|
||||
- 企业端可以对设备下的卡进行停机/复机操作
|
||||
- 单卡授权入口禁止授权已绑定设备的卡
|
||||
|
||||
**Non-Goals:**
|
||||
- 不涉及设备分销逻辑(设备 → 店铺,已有功能)
|
||||
- 不涉及设备级别的停机/复机(仍然是卡级别操作)
|
||||
- 不涉及设备解绑卡的功能(企业只能查看,不能解绑)
|
||||
- 不涉及设备钱包或套餐购买功能
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 新增独立的设备授权表
|
||||
|
||||
**决策**:创建 `tb_enterprise_device_authorization` 表,与卡授权表结构类似
|
||||
|
||||
**理由**:
|
||||
- 设备授权是独立的业务概念,需要独立的授权记录
|
||||
- 支持设备级别的授权/回收操作和记录查询
|
||||
- 与卡授权表解耦,职责清晰
|
||||
|
||||
**替代方案**:
|
||||
- ❌ 在卡授权表中添加 device_id 字段:无法表达"设备授权"这个独立概念,回收设备时逻辑复杂
|
||||
- ❌ 只用卡授权表+标记字段:无法追踪设备授权的元信息(授权人、时间等)
|
||||
|
||||
### 2. 卡授权表添加 device_auth_id 关联字段
|
||||
|
||||
**决策**:在 `tb_enterprise_card_authorization` 表添加 `device_auth_id` 字段
|
||||
|
||||
**理由**:
|
||||
- 明确区分卡授权来源:单卡授权(NULL)vs 设备授权(有值)
|
||||
- 回收设备授权时可以精确定位需要回收的卡授权记录
|
||||
- 支持查询"某设备授权下的所有卡"
|
||||
|
||||
**表结构变更**:
|
||||
```sql
|
||||
ALTER TABLE tb_enterprise_card_authorization
|
||||
ADD COLUMN device_auth_id BIGINT DEFAULT NULL;
|
||||
|
||||
CREATE INDEX idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id);
|
||||
```
|
||||
|
||||
### 3. 设备授权唯一性约束
|
||||
|
||||
**决策**:使用部分唯一索引保证一个设备同时只能授权给一个企业
|
||||
|
||||
**实现**:
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_active_device_auth
|
||||
ON tb_enterprise_device_authorization(device_id)
|
||||
WHERE revoked_at IS NULL AND deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 允许历史授权记录(已回收的)存在多条
|
||||
- 只限制"当前有效"的授权唯一性
|
||||
- 与卡授权的设计模式一致
|
||||
|
||||
### 4. 授权联动机制
|
||||
|
||||
**决策**:授权设备时在同一事务内创建设备授权和卡授权记录
|
||||
|
||||
**流程**:
|
||||
```
|
||||
授权设备 → 事务开始
|
||||
→ 创建 EnterpriseDeviceAuthorization
|
||||
→ 获取 device_auth_id
|
||||
→ 查询设备下所有已绑定的卡
|
||||
→ 批量创建 EnterpriseCardAuthorization (device_auth_id = 上一步的ID)
|
||||
→ 事务提交
|
||||
```
|
||||
|
||||
**回收流程**:
|
||||
```
|
||||
回收设备 → 事务开始
|
||||
→ 更新 EnterpriseDeviceAuthorization.revoked_at
|
||||
→ 批量更新关联的 EnterpriseCardAuthorization.revoked_at
|
||||
→ 事务提交
|
||||
```
|
||||
|
||||
### 5. 单卡授权入口修改
|
||||
|
||||
**决策**:移除 DeviceBundle 支持,禁止授权已绑定设备的卡
|
||||
|
||||
**修改点**:
|
||||
- `Service.AllocateCards`:移除 DeviceBundle 预检和处理逻辑
|
||||
- `Service.AllocateCardsPreview`:直接返回错误而非 DeviceBundle
|
||||
- DTO:移除 `DeviceBundle`、`ConfirmDeviceBundles` 等相关结构
|
||||
|
||||
**理由**:
|
||||
- 职责分离:单卡授权只处理独立卡,设备授权处理设备
|
||||
- 避免逻辑混淆:用户不会再在单卡入口看到设备相关提示
|
||||
- 简化代码:移除复杂的 DeviceBundle 处理逻辑
|
||||
|
||||
**BREAKING CHANGE**:前端单卡授权页面需要适配,不再支持确认设备包
|
||||
|
||||
### 6. API 路径设计
|
||||
|
||||
**后台管理(Admin)**:
|
||||
```
|
||||
POST /api/admin/enterprises/:id/allocate-devices # 授权设备
|
||||
POST /api/admin/enterprises/:id/recall-devices # 回收设备
|
||||
GET /api/admin/enterprises/:id/devices # 设备列表
|
||||
```
|
||||
|
||||
**企业端(H5)**:
|
||||
```
|
||||
GET /api/h5/enterprise/devices # 设备列表
|
||||
GET /api/h5/enterprise/devices/:device_id # 设备详情
|
||||
POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend # 停机
|
||||
POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume # 复机
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 与现有单卡授权 API 风格一致(`/enterprises/:id/allocate-cards`)
|
||||
- H5 端使用 `/enterprise/devices` 而非 `/enterprises/:id/devices`,因为企业用户只能访问自己的资源
|
||||
|
||||
### 7. 权限控制
|
||||
|
||||
**后台管理**:
|
||||
- 平台用户:可以授权任意设备给任意企业
|
||||
- 代理用户:只能授权自己店铺的设备给自己店铺下的企业
|
||||
|
||||
**企业端**:
|
||||
- 只能访问授权给自己企业的设备
|
||||
- 通过 GORM Callback 自动过滤
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### [风险] 单卡授权入口 Breaking Change
|
||||
**影响**:前端单卡授权页面行为变更,不再支持授权设备卡
|
||||
**缓解**:
|
||||
- 提前通知前端团队
|
||||
- 返回明确的错误信息引导用户使用设备授权入口
|
||||
- 可选:在错误响应中返回涉及的设备信息,方便前端跳转
|
||||
|
||||
### [风险] 数据迁移
|
||||
**影响**:如果现有数据中有通过单卡授权入口授权的设备卡,无法追溯来源
|
||||
**缓解**:
|
||||
- 新字段 `device_auth_id` 默认 NULL,兼容历史数据
|
||||
- 历史数据视为"单卡授权",行为不变
|
||||
- 无需数据迁移脚本
|
||||
|
||||
### [权衡] 回收粒度
|
||||
**选择**:回收设备授权时同步回收所有关联的卡授权
|
||||
**权衡**:不支持只回收设备授权但保留卡授权
|
||||
**理由**:简化业务逻辑,保持授权关系一致性
|
||||
|
||||
### [权衡] 设备新增卡后的处理
|
||||
**场景**:设备已授权给企业后,又绑定了新的卡
|
||||
**选择**:新卡不自动授权,需要重新授权设备或单独处理
|
||||
**理由**:避免隐式授权带来的安全风险,保持授权行为显式可控
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **设备授权记录管理页面**:是否需要独立的设备授权记录列表页面(类似现有的卡授权记录页面)?
|
||||
- 建议:本期先不做,通过企业设备列表满足基本需求
|
||||
|
||||
2. **批量操作限制**:单次授权/回收设备的数量上限?
|
||||
- 建议:与单卡授权一致,最多 100 个设备
|
||||
@@ -0,0 +1,53 @@
|
||||
## Why
|
||||
|
||||
企业用户目前只能管理被授权的单卡,但实际业务中设备(绑定 1-4 张卡)是更常见的授权单位。企业需要以设备为维度查看和管理被授权的资产,包括查看设备列表、设备详情及其绑定的卡,以及对卡进行停机/复机操作。这与"分销设备给代理"的模式类似,但目标是企业而非店铺。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增设备授权表**:`tb_enterprise_device_authorization`,记录设备与企业的授权关系
|
||||
- **修改卡授权表**:`tb_enterprise_card_authorization` 新增 `device_auth_id` 字段,关联设备授权记录
|
||||
- **新增设备授权 API**(后台):授权设备给企业、回收设备授权、企业设备列表
|
||||
- **新增企业端设备管理 API**(H5):设备列表、设备详情(含卡)、停机/复机
|
||||
- **修改单卡授权逻辑**:**BREAKING** 禁止通过单卡授权入口授权已绑定设备的卡,移除 DeviceBundle 支持
|
||||
- **授权联动**:授权设备时自动授权设备下所有已绑定的卡,回收时同步回收
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `enterprise-device-authorization`: 设备授权企业用户功能,包含设备授权/回收、设备授权记录管理、企业端设备列表和管理
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `enterprise-card-authorization`: 禁止授权已绑定设备的卡,移除 DeviceBundle 确认流程,强制使用设备授权入口
|
||||
|
||||
## Impact
|
||||
|
||||
**数据库**:
|
||||
- 新增表 `tb_enterprise_device_authorization`
|
||||
- 修改表 `tb_enterprise_card_authorization`(新增字段 + 索引)
|
||||
|
||||
**后台 API**:
|
||||
- 新增 `POST /api/admin/enterprises/:id/allocate-devices`
|
||||
- 新增 `POST /api/admin/enterprises/:id/recall-devices`
|
||||
- 新增 `GET /api/admin/enterprises/:id/devices`
|
||||
- 修改 `POST /api/admin/enterprises/:id/allocate-cards`(禁止设备卡)
|
||||
|
||||
**H5 API**:
|
||||
- 新增 `GET /api/h5/enterprise/devices`
|
||||
- 新增 `GET /api/h5/enterprise/devices/:device_id`
|
||||
- 新增 `POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend`
|
||||
- 新增 `POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume`
|
||||
|
||||
**代码模块**:
|
||||
- Model: 新增 `EnterpriseDeviceAuthorization`,修改 `EnterpriseCardAuthorization`
|
||||
- Store: 新增 `EnterpriseDeviceAuthorizationStore`
|
||||
- Service: 新增 `enterprise_device` 服务,修改 `enterprise_card` 服务
|
||||
- Handler: 新增 `admin/enterprise_device.go`,新增 `h5/enterprise_device.go`
|
||||
- Routes: 新增设备授权路由注册
|
||||
- DTO: 新增设备授权相关 DTO
|
||||
|
||||
**前端影响**:
|
||||
- 后台管理系统需要新增设备授权功能页面
|
||||
- 企业端 H5 需要新增设备列表和管理页面
|
||||
- 单卡授权页面行为变更(不再支持授权设备卡)
|
||||
@@ -0,0 +1,132 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 企业单卡授权管理
|
||||
|
||||
系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。
|
||||
|
||||
**授权规则**:
|
||||
- 代理只能授权自己的卡(owner_type="agent" 且 owner_id=自己的 shop_id)给自己的企业
|
||||
- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业
|
||||
- 支持批量授权(最多1000张卡)
|
||||
- **已绑定设备的卡不能通过单卡授权接口授权,MUST 使用设备授权接口**
|
||||
- 只能授权状态为 "已分销(2)" 的卡
|
||||
|
||||
**授权记录存储**:
|
||||
- 使用 `enterprise_card_authorization` 表记录授权关系
|
||||
- 通过单卡授权创建的记录 device_auth_id 为 NULL
|
||||
- 不使用 `asset_allocation_record` 表(该表用于分配,非授权)
|
||||
|
||||
**权限控制**:
|
||||
- 企业用户只能查看被授权的卡
|
||||
- 授权后卡的 shop_id 保持不变(所有权不转移)
|
||||
- 回收授权后企业立即失去访问权限
|
||||
|
||||
#### Scenario: 代理授权自己的卡给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己的未绑定设备的卡授权给企业(enterprise_id=5, owner_shop_id=10)
|
||||
- **THEN** 系统创建授权记录(device_auth_id=NULL),企业可以查看和管理该卡
|
||||
|
||||
#### Scenario: 平台授权任意卡给企业
|
||||
|
||||
- **WHEN** 平台管理员将未绑定设备的卡授权给企业
|
||||
- **THEN** 系统创建授权记录(device_auth_id=NULL),企业获得该卡的访问权限
|
||||
|
||||
#### Scenario: 代理无法授权其他代理的卡
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他代理的卡(owner_id=20)给企业
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 已绑定设备的卡不能通过单卡授权
|
||||
|
||||
- **WHEN** 用户尝试通过单卡授权接口授权已绑定到设备的卡
|
||||
- **THEN** 系统拒绝操作,返回错误码 CodeCannotAuthorizeBoundCard,提示"该卡已绑定设备,请使用设备授权功能"
|
||||
|
||||
#### Scenario: 只能授权已分销状态的卡
|
||||
|
||||
- **WHEN** 用户尝试授权非"已分销"状态的卡
|
||||
- **THEN** 系统拒绝操作,提示只能授权"已分销"状态的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业卡授权数据模型
|
||||
|
||||
系统 SHALL 定义 EnterpriseCardAuthorization 实体,记录企业卡授权关系。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 主键(BIGINT)
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,关联 enterprises 表)
|
||||
- `card_id`: IoT卡ID(BIGINT,关联 iot_cards 表)
|
||||
- `authorizer_id`: 授权人账号ID(BIGINT,关联 accounts 表)
|
||||
- `authorizer_type`: 授权人类型(SMALLINT,2=平台用户 3=代理账号)
|
||||
- `authorized_at`: 授权时间(TIMESTAMP)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `remark`: 备注(VARCHAR(500))
|
||||
- **`device_auth_id`: 关联的设备授权ID(BIGINT,可空)**
|
||||
- NULL = 通过单卡授权创建
|
||||
- 有值 = 通过设备授权创建
|
||||
- `created_at`: 创建时间(TIMESTAMP)
|
||||
- `updated_at`: 更新时间(TIMESTAMP)
|
||||
|
||||
**新增索引**:
|
||||
- `idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id)`
|
||||
|
||||
#### Scenario: 创建单卡授权记录
|
||||
|
||||
- **WHEN** 通过单卡授权接口授权卡给企业时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,device_auth_id 为 NULL
|
||||
|
||||
#### Scenario: 创建设备关联卡授权记录
|
||||
|
||||
- **WHEN** 通过设备授权创建卡授权记录时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,device_auth_id 指向对应的设备授权ID
|
||||
|
||||
#### Scenario: 回收授权
|
||||
|
||||
- **WHEN** 回收企业的卡授权时
|
||||
- **THEN** 系统更新对应记录的 revoked_at 和 revoked_by 字段,不删除记录(保留历史)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量授权接口
|
||||
|
||||
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/allocate-cards`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"iccids": ["8986001234567890", "8986001234567891"],
|
||||
"remark": "批量授权"
|
||||
}
|
||||
```
|
||||
- 响应:成功/失败的卡列表及原因
|
||||
|
||||
**处理流程**:
|
||||
1. 验证每张卡的授权权限
|
||||
2. 检查卡状态是否为"已分销"
|
||||
3. **检查卡是否已绑定设备,绑定设备的卡直接拒绝并返回错误**
|
||||
4. 检查是否已授权给该企业
|
||||
5. 创建授权记录(device_auth_id = NULL)
|
||||
6. 返回处理结果
|
||||
|
||||
**移除功能**:
|
||||
- ~~DeviceBundle 预检和确认流程~~(已移除)
|
||||
- ~~confirm_device_bundles 参数~~(已移除)
|
||||
- ~~AllocatedDevices 响应字段~~(已移除)
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张未绑定设备的卡给企业
|
||||
- **THEN** 系统创建 5 条授权记录(device_auth_id 均为 NULL),返回全部成功
|
||||
|
||||
#### Scenario: 批量授权遇到设备卡
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 2 张已绑定设备
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败,失败原因为"该卡已绑定设备,请使用设备授权功能"
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 1 张已绑定设备、1 张非已分销状态
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及各自失败原因
|
||||
@@ -0,0 +1,319 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备授权企业数据模型
|
||||
|
||||
系统 SHALL 定义 EnterpriseDeviceAuthorization 实体,记录设备与企业的授权关系。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 主键(BIGSERIAL)
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,NOT NULL)
|
||||
- `device_id`: 被授权设备ID(BIGINT,NOT NULL)
|
||||
- `authorized_by`: 授权人账号ID(BIGINT,NOT NULL)
|
||||
- `authorized_at`: 授权时间(TIMESTAMP,NOT NULL)
|
||||
- `authorizer_type`: 授权人类型(SMALLINT,2=平台用户 3=代理账号)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `remark`: 备注(VARCHAR(500))
|
||||
- `created_at`, `updated_at`, `deleted_at`: 标准时间字段
|
||||
|
||||
**唯一性约束**:
|
||||
- 一个设备同时只能授权给一个企业:`UNIQUE (device_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
|
||||
|
||||
**表名**:`tb_enterprise_device_authorization`
|
||||
|
||||
#### Scenario: 创建设备授权记录
|
||||
|
||||
- **WHEN** 授权设备给企业时
|
||||
- **THEN** 系统创建 EnterpriseDeviceAuthorization 记录,authorized_at 设置为当前时间,revoked_at 为 NULL
|
||||
|
||||
#### Scenario: 设备重复授权被拒绝
|
||||
|
||||
- **WHEN** 尝试将已授权给企业A的设备(未回收)再授权给企业B
|
||||
- **THEN** 系统拒绝操作,返回错误"设备已授权给其他企业"
|
||||
|
||||
#### Scenario: 回收后可重新授权
|
||||
|
||||
- **WHEN** 设备授权已被回收后,重新授权给同一企业或其他企业
|
||||
- **THEN** 系统允许创建新的授权记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡授权记录关联设备授权
|
||||
|
||||
系统 SHALL 在 EnterpriseCardAuthorization 表中添加 device_auth_id 字段,关联设备授权记录。
|
||||
|
||||
**新增字段**:
|
||||
- `device_auth_id`: 关联的设备授权ID(BIGINT,可空)
|
||||
- NULL = 通过单卡授权创建
|
||||
- 有值 = 通过设备授权创建
|
||||
|
||||
**索引**:
|
||||
- `idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id)`
|
||||
|
||||
#### Scenario: 设备授权创建关联卡授权
|
||||
|
||||
- **WHEN** 通过设备授权创建卡授权记录时
|
||||
- **THEN** 卡授权记录的 device_auth_id 字段设置为对应的设备授权ID
|
||||
|
||||
#### Scenario: 单卡授权不关联设备
|
||||
|
||||
- **WHEN** 通过单卡授权创建卡授权记录时
|
||||
- **THEN** 卡授权记录的 device_auth_id 字段为 NULL
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备授权管理功能
|
||||
|
||||
系统 SHALL 提供设备授权给企业的功能,支持批量授权和回收。
|
||||
|
||||
**授权规则**:
|
||||
- 代理只能授权自己店铺的设备给自己店铺下的企业
|
||||
- 平台可以授权任意设备给任意企业
|
||||
- 设备 MUST 属于操作者(平台或代理店铺)
|
||||
- 设备 MUST 处于"已分销"状态(status=2)
|
||||
- 设备 MUST 未授权给其他企业(唯一性约束)
|
||||
|
||||
**授权联动**:
|
||||
- 授权设备时,系统 SHALL 自动授权设备下所有已绑定的卡
|
||||
- 卡授权记录的 device_auth_id 指向设备授权记录
|
||||
- 如果设备没有绑定卡,仍然创建设备授权记录(无卡授权)
|
||||
|
||||
#### Scenario: 代理授权设备给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己店铺的设备授权给企业(owner_shop_id=10)
|
||||
- **THEN** 系统创建设备授权记录,并为设备下所有已绑定的卡创建卡授权记录
|
||||
|
||||
#### Scenario: 平台授权任意设备
|
||||
|
||||
- **WHEN** 平台管理员授权设备给任意企业
|
||||
- **THEN** 系统创建授权记录,不检查设备和企业的归属关系
|
||||
|
||||
#### Scenario: 代理无法授权其他店铺的设备
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他店铺的设备(shop_id=20)
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 设备授权联动卡授权
|
||||
|
||||
- **WHEN** 授权一个绑定了3张卡的设备给企业
|
||||
- **THEN** 系统创建1条设备授权记录和3条卡授权记录,所有卡授权的 device_auth_id 指向该设备授权
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量授权设备接口
|
||||
|
||||
系统 SHALL 提供批量授权设备给企业的后台接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/allocate-devices`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"device_nos": ["D001", "D002", "D003"],
|
||||
"remark": "批量授权备注"
|
||||
}
|
||||
```
|
||||
- 响应体:
|
||||
```json
|
||||
{
|
||||
"success_count": 2,
|
||||
"fail_count": 1,
|
||||
"failed_items": [
|
||||
{ "device_no": "D003", "reason": "设备不存在" }
|
||||
],
|
||||
"authorized_devices": [
|
||||
{ "device_id": 1, "device_no": "D001", "card_count": 3 },
|
||||
{ "device_id": 2, "device_no": "D002", "card_count": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**处理流程**:
|
||||
1. 验证企业存在且有权限
|
||||
2. 验证每个设备的授权权限
|
||||
3. 检查设备状态和唯一性约束
|
||||
4. 在事务内创建设备授权和卡授权记录
|
||||
5. 返回处理结果
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 平台批量授权3个符合条件的设备给企业
|
||||
- **THEN** 系统创建3条设备授权记录和对应的卡授权记录,返回全部成功
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权3个设备,其中1个已授权给其他企业
|
||||
- **THEN** 系统创建2条设备授权记录,返回2个成功、1个失败及失败原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备授权回收功能
|
||||
|
||||
系统 SHALL 提供回收设备授权的功能,回收时同步回收关联的卡授权。
|
||||
|
||||
**回收规则**:
|
||||
- 代理可以回收自己授权的设备
|
||||
- 平台可以回收任何设备授权
|
||||
- 回收操作在事务内完成
|
||||
|
||||
**回收联动**:
|
||||
- 回收设备授权时,系统 SHALL 同步回收所有 device_auth_id 指向该设备授权的卡授权记录
|
||||
- 更新 revoked_at 和 revoked_by 字段
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/recall-devices`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"device_nos": ["D001", "D002"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 回收设备授权联动回收卡授权
|
||||
|
||||
- **WHEN** 回收一个绑定了3张卡的设备的授权
|
||||
- **THEN** 系统更新设备授权的 revoked_at,同时更新3条关联卡授权的 revoked_at
|
||||
|
||||
#### Scenario: 回收后企业无法访问设备和卡
|
||||
|
||||
- **WHEN** 设备授权被回收后,企业用户查询设备或卡
|
||||
- **THEN** 系统不返回该设备和其下的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台企业设备列表
|
||||
|
||||
系统 SHALL 提供后台管理查询企业授权设备列表的接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/admin/enterprises/:id/devices`
|
||||
- 查询参数:`page`, `page_size`, `device_no`, `status`
|
||||
- 响应:设备列表,包含设备信息和绑定卡数量
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有企业的授权设备
|
||||
- 代理用户只能查看自己店铺下企业的授权设备
|
||||
|
||||
#### Scenario: 查询企业授权设备列表
|
||||
|
||||
- **WHEN** 管理员查询企业ID=5的授权设备
|
||||
- **THEN** 系统返回该企业所有授权设备列表,每个设备包含绑定卡数量
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备列表
|
||||
|
||||
系统 SHALL 提供企业用户查询自己授权设备列表的 H5 接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/h5/enterprise/devices`
|
||||
- 查询参数:`page`, `page_size`, `device_no`
|
||||
- 响应:
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"device_id": 1,
|
||||
"device_no": "D001",
|
||||
"device_name": "GPS追踪器-001",
|
||||
"device_model": "GT-100",
|
||||
"card_count": 3,
|
||||
"authorized_at": "2025-01-29T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 10
|
||||
}
|
||||
```
|
||||
|
||||
**数据权限**:
|
||||
- 企业用户只能看到授权给自己企业的设备
|
||||
- 通过 GORM Callback 自动过滤
|
||||
|
||||
#### Scenario: 企业用户查看设备列表
|
||||
|
||||
- **WHEN** 企业用户查询设备列表
|
||||
- **THEN** 系统返回授权给该企业的所有设备,包含设备信息和卡数量
|
||||
|
||||
#### Scenario: 企业用户无法看到未授权设备
|
||||
|
||||
- **WHEN** 企业用户查询设备列表
|
||||
- **THEN** 系统不返回未授权给该企业的设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备详情
|
||||
|
||||
系统 SHALL 提供企业用户查询设备详情的 H5 接口,包含设备绑定的卡列表。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/h5/enterprise/devices/:device_id`
|
||||
- 响应:
|
||||
```json
|
||||
{
|
||||
"device": {
|
||||
"device_id": 1,
|
||||
"device_no": "D001",
|
||||
"device_name": "GPS追踪器-001",
|
||||
"device_model": "GT-100",
|
||||
"device_type": "GPS",
|
||||
"authorized_at": "2025-01-29T10:00:00Z"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"card_id": 101,
|
||||
"iccid": "8986001234567890",
|
||||
"msisdn": "1380000001",
|
||||
"carrier_name": "中国联通",
|
||||
"network_status": 1,
|
||||
"network_status_name": "开机"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**可见信息**:
|
||||
- 设备基本信息:设备号、名称、型号、类型
|
||||
- 卡信息:ICCID、MSISDN、运营商、网络状态
|
||||
|
||||
**不可见信息**:
|
||||
- 成本价、分销价、供应商等商业敏感信息
|
||||
|
||||
#### Scenario: 企业用户查看设备详情
|
||||
|
||||
- **WHEN** 企业用户查看授权设备ID=1的详情
|
||||
- **THEN** 系统返回设备信息和该设备绑定的所有卡信息
|
||||
|
||||
#### Scenario: 企业用户无法查看未授权设备
|
||||
|
||||
- **WHEN** 企业用户尝试查看未授权的设备详情
|
||||
- **THEN** 系统返回 404 错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备卡停机复机
|
||||
|
||||
系统 SHALL 提供企业用户对设备下的卡进行停机/复机操作的 H5 接口。
|
||||
|
||||
**接口设计**:
|
||||
- 停机:`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend`
|
||||
- 复机:`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume`
|
||||
|
||||
**权限校验**:
|
||||
- 设备 MUST 授权给当前企业
|
||||
- 卡 MUST 属于该设备(通过 device_sim_binding 验证)
|
||||
- 卡 MUST 通过设备授权(device_auth_id 不为空且有效)
|
||||
|
||||
#### Scenario: 企业用户停机设备下的卡
|
||||
|
||||
- **WHEN** 企业用户对授权设备下的卡执行停机操作
|
||||
- **THEN** 系统更新卡的 network_status 为 0(停机)
|
||||
|
||||
#### Scenario: 企业用户复机设备下的卡
|
||||
|
||||
- **WHEN** 企业用户对授权设备下的卡执行复机操作
|
||||
- **THEN** 系统更新卡的 network_status 为 1(开机)
|
||||
|
||||
#### Scenario: 无法操作未授权设备的卡
|
||||
|
||||
- **WHEN** 企业用户尝试操作未授权设备下的卡
|
||||
- **THEN** 系统返回 403 错误
|
||||
@@ -0,0 +1,163 @@
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 创建 `tb_enterprise_device_authorization` 表迁移文件
|
||||
- 包含所有字段:enterprise_id, device_id, authorized_by, authorized_at, authorizer_type, revoked_by, revoked_at, remark
|
||||
- 添加部分唯一索引:`UNIQUE (device_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
|
||||
- 添加常规索引:idx_eda_enterprise, idx_eda_device, idx_eda_authorized_by
|
||||
|
||||
- [x] 1.2 创建 `tb_enterprise_card_authorization` 表修改迁移文件
|
||||
- 新增字段:device_auth_id(BIGINT,可空)
|
||||
- 添加索引:idx_eca_device_auth
|
||||
|
||||
- [x] 1.3 执行迁移并验证表结构正确
|
||||
|
||||
## 2. Model 层
|
||||
|
||||
- [x] 2.1 创建 `EnterpriseDeviceAuthorization` 模型
|
||||
- 文件路径:`internal/model/enterprise_device_authorization.go`
|
||||
- 包含所有字段和 TableName 方法
|
||||
- 遵循项目 GORM 模型规范
|
||||
|
||||
- [x] 2.2 修改 `EnterpriseCardAuthorization` 模型
|
||||
- 新增 `DeviceAuthID` 字段(`*uint`)
|
||||
- 添加 GORM 标签:`gorm:"column:device_auth_id;comment:关联的设备授权ID"`
|
||||
|
||||
## 3. Store 层
|
||||
|
||||
- [x] 3.1 创建 `EnterpriseDeviceAuthorizationStore`
|
||||
- 文件路径:`internal/store/postgres/enterprise_device_authorization_store.go`
|
||||
- 实现方法:Create, BatchCreate, GetByID, GetByDeviceID, GetByEnterpriseID
|
||||
- 实现方法:ListByEnterprise(分页、筛选), RevokeByIDs, GetActiveAuthsByDeviceIDs
|
||||
- 实现方法:ListDeviceIDsByEnterprise(获取企业授权的设备ID列表)
|
||||
|
||||
- [x] 3.2 修改 `EnterpriseCardAuthorizationStore`
|
||||
- 新增方法:RevokeByDeviceAuthID(根据设备授权ID批量回收卡授权)
|
||||
|
||||
## 4. Service 层 - 设备授权服务
|
||||
|
||||
- [x] 4.1 创建 `enterprise_device` 服务
|
||||
- 文件路径:`internal/service/enterprise_device/service.go`
|
||||
- 依赖注入:db, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger
|
||||
|
||||
- [x] 4.2 实现 `AllocateDevices` 方法(授权设备给企业)
|
||||
- 验证企业存在和权限
|
||||
- 验证每个设备的权限和状态
|
||||
- 检查唯一性约束(设备未授权给其他企业)
|
||||
- 在事务内创建设备授权和卡授权记录
|
||||
- 返回授权结果
|
||||
|
||||
- [x] 4.3 实现 `RecallDevices` 方法(回收设备授权)
|
||||
- 验证授权记录存在
|
||||
- 在事务内回收设备授权和关联的卡授权
|
||||
- 返回回收结果
|
||||
|
||||
- [x] 4.4 实现 `ListDevices` 方法(后台管理:企业设备列表)
|
||||
- 分页查询授权给企业的设备
|
||||
- 包含设备信息和绑定卡数量
|
||||
|
||||
- [x] 4.5 实现 `ListDevicesForEnterprise` 方法(H5:企业设备列表)
|
||||
- 企业用户查询自己的授权设备
|
||||
- 数据权限自动过滤
|
||||
|
||||
- [x] 4.6 实现 `GetDeviceDetail` 方法(H5:设备详情)
|
||||
- 查询设备信息和绑定的卡列表
|
||||
- 验证企业权限
|
||||
|
||||
- [x] 4.7 实现 `SuspendCard` 和 `ResumeCard` 方法(H5:停机/复机)
|
||||
- 验证设备和卡的授权关系
|
||||
- 更新卡的网络状态
|
||||
|
||||
## 5. Service 层 - 修改单卡授权服务
|
||||
|
||||
- [x] 5.1 修改 `enterprise_card/service.go` 的 `AllocateCardsPreview` 方法
|
||||
- 移除 DeviceBundle 处理逻辑
|
||||
- 绑定设备的卡直接加入 FailedItems,原因为"该卡已绑定设备,请使用设备授权功能"
|
||||
- 移除 DeviceBundles 响应字段
|
||||
|
||||
- [x] 5.2 修改 `enterprise_card/service.go` 的 `AllocateCards` 方法
|
||||
- 移除 DeviceBundle 确认流程(confirm_device_bundles 参数)
|
||||
- 移除 AllocatedDevices 响应字段
|
||||
- 绑定设备的卡直接拒绝
|
||||
|
||||
- [x] 5.3 清理相关 DTO
|
||||
- 移除或标记废弃:DeviceBundle, DeviceBundleCard, ConfirmDeviceBundles, AllocatedDevice 相关字段
|
||||
- 更新 AllocateCardsReq 和 AllocateCardsResp
|
||||
|
||||
## 6. Handler 层 - 后台管理
|
||||
|
||||
- [x] 6.1 创建 `admin/enterprise_device.go` Handler
|
||||
- AllocateDevices:授权设备给企业
|
||||
- RecallDevices:回收设备授权
|
||||
- ListDevices:企业设备列表
|
||||
|
||||
- [x] 6.2 注册后台路由
|
||||
- 文件路径:`internal/routes/enterprise_device.go`
|
||||
- POST /api/admin/enterprises/:id/allocate-devices
|
||||
- POST /api/admin/enterprises/:id/recall-devices
|
||||
- GET /api/admin/enterprises/:id/devices
|
||||
|
||||
- [x] 6.3 更新 Bootstrap 注册
|
||||
- 在 `internal/bootstrap/` 中注册新的 Store、Service、Handler
|
||||
|
||||
## 7. Handler 层 - 企业端 H5
|
||||
|
||||
- [x] 7.1 创建 `h5/enterprise_device.go` Handler
|
||||
- ListDevices:设备列表
|
||||
- GetDeviceDetail:设备详情
|
||||
- SuspendCard:停机卡
|
||||
- ResumeCard:复机卡
|
||||
|
||||
- [x] 7.2 注册 H5 路由
|
||||
- 文件路径:`internal/routes/h5/enterprise_device.go`
|
||||
- GET /api/h5/enterprise/devices
|
||||
- GET /api/h5/enterprise/devices/:device_id
|
||||
- POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend
|
||||
- POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume
|
||||
|
||||
## 8. DTO 层
|
||||
|
||||
- [x] 8.1 创建设备授权相关 DTO
|
||||
- 文件路径:`internal/model/dto/enterprise_device_authorization_dto.go`
|
||||
- AllocateDevicesReq / AllocateDevicesResp
|
||||
- RecallDevicesReq / RecallDevicesResp
|
||||
- EnterpriseDeviceListReq / EnterpriseDeviceListResp
|
||||
- EnterpriseDeviceDetailResp
|
||||
- DeviceCardSuspendReq / DeviceCardResumeReq
|
||||
|
||||
## 9. 错误码
|
||||
|
||||
- [x] 9.1 新增设备授权相关错误码
|
||||
- CodeDeviceAlreadyAuthorized:设备已授权给该企业
|
||||
- CodeDeviceNotAuthorized:设备未授权给该企业
|
||||
- CodeDeviceAuthorizedToOther:设备已授权给其他企业
|
||||
- CodeCannotAuthorizeOthersDevice:不能授权非自己的设备
|
||||
|
||||
## 10. 测试
|
||||
|
||||
- [x] 10.1 Store 层单元测试
|
||||
- EnterpriseDeviceAuthorizationStore 各方法测试
|
||||
- EnterpriseCardAuthorizationStore 新方法测试
|
||||
|
||||
- [x] 10.2 Service 层单元测试
|
||||
- enterprise_device 服务各方法测试
|
||||
- 权限验证测试
|
||||
- 授权联动测试
|
||||
- 测试覆盖率:88.9%
|
||||
|
||||
- [x] 10.3 修改 enterprise_card 服务测试
|
||||
- 验证绑定设备的卡被正确拒绝
|
||||
- 移除 DeviceBundle 相关测试
|
||||
|
||||
- [x] 10.4 集成测试
|
||||
- 完整授权/回收流程测试
|
||||
- 企业端 API 测试
|
||||
- 权限隔离测试
|
||||
|
||||
## 11. 文档更新
|
||||
|
||||
- [x] 11.1 更新 OpenAPI 文档生成器
|
||||
- 在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中注册新 Handler
|
||||
- 重新生成 OpenAPI 文档
|
||||
|
||||
- [x] 11.2 创建功能文档
|
||||
- 在 `docs/enterprise-device-authorization/` 目录下创建设备授权功能说明文档
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
@@ -0,0 +1,99 @@
|
||||
# 设计:RoleHandler 请求验证
|
||||
|
||||
## 上下文
|
||||
|
||||
项目中已有标准的请求验证模式:
|
||||
- 使用 `github.com/go-playground/validator/v10` 库
|
||||
- 在 bootstrap 层创建全局 validator 实例
|
||||
- Handler 构造函数接收 validator
|
||||
- 请求解析后立即调用 `validator.Struct()` 验证
|
||||
|
||||
AuthHandler 已正确实现此模式(参考 `internal/handler/admin/auth.go:34`),RoleHandler 需要遵循相同模式。
|
||||
|
||||
## 目标 / 非目标
|
||||
|
||||
**目标:**
|
||||
- RoleHandler 遵循项目验证标准模式
|
||||
- 所有请求在到达 Service 层前完成验证
|
||||
- 取消被跳过的集成测试
|
||||
|
||||
**非目标:**
|
||||
- 不修改 DTO 的 validate 标签(已正确定义)
|
||||
- 不改变错误响应格式(使用现有 CodeInvalidParam)
|
||||
- 不引入新的验证库或模式
|
||||
|
||||
## 决策
|
||||
|
||||
### 决策 1:遵循 AuthHandler 模式
|
||||
|
||||
**方法:** 完全复制 AuthHandler 的验证模式到 RoleHandler
|
||||
|
||||
**理由:**
|
||||
- 保持代码库一致性
|
||||
- AuthHandler 模式已验证有效
|
||||
- 无需重新设计验证流程
|
||||
|
||||
**实现细节:**
|
||||
```go
|
||||
// 1. RoleHandler 结构体添加 validator 字段
|
||||
type RoleHandler struct {
|
||||
service *roleService.Service
|
||||
validator *validator.Validate // 新增
|
||||
}
|
||||
|
||||
// 2. 构造函数接收 validator
|
||||
func NewRoleHandler(service *roleService.Service, validator *validator.Validate) *RoleHandler {
|
||||
return &RoleHandler{
|
||||
service: service,
|
||||
validator: validator,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create 方法中验证
|
||||
func (h *RoleHandler) Create(c *fiber.Ctx) error {
|
||||
var req dto.CreateRoleRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
// 新增验证逻辑
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
|
||||
}
|
||||
|
||||
// ... 现有逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 决策 2:验证所有接受 body 的方法
|
||||
|
||||
**需要验证的方法:**
|
||||
- `Create()` - CreateRoleRequest
|
||||
- `Update()` - UpdateRoleRequest
|
||||
- `AssignPermissions()` - AssignPermissionsRequest
|
||||
- `UpdateStatus()` - UpdateRoleStatusRequest
|
||||
|
||||
**不需要验证的方法:**
|
||||
- `Get()` - 只有路径参数,已有 ParseUint 检查
|
||||
- `List()` - Query 参数,已有 QueryParser
|
||||
- `GetPermissions()` - 只有路径参数
|
||||
- `RemovePermission()` - 只有路径参数
|
||||
- `Delete()` - 只有路径参数
|
||||
|
||||
### 决策 3:错误消息格式
|
||||
|
||||
**使用现有格式:**
|
||||
```go
|
||||
errors.New(errors.CodeInvalidParam, "参数验证失败: "+err.Error())
|
||||
```
|
||||
|
||||
**理由:**
|
||||
- 与 AuthHandler 保持一致
|
||||
- validator 库的错误消息已足够清晰
|
||||
- 前端可以解析错误码和消息
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 取消 `tests/integration/role_test.go:51` 的 TODO 跳过
|
||||
- 运行现有测试验证"缺少必填字段返回错误"场景
|
||||
- 无需添加新测试(现有被跳过的测试已覆盖验证逻辑)
|
||||
@@ -0,0 +1,27 @@
|
||||
# 提案:为 RoleHandler 添加请求验证
|
||||
|
||||
## 为什么
|
||||
|
||||
当前 RoleHandler 缺少请求参数验证,导致无效的请求可以通过 Handler 层传递到 Service 层。这违反了项目的"Handler 层负责参数验证"原则,并且导致集成测试被迫跳过(tests/integration/role_test.go:51,282)。
|
||||
|
||||
其他 Handler(如 AuthHandler)已经正确实现了验证,RoleHandler 需要遵循相同模式。
|
||||
|
||||
## 变更内容
|
||||
|
||||
- RoleHandler 将接收 validator 实例并验证所有请求参数
|
||||
- Create 和 Update 方法将调用 validator.Struct() 验证 DTO
|
||||
- 取消 role_test.go 中被跳过的验证测试
|
||||
|
||||
## 能力
|
||||
|
||||
### 新增能力
|
||||
- `role-request-validation`: RoleHandler 的所有请求(Create、Update、AssignPermissions、UpdateStatus)都将验证必填字段和格式
|
||||
|
||||
### 修改能力
|
||||
无(现有功能无变化,只是增强了输入验证)
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `internal/handler/admin/role.go`: 添加 validator 字段,调用验证方法
|
||||
- `internal/bootstrap/handlers.go`: 传递 validator 给 RoleHandler
|
||||
- `tests/integration/role_test.go`: 取消被跳过的测试
|
||||
@@ -0,0 +1,67 @@
|
||||
# 规格:角色请求验证
|
||||
|
||||
## 新增需求
|
||||
|
||||
### 需求:Create 方法验证必填字段
|
||||
|
||||
RoleHandler.Create 方法必须验证 CreateRoleRequest 的所有必填字段。
|
||||
|
||||
#### 场景:缺少 role_name 返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 POST /api/admin/roles 请求,body 缺少 role_name 字段
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
- **AND** 错误消息提示 "参数验证失败"
|
||||
|
||||
#### 场景:缺少 role_type 返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 POST /api/admin/roles 请求,body 缺少 role_type 字段
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
|
||||
#### 场景:role_name 过长返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 POST /api/admin/roles 请求,role_name 超过 50 个字符
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
|
||||
### 需求:Update 方法验证字段格式
|
||||
|
||||
RoleHandler.Update 方法必须验证 UpdateRoleRequest 的字段格式。
|
||||
|
||||
#### 场景:role_name 过长返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 PUT /api/admin/roles/:id 请求,role_name 超过 50 个字符
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
|
||||
#### 场景:status 值非法返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 PUT /api/admin/roles/:id 请求,status 值不是 0 或 1
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
|
||||
### 需求:AssignPermissions 方法验证权限ID列表
|
||||
|
||||
RoleHandler.AssignPermissions 方法必须验证权限ID列表不为空。
|
||||
|
||||
#### 场景:perm_ids 为空数组返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 POST /api/admin/roles/:id/permissions 请求,perm_ids 为空数组 []
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
|
||||
### 需求:UpdateStatus 方法验证状态值
|
||||
|
||||
RoleHandler.UpdateStatus 方法必须验证状态值在有效范围内。
|
||||
|
||||
#### 场景:status 值非法返回验证错误
|
||||
|
||||
- **WHEN** 客户端发送 PUT /api/admin/roles/:id/status 请求,status 值不是 0 或 1
|
||||
- **THEN** 返回 HTTP 400
|
||||
- **AND** 响应包含错误码(CodeInvalidParam)
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 取消 tests/integration/role_test.go 中的 TODO 跳过(第 51 行)
|
||||
- 验证测试能够通过,证明验证逻辑正常工作
|
||||
@@ -0,0 +1,30 @@
|
||||
# 实现任务
|
||||
|
||||
## 1. 修改 RoleHandler 结构
|
||||
|
||||
- [x] 1.1 在 RoleHandler 结构体中添加 `validator *validator.Validate` 字段
|
||||
- [x] 1.2 修改 NewRoleHandler 构造函数,接收 validator 参数
|
||||
- [x] 1.3 导入 `github.com/go-playground/validator/v10` 包
|
||||
|
||||
## 2. 添加验证逻辑
|
||||
|
||||
- [x] 2.1 Create 方法:BodyParser 后调用 validator.Struct(&req)
|
||||
- [x] 2.2 Update 方法:BodyParser 后调用 validator.Struct(&req)
|
||||
- [x] 2.3 AssignPermissions 方法:BodyParser 后调用 validator.Struct(&req)
|
||||
- [x] 2.4 UpdateStatus 方法:BodyParser 后调用 validator.Struct(&req)
|
||||
|
||||
## 3. 更新 Bootstrap
|
||||
|
||||
- [x] 3.1 修改 internal/bootstrap/handlers.go 中的 initHandlers 函数
|
||||
- [x] 3.2 传递 validate 参数给 NewRoleHandler:`admin.NewRoleHandler(svc.Role, validate)`
|
||||
|
||||
## 4. 取消测试跳过
|
||||
|
||||
- [x] 4.1 删除 tests/integration/role_test.go:51-62 的 TODO 跳过代码
|
||||
- [x] 4.2 取消注释被跳过的测试代码
|
||||
|
||||
## 5. 验证
|
||||
|
||||
- [x] 5.1 运行 `go test -v ./tests/integration/role_test.go` 确保测试通过
|
||||
- [x] 5.2 运行 LSP diagnostics 检查 internal/handler/admin/role.go
|
||||
- [x] 5.3 确认没有编译错误
|
||||
@@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 授权记录备注修改权限修复 - 设计
|
||||
|
||||
## 目标
|
||||
|
||||
1. 代理用户无法修改非本人创建的授权记录备注。
|
||||
2. 企业用户无法修改任何授权记录备注。
|
||||
3. 平台/超级管理员可修改任意授权记录备注。
|
||||
4. 任何情况下都必须满足数据可见性(代理只能在自己店铺企业范围内操作)。
|
||||
|
||||
## 现状与风险点
|
||||
|
||||
- 备注更新当前仅按 `id` 更新,缺少“创建者/可见性”约束。
|
||||
- 现有数据权限 callback 主要作用于 Query,不覆盖 Update,因此必须在业务链路显式校验。
|
||||
|
||||
## 方案
|
||||
|
||||
### 1) Service 层统一鉴权
|
||||
|
||||
在 `AuthorizationService.UpdateRecordRemark` 内新增权限判断:
|
||||
|
||||
- 取当前用户信息(user_id/user_type/shop_id/enterprise_id)
|
||||
- 先通过 `GetByIDWithJoin` 获取授权记录详情(包含 `authorized_by`、`enterprise_id` 等)
|
||||
- 按规则判断:
|
||||
- 平台/超级管理员:允许
|
||||
- 代理:
|
||||
- 必须 `record.AuthorizedBy == 当前 user_id`
|
||||
- 且授权记录对应企业必须属于当前店铺(`enterprise.owner_shop_id == shop_id`,可通过 join 查询或使用现有原生 SQL 结果)
|
||||
- 企业:直接拒绝
|
||||
|
||||
### 2) Store 层更新增加约束(防御性)
|
||||
|
||||
提供一个“带约束”的更新方法(示例语义):
|
||||
- 平台路径:`UpdateRemarkByID(id, remark)`
|
||||
- 代理路径:`UpdateRemarkByIDAndAuthorizedBy(id, remark, userID)`(必要时再加 enterprise 范围约束)
|
||||
|
||||
确保即使上层遗漏判断,也难以越权更新成功。
|
||||
|
||||
### 3) 错误与返回
|
||||
|
||||
- 无权限:返回统一错误码(例如 `CodeForbidden`),错误信息使用中文并可被前端直接展示。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 平台用户可修改任意授权记录备注。
|
||||
- 代理用户仅可修改自己创建的授权记录备注;修改他人创建的记录必须失败。
|
||||
- 企业用户调用修改备注接口必须失败。
|
||||
- 新增/更新用例后相关集成测试通过。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 授权记录备注修改权限修复
|
||||
|
||||
## Why
|
||||
|
||||
当前“授权记录备注修改”链路缺少明确的权限边界校验:代理用户可能通过接口修改不属于自己创建的授权记录备注;企业用户也需要被明确禁止修改。
|
||||
|
||||
该问题会导致越权修改、审计信息失真,属于高风险权限缺陷。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **权限规则落地**:
|
||||
- 平台/超级管理员:可修改任意授权记录备注
|
||||
- 代理:仅可修改“自己创建的授权记录”的备注(且必须在其可见数据范围内)
|
||||
- 企业:禁止修改授权记录备注
|
||||
- **服务端强校验**:在 Service 层统一做权限判断与可见性校验,Store 层更新语句增加必要约束,避免仅凭 `id` 更新造成越权。
|
||||
- **补充测试**:新增集成测试覆盖平台/代理/企业三种用户场景,确保规则稳定。
|
||||
|
||||
## Impact
|
||||
|
||||
涉及文件(预期):
|
||||
- Handler:`internal/handler/admin/authorization.go`
|
||||
- Service:`internal/service/enterprise_card/authorization_service.go`
|
||||
- Store:`internal/store/postgres/enterprise_card_authorization_store.go`
|
||||
- 测试:`tests/integration/authorization_test.go`(或新增对应用例文件)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 授权记录备注修改权限修复 - 实现任务
|
||||
|
||||
## 1. 权限规则实现
|
||||
|
||||
- [ ] 1.1 在 `internal/service/enterprise_card/authorization_service.go` 中为 `UpdateRecordRemark` 增加权限校验:平台全量、代理仅本人创建、企业禁止
|
||||
- [ ] 1.2 在 `internal/store/postgres/enterprise_card_authorization_store.go` 增加带约束的更新方法(至少支持 `id + authorized_by` 约束)
|
||||
- [ ] 1.3 更新 `internal/handler/admin/authorization.go`:将权限失败场景返回统一错误(中文错误消息)
|
||||
|
||||
## 2. 测试
|
||||
|
||||
- [ ] 2.1 为平台用户新增集成测试:可修改任意授权记录备注
|
||||
- [ ] 2.2 为代理用户新增集成测试:可修改本人创建记录、不可修改他人创建记录
|
||||
- [ ] 2.3 为企业用户新增集成测试:调用修改备注接口必须失败
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [ ] 3.1 运行 `go test ./...` 确保通过
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 佣金计算链路修复 - 设计
|
||||
|
||||
## 目标
|
||||
|
||||
1. 订单创建时就写入后续佣金计算所需的关键字段快照。
|
||||
2. 订单支付成功后(首次成功支付)自动 enqueue 佣金计算异步任务。
|
||||
3. 佣金计算具备可重复执行的幂等语义(订单佣金已计算则跳过)。
|
||||
|
||||
## 关键字段来源(你已确认)
|
||||
|
||||
以购买校验结果为准:
|
||||
- `allocation`:来自 `PurchaseValidationResult.Allocation`
|
||||
- `SeriesID`:`allocation.SeriesID`
|
||||
- `SellerShopID`:`allocation.ShopID`(该分配记录对应的“售卖/收益归属店铺”)
|
||||
- `SellerCostPrice`:根据 allocation 的基础返佣规则从订单金额推导(与成本价差计算一致的口径)
|
||||
|
||||
说明:这里的 SellerCostPrice 是为了支持“成本价差佣金”计算,作为链路上的稳定快照,避免后续配置变更影响历史订单。
|
||||
|
||||
## 支付成功后触发佣金计算
|
||||
|
||||
- 触发点:订单支付成功且为首次成功支付(由“订单激活幂等提案”提供门闸)。
|
||||
- 触发动作:enqueue `commission:calculate`,payload 为 `order_id`。
|
||||
- 入队失败策略:
|
||||
- 不回滚支付成功(避免影响主链路)
|
||||
- 保持 `commission_status = pending`,允许后续重试(例如后台补偿任务/人工触发/定时任务扫描)
|
||||
|
||||
## 事务一致性(可选)
|
||||
|
||||
当前佣金计算服务使用了 `Transaction` 包裹,但内部 Store 若未使用同一个 `tx`,一致性会被破坏。
|
||||
|
||||
推荐方案之一:
|
||||
- 为各 Store 增加 `WithDB(tx)` 或在方法中接收 `db *gorm.DB` 参数,确保写入走同一个事务 `tx`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 创建订单后,订单表中 `series_id/seller_shop_id/seller_cost_price` 等字段正确写入。
|
||||
- 首次支付成功后,会 enqueue 佣金计算任务(可通过日志/测试验证)。
|
||||
- 佣金任务重复执行不重复发放(已计算则跳过)。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 佣金计算链路修复:支付后自动入队 + 订单佣金字段快照
|
||||
|
||||
## Why
|
||||
|
||||
你确认的目标:**订单支付成功后自动触发佣金计算(异步任务)**,且佣金计算所需的关键字段应当来源于“购买校验结果”。
|
||||
|
||||
当前实现存在以下风险:
|
||||
- 佣金计算依赖订单字段(如 `series_id/seller_shop_id/seller_cost_price`),但订单创建时未填充,可能导致计算错误或空指针风险。
|
||||
- 佣金计算任务已定义,但缺少稳定触发入口,导致支付后佣金不计算或需要人工补偿。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **订单创建时写入佣金快照字段**:基于购买校验结果(allocation/series)填充订单的 `SeriesID/SellerShopID/SellerCostPrice` 等字段,确保后续计算稳定。
|
||||
- **支付成功后自动入队佣金计算任务**:在订单从待支付变为已支付的“首次成功支付”场景,enqueue `commission:calculate` 异步任务,执行佣金计算。
|
||||
- **计算事务一致性(可选但推荐)**:调整佣金计算服务的事务使用方式,确保“佣金记录 + 钱包入账 + 订单佣金状态更新”具备一致性。
|
||||
- **补充测试**:新增/完善测试,避免回归。
|
||||
|
||||
## Impact
|
||||
|
||||
涉及模块(预期):
|
||||
- 订单创建:`internal/service/order/service.go`
|
||||
- 异步任务:`internal/task/commission_calculation.go`(触发入口)与队列注入
|
||||
- 佣金计算:`internal/service/commission_calculation/service.go`
|
||||
- 测试:`internal/service/order/service_test.go`、`internal/task/*` 或集成测试
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 佣金计算链路修复 - 实现任务
|
||||
|
||||
## 1. 订单佣金字段快照
|
||||
|
||||
- [ ] 1.1 在 `internal/service/order/service.go` 创建订单时,从购买校验结果填充 `SeriesID/SellerShopID/SellerCostPrice`
|
||||
- [ ] 1.2 补充/复用“成本价推导”工具方法,确保口径与佣金计算一致
|
||||
|
||||
## 2. 支付成功后自动入队
|
||||
|
||||
- [ ] 2.1 在首次支付成功路径 enqueue `commission:calculate`(payload: order_id)
|
||||
- [ ] 2.2 注入队列客户端到订单服务(遵循现有 bootstrap 依赖注入方式)
|
||||
- [ ] 2.3 明确入队失败策略:记录日志,订单保持 `commission_status=pending` 可重试
|
||||
|
||||
## 3. 佣金计算一致性与健壮性(可选但推荐)
|
||||
|
||||
- [ ] 3.1 调整 `internal/service/commission_calculation/service.go`,确保事务内对佣金记录/钱包/订单状态更新使用同一 `tx`
|
||||
- [ ] 3.2 增加必要的空值保护:缺少关键字段时返回业务错误而非 panic
|
||||
|
||||
## 4. 测试与验证
|
||||
|
||||
- [ ] 4.1 新增单元测试:订单创建后佣金快照字段写入正确
|
||||
- [ ] 4.2 新增单元/集成测试:支付成功后会 enqueue 佣金计算任务(可通过可注入的队列 client 验证)
|
||||
- [ ] 4.3 运行 `go test ./...` 确保通过
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 一次性佣金修复 - 设计
|
||||
|
||||
## 目标
|
||||
|
||||
1. 通过 `ShopSeriesAllocation` 创建/更新接口即可配置一次性佣金并生效(你确认 B=1)。
|
||||
2. “累计充值触发”场景每次支付成功都累加保存,达到阈值触发一次性佣金发放。
|
||||
3. 发放具备幂等:同一资源(卡/设备)只发放一次。
|
||||
|
||||
## 1) 配置落库
|
||||
|
||||
### 固定类型(fixed)
|
||||
|
||||
在 `tb_shop_series_allocation` 写入:
|
||||
- enable_one_time_commission
|
||||
- one_time_commission_type = fixed
|
||||
- one_time_commission_trigger
|
||||
- one_time_commission_threshold
|
||||
- one_time_commission_mode(fixed/percent)
|
||||
- one_time_commission_value
|
||||
|
||||
### 梯度类型(tiered)
|
||||
|
||||
在 `tb_shop_series_allocation` 写入:
|
||||
- enable_one_time_commission
|
||||
- one_time_commission_type = tiered
|
||||
- one_time_commission_trigger
|
||||
- one_time_commission_threshold
|
||||
|
||||
并在 `tb_shop_series_one_time_commission_tier` 维护档位:
|
||||
- allocation_id
|
||||
- tier_type(sales_count/sales_amount)
|
||||
- threshold_value
|
||||
- commission_mode(fixed/percent)
|
||||
- commission_value
|
||||
|
||||
更新策略建议:
|
||||
- 更新配置时:先删除 allocation_id 对应的旧 tiers,再批量插入新 tiers(实现简单且可控)
|
||||
|
||||
## 2) 累计触发逻辑
|
||||
|
||||
针对 `accumulated_recharge`:
|
||||
- 每次支付成功都更新 `AccumulatedRecharge += orderAmount`
|
||||
- 若累计达到阈值且未发放过:
|
||||
- 计算佣金金额
|
||||
- 创建佣金记录并入账
|
||||
- 标记 `FirstCommissionPaid = true`
|
||||
|
||||
注意:累计的更新应当以“支付成功”为准,避免未支付订单污染累计值。
|
||||
|
||||
## 3) 测试
|
||||
|
||||
- 配置落库测试:创建/更新分配后,查询数据库字段与 tiers 表是否一致
|
||||
- 累计触发测试:模拟多次支付累计到阈值,验证只发放一次且累计值递增
|
||||
- 修复现有单测字段不匹配导致的编译失败
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 创建/更新 `ShopSeriesAllocation` 时,一次性佣金配置能正确落库并在查询响应中返回。
|
||||
- 累计触发场景下,多次支付能累加并在达到阈值时发放一次性佣金;之后不重复发放。
|
||||
- `go test ./...` 通过。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 一次性佣金修复:配置可落库 + 累计触发可累加 + 单测修复
|
||||
|
||||
## Why
|
||||
|
||||
一次性佣金能力已引入配置字段与计算逻辑,但目前存在两类关键问题:
|
||||
|
||||
1. **配置无法生效**:你确认希望通过 `ShopSeriesAllocation` 的创建/更新接口(B=1)直接配置一次性佣金并落库生效;否则会出现“接口看起来支持配置,但实际不生效”的问题。
|
||||
2. **累计触发无法累加**:你确认累计规则为“每次购买都要累加,达到阈值就发放佣金”;如果不写回累计值,阈值永远达不到。
|
||||
|
||||
此外,相关单测目前编译失败,需要修复以保证回归可控。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **一次性佣金配置落库**:
|
||||
- 在 `ShopSeriesAllocation` 创建/更新时写入一次性佣金相关字段
|
||||
- 若类型为梯度(tiered),同步维护 `tb_shop_series_one_time_commission_tier` 档位数据(更新时先清理再重建或做差量更新)
|
||||
- **累计触发逻辑修复**:
|
||||
- 对“累计充值触发”场景,每次支付成功都写回累计金额
|
||||
- 达到阈值的那次发放一次性佣金,并标记 `FirstCommissionPaid` 防止重复发放
|
||||
- **测试修复与补充**:
|
||||
- 修复 `tests/unit/my_commission_service_test.go` 字段不匹配导致的编译失败
|
||||
- 新增测试覆盖:配置落库与累计逻辑
|
||||
|
||||
## Impact
|
||||
|
||||
涉及模块(预期):
|
||||
- 分配配置:`internal/service/shop_series_allocation/service.go`、`internal/store/postgres/shop_series_one_time_commission_tier_store.go`
|
||||
- 佣金计算:`internal/service/commission_calculation/service.go`
|
||||
- 测试:`tests/unit/my_commission_service_test.go` 及新增用例
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 一次性佣金修复 - 实现任务
|
||||
|
||||
## 1. 配置落库(ShopSeriesAllocation)
|
||||
|
||||
- [ ] 1.1 更新 `internal/service/shop_series_allocation/service.go`:在创建分配时处理 `EnableOneTimeCommission/OneTimeCommissionConfig` 并落库
|
||||
- [ ] 1.2 更新 `internal/service/shop_series_allocation/service.go`:在更新分配时支持更新一次性佣金配置并落库
|
||||
- [ ] 1.3 梯度配置:使用 `ShopSeriesOneTimeCommissionTierStore` 在创建/更新时写入 tiers(更新时先清理再重建)
|
||||
- [ ] 1.4 参数校验:启用一次性佣金时必须提供配置;fixed 必须有 mode/value;tiered 必须有 tiers
|
||||
|
||||
## 2. 累计触发逻辑修复
|
||||
|
||||
- [ ] 2.1 更新 `internal/service/commission_calculation/service.go`:累计触发场景每次支付成功都写回累计金额
|
||||
- [ ] 2.2 达到阈值时仅发放一次,发放后标记 `FirstCommissionPaid=true`
|
||||
|
||||
## 3. 测试修复与补充
|
||||
|
||||
- [ ] 3.1 修复 `tests/unit/my_commission_service_test.go`:将 `CommissionType` 调整为 `CommissionSource`
|
||||
- [ ] 3.2 新增测试:一次性佣金配置落库(含 tiered tiers 落库)
|
||||
- [ ] 3.3 新增测试:累计触发多次支付后达到阈值触发一次性佣金且不重复
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- [ ] 4.1 运行 `go test ./...` 确保通过
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-29
|
||||
|
||||
40
openspec/changes/fix-order-activation-idempotency/design.md
Normal file
40
openspec/changes/fix-order-activation-idempotency/design.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 订单激活幂等性修复 - 设计
|
||||
|
||||
## 目标
|
||||
|
||||
1. 同一订单被重复支付/重复回调/重复请求时,只会激活一次套餐使用记录。
|
||||
2. 重复请求返回“幂等成功”(不报错,不重复激活)。
|
||||
3. 避免因并发导致的重复插入。
|
||||
|
||||
## 方案
|
||||
|
||||
### 1) 以状态机作为幂等门闸
|
||||
|
||||
将订单支付状态从 `pending` 变更为 `paid` 时使用条件更新:
|
||||
|
||||
- `UPDATE tb_order SET payment_status=paid,... WHERE id=? AND payment_status=pending`
|
||||
- 若 `RowsAffected == 0`:
|
||||
- 视为订单已被处理(可能已支付/已取消/已退款)
|
||||
- 对“已支付”场景直接返回成功(幂等成功)
|
||||
- 对“非待支付且非已支付”场景返回对应业务错误(例如已取消不允许支付)
|
||||
|
||||
这样可以确保并发下只有一个请求能“拿到激活资格”。
|
||||
|
||||
### 2) 激活逻辑只在首次成功支付后执行
|
||||
|
||||
`activatePackage` 只在上述条件更新成功后执行;并确保激活过程内的数据读取使用同一个事务 `tx`(避免出现读取不一致或部分写入)。
|
||||
|
||||
### 3) 防御性约束(可选但推荐)
|
||||
|
||||
为 `tb_package_usage` 增加唯一约束(示例):
|
||||
- 同一订单下,同一 `package_id` 只能有一条 usage
|
||||
- 以 `order_id + package_id` 为主(按当前业务:一个订单对应一个资源,且一次购买不应重复同套餐)
|
||||
|
||||
如果未来允许同订单同套餐多份购买,则需要同时引入 `quantity` 或 usage 的明细拆分策略,再调整唯一约束。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 重复调用钱包支付/支付回调接口,不会重复生成 `tb_package_usage` 记录。
|
||||
- 幂等重复请求返回成功。
|
||||
- 新增测试通过。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 订单激活幂等性修复(同订单只激活一次)
|
||||
|
||||
## Why
|
||||
|
||||
业务规则确认:**同一个订单只能激活一次**,不允许重复生成套餐生效记录。
|
||||
|
||||
当前订单支付成功后会生成 `PackageUsage`(套餐使用记录)。在并发请求、回调重放、网络重试等场景下,如果缺少幂等控制,可能重复插入套餐使用记录,导致用户重复获得权益,属于高风险资金/权益漏洞。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **支付状态原子转换**:将“待支付 -> 已支付”的状态变更做成原子操作(带条件更新),只有第一次成功转换才会触发套餐激活。
|
||||
- **激活过程幂等**:`activatePackage` 只在“首次支付成功”场景执行;重复请求返回“幂等成功”(你确认 A=1)。
|
||||
- **可选防御性约束**:为 `tb_package_usage` 增加必要的唯一约束或幂等检查,防止异常路径插入重复记录。
|
||||
- **补充测试**:覆盖并发/重复调用场景,验证不会重复激活。
|
||||
|
||||
## Impact
|
||||
|
||||
涉及模块(预期):
|
||||
- Service:`internal/service/order/service.go`
|
||||
- Store(可选):`internal/store/postgres/order_store.go` / `internal/store/postgres/order_item_store.go`
|
||||
- 迁移(可选):新增唯一索引
|
||||
- 测试:`internal/service/order/service_test.go` 或 `tests/integration/*`
|
||||
|
||||
22
openspec/changes/fix-order-activation-idempotency/tasks.md
Normal file
22
openspec/changes/fix-order-activation-idempotency/tasks.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 订单激活幂等性修复 - 实现任务
|
||||
|
||||
## 1. 状态原子转换
|
||||
|
||||
- [ ] 1.1 在 `internal/service/order/service.go` 中将支付成功状态更新改为条件更新(仅 `pending -> paid` 成功时继续)
|
||||
- [ ] 1.2 重复请求处理:当订单已支付时返回成功(幂等成功);当订单为已取消/已退款等非待支付状态时返回业务错误
|
||||
|
||||
## 2. 激活幂等与事务一致性
|
||||
|
||||
- [ ] 2.1 调整 `activatePackage`:只在首次支付成功后执行
|
||||
- [ ] 2.2 确保 `activatePackage` 内部读取订单明细/套餐信息使用事务 `tx`(避免使用非事务 DB 导致不一致)
|
||||
|
||||
## 3. 防御性约束(可选)
|
||||
|
||||
- [ ] 3.1 评估并新增 `tb_package_usage` 的唯一索引(例如 `order_id + package_id`),并提供 down.sql
|
||||
- [ ] 3.2 对应调整代码:若插入触发唯一冲突,按幂等成功处理或提前查询避免冲突
|
||||
|
||||
## 4. 测试与验证
|
||||
|
||||
- [ ] 4.1 新增单元/集成测试:重复支付/重复回调不会重复插入 usage
|
||||
- [ ] 4.2 运行 `go test ./...` 确保通过
|
||||
|
||||
81
openspec/config.yaml
Normal file
81
openspec/config.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
# OpenSpec Configuration
|
||||
# https://github.com/Fission-AI/OpenSpec
|
||||
|
||||
schema: spec-driven
|
||||
|
||||
# 项目上下文 - 注入到所有 artifacts(proposal, specs, design, tasks)
|
||||
context: |
|
||||
## 项目概述
|
||||
junhong_cmp_fiber 是一个基于 Go + Fiber 的企业级中后台管理系统(Content Management Platform),
|
||||
专注于物联网卡和号卡的全生命周期管理,支持代理商体系和分佣结算。
|
||||
|
||||
## 核心技术栈
|
||||
- **后端框架**: Go 1.25.4 + Fiber v2.x(HTTP)+ GORM v1.25.x(ORM)
|
||||
- **数据存储**: PostgreSQL 14+ + Redis 6.0+
|
||||
- **基础设施**: Asynq v0.24.x(任务队列)+ Viper(配置)+ Zap(日志)
|
||||
- **JSON 序列化**: sonic(优先),encoding/json(必要时)
|
||||
- **验证**: Validator
|
||||
|
||||
## 架构分层(严格遵守)
|
||||
```
|
||||
Handler → Service → Store → Model
|
||||
```
|
||||
- **Handler**: 仅处理 HTTP 请求/响应,参数验证,无业务逻辑
|
||||
- **Service**: 所有业务逻辑,支持跨模块调用
|
||||
- **Store**: 统一数据访问,支持事务
|
||||
- **Model**: 数据结构和 DTO
|
||||
|
||||
## 核心约束
|
||||
- ❌ 禁止使用 `database/sql` 直接调用(必须用 GORM)
|
||||
- ❌ 禁止使用 `net/http` 替代 Fiber
|
||||
- ❌ 禁止使用外键约束和 GORM 关联关系(foreignKey, hasMany, belongsTo)
|
||||
- ✅ 表关联通过 ID 字段手动维护,代码层显式查询
|
||||
- ✅ Go 惯用模式:扁平化包结构、小接口、组合优于继承、显式错误返回
|
||||
- ✅ 遵循 gofmt、Effective Go、Go Code Review Comments
|
||||
|
||||
## 语言要求
|
||||
- 交互、注释、文档、日志、错误消息:中文
|
||||
- 变量名、函数名、类型名:英文(Go 命名规范)
|
||||
- Git commit:中文
|
||||
|
||||
## 性能要求
|
||||
- API P95 < 200ms,P99 < 500ms
|
||||
- 数据库查询 < 50ms
|
||||
- 列表查询必须分页(默认 20,最大 100)
|
||||
|
||||
## 测试要求
|
||||
- 核心业务逻辑测试覆盖率 ≥ 90%
|
||||
- 所有 API 端点必须有集成测试
|
||||
- 使用 table-driven tests
|
||||
|
||||
# 每个 artifact 的特定规则
|
||||
rules:
|
||||
proposal:
|
||||
- 必须检查技术栈合规性(Fiber、GORM、Viper、Zap、Asynq)
|
||||
- 必须说明架构分层(Handler → Service → Store → Model)
|
||||
- 必须包含测试计划和性能考虑
|
||||
- 文档使用中文,代码命名使用英文
|
||||
- 包含功能 ID(如 feature-001-xxx)
|
||||
|
||||
specs:
|
||||
- API 规格必须定义统一响应格式 {code, message, data, timestamp}
|
||||
- 错误码在 pkg/errors/ 中定义,使用双语错误消息
|
||||
- 数据模型禁止使用外键和 GORM 关联,关联通过 ID 维护
|
||||
- 必须明确数据权限规则(基于用户类型和店铺层级)
|
||||
- Redis key 必须使用函数生成:Redis{Module}{Purpose}Key(params...)
|
||||
|
||||
design:
|
||||
- 严格遵循 Handler → Service → Store → Model 分层
|
||||
- 必须说明依赖注入方式(结构体字段注入)
|
||||
- 必须包含事务处理设计(如涉及多表操作)
|
||||
- 必须定义常量在 pkg/constants/(禁止硬编码)
|
||||
- 异步任务使用 Asynq,必须支持重试和幂等性
|
||||
- 性能敏感操作必须考虑 Redis 缓存
|
||||
|
||||
tasks:
|
||||
- tasks.md 是契约,不可擅自变更
|
||||
- 禁止跳过任务、合并任务、简化任务(除非获得许可)
|
||||
- 必须逐项完成并标记状态
|
||||
- 每个任务必须包含验证步骤(单元测试、集成测试、lsp_diagnostics)
|
||||
- 数据库变更必须包含迁移文件(使用 golang-migrate)
|
||||
- 新增 Handler 必须更新文档生成器(cmd/api/docs.go 和 cmd/gendocs/main.go)
|
||||
@@ -1,200 +0,0 @@
|
||||
# Project Context
|
||||
|
||||
## Purpose
|
||||
|
||||
junhong_cmp_fiber 是一个基于 Go + Fiber 的企业级中后台管理系统(Content Management Platform),提供完整的业务管理和数据处理能力。
|
||||
|
||||
**核心目标:**
|
||||
- 提供高性能、可扩展的后端 API 服务
|
||||
- 实现完善的权限管理和数据权限控制(RBAC)
|
||||
- 支持异步任务处理和批量数据操作
|
||||
- 提供统一的错误处理和日志审计能力
|
||||
- 构建可维护、高质量的 Go 代码库
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**核心框架:**
|
||||
- **Go 1.25.4** - 编程语言
|
||||
- **Fiber v2.x** - 高性能 HTTP 框架(基于 fasthttp)
|
||||
- **GORM v1.25.x** - ORM 框架,用于数据库操作
|
||||
|
||||
**数据存储:**
|
||||
- **PostgreSQL 14+** - 主数据库
|
||||
- **Redis 6.0+** - 缓存和任务队列存储
|
||||
|
||||
**基础设施:**
|
||||
- **Asynq v0.24.x** - 异步任务队列
|
||||
- **Viper** - 配置管理
|
||||
- **Zap + Lumberjack.v2** - 结构化日志和日志轮转
|
||||
- **sonic** - 高性能 JSON 序列化
|
||||
- **golang-migrate** - 数据库迁移工具
|
||||
- **validator** - 请求参数验证
|
||||
|
||||
**开发工具:**
|
||||
- **Go Modules** - 依赖管理
|
||||
- **gofmt** - 代码格式化
|
||||
- **go vet** - 静态分析
|
||||
- **golangci-lint** - 代码质量检查
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
**严格遵循 Go 官方规范:**
|
||||
- 必须使用 `gofmt` 格式化所有代码
|
||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go) 和 [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments)
|
||||
- 变量命名使用 Go 风格:`userID`(不是 `userId`)、`HTTPServer`(不是 `HttpServer`)
|
||||
- 缩写词全部大写或全部小写:`URL`、`ID`、`HTTP`(导出)或 `url`、`id`、`http`(未导出)
|
||||
- 包名简短、小写、单数、无下划线:`user`、`order`、`pkg`
|
||||
- 接口命名使用 `-er` 后缀:`Reader`、`Writer`、`Logger`
|
||||
|
||||
**注释和文档规范:**
|
||||
- 所有导出的函数、类型和常量必须有 Go 风格文档注释(英文)
|
||||
- 代码注释(implementation comments)使用中文
|
||||
- 日志消息使用中文
|
||||
- 用户可见错误消息使用中文(通过 `pkg/errors/` 双语支持)
|
||||
- 变量名、函数名、类型名必须使用英文
|
||||
|
||||
**常量管理:**
|
||||
- 业务常量必须定义在 `pkg/constants/`
|
||||
- Redis key 必须使用函数生成:`Redis{Module}{Purpose}Key(params...)`
|
||||
- Redis key 格式:`{module}:{purpose}:{identifier}`
|
||||
- 禁止硬编码 magic numbers 和字符串字面量
|
||||
|
||||
**函数复杂度:**
|
||||
- 函数长度不超过 50-100 行(核心逻辑 ≤ 50 行)
|
||||
- 超过 100 行的函数必须拆分
|
||||
- 遵循单一职责原则(Single Responsibility Principle)
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
**严格的四层架构:**
|
||||
|
||||
```
|
||||
Handler 层 → Service 层 → Store 层 → Model 层
|
||||
```
|
||||
|
||||
- **Handler 层**:处理 HTTP 请求/响应,参数验证,不包含业务逻辑
|
||||
- **Service 层**:包含所有业务逻辑,支持跨模块调用
|
||||
- **Store 层**:统一管理数据访问,支持事务处理
|
||||
- **Model 层**:定义数据结构和 DTO
|
||||
|
||||
**依赖注入:**
|
||||
- 所有依赖通过结构体字段注入(不使用构造函数模式)
|
||||
- 禁止使用 Java 风格的 DI 框架
|
||||
|
||||
**错误处理:**
|
||||
- 所有公共错误在 `pkg/errors/` 定义
|
||||
- 使用统一错误码和双语错误消息
|
||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||
- 禁止手动构造错误响应
|
||||
|
||||
**响应格式:**
|
||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||
- 标准响应结构:`{code, message, data, timestamp}`
|
||||
|
||||
**数据库设计原则(核心):**
|
||||
- ❌ **禁止使用外键约束**(Foreign Key Constraints)
|
||||
- ❌ **禁止使用 GORM 关联关系**(`foreignKey`、`hasMany`、`belongsTo` 等)
|
||||
- ✅ 表之间关联通过存储关联 ID 字段手动维护
|
||||
- ✅ 关联数据查询在代码层面显式执行
|
||||
- ✅ 模型结构体只包含简单字段,不嵌套其他模型
|
||||
- **理由**:灵活性、性能、可控性、分布式友好
|
||||
|
||||
**Go 惯用模式(vs Java):**
|
||||
- ✅ 扁平化包结构(按功能组织,不按层次)
|
||||
- ✅ 小而专注的接口(1-3 个方法)
|
||||
- ✅ 使用组合(composition),不是继承
|
||||
- ✅ 直接访问导出字段,不使用 getter/setter
|
||||
- ✅ 显式错误返回,不使用 panic/recover
|
||||
- ❌ 禁止过度抽象(不必要的工厂、构造器)
|
||||
- ❌ 禁止类型前缀(`IService`、`AbstractBase`、`ServiceImpl`)
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
**测试要求:**
|
||||
- 所有核心业务逻辑(Service 层)必须有单元测试
|
||||
- 所有 API 端点必须有集成测试
|
||||
- 测试使用 Go 标准 `testing` 包
|
||||
- 测试文件命名:`*_test.go`
|
||||
- 测试函数命名:`func TestUserCreate(t *testing.T)`
|
||||
|
||||
**测试性能:**
|
||||
- 单元测试 < 100ms
|
||||
- 集成测试 < 1s
|
||||
- 测试覆盖率 ≥ 70%(核心业务 ≥ 90%)
|
||||
|
||||
**测试最佳实践:**
|
||||
- 使用 table-driven tests 处理多个测试用例
|
||||
- 使用 `t.Helper()` 标记辅助函数
|
||||
- 使用 mock 或 testcontainers,不依赖外部服务
|
||||
|
||||
### Git Workflow
|
||||
|
||||
**分支策略:**
|
||||
- 功能开发使用独立分支:`{feature-id}-{feature-name}`
|
||||
- 完成后合并到主分支
|
||||
|
||||
**Commit 规范:**
|
||||
- 遵循项目现有 commit message 风格
|
||||
- 清晰描述变更内容和原因
|
||||
- 包含 feature ID(如 `feat: 实现 RBAC 权限系统 (004-rbac-data-permission)`)
|
||||
|
||||
## Domain Context
|
||||
|
||||
**业务模块:**
|
||||
- **RBAC 权限系统**:基于角色的访问控制,支持数据权限
|
||||
- **用户管理**:用户注册、认证、权限分配
|
||||
- **数据权限**:支持全部数据、本部门数据、本人数据、自定义数据权限
|
||||
- **异步任务**:批量数据处理、定时任务
|
||||
|
||||
**核心概念:**
|
||||
- **权限控制**:通过中间件实现路由级和数据级权限控制
|
||||
- **数据隔离**:基于用户权限自动过滤查询数据
|
||||
- **审计日志**:完整记录所有 API 请求和响应(request/response body)
|
||||
|
||||
## Important Constraints
|
||||
|
||||
**技术栈约束(严格遵守):**
|
||||
- ❌ 禁止使用 `database/sql` 直接调用(必须使用 GORM)
|
||||
- ❌ 禁止使用 `net/http` 替代 Fiber
|
||||
- ❌ 禁止使用 `encoding/json` 替代 sonic(除非必要)
|
||||
- ❌ 禁止绕过框架使用原生调用
|
||||
- **理由**:确保代码一致性、可维护性和长期技术债务可控
|
||||
|
||||
**性能要求:**
|
||||
- API 响应时间(P95)< 200ms
|
||||
- API 响应时间(P99)< 500ms
|
||||
- 数据库查询 < 50ms
|
||||
- 列表查询必须分页(默认 20,最大 100)
|
||||
|
||||
**资源限制:**
|
||||
- API 服务内存 < 500MB
|
||||
- Worker 服务内存 < 1GB
|
||||
- 数据库连接池:MaxOpenConns=25, MaxIdleConns=10
|
||||
- Redis 连接池:PoolSize=10, MinIdleConns=5
|
||||
|
||||
**安全要求:**
|
||||
- 避免 OWASP Top 10 漏洞(XSS、SQL 注入、命令注入等)
|
||||
- 敏感数据脱敏
|
||||
- 完整的访问日志审计
|
||||
|
||||
**日志要求:**
|
||||
- 所有 HTTP 请求必须记录访问日志(`access.log`)
|
||||
- 记录完整请求/响应 body(限制 50KB)
|
||||
- 使用 JSON 格式便于分析
|
||||
|
||||
## External Dependencies
|
||||
|
||||
**数据库:**
|
||||
- PostgreSQL 14+ (必需,端口 5432)
|
||||
- Redis 6.0+(必需,端口 6379)
|
||||
|
||||
**开发工具:**
|
||||
- golang-migrate CLI(数据库迁移)
|
||||
- golangci-lint(代码质量)
|
||||
|
||||
**可选服务:**
|
||||
- 监控系统(Prometheus/Grafana)
|
||||
- 日志收集(ELK Stack)
|
||||
- API 文档(Swagger/OpenAPI)
|
||||
@@ -1,8 +1,5 @@
|
||||
# enterprise-card-authorization Specification
|
||||
## MODIFIED Requirements
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change enterprise-card-authorization. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 企业单卡授权管理
|
||||
|
||||
系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。
|
||||
@@ -10,12 +7,13 @@ TBD - created by archiving change enterprise-card-authorization. Update Purpose
|
||||
**授权规则**:
|
||||
- 代理只能授权自己的卡(owner_type="agent" 且 owner_id=自己的 shop_id)给自己的企业
|
||||
- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业
|
||||
- 只能授权单张卡,不支持批量选择
|
||||
- 已绑定设备的卡不能授权(设备卡应整体授权,而非单卡)
|
||||
- 支持批量授权(最多1000张卡)
|
||||
- **已绑定设备的卡不能通过单卡授权接口授权,MUST 使用设备授权接口**
|
||||
- 只能授权状态为 "已分销(2)" 的卡
|
||||
|
||||
**授权记录存储**:
|
||||
- 使用 `enterprise_card_authorization` 表记录授权关系
|
||||
- 通过单卡授权创建的记录 device_auth_id 为 NULL
|
||||
- 不使用 `asset_allocation_record` 表(该表用于分配,非授权)
|
||||
|
||||
**权限控制**:
|
||||
@@ -25,23 +23,23 @@ TBD - created by archiving change enterprise-card-authorization. Update Purpose
|
||||
|
||||
#### Scenario: 代理授权自己的卡给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己的卡(owner_type="agent", owner_id=10)授权给企业(enterprise_id=5, owner_shop_id=10)
|
||||
- **THEN** 系统创建授权记录,企业可以查看和管理该卡,卡的 shop_id 保持为 10
|
||||
- **WHEN** 代理(shop_id=10)将自己的未绑定设备的卡授权给企业(enterprise_id=5, owner_shop_id=10)
|
||||
- **THEN** 系统创建授权记录(device_auth_id=NULL),企业可以查看和管理该卡
|
||||
|
||||
#### Scenario: 平台授权任意卡给企业
|
||||
|
||||
- **WHEN** 平台管理员将卡授权给企业
|
||||
- **THEN** 系统创建授权记录,不检查卡的所有者,企业获得该卡的访问权限
|
||||
- **WHEN** 平台管理员将未绑定设备的卡授权给企业
|
||||
- **THEN** 系统创建授权记录(device_auth_id=NULL),企业获得该卡的访问权限
|
||||
|
||||
#### Scenario: 代理无法授权其他代理的卡
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他代理的卡(owner_id=20)给企业
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 已绑定设备的卡不能授权
|
||||
#### Scenario: 已绑定设备的卡不能通过单卡授权
|
||||
|
||||
- **WHEN** 用户尝试授权已绑定到设备的卡
|
||||
- **THEN** 系统拒绝操作,提示该卡已绑定设备,请使用设备授权功能
|
||||
- **WHEN** 用户尝试通过单卡授权接口授权已绑定到设备的卡
|
||||
- **THEN** 系统拒绝操作,返回错误码 CodeCannotAuthorizeBoundCard,提示"该卡已绑定设备,请使用设备授权功能"
|
||||
|
||||
#### Scenario: 只能授权已分销状态的卡
|
||||
|
||||
@@ -59,17 +57,29 @@ TBD - created by archiving change enterprise-card-authorization. Update Purpose
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,关联 enterprises 表)
|
||||
- `card_id`: IoT卡ID(BIGINT,关联 iot_cards 表)
|
||||
- `authorizer_id`: 授权人账号ID(BIGINT,关联 accounts 表)
|
||||
- `authorizer_type`: 授权人类型(VARCHAR(20),"platform" | "agent")
|
||||
- `authorizer_type`: 授权人类型(SMALLINT,2=平台用户 3=代理账号)
|
||||
- `authorized_at`: 授权时间(TIMESTAMP)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `remark`: 备注(VARCHAR(500))
|
||||
- **`device_auth_id`: 关联的设备授权ID(BIGINT,可空)**
|
||||
- NULL = 通过单卡授权创建
|
||||
- 有值 = 通过设备授权创建
|
||||
- `created_at`: 创建时间(TIMESTAMP)
|
||||
- `updated_at`: 更新时间(TIMESTAMP)
|
||||
|
||||
#### Scenario: 创建授权记录
|
||||
**新增索引**:
|
||||
- `idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id)`
|
||||
|
||||
- **WHEN** 授权卡给企业时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,authorized_at 设置为当前时间,revoked_at 为 NULL
|
||||
#### Scenario: 创建单卡授权记录
|
||||
|
||||
- **WHEN** 通过单卡授权接口授权卡给企业时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,device_auth_id 为 NULL
|
||||
|
||||
#### Scenario: 创建设备关联卡授权记录
|
||||
|
||||
- **WHEN** 通过设备授权创建卡授权记录时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,device_auth_id 指向对应的设备授权ID
|
||||
|
||||
#### Scenario: 回收授权
|
||||
|
||||
@@ -80,87 +90,43 @@ TBD - created by archiving change enterprise-card-authorization. Update Purpose
|
||||
|
||||
### Requirement: 批量授权接口
|
||||
|
||||
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业,不需要预检接口。
|
||||
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/{enterpriseId}/authorize-cards`
|
||||
- 请求体:包含卡ID列表
|
||||
- 路径:`POST /api/admin/enterprises/:id/allocate-cards`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"iccids": ["8986001234567890", "8986001234567891"],
|
||||
"remark": "批量授权"
|
||||
}
|
||||
```
|
||||
- 响应:成功/失败的卡列表及原因
|
||||
|
||||
**处理流程**:
|
||||
1. 验证每张卡的授权权限
|
||||
2. 检查卡状态是否为"已分销"
|
||||
3. 检查卡是否已绑定设备
|
||||
4. 检查是否已授权给其他企业
|
||||
5. 创建授权记录
|
||||
3. **检查卡是否已绑定设备,绑定设备的卡直接拒绝并返回错误**
|
||||
4. 检查是否已授权给该企业
|
||||
5. 创建授权记录(device_auth_id = NULL)
|
||||
6. 返回处理结果
|
||||
|
||||
**移除功能**:
|
||||
- ~~DeviceBundle 预检和确认流程~~(已移除)
|
||||
- ~~confirm_device_bundles 参数~~(已移除)
|
||||
- ~~AllocatedDevices 响应字段~~(已移除)
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张符合条件的卡给企业
|
||||
- **THEN** 系统创建 5 条授权记录,返回全部成功
|
||||
- **WHEN** 代理批量授权 5 张未绑定设备的卡给企业
|
||||
- **THEN** 系统创建 5 条授权记录(device_auth_id 均为 NULL),返回全部成功
|
||||
|
||||
#### Scenario: 批量授权遇到设备卡
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 2 张已绑定设备
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败,失败原因为"该卡已绑定设备,请使用设备授权功能"
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 2 张不符合条件(1 张已绑定设备,1 张非已分销状态)
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及失败原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业查看授权卡信息
|
||||
|
||||
系统 SHALL 允许企业查看被授权卡的特定信息,同时隐藏商业敏感信息。
|
||||
|
||||
**可见信息**:
|
||||
- 卡基本信息:ICCID、卡类型、运营商、批次号
|
||||
- 使用信息:激活状态、实名状态、网络状态、流量使用
|
||||
- 套餐信息:当前套餐、有效期
|
||||
- 授权信息:授权人、授权时间
|
||||
|
||||
**不可见信息**:
|
||||
- 成本价(cost_price)
|
||||
- 分销价(distribute_price)
|
||||
- 供应商(supplier)
|
||||
- 所有者信息(owner_type、owner_id)
|
||||
|
||||
#### Scenario: 企业查看授权卡详情
|
||||
|
||||
- **WHEN** 企业用户查看被授权的卡详情
|
||||
- **THEN** 系统返回卡信息,但 cost_price、distribute_price、supplier 字段为空或不返回
|
||||
|
||||
#### Scenario: 企业无法查看未授权的卡
|
||||
|
||||
- **WHEN** 企业用户尝试查看未被授权的卡
|
||||
- **THEN** 系统返回 404 错误,提示卡不存在或无权限查看
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 授权回收功能
|
||||
|
||||
系统 SHALL 支持回收企业的卡授权,回收后企业立即失去访问权限。
|
||||
|
||||
**回收规则**:
|
||||
- 代理可以回收自己授权的卡
|
||||
- 平台可以回收任何授权
|
||||
- 回收操作不可逆(需重新授权才能恢复访问)
|
||||
|
||||
**回收效果**:
|
||||
- 更新 revoked_at 和 revoked_by 字段
|
||||
- 企业立即无法查看该卡
|
||||
- 保留授权历史记录
|
||||
|
||||
#### Scenario: 代理回收自己的授权
|
||||
|
||||
- **WHEN** 代理回收之前授权给企业的卡
|
||||
- **THEN** 系统更新授权记录的回收字段,企业立即无法访问该卡
|
||||
|
||||
#### Scenario: 平台回收任意授权
|
||||
|
||||
- **WHEN** 平台管理员回收任意企业的卡授权
|
||||
- **THEN** 系统更新授权记录,不检查原授权人,企业失去访问权限
|
||||
|
||||
#### Scenario: 回收后企业无法访问
|
||||
|
||||
- **WHEN** 授权被回收后,企业用户尝试查看该卡
|
||||
- **THEN** 系统返回 404 错误,如同该卡从未被授权过
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 1 张已绑定设备、1 张非已分销状态
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及各自失败原因
|
||||
|
||||
319
openspec/specs/enterprise-device-authorization/spec.md
Normal file
319
openspec/specs/enterprise-device-authorization/spec.md
Normal file
@@ -0,0 +1,319 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备授权企业数据模型
|
||||
|
||||
系统 SHALL 定义 EnterpriseDeviceAuthorization 实体,记录设备与企业的授权关系。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 主键(BIGSERIAL)
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,NOT NULL)
|
||||
- `device_id`: 被授权设备ID(BIGINT,NOT NULL)
|
||||
- `authorized_by`: 授权人账号ID(BIGINT,NOT NULL)
|
||||
- `authorized_at`: 授权时间(TIMESTAMP,NOT NULL)
|
||||
- `authorizer_type`: 授权人类型(SMALLINT,2=平台用户 3=代理账号)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `remark`: 备注(VARCHAR(500))
|
||||
- `created_at`, `updated_at`, `deleted_at`: 标准时间字段
|
||||
|
||||
**唯一性约束**:
|
||||
- 一个设备同时只能授权给一个企业:`UNIQUE (device_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
|
||||
|
||||
**表名**:`tb_enterprise_device_authorization`
|
||||
|
||||
#### Scenario: 创建设备授权记录
|
||||
|
||||
- **WHEN** 授权设备给企业时
|
||||
- **THEN** 系统创建 EnterpriseDeviceAuthorization 记录,authorized_at 设置为当前时间,revoked_at 为 NULL
|
||||
|
||||
#### Scenario: 设备重复授权被拒绝
|
||||
|
||||
- **WHEN** 尝试将已授权给企业A的设备(未回收)再授权给企业B
|
||||
- **THEN** 系统拒绝操作,返回错误"设备已授权给其他企业"
|
||||
|
||||
#### Scenario: 回收后可重新授权
|
||||
|
||||
- **WHEN** 设备授权已被回收后,重新授权给同一企业或其他企业
|
||||
- **THEN** 系统允许创建新的授权记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡授权记录关联设备授权
|
||||
|
||||
系统 SHALL 在 EnterpriseCardAuthorization 表中添加 device_auth_id 字段,关联设备授权记录。
|
||||
|
||||
**新增字段**:
|
||||
- `device_auth_id`: 关联的设备授权ID(BIGINT,可空)
|
||||
- NULL = 通过单卡授权创建
|
||||
- 有值 = 通过设备授权创建
|
||||
|
||||
**索引**:
|
||||
- `idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id)`
|
||||
|
||||
#### Scenario: 设备授权创建关联卡授权
|
||||
|
||||
- **WHEN** 通过设备授权创建卡授权记录时
|
||||
- **THEN** 卡授权记录的 device_auth_id 字段设置为对应的设备授权ID
|
||||
|
||||
#### Scenario: 单卡授权不关联设备
|
||||
|
||||
- **WHEN** 通过单卡授权创建卡授权记录时
|
||||
- **THEN** 卡授权记录的 device_auth_id 字段为 NULL
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备授权管理功能
|
||||
|
||||
系统 SHALL 提供设备授权给企业的功能,支持批量授权和回收。
|
||||
|
||||
**授权规则**:
|
||||
- 代理只能授权自己店铺的设备给自己店铺下的企业
|
||||
- 平台可以授权任意设备给任意企业
|
||||
- 设备 MUST 属于操作者(平台或代理店铺)
|
||||
- 设备 MUST 处于"已分销"状态(status=2)
|
||||
- 设备 MUST 未授权给其他企业(唯一性约束)
|
||||
|
||||
**授权联动**:
|
||||
- 授权设备时,系统 SHALL 自动授权设备下所有已绑定的卡
|
||||
- 卡授权记录的 device_auth_id 指向设备授权记录
|
||||
- 如果设备没有绑定卡,仍然创建设备授权记录(无卡授权)
|
||||
|
||||
#### Scenario: 代理授权设备给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己店铺的设备授权给企业(owner_shop_id=10)
|
||||
- **THEN** 系统创建设备授权记录,并为设备下所有已绑定的卡创建卡授权记录
|
||||
|
||||
#### Scenario: 平台授权任意设备
|
||||
|
||||
- **WHEN** 平台管理员授权设备给任意企业
|
||||
- **THEN** 系统创建授权记录,不检查设备和企业的归属关系
|
||||
|
||||
#### Scenario: 代理无法授权其他店铺的设备
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他店铺的设备(shop_id=20)
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 设备授权联动卡授权
|
||||
|
||||
- **WHEN** 授权一个绑定了3张卡的设备给企业
|
||||
- **THEN** 系统创建1条设备授权记录和3条卡授权记录,所有卡授权的 device_auth_id 指向该设备授权
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量授权设备接口
|
||||
|
||||
系统 SHALL 提供批量授权设备给企业的后台接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/allocate-devices`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"device_nos": ["D001", "D002", "D003"],
|
||||
"remark": "批量授权备注"
|
||||
}
|
||||
```
|
||||
- 响应体:
|
||||
```json
|
||||
{
|
||||
"success_count": 2,
|
||||
"fail_count": 1,
|
||||
"failed_items": [
|
||||
{ "device_no": "D003", "reason": "设备不存在" }
|
||||
],
|
||||
"authorized_devices": [
|
||||
{ "device_id": 1, "device_no": "D001", "card_count": 3 },
|
||||
{ "device_id": 2, "device_no": "D002", "card_count": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**处理流程**:
|
||||
1. 验证企业存在且有权限
|
||||
2. 验证每个设备的授权权限
|
||||
3. 检查设备状态和唯一性约束
|
||||
4. 在事务内创建设备授权和卡授权记录
|
||||
5. 返回处理结果
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 平台批量授权3个符合条件的设备给企业
|
||||
- **THEN** 系统创建3条设备授权记录和对应的卡授权记录,返回全部成功
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权3个设备,其中1个已授权给其他企业
|
||||
- **THEN** 系统创建2条设备授权记录,返回2个成功、1个失败及失败原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备授权回收功能
|
||||
|
||||
系统 SHALL 提供回收设备授权的功能,回收时同步回收关联的卡授权。
|
||||
|
||||
**回收规则**:
|
||||
- 代理可以回收自己授权的设备
|
||||
- 平台可以回收任何设备授权
|
||||
- 回收操作在事务内完成
|
||||
|
||||
**回收联动**:
|
||||
- 回收设备授权时,系统 SHALL 同步回收所有 device_auth_id 指向该设备授权的卡授权记录
|
||||
- 更新 revoked_at 和 revoked_by 字段
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/recall-devices`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"device_nos": ["D001", "D002"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 回收设备授权联动回收卡授权
|
||||
|
||||
- **WHEN** 回收一个绑定了3张卡的设备的授权
|
||||
- **THEN** 系统更新设备授权的 revoked_at,同时更新3条关联卡授权的 revoked_at
|
||||
|
||||
#### Scenario: 回收后企业无法访问设备和卡
|
||||
|
||||
- **WHEN** 设备授权被回收后,企业用户查询设备或卡
|
||||
- **THEN** 系统不返回该设备和其下的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台企业设备列表
|
||||
|
||||
系统 SHALL 提供后台管理查询企业授权设备列表的接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/admin/enterprises/:id/devices`
|
||||
- 查询参数:`page`, `page_size`, `device_no`, `status`
|
||||
- 响应:设备列表,包含设备信息和绑定卡数量
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有企业的授权设备
|
||||
- 代理用户只能查看自己店铺下企业的授权设备
|
||||
|
||||
#### Scenario: 查询企业授权设备列表
|
||||
|
||||
- **WHEN** 管理员查询企业ID=5的授权设备
|
||||
- **THEN** 系统返回该企业所有授权设备列表,每个设备包含绑定卡数量
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备列表
|
||||
|
||||
系统 SHALL 提供企业用户查询自己授权设备列表的 H5 接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/h5/enterprise/devices`
|
||||
- 查询参数:`page`, `page_size`, `device_no`
|
||||
- 响应:
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"device_id": 1,
|
||||
"device_no": "D001",
|
||||
"device_name": "GPS追踪器-001",
|
||||
"device_model": "GT-100",
|
||||
"card_count": 3,
|
||||
"authorized_at": "2025-01-29T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 10
|
||||
}
|
||||
```
|
||||
|
||||
**数据权限**:
|
||||
- 企业用户只能看到授权给自己企业的设备
|
||||
- 通过 GORM Callback 自动过滤
|
||||
|
||||
#### Scenario: 企业用户查看设备列表
|
||||
|
||||
- **WHEN** 企业用户查询设备列表
|
||||
- **THEN** 系统返回授权给该企业的所有设备,包含设备信息和卡数量
|
||||
|
||||
#### Scenario: 企业用户无法看到未授权设备
|
||||
|
||||
- **WHEN** 企业用户查询设备列表
|
||||
- **THEN** 系统不返回未授权给该企业的设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备详情
|
||||
|
||||
系统 SHALL 提供企业用户查询设备详情的 H5 接口,包含设备绑定的卡列表。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/h5/enterprise/devices/:device_id`
|
||||
- 响应:
|
||||
```json
|
||||
{
|
||||
"device": {
|
||||
"device_id": 1,
|
||||
"device_no": "D001",
|
||||
"device_name": "GPS追踪器-001",
|
||||
"device_model": "GT-100",
|
||||
"device_type": "GPS",
|
||||
"authorized_at": "2025-01-29T10:00:00Z"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"card_id": 101,
|
||||
"iccid": "8986001234567890",
|
||||
"msisdn": "1380000001",
|
||||
"carrier_name": "中国联通",
|
||||
"network_status": 1,
|
||||
"network_status_name": "开机"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**可见信息**:
|
||||
- 设备基本信息:设备号、名称、型号、类型
|
||||
- 卡信息:ICCID、MSISDN、运营商、网络状态
|
||||
|
||||
**不可见信息**:
|
||||
- 成本价、分销价、供应商等商业敏感信息
|
||||
|
||||
#### Scenario: 企业用户查看设备详情
|
||||
|
||||
- **WHEN** 企业用户查看授权设备ID=1的详情
|
||||
- **THEN** 系统返回设备信息和该设备绑定的所有卡信息
|
||||
|
||||
#### Scenario: 企业用户无法查看未授权设备
|
||||
|
||||
- **WHEN** 企业用户尝试查看未授权的设备详情
|
||||
- **THEN** 系统返回 404 错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备卡停机复机
|
||||
|
||||
系统 SHALL 提供企业用户对设备下的卡进行停机/复机操作的 H5 接口。
|
||||
|
||||
**接口设计**:
|
||||
- 停机:`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend`
|
||||
- 复机:`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume`
|
||||
|
||||
**权限校验**:
|
||||
- 设备 MUST 授权给当前企业
|
||||
- 卡 MUST 属于该设备(通过 device_sim_binding 验证)
|
||||
- 卡 MUST 通过设备授权(device_auth_id 不为空且有效)
|
||||
|
||||
#### Scenario: 企业用户停机设备下的卡
|
||||
|
||||
- **WHEN** 企业用户对授权设备下的卡执行停机操作
|
||||
- **THEN** 系统更新卡的 network_status 为 0(停机)
|
||||
|
||||
#### Scenario: 企业用户复机设备下的卡
|
||||
|
||||
- **WHEN** 企业用户对授权设备下的卡执行复机操作
|
||||
- **THEN** 系统更新卡的 network_status 为 1(开机)
|
||||
|
||||
#### Scenario: 无法操作未授权设备的卡
|
||||
|
||||
- **WHEN** 企业用户尝试操作未授权设备下的卡
|
||||
- **THEN** 系统返回 403 错误
|
||||
Reference in New Issue
Block a user