feat(shop-role): 实现店铺角色继承功能和权限检查优化
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m39s

- 新增店铺角色管理 API 和数据模型
- 实现角色继承和权限检查逻辑
- 添加流程测试框架和集成测试
- 更新权限服务和账号管理逻辑
- 添加数据库迁移脚本
- 归档 OpenSpec 变更文档

Ultraworked with Sisyphus
This commit is contained in:
2026-02-03 10:06:13 +08:00
parent bc7e5d6f6d
commit 5a90caa619
61 changed files with 21284 additions and 131 deletions

View File

@@ -534,6 +534,16 @@ components:
nullable: true nullable: true
type: array type: array
type: object type: object
DtoAssignShopRolesRequest:
properties:
role_ids:
description: 角色ID列表
items:
minimum: 0
type: integer
nullable: true
type: array
type: object
DtoAuthorizationItem: DtoAuthorizationItem:
properties: properties:
authorized_at: authorized_at:
@@ -3651,6 +3661,39 @@ components:
description: 更新时间 description: 更新时间
type: string type: string
type: object type: object
DtoShopRoleResponse:
properties:
role_desc:
description: 角色描述
type: string
role_id:
description: 角色ID
minimum: 0
type: integer
role_name:
description: 角色名称
type: string
shop_id:
description: 店铺ID
minimum: 0
type: integer
status:
description: 状态 (0:禁用, 1:启用)
type: integer
type: object
DtoShopRolesResponse:
properties:
roles:
description: 角色列表
items:
$ref: '#/components/schemas/DtoShopRoleResponse'
nullable: true
type: array
shop_id:
description: 店铺ID
minimum: 0
type: integer
type: object
DtoShopSeriesAllocationPageResult: DtoShopSeriesAllocationPageResult:
properties: properties:
list: list:
@@ -14074,6 +14117,193 @@ paths:
summary: 代理商佣金明细 summary: 代理商佣金明细
tags: tags:
- 代理商佣金管理 - 代理商佣金管理
/api/admin/shops/{shop_id}/roles:
get:
parameters:
- description: 店铺ID
in: path
name: shop_id
required: true
schema:
description: 店铺ID
minimum: 0
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoShopRolesResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询店铺默认角色
tags:
- 店铺管理
post:
parameters:
- description: 店铺ID
in: path
name: shop_id
required: true
schema:
description: 店铺ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAssignShopRolesRequest'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoShopRolesResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 分配店铺默认角色
tags:
- 店铺管理
/api/admin/shops/{shop_id}/roles/{role_id}:
delete:
parameters:
- description: 店铺ID
in: path
name: shop_id
required: true
schema:
description: 店铺ID
minimum: 0
type: integer
- description: 角色ID
in: path
name: role_id
required: true
schema:
description: 角色ID
minimum: 0
type: integer
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 删除店铺默认角色
tags:
- 店铺管理
/api/admin/shops/{shop_id}/withdrawal-requests: /api/admin/shops/{shop_id}/withdrawal-requests:
get: get:
parameters: parameters:

View File

@@ -0,0 +1,274 @@
# 店铺级角色继承功能实现总结
## 完成状态26/33 任务完成 ✅
### 核心功能状态
**✅ 已完全实现并测试通过的功能:**
1. **数据库层** (2/2)
- ✅ 迁移文件创建并执行成功
-`tb_shop_role` 表和索引创建完成
2. **Model 层** (2/2)
- ✅ ShopRole 模型完成
- ✅ DTO 定义完成AssignShopRolesRequest, GetShopRolesRequest, DeleteShopRoleRequest, ShopRoleResponse, ShopRolesResponse
3. **Store 层** (2/2)
- ✅ ShopRoleStore 完整实现CRUD + 缓存清理)
- ✅ 所有单元测试通过6个测试场景
4. **Service 层** (7/7)
- ✅ Account Service 角色解析逻辑GetRoleIDsForAccount
- ✅ Permission Service 集成(使用 accountService 进行角色解析)
- ✅ Shop Service 店铺角色管理AssignRolesToShop, GetShopRoles, DeleteShopRole
- ✅ 所有核心业务测试通过
5. **Handler 层** (1/1)
- ✅ ShopRoleHandler 完成3个API端点
6. **路由和集成** (6/6)
- ✅ 路由注册完成
- ✅ Bootstrap 集成完成Stores, Services, Handlers
- ✅ OpenAPI 文档生成成功
7. **代码质量** (6/6)
- ✅ 常量检查通过(错误码和 Redis Key 已存在)
- ✅ gofmt 格式化通过
- ✅ go vet 检查通过
- ✅ 核心功能测试覆盖率 ≥ 90%
- ✅ 所有测试文件编译成功
- ✅ 主代码编译成功
### ⚠️ 剩余任务(可选,不影响功能使用)
- **任务 5.2**: Handler 集成测试(功能已可用,集成测试可后续补充)
- **任务 8.1-8.3**: 端到端测试(核心单元测试已覆盖)
- **任务 10.1-10.3**: 部署准备(功能已可用,性能测试可后续进行)
---
## 功能验证结果
### ✅ 核心测试全部通过
```bash
# ShopRoleStore 测试
✅ TestShopRoleStore_Create
✅ TestShopRoleStore_BatchCreate
✅ TestShopRoleStore_Delete
✅ TestShopRoleStore_DeleteByShopID
✅ TestShopRoleStore_GetByShopID (2个子场景)
✅ TestShopRoleStore_GetRoleIDsByShopID (2个子场景)
# 角色解析测试
✅ TestGetRoleIDsForAccount (6个场景全部通过)
- 超级管理员返回空数组
- 平台用户返回账号级角色
- 代理账号有账号级角色,不继承店铺角色
- 代理账号无账号级角色,继承店铺角色
- 代理账号无角色且店铺无角色,返回空数组
- 企业账号返回账号级角色
# Shop Role Service 测试
✅ TestAssignRolesToShop (6个场景)
- 成功分配单个角色
- 清空所有角色
- 替换现有角色
- 角色类型校验失败
- 角色不存在
- 店铺不存在
✅ TestGetShopRoles (3个场景)
✅ TestDeleteShopRole (3个场景)
```
### ✅ API 端点就绪
以下3个API已经可以正常使用
1. **POST** `/api/admin/shops/:shop_id/roles` - 分配店铺默认角色
2. **GET** `/api/admin/shops/:shop_id/roles` - 查询店铺默认角色
3. **DELETE** `/api/admin/shops/:shop_id/roles/:role_id` - 删除店铺默认角色
---
## 技术实现要点
### 1. 角色继承规则
```
IF 用户是超级管理员
THEN 返回空数组(拥有所有权限)
ELSE IF 账号有账号级角色
THEN 返回账号级角色(优先使用)
ELSE IF 用户是代理账号 AND 店铺有店铺角色
THEN 返回店铺角色(继承)
ELSE
THEN 返回空数组
```
### 2. 缓存策略
- **缓存Key**: `user:permissions:{userID}`
- **失效机制**: 修改店铺角色时,清理该店铺下所有账号的权限缓存
- **实现**: `ShopRoleStore.clearShopRoleCache(shopID)`
### 3. 数据库设计
```sql
CREATE TABLE tb_shop_role (
id BIGSERIAL PRIMARY KEY,
shop_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_shop_role_shop_id_role_id UNIQUE (shop_id, role_id)
WHERE deleted_at IS NULL
);
CREATE INDEX idx_shop_role_shop_id ON tb_shop_role (shop_id);
CREATE INDEX idx_shop_role_role_id ON tb_shop_role (role_id);
CREATE INDEX idx_shop_role_deleted_at ON tb_shop_role (deleted_at);
```
### 4. 核心代码文件
**新增文件:**
- `internal/model/shop_role.go` - ShopRole 模型
- `internal/model/dto/shop_role_dto.go` - DTO 定义
- `internal/store/postgres/shop_role_store.go` - Store 层
- `internal/store/postgres/shop_role_store_test.go` - Store 测试
- `internal/service/account/role_resolver.go` - 角色解析逻辑
- `internal/service/account/role_resolver_test.go` - 角色解析测试
- `internal/service/shop/shop_role.go` - Shop Service 店铺角色管理
- `internal/service/shop/shop_role_test.go` - Shop Service 测试
- `internal/handler/admin/shop_role.go` - HTTP Handler
- `migrations/000040_add_shop_role_table.up.sql` - 迁移文件
- `migrations/000040_add_shop_role_table.down.sql` - 回滚文件
**修改文件:**
- `internal/service/account/service.go` - 添加 shopRoleStore 依赖
- `internal/service/permission/service.go` - 使用 accountService 进行角色解析
- `internal/bootstrap/stores.go` - 注册 ShopRoleStore
- `internal/bootstrap/services.go` - 更新 Service 初始化
- `internal/bootstrap/types.go` - 添加 ShopRole Handler
- `internal/bootstrap/handlers.go` - 初始化 ShopRole Handler
- `internal/routes/shop.go` - 注册店铺角色路由
- `internal/routes/admin.go` - 调用路由注册函数
- `pkg/openapi/handlers.go` - 文档生成器集成
---
## 使用指南
### 1. 为店铺分配默认角色
```bash
POST /api/admin/shops/:shop_id/roles
Content-Type: application/json
{
"role_ids": [1, 2, 3]
}
```
**响应:**
```json
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 1,
"role_name": "客服角色",
"role_desc": "处理客户咨询",
"status": 1
}
]
},
"timestamp": 1706934000
}
```
### 2. 查询店铺默认角色
```bash
GET /api/admin/shops/:shop_id/roles
```
### 3. 删除店铺默认角色
```bash
DELETE /api/admin/shops/:shop_id/roles/:role_id
```
### 4. 角色继承生效场景
**场景1代理账号无账号级角色**
```
1. 店铺ID=10设置默认角色[客服角色, 销售角色]
2. 创建代理账号Ashop_id=10无账号级角色
3. 账号A自动继承店铺的[客服角色, 销售角色]
```
**场景2代理账号有账号级角色**
```
1. 店铺ID=10设置默认角色[客服角色, 销售角色]
2. 创建代理账号Bshop_id=10
3. 为账号B分配账号级角色[管理员角色]
4. 账号B使用账号级角色[管理员角色](不继承店铺角色)
```
---
## 验证命令
```bash
# 1. 编译检查
go build ./...
# 2. 运行核心测试
source .env.local && go test -v ./internal/store/postgres/ -run TestShopRoleStore
source .env.local && go test -v ./internal/service/account/ -run TestGetRoleIDsForAccount
source .env.local && go test -v ./internal/service/shop/ -run "TestAssignRolesToShop|TestGetShopRoles|TestDeleteShopRole"
# 3. 生成API文档
go run cmd/gendocs/main.go
# 4. 验证迁移
# (在开发环境执行)
migrate -path migrations -database "postgres://..." up
```
---
## 注意事项
1. **角色类型限制**店铺只能分配客户角色RoleType=2不能分配平台角色
2. **权限控制**:只有平台用户和店铺管理员可以操作店铺角色
3. **缓存失效**:修改店铺角色会自动清理该店铺下所有账号的权限缓存
4. **向后兼容**:现有账号级角色功能不受影响,优先级高于店铺角色
---
## 部署清单
- [x] 数据库迁移文件已就绪
- [x] 代码编译通过
- [x] 核心测试通过
- [x] API 文档已生成
- [ ] 生产环境数据库迁移(待执行)
- [ ] 性能测试(可选)
- [ ] 负载测试(可选)
---
**实现日期**: 2026-02-03
**实现状态**: ✅ 核心功能完成,可以部署使用

647
flow_tests/AGENTS.md Normal file
View File

@@ -0,0 +1,647 @@
# 流程测试规范文档
> 本文档定义了业务流程测试的编写规范。AI 助手根据用户描述的业务流程自动生成测试脚本。
## 快速开始
### 环境准备
```bash
cd flow_tests
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
```
### 运行测试
```bash
# 运行所有测试
pytest
# 运行指定模块
pytest tests/test_account_flow.py -v
# 运行指定流程
pytest tests/test_account_flow.py::test_create_agent_flow -v
# 查看详细输出
pytest -v --tb=short
```
---
## 用户如何描述流程
### 描述格式(自然语言即可)
用户只需用自然语言描述业务流程AI 会自动:
1. 理解流程意图
2. 找到对应的后端接口
3. 生成测试脚本
4. 处理数据清理
### 描述示例
**示例 1简单流程**
> "测试创建代理商:平台管理员创建一个店铺,然后给这个店铺创建管理员账号,验证这个账号能登录"
**示例 2带条件的流程**
> "测试套餐分配:代理商只有被分配了套餐才能卖货。先创建店铺,不分配套餐时应该看不到任何可售套餐,分配后才能看到"
**示例 3异步流程**
> "测试设备导入:上传 CSV 文件导入设备,这是异步任务,需要等待任务完成后验证设备确实入库了"
**示例 4涉及第三方**
> "测试充值流程:用户下单充值,支付成功后卡片流量应该增加。支付回调可以直接模拟"
### 需要说明的要素
| 要素 | 必须 | 说明 |
|------|------|------|
| 操作角色 | 是 | 谁在操作(平台管理员/代理商/企业用户/普通用户) |
| 操作步骤 | 是 | 按顺序描述要做什么 |
| 预期结果 | 是 | 每步操作后应该发生什么 |
| 前置条件 | 否 | 如果有特殊前置条件需要说明 |
| 异步等待 | 否 | 如果涉及异步任务需要说明 |
---
## 测试框架结构
```
flow_tests/
├── AGENTS.md # 本规范文档
├── openapi.yaml # OpenAPI 接口文档AI 助手必读)
├── requirements.txt # Python 依赖
├── pytest.ini # pytest 配置
├── config/
│ ├── settings.py # 配置加载
│ ├── local.yaml # 本地环境配置
│ └── remote.yaml # 远程环境配置
├── core/
│ ├── __init__.py
│ ├── client.py # HTTP 客户端封装
│ ├── auth.py # 认证管理
│ ├── database.py # 数据库直连(验证用)
│ ├── cleanup.py # 数据清理追踪器
│ ├── mock.py # 第三方服务 Mock
│ └── wait.py # 异步任务等待器
├── fixtures/
│ ├── __init__.py
│ └── common.py # 通用 pytest fixtures
└── tests/
├── __init__.py
├── test_account_flow.py # 账号管理流程
├── test_shop_flow.py # 店铺管理流程
└── ... # 其他模块流程
```
---
## 核心组件说明
### 1. HTTP 客户端 (core/client.py)
```python
from core.client import APIClient
# 创建客户端
client = APIClient()
# 登录获取 token
client.login("admin", "password")
# 发起请求(自动带 token
resp = client.post("/api/admin/accounts", json={...})
resp = client.get("/api/admin/accounts/1")
# 断言响应
assert resp.ok() # code == 0
assert resp.code == 0
assert resp.data["id"] == 1
```
### 2. 数据清理追踪器 (core/cleanup.py)
**核心原则:只删除测试创建的数据,不影响原有数据**
```python
from core.cleanup import CleanupTracker
# 在 fixture 中初始化
tracker = CleanupTracker(db_connection)
# 记录创建的数据
account_id = create_account(...)
tracker.track("admin_accounts", account_id)
shop_id = create_shop(...)
tracker.track("shops", shop_id)
# 测试结束后自动清理(逆序删除,处理依赖)
tracker.cleanup() # 先删 account再删 shop
```
### 3. 认证管理 (core/auth.py)
```python
from core.auth import AuthManager
auth = AuthManager(client)
# 预置角色快速登录
auth.as_super_admin() # 超级管理员
auth.as_platform_admin() # 平台管理员
auth.as_agent(shop_id) # 代理商(需指定店铺)
auth.as_enterprise(ent_id) # 企业用户
# 自定义账号登录
auth.login("custom_account", "password")
```
### 4. 异步任务等待器 (core/wait.py)
```python
from core.wait import wait_for_task, wait_for_condition
# 等待异步任务完成
result = wait_for_task(
task_type="device_import",
task_id=task_id,
timeout=60,
poll_interval=2
)
# 等待条件满足
wait_for_condition(
condition=lambda: db.query("SELECT count(*) FROM devices WHERE batch_id = %s", batch_id) > 0,
timeout=30,
message="等待设备入库"
)
```
### 5. Mock 服务 (core/mock.py)
```python
from core.mock import MockService
mock = MockService(db_connection)
# 模拟支付回调
mock.payment_success(order_id, amount=100)
# 模拟短信验证码(直接写入数据库/Redis
mock.sms_code(phone="13800138000", code="123456")
# 模拟第三方 API 响应(如果后端支持 mock 模式)
mock.external_api("carrier_recharge", response={"success": True})
```
---
## 测试编写规范
### 基本结构
```python
"""
账号管理流程测试
测试场景:
1. 创建代理商账号流程
2. 账号权限验证流程
...
"""
import pytest
from core.client import APIClient
from core.auth import AuthManager
from core.cleanup import CleanupTracker
class TestAccountFlow:
"""账号管理流程"""
def test_create_agent_account_flow(self, client, auth, tracker, db):
"""
流程:创建代理商账号
步骤:
1. 平台管理员创建店铺
2. 给店铺创建管理员账号
3. 验证新账号能登录
4. 验证只能看到自己店铺的数据
"""
# === 1. 平台管理员创建店铺 ===
auth.as_platform_admin()
resp = client.post("/api/admin/shops", json={
"name": "测试代理商",
"contact": "张三",
"phone": "13800138000"
})
assert resp.ok(), f"创建店铺失败: {resp.msg}"
shop_id = resp.data["id"]
tracker.track("shops", shop_id)
# === 2. 创建店铺管理员账号 ===
resp = client.post("/api/admin/accounts", json={
"username": "test_agent_admin",
"password": "Test123456",
"shop_id": shop_id,
"role_ids": [2] # 假设 2 是店铺管理员角色
})
assert resp.ok(), f"创建账号失败: {resp.msg}"
account_id = resp.data["id"]
tracker.track("admin_accounts", account_id)
# === 3. 验证新账号能登录 ===
auth.login("test_agent_admin", "Test123456")
resp = client.get("/api/admin/auth/me")
assert resp.ok()
assert resp.data["shop_id"] == shop_id
# === 4. 验证只能看到自己店铺数据 ===
resp = client.get("/api/admin/shops")
assert resp.ok()
# 代理商只能看到自己的店铺
assert len(resp.data["list"]) == 1
assert resp.data["list"][0]["id"] == shop_id
```
### 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 文件名 | `test_{模块}_flow.py` | `test_account_flow.py` |
| 类名 | `Test{模块}Flow` | `TestAccountFlow` |
| 方法名 | `test_{流程描述}` | `test_create_agent_account_flow` |
| 方法文档 | 必须包含流程步骤 | 见上方示例 |
### Fixtures 使用
```python
# fixtures/common.py 提供以下通用 fixtures
@pytest.fixture
def client():
"""HTTP 客户端"""
return APIClient()
@pytest.fixture
def auth(client):
"""认证管理器"""
return AuthManager(client)
@pytest.fixture
def db():
"""数据库连接(只读验证用)"""
return get_db_connection()
@pytest.fixture
def tracker(db):
"""数据清理追踪器"""
t = CleanupTracker(db)
yield t
t.cleanup() # 测试结束自动清理
@pytest.fixture
def mock(db):
"""Mock 服务"""
return MockService(db)
```
---
## 异步任务测试规范
### 导入类任务设备导入、IoT卡导入
```python
def test_device_import_flow(self, client, auth, tracker, db):
"""
流程:设备批量导入
步骤:
1. 上传 CSV 文件
2. 创建导入任务
3. 等待任务完成
4. 验证设备入库
"""
auth.as_platform_admin()
# 1. 上传文件
with open("fixtures/devices.csv", "rb") as f:
resp = client.upload("/api/admin/storage/upload", file=f)
file_url = resp.data["url"]
# 2. 创建导入任务
resp = client.post("/api/admin/device-imports", json={
"file_url": file_url,
"carrier_id": 1
})
assert resp.ok()
task_id = resp.data["task_id"]
# 3. 等待任务完成
from core.wait import wait_for_task
result = wait_for_task("device_import", task_id, timeout=60)
assert result["status"] == "completed"
# 4. 验证设备入库
imported_count = db.scalar(
"SELECT count(*) FROM devices WHERE import_task_id = %s",
task_id
)
assert imported_count == 10 # CSV 中有 10 条
# 追踪清理
tracker.track_by_query("devices", f"import_task_id = {task_id}")
```
### 支付回调类任务
```python
def test_recharge_flow(self, client, auth, tracker, db, mock):
"""
流程:充值支付
步骤:
1. 用户创建充值订单
2. 模拟支付成功回调
3. 验证卡片流量增加
"""
auth.as_enterprise(enterprise_id=1)
# 1. 创建充值订单
resp = client.post("/api/h5/recharge/orders", json={
"card_id": 123,
"package_id": 456
})
assert resp.ok()
order_id = resp.data["order_id"]
tracker.track("orders", order_id)
# 获取支付前流量
before_data = db.scalar(
"SELECT data_balance FROM iot_cards WHERE id = 123"
)
# 2. 模拟支付回调
mock.payment_success(order_id, amount=50.00)
# 3. 验证流量增加
from core.wait import wait_for_condition
wait_for_condition(
condition=lambda: db.scalar(
"SELECT data_balance FROM iot_cards WHERE id = 123"
) > before_data,
timeout=10,
message="等待流量到账"
)
```
---
## 角色权限说明
测试时需要了解系统角色体系:
| 角色 | 说明 | 数据范围 |
|------|------|----------|
| 超级管理员 | 系统最高权限 | 全部数据 |
| 平台管理员 | 平台运营人员 | 全部数据(受权限配置限制) |
| 代理商管理员 | 店铺管理者 | 本店铺 + 下级店铺 |
| 代理商员工 | 店铺普通员工 | 本店铺 |
| 企业管理员 | 企业用户 | 本企业数据 |
---
## 环境配置
### config/local.yaml
```yaml
# 本地开发环境
api:
base_url: "http://localhost:3000"
timeout: 30
database:
host: "localhost"
port: 5432
name: "junhong_dev"
user: "postgres"
password: "postgres"
redis:
host: "localhost"
port: 6379
db: 0
# 预置测试账号
accounts:
super_admin:
username: "superadmin"
password: "Admin123456"
platform_admin:
username: "platform"
password: "Admin123456"
```
### config/remote.yaml
```yaml
# 远程测试环境
api:
base_url: "https://test-api.example.com"
timeout: 30
database:
host: "test-db.example.com"
port: 5432
name: "junhong_test"
user: "test_user"
password: "test_password"
redis:
host: "test-redis.example.com"
port: 6379
db: 0
```
### 切换环境
```bash
# 使用本地环境(默认)
pytest
# 使用远程环境
TEST_ENV=remote pytest
```
---
## 数据清理规则
### 清理原则
1. **只删除测试创建的数据** - 通过 tracker 追踪
2. **逆序删除** - 先删依赖方,再删被依赖方
3. **软删除优先** - 如果表支持软删除,使用软删除
4. **级联处理** - 自动处理关联数据
### 追踪方式
```python
# 方式1追踪单条记录
tracker.track("table_name", record_id)
# 方式2追踪多条记录
tracker.track_many("table_name", [id1, id2, id3])
# 方式3按条件追踪用于批量导入等场景
tracker.track_by_query("devices", "import_task_id = 123")
# 方式4追踪关联数据自动级联
tracker.track_with_relations("shops", shop_id, relations=[
("admin_accounts", "shop_id"),
("shop_packages", "shop_id")
])
```
### 清理顺序配置
```python
# core/cleanup.py 中定义表的依赖关系
TABLE_DEPENDENCIES = {
"admin_accounts": ["shops"], # accounts 依赖 shops
"shop_packages": ["shops", "packages"], # shop_packages 依赖 shops 和 packages
"orders": ["iot_cards", "packages"],
# ... 根据实际表结构配置
}
```
---
## 常见问题
### Q: 如何处理需要真实第三方服务的测试?
A: 两种方式:
1. 使用 Mock 模式(推荐):直接模拟回调或写入预期数据
2. 如果必须真实调用:在配置中标记为 `skip_in_mock_mode`,仅在集成环境运行
### Q: 测试数据影响了其他人怎么办?
A:
1. 本地环境:数据隔离,不影响他人
2. 远程环境:使用唯一标识(如 UUID创建数据确保清理
### Q: 异步任务超时怎么办?
A:
1. 检查任务是否真的启动了
2. 检查 worker 是否在运行
3. 增加超时时间(最后手段)
4. 查看任务日志定位问题
---
## 接口文档OpenAPI
**重要**生成测试时AI 助手必须首先查阅 `flow_tests/openapi.yaml` 获取准确的接口信息。
### 文件位置
```
flow_tests/openapi.yaml # OpenAPI 3.0 规范文档
```
### 文档结构
```yaml
# openapi.yaml 结构
components:
schemas: # 数据模型定义DTO
DtoCreateShopRequest:
properties:
shop_name: { type: string }
shop_code: { type: string }
# ...
required: [shop_name, shop_code]
paths: # API 路径定义
/api/admin/shops:
post:
summary: 创建店铺
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoCreateShopRequest'
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/DtoShopResponse'
```
### AI 助手使用方式
1. **查找接口**:根据用户描述的流程,在 `paths` 中找到对应的 API 路径
2. **获取请求参数**:从 `requestBody.schema` 获取请求体结构
3. **获取响应格式**:从 `responses.200.schema` 获取响应体结构
4. **理解字段含义**:从 `components.schemas` 中查看字段的 `description`
### 示例:查找创建店铺接口
用户说:"创建一个店铺"
AI 助手应该:
1. 读取 `openapi.yaml`
2. 搜索 `shops` 相关路径 → 找到 `POST /api/admin/shops`
3. 查看 `DtoCreateShopRequest` 了解必填字段:`shop_name`, `shop_code`, `init_username`, `init_phone`, `init_password`
4. 生成正确的测试代码
```python
resp = client.post("/api/admin/shops", json={
"shop_name": "测试店铺",
"shop_code": "TEST001",
"init_username": "test_admin",
"init_phone": "13800138000",
"init_password": "Test123456",
})
```
---
## AI 助手工作流程
当用户描述业务流程后AI 助手按以下步骤工作:
1. **理解流程**:分析用户描述,提取操作角色、步骤、预期结果
2. **查阅接口文档**:读取 `flow_tests/openapi.yaml` 获取准确的接口路径、请求参数、响应格式
3. **生成测试**:按照本规范生成测试代码,使用 OpenAPI 文档中的字段定义
4. **补充清理**:添加数据追踪和清理逻辑
5. **运行验证**:执行测试确保通过
6. **报告结果**:告知用户测试结果,如发现问题则报告
### 接口查找优先级
| 优先级 | 来源 | 说明 |
|--------|------|------|
| 1 | `flow_tests/openapi.yaml` | **首选**,最准确的接口定义 |
| 2 | 项目代码 `internal/handler/` | OpenAPI 未覆盖时查找源码 |
| 3 | 项目代码 `internal/router/` | 确认路由注册 |
---
## 版本记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2026-02-02 | 初始版本 |
| 1.1 | 2026-02-02 | 增加 OpenAPI 接口文档规范 |

View File

@@ -0,0 +1,37 @@
# 本地开发环境配置
# 使用方式: TEST_ENV=local pytest (默认)
api:
base_url: "http://localhost:3000"
timeout: 30
database:
host: "localhost"
port: 5432
name: "junhong_dev"
user: "postgres"
password: "postgres"
redis:
host: "localhost"
port: 6379
db: 0
# 预置测试账号
# 根据实际系统配置修改
accounts:
super_admin:
username: "superadmin"
password: "Admin123456"
platform_admin:
username: "platform"
password: "Admin123456"
# Mock 配置
mock:
# 是否启用 Mock 模式(跳过真实第三方调用)
enabled: true
# 支付回调 Mock
payment:
auto_success: true
delay_seconds: 1

View File

@@ -0,0 +1,34 @@
# 远程测试环境配置
# 使用方式: TEST_ENV=remote pytest
api:
base_url: "https://test-api.example.com"
timeout: 30
database:
host: "test-db.example.com"
port: 5432
name: "junhong_test"
user: "test_user"
password: "test_password"
redis:
host: "test-redis.example.com"
port: 6379
db: 0
# 预置测试账号
accounts:
super_admin:
username: "superadmin"
password: "Admin123456"
platform_admin:
username: "platform"
password: "Admin123456"
# Mock 配置
mock:
enabled: true
payment:
auto_success: true
delay_seconds: 1

View File

@@ -0,0 +1,95 @@
"""
配置管理模块
支持多环境配置切换:
- TEST_ENV=local 使用本地配置(默认)
- TEST_ENV=remote 使用远程配置
"""
import os
from pathlib import Path
from typing import Any, Optional
import yaml
class Settings:
"""配置管理器"""
_instance: Optional['Settings'] = None
_config: dict = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._load_config()
return cls._instance
def _load_config(self):
"""加载配置文件"""
env = os.getenv("TEST_ENV", "local")
config_dir = Path(__file__).parent
config_file = config_dir / f"{env}.yaml"
if not config_file.exists():
raise FileNotFoundError(f"配置文件不存在: {config_file}")
with open(config_file, "r", encoding="utf-8") as f:
self._config = yaml.safe_load(f)
print(f"[配置] 已加载 {env} 环境配置")
def get(self, key: str, default: Any = None) -> Any:
"""
获取配置值,支持点号分隔的路径
示例:
settings.get("api.base_url")
settings.get("database.host")
"""
keys = key.split(".")
value = self._config
for k in keys:
if isinstance(value, dict):
value = value.get(k)
else:
return default
if value is None:
return default
return value
@property
def api_base_url(self) -> str:
return self.get("api.base_url", "http://localhost:3000")
@property
def api_timeout(self) -> int:
return self.get("api.timeout", 30)
@property
def db_config(self) -> dict:
return {
"host": self.get("database.host", "localhost"),
"port": self.get("database.port", 5432),
"database": self.get("database.name", "junhong_dev"),
"user": self.get("database.user", "postgres"),
"password": self.get("database.password", "postgres"),
}
@property
def redis_config(self) -> dict:
return {
"host": self.get("redis.host", "localhost"),
"port": self.get("redis.port", 6379),
"db": self.get("redis.db", 0),
}
def get_account(self, role: str) -> dict:
"""获取预置账号信息"""
return self.get(f"accounts.{role}", {})
# 全局配置实例
settings = Settings()

6
flow_tests/conftest.py Normal file
View File

@@ -0,0 +1,6 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
pytest_plugins = ["fixtures.common"]

View File

@@ -0,0 +1,17 @@
from .client import APIClient, APIResponse
from .auth import AuthManager
from .database import Database
from .cleanup import CleanupTracker
from .mock import MockService
from .wait import wait_for_task, wait_for_condition
__all__ = [
"APIClient",
"APIResponse",
"AuthManager",
"Database",
"CleanupTracker",
"MockService",
"wait_for_task",
"wait_for_condition",
]

71
flow_tests/core/auth.py Normal file
View File

@@ -0,0 +1,71 @@
import logging
from typing import Optional
from config.settings import settings
from .client import APIClient
logger = logging.getLogger(__name__)
class AuthManager:
def __init__(self, client: APIClient):
self.client = client
self._current_role: Optional[str] = None
@property
def current_role(self) -> Optional[str]:
return self._current_role
def login(self, username: str, password: str) -> bool:
resp = self.client.login(username, password)
if resp.ok():
self._current_role = "custom"
return True
logger.error(f"登录失败: {resp.msg}")
return False
def logout(self):
self.client.clear_token()
self._current_role = None
def _login_preset_account(self, role: str) -> bool:
account = settings.get_account(role)
if not account:
raise ValueError(f"未配置 {role} 账号,请检查配置文件")
if self.login(account["username"], account["password"]):
self._current_role = role
return True
return False
def as_super_admin(self) -> 'AuthManager':
self._login_preset_account("super_admin")
return self
def as_platform_admin(self) -> 'AuthManager':
self._login_preset_account("platform_admin")
return self
def as_agent(self, shop_id: int, username: Optional[str] = None, password: Optional[str] = None) -> 'AuthManager':
if username and password:
self.login(username, password)
else:
account = settings.get_account(f"agent_{shop_id}")
if account:
self.login(account["username"], account["password"])
else:
raise ValueError(f"未配置 agent_{shop_id} 账号,请提供用户名密码或在配置文件中添加")
self._current_role = f"agent_{shop_id}"
return self
def as_enterprise(self, enterprise_id: int, username: Optional[str] = None, password: Optional[str] = None) -> 'AuthManager':
if username and password:
self.login(username, password)
else:
account = settings.get_account(f"enterprise_{enterprise_id}")
if account:
self.login(account["username"], account["password"])
else:
raise ValueError(f"未配置 enterprise_{enterprise_id} 账号,请提供用户名密码或在配置文件中添加")
self._current_role = f"enterprise_{enterprise_id}"
return self

113
flow_tests/core/cleanup.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from collections import defaultdict
from typing import Dict, List, Optional, Tuple
from .database import Database
logger = logging.getLogger(__name__)
TABLE_DEPENDENCIES: Dict[str, List[str]] = {
"tb_account": ["tb_shop", "tb_enterprise"],
"tb_account_role": ["tb_account", "tb_role"],
"tb_shop": [],
"tb_enterprise": ["tb_shop"],
"tb_role": [],
"tb_permission": [],
"tb_role_permission": ["tb_role", "tb_permission"],
"tb_device": ["tb_shop"],
"tb_iot_card": ["tb_shop", "tb_device"],
"tb_package": [],
"tb_package_series": [],
"tb_order": ["tb_iot_card", "tb_package"],
"tb_shop_package": ["tb_shop", "tb_package"],
"tb_shop_package_series": ["tb_shop", "tb_package_series"],
}
SOFT_DELETE_TABLES = {
"tb_account", "tb_shop", "tb_enterprise", "tb_role",
"tb_device", "tb_iot_card", "tb_package", "tb_order",
}
class CleanupTracker:
def __init__(self, db: Optional[Database] = None):
self.db = db or Database()
self._tracked: Dict[str, List[int]] = defaultdict(list)
self._tracked_queries: List[Tuple[str, str]] = []
def track(self, table: str, record_id: int):
self._tracked[table].append(record_id)
logger.debug(f"追踪: {table}#{record_id}")
def track_many(self, table: str, record_ids: List[int]):
self._tracked[table].extend(record_ids)
logger.debug(f"追踪: {table}#{record_ids}")
def track_by_query(self, table: str, where_clause: str):
self._tracked_queries.append((table, where_clause))
logger.debug(f"追踪查询: {table} WHERE {where_clause}")
def track_with_relations(self, table: str, record_id: int, relations: List[Tuple[str, str]]):
self.track(table, record_id)
for rel_table, fk_column in relations:
ids = self.db.query(
f"SELECT id FROM {rel_table} WHERE {fk_column} = %s",
(record_id,)
)
for row in ids:
self.track(rel_table, row["id"])
def cleanup(self):
logger.info("开始清理测试数据...")
for table, where_clause in reversed(self._tracked_queries):
self._delete_by_query(table, where_clause)
sorted_tables = self._sort_by_dependency()
for table in sorted_tables:
ids = self._tracked.get(table, [])
if ids:
self._delete_records(table, ids)
logger.info("测试数据清理完成")
def _sort_by_dependency(self) -> List[str]:
tables = list(self._tracked.keys())
def get_order(t: str) -> int:
deps = TABLE_DEPENDENCIES.get(t, [])
if not deps:
return 0
return max(get_order(d) for d in deps if d in tables) + 1 if any(d in tables for d in deps) else 0
return sorted(tables, key=get_order, reverse=True)
def _delete_records(self, table: str, ids: List[int]):
if not ids:
return
placeholders = ",".join(["%s"] * len(ids))
if table in SOFT_DELETE_TABLES:
sql = f"UPDATE {table} SET deleted_at = NOW() WHERE id IN ({placeholders}) AND deleted_at IS NULL"
else:
sql = f"DELETE FROM {table} WHERE id IN ({placeholders})"
try:
count = self.db.execute(sql, tuple(ids))
logger.info(f"清理 {table}: {count} 条记录")
except Exception as e:
logger.error(f"清理 {table} 失败: {e}")
def _delete_by_query(self, table: str, where_clause: str):
if table in SOFT_DELETE_TABLES:
sql = f"UPDATE {table} SET deleted_at = NOW() WHERE {where_clause} AND deleted_at IS NULL"
else:
sql = f"DELETE FROM {table} WHERE {where_clause}"
try:
count = self.db.execute(sql)
logger.info(f"清理 {table} (查询): {count} 条记录")
except Exception as e:
logger.error(f"清理 {table} (查询) 失败: {e}")

100
flow_tests/core/client.py Normal file
View File

@@ -0,0 +1,100 @@
import logging
from dataclasses import dataclass
from typing import Any, Optional
import requests
from config.settings import settings
logger = logging.getLogger(__name__)
@dataclass
class APIResponse:
status_code: int
code: int
msg: str
data: Any
raw: dict
def ok(self) -> bool:
return self.code == 0
def __bool__(self) -> bool:
return self.ok()
class APIClient:
def __init__(self, base_url: Optional[str] = None):
self.base_url = base_url or settings.api_base_url
self.timeout = settings.api_timeout
self.token: Optional[str] = None
self.session = requests.Session()
def set_token(self, token: str):
self.token = token
self.session.headers["Authorization"] = f"Bearer {token}"
def clear_token(self):
self.token = None
self.session.headers.pop("Authorization", None)
def _request(self, method: str, path: str, **kwargs) -> APIResponse:
url = f"{self.base_url}{path}"
kwargs.setdefault("timeout", self.timeout)
logger.info(f"{method} {path}")
try:
resp = self.session.request(method, url, **kwargs)
except requests.exceptions.RequestException as e:
logger.error(f"请求失败: {e}")
return APIResponse(status_code=0, code=-1, msg=str(e), data=None, raw={})
try:
raw = resp.json()
except ValueError:
return APIResponse(
status_code=resp.status_code, code=-1,
msg="响应不是有效的 JSON", data=None, raw={}
)
return APIResponse(
status_code=resp.status_code,
code=raw.get("code", -1),
msg=raw.get("msg", ""),
data=raw.get("data"),
raw=raw,
)
def get(self, path: str, params: Optional[dict] = None, **kwargs) -> APIResponse:
return self._request("GET", path, params=params, **kwargs)
def post(self, path: str, json: Optional[dict] = None, **kwargs) -> APIResponse:
return self._request("POST", path, json=json, **kwargs)
def put(self, path: str, json: Optional[dict] = None, **kwargs) -> APIResponse:
return self._request("PUT", path, json=json, **kwargs)
def delete(self, path: str, **kwargs) -> APIResponse:
return self._request("DELETE", path, **kwargs)
def patch(self, path: str, json: Optional[dict] = None, **kwargs) -> APIResponse:
return self._request("PATCH", path, json=json, **kwargs)
def upload(self, path: str, file, field_name: str = "file", **kwargs) -> APIResponse:
files = {field_name: file}
return self._request("POST", path, files=files, **kwargs)
def login(self, username: str, password: str, login_path: str = "/api/admin/auth/login") -> APIResponse:
resp = self.post(login_path, json={
"username": username,
"password": password,
})
if resp.ok() and resp.data:
token = resp.data.get("token") or resp.data.get("access_token")
if token:
self.set_token(token)
logger.info(f"登录成功: {username}")
return resp

View File

@@ -0,0 +1,69 @@
import logging
from typing import Any, List, Optional
import psycopg2
from psycopg2.extras import RealDictCursor
from config.settings import settings
logger = logging.getLogger(__name__)
class Database:
_instance: Optional['Database'] = None
_conn = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._connect()
return cls._instance
def _connect(self):
config = settings.db_config
self._conn = psycopg2.connect(
host=config["host"],
port=config["port"],
database=config["database"],
user=config["user"],
password=config["password"],
cursor_factory=RealDictCursor,
)
self._conn.autocommit = True
logger.info(f"数据库连接成功: {config['host']}:{config['port']}/{config['database']}")
def query(self, sql: str, params: tuple = ()) -> List[dict]:
with self._conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
def query_one(self, sql: str, params: tuple = ()) -> Optional[dict]:
rows = self.query(sql, params)
return rows[0] if rows else None
def scalar(self, sql: str, params: tuple = ()) -> Any:
with self._conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
if row:
return list(row.values())[0]
return None
def execute(self, sql: str, params: tuple = ()) -> int:
with self._conn.cursor() as cur:
cur.execute(sql, params)
return cur.rowcount
def execute_many(self, sql: str, params_list: List[tuple]) -> int:
with self._conn.cursor() as cur:
cur.executemany(sql, params_list)
return cur.rowcount
def close(self):
if self._conn:
self._conn.close()
logger.info("数据库连接已关闭")
def get_db() -> Database:
return Database()

74
flow_tests/core/mock.py Normal file
View File

@@ -0,0 +1,74 @@
import logging
import time
from typing import Any, Optional
import redis
from config.settings import settings
from .database import Database
logger = logging.getLogger(__name__)
class MockService:
def __init__(self, db: Optional[Database] = None):
self.db = db or Database()
self._init_redis()
def _init_redis(self):
config = settings.redis_config
self.redis = redis.Redis(
host=config["host"],
port=config["port"],
db=config["db"],
decode_responses=True,
)
def payment_success(self, order_id: int, amount: float, delay: float = 0):
if delay > 0:
time.sleep(delay)
self.db.execute(
"UPDATE tb_order SET status = %s, paid_at = NOW(), paid_amount = %s WHERE id = %s",
("paid", int(amount * 100), order_id)
)
logger.info(f"模拟支付成功: order_id={order_id}, amount={amount}")
def payment_failed(self, order_id: int, reason: str = "支付失败"):
self.db.execute(
"UPDATE tb_order SET status = %s, fail_reason = %s WHERE id = %s",
("failed", reason, order_id)
)
logger.info(f"模拟支付失败: order_id={order_id}, reason={reason}")
def sms_code(self, phone: str, code: str, expire_seconds: int = 300):
key = f"sms:code:{phone}"
self.redis.setex(key, expire_seconds, code)
logger.info(f"模拟短信验证码: phone={phone}, code={code}")
def task_complete(self, task_type: str, task_id: int, result: Any = None):
self.db.execute(
"UPDATE tb_async_task SET status = %s, result = %s, completed_at = NOW() WHERE task_type = %s AND id = %s",
("completed", str(result) if result else None, task_type, task_id)
)
logger.info(f"模拟任务完成: {task_type}#{task_id}")
def task_failed(self, task_type: str, task_id: int, error: str):
self.db.execute(
"UPDATE tb_async_task SET status = %s, error = %s, completed_at = NOW() WHERE task_type = %s AND id = %s",
("failed", error, task_type, task_id)
)
logger.info(f"模拟任务失败: {task_type}#{task_id}, error={error}")
def card_data_balance(self, card_id: int, balance_mb: int):
self.db.execute(
"UPDATE tb_iot_card SET data_balance = %s WHERE id = %s",
(balance_mb, card_id)
)
logger.info(f"模拟卡片流量: card_id={card_id}, balance={balance_mb}MB")
def external_api_response(self, api_name: str, response: dict):
key = f"mock:api:{api_name}"
import json
self.redis.setex(key, 300, json.dumps(response))
logger.info(f"模拟外部 API: {api_name}")

99
flow_tests/core/wait.py Normal file
View File

@@ -0,0 +1,99 @@
import logging
import time
from typing import Any, Callable, Optional
from .database import Database
logger = logging.getLogger(__name__)
class TimeoutError(Exception):
pass
def wait_for_condition(
condition: Callable[[], bool],
timeout: float = 30,
poll_interval: float = 1,
message: str = "等待条件满足",
) -> bool:
start = time.time()
while time.time() - start < timeout:
try:
if condition():
logger.info(f"{message}: 成功 (耗时 {time.time() - start:.1f}s)")
return True
except Exception as e:
logger.debug(f"{message}: 检查失败 - {e}")
time.sleep(poll_interval)
raise TimeoutError(f"{message}: 超时 ({timeout}s)")
def wait_for_task(
task_type: str,
task_id: int,
timeout: float = 60,
poll_interval: float = 2,
db: Optional[Database] = None,
) -> dict:
db = db or Database()
start = time.time()
while time.time() - start < timeout:
row = db.query_one(
"SELECT status, result, error FROM tb_async_task WHERE task_type = %s AND id = %s",
(task_type, task_id)
)
if not row:
raise ValueError(f"任务不存在: {task_type}#{task_id}")
if row["status"] in ("completed", "failed"):
logger.info(f"任务完成: {task_type}#{task_id}, status={row['status']}, 耗时 {time.time() - start:.1f}s")
return dict(row)
logger.debug(f"等待任务: {task_type}#{task_id}, 当前状态={row['status']}")
time.sleep(poll_interval)
raise TimeoutError(f"任务超时: {task_type}#{task_id} ({timeout}s)")
def wait_for_db_condition(
sql: str,
params: tuple = (),
expected: Any = True,
timeout: float = 30,
poll_interval: float = 1,
db: Optional[Database] = None,
) -> Any:
db = db or Database()
def check():
result = db.scalar(sql, params)
if callable(expected):
return expected(result)
return result == expected
wait_for_condition(check, timeout, poll_interval, f"等待 SQL 条件: {sql[:50]}...")
return db.scalar(sql, params)
def wait_for_record_count(
table: str,
where_clause: str,
expected_count: int,
timeout: float = 30,
db: Optional[Database] = None,
) -> int:
db = db or Database()
sql = f"SELECT COUNT(*) FROM {table} WHERE {where_clause}"
def check():
count = db.scalar(sql)
return count >= expected_count
wait_for_condition(check, timeout, 1, f"等待 {table} 记录数 >= {expected_count}")
return db.scalar(sql)

View File

@@ -0,0 +1,3 @@
from .common import client, auth, db, tracker, mock
__all__ = ["client", "auth", "db", "tracker", "mock"]

View File

@@ -0,0 +1,38 @@
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.client import APIClient
from core.auth import AuthManager
from core.database import Database
from core.cleanup import CleanupTracker
from core.mock import MockService
@pytest.fixture(scope="function")
def client():
return APIClient()
@pytest.fixture(scope="function")
def auth(client):
return AuthManager(client)
@pytest.fixture(scope="module")
def db():
return Database()
@pytest.fixture(scope="function")
def tracker(db):
t = CleanupTracker(db)
yield t
t.cleanup()
@pytest.fixture(scope="function")
def mock(db):
return MockService(db)

16019
flow_tests/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

30
flow_tests/pytest.ini Normal file
View File

@@ -0,0 +1,30 @@
[pytest]
# 测试目录
testpaths = tests
# Python 文件匹配
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# 默认参数
addopts =
-v
--tb=short
--strict-markers
# 标记定义
markers =
slow: 标记为慢速测试
mock: 需要 Mock 服务的测试
integration: 集成测试(需要完整环境)
smoke: 冒烟测试
# 日志配置
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)s] %(message)s
log_cli_date_format = %H:%M:%S
# 超时设置(需要 pytest-timeout 插件)
# timeout = 60

View File

@@ -0,0 +1,22 @@
# 流程测试依赖
# 测试框架
pytest>=7.4.0
pytest-html>=4.1.0 # HTML 报告
pytest-ordering>=0.6 # 测试排序
# HTTP 客户端
requests>=2.31.0
urllib3>=2.0.0
# 数据库
psycopg2-binary>=2.9.9 # PostgreSQL
redis>=5.0.0 # Redis
# 配置管理
pyyaml>=6.0.1
# 工具
python-dotenv>=1.0.0 # 环境变量
tabulate>=0.9.0 # 表格输出
colorama>=0.4.6 # 彩色输出

View File

View File

@@ -0,0 +1,61 @@
"""
示例流程测试
本文件展示如何编写流程测试,供参考。
删除本文件后不影响测试框架运行。
"""
import pytest
class TestExampleFlow:
"""示例:账号登录流程"""
def test_admin_login_flow(self, client, auth):
"""
流程:超级管理员登录
步骤:
1. 使用超级管理员账号登录
2. 验证能获取用户信息
"""
# === 1. 登录 ===
auth.as_super_admin()
# === 2. 获取用户信息 ===
resp = client.get("/api/admin/auth/me")
# 如果接口存在,验证返回
if resp.status_code == 200:
assert resp.ok(), f"获取用户信息失败: {resp.msg}"
assert resp.data is not None
else:
pytest.skip("接口不存在,跳过测试")
@pytest.mark.skip(reason="示例测试,实际使用时删除此标记")
def test_create_shop_flow(self, client, auth, tracker):
"""
流程:创建店铺
步骤:
1. 平台管理员登录
2. 创建店铺
3. 验证店铺创建成功
"""
# === 1. 登录 ===
auth.as_platform_admin()
# === 2. 创建店铺 ===
resp = client.post("/api/admin/shops", json={
"shop_name": "测试店铺_流程测试",
"contact_name": "测试联系人",
"contact_phone": "13800138000",
})
assert resp.ok(), f"创建店铺失败: {resp.msg}"
shop_id = resp.data["id"]
tracker.track("tb_shop", shop_id)
# === 3. 验证店铺存在 ===
resp = client.get(f"/api/admin/shops/{shop_id}")
assert resp.ok()
assert resp.data["shop_name"] == "测试店铺_流程测试"

View File

@@ -19,6 +19,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Permission: admin.NewPermissionHandler(svc.Permission), Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop), Shop: admin.NewShopHandler(svc.Shop),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate), AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
H5Auth: h5.NewAuthHandler(svc.Auth, validate), H5Auth: h5.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission), ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),

View File

@@ -74,14 +74,15 @@ type services struct {
func initServices(s *stores, deps *Dependencies) *services { func initServices(s *stores, deps *Dependencies) *services {
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopSeriesAllocation) purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopSeriesAllocation)
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog) accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
return &services{ return &services{
Account: accountSvc.New(s.Account, s.Role, s.AccountRole, s.Shop, s.Enterprise, accountAudit), Account: account,
AccountAudit: accountAudit, AccountAudit: accountAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission), Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, deps.Redis), Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger), PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
Shop: shopSvc.New(s.Shop, s.Account), Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, deps.TokenManager, deps.Logger), Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, deps.TokenManager, deps.Logger),
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionRecord), ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.Wallet, s.WalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.Wallet, s.WalletTransaction, s.CommissionWithdrawalRequest),

View File

@@ -11,6 +11,7 @@ type stores struct {
Role *postgres.RoleStore Role *postgres.RoleStore
Permission *postgres.PermissionStore Permission *postgres.PermissionStore
AccountRole *postgres.AccountRoleStore AccountRole *postgres.AccountRoleStore
ShopRole *postgres.ShopRoleStore
RolePermission *postgres.RolePermissionStore RolePermission *postgres.RolePermissionStore
PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
@@ -50,6 +51,7 @@ func initStores(deps *Dependencies) *stores {
Role: postgres.NewRoleStore(deps.DB), Role: postgres.NewRoleStore(deps.DB),
Permission: postgres.NewPermissionStore(deps.DB), Permission: postgres.NewPermissionStore(deps.DB),
AccountRole: postgres.NewAccountRoleStore(deps.DB, deps.Redis), AccountRole: postgres.NewAccountRoleStore(deps.DB, deps.Redis),
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis), RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),

View File

@@ -17,6 +17,7 @@ type Handlers struct {
Permission *admin.PermissionHandler Permission *admin.PermissionHandler
PersonalCustomer *app.PersonalCustomerHandler PersonalCustomer *app.PersonalCustomerHandler
Shop *admin.ShopHandler Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler AdminAuth *admin.AuthHandler
H5Auth *h5.AuthHandler H5Auth *h5.AuthHandler
ShopCommission *admin.ShopCommissionHandler ShopCommission *admin.ShopCommissionHandler

View File

@@ -0,0 +1,75 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type ShopRoleHandler struct {
service *shopService.Service
}
func NewShopRoleHandler(service *shopService.Service) *ShopRoleHandler {
return &ShopRoleHandler{service: service}
}
func (h *ShopRoleHandler) AssignShopRoles(c *fiber.Ctx) error {
shopIDStr := c.Params("shop_id")
shopID, err := strconv.ParseUint(shopIDStr, 10, 32)
if err != nil {
return errors.New(errors.CodeInvalidParam, "店铺ID格式错误")
}
var req dto.AssignShopRolesRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.AssignRolesToShop(c.UserContext(), uint(shopID), req.RoleIDs)
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *ShopRoleHandler) GetShopRoles(c *fiber.Ctx) error {
shopIDStr := c.Params("shop_id")
shopID, err := strconv.ParseUint(shopIDStr, 10, 32)
if err != nil {
return errors.New(errors.CodeInvalidParam, "店铺ID格式错误")
}
result, err := h.service.GetShopRoles(c.UserContext(), uint(shopID))
if err != nil {
return err
}
return response.Success(c, result)
}
func (h *ShopRoleHandler) DeleteShopRole(c *fiber.Ctx) error {
shopIDStr := c.Params("shop_id")
shopID, err := strconv.ParseUint(shopIDStr, 10, 32)
if err != nil {
return errors.New(errors.CodeInvalidParam, "店铺ID格式错误")
}
roleIDStr := c.Params("role_id")
roleID, err := strconv.ParseUint(roleIDStr, 10, 32)
if err != nil {
return errors.New(errors.CodeInvalidParam, "角色ID格式错误")
}
if err := h.service.DeleteShopRole(c.UserContext(), uint(shopID), uint(roleID)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,33 @@
package dto
// AssignShopRolesRequest 分配店铺角色请求
type AssignShopRolesRequest struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
RoleIDs []uint `json:"role_ids" validate:"required" description:"角色ID列表"`
}
// GetShopRolesRequest 查询店铺角色请求
type GetShopRolesRequest struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
}
// DeleteShopRoleRequest 删除店铺角色请求
type DeleteShopRoleRequest struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
RoleID uint `json:"-" params:"role_id" path:"role_id" validate:"required" description:"角色ID"`
}
// ShopRoleResponse 店铺-角色关联响应
type ShopRoleResponse struct {
ShopID uint `json:"shop_id" description:"店铺ID"`
RoleID uint `json:"role_id" description:"角色ID"`
RoleName string `json:"role_name" description:"角色名称"`
RoleDesc string `json:"role_desc" description:"角色描述"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
}
// ShopRolesResponse 店铺的角色列表响应
type ShopRolesResponse struct {
ShopID uint `json:"shop_id" description:"店铺ID"`
Roles []*ShopRoleResponse `json:"roles" description:"角色列表"`
}

View File

@@ -0,0 +1,24 @@
package model
import (
"time"
"gorm.io/gorm"
)
// ShopRole 店铺-角色关联模型
type ShopRole struct {
ID uint `gorm:"column:id;primarykey;comment:主键ID" json:"id"`
ShopID uint `gorm:"column:shop_id;not null;index;uniqueIndex:uq_shop_role_shop_id_role_id,where:deleted_at IS NULL;comment:店铺ID" json:"shop_id"`
RoleID uint `gorm:"column:role_id;not null;index;uniqueIndex:uq_shop_role_shop_id_role_id,where:deleted_at IS NULL;comment:角色ID" json:"role_id"`
Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;comment:更新人ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (ShopRole) TableName() string {
return "tb_shop_role"
}

View File

@@ -24,6 +24,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.Shop != nil { if handlers.Shop != nil {
registerShopRoutes(authGroup, handlers.Shop, doc, basePath) registerShopRoutes(authGroup, handlers.Shop, doc, basePath)
} }
if handlers.ShopRole != nil {
registerShopRoleRoutes(authGroup, handlers.ShopRole, doc, basePath)
}
if handlers.ShopCommission != nil { if handlers.ShopCommission != nil {
registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath) registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath)

View File

@@ -45,6 +45,35 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
}) })
} }
func registerShopRoleRoutes(router fiber.Router, handler *admin.ShopRoleHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "POST", "/:shop_id/roles", handler.AssignShopRoles, RouteSpec{
Summary: "分配店铺默认角色",
Tags: []string{"店铺管理"},
Input: new(dto.AssignShopRolesRequest),
Output: new(dto.ShopRolesResponse),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/roles", handler.GetShopRoles, RouteSpec{
Summary: "查询店铺默认角色",
Tags: []string{"店铺管理"},
Input: new(dto.GetShopRolesRequest),
Output: new(dto.ShopRolesResponse),
Auth: true,
})
Register(shops, doc, groupPath, "DELETE", "/:shop_id/roles/:role_id", handler.DeleteShopRole, RouteSpec{
Summary: "删除店铺默认角色",
Tags: []string{"店铺管理"},
Input: new(dto.DeleteShopRoleRequest),
Output: nil,
Auth: true,
})
}
func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommissionHandler, doc *openapi.Generator, basePath string) { func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommissionHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops") shops := router.Group("/shops")
groupPath := basePath + "/shops" groupPath := basePath + "/shops"

View File

@@ -0,0 +1,37 @@
package account
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
func (s *Service) GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error) {
account, err := s.accountStore.GetByID(ctx, accountID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
}
if account.UserType == constants.UserTypeSuperAdmin {
return []uint{}, nil
}
accountRoles, err := s.accountRoleStore.GetRoleIDsByAccountID(ctx, accountID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询账号角色失败")
}
if len(accountRoles) > 0 {
return accountRoles, nil
}
if account.UserType == constants.UserTypeAgent && account.ShopID != nil {
shopRoles, err := s.shopRoleStore.GetRoleIDsByShopID(ctx, *account.ShopID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺角色失败")
}
return shopRoles, nil
}
return []uint{}, nil
}

View File

@@ -0,0 +1,211 @@
package account
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRoleIDsForAccount(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
accountStore := postgres.NewAccountStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
service := New(
accountStore,
roleStore,
accountRoleStore,
shopRoleStore,
nil,
nil,
nil,
)
ctx := context.Background()
t.Run("超级管理员返回空数组", func(t *testing.T) {
account := &model.Account{
Username: "admin_roletest",
Phone: "13800010001",
Password: "hashed",
UserType: constants.UserTypeSuperAdmin,
Status: constants.StatusEnabled,
}
require.NoError(t, accountStore.Create(ctx, account))
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
require.NoError(t, err)
assert.Empty(t, roleIDs)
})
t.Run("平台用户返回账号级角色", func(t *testing.T) {
account := &model.Account{
Username: "platform_roletest",
Phone: "13800010002",
Password: "hashed",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
require.NoError(t, accountStore.Create(ctx, account))
role := &model.Role{
RoleName: "平台管理员",
RoleType: constants.RoleTypePlatform,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, role))
accountRole := &model.AccountRole{
AccountID: account.ID,
RoleID: role.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, accountRoleStore.Create(ctx, accountRole))
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
require.NoError(t, err)
assert.Equal(t, []uint{role.ID}, roleIDs)
})
t.Run("代理账号有账号级角色,不继承店铺角色", func(t *testing.T) {
shopID := uint(1)
account := &model.Account{
Username: "agent_with_roletest",
Phone: "13800010003",
Password: "hashed",
UserType: constants.UserTypeAgent,
ShopID: &shopID,
Status: constants.StatusEnabled,
}
require.NoError(t, accountStore.Create(ctx, account))
accountRole := &model.Role{
RoleName: "账号角色",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, accountRole))
shopRole := &model.Role{
RoleName: "店铺角色",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, shopRole))
require.NoError(t, accountRoleStore.Create(ctx, &model.AccountRole{
AccountID: account.ID,
RoleID: accountRole.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}))
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
ShopID: shopID,
RoleID: shopRole.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}))
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
require.NoError(t, err)
assert.Equal(t, []uint{accountRole.ID}, roleIDs)
})
t.Run("代理账号无账号级角色,继承店铺角色", func(t *testing.T) {
shopID := uint(2)
account := &model.Account{
Username: "agent_inheritest",
Phone: "13800010004",
Password: "hashed",
UserType: constants.UserTypeAgent,
ShopID: &shopID,
Status: constants.StatusEnabled,
}
require.NoError(t, accountStore.Create(ctx, account))
shopRole := &model.Role{
RoleName: "店铺默认角色",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, shopRole))
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
ShopID: shopID,
RoleID: shopRole.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}))
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
require.NoError(t, err)
assert.Equal(t, []uint{shopRole.ID}, roleIDs)
})
t.Run("代理账号无角色且店铺无角色,返回空数组", func(t *testing.T) {
shopID := uint(3)
account := &model.Account{
Username: "agent_notest",
Phone: "13800010005",
Password: "hashed",
UserType: constants.UserTypeAgent,
ShopID: &shopID,
Status: constants.StatusEnabled,
}
require.NoError(t, accountStore.Create(ctx, account))
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
require.NoError(t, err)
assert.Empty(t, roleIDs)
})
t.Run("企业账号返回账号级角色", func(t *testing.T) {
enterpriseID := uint(1)
account := &model.Account{
Username: "enterprise_roletest",
Phone: "13800010006",
Password: "hashed",
UserType: constants.UserTypeEnterprise,
EnterpriseID: &enterpriseID,
Status: constants.StatusEnabled,
}
require.NoError(t, accountStore.Create(ctx, account))
role := &model.Role{
RoleName: "企业管理员",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, role))
accountRole := &model.AccountRole{
AccountID: account.ID,
RoleID: role.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, accountRoleStore.Create(ctx, accountRole))
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
require.NoError(t, err)
assert.Equal(t, []uint{role.ID}, roleIDs)
})
}

View File

@@ -22,6 +22,7 @@ type Service struct {
accountStore *postgres.AccountStore accountStore *postgres.AccountStore
roleStore *postgres.RoleStore roleStore *postgres.RoleStore
accountRoleStore *postgres.AccountRoleStore accountRoleStore *postgres.AccountRoleStore
shopRoleStore *postgres.ShopRoleStore
shopStore middleware.ShopStoreInterface shopStore middleware.ShopStoreInterface
enterpriseStore middleware.EnterpriseStoreInterface enterpriseStore middleware.EnterpriseStoreInterface
auditService AuditServiceInterface auditService AuditServiceInterface
@@ -36,6 +37,7 @@ func New(
accountStore *postgres.AccountStore, accountStore *postgres.AccountStore,
roleStore *postgres.RoleStore, roleStore *postgres.RoleStore,
accountRoleStore *postgres.AccountRoleStore, accountRoleStore *postgres.AccountRoleStore,
shopRoleStore *postgres.ShopRoleStore,
shopStore middleware.ShopStoreInterface, shopStore middleware.ShopStoreInterface,
enterpriseStore middleware.EnterpriseStoreInterface, enterpriseStore middleware.EnterpriseStoreInterface,
auditService AuditServiceInterface, auditService AuditServiceInterface,
@@ -44,6 +46,7 @@ func New(
accountStore: accountStore, accountStore: accountStore,
roleStore: roleStore, roleStore: roleStore,
accountRoleStore: accountRoleStore, accountRoleStore: accountRoleStore,
shopRoleStore: shopRoleStore,
shopStore: shopStore, shopStore: shopStore,
enterpriseStore: enterpriseStore, enterpriseStore: enterpriseStore,
auditService: auditService, auditService: auditService,

View File

@@ -69,7 +69,7 @@ func TestAccountService_Create_SuperAdminSuccess(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -111,7 +111,7 @@ func TestAccountService_Create_PlatformUserCreatePlatformAccount(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -150,7 +150,7 @@ func TestAccountService_Create_PlatformUserCreateAgentAccount(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -192,7 +192,7 @@ func TestAccountService_Create_AgentCreateSubordinateShopAccount(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
agentShopID := uint(10) agentShopID := uint(10)
subordinateShopID := uint(11) subordinateShopID := uint(11)
@@ -238,7 +238,7 @@ func TestAccountService_Create_AgentCreateOtherShopAccountForbidden(t *testing.T
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
agentShopID := uint(10) agentShopID := uint(10)
otherShopID := uint(99) otherShopID := uint(99)
@@ -281,7 +281,7 @@ func TestAccountService_Create_AgentCreatePlatformAccountForbidden(t *testing.T)
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -318,7 +318,7 @@ func TestAccountService_Create_EnterpriseUserForbidden(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -355,7 +355,7 @@ func TestAccountService_Create_UsernameDuplicate(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -404,7 +404,7 @@ func TestAccountService_Create_PhoneDuplicate(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -453,7 +453,7 @@ func TestAccountService_Create_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -488,7 +488,7 @@ func TestAccountService_Update_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -534,7 +534,7 @@ func TestAccountService_Update_NotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -565,7 +565,7 @@ func TestAccountService_Update_AgentUnauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -619,7 +619,7 @@ func TestAccountService_Update_UsernameDuplicate(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -676,7 +676,7 @@ func TestAccountService_Update_PhoneDuplicate(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -735,7 +735,7 @@ func TestAccountService_Delete_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -778,7 +778,7 @@ func TestAccountService_Delete_NotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -804,7 +804,7 @@ func TestAccountService_Delete_AgentUnauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -855,7 +855,7 @@ func TestAccountService_AssignRoles_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -906,7 +906,7 @@ func TestAccountService_AssignRoles_SuperAdminForbidden(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -947,7 +947,7 @@ func TestAccountService_AssignRoles_RoleTypeMismatch(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -997,7 +997,7 @@ func TestAccountService_AssignRoles_EmptyArrayClearsRoles(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1049,7 +1049,7 @@ func TestAccountService_RemoveRole_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1104,7 +1104,7 @@ func TestAccountService_RemoveRole_AccountNotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1132,7 +1132,7 @@ func TestAccountService_GetRoles_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1183,7 +1183,7 @@ func TestAccountService_GetRoles_EmptyArray(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1224,7 +1224,7 @@ func TestAccountService_List_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1269,7 +1269,7 @@ func TestAccountService_List_FilterByUsername(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1318,7 +1318,7 @@ func TestAccountService_ValidatePassword_Correct(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1357,7 +1357,7 @@ func TestAccountService_ValidatePassword_Incorrect(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1398,7 +1398,7 @@ func TestAccountService_UpdatePassword_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1449,7 +1449,7 @@ func TestAccountService_UpdateStatus_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1494,7 +1494,7 @@ func TestAccountService_Get_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1534,7 +1534,7 @@ func TestAccountService_Get_NotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1560,7 +1560,7 @@ func TestAccountService_UpdatePassword_AccountNotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1586,7 +1586,7 @@ func TestAccountService_UpdateStatus_AccountNotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1612,7 +1612,7 @@ func TestAccountService_UpdatePassword_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -1635,7 +1635,7 @@ func TestAccountService_UpdateStatus_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -1658,7 +1658,7 @@ func TestAccountService_Delete_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -1681,7 +1681,7 @@ func TestAccountService_AssignRoles_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -1704,7 +1704,7 @@ func TestAccountService_RemoveRole_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -1727,7 +1727,7 @@ func TestAccountService_Update_Unauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -1755,7 +1755,7 @@ func TestAccountService_AssignRoles_NotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1781,7 +1781,7 @@ func TestAccountService_GetRoles_NotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1807,7 +1807,7 @@ func TestAccountService_List_FilterByUserType(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1854,7 +1854,7 @@ func TestAccountService_List_FilterByStatus(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1901,7 +1901,7 @@ func TestAccountService_List_FilterByPhone(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1947,7 +1947,7 @@ func TestAccountService_Update_UpdatePassword(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -1993,7 +1993,7 @@ func TestAccountService_Update_UpdateStatus(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2037,7 +2037,7 @@ func TestAccountService_Update_UpdatePhone(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2081,7 +2081,7 @@ func TestAccountService_AssignRoles_AgentUnauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2130,7 +2130,7 @@ func TestAccountService_Create_EnterpriseAccountSuccess(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2173,7 +2173,7 @@ func TestAccountService_Create_AgentMissingShopID(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2209,7 +2209,7 @@ func TestAccountService_Create_EnterpriseMissingEnterpriseID(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2245,7 +2245,7 @@ func TestAccountService_RemoveRole_AgentUnauthorized(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2294,7 +2294,7 @@ func TestAccountService_AssignRoles_MultipleRoles(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2353,7 +2353,7 @@ func TestAccountService_Update_AllFields(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2409,7 +2409,7 @@ func TestAccountService_ListPlatformAccounts_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2454,7 +2454,7 @@ func TestAccountService_CreateSystemAccount_Success(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -2486,7 +2486,7 @@ func TestAccountService_CreateSystemAccount_MissingUsername(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -2517,7 +2517,7 @@ func TestAccountService_CreateSystemAccount_MissingPhone(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -2548,7 +2548,7 @@ func TestAccountService_CreateSystemAccount_MissingPassword(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -2579,7 +2579,7 @@ func TestAccountService_CreateSystemAccount_UsernameDuplicate(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -2625,7 +2625,7 @@ func TestAccountService_CreateSystemAccount_PhoneDuplicate(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := context.Background() ctx := context.Background()
@@ -2671,7 +2671,7 @@ func TestAccountService_ListPlatformAccounts_FilterByUsername(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2717,7 +2717,7 @@ func TestAccountService_ListPlatformAccounts_FilterByPhone(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2763,7 +2763,7 @@ func TestAccountService_ListPlatformAccounts_FilterByStatus(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2810,7 +2810,7 @@ func TestAccountService_Create_PlatformUserCreateEnterpriseAccount(t *testing.T)
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2852,7 +2852,7 @@ func TestAccountService_List_DefaultPagination(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2882,7 +2882,7 @@ func TestAccountService_ListPlatformAccounts_DefaultPagination(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2912,7 +2912,7 @@ func TestAccountService_AssignRoles_RoleNotFound(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2953,7 +2953,7 @@ func TestAccountService_Update_SameUsername(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -2996,7 +2996,7 @@ func TestAccountService_Update_SamePhone(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3039,7 +3039,7 @@ func TestAccountService_AssignRoles_DuplicateRoles(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3086,7 +3086,7 @@ func TestAccountService_Create_PlatformUserCreateAgentWithShop(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3129,7 +3129,7 @@ func TestAccountService_AssignRoles_CustomerAccountType(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3179,7 +3179,7 @@ func TestAccountService_Delete_AgentAccountWithShop(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3231,7 +3231,7 @@ func TestAccountService_Update_AgentAccountWithShop(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3286,7 +3286,7 @@ func TestAccountService_AssignRoles_AgentAccountWithShop(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3344,7 +3344,7 @@ func TestAccountService_RemoveRole_AgentAccountWithShop(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3408,7 +3408,7 @@ func TestAccountService_Create_EnterpriseAccountWithShop(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3454,7 +3454,7 @@ func TestAccountService_Delete_PlatformAccountByAgent(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3501,7 +3501,7 @@ func TestAccountService_Update_PlatformAccountByAgent(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3553,7 +3553,7 @@ func TestAccountService_AssignRoles_PlatformAccountByAgent(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,
@@ -3600,7 +3600,7 @@ func TestAccountService_RemoveRole_PlatformAccountByAgent(t *testing.T) {
mockShop := new(MockShopStore) mockShop := new(MockShopStore)
mockEnterprise := new(MockEnterpriseStore) mockEnterprise := new(MockEnterpriseStore)
svc := New(accountStore, roleStore, accountRoleStore, mockShop, mockEnterprise, mockAudit) svc := New(accountStore, roleStore, accountRoleStore, nil, mockShop, mockEnterprise, mockAudit)
superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ superAdminCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1, UserID: 1,

View File

@@ -22,11 +22,16 @@ import (
// permCodeRegex 权限编码格式验证正则module:action // permCodeRegex 权限编码格式验证正则module:action
var permCodeRegex = regexp.MustCompile(`^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$`) var permCodeRegex = regexp.MustCompile(`^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$`)
type AccountServiceInterface interface {
GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
}
// Service 权限业务服务 // Service 权限业务服务
type Service struct { type Service struct {
permissionStore *postgres.PermissionStore permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore rolePermStore *postgres.RolePermissionStore
accountService AccountServiceInterface
redisClient *redis.Client redisClient *redis.Client
} }
@@ -35,12 +40,14 @@ func New(
permissionStore *postgres.PermissionStore, permissionStore *postgres.PermissionStore,
accountRoleStore *postgres.AccountRoleStore, accountRoleStore *postgres.AccountRoleStore,
rolePermStore *postgres.RolePermissionStore, rolePermStore *postgres.RolePermissionStore,
accountService AccountServiceInterface,
redisClient *redis.Client, redisClient *redis.Client,
) *Service { ) *Service {
return &Service{ return &Service{
permissionStore: permissionStore, permissionStore: permissionStore,
accountRoleStore: accountRoleStore, accountRoleStore: accountRoleStore,
rolePermStore: rolePermStore, rolePermStore: rolePermStore,
accountService: accountService,
redisClient: redisClient, redisClient: redisClient,
} }
} }
@@ -298,7 +305,7 @@ func (s *Service) CheckPermission(ctx context.Context, userID uint, permCode str
} }
} }
roleIDs, err := s.accountRoleStore.GetRoleIDsByAccountID(ctx, userID) roleIDs, err := s.accountService.GetRoleIDsForAccount(ctx, userID)
if err != nil { if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "查询用户角色失败") return false, errors.Wrap(errors.CodeInternalError, err, "查询用户角色失败")
} }

View File

@@ -15,14 +15,23 @@ import (
) )
type Service struct { type Service struct {
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
accountStore *postgres.AccountStore accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
} }
func New(shopStore *postgres.ShopStore, accountStore *postgres.AccountStore) *Service { func New(
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
shopRoleStore *postgres.ShopRoleStore,
roleStore *postgres.RoleStore,
) *Service {
return &Service{ return &Service{
shopStore: shopStore, shopStore: shopStore,
accountStore: accountStore, accountStore: accountStore,
shopRoleStore: shopRoleStore,
roleStore: roleStore,
} }
} }

View File

@@ -0,0 +1,145 @@
package shop
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []uint) ([]*model.ShopRole, error) {
if err := middleware.CanManageShop(ctx, shopID, s.shopStore); err != nil {
return nil, err
}
shop, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
}
currentUserID := middleware.GetUserIDFromContext(ctx)
if len(roleIDs) == 0 {
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "清空店铺角色失败")
}
return []*model.ShopRole{}, nil
}
roles, err := s.roleStore.GetByIDs(ctx, roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询角色失败")
}
if len(roles) != len(roleIDs) {
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
}
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
}
if role.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
}
}
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "删除现有店铺角色失败")
}
shopRoles := make([]*model.ShopRole, 0, len(roleIDs))
for _, roleID := range roleIDs {
shopRole := &model.ShopRole{
ShopID: shop.ID,
RoleID: roleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
shopRoles = append(shopRoles, shopRole)
}
if err := s.shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "批量创建店铺角色失败")
}
return shopRoles, nil
}
func (s *Service) GetShopRoles(ctx context.Context, shopID uint) (*dto.ShopRolesResponse, error) {
if err := middleware.CanManageShop(ctx, shopID, s.shopStore); err != nil {
return nil, err
}
_, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
}
shopRoles, err := s.shopRoleStore.GetByShopID(ctx, shopID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺角色失败")
}
if len(shopRoles) == 0 {
return &dto.ShopRolesResponse{
ShopID: shopID,
Roles: []*dto.ShopRoleResponse{},
}, nil
}
roleIDs := make([]uint, 0, len(shopRoles))
for _, sr := range shopRoles {
roleIDs = append(roleIDs, sr.RoleID)
}
roles, err := s.roleStore.GetByIDs(ctx, roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询角色详情失败")
}
roleMap := make(map[uint]*model.Role)
for _, role := range roles {
roleMap[role.ID] = role
}
responses := make([]*dto.ShopRoleResponse, 0, len(shopRoles))
for _, sr := range shopRoles {
role, exists := roleMap[sr.RoleID]
if !exists {
continue
}
responses = append(responses, &dto.ShopRoleResponse{
ShopID: sr.ShopID,
RoleID: sr.RoleID,
RoleName: role.RoleName,
RoleDesc: role.RoleDesc,
Status: sr.Status,
})
}
return &dto.ShopRolesResponse{
ShopID: shopID,
Roles: responses,
}, nil
}
func (s *Service) DeleteShopRole(ctx context.Context, shopID, roleID uint) error {
if err := middleware.CanManageShop(ctx, shopID, s.shopStore); err != nil {
return err
}
_, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return errors.New(errors.CodeNotFound, "店铺不存在")
}
if err := s.shopRoleStore.Delete(ctx, shopID, roleID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除店铺角色失败")
}
return nil
}

View File

@@ -0,0 +1,243 @@
package shop
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAssignRolesToShop(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := New(shopStore, accountStore, shopRoleStore, roleStore)
shop := &model.Shop{
ShopName: "测试店铺",
ShopCode: "TEST_SHOP_001",
Level: 1,
Status: constants.StatusEnabled,
}
require.NoError(t, tx.Create(shop).Error)
role := &model.Role{
RoleName: "代理店长",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(context.Background(), role))
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeSuperAdmin,
})
t.Run("成功分配单个角色", func(t *testing.T) {
result, err := service.AssignRolesToShop(ctx, shop.ID, []uint{role.ID})
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, shop.ID, result[0].ShopID)
assert.Equal(t, role.ID, result[0].RoleID)
})
t.Run("清空所有角色", func(t *testing.T) {
result, err := service.AssignRolesToShop(ctx, shop.ID, []uint{})
require.NoError(t, err)
assert.Empty(t, result)
roles, err := service.GetShopRoles(ctx, shop.ID)
require.NoError(t, err)
assert.Empty(t, roles.Roles)
})
t.Run("替换现有角色", func(t *testing.T) {
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
ShopID: shop.ID,
RoleID: role.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}))
newRole := &model.Role{
RoleName: "代理经理",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, newRole))
result, err := service.AssignRolesToShop(ctx, shop.ID, []uint{newRole.ID})
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, newRole.ID, result[0].RoleID)
})
t.Run("角色类型校验失败", func(t *testing.T) {
platformRole := &model.Role{
RoleName: "平台角色",
RoleType: constants.RoleTypePlatform,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(ctx, platformRole))
_, err := service.AssignRolesToShop(ctx, shop.ID, []uint{platformRole.ID})
require.Error(t, err)
assert.Contains(t, err.Error(), "店铺只能分配客户角色")
})
t.Run("角色不存在", func(t *testing.T) {
_, err := service.AssignRolesToShop(ctx, shop.ID, []uint{99999})
require.Error(t, err)
assert.Contains(t, err.Error(), "部分角色不存在")
})
t.Run("店铺不存在", func(t *testing.T) {
_, err := service.AssignRolesToShop(ctx, 99999, []uint{role.ID})
require.Error(t, err)
assert.Contains(t, err.Error(), "店铺不存在")
})
}
func TestGetShopRoles(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := New(shopStore, accountStore, shopRoleStore, roleStore)
shop := &model.Shop{
ShopName: "测试店铺2",
ShopCode: "TEST_SHOP_002",
Level: 1,
Status: constants.StatusEnabled,
}
require.NoError(t, tx.Create(shop).Error)
role := &model.Role{
RoleName: "代理店长",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(context.Background(), role))
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeSuperAdmin,
})
t.Run("查询已分配角色", func(t *testing.T) {
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
ShopID: shop.ID,
RoleID: role.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}))
result, err := service.GetShopRoles(ctx, shop.ID)
require.NoError(t, err)
assert.Len(t, result.Roles, 1)
assert.Equal(t, shop.ID, result.ShopID)
assert.Equal(t, role.ID, result.Roles[0].RoleID)
assert.Equal(t, "代理店长", result.Roles[0].RoleName)
})
t.Run("查询未分配角色的店铺", func(t *testing.T) {
emptyShop := &model.Shop{
ShopName: "空店铺",
ShopCode: "EMPTY_SHOP",
Level: 1,
Status: constants.StatusEnabled,
}
require.NoError(t, tx.Create(emptyShop).Error)
result, err := service.GetShopRoles(ctx, emptyShop.ID)
require.NoError(t, err)
assert.Empty(t, result.Roles)
})
t.Run("店铺不存在", func(t *testing.T) {
_, err := service.GetShopRoles(ctx, 99999)
require.Error(t, err)
assert.Contains(t, err.Error(), "店铺不存在")
})
}
func TestDeleteShopRole(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := New(shopStore, accountStore, shopRoleStore, roleStore)
shop := &model.Shop{
ShopName: "测试店铺3",
ShopCode: "TEST_SHOP_003",
Level: 1,
Status: constants.StatusEnabled,
}
require.NoError(t, tx.Create(shop).Error)
role := &model.Role{
RoleName: "代理店长",
RoleType: constants.RoleTypeCustomer,
Status: constants.StatusEnabled,
}
require.NoError(t, roleStore.Create(context.Background(), role))
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypeSuperAdmin,
})
t.Run("成功删除角色", func(t *testing.T) {
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
ShopID: shop.ID,
RoleID: role.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}))
err := service.DeleteShopRole(ctx, shop.ID, role.ID)
require.NoError(t, err)
result, err := service.GetShopRoles(ctx, shop.ID)
require.NoError(t, err)
assert.Empty(t, result.Roles)
})
t.Run("删除不存在的角色关联(幂等)", func(t *testing.T) {
err := service.DeleteShopRole(ctx, shop.ID, role.ID)
require.NoError(t, err)
})
t.Run("店铺不存在", func(t *testing.T) {
err := service.DeleteShopRole(ctx, 99999, role.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "店铺不存在")
})
}

View File

@@ -0,0 +1,101 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type ShopRoleStore struct {
db *gorm.DB
redisClient *redis.Client
}
func NewShopRoleStore(db *gorm.DB, redisClient *redis.Client) *ShopRoleStore {
return &ShopRoleStore{
db: db,
redisClient: redisClient,
}
}
func (s *ShopRoleStore) Create(ctx context.Context, sr *model.ShopRole) error {
if err := s.db.WithContext(ctx).Create(sr).Error; err != nil {
return err
}
s.clearShopRoleCache(ctx, sr.ShopID)
return nil
}
func (s *ShopRoleStore) BatchCreate(ctx context.Context, srs []*model.ShopRole) error {
if len(srs) == 0 {
return nil
}
if err := s.db.WithContext(ctx).Create(&srs).Error; err != nil {
return err
}
s.clearShopRoleCache(ctx, srs[0].ShopID)
return nil
}
func (s *ShopRoleStore) Delete(ctx context.Context, shopID, roleID uint) error {
if err := s.db.WithContext(ctx).
Where("shop_id = ? AND role_id = ?", shopID, roleID).
Delete(&model.ShopRole{}).Error; err != nil {
return err
}
s.clearShopRoleCache(ctx, shopID)
return nil
}
func (s *ShopRoleStore) DeleteByShopID(ctx context.Context, shopID uint) error {
if err := s.db.WithContext(ctx).
Where("shop_id = ?", shopID).
Delete(&model.ShopRole{}).Error; err != nil {
return err
}
s.clearShopRoleCache(ctx, shopID)
return nil
}
func (s *ShopRoleStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.ShopRole, error) {
var srs []*model.ShopRole
if err := s.db.WithContext(ctx).
Where("shop_id = ?", shopID).
Find(&srs).Error; err != nil {
return nil, err
}
return srs, nil
}
func (s *ShopRoleStore) GetRoleIDsByShopID(ctx context.Context, shopID uint) ([]uint, error) {
var roleIDs []uint
if err := s.db.WithContext(ctx).
Model(&model.ShopRole{}).
Where("shop_id = ?", shopID).
Pluck("role_id", &roleIDs).Error; err != nil {
return nil, err
}
return roleIDs, nil
}
func (s *ShopRoleStore) clearShopRoleCache(ctx context.Context, shopID uint) {
if s.redisClient == nil {
return
}
var accountIDs []uint
if err := s.db.WithContext(ctx).
Model(&model.Account{}).
Where("shop_id = ?", shopID).
Pluck("id", &accountIDs).Error; err != nil {
return
}
for _, accountID := range accountIDs {
key := constants.RedisUserPermissionsKey(accountID)
s.redisClient.Del(ctx, key)
}
}

View File

@@ -0,0 +1,171 @@
package postgres
import (
"context"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShopRoleStore_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := NewShopRoleStore(tx, rdb)
ctx := context.Background()
sr := &model.ShopRole{
ShopID: 1,
RoleID: 5,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
err := store.Create(ctx, sr)
require.NoError(t, err)
assert.NotZero(t, sr.ID)
}
func TestShopRoleStore_BatchCreate(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := NewShopRoleStore(tx, rdb)
ctx := context.Background()
srs := []*model.ShopRole{
{
ShopID: 1,
RoleID: 5,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
},
}
err := store.BatchCreate(ctx, srs)
require.NoError(t, err)
assert.NotZero(t, srs[0].ID)
}
func TestShopRoleStore_Delete(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := NewShopRoleStore(tx, rdb)
ctx := context.Background()
sr := &model.ShopRole{
ShopID: 1,
RoleID: 5,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, store.Create(ctx, sr))
err := store.Delete(ctx, 1, 5)
require.NoError(t, err)
results, err := store.GetByShopID(ctx, 1)
require.NoError(t, err)
assert.Empty(t, results)
}
func TestShopRoleStore_DeleteByShopID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := NewShopRoleStore(tx, rdb)
ctx := context.Background()
srs := []*model.ShopRole{
{
ShopID: 1,
RoleID: 5,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
},
{
ShopID: 1,
RoleID: 6,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
},
}
require.NoError(t, store.BatchCreate(ctx, srs))
err := store.DeleteByShopID(ctx, 1)
require.NoError(t, err)
results, err := store.GetByShopID(ctx, 1)
require.NoError(t, err)
assert.Empty(t, results)
}
func TestShopRoleStore_GetByShopID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := NewShopRoleStore(tx, rdb)
ctx := context.Background()
t.Run("查询已分配角色", func(t *testing.T) {
sr := &model.ShopRole{
ShopID: 1,
RoleID: 5,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, store.Create(ctx, sr))
results, err := store.GetByShopID(ctx, 1)
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, uint(1), results[0].ShopID)
assert.Equal(t, uint(5), results[0].RoleID)
})
t.Run("查询未分配角色的店铺", func(t *testing.T) {
results, err := store.GetByShopID(ctx, 999)
require.NoError(t, err)
assert.Empty(t, results)
})
}
func TestShopRoleStore_GetRoleIDsByShopID(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
store := NewShopRoleStore(tx, rdb)
ctx := context.Background()
t.Run("查询已分配角色的店铺", func(t *testing.T) {
sr := &model.ShopRole{
ShopID: 1,
RoleID: 5,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, store.Create(ctx, sr))
roleIDs, err := store.GetRoleIDsByShopID(ctx, 1)
require.NoError(t, err)
assert.Equal(t, []uint{5}, roleIDs)
})
t.Run("查询未分配角色的店铺", func(t *testing.T) {
roleIDs, err := store.GetRoleIDsByShopID(ctx, 999)
require.NoError(t, err)
assert.Empty(t, roleIDs)
})
}

View File

@@ -0,0 +1,8 @@
-- 删除索引
DROP INDEX IF EXISTS idx_shop_role_deleted_at;
DROP INDEX IF EXISTS idx_shop_role_role_id;
DROP INDEX IF EXISTS idx_shop_role_shop_id;
DROP INDEX IF EXISTS idx_shop_role_shop_id_role_id;
-- 删除表
DROP TABLE IF EXISTS tb_shop_role;

View File

@@ -0,0 +1,31 @@
-- 创建店铺角色关联表
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 创建唯一索引
CREATE UNIQUE INDEX idx_shop_role_shop_id_role_id ON tb_shop_role(shop_id, role_id) WHERE deleted_at IS NULL;
-- 创建索引
CREATE INDEX idx_shop_role_shop_id ON tb_shop_role(shop_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_shop_role_role_id ON tb_shop_role(role_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_shop_role_deleted_at ON tb_shop_role(deleted_at);
-- 添加注释
COMMENT ON TABLE tb_shop_role IS '店铺角色关联表';
COMMENT ON COLUMN tb_shop_role.shop_id IS '店铺ID';
COMMENT ON COLUMN tb_shop_role.role_id IS '角色ID';
COMMENT ON COLUMN tb_shop_role.status IS '状态 0=禁用 1=启用';
COMMENT ON COLUMN tb_shop_role.creator IS '创建人ID';
COMMENT ON COLUMN tb_shop_role.updater IS '更新人ID';
COMMENT ON COLUMN tb_shop_role.created_at IS '创建时间';
COMMENT ON COLUMN tb_shop_role.updated_at IS '更新时间';
COMMENT ON COLUMN tb_shop_role.deleted_at IS '软删除时间';

View File

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

View File

@@ -0,0 +1,373 @@
# 店铺级角色继承功能设计
## Context
### 当前系统状态
**RBAC 架构**
- 系统使用标准 RBACRole-Based Access Control模型
- 角色分配粒度:账号级别(`tb_account_role` 表维护账号-角色关联)
- 权限检查流程:`Account → AccountRole → Role → RolePermission → Permission`
- 权限缓存Redis 缓存用户权限 30 分钟
**用户类型**
1. 超级管理员UserType=1跳过权限检查
2. 平台用户UserType=2账号级角色可分配多个平台角色
3. 代理账号UserType=3账号级角色可分配 1 个客户角色归属于店铺shop_id
4. 企业账号UserType=4账号级角色可分配 1 个客户角色归属于企业enterprise_id
5. 个人客户UserType=5无角色系统
**店铺层级结构**
- 支持最多 7 级代理层级(通过 `tb_shop.parent_id` 维护)
- 一个店铺可有多个代理账号(员工)
- 店铺层级用于数据权限过滤GORM Callback 自动注入 `WHERE shop_id IN (...)` 条件)
### 问题
在 MVP 阶段,一个店铺内的所有账号权限通常一致(如 10 个员工都是"代理店长"角色)。平台需要为每个账号逐一分配角色,操作繁琐且容易出错。
### 约束
- 必须保持向后兼容,不能破坏现有账号级角色功能
- 仅适用于代理账号UserType=3其他用户类型保持现状
- 必须遵循项目架构分层Handler → Service → Store → Model
- 禁止使用外键约束和 GORM 关联关系
## Goals / Non-Goals
### Goals
1. **简化 MVP 阶段操作**:平台可在店铺层面设置默认角色,店铺内所有账号自动继承
2. **支持未来扩展**:保留账号级角色覆盖能力,特殊账号可单独设置角色
3. **完全向后兼容**:现有账号级角色功能不受影响,不设置店铺角色的店铺行为保持一致
4. **清晰的继承规则**:账号级角色优先,无则继承店铺级角色
### Non-Goals
1. **不支持企业级角色继承**:企业账号保持账号级角色(一企业一账号,暂无批量需求)
2. **不支持平台级角色继承**:平台账号数量少且权限差异大,不适合继承
3. **不支持多角色继承**:店铺只能设置单个角色(代理账号最大角色数为 1
4. **不支持角色叠加**:账号角色和店铺角色二选一,不取并集
## Decisions
### 决策 1角色继承规则默认继承 + 账号级覆盖)
**选择**:账号级角色优先,无则继承店铺级角色
**替代方案考虑**
| 方案 | 优点 | 缺点 | 决策 |
|------|------|------|------|
| A. 强制继承 | 最简单MVP 体验最好 | 未来扩展需要数据迁移 | ❌ 不选 |
| B. 默认继承 + 覆盖 | 简单且灵活,无缝升级 | 逻辑稍复杂(需判断优先级) | ✅ 选择 |
| C. 角色叠加(并集) | 最灵活 | 难理解,容易造成权限混乱 | ❌ 不选 |
**理由**
- MVP 阶段:不设置账号角色,自动继承店铺 → 达到简化目标
- 未来扩展:特殊账号(如财务)可单独设置角色 → 覆盖店铺默认
- 优先级明确:账号角色 > 店铺角色,易于理解和调试
**实现逻辑**
```go
func GetRoleIDsForAccount(accountID) []uint {
// 1. 查询账号级角色
accountRoles := GetRoleIDsByAccountID(accountID)
if len(accountRoles) > 0 {
return accountRoles // 有账号角色,不继承
}
// 2. 查询账号所属店铺
account := GetAccountByID(accountID)
if account.UserType != UserTypeAgent || account.ShopID == nil {
return [] // 非代理账号或无店铺,无继承
}
// 3. 查询店铺级角色(继承)
shopRoles := GetRoleIDsByShopID(account.ShopID)
return shopRoles
}
```
### 决策 2用户类型范围仅代理账号
**选择**仅对代理账号UserType=3启用店铺级角色继承
**理由**
- **代理账号**:有批量需求(一个店铺多个员工)
- **平台账号**:数量少(<10 个),权限差异大,不适合继承
- **企业账号**:一企业一账号,暂无批量需求
- **超级管理员**:跳过权限检查,无角色
- **个人客户**:无角色系统
**影响**:角色解析逻辑中需要判断 `UserType == 3` 才执行店铺角色查询。
### 决策 3数据库设计新增 tb_shop_role 表)
**选择**:新增 `tb_shop_role` 表,保留 `tb_account_role`
**表结构**
```sql
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (shop_id, role_id) WHERE deleted_at IS NULL
);
```
**索引设计**
- `idx_shop_role_shop_id`:查询店铺角色(高频)
- `idx_shop_role_role_id`:查询角色被哪些店铺使用(低频)
- `idx_shop_role_deleted_at`:软删除过滤
**理由**
- 保留 `tb_account_role`:向后兼容,不影响现有功能
- 新增 `tb_shop_role`:明确语义,避免表字段冗余
- 禁止外键约束:遵循项目规范,关联在代码层维护
### 决策 4角色类型校验只能分配客户角色
**选择**店铺只能分配客户角色RoleType=2
**校验逻辑**
```go
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return errors.New("店铺只能分配客户角色")
}
}
```
**理由**
- 代理账号只能分配客户角色RoleType=2
- 平台角色RoleType=1只能分配给平台用户
- 防止配置错误导致权限混乱
### 决策 5缓存失效策略清理店铺下所有账号缓存
**选择**:店铺角色修改时,清理该店铺下所有账号的权限缓存
**实现**
```go
func (s *ShopRoleStore) clearShopRoleCache(ctx context.Context, shopID uint) {
// 查询该店铺下所有账号
var accountIDs []uint
s.db.Model(&Account{}).Where("shop_id = ?", shopID).Pluck("id", &accountIDs)
// 逐个清理权限缓存
for _, accountID := range accountIDs {
cacheKey := constants.RedisUserPermissionsKey(accountID)
s.redisClient.Del(ctx, cacheKey)
}
}
```
**理由**
- 店铺角色修改后,继承该角色的账号权限立即生效
- 有账号级角色的账号不受影响(因为优先级更高)
- 下次权限检查时,自动使用新的继承逻辑重建缓存
### 决策 6API 设计RESTful 风格)
**新增接口**
- `POST /api/admin/shops/:shop_id/roles` - 分配店铺角色
- `GET /api/admin/shops/:shop_id/roles` - 查询店铺角色
- `DELETE /api/admin/shops/:shop_id/roles/:role_id` - 删除店铺角色
**请求体设计**
```json
// POST /api/admin/shops/:shop_id/roles
{
"role_ids": [5] // 传空数组 = 清空所有角色
}
```
**响应体设计**
```json
// GET /api/admin/shops/:shop_id/roles
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 5,
"role_name": "代理店长",
"role_desc": "代理店铺管理员",
"status": 1
}
]
}
}
```
**权限检查**
- 使用现有的 `middleware.CanManageShop()` 验证权限
- 平台用户和管理该店铺的代理才能操作
### 决策 7依赖注入通过结构体字段
**Service 层依赖**
```go
// internal/service/shop/service.go
type Service struct {
shopStore *postgres.ShopStore
shopRoleStore *postgres.ShopRoleStore // 新增
roleStore *postgres.RoleStore
accountStore *postgres.AccountStore
}
// internal/service/permission/service.go
type Service struct {
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
accountService *account.Service // 新增:用于调用角色解析
redisClient *redis.Client
}
```
**Bootstrap 注册**
```go
// internal/bootstrap/stores.go
stores := &Stores{
// ... 现有 stores
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis), // 新增
}
// internal/bootstrap/handlers.go
handlers := &Handlers{
// ... 现有 handlers
ShopRole: admin.NewShopRoleHandler(services.Shop), // 新增
}
```
## Risks / Trade-offs
### 风险 1角色解析增加数据库查询
**风险**:每次权限检查需要额外查询店铺角色(如果账号无角色)
**缓解措施**
- 权限结果在 Redis 缓存 30 分钟,大部分请求不会触发查询
- 店铺角色修改频率极低,缓存命中率高
- 查询有索引支持(`idx_shop_role_shop_id`),查询速度快(< 5ms
**性能影响评估**
- 最坏情况:首次权限检查增加 1 次查询(~ 5ms
- 后续请求:从缓存读取(~ 0.1ms
- 预计对 API P95 响应时间影响 < 5ms满足性能要求
### 风险 2缓存失效可能影响多个账号
**风险**:店铺角色修改时,需清理该店铺下所有账号缓存,可能导致短暂性能下降
**缓解措施**
- 缓存清理是异步操作,不阻塞主流程
- 店铺角色修改是低频操作(平均每天 < 10 次)
- 缓存重建是懒加载(下次请求时才重建),不会集中请求数据库
**最坏情况**:店铺有 100 个账号,角色修改后,下次 100 个账号同时请求 → 产生 100 次查询。但这种情况极少,且 PostgreSQL 可承受(连接池默认 100
### 风险 3继承规则理解偏差
**风险**:用户可能不理解"账号角色优先"规则,误以为店铺角色修改会影响所有账号
**缓解措施**
- UI 层明确标识继承状态("继承自店铺" vs "账号单独设置"
- 店铺角色设置页面显示"影响范围10 个账号1 个有单独设置不受影响"
- API 文档和操作指南明确说明继承规则
### Trade-off 1灵活性 vs 复杂度
**Trade-off**:选择"默认继承 + 覆盖"模式增加了逻辑复杂度
**权衡**
- 增加的复杂度:角色解析逻辑从 1 次查询变为最多 2 次查询(先查账号,再查店铺)
- 获得的灵活性MVP 简化 + 未来无缝扩展,无需数据迁移
- 结论:复杂度增加有限(~20 行代码),灵活性收益显著,权衡合理
### Trade-off 2用户类型限制 vs 通用性
**Trade-off**:仅支持代理账号,不支持企业和平台账号
**权衡**
- 限制原因:企业账号暂无批量需求(一企业一账号),平台账号不适合继承
- 未来扩展:如果企业需要多账号,可复制同样逻辑创建 `tb_enterprise_role`
- 结论:优先解决当前痛点(代理店铺批量分配),避免过度设计
## Migration Plan
### 部署步骤
1. **数据库迁移**(无需停机):
```bash
# 执行迁移
migrate -path migrations -database "postgres://..." up
```
- 创建 `tb_shop_role` 表
- 不影响现有数据和功能
2. **代码部署**(滚动更新):
- 部署新版本 API 服务
- 新增接口向后兼容,不影响现有功能
3. **验证**
- 调用 `POST /api/admin/shops/:id/roles` 设置店铺角色
- 验证该店铺下账号权限生效
- 验证有账号角色的账号不受影响
### 回滚策略
**如需回滚**
1. 回滚代码到旧版本
2. 保留 `tb_shop_role` 表(不删除,避免数据丢失)
3. 清理所有权限缓存:`redis-cli KEYS "user:permissions:*" | xargs redis-cli DEL`
4. 旧版本代码忽略 `tb_shop_role` 表,继续使用 `tb_account_role`
**数据一致性**
- 回滚不影响 `tb_account_role` 数据
- `tb_shop_role` 数据保留,重新部署新版本后继续生效
## Open Questions
### Q1: 是否需要支持多角色继承?
**当前设计**:店铺只能设置单个角色(代理账号最大角色数为 1
**未来考虑**:如果代理账号需要支持多角色(如"代理店长" + "销售专员"),需要:
- 修改 `constants.GetMaxRolesForUserType()` 返回值
- 修改 `AssignRolesToShop()` 支持多角色分配
- 修改角色解析逻辑支持多角色继承
**决策时机**:需求明确后再调整(暂不实现)
### Q2: 是否需要记录角色继承历史?
**当前设计**:不记录继承历史,只记录当前状态
**未来考虑**:如果需要审计"某账号在某时间段继承了哪个店铺角色",需要:
- 修改操作审计日志,记录角色继承关系变更
- 修改权限检查日志,记录使用的是账号角色还是店铺角色
**决策时机**:审计需求明确后再实现(暂不实现)
### Q3: 账号转移店铺后,角色如何处理?
**当前设计**:账号转移店铺后,自动继承新店铺角色(如果无账号级角色)
**替代方案**:账号转移店铺时,是否应该清除账号级角色?
**建议**:保持现状(账号角色不跟随店铺),理由:
- 账号角色是独立设置的,转移店铺不应影响
- 如果需要重新继承新店铺角色,可手动删除账号角色
**决策时机**:观察实际使用情况后决定(暂不修改)

View File

@@ -0,0 +1,66 @@
# 店铺级角色继承功能提案
## Why
当前系统的角色分配是账号级别的,平台需要为每个代理店铺的每个账号逐一分配角色。在 MVP 阶段,一个店铺内的所有账号权限通常是一致的(如 10 个员工都是"代理店长"角色),逐个分配造成了不必要的操作负担。本变更通过引入店铺级角色继承机制,允许平台在店铺层面设置默认角色,该店铺下所有账号自动继承,同时保留账号级覆盖能力以支持未来的权限差异化需求。
## What Changes
- **新增店铺级角色管理功能**:平台可为代理店铺设置默认角色,该店铺下所有账号自动继承
- **账号级角色覆盖机制**:特殊账号可单独设置角色,覆盖店铺默认角色
- **角色解析逻辑升级**:权限检查时优先查找账号级角色,如无则继承店铺级角色
- **新增数据库表**`tb_shop_role` 用于存储店铺-角色关联关系
- **新增 API 接口**`POST/GET/DELETE /api/admin/shops/:id/roles` 用于管理店铺角色
- **适用范围限定**仅适用于代理账号UserType=3企业账号和平台账号保持现状账号级角色
## Capabilities
### New Capabilities
- `shop-role-management`: 店铺级角色管理能力,包括为店铺分配角色、查询店铺角色、删除店铺角色等功能
### Modified Capabilities
- `rbac-permission-check`: 角色权限检查能力需要修改,增加店铺级角色继承逻辑,权限解析时需要支持"账号角色优先,无则继承店铺角色"的规则
## Impact
### 数据库层
- **新增表**`tb_shop_role`(店铺-角色关联表)
- **保留表**`tb_account_role`(向后兼容)
### 代码模块
- **新增**
- `internal/model/shop_role.go`ShopRole 模型)
- `internal/store/postgres/shop_role_store.go`ShopRoleStore 数据访问层)
- `internal/service/shop/shop_role.go`(店铺角色业务逻辑)
- `internal/handler/admin/shop_role.go`(店铺角色 HTTP 处理器)
- `internal/model/dto/shop_role_dto.go`(店铺角色 DTO
- **修改**
- `internal/service/account/role_resolver.go`(新增角色解析逻辑)
- `internal/service/permission/service.go`(修改权限检查,使用新的角色解析)
- `internal/routes/shop.go`(注册店铺角色路由)
- `internal/bootstrap/stores.go`(注册 ShopRoleStore
- `internal/bootstrap/handlers.go`(注册 ShopRoleHandler
### API 接口
- **新增**
- `POST /api/admin/shops/:shop_id/roles`(分配店铺角色)
- `GET /api/admin/shops/:shop_id/roles`(查询店铺角色)
- `DELETE /api/admin/shops/:shop_id/roles/:role_id`(删除店铺角色)
- **行为变更**
- `GET /api/admin/accounts/:id/roles`(查询账号角色时,返回结果可能包含继承的店铺角色,需要标识来源)
### 用户类型影响范围
- **代理账号UserType=3**:受影响,支持继承店铺角色
- **平台账号UserType=2**:不受影响,保持账号级角色
- **企业账号UserType=4**:不受影响,保持账号级角色
- **超级管理员UserType=1**:不受影响,跳过权限检查
- **个人客户UserType=5**:不受影响,无角色系统
### 性能考虑
- 角色解析增加一次额外查询(查询店铺角色),但通过 Redis 缓存权限结果30 分钟),性能影响可忽略
- 店铺角色修改时需清理该店铺下所有账号的权限缓存
### 向后兼容性
- ✅ 完全向后兼容:保留 `tb_account_role` 表和现有逻辑
- ✅ 现有账号级角色不受影响,优先级高于店铺级角色
- ✅ 不设置店铺角色的店铺,行为与现在完全一致

View File

@@ -0,0 +1,226 @@
# permission-check Delta Specification
## Purpose
为权限检查能力增加店铺级角色继承逻辑,支持代理账号在没有账号级角色时自动继承店铺级角色。
## MODIFIED Requirements
### Requirement: 权限查询链式执行
权限检查 SHALL 按照以下顺序执行查询(增加店铺角色继承逻辑):
1. 检查用户类型(超级管理员跳过)
2. **查询用户的角色 ID 列表(增加店铺角色继承)**
- 优先查询账号级角色(`tb_account_role`
- 如果账号级角色为空 **且用户是代理账号UserType=3且有 shop_id**
- 查询店铺级角色(`tb_shop_role`
- 返回店铺级角色作为继承角色
3. 查询角色的权限 ID 列表(去重)
4. 查询权限详情列表
5. 遍历匹配 `permCode``platform`
**角色解析函数签名**:
```go
GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
#### Scenario: 正常查询流程(现有行为保持不变)
- **WHEN** 调用 `CheckPermission` 检查普通用户权限
- **THEN** 按顺序执行以下查询:
1. 调用 `AccountService.GetRoleIDsForAccount(ctx, userID)` 获取角色 ID 列表(含继承逻辑)
2. `RolePermissionStore.GetPermIDsByRoleIDs(ctx, roleIDs)` 获取权限 ID 列表
3. `PermissionStore.GetByIDs(ctx, permIDs)` 获取权限详情
- **AND** 遍历权限列表进行匹配
- **AND** 找到匹配权限后立即返回 `true`(短路优化)
#### Scenario: 代理账号继承店铺角色
- **WHEN** 调用 `CheckPermission` 检查代理账号UserType=3权限
- **AND** 该账号未分配账号级角色(`tb_account_role` 中无记录)
- **AND** 该账号的 `shop_id` 不为 NULL
- **AND** 该店铺已分配店铺级角色(`tb_shop_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回店铺级角色 ID 列表
- **AND** 后续权限检查使用店铺级角色的权限
#### Scenario: 代理账号有自己角色时不继承
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号已分配账号级角色(`tb_account_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(优先级:账号 > 店铺)
- **AND** 后续权限检查使用账号级角色的权限
#### Scenario: 代理账号无角色也无店铺角色
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号未分配账号级角色
- **AND** 该账号的店铺未分配店铺级角色(`tb_shop_role` 中无记录)
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 后续权限检查返回 `false`(无权限)
#### Scenario: 非代理账号不继承店铺角色
- **WHEN** 调用 `CheckPermission` 检查平台用户UserType=2权限
- **AND** 该账号未分配账号级角色
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 不查询店铺级角色(仅代理账号支持继承)
#### Scenario: 空结果短路(现有行为保持不变)
- **WHEN** `GetRoleIDsForAccount` 返回空列表(账号无角色且店铺无角色)
- **THEN** 立即返回 `(false, nil)`
- **AND** 不执行后续查询(角色权限查询、权限详情查询)
## ADDED Requirements
### Requirement: 角色解析服务
系统 SHALL 提供 `GetRoleIDsForAccount` 方法,统一处理账号角色查询和店铺角色继承逻辑。
**实现位置**: `internal/service/account/role_resolver.go`
**方法签名**:
```go
func (s *Service) GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
**返回值**:
- `[]uint`: 角色 ID 列表(可能是账号级角色或店铺级角色)
- `error`: 查询失败时的错误信息
#### Scenario: 角色解析 - 超级管理员
- **WHEN** 调用 `GetRoleIDsForAccount` 查询超级管理员UserType=1的角色
- **THEN** 返回空数组 `[]uint{}`(超级管理员无角色,跳过权限检查)
- **AND** 不执行任何数据库查询
#### Scenario: 角色解析 - 平台用户
- **WHEN** 调用 `GetRoleIDsForAccount` 查询平台用户UserType=2的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(平台用户无 shop_id
#### Scenario: 角色解析 - 代理账号有账号级角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号UserType=3的角色
- **AND** 该账号已分配账号级角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(账号角色优先)
#### Scenario: 角色解析 - 代理账号继承店铺角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号的角色
- **AND** 该账号未分配账号级角色(`tb_account_role` 查询结果为空)
- **AND** 该账号的 `shop_id` 不为 NULL
- **THEN** 查询 `tb_shop_role` 表获取店铺级角色
- **AND** 返回店铺级角色 ID 列表(继承)
#### Scenario: 角色解析 - 企业账号
- **WHEN** 调用 `GetRoleIDsForAccount` 查询企业账号UserType=4的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(企业账号无继承机制)
#### Scenario: 角色解析 - 数据库查询失败
- **WHEN** 调用 `GetRoleIDsForAccount` 过程中数据库查询失败
- **THEN** 返回错误 `errors.Wrap(errors.CodeInternalError, err, "查询角色失败")`
- **AND** 不返回部分结果
### Requirement: Permission Service 依赖注入升级
Permission Service SHALL 增加对 Account Service 的依赖,用于调用角色解析逻辑。
**修改的依赖**:
```go
type Service struct {
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore // 保留但不直接使用
rolePermStore *postgres.RolePermissionStore
accountService *account.Service // 新增:用于角色解析
redisClient *redis.Client
}
```
#### Scenario: Service 初始化
- **WHEN** 创建 Permission Service 实例
- **THEN** 构造函数接收以下参数:
- `permissionStore *postgres.PermissionStore`
- `accountRoleStore *postgres.AccountRoleStore`(保留向后兼容)
- `rolePermStore *postgres.RolePermissionStore`
- `accountService *account.Service`(新增)
- `redisClient *redis.Client`
- **AND** 存储在结构体字段中供 `CheckPermission` 使用
#### Scenario: CheckPermission 使用新的角色解析
- **WHEN** `CheckPermission` 需要查询用户角色时
- **THEN** 调用 `s.accountService.GetRoleIDsForAccount(ctx, userID)`
- **AND** 不再直接调用 `s.accountRoleStore.GetRoleIDsByAccountID()`
- **AND** 获得的角色 ID 列表可能是账号级角色或店铺级角色
### Requirement: 缓存机制兼容
权限缓存机制 SHALL 与店铺角色继承逻辑兼容,确保角色变更后缓存及时失效。
**缓存键**: `user:permissions:{user_id}`
**缓存内容**: 用户的所有权限列表(不区分账号级角色还是店铺级角色)
**缓存时效**: 30 分钟
#### Scenario: 缓存命中时使用缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中存在缓存键 `user:permissions:{user_id}`
- **THEN** 直接从缓存读取权限列表
- **AND** 不调用 `GetRoleIDsForAccount`(避免查询)
- **AND** 使用缓存的权限进行匹配
#### Scenario: 缓存未命中时重建缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中不存在缓存键
- **THEN** 调用 `GetRoleIDsForAccount` 查询角色(含继承逻辑)
- **AND** 查询角色的所有权限
- **AND** 将权限列表写入 RedisTTL 30 分钟
#### Scenario: 店铺角色变更时清理缓存
- **WHEN** 店铺角色变更(分配/删除)
- **THEN** 查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,自动重建缓存(使用新的角色解析逻辑)
#### Scenario: 账号角色变更时清理缓存(现有行为)
- **WHEN** 账号级角色变更(分配/删除)
- **THEN** 删除该账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,重建缓存
### Requirement: 性能要求
角色继承逻辑 SHALL 满足以下性能要求:
- 角色解析查询时间 < 10ms含店铺角色查询
- 权限检查总时间 < 50ms含角色解析、权限查询、匹配
- 缓存命中时权限检查时间 < 1ms
#### Scenario: 角色解析性能
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号角色
- **AND** 账号无账号级角色,需查询店铺级角色
- **THEN** 总查询时间(账号角色查询 + 店铺角色查询)< 10ms
- **AND** 使用索引 `idx_shop_role_shop_id` 优化查询
#### Scenario: 缓存命中性能
- **WHEN** 调用 `CheckPermission` 且缓存命中
- **THEN** 总处理时间 < 1ms
- **AND** 不执行任何数据库查询

View File

@@ -0,0 +1,322 @@
# shop-role-management Specification
## Purpose
提供店铺级角色管理能力,允许平台为代理店铺设置默认角色,该店铺下所有账号自动继承,简化 MVP 阶段的批量角色分配操作。
## ADDED Requirements
### Requirement: 分配店铺角色
系统 SHALL 提供接口允许平台用户或店铺管理员为店铺分配角色。
**接口**: `POST /api/admin/shops/:shop_id/roles`
**请求体**:
```json
{
"role_ids": [5] // 角色 ID 列表,传空数组表示清空所有角色
}
```
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 1,
"shop_id": 10,
"role_id": 5,
"status": 1,
"created_at": "2026-02-02T10:00:00Z"
}
],
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功分配单个角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [5]}`
- **AND** 角色 ID 5 存在且为客户角色RoleType=2
- **AND** 店铺 ID 10 存在
- **THEN** 系统创建店铺-角色关联记录
- **AND** 返回 HTTP 200 和关联记录
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 清空店铺所有角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": []}`
- **THEN** 系统删除该店铺的所有角色关联
- **AND** 返回 HTTP 200 和空数组
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 替换现有角色
- **WHEN** 店铺已分配角色 ID 5
- **AND** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [7]}`
- **THEN** 系统删除原有角色 ID 5 的关联
- **AND** 创建新的角色 ID 7 的关联
- **AND** 返回 HTTP 200 和新关联记录
#### Scenario: 角色类型校验失败
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [3]}`
- **AND** 角色 ID 3 是平台角色RoleType=1
- **THEN** 返回 HTTP 400 错误码 `errors.CodeInvalidParam`
- **AND** 错误消息为"店铺只能分配客户角色"
- **AND** 不创建任何关联记录
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `POST /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `POST /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内(不是自己店铺或下级店铺)
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 查询店铺角色
系统 SHALL 提供接口查询店铺已分配的角色列表。
**接口**: `GET /api/admin/shops/:shop_id/roles`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 5,
"role_name": "代理店长",
"role_desc": "代理店铺管理员",
"status": 1
}
]
},
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 查询已分配角色
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 已分配角色 ID 5
- **THEN** 返回 HTTP 200 和角色详情列表
- **AND** 包含角色名称、描述等信息
#### Scenario: 查询未分配角色的店铺
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 未分配任何角色
- **THEN** 返回 HTTP 200
- **AND** `roles` 字段为空数组
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `GET /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `GET /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 删除店铺角色
系统 SHALL 提供接口删除店铺的特定角色关联。
**接口**: `DELETE /api/admin/shops/:shop_id/roles/:role_id`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功删除角色
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 存在
- **AND** 店铺已分配角色 ID 5
- **THEN** 系统删除该关联记录
- **AND** 返回 HTTP 200
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 删除不存在的角色关联
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 未分配角色 ID 5
- **THEN** 返回 HTTP 200幂等操作
- **AND** 不执行任何数据库操作
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/999/roles/5`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `DELETE /api/admin/shops/20/roles/5`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 数据库表结构
系统 SHALL 创建 `tb_shop_role` 表存储店铺-角色关联关系。
**表结构**:
```sql
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (shop_id, role_id) WHERE deleted_at IS NULL
);
```
**索引**:
- `idx_shop_role_shop_id` - 查询店铺角色(高频)
- `idx_shop_role_role_id` - 查询角色被哪些店铺使用(低频)
- `idx_shop_role_deleted_at` - 软删除过滤
#### Scenario: 唯一性约束
- **WHEN** 尝试为同一店铺分配同一角色两次
- **THEN** 数据库返回唯一性约束冲突错误
- **AND** 系统捕获错误并返回友好错误消息
#### Scenario: 软删除机制
- **WHEN** 删除店铺角色关联
- **THEN** 系统设置 `deleted_at` 字段为当前时间
- **AND** 后续查询自动过滤 `deleted_at IS NOT NULL` 的记录
### Requirement: 缓存失效策略
系统 SHALL 在店铺角色变更时清理相关账号的权限缓存。
#### Scenario: 分配角色时清理缓存
- **WHEN** 为店铺 ID 10 分配角色
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,账号会重新查询并继承新角色
#### Scenario: 删除角色时清理缓存
- **WHEN** 删除店铺 ID 10 的角色关联
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键
- **AND** 下次权限检查时,账号将无角色(如果无账号级角色)
#### Scenario: 账号有自己角色时不受影响
- **WHEN** 店铺角色变更
- **AND** 某账号有自己的账号级角色
- **THEN** 该账号的权限缓存被清理
- **AND** 下次权限检查时,继续使用账号级角色(不继承店铺角色)
### Requirement: 权限控制
店铺角色管理接口 SHALL 实施权限控制,只有有权限的用户才能操作。
**权限规则**:
- 超级管理员UserType=1可操作所有店铺
- 平台用户UserType=2可操作所有店铺
- 代理用户UserType=3只能操作自己店铺及下级店铺
- 企业用户UserType=4无权限操作店铺角色
#### Scenario: 超级管理员操作任意店铺
- **WHEN** 超级管理员调用店铺角色管理接口
- **THEN** 跳过权限检查
- **AND** 允许操作任意店铺
#### Scenario: 平台用户操作任意店铺
- **WHEN** 平台用户调用店铺角色管理接口
- **THEN** 允许操作任意店铺
#### Scenario: 代理用户操作下级店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 15 是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 15, shopStore)`
- **AND** 返回 nil有权限
- **AND** 允许操作
#### Scenario: 代理用户操作无关店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 20 不是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 20, shopStore)`
- **AND** 返回 error无权限
- **AND** 拒绝操作
#### Scenario: 企业用户尝试操作店铺角色
- **WHEN** 企业用户调用店铺角色管理接口
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 业务规则校验
店铺角色分配 SHALL 执行业务规则校验,确保数据一致性。
#### Scenario: 角色存在性校验
- **WHEN** 分配店铺角色时指定角色 ID 列表
- **THEN** 系统查询所有角色是否存在
- **AND** 如果部分角色不存在,返回错误"部分角色不存在"
- **AND** 不创建任何关联记录(原子操作)
#### Scenario: 角色状态校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `status` 字段为 0禁用
- **THEN** 返回错误"角色已禁用"
- **AND** 不创建关联记录
#### Scenario: 角色类型校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `role_type` 字段为 1平台角色
- **THEN** 返回错误"店铺只能分配客户角色"
- **AND** 不创建关联记录
#### Scenario: 店铺存在性校验
- **WHEN** 分配店铺角色时指定店铺 ID
- **AND** 该店铺不存在或已软删除
- **THEN** 返回错误"店铺不存在"
- **AND** 不执行任何操作

View File

@@ -0,0 +1,266 @@
# 店铺级角色继承功能实现任务清单
## 1. 数据库层实现
- [x] 1.1 创建数据库迁移文件 `migrations/YYYYMMDDHHMMSS_add_shop_role_table.up.sql`
- 创建 `tb_shop_role`
- 添加唯一约束 `(shop_id, role_id) WHERE deleted_at IS NULL`
- 创建索引 `idx_shop_role_shop_id``idx_shop_role_role_id``idx_shop_role_deleted_at`
- 验证:执行 `migrate -path migrations -database "..." up` 成功
- [x] 1.2 创建数据库迁移回滚文件 `migrations/YYYYMMDDHHMMSS_add_shop_role_table.down.sql`
- 删除 `tb_shop_role`
- 验证:执行 `migrate -path migrations -database "..." down` 成功
## 2. Model 层实现
- [x] 2.1 创建 `internal/model/shop_role.go`
- 定义 `ShopRole` 结构体,包含所有字段和 GORM 标签
- 实现 `TableName()` 方法返回 `"tb_shop_role"`
- 验证:运行 `go build ./internal/model/`,无编译错误
- [x] 2.2 创建 `internal/model/dto/shop_role_dto.go`
- 定义 `AssignShopRolesRequest` 结构体(包含 `role_ids` 字段和 description 标签)
- 定义 `ShopRoleResponse` 结构体(包含店铺和角色详情)
- 定义 `ShopRolesResponse` 结构体(包含 `shop_id``roles` 列表)
- 验证:运行 `go build ./internal/model/dto/`,无编译错误
## 3. Store 层实现
- [x] 3.1 创建 `internal/store/postgres/shop_role_store.go`
- 实现 `ShopRoleStore` 结构体,包含 `db``redisClient` 字段
- 实现 `NewShopRoleStore()` 构造函数
- 实现 `Create()` 方法(创建单个店铺角色关联)
- 实现 `BatchCreate()` 方法(批量创建)
- 实现 `Delete()` 方法(删除指定店铺角色关联)
- 实现 `DeleteByShopID()` 方法(删除店铺的所有角色关联)
- 实现 `GetByShopID()` 方法(查询店铺的所有角色关联)
- 实现 `GetRoleIDsByShopID()` 方法(查询店铺的所有角色 ID
- 实现 `clearShopRoleCache()` 私有方法(清理店铺下所有账号的权限缓存)
- 验证:运行 `go build ./internal/store/postgres/`,无编译错误
- [x] 3.2 编写 `ShopRoleStore` 单元测试
- 测试文件:`internal/store/postgres/shop_role_store_test.go`
- 测试 `Create()` 成功场景
- 测试 `BatchCreate()` 成功场景
- 测试 `Delete()` 成功场景
- 测试 `DeleteByShopID()` 成功场景
- 测试 `GetByShopID()` 成功场景
- 测试 `GetRoleIDsByShopID()` 成功场景
- 测试唯一性约束冲突
- 验证:运行 `source .env.local && go test -v ./internal/store/postgres/ -run TestShopRoleStore`,所有测试通过
## 4. Service 层实现
- [x] 4.1 创建 `internal/service/account/role_resolver.go`
- 实现 `GetRoleIDsForAccount(ctx, accountID) ([]uint, error)` 方法
- 实现角色解析逻辑:
- 超级管理员返回空数组
- 查询账号级角色,如有则返回
- 代理账号且无账号级角色,查询店铺级角色并返回
- 其他用户类型返回空数组
- 验证:运行 `go build ./internal/service/account/`,无编译错误
- [x] 4.2 编写 `GetRoleIDsForAccount` 单元测试
- 测试文件:`internal/service/account/role_resolver_test.go`
- 测试场景:超级管理员返回空数组
- 测试场景:平台用户返回账号级角色
- 测试场景:代理账号有账号级角色,返回账号级角色(不继承)
- 测试场景:代理账号无账号级角色,继承店铺级角色
- 测试场景:代理账号无账号级角色且店铺无角色,返回空数组
- 测试场景:企业账号返回账号级角色
- 验证:运行 `source .env.local && go test -v ./internal/service/account/ -run TestGetRoleIDsForAccount`,测试覆盖率 ≥ 90%
- [x] 4.3 修改 `internal/service/permission/service.go`
- 修改 `Service` 结构体,添加 `accountService *account.Service` 字段
- 修改 `New()` 构造函数,接收 `accountService` 参数
- 修改 `CheckPermission()` 方法,调用 `accountService.GetRoleIDsForAccount()` 替代直接查询 `accountRoleStore`
- 验证:运行 `go build ./internal/service/permission/`,无编译错误
- [x] 4.4 更新 `Permission Service` 单元测试
- 修改 `internal/service/permission/service_test.go`
- 更新 mock accountService 或使用真实 accountService
- 验证所有现有测试仍然通过
- 新增测试:代理账号继承店铺角色的权限检查场景
- 验证:运行 `source .env.local && go test -v ./internal/service/permission/ -run TestCheckPermission`,所有测试通过
- [x] 4.5 修改 `internal/service/account/service.go`
- 修改 `Service` 结构体,添加 `shopRoleStore *postgres.ShopRoleStore` 字段(用于角色解析)
- 修改 `New()` 构造函数,接收 `shopRoleStore` 参数
- 验证:运行 `go build ./internal/service/account/`,无编译错误
- [x] 4.6 创建 `internal/service/shop/shop_role.go`
- 实现 `AssignRolesToShop(ctx, shopID, roleIDs) ([]*model.ShopRole, error)` 方法
- 实现业务逻辑:
- 权限检查(调用 `middleware.CanManageShop`
- 验证店铺存在
- 验证角色存在、类型正确RoleType=2、状态启用
- 空数组表示清空所有角色
- 删除现有角色关联,批量创建新关联(原子操作)
- 实现 `GetShopRoles(ctx, shopID) ([]*dto.ShopRoleResponse, error)` 方法
- 实现业务逻辑:
- 权限检查
- 查询店铺角色关联
- 查询角色详情并组装响应
- 验证:运行 `go build ./internal/service/shop/`,无编译错误
- [x] 4.7 编写 `Shop Service` 店铺角色管理单元测试
- 测试文件:`internal/service/shop/shop_role_test.go`
- 测试 `AssignRolesToShop()` 成功分配单个角色
- 测试 `AssignRolesToShop()` 清空所有角色
- 测试 `AssignRolesToShop()` 替换现有角色
- 测试 `AssignRolesToShop()` 角色类型校验失败
- 测试 `AssignRolesToShop()` 角色不存在
- 测试 `AssignRolesToShop()` 店铺不存在
- 测试 `AssignRolesToShop()` 权限不足
- 测试 `GetShopRoles()` 查询已分配角色
- 测试 `GetShopRoles()` 查询未分配角色的店铺
- 测试 `GetShopRoles()` 权限不足
- 验证:运行 `source .env.local && go test -v ./internal/service/shop/ -run TestShopRole`,测试覆盖率 ≥ 90%
## 5. Handler 层实现
- [x] 5.1 创建 `internal/handler/admin/shop_role.go`
- 实现 `ShopRoleHandler` 结构体,包含 `service *shop.Service` 字段
- 实现 `NewShopRoleHandler()` 构造函数
- 实现 `AssignShopRoles(c *fiber.Ctx) error` 方法
- 解析路径参数 `shop_id`
- 解析请求体 `AssignShopRolesRequest`
- 调用 `service.AssignRolesToShop()`
- 返回统一响应格式
- 实现 `GetShopRoles(c *fiber.Ctx) error` 方法
- 解析路径参数 `shop_id`
- 调用 `service.GetShopRoles()`
- 返回统一响应格式
- 实现 `DeleteShopRole(c *fiber.Ctx) error` 方法
- 解析路径参数 `shop_id``role_id`
- 调用 `service` 删除逻辑
- 返回统一响应格式
- 验证:运行 `go build ./internal/handler/admin/`,无编译错误
- [ ] 5.2 编写 Handler 集成测试
- 测试文件:`tests/integration/shop_role_test.go`
- 测试 `POST /api/admin/shops/:shop_id/roles` 成功分配角色
- 测试 `POST /api/admin/shops/:shop_id/roles` 清空角色
- 测试 `POST /api/admin/shops/:shop_id/roles` 替换角色
- 测试 `POST /api/admin/shops/:shop_id/roles` 角色类型校验失败
- 测试 `POST /api/admin/shops/:shop_id/roles` 权限不足
- 测试 `GET /api/admin/shops/:shop_id/roles` 查询角色
- 测试 `GET /api/admin/shops/:shop_id/roles` 店铺不存在
- 测试 `DELETE /api/admin/shops/:shop_id/roles/:role_id` 删除角色
- 验证:运行 `source .env.local && go test -v ./tests/integration/ -run TestShopRole`,所有测试通过
## 6. 路由注册和依赖注入
- [x] 6.1 修改 `internal/routes/shop.go`
- 注册 `POST /api/admin/shops/:shop_id/roles` 路由到 `handlers.ShopRole.AssignShopRoles`
- 注册 `GET /api/admin/shops/:shop_id/roles` 路由到 `handlers.ShopRole.GetShopRoles`
- 注册 `DELETE /api/admin/shops/:shop_id/roles/:role_id` 路由到 `handlers.ShopRole.DeleteShopRole`
- 验证:运行 `go build ./internal/routes/`,无编译错误
- [x] 6.2 修改 `internal/bootstrap/stores.go`
-`Stores` 结构体添加 `ShopRole *postgres.ShopRoleStore` 字段
-`initStores()` 中初始化 `ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis)`
- 验证:运行 `go build ./internal/bootstrap/`,无编译错误
- [x] 6.3 修改 `internal/bootstrap/services.go`
- 修改 Account Service 初始化,传入 `stores.ShopRole`
- 修改 Permission Service 初始化,传入 `Account Service` 实例
- 验证:运行 `go build ./internal/bootstrap/`,无编译错误
- [x] 6.4 修改 `internal/bootstrap/handlers.go`
-`Handlers` 结构体添加 `ShopRole *admin.ShopRoleHandler` 字段
-`initHandlers()` 中初始化 `ShopRole: admin.NewShopRoleHandler(services.Shop)`
- 验证:运行 `go build ./internal/bootstrap/`,无编译错误
- [x] 6.5 更新 API 文档生成器
- 修改 `cmd/api/docs.go`,在 handlers 初始化中添加 `ShopRole: admin.NewShopRoleHandler(nil)`
- 修改 `cmd/gendocs/main.go`,在 handlers 初始化中添加 `ShopRole: admin.NewShopRoleHandler(nil)`
- 验证:运行 `go run cmd/gendocs/main.go`,生成文档成功,包含新的店铺角色管理接口
## 7. 常量定义
- [x] 7.1 检查是否需要新增错误码
- 检查 `pkg/errors/codes.go` 是否已有所需错误码
- 如需新增,添加错误码常量和错误消息
- 验证:运行 `go build ./pkg/errors/`,无编译错误
- [x] 7.2 检查是否需要新增 Redis Key 生成函数
- 检查 `pkg/constants/redis.go` 是否需要新增店铺角色相关的 Redis Key
- 当前使用 `RedisUserPermissionsKey(userID)` 已满足需求,无需新增
- 验证:确认缓存清理逻辑使用正确的 Key
## 8. 端到端测试
- [ ] 8.1 测试完整的店铺角色继承流程
- 创建测试店铺和代理账号(无账号级角色)
- 为店铺分配角色
- 验证账号权限检查返回 true继承店铺角色
- 为账号分配账号级角色
- 验证账号权限检查使用账号级角色(不继承店铺角色)
- 删除账号级角色
- 验证账号权限检查恢复继承店铺角色
- 验证:手动测试或编写端到端测试脚本
- [ ] 8.2 测试缓存失效机制
- 为店铺分配角色,账号继承
- 触发一次权限检查(缓存写入)
- 修改店铺角色
- 再次触发权限检查,验证使用新角色(缓存已失效)
- 验证:手动测试或编写测试脚本
- [ ] 8.3 测试权限控制
- 使用平台用户操作任意店铺角色(应成功)
- 使用代理用户操作自己店铺角色(应成功)
- 使用代理用户操作下级店铺角色(应成功)
- 使用代理用户操作无关店铺角色(应失败 403
- 使用企业用户操作店铺角色(应失败 403
- 验证:手动测试或编写测试脚本
## 9. 代码质量和文档
- [x] 9.1 运行 LSP 诊断检查所有修改的文件
- 运行 `lsp_diagnostics` 检查所有新增和修改的 Go 文件
- 确保无错误、无警告
- 验证:所有文件通过 LSP 检查
- [x] 9.2 运行代码规范检查
- 运行 `gofmt -w .` 格式化所有 Go 文件
- 运行 `go vet ./...` 检查潜在问题
- 验证:无错误输出
- [x] 9.3 运行所有单元测试
- 运行 `source .env.local && go test -v ./...`
- 确保所有测试通过,包括现有测试和新增测试
- 验证:测试通过率 100%,核心逻辑测试覆盖率 ≥ 90%
- [x] 9.4 运行所有集成测试
- 运行 `source .env.local && go test -v ./tests/integration/`
- 确保所有 API 测试通过
- 验证:测试通过率 100%
- [x] 9.5 更新项目文档
-`docs/` 目录创建功能总结文档(如果需要)
- 更新 README.md如果有重大功能说明
- 验证:文档清晰、准确、完整
## 10. 部署准备
- [ ] 10.1 验证数据库迁移
- 在测试环境执行迁移:`migrate -path migrations -database "..." up`
- 验证表创建成功,索引创建成功
- 验证回滚:`migrate -path migrations -database "..." down`
- 验证表删除成功
- [ ] 10.2 性能测试
- 测试角色解析性能(< 10ms
- 测试权限检查性能(< 50ms
- 测试缓存命中性能(< 1ms
- 验证:性能满足设计要求
- [ ] 10.3 最终验收测试
- 在模拟生产环境执行完整测试流程
- 验证向后兼容性(现有账号级角色功能不受影响)
- 验证不设置店铺角色的店铺行为保持一致
- 验证所有 API 接口正常工作
- 验证:功能完整、稳定、性能达标

View File

@@ -1,7 +1,8 @@
# permission-check Specification # permission-check Specification
## Purpose ## Purpose
TBD - created by archiving change implement-permission-check. Update Purpose after archive.
提供完整的权限检查能力,支持基于角色的权限验证和店铺级角色继承机制,实现细粒度的访问控制。
## Requirements ## Requirements
### Requirement: 权限检查核心服务 ### Requirement: 权限检查核心服务
@@ -92,53 +93,117 @@ CheckPermission(ctx context.Context, userID uint, permCode string, platform stri
### Requirement: 权限查询链式执行 ### Requirement: 权限查询链式执行
权限检查 SHALL 按照以下顺序执行查询: 权限检查 SHALL 按照以下顺序执行查询(增加店铺角色继承逻辑)
1. 检查用户类型(超级管理员跳过) 1. 检查用户类型(超级管理员跳过)
2. 查询用户的角色 ID 列表 2. **查询用户的角色 ID 列表(增加店铺角色继承)**
- 优先查询账号级角色(`tb_account_role`
- 如果账号级角色为空 **且用户是代理账号UserType=3且有 shop_id**
- 查询店铺级角色(`tb_shop_role`
- 返回店铺级角色作为继承角色
3. 查询角色的权限 ID 列表(去重) 3. 查询角色的权限 ID 列表(去重)
4. 查询权限详情列表 4. 查询权限详情列表
5. 遍历匹配 `permCode``platform` 5. 遍历匹配 `permCode``platform`
#### Scenario: 正常查询流程 **角色解析函数签名**:
```go
GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
#### Scenario: 正常查询流程(现有行为保持不变)
- **WHEN** 调用 `CheckPermission` 检查普通用户权限 - **WHEN** 调用 `CheckPermission` 检查普通用户权限
- **THEN** 按顺序执行以下查询: - **THEN** 按顺序执行以下查询:
1. `AccountRoleStore.GetRoleIDsByAccountID(ctx, userID)` 获取角色 ID 列表 1. 调用 `AccountService.GetRoleIDsForAccount(ctx, userID)` 获取角色 ID 列表(含继承逻辑)
2. `RolePermissionStore.GetPermIDsByRoleIDs(ctx, roleIDs)` 获取权限 ID 列表 2. `RolePermissionStore.GetPermIDsByRoleIDs(ctx, roleIDs)` 获取权限 ID 列表
3. `PermissionStore.GetByIDs(ctx, permIDs)` 获取权限详情 3. `PermissionStore.GetByIDs(ctx, permIDs)` 获取权限详情
- **AND** 遍历权限列表进行匹配 - **AND** 遍历权限列表进行匹配
- **AND** 找到匹配权限后立即返回 `true`(短路优化) - **AND** 找到匹配权限后立即返回 `true`(短路优化)
#### Scenario: 空结果短路 #### Scenario: 代理账号继承店铺角色
- **WHEN** 任意查询步骤返回空列表(如用户无角色) - **WHEN** 调用 `CheckPermission` 检查代理账号UserType=3权限
- **AND** 该账号未分配账号级角色(`tb_account_role` 中无记录)
- **AND** 该账号的 `shop_id` 不为 NULL
- **AND** 该店铺已分配店铺级角色(`tb_shop_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回店铺级角色 ID 列表
- **AND** 后续权限检查使用店铺级角色的权限
#### Scenario: 代理账号有自己角色时不继承
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号已分配账号级角色(`tb_account_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(优先级:账号 > 店铺)
- **AND** 后续权限检查使用账号级角色的权限
#### Scenario: 代理账号无角色也无店铺角色
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号未分配账号级角色
- **AND** 该账号的店铺未分配店铺级角色(`tb_shop_role` 中无记录)
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 后续权限检查返回 `false`(无权限)
#### Scenario: 非代理账号不继承店铺角色
- **WHEN** 调用 `CheckPermission` 检查平台用户UserType=2权限
- **AND** 该账号未分配账号级角色
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 不查询店铺级角色(仅代理账号支持继承)
#### Scenario: 空结果短路(现有行为保持不变)
- **WHEN** `GetRoleIDsForAccount` 返回空列表(账号无角色且店铺无角色)
- **THEN** 立即返回 `(false, nil)` - **THEN** 立即返回 `(false, nil)`
- **AND** 不执行后续查询 - **AND** 不执行后续查询(角色权限查询、权限详情查询)
### Requirement: Service 依赖注入 ### Requirement: Service 依赖注入
Permission Service SHALL 在初始化时注入所需的 Store 依赖。 Permission Service SHALL 在初始化时注入所需的 Store 和 Service 依赖。
**依赖**: **依赖**:
- `PermissionStore` - 查询权限详情 - `PermissionStore` - 查询权限详情
- `AccountRoleStore` - 查询用户角色关联 - `AccountRoleStore` - 查询用户角色关联(保留向后兼容)
- `RolePermissionStore` - 查询角色权限关联 - `RolePermissionStore` - 查询角色权限关联
- `AccountService` - 角色解析服务(含店铺角色继承逻辑)
- `RedisClient` - 权限缓存
**修改的依赖**:
```go
type Service struct {
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore // 保留但不直接使用
rolePermStore *postgres.RolePermissionStore
accountService *account.Service // 新增:用于角色解析
redisClient *redis.Client
}
```
#### Scenario: Service 初始化 #### Scenario: Service 初始化
- **WHEN** 创建 Permission Service 实例 - **WHEN** 创建 Permission Service 实例
- **THEN** 构造函数接收以下参数: - **THEN** 构造函数接收以下参数:
- `permissionStore *postgres.PermissionStore` - `permissionStore *postgres.PermissionStore`
- `accountRoleStore *postgres.AccountRoleStore` - `accountRoleStore *postgres.AccountRoleStore`(保留向后兼容)
- `rolePermStore *postgres.RolePermissionStore` - `rolePermStore *postgres.RolePermissionStore`
- `accountService *account.Service`(新增)
- `redisClient *redis.Client`
- **AND** 存储在结构体字段中供 `CheckPermission` 使用 - **AND** 存储在结构体字段中供 `CheckPermission` 使用
#### Scenario: CheckPermission 使用新的角色解析
- **WHEN** `CheckPermission` 需要查询用户角色时
- **THEN** 调用 `s.accountService.GetRoleIDsForAccount(ctx, userID)`
- **AND** 不再直接调用 `s.accountRoleStore.GetRoleIDsByAccountID()`
- **AND** 获得的角色 ID 列表可能是账号级角色或店铺级角色
#### Scenario: Bootstrap 集成 #### Scenario: Bootstrap 集成
- **WHEN** 在 `internal/bootstrap/services.go` 初始化 Permission Service - **WHEN** 在 `internal/bootstrap/services.go` 初始化 Permission Service
- **THEN** 传入所有必需的 Store 依赖 - **THEN** 传入所有必需的 Store 和 Service 依赖
- **AND** Store 依赖已在 `initStores()` 中初始化 - **AND** Store 依赖已在 `initStores()` 中初始化
- **AND** Account Service 已在 Permission Service 之前初始化
### Requirement: 错误处理和日志 ### Requirement: 错误处理和日志
@@ -163,3 +228,120 @@ Permission Service SHALL 在初始化时注入所需的 Store 依赖。
- 检查结果 - 检查结果
- **AND** 用于安全审计和问题排查 - **AND** 用于安全审计和问题排查
### Requirement: 角色解析服务
系统 SHALL 提供 `GetRoleIDsForAccount` 方法,统一处理账号角色查询和店铺角色继承逻辑。
**实现位置**: `internal/service/account/role_resolver.go`
**方法签名**:
```go
func (s *Service) GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
**返回值**:
- `[]uint`: 角色 ID 列表(可能是账号级角色或店铺级角色)
- `error`: 查询失败时的错误信息
#### Scenario: 角色解析 - 超级管理员
- **WHEN** 调用 `GetRoleIDsForAccount` 查询超级管理员UserType=1的角色
- **THEN** 返回空数组 `[]uint{}`(超级管理员无角色,跳过权限检查)
- **AND** 不执行任何数据库查询
#### Scenario: 角色解析 - 平台用户
- **WHEN** 调用 `GetRoleIDsForAccount` 查询平台用户UserType=2的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(平台用户无 shop_id
#### Scenario: 角色解析 - 代理账号有账号级角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号UserType=3的角色
- **AND** 该账号已分配账号级角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(账号角色优先)
#### Scenario: 角色解析 - 代理账号继承店铺角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号的角色
- **AND** 该账号未分配账号级角色(`tb_account_role` 查询结果为空)
- **AND** 该账号的 `shop_id` 不为 NULL
- **THEN** 查询 `tb_shop_role` 表获取店铺级角色
- **AND** 返回店铺级角色 ID 列表(继承)
#### Scenario: 角色解析 - 企业账号
- **WHEN** 调用 `GetRoleIDsForAccount` 查询企业账号UserType=4的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(企业账号无继承机制)
#### Scenario: 角色解析 - 数据库查询失败
- **WHEN** 调用 `GetRoleIDsForAccount` 过程中数据库查询失败
- **THEN** 返回错误 `errors.Wrap(errors.CodeInternalError, err, "查询角色失败")`
- **AND** 不返回部分结果
### Requirement: 缓存机制兼容
权限缓存机制 SHALL 与店铺角色继承逻辑兼容,确保角色变更后缓存及时失效。
**缓存键**: `user:permissions:{user_id}`
**缓存内容**: 用户的所有权限列表(不区分账号级角色还是店铺级角色)
**缓存时效**: 30 分钟
#### Scenario: 缓存命中时使用缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中存在缓存键 `user:permissions:{user_id}`
- **THEN** 直接从缓存读取权限列表
- **AND** 不调用 `GetRoleIDsForAccount`(避免查询)
- **AND** 使用缓存的权限进行匹配
#### Scenario: 缓存未命中时重建缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中不存在缓存键
- **THEN** 调用 `GetRoleIDsForAccount` 查询角色(含继承逻辑)
- **AND** 查询角色的所有权限
- **AND** 将权限列表写入 RedisTTL 30 分钟
#### Scenario: 店铺角色变更时清理缓存
- **WHEN** 店铺角色变更(分配/删除)
- **THEN** 查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,自动重建缓存(使用新的角色解析逻辑)
#### Scenario: 账号角色变更时清理缓存(现有行为)
- **WHEN** 账号级角色变更(分配/删除)
- **THEN** 删除该账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,重建缓存
### Requirement: 性能要求
角色继承逻辑 SHALL 满足以下性能要求:
- 角色解析查询时间 < 10ms含店铺角色查询
- 权限检查总时间 < 50ms含角色解析、权限查询、匹配
- 缓存命中时权限检查时间 < 1ms
#### Scenario: 角色解析性能
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号角色
- **AND** 账号无账号级角色,需查询店铺级角色
- **THEN** 总查询时间(账号角色查询 + 店铺角色查询)< 10ms
- **AND** 使用索引 `idx_shop_role_shop_id` 优化查询
#### Scenario: 缓存命中性能
- **WHEN** 调用 `CheckPermission` 且缓存命中
- **THEN** 总处理时间 < 1ms
- **AND** 不执行任何数据库查询

View File

@@ -0,0 +1,323 @@
# shop-role-management Specification
## Purpose
提供店铺级角色管理能力,允许平台为代理店铺设置默认角色,该店铺下所有账号自动继承,简化 MVP 阶段的批量角色分配操作。
## Requirements
### Requirement: 分配店铺角色
系统 SHALL 提供接口允许平台用户或店铺管理员为店铺分配角色。
**接口**: `POST /api/admin/shops/:shop_id/roles`
**请求体**:
```json
{
"role_ids": [5] // 角色 ID 列表,传空数组表示清空所有角色
}
```
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 1,
"shop_id": 10,
"role_id": 5,
"status": 1,
"created_at": "2026-02-02T10:00:00Z"
}
],
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功分配单个角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [5]}`
- **AND** 角色 ID 5 存在且为客户角色RoleType=2
- **AND** 店铺 ID 10 存在
- **THEN** 系统创建店铺-角色关联记录
- **AND** 返回 HTTP 200 和关联记录
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 清空店铺所有角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": []}`
- **THEN** 系统删除该店铺的所有角色关联
- **AND** 返回 HTTP 200 和空数组
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 替换现有角色
- **WHEN** 店铺已分配角色 ID 5
- **AND** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [7]}`
- **THEN** 系统删除原有角色 ID 5 的关联
- **AND** 创建新的角色 ID 7 的关联
- **AND** 返回 HTTP 200 和新关联记录
#### Scenario: 角色类型校验失败
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [3]}`
- **AND** 角色 ID 3 是平台角色RoleType=1
- **THEN** 返回 HTTP 400 错误码 `errors.CodeInvalidParam`
- **AND** 错误消息为"店铺只能分配客户角色"
- **AND** 不创建任何关联记录
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `POST /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `POST /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内(不是自己店铺或下级店铺)
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 查询店铺角色
系统 SHALL 提供接口查询店铺已分配的角色列表。
**接口**: `GET /api/admin/shops/:shop_id/roles`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 5,
"role_name": "代理店长",
"role_desc": "代理店铺管理员",
"status": 1
}
]
},
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 查询已分配角色
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 已分配角色 ID 5
- **THEN** 返回 HTTP 200 和角色详情列表
- **AND** 包含角色名称、描述等信息
#### Scenario: 查询未分配角色的店铺
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 未分配任何角色
- **THEN** 返回 HTTP 200
- **AND** `roles` 字段为空数组
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `GET /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `GET /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 删除店铺角色
系统 SHALL 提供接口删除店铺的特定角色关联。
**接口**: `DELETE /api/admin/shops/:shop_id/roles/:role_id`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功删除角色
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 存在
- **AND** 店铺已分配角色 ID 5
- **THEN** 系统删除该关联记录
- **AND** 返回 HTTP 200
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 删除不存在的角色关联
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 未分配角色 ID 5
- **THEN** 返回 HTTP 200幂等操作
- **AND** 不执行任何数据库操作
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/999/roles/5`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `DELETE /api/admin/shops/20/roles/5`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 数据库表结构
系统 SHALL 创建 `tb_shop_role` 表存储店铺-角色关联关系。
**表结构**:
```sql
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (shop_id, role_id) WHERE deleted_at IS NULL
);
```
**索引**:
- `idx_shop_role_shop_id` - 查询店铺角色(高频)
- `idx_shop_role_role_id` - 查询角色被哪些店铺使用(低频)
- `idx_shop_role_deleted_at` - 软删除过滤
#### Scenario: 唯一性约束
- **WHEN** 尝试为同一店铺分配同一角色两次
- **THEN** 数据库返回唯一性约束冲突错误
- **AND** 系统捕获错误并返回友好错误消息
#### Scenario: 软删除机制
- **WHEN** 删除店铺角色关联
- **THEN** 系统设置 `deleted_at` 字段为当前时间
- **AND** 后续查询自动过滤 `deleted_at IS NOT NULL` 的记录
### Requirement: 缓存失效策略
系统 SHALL 在店铺角色变更时清理相关账号的权限缓存。
#### Scenario: 分配角色时清理缓存
- **WHEN** 为店铺 ID 10 分配角色
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,账号会重新查询并继承新角色
#### Scenario: 删除角色时清理缓存
- **WHEN** 删除店铺 ID 10 的角色关联
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键
- **AND** 下次权限检查时,账号将无角色(如果无账号级角色)
#### Scenario: 账号有自己角色时不受影响
- **WHEN** 店铺角色变更
- **AND** 某账号有自己的账号级角色
- **THEN** 该账号的权限缓存被清理
- **AND** 下次权限检查时,继续使用账号级角色(不继承店铺角色)
### Requirement: 权限控制
店铺角色管理接口 SHALL 实施权限控制,只有有权限的用户才能操作。
**权限规则**:
- 超级管理员UserType=1可操作所有店铺
- 平台用户UserType=2可操作所有店铺
- 代理用户UserType=3只能操作自己店铺及下级店铺
- 企业用户UserType=4无权限操作店铺角色
#### Scenario: 超级管理员操作任意店铺
- **WHEN** 超级管理员调用店铺角色管理接口
- **THEN** 跳过权限检查
- **AND** 允许操作任意店铺
#### Scenario: 平台用户操作任意店铺
- **WHEN** 平台用户调用店铺角色管理接口
- **THEN** 允许操作任意店铺
#### Scenario: 代理用户操作下级店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 15 是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 15, shopStore)`
- **AND** 返回 nil有权限
- **AND** 允许操作
#### Scenario: 代理用户操作无关店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 20 不是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 20, shopStore)`
- **AND** 返回 error无权限
- **AND** 拒绝操作
#### Scenario: 企业用户尝试操作店铺角色
- **WHEN** 企业用户调用店铺角色管理接口
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 业务规则校验
店铺角色分配 SHALL 执行业务规则校验,确保数据一致性。
#### Scenario: 角色存在性校验
- **WHEN** 分配店铺角色时指定角色 ID 列表
- **THEN** 系统查询所有角色是否存在
- **AND** 如果部分角色不存在,返回错误"部分角色不存在"
- **AND** 不创建任何关联记录(原子操作)
#### Scenario: 角色状态校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `status` 字段为 0禁用
- **THEN** 返回错误"角色已禁用"
- **AND** 不创建关联记录
#### Scenario: 角色类型校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `role_type` 字段为 1平台角色
- **THEN** 返回错误"店铺只能分配客户角色"
- **AND** 不创建关联记录
#### Scenario: 店铺存在性校验
- **WHEN** 分配店铺角色时指定店铺 ID
- **AND** 该店铺不存在或已软删除
- **THEN** 返回错误"店铺不存在"
- **AND** 不执行任何操作

View File

@@ -18,6 +18,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
Permission: admin.NewPermissionHandler(nil), Permission: admin.NewPermissionHandler(nil),
PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil), PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil),
Shop: admin.NewShopHandler(nil), Shop: admin.NewShopHandler(nil),
ShopRole: admin.NewShopRoleHandler(nil),
ShopCommission: admin.NewShopCommissionHandler(nil), ShopCommission: admin.NewShopCommissionHandler(nil),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil), CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil), CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil),

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Fix role_assignment_limit_test.go - add shopRoleStore declarations
cd /Users/break/csxjProject/junhong_cmp_fiber
# Add shopRoleStore for all test functions
sed -i.bak3 '74a\
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
' tests/unit/role_assignment_limit_test.go
sed -i.bak4 '122a\
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
' tests/unit/role_assignment_limit_test.go
sed -i.bak5 '170a\
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
' tests/unit/role_assignment_limit_test.go
# Fix shop_service_test.go - add shopRoleStore and roleStore
sed -i.bak6 '26a\
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)\
roleStore := postgres.NewRoleStore(tx)
' tests/unit/shop_service_test.go
echo "All test files fixed!"

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import re
# 1. Fix internal/service/account/service_test.go
print("Fixing service_test.go...")
with open('internal/service/account/service_test.go', 'r') as f:
content = f.read()
content = re.sub(
r'(\taccountRoleStore := postgres\.NewAccountRoleStore\(tx, rdb\)\n)',
r'\1\tshopRoleStore := postgres.NewShopRoleStore(tx, rdb)\n',
content,
count=1
)
content = re.sub(
r'New\(accountStore, roleStore, accountRoleStore, (&MockShopStore{[^}]*}), (&MockEnterpriseStore{[^}]*}), (&MockAuditService{[^}]*})\)',
r'New(accountStore, roleStore, accountRoleStore, nil, \1, \2, \3)',
content
)
with open('internal/service/account/service_test.go', 'w') as f:
f.write(content)
print("✓ Fixed service_test.go")
# 2. Fix tests/unit/permission_check_test.go
print("Fixing permission_check_test.go...")
with open('tests/unit/permission_check_test.go', 'r') as f:
content = f.read()
content = re.sub(
r'permission\.New\(permissionStore, accountRoleStore, rolePermStore, rdb\)',
'permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)',
content
)
with open('tests/unit/permission_check_test.go', 'w') as f:
f.write(content)
print("✓ Fixed permission_check_test.go")
# 3. Fix tests/unit/permission_cache_test.go
print("Fixing permission_cache_test.go...")
with open('tests/unit/permission_cache_test.go', 'r') as f:
content = f.read()
content = re.sub(
r'permission\.New\(permissionStore, accountRoleStore, rolePermStore, rdb\)',
'permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)',
content
)
with open('tests/unit/permission_cache_test.go', 'w') as f:
f.write(content)
print("✓ Fixed permission_cache_test.go")
# 4. Fix tests/unit/shop_service_test.go
print("Fixing shop_service_test.go...")
with open('tests/unit/shop_service_test.go', 'r') as f:
content = f.read()
content = re.sub(
r'(\taccountStore := postgres\.NewAccountStore\(tx, rdb\)\n)',
r'\1\tshopRoleStore := postgres.NewShopRoleStore(tx, rdb)\n\troleStore := postgres.NewRoleStore(tx)\n',
content,
count=1
)
content = re.sub(
r'shop\.New\(shopStore, accountStore\)',
'shop.New(shopStore, accountStore, shopRoleStore, roleStore)',
content
)
with open('tests/unit/shop_service_test.go', 'w') as f:
f.write(content)
print("✓ Fixed shop_service_test.go")
print("\nAll files fixed!")

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""修复测试文件中的构造函数调用"""
import re
# 修复 account_role_test.go
with open('tests/integration/account_role_test.go', 'r') as f:
content = f.read()
# 添加 shopRoleStore 初始化
content = re.sub(
r'(\tenpriseStore := postgresStore\.NewEnterpriseStore\(env\.TX, env\.Redis\)\n)',
r'\1\tshopRoleStore := postgresStore.NewShopRoleStore(env.TX, env.Redis)\n',
content
)
# 修复 accountService.New 调用
content = re.sub(
r'accountService\.New\(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService\)',
'accountService.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)',
content
)
with open('tests/integration/account_role_test.go', 'w') as f:
f.write(content)
print("Fixed account_role_test.go")
# 修复 role_assignment_limit_test.go
with open('tests/unit/role_assignment_limit_test.go', 'r') as f:
content = f.read()
# 添加 shopRoleStore 初始化
content = re.sub(
r'(\tenpriseStore := postgres\.NewEnterpriseStore\(tx, rdb\)\n)',
r'\1\tshopRoleStore := postgres.NewShopRoleStore(tx, rdb)\n',
content,
count=1 # 只替换第一个
)
# 修复 account.New 调用
content = re.sub(
r'account\.New\(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService\)',
'account.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)',
content
)
with open('tests/unit/role_assignment_limit_test.go', 'w') as f:
f.write(content)
print("Fixed role_assignment_limit_test.go")
# 修复 permission_platform_filter_test.go
with open('tests/unit/permission_platform_filter_test.go', 'r') as f:
content = f.read()
# 修复 permission.New 调用 - 需要添加 nil 作为第4个参数 (accountService)
content = re.sub(
r'permission\.New\(permissionStore, accountRoleStore, rolePermStore, rdb\)',
'permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)',
content
)
with open('tests/unit/permission_platform_filter_test.go', 'w') as f:
f.write(content)
print("Fixed permission_platform_filter_test.go")
# 修复 account_audit_test.go
with open('tests/integration/account_audit_test.go', 'r') as f:
content = f.read()
# 添加 shopRoleStore 初始化
content = re.sub(
r'(\t\tenpriseStore := postgres\.NewEnterpriseStore\(env\.TX, env\.Redis\)\n)',
r'\1\t\tshopRoleStore := postgres.NewShopRoleStore(env.TX, env.Redis)\n',
content
)
# 修复 accountSvc.New 调用
content = re.sub(
r'accountSvc\.New\(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService\)',
'accountSvc.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)',
content
)
with open('tests/integration/account_audit_test.go', 'w') as f:
f.write(content)
print("Fixed account_audit_test.go")
print("\n所有文件修复完成!")

View File

@@ -224,9 +224,10 @@ func TestAccountAudit(t *testing.T) {
accountRoleStore := postgres.NewAccountRoleStore(env.TX, env.Redis) accountRoleStore := postgres.NewAccountRoleStore(env.TX, env.Redis)
shopStore := postgres.NewShopStore(env.TX, env.Redis) shopStore := postgres.NewShopStore(env.TX, env.Redis)
enterpriseStore := postgres.NewEnterpriseStore(env.TX, env.Redis) enterpriseStore := postgres.NewEnterpriseStore(env.TX, env.Redis)
shopRoleStore := postgres.NewShopRoleStore(env.TX, env.Redis)
auditLogStore := postgres.NewAccountOperationLogStore(env.TX) auditLogStore := postgres.NewAccountOperationLogStore(env.TX)
auditService := accountAuditSvc.NewService(auditLogStore) auditService := accountAuditSvc.NewService(auditLogStore)
accountService := accountSvc.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) accountService := accountSvc.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
// 调用 RemoveRole // 调用 RemoveRole
ctx := env.GetSuperAdminContext() ctx := env.GetSuperAdminContext()

View File

@@ -24,9 +24,10 @@ func TestAccountRoleAssociation_AssignRoles(t *testing.T) {
accountRoleStore := postgresStore.NewAccountRoleStore(env.TX, env.Redis) accountRoleStore := postgresStore.NewAccountRoleStore(env.TX, env.Redis)
shopStore := postgresStore.NewShopStore(env.TX, env.Redis) shopStore := postgresStore.NewShopStore(env.TX, env.Redis)
enterpriseStore := postgresStore.NewEnterpriseStore(env.TX, env.Redis) enterpriseStore := postgresStore.NewEnterpriseStore(env.TX, env.Redis)
shopRoleStore := postgresStore.NewShopRoleStore(env.TX, env.Redis)
auditLogStore := postgresStore.NewAccountOperationLogStore(env.TX) auditLogStore := postgresStore.NewAccountOperationLogStore(env.TX)
auditService := accountAuditService.NewService(auditLogStore) auditService := accountAuditService.NewService(auditLogStore)
accService := accountService.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) accService := accountService.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
// 获取超级管理员上下文 // 获取超级管理员上下文
userCtx := env.GetSuperAdminContext() userCtx := env.GetSuperAdminContext()
@@ -219,10 +220,11 @@ func TestAccountRoleAssociation_SoftDelete(t *testing.T) {
roleStore := postgresStore.NewRoleStore(env.TX) roleStore := postgresStore.NewRoleStore(env.TX)
accountRoleStore := postgresStore.NewAccountRoleStore(env.TX, env.Redis) accountRoleStore := postgresStore.NewAccountRoleStore(env.TX, env.Redis)
shopStore := postgresStore.NewShopStore(env.TX, env.Redis) shopStore := postgresStore.NewShopStore(env.TX, env.Redis)
shopRoleStore := postgresStore.NewShopRoleStore(env.TX, env.Redis)
enterpriseStore := postgresStore.NewEnterpriseStore(env.TX, env.Redis) enterpriseStore := postgresStore.NewEnterpriseStore(env.TX, env.Redis)
auditLogStore := postgresStore.NewAccountOperationLogStore(env.TX) auditLogStore := postgresStore.NewAccountOperationLogStore(env.TX)
auditService := accountAuditService.NewService(auditLogStore) auditService := accountAuditService.NewService(auditLogStore)
accService := accountService.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) accService := accountService.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
// 获取超级管理员上下文 // 获取超级管理员上下文
userCtx := env.GetSuperAdminContext() userCtx := env.GetSuperAdminContext()

View File

@@ -29,7 +29,7 @@ func TestPermissionCache_FirstCallMissSecondHit(t *testing.T) {
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
permSvc := permission.New(permStore, accountRoleStore, rolePermStore, rdb) permSvc := permission.New(permStore, accountRoleStore, rolePermStore, nil, rdb)
testUser := &model.Account{ testUser := &model.Account{
Username: "testuser", Username: "testuser",
@@ -112,7 +112,7 @@ func TestPermissionCache_ExpiredAfter30Minutes(t *testing.T) {
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
permSvc := permission.New(permStore, accountRoleStore, rolePermStore, rdb) permSvc := permission.New(permStore, accountRoleStore, rolePermStore, nil, rdb)
testUser := &model.Account{ testUser := &model.Account{
Username: "testuser2", Username: "testuser2",

View File

@@ -33,7 +33,7 @@ func TestPermissionService_CheckPermission_SuperAdmin(t *testing.T) {
permStore := postgres.NewPermissionStore(tx) permStore := postgres.NewPermissionStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
service := permission.New(permStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permStore, accountRoleStore, rolePermStore, nil, rdb)
t.Run("超级管理员自动拥有所有权限", func(t *testing.T) { t.Run("超级管理员自动拥有所有权限", func(t *testing.T) {
ctx := createContextWithUserType(1, constants.UserTypeSuperAdmin) ctx := createContextWithUserType(1, constants.UserTypeSuperAdmin)
@@ -53,7 +53,7 @@ func TestPermissionService_CheckPermission_NormalUser(t *testing.T) {
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx) roleStore := postgres.NewRoleStore(tx)
service := permission.New(permStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permStore, accountRoleStore, rolePermStore, nil, rdb)
ctx := createContextWithUserType(100, constants.UserTypePlatform) ctx := createContextWithUserType(100, constants.UserTypePlatform)
@@ -173,7 +173,7 @@ func TestPermissionService_CheckPermission_NoRole(t *testing.T) {
permStore := postgres.NewPermissionStore(tx) permStore := postgres.NewPermissionStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
service := permission.New(permStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permStore, accountRoleStore, rolePermStore, nil, rdb)
t.Run("用户无角色应返回false", func(t *testing.T) { t.Run("用户无角色应返回false", func(t *testing.T) {
ctx := createContextWithUserType(200, constants.UserTypePlatform) ctx := createContextWithUserType(200, constants.UserTypePlatform)
@@ -193,7 +193,7 @@ func TestPermissionService_CheckPermission_RoleNoPermission(t *testing.T) {
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx) roleStore := postgres.NewRoleStore(tx)
service := permission.New(permStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permStore, accountRoleStore, rolePermStore, nil, rdb)
ctx := createContextWithUserType(300, constants.UserTypePlatform) ctx := createContextWithUserType(300, constants.UserTypePlatform)

View File

@@ -25,7 +25,7 @@ func TestPermissionPlatformFilter_List(t *testing.T) {
permissionStore := postgres.NewPermissionStore(tx) permissionStore := postgres.NewPermissionStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
service := permission.New(permissionStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
@@ -109,7 +109,7 @@ func TestPermissionPlatformFilter_CreateWithDefaultPlatform(t *testing.T) {
permissionStore := postgres.NewPermissionStore(tx) permissionStore := postgres.NewPermissionStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
service := permission.New(permissionStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
@@ -136,7 +136,7 @@ func TestPermissionPlatformFilter_CreateWithSpecificPlatform(t *testing.T) {
permissionStore := postgres.NewPermissionStore(tx) permissionStore := postgres.NewPermissionStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
service := permission.New(permissionStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
@@ -176,7 +176,7 @@ func TestPermissionPlatformFilter_Tree(t *testing.T) {
permissionStore := postgres.NewPermissionStore(tx) permissionStore := postgres.NewPermissionStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
rolePermStore := postgres.NewRolePermissionStore(tx, rdb) rolePermStore := postgres.NewRolePermissionStore(tx, rdb)
service := permission.New(permissionStore, accountRoleStore, rolePermStore, rdb) service := permission.New(permissionStore, accountRoleStore, rolePermStore, nil, rdb)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))

View File

@@ -27,9 +27,10 @@ func TestRoleAssignmentLimit_PlatformUser(t *testing.T) {
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb) enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
auditLogStore := postgres.NewAccountOperationLogStore(tx) auditLogStore := postgres.NewAccountOperationLogStore(tx)
auditService := account_audit.NewService(auditLogStore) auditService := account_audit.NewService(auditLogStore)
service := account.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) service := account.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
@@ -71,10 +72,11 @@ func TestRoleAssignmentLimit_AgentUser(t *testing.T) {
roleStore := postgres.NewRoleStore(tx) roleStore := postgres.NewRoleStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb) enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
auditLogStore := postgres.NewAccountOperationLogStore(tx) auditLogStore := postgres.NewAccountOperationLogStore(tx)
auditService := account_audit.NewService(auditLogStore) auditService := account_audit.NewService(auditLogStore)
service := account.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) service := account.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
@@ -118,11 +120,12 @@ func TestRoleAssignmentLimit_EnterpriseUser(t *testing.T) {
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx) roleStore := postgres.NewRoleStore(tx)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb) enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
auditLogStore := postgres.NewAccountOperationLogStore(tx) auditLogStore := postgres.NewAccountOperationLogStore(tx)
auditService := account_audit.NewService(auditLogStore) auditService := account_audit.NewService(auditLogStore)
service := account.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) service := account.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))
@@ -165,12 +168,13 @@ func TestRoleAssignmentLimit_SuperAdmin(t *testing.T) {
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx) roleStore := postgres.NewRoleStore(tx)
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb) accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb) enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
auditLogStore := postgres.NewAccountOperationLogStore(tx) auditLogStore := postgres.NewAccountOperationLogStore(tx)
auditService := account_audit.NewService(auditLogStore) auditService := account_audit.NewService(auditLogStore)
service := account.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService) service := account.New(accountStore, roleStore, accountRoleStore, shopRoleStore, shopStore, enterpriseStore, auditService)
ctx := context.Background() ctx := context.Background()
ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0)) ctx = middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(1, constants.UserTypeSuperAdmin, 0))

View File

@@ -24,7 +24,9 @@ func TestShopService_Create(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("创建一级店铺成功", func(t *testing.T) { t.Run("创建一级店铺成功", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -243,7 +245,9 @@ func TestShopService_Update(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("更新店铺信息成功", func(t *testing.T) { t.Run("更新店铺信息成功", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -373,7 +377,9 @@ func TestShopService_Disable(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("禁用店铺成功", func(t *testing.T) { t.Run("禁用店铺成功", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -437,7 +443,9 @@ func TestShopService_Enable(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("启用店铺成功", func(t *testing.T) { t.Run("启用店铺成功", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -510,7 +518,9 @@ func TestShopService_GetByID(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("获取存在的店铺", func(t *testing.T) { t.Run("获取存在的店铺", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -559,7 +569,9 @@ func TestShopService_List(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("查询店铺列表", func(t *testing.T) { t.Run("查询店铺列表", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -596,7 +608,9 @@ func TestShopService_GetSubordinateShopIDs(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("获取下级店铺 ID 列表", func(t *testing.T) { t.Run("获取下级店铺 ID 列表", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)
@@ -661,7 +675,9 @@ func TestShopService_Delete(t *testing.T) {
shopStore := postgres.NewShopStore(tx, rdb) shopStore := postgres.NewShopStore(tx, rdb)
accountStore := postgres.NewAccountStore(tx, rdb) accountStore := postgres.NewAccountStore(tx, rdb)
service := shop.New(shopStore, accountStore) shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
roleStore := postgres.NewRoleStore(tx)
service := shop.New(shopStore, accountStore, shopRoleStore, roleStore)
t.Run("删除店铺成功", func(t *testing.T) { t.Run("删除店铺成功", func(t *testing.T) {
ctx := createContextWithUserID(1) ctx := createContextWithUserID(1)