merge: integrate latest qyqy_develop (auth login + RBAC read) into ZSY branch

Incremental merge on top of ef701c8, which already integrated the earlier qyqy base bbf623a. qyqy_develop only added commits on top of bbf623a, so this merge is conflict-free. Incoming: account/password login (POST /api/v1/auth/tokens), RBAC read-only query API, rate limit dependency, login test console and user management tools. Additive changes in app/main.py, requirements.txt and pyproject.toml from both sides are all preserved. ZSY side capabilities (visitor tokens, customer service agent, knowledge retrieval, profile projection) are unchanged.
This commit is contained in:
张胜宇
2026-09-12 10:37:53 +08:00
18 changed files with 2063 additions and 0 deletions
+27
View File
@@ -1002,6 +1002,17 @@ GET /internal/metrics
这些接口不使用业务 JSON 信封,不暴露数据库地址、模型密钥、Token、完整客户资料或异常堆栈,只允许内网和监控系统访问。JWT 签发、刷新、注销由统一身份认证模块负责,Agent 平台不重复实现。
> **实现现状(2026-09-11 更新)**:上面这句原本是"平台不做签发"的依据,实际落地时确认了
> 平台**必须**有一个登录入口 —— 否则客户 / 员工 / 管理员三种身份无法区分(各 Agent 的
> `allowed_roles` 早就分开了,缺的只是"怎么证明你是谁")。因此平台现在提供
> **`POST /api/v1/auth/tokens`**(账号密码换访问令牌,见 §19 的 A034),
> 这是本文档 §11 那句的**唯一例外**。
>
> 边界仍然守住:平台**只做登录**,**刷新与注销仍归统一身份认证模块**
> (`app/core/security.py` 已留好 `RevocationStore` 协议,接上 Redis 即可)。
> 令牌里只放 `sub`,角色 / 权限 / 数据范围一律由 `IdentityService` 每次请求查库解析,
> 所以权限变更立即生效,不受令牌有效期影响。
## 16. 验收与契约测试
### 16.1 HTTP 通用测试
@@ -1123,10 +1134,26 @@ GET /internal/metrics
| A031 | `GET /api/v1/admin/negative-word-rules` | `config:read` | 否 | `200` | 否 |
| A032 | `PUT /api/v1/admin/negative-word-rules/{rule_id}` | `config:write` | 必须 | `200` | 禁止表达 |
| A033 | `GET /api/v1/admin/audit-records` | `audit:read` | 否 | `200` | 否 |
| A034 | `POST /api/v1/auth/tokens` | 公开(登录前无身份) | 否 | `200` | 登录成功/失败 |
| A035 | `GET /api/v1/admin/roles` | `audit:read` | 否 | `200` | 否 |
| A036 | `GET /api/v1/admin/roles/{role_code}` | `audit:read` | 否 | `200` | 否 |
| A037 | `GET /api/v1/admin/roles/{role_code}/permissions` | `audit:read` | 否 | `200` | 否 |
| A038 | `GET /api/v1/admin/users/{user_id}/roles` | `audit:read` | 否 | `200` | 否 |
| O001 | `GET /internal/health/live` | 内网 | 否 | `200` | 否 |
| O002 | `GET /internal/health/ready` | 内网 | 否 | `200/503` | 否 |
| O003 | `GET /internal/metrics` | 监控系统 | 否 | `200` | 否 |
> **A034 – A038 的两点说明**:
>
> - `POST /api/v1/auth/tokens` 是平台内**唯一的登录入口**(§11 已注明这是"平台不重复实现
> 签发"的唯一例外;**刷新与注销仍归统一身份认证模块**)。
> - A035 – A038 是 RBAC 的**只读**查询,供管理员回答"谁能访问什么""这个人为什么 403"。
> 它们复用 `audit:read` 而**不新增** `rbac:read`:这份清单本身就是审计材料,且复用是
> 零数据改动、立刻可用(新增权限码得先改 `sys_permission`,而它目前由
> `seed_test_rbac.py` 以 DELETE 重建语义管理)。
> **权限变更(提权 / 降权)尚无接口** —— 那条写路径必须带三条红线
> (审计留痕、禁止自我提权、保护内置角色),需要单独评审,不是遗漏。
业务域接口 `/customer-service/handover-tickets/**`、`/advisory-plans/**`、`/sim-orders/**`、`/risk-scans/**` 和 `/risk-alerts/**` 的具体方法、请求体、领域状态机和错误码分别由对应业务文档登记;它们仍必须遵守本文第 3-5、11 和 12 节。
## 20. 变更流程
@@ -0,0 +1,274 @@
# 登录接口使用说明(给 Agent 组员)
> **面向**:需要联调登录 / 需要造测试账号的人
> **接口**:`POST /api/v1/auth/tokens`(`docs/05` §19 的 **A034**)
> **归属**:认证是**平台级能力**,由平台侧维护。业务分支**不要**自己加登录路由
> (会绕开公共鉴权,违反 `AGENTS.md` 规则 7)。
> **日期**:2026-09-11
---
## 1. 一句话
用 **`username` + `password`** 换一个 **Bearer 令牌**,之后所有接口带
`Authorization: Bearer <token>` 即可。**令牌里只有用户 id**,你的角色、权限、能看多少数据
全部由服务端按库里的 RBAC 实时解析——所以业务代码**永远只从 `RequestContext` 取身份**,
不要自己解析令牌,也不要硬编码 `user_id`。
---
## 2. ⚠️ 先记住:登录用的是 `username`,不是用户 id
演示账号的用户名**不是** `9001/9002/9003`:
| 用户 id | **username(登录用这个)** | 密码 | 角色 | 进去该看到什么 |
|---|---|---|---|---|
| 9001 | **`cust_t`** | `123456` | `customer` | 客户界面:自己的会话、适当性、知识问答 |
| 9002 | **`risk_t`** | `666666` | `risk_operator` | 风控界面:预警队列、证据、日报 |
| 9003 | **`admin_t`** | `88888888` | `admin` | 管理界面:配置发布、模型端点、审计 |
> 密码由 `tools/set_user_password.py` 设置。**这三种弱口令仅用于演示**,
> 上线前必须全部更换。
---
## 3. 接口契约
### 请求
```http
POST /api/v1/auth/tokens
Content-Type: application/json
{"username": "cust_t", "password": "123456"}
```
- **不需要** `Authorization` 头(本来就没有令牌)。
- 请求体**只接受这两个字段**(`extra="forbid"`)。多传 `roles`、`user_id` 之类会 **422** ——
这是有意的,身份只能由服务端解析,不能由调用方声明。
- 建议带 `X-Trace-ID`(便于和服务端日志对齐;不带也能用)。
### 成功响应(`200`,`docs/05` §3.3 统一信封)
```json
{
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 1800,
"user_id": "9001",
"roles": ["customer"],
"data_scope": "self"
},
"meta": {"trace_id": "..."}
}
```
| 字段 | 用途 |
|---|---|
| `access_token` | 后续所有请求的 `Authorization: Bearer <它>` |
| `expires_in` | 有效期(秒),当前 **1800**(30 分钟) |
| `roles` | **前端据此决定进哪个界面** |
| `data_scope` | 该身份能看的数据范围(`self` / `own_customers` / `all`) |
> ⚠️ `roles` 与 `data_scope` 是**给前端做界面分流用的**,不是权限凭证。
> 真正的鉴权每次请求都由服务端查库解析,所以**权限被改后立刻生效**,
> 不用等令牌过期、也不要用它们在前端做安全判断。
### 失败响应
| 场景 | 状态码 | 错误码 |
|---|---|---|
| 用户名不存在 / 密码错 / 账号停用 / 从没设过密码 | **401** | `AUTHENTICATION_REQUIRED` |
| 缺少字段、多传字段 | **422** | `AGENT_INPUT_INVALID` |
| 登录尝试过于频繁(60 秒 10 次) | **429** | `RATE_LIMITED`(带 `Retry-After`) |
**401 的消息对所有失败原因都一样**("用户名或密码不正确")。这是有意设计——否则这个接口
就成了账号枚举器。**前端不要试图从 401 的 message 里区分原因**,统一提示"账号或密码错误"。
```json
{"error": {"code": "AUTHENTICATION_REQUIRED", "message": "用户名或密码不正确",
"retryable": false, "field_errors": []},
"meta": {"trace_id": "..."}}
```
---
## 4. 怎么加一个测试人员
### ⚠️ 第 0 步:先确认**你们自己那台**有角色和密码
**环境数据不随代码合并**——`config_release`、`sys_role`、`sys_user` 都是各环境自己的。
换句话说:**别人机器上能登录的账号,你们那台不一定有**。先跑这两条(都幂等,可重复执行):
```powershell
python tools/seed_test_rbac.py # 建三个角色(customer/risk_operator/admin)与它们的权限
python tools/set_user_password.py # 给 9001/9002/9003 设演示密码
```
然后确认角色确实绑上了(最后一列不该是"(无角色)"):
```powershell
python tools/create_test_user.py --list
```
```
id username user_type status roles 密码
9001 cust_t customer 正常 customer 已设
9002 risk_t employee 正常 risk_operator 已设
9003 admin_t employee 正常 admin 已设
```
`create_test_user.py` 也会替你检查:如果 `sys_role` 里没有对应角色,它会直接告诉你
"先跑 tools/seed_test_rbac.py",而不是建出一个登不进任何地方的账号。
### 一条命令
```powershell
# 先看现在有哪些账号
python tools/create_test_user.py --list
# 建一个客户账号
python tools/create_test_user.py --id 9010 --username test_cust --role customer --password abc12345
# 建一个风控专员账号
python tools/create_test_user.py --id 9011 --username test_risk --role risk_operator --password abc12345
```
**角色只有三个可选值**:`customer` / `risk_operator` / `admin`(`sys_role` 里现成的三个)。
要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),那超出这个脚本的范围,
找平台侧。
### 脚本会做三件事,并**验证第四件**
1. 写 `sys_user`(含 bcrypt 密码哈希);
2. 绑 `sys_user_role`;
3. 打印**真实解析结果**:
```
[OK] id=9010 username=test_cust role=customer 已写入
解析结果:roles=('customer',) data_scope=self
权限 10 项
[OK] 可以用它登录:{"username": "test_cust", "password": "<你刚设的>"}
```
4. 第 3 步的验证很关键——它调的是 `IdentityService.resolve`,也就是请求进来时走的**同一条
链路**。**不要只看"插入成功"**:`sys_user_role` 有个静默陷阱(下节)。
### ⚠️ 一个静默陷阱(脚本已处理,但你手写 SQL 时要当心)
`MySQL` 的 `DATETIME(0)` 会把微秒**四舍五入到秒**。如果 `sys_user_role.assigned_at`
用"当前时间"写入、进位后落在**未来**,而授权校验是 `assigned_at <= now`,那么:
> 账号建好了、密码也对、**但一个角色都拿不到** —— 表现为 `roles=()`,
> 登录照样成功,**不报错**。
`tools/create_test_user.py` 统一把 `assigned_at` **往前留 5 秒**避开这个窗口。
若你手写 SQL 或另写脚本,请照做。`tools/seed_test_rbac.py` 里也有这条注释。
### 重复执行同一个 `--id`
是**覆盖**语义(更新用户名 / 密码 / 角色),不会堆出重复行。改角色也用它:
```powershell
python tools/create_test_user.py --id 9010 --username test_cust --role admin --password abc12345
```
### 单独改密码
```powershell
python tools/set_user_password.py --list # 看现状
python tools/set_user_password.py --user 9010 --password newpass123 # 改一个
python tools/set_user_password.py # 按演示规则设置 9001/9002/9003
```
---
## 5. 前端怎么接
### 登录并保存令牌
```javascript
async function login(username, password) {
const res = await fetch("/api/v1/auth/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.status === 401) throw new Error("账号或密码错误"); // 不要区分原因
if (res.status === 429) throw new Error("尝试过于频繁,请稍后再试");
if (!res.ok) throw new Error("登录失败");
const { data } = await res.json();
localStorage.setItem("token", data.access_token); // 演示够用;
return data; // 生产建议 httpOnly Cookie
}
// 按角色分流界面
const { roles } = await login("cust_t", "123456");
if (roles.includes("admin")) router.push("/admin");
else if (roles.includes("risk_operator")) router.push("/risk");
else router.push("/chat");
```
### 之后每个请求
```javascript
fetch("/api/v1/risk/alerts", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
```
### 401 的处理
拿到 **401** 就清掉令牌、回到登录页——它涵盖"令牌缺失/无效/过期/被吊销/账号停用",
不用也无法区分。**不要**在 401 里重试登录,那会撞上 429。
### SSE 接口注意
`GET /api/v1/agent-runs/{run_id}/events` 这类 SSE 端点**必须**带 `Authorization` 头,
所以**不能用原生 `EventSource`**(它设不了自定义头),要用支持自定义头的 `fetch` 流式实现
或合规的 SSE 客户端(`docs/05` §3.2)。
---
## 6. 常见问题
| 现象 | 原因 |
|---|---|
| **登录一直 401** | ① 用错用户名——是 `cust_t` 不是 `9001`;② 密码没设过(`tools/set_user_password.py --list` 会显示"占位符,无法登录");③ 账号 `status` 不是 `正常` |
| **登录成功但接口全 403** | 角色的权限不够(不是登录问题)。看该接口要求的权限码,再核对 `sys_role_permission` |
| **登录成功但 `roles` 是 `()`** | 角色没绑上,多数是 `assigned_at` 落在未来——用 `create_test_user.py` 重跑,别手写 SQL |
| **429** | 60 秒内超过 10 次登录(含失败)。等 `Retry-After` 秒 |
| **令牌用一会儿就 401** | 有效期 30 分钟。**当前还没有刷新接口**,过期就重新登录 |
| **改了这个用户的角色,但界面没变** | 前端缓存了 `roles`。令牌里不含权限,重新调一次登录接口拿最新 `roles` 即可 |
---
## 7. 当前边界(做之前先看这里)
**已有**:
- 登录(账号密码换令牌)、限流、登录成功/失败审计(写在 `interaction_audit`)。
**还没有**(别假设它们存在):
- **刷新令牌**、**注销/吊销**、**改密码接口**、**注册**、**找回密码**。
其中刷新与注销按 `docs/05` §11 归"统一身份认证模块";
`app/core/security.py` 已留好 `RevocationStore` 协议,接上 Redis 即可,属于二期。
- 令牌**过期只能重新登录**(30 分钟)。
**审计**:每次登录成功与失败都会写一条 `interaction_audit`
(`action_type` 为 `auth.login_succeeded` / `auth.login_failed`)。
排查"谁登过"用 `GET /api/v1/admin/audit-records`。
---
## 8. 给平台侧的反馈
这个接口是平台侧新加的,边界是"**只做登录**"。如果你在联调中发现:
- 需要**刷新令牌**(不想每 30 分钟重登);
- 需要**注销**(切换账号、退出登录);
- 需要更多**角色**(`operator`、`advisor`、`super_admin` 在 `bootstrap.py` 的
`allowed_roles` 里已经被引用,但 `sys_role` 里还没有对应的角色行);
- 需要**改密码**;
直接提,别自己在业务分支里加路由——认证是共用的,两边各加一套会打架。
+27
View File
@@ -0,0 +1,27 @@
{
"user_count": 5,
"by_status": {
"正常": 4,
"禁用": 1
},
"by_user_type": {
"employee": 3,
"customer": 1,
"员工": 1
},
"password_hash_shape": {
"length_distribution": {
"1": 4,
"31": 1
},
"prefix_distribution": {
"<未知格式,首字符 'x'>": 4,
"<未知格式,首字符 '!'>": 1
}
},
"has_real_password_hash": false,
"placeholder_examples": [
"!worker-only-no-password-login!",
"x"
]
}