feat: OTP首次绑定 + 三端登录修复 + 管理端权限修复 (2026-07-08)
OTP首次绑定: - 新增统一 OTP 路由 /auth/otp-* (otp.py + router.py) - 坐席端 OTP 绑定面板 (OtpBindPanel.vue) - 管理端 OTP 管理列表 (MfaManage.vue) - agent_login 签发半认证 token 支持首次绑定流程 三端登录修复: - 坐席/管理端去掉'返回扫码登录'按钮 - 管理端改为二维码始终可见+轮询扫码状态 - 员工端 /itdesk/ 改为 alias 直接服务 H5 (不再301重定向) - docker-compose 添加 h5 volume 挂载 管理端权限修复: - 扫码登录改用 get_user_roles() 替代写死 roles=['agent'] - get_user_roles() 增加 agents.role 回退 - 新增 GET /admin/roles/user-roles 端点 - 角色管理页加载用户角色分配数据 文档更新: - OTP PRD + 系统设计文档 - 故障排查手册 v1.1 (新增6案例) - nginx 生产基准配置
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
# 增量 PRD — OTP 首次绑定流程及管理后台清除功能
|
||||
|
||||
> **文档版本**: v1.0(增量)
|
||||
> **创建日期**: 2026-07-06
|
||||
> **产品经理**: Alice
|
||||
> **状态**: 待评审
|
||||
> **关联文档**:
|
||||
> - `docs/02-产品需求/04-增量PRD-三端认证重构.md` — 母 PRD(认证重构,AUTH-P1-3 预留"首次绑定OTP引导")
|
||||
> - `docs/02-产品需求/02-产品需求文档PRD-v1.2-20260704.md` — 主 PRD
|
||||
> - `backend/app/api/otp.py` — OTP 六个端点已实现
|
||||
> - `backend/app/api/agents.py:274` — `mfa_enabled=False` 时直通登录(无绑定引导)
|
||||
> - `frontend-agent/src/views/Login.vue` — OTP 输入框已实现 `v-if="requireOtp"`,无绑定流程
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档目标
|
||||
|
||||
本增量 PRD 旨在为 **坐席端 OTP 首次绑定引导** 与 **管理后台 OTP 清除功能** 提供完整的交互定义。解决当前三大缺口:
|
||||
|
||||
1. 新坐席 `mfa_enabled=False` → 账密通过后直通工作台,无绑定引导途径
|
||||
2. 坐席 OTP 丢失(换手机 / Authenticator 误删)后无法自助重新绑定
|
||||
3. 管理端缺少「清除已绑定 OTP」管理入口(后端已有 `/otp-admin-reset`,前端未接入)
|
||||
|
||||
> **注意**:后端六个 OTP 端点(bind / verify / unbind / status / admin-reset / admin-users)已在 `backend/app/api/otp.py` 实现完成,本 PRD 聚焦**前端交互与流程设计**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|------|
|
||||
| 项目名称 | `wecom_it_smart_desk` |
|
||||
| 文档语言 | 中文 |
|
||||
| Programming Language | Vite + Vue3 + ElementPlus(前端不变) |
|
||||
| 原始需求 | 三端认证重构(PRD-04)已确定 OTP 接口与路由,本增量补充首次绑定引导与管理员重置的前端交互 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 产品目标
|
||||
|
||||
1. **坐席首次登录闭环**:新坐席账密验证通过后自动引导绑定 OTP,绑定成功后才能进入工作台——堵死"无 OTP 直通"漏洞
|
||||
2. **丢失恢复路径**:坐席 OTP 丢失后,通过管理后台申请重置 → 重新走首次绑定流程
|
||||
3. **管理员可见可控**:管理员可在后台查看全量坐席 OTP 绑定状态,并一键清除指定坐席的绑定
|
||||
|
||||
### 2.2 用户故事
|
||||
|
||||
| ID | 角色 | 用户故事 | 优先级 |
|
||||
|----|------|----------|--------|
|
||||
| US-OTP-1 | 新坐席 (new agent) | 作为首次登录的 IT 坐席,我希望账密验证通过后系统自动弹出二维码引导我绑定 OTP,扫码+输入验证码后即可进入工作台 | P0 |
|
||||
| US-OTP-2 | 已有 OTP 的坐席 (agent) | 作为已绑定 OTP 的坐席,我登录时输入账密后直接弹出 OTP 输入框验证,不要展示首次绑定流程 | P0 |
|
||||
| US-OTP-3 | OTP 丢失的坐席 (agent) | 作为手机丢失/Authenticator 误删的坐席,我希望先在管理后台申请重置 OTP,然后下次登录时重新走绑定流程,最后能自助解绑+重新绑定 | P1 |
|
||||
| US-OTP-4 | 管理员 (admin) | 作为管理员,我希望在管理后台查看所有坐席的 OTP 绑定状态,并能在坐席丢失 OTP 时一键清除其绑定,使其下次登录强制重新绑定 | P0 |
|
||||
| US-OTP-5 | 坐席 (agent) | 作为已绑定 OTP 的坐席,我希望在设置页面能自助解绑 OTP(需验证当前 OTP 码),解绑后下次登录直接进入首次绑定流程 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 需求池
|
||||
|
||||
### 3.1 P0 — 必须实现
|
||||
|
||||
| ID | 需求 | 说明 / 验收标准 |
|
||||
|----|------|-----------------|
|
||||
| OTP-P0-1 | 首次登录 OTP 绑定引导 | 坐席账密验证通过 → 后端返回 `require_otp_bind: true`(而非 `require_otp: true`)→ 前端弹出 OTP 绑定面板(二维码 + secret 明文 + 6 位验证码输入框 + 确认按钮)→ 调用 `/api/auth/otp-bind` 获取 secret/QR → 用户扫码后输入 6 位码 → 调用 `/api/auth/otp-verify` → 验证通过后 `mfa_enabled=True` → 签发完整 token → 进入工作台 |
|
||||
| OTP-P0-2 | 管理后台 OTP 绑定状态列表 | 管理后台新增「OTP 管理」页面,列表展示:坐席姓名、employee_id、OTP 绑定状态(已绑定/未绑定)、绑定时间、最后验证时间。调用 `GET /api/auth/otp-admin-users` |
|
||||
| OTP-P0-3 | 管理后台清除 OTP 绑定 | 管理员可在 OTP 管理页面点击「清除绑定」按钮 → 二次确认弹窗 → 调用 `POST /api/auth/otp-admin-reset/{employee_id}` → 该坐席下次登录强制走首次绑定流程 |
|
||||
| OTP-P0-4 | 后端区分"需要OTP验证"和"需要OTP绑定"两种状态 | `POST /agents/login` 在 `mfa_enabled=False` 时,不直接签发 token,而是返回 `require_otp_bind: true`;前端据此展示绑定面板。`mfa_enabled=True` 时保持现有 `require_otp: true` |
|
||||
|
||||
### 3.2 P1 — 应该实现
|
||||
|
||||
| ID | 需求 | 说明 / 验收标准 |
|
||||
|----|------|-----------------|
|
||||
| OTP-P1-1 | 坐席端自助解绑+重新绑定 | 坐席在「个人设置」页面看到 OTP 绑定状态,可点击「解绑 OTP」→ 输入当前 OTP 验证码 → 调用 `POST /api/auth/otp-unbind` → 解绑成功后 `mfa_enabled=False` → 下次登录自动进入首次绑定流程(复用 OTP-P0-1 面板) |
|
||||
| OTP-P1-2 | 重新绑定流程 | 坐席已解绑后,登录时走 OTP-P0-1 绑定引导(与首次登录完全相同)。也可在设置页面提供「重新绑定」入口,主动触发绑定流程 |
|
||||
| OTP-P1-3 | 绑定流程中的"跳过"选项 | 首次绑定向导中提供"暂不绑定,稍后设置"按钮 → 跳过绑定直接进入工作台 → 在导航栏/设置页显示警告徽章提醒完成绑定 → 限制跳过次数或有效期(如仅可跳过 1 次) |
|
||||
|
||||
### 3.3 P2 — 锦上添花
|
||||
|
||||
| ID | 需求 | 说明 / 验收标准 |
|
||||
|----|------|-----------------|
|
||||
| OTP-P2-1 | OTP 绑定操作日志 | 管理员可查看 OTP 绑定/解绑/清除操作记录(操作人、操作时间、操作类型、目标坐席),调用现有后端日志即可 |
|
||||
| OTP-P2-2 | 批量清除 OTP | 管理员可多选坐席后批量清除 OTP 绑定 |
|
||||
| OTP-P2-3 | 绑定截止日强制提醒 | 跳过绑定的坐席在 N 天后强制弹出绑定面板,不允许继续跳过 |
|
||||
|
||||
---
|
||||
|
||||
## 4. UI 设计稿说明
|
||||
|
||||
> 原型图目录:`docs/04-原型设计/prototypes-原型图/`
|
||||
|
||||
### 4.1 坐席端:首次绑定引导面板(新增)
|
||||
|
||||
**触发时机**:`handleLogin` 收到 `require_otp_bind: true` 后展示。
|
||||
|
||||
**布局**(在现有 `agent-login-v1.html` 登录卡片内,替换 OTP 输入区):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🛠️ IT智能服务台 │
|
||||
│ 坐席工作台 │
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🔐 首次登录 — 绑定 OTP 二次验证 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [二维码 QR Code PNG] │ │
|
||||
│ │ 200×200 px │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ 请使用 Google Authenticator │
|
||||
│ 或 Microsoft Authenticator 扫码 │
|
||||
│ │
|
||||
│ ── 或手动输入密钥 ── │
|
||||
│ 密钥:XXXX XXXX XXXX XXXX │
|
||||
│ [📋 复制] │
|
||||
│ │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ 输入 6 位验证码 │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ 验证并完成绑定 ] ← 主 CTA │
|
||||
│ [ 暂不绑定,稍后设置 ] ← 次要 │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 坐席端:登录页 OTP 验证(已有,无需改动)
|
||||
|
||||
保持现有 `agent-login-v1.html` 的 OTP 输入框逻辑:
|
||||
- `v-if="requireOtp"` 渲染 6 位 OTP 输入框
|
||||
- 按键文案「验证 OTP」
|
||||
|
||||
### 4.3 管理端:OTP 管理页面(新增)
|
||||
|
||||
**入口**:「坐席管理」→ 新增 Tab「OTP 绑定状态」,或在左侧菜单新增「OTP 管理」。
|
||||
|
||||
**列表字段**:
|
||||
|
||||
| 列名 | 数据来源 |
|
||||
|------|----------|
|
||||
| 坐席姓名 | `GET /otp-admin-users` → `name` |
|
||||
| 企微 ID | `employee_id` |
|
||||
| OTP 状态 | `mfa_enabled` → 「已绑定」/「未绑定」标签 |
|
||||
| 绑定时间 | `mfa_bound_at` |
|
||||
| 最后验证 | `mfa_last_verified_at` |
|
||||
| 操作 | 「清除绑定」按钮(仅 `mfa_enabled=true` 时可用) |
|
||||
|
||||
**清除确认弹窗**:
|
||||
```
|
||||
⚠️ 确认清除 OTP 绑定?
|
||||
坐席「张三 (zhangsan)」的 OTP 二次验证将被清除。
|
||||
该坐席下次登录时需重新绑定。
|
||||
|
||||
[取消] [确认清除]
|
||||
```
|
||||
|
||||
### 4.4 坐席端:个人设置页 OTP 管理(P1,新增)
|
||||
|
||||
在坐席端「设置」页新增「OTP 二次验证」面板:
|
||||
|
||||
- 已绑定状态:显示「✅ 已绑定|绑定时间:xxxx-xx-xx」,提供「解绑 OTP」按钮
|
||||
- 解绑操作:弹出输入框要求输入当前 6 位 OTP 验证码 → 确认后调用 `/api/auth/otp-unbind`
|
||||
- 未绑定状态:显示「⚠️ 未绑定 — [立即绑定]」按钮 → 弹出与首次登录相同的绑定面板
|
||||
|
||||
---
|
||||
|
||||
## 5. 交互流程
|
||||
|
||||
### 5.1 首次绑定流程(P0 核心流程)
|
||||
|
||||
```
|
||||
坐席打开 /itagent/login
|
||||
→ 选择"账号密码登录"
|
||||
→ 输入账号、密码 → 点击登录
|
||||
→ POST /agents/login {user_id, password}
|
||||
→ 后端返回:{ require_otp_bind: true, user_id, name }
|
||||
→ 前端展示 OTP 绑定面板(隐藏账密表单)
|
||||
→ 前端调用 POST /api/auth/otp-bind → 获得 { secret, otpauth_url, qr_code_base64 }
|
||||
→ 渲染二维码 + secret 明文
|
||||
→ 坐席用 Authenticator 扫码(或手动输入 secret)
|
||||
→ Authenticator 生成 6 位 TOTP 码
|
||||
→ 坐席在输入框中输入 6 位码 → 点击"验证并完成绑定"
|
||||
→ 前端调用 POST /api/auth/otp-verify { otp_code }
|
||||
→ 后端:校验通过 → mfa_enabled=True, mfa_bound_at=now → 返回 { verified: true }
|
||||
→ 前端再次调用 POST /agents/login {user_id, password}(或后端在 verify 成功后直接签发 token)
|
||||
→ 进入工作台
|
||||
```
|
||||
|
||||
### 5.2 OTP 丢失恢复流程(P0 + P1)
|
||||
|
||||
```
|
||||
坐席发现手机丢失/Authenticator 误删
|
||||
→ 联系管理员(企微/电话)
|
||||
→ 管理员登录管理后台 → OTP 管理页面
|
||||
→ 找到该坐席 → 点击"清除绑定" → 确认
|
||||
→ POST /api/auth/otp-admin-reset/{employee_id}
|
||||
→ 坐席下次登录时 mfa_enabled=False → 触发首次绑定流程(5.1)
|
||||
```
|
||||
|
||||
### 5.3 坐席自助解绑+重新绑定流程(P1)
|
||||
|
||||
```
|
||||
坐席正常登录进入工作台
|
||||
→ 打开"设置" → OTP 二次验证
|
||||
→ 点击"解绑 OTP"
|
||||
→ 弹窗输入当前 6 位 OTP 验证码 → 确认
|
||||
→ POST /api/auth/otp-unbind { otp_code }
|
||||
→ 解绑成功 → mfa_enabled=False
|
||||
→ 下次登录自动进入首次绑定流程(5.1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键设计决策
|
||||
|
||||
### 6.1 后端响应区分 `require_otp` vs `require_otp_bind`
|
||||
|
||||
`POST /agents/login` 当前逻辑(`agents.py:274`):
|
||||
|
||||
```python
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
return {"require_otp": True, ...} # 已有 OTP → 要求验证
|
||||
else:
|
||||
# 校验 OTP → 签发 token
|
||||
# 当 mfa_enabled=False 时,直接 fall through 到签发 token ← 问题所在
|
||||
```
|
||||
|
||||
**建议改为**:
|
||||
|
||||
```python
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
return {"require_otp": True, "message": "请输入OTP动态码", ...}
|
||||
else:
|
||||
# 校验 OTP → 签发 token
|
||||
else:
|
||||
# 未绑定 OTP → 引导绑定
|
||||
return {"require_otp_bind": True, "message": "首次登录请先绑定OTP二次验证", "user_id": agent.user_id, "name": agent.name}
|
||||
```
|
||||
|
||||
### 6.2 绑定面板是"替换登录表单"还是"新页面跳转"
|
||||
|
||||
**建议**:在登录卡片内**替换**(隐藏账密表单,显示绑定面板),保持用户在同一页面上下文内,避免跳转带来的 token 状态管理复杂度。绑定成功后直接跳转工作台。
|
||||
|
||||
### 6.3 OTP 绑定成功后是否需要重新调用 login
|
||||
|
||||
**建议**:`POST /api/auth/otp-verify` 在 `mfa_enabled=False` 的绑定场景下,验证成功后**直接返回完整 token**(类似于注册+登录合并),避免前端二次调用 login。后端 `otp.py:verify_otp` 中当 `mfa_enabled` 从 False 变为 True 时,除了写 Redis 标记,还应签发 JWT token 一并返回。
|
||||
|
||||
---
|
||||
|
||||
## 7. 待确认问题
|
||||
|
||||
| # | 问题 | 建议 | 影响 |
|
||||
|---|------|------|------|
|
||||
| Q1 | 首次绑定能否"跳过"? | 建议允许跳过 1 次(P1),在导航栏持续提醒"请完成 OTP 绑定",3 天后强制弹出绑定面板。**也可 P0 阶段先不允许跳过**,简化首版逻辑。 | 用户体验 vs 安全刚性 |
|
||||
| Q2 | 扫码登录的坐席是否需要 OTP? | 三端认证重构决策3 要求「所有登录均需 OTP」。如果是,扫码登录也需要区分 `require_otp` vs `require_otp_bind`。但扫码登录本身已是企微身份强验证,再叠加 OTP 可能过度。**建议与架构师确认**。 | 扫码登录流程复杂度 |
|
||||
| Q3 | OTP verify 绑定场景是否直接返回 token? | 建议是(见 §6.3),避免前端二次调用 login 的状态同步问题。需要修改 `otp.py:verify_otp`:绑定场景(首次)额外返回 token。 | 后端改动量小 |
|
||||
| Q4 | 管理后台的 OTP 清除操作是否需要审计日志? | 建议 P2 补充,P0 阶段先记 logger。后端已有 `logger.info`(如 `otp.py:327`)。 | 合规性 |
|
||||
| Q5 | 绑定面板中 secret 是否明文展示? | 建议默认展示(带复制按钮),用折叠/展开切换"手动输入密钥"区域。这是标准 TOTP 实践,Google/Microsoft Authenticator 都支持手动输入。 | 无安全风险(secret 仅在绑定过程中临时展示) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 与母 PRD 的衔接关系
|
||||
|
||||
| 母 PRD 条目 | 衔接 |
|
||||
|-------------|------|
|
||||
| AUTH-P1-3「首次绑定 OTP 引导」 | 本增量 PRD 将其从 P1 升级为 **P0**,并给出完整交互定义 |
|
||||
| AUTH-P0-5 OTP 接口统一 | 复用已有 `/api/auth/otp-bind` / `otp-verify` / `otp-unbind` / `otp-status` |
|
||||
| AUTH-P0-4 OTP 输入框渲染时机 | 补充:首次绑定时渲染的是**绑定面板**(二维码+输入框),而非仅 OTP 输入框 |
|
||||
| CTRT-P0-1 响应契约 | 所有 OTP 接口均遵循统一信封,前端直接消费 inner data |
|
||||
|
||||
---
|
||||
|
||||
> **文档结束** — 本增量 PRD 作为三端认证重构的子增量,聚焦 OTP 生命周期完整体验。待评审确认待确认问题后,移交架构师进行前端方案设计。
|
||||
@@ -0,0 +1,512 @@
|
||||
# 系统架构设计 — OTP 首次绑定与重置
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-07-06
|
||||
> **架构师**: Bob(高见远)
|
||||
> **关联 PRD**: `docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md`
|
||||
> **关联母文档**: `docs/02-产品需求/04-增量PRD-三端认证重构.md`
|
||||
|
||||
---
|
||||
|
||||
## Part A: 系统设计
|
||||
|
||||
### 1. 实现方案与框架选型
|
||||
|
||||
#### 1.1 核心技术挑战
|
||||
|
||||
| 挑战 | 描述 | 策略 |
|
||||
|------|------|------|
|
||||
| **登录态区分** | 后端需区分"需要 OTP 验证"(已绑定)和"需要 OTP 绑定"(未绑定)两种状态 | `agents.py` 新增 `else` 分支,返回 `require_otp_bind: true` |
|
||||
| **绑定即登录** | OTP 首次绑定成功后,用户不应重新输入账密 | `otp-verify` 绑定场景下直接签发 token 返回 |
|
||||
| **前端 API 迁移** | 现有坐席/管理端 API 适配层使用旧端点 `/mfa/*`、`/admin/mfa/*`,需迁移到统一 `/auth/otp-*` | 逐步迁移,保留旧端点兼容过渡期 |
|
||||
| **绑定面板嵌入** | PRD 要求绑定面板嵌入登录卡片内(非独立页面) | 新建 `OtpBindPanel.vue` 组件,在 `Login.vue` 中 `v-if="requireOtpBind"` 条件渲染 |
|
||||
|
||||
#### 1.2 框架与库(沿用现有栈,无新增)
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|----------|------|
|
||||
| 后端框架 | FastAPI + SQLAlchemy Async | 已有,不新增 |
|
||||
| OTP 服务 | `pyotp` + `qrcode` | 已有 `MFAService`,不新增 |
|
||||
| 前端坐席端 | Vite + Vue 3 + Element Plus + Pinia | 已有,不新增 |
|
||||
| 前端管理端 | Vite + Vue 3 + Element Plus + Pinia | 已有,不新增 |
|
||||
| Redis | `redis.asyncio` | 已有,用于 MFA 验证状态缓存 |
|
||||
|
||||
#### 1.3 架构模式
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ nginx (反向代理) │
|
||||
│ /api/auth/otp-* → backend │
|
||||
└────────────┬────────────────────────────┬────────────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ frontend-agent │ │ frontend-admin │
|
||||
│ (坐席端 SPA) │ │ (管理端 SPA) │
|
||||
│ │ │ │
|
||||
│ Login.vue │ │ MfaManage.vue │
|
||||
│ ├─ 账密表单 │ │ ├─ 列表+搜索 │
|
||||
│ ├─ OTP 输入框 │ │ ├─ 清除绑定按钮 │
|
||||
│ └─ OtpBindPanel │ │ └─ 确认弹窗 │
|
||||
│ (新增组件) │ │ │
|
||||
└────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
└──────────┬──────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ backend (FastAPI) │
|
||||
│ │
|
||||
│ agents.py │
|
||||
│ └─ agent_login: │
|
||||
│ mfa_enabled? │
|
||||
│ ├─ True+无码 │
|
||||
│ │ → require_otp│
|
||||
│ ├─ True+有码 │
|
||||
│ │ → 验证→token │
|
||||
│ └─ False │
|
||||
│ → require_ │
|
||||
│ otp_bind ★ │
|
||||
│ │
|
||||
│ otp.py │
|
||||
│ ├─ otp-bind │
|
||||
│ ├─ otp-verify ★ │
|
||||
│ │ (绑定场景返token)│
|
||||
│ ├─ otp-unbind │
|
||||
│ ├─ otp-status │
|
||||
│ ├─ otp-admin-users│
|
||||
│ └─ otp-admin-reset│
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 文件列表
|
||||
|
||||
#### 2.1 后端
|
||||
|
||||
| 相对路径 | 操作 | 说明 |
|
||||
|----------|------|------|
|
||||
| `backend/app/api/agents.py` | **【修改】** | `agent_login` 增加 `else` 分支:`mfa_enabled=False` → `require_otp_bind: true` |
|
||||
| `backend/app/api/otp.py` | **【修改】** | `verify_otp` 绑定场景(`mfa_enabled=False`)校验成功后设置 `mfa_enabled=True`、`mfa_bound_at=now`,并签发返回 token |
|
||||
| `backend/app/schemas/mfa.py` | **【修改】** | 新增 `MFAVerifyBindResponse`(含 `token` 字段) |
|
||||
|
||||
#### 2.2 前端 — 坐席端
|
||||
|
||||
| 相对路径 | 操作 | 说明 |
|
||||
|----------|------|------|
|
||||
| `frontend-agent/src/api/otp.ts` | **【新增】** | 统一 OTP API 适配层(bind / verify / unbind / status),使用新端点 `/auth/otp-*` |
|
||||
| `frontend-agent/src/stores/agent.ts` | **【修改】** | `login()` 方法处理 `require_otp_bind` 响应(不再 throw Error,返回标记供 UI 消费) |
|
||||
| `frontend-agent/src/views/Login.vue` | **【修改】** | 新增 `requireOtpBind` 状态 + `OtpBindPanel` 组件渲染 |
|
||||
| `frontend-agent/src/components/OtpBindPanel.vue` | **【新增】** | 可复用 OTP 绑定面板(二维码 + secret + 验证码输入 + 确认按钮 + "暂不绑定"按钮) |
|
||||
| `frontend-agent/src/views/Settings.vue` | **【新增】** | 坐席设置页,含 OTP 管理面板(状态显示 + 解绑 + 重新绑定)— P1 |
|
||||
| `frontend-agent/src/router/index.ts` | **【修改】** | 新增 `/settings` 路由 — P1 |
|
||||
|
||||
#### 2.3 前端 — 管理端
|
||||
|
||||
| 相对路径 | 操作 | 说明 |
|
||||
|----------|------|------|
|
||||
| `frontend-admin/src/api/mfa.ts` | **【修改】** | 切换 API 路径到 `/auth/otp-admin-users` 和 `/auth/otp-admin-reset`,更新 TS 类型字段名 |
|
||||
| `frontend-admin/src/views/MfaManage.vue` | **【修改】** | 更新字段映射(`mfa_enabled`/`mfa_bound_at`/`mfa_last_verified_at`),"重置 MFA"→"清除绑定",更新确认弹窗文案 |
|
||||
| `frontend-admin/src/components/Sidebar.vue` | **【修改】** | 在"运营管理"分区下新增「OTP 管理」菜单项 |
|
||||
| `frontend-admin/src/router/index.ts` | **【修改】** | 路由标题更新为「OTP 管理」 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据结构与接口
|
||||
|
||||
#### 3.1 后端 Schema 变更
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class MFAVerifyRequest {
|
||||
+str otp_code
|
||||
}
|
||||
|
||||
class MFAVerifyResponse {
|
||||
+bool verified
|
||||
+int expires_in
|
||||
}
|
||||
|
||||
class MFAVerifyBindResponse {
|
||||
+bool verified
|
||||
+int expires_in
|
||||
+str token
|
||||
+str user_id
|
||||
+str name
|
||||
+str role
|
||||
}
|
||||
|
||||
class MFABindStartResponse {
|
||||
+str secret
|
||||
+str otpauth_url
|
||||
+str qr_code_base64
|
||||
}
|
||||
|
||||
class MFAStatusResponse {
|
||||
+bool bound
|
||||
+bool enabled
|
||||
+datetime last_verified_at
|
||||
}
|
||||
|
||||
MFAVerifyRequest --> MFAVerifyResponse : "mfa_enabled=True → 仅验证"
|
||||
MFAVerifyRequest --> MFAVerifyBindResponse : "mfa_enabled=False → 绑定+签发token"
|
||||
```
|
||||
|
||||
#### 3.2 核心 API 契约
|
||||
|
||||
| 端点 | 方法 | 鉴权 | 说明 | 变更 |
|
||||
|------|------|------|------|------|
|
||||
| `/api/auth/otp-status` | GET | `get_current_user` | 查询绑定状态 | 无变更 |
|
||||
| `/api/auth/otp-bind` | POST | `get_current_user` | 生成 secret + QR | 无变更 |
|
||||
| `/api/auth/otp-verify` | POST | `get_current_user` | 校验 OTP → **绑定场景额外返回 token** | **★ 行为变更** |
|
||||
| `/api/auth/otp-unbind` | POST | `get_current_user` | 用户主动解绑 | 无变更 |
|
||||
| `/api/auth/otp-admin-users` | GET | `require_role("admin")` | 管理员查看全量坐席 OTP 状态 | 无变更 |
|
||||
| `/api/auth/otp-admin-reset/{employee_id}` | POST | `require_role("admin")` | 管理员清除指定坐席 OTP | 无变更 |
|
||||
| `/api/agents/login` | POST | 无(登录接口) | **mfa_enabled=False → require_otp_bind** | **★ 行为变更** |
|
||||
|
||||
#### 3.3 `POST /api/agents/login` 响应契约变更
|
||||
|
||||
**变更前**(当前 `agents.py:274`):
|
||||
```json
|
||||
// mfa_enabled=True 且未传 otp_code
|
||||
{ "code": 0, "data": { "require_otp": true, "message": "请输入OTP动态码", "user_id": "...", "name": "...", "role": "..." } }
|
||||
|
||||
// mfa_enabled=False → 直接签发 token(问题)
|
||||
{ "code": 0, "data": { "token": "...", "user_id": "...", ... } }
|
||||
```
|
||||
|
||||
**变更后**:
|
||||
```json
|
||||
// mfa_enabled=True 且未传 otp_code — 不变
|
||||
{ "code": 0, "data": { "require_otp": true, "message": "请输入OTP动态码", "user_id": "...", "name": "...", "role": "..." } }
|
||||
|
||||
// mfa_enabled=False — 新增 ★
|
||||
{ "code": 0, "data": { "require_otp_bind": true, "message": "首次登录请先绑定OTP二次验证", "user_id": "...", "name": "...", "role": "..." } }
|
||||
```
|
||||
|
||||
#### 3.4 `POST /api/auth/otp-verify` 响应契约变更
|
||||
|
||||
**变更前**(当前 `otp.py:verify_otp`):
|
||||
```json
|
||||
// mfa_enabled=False → 直接返回 verified=false
|
||||
{ "code": 0, "data": { "verified": false, "expires_in": 0 } }
|
||||
```
|
||||
|
||||
**变更后**:
|
||||
```json
|
||||
// mfa_enabled=False + 校验通过 → 绑定并签发 token ★
|
||||
{ "code": 0, "data": { "verified": true, "expires_in": 1800, "token": "...", "user_id": "...", "name": "...", "role": "..." } }
|
||||
|
||||
// mfa_enabled=True + 校验通过 → 不变
|
||||
{ "code": 0, "data": { "verified": true, "expires_in": 1800 } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 程序调用流程
|
||||
|
||||
#### 4.1 首次登录 OTP 绑定流程(P0 核心)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 坐席
|
||||
participant LoginVue as Login.vue
|
||||
participant AgentStore as agentStore
|
||||
participant OtpBind as OtpBindPanel.vue
|
||||
participant API as apiClient
|
||||
participant Backend as FastAPI Backend
|
||||
participant Redis as Redis
|
||||
|
||||
User->>LoginVue: 输入账号密码 → 点击登录
|
||||
LoginVue->>AgentStore: login(userId, password, undefined)
|
||||
AgentStore->>API: POST /api/agents/login {user_id, password}
|
||||
API->>Backend: agent_login()
|
||||
|
||||
Note over Backend: agent.mfa_enabled == False
|
||||
Backend-->>API: { require_otp_bind: true, user_id, name, role }
|
||||
API-->>AgentStore: { require_otp_bind: true, ... }
|
||||
AgentStore-->>LoginVue: return { require_otp_bind: true }
|
||||
|
||||
Note over LoginVue: 隐藏账密表单<br/>显示 OtpBindPanel
|
||||
LoginVue->>OtpBind: requireOtpBind = true
|
||||
|
||||
OtpBind->>API: POST /api/auth/otp-bind
|
||||
API->>Backend: bind_otp()
|
||||
Note over Backend: 生成 secret + QR
|
||||
Backend-->>API: { secret, otpauth_url, qr_code_base64 }
|
||||
API-->>OtpBind: { secret, otpauth_url, qr_code_base64 }
|
||||
|
||||
Note over OtpBind: 渲染二维码 + secret 明文
|
||||
User->>User: 用 Authenticator 扫码(或手动输入 secret)
|
||||
User->>OtpBind: 输入 6 位验证码 → 点击"验证并完成绑定"
|
||||
|
||||
OtpBind->>API: POST /api/auth/otp-verify { otp_code }
|
||||
API->>Backend: verify_otp()
|
||||
|
||||
Note over Backend: mfa_enabled=False (绑定场景)<br/>校验 TOTP → 通过<br/>设置 mfa_enabled=True<br/>设置 mfa_bound_at=now
|
||||
|
||||
Backend->>Redis: mark_verified(employee_id, TTL=1800)
|
||||
Backend->>Backend: 签发 JWT token
|
||||
Backend-->>API: { verified: true, token, user_id, name, role }
|
||||
API-->>OtpBind: { verified: true, token, ... }
|
||||
|
||||
OtpBind-->>LoginVue: emit('bind-success', { token, user_id, name })
|
||||
LoginVue->>AgentStore: 保存 token → 设置 agentInfo
|
||||
LoginVue->>LoginVue: router.push('/workspace')
|
||||
```
|
||||
|
||||
#### 4.2 管理后台清除 OTP 绑定流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant MfaVue as MfaManage.vue
|
||||
participant API as apiClient
|
||||
participant Backend as FastAPI Backend
|
||||
participant DB as PostgreSQL
|
||||
participant Redis as Redis
|
||||
|
||||
Admin->>MfaVue: 访问 OTP 管理页面
|
||||
MfaVue->>API: GET /api/auth/otp-admin-users
|
||||
API->>Backend: admin_list_otp_users()
|
||||
Backend->>DB: SELECT * FROM agents
|
||||
DB-->>Backend: agent rows
|
||||
Backend-->>API: [{employee_id, name, mfa_enabled, mfa_bound_at, ...}]
|
||||
API-->>MfaVue: 渲染表格
|
||||
|
||||
Admin->>MfaVue: 点击某坐席的「清除绑定」
|
||||
MfaVue->>MfaVue: ElMessageBox 二次确认弹窗
|
||||
Admin->>MfaVue: 确认清除
|
||||
|
||||
MfaVue->>API: POST /api/auth/otp-admin-reset/{employee_id}
|
||||
API->>Backend: admin_reset_otp(employee_id)
|
||||
Backend->>DB: UPDATE agents SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
|
||||
Backend->>Redis: clear_verified(employee_id)
|
||||
Backend-->>API: { success: true }
|
||||
API-->>MfaVue: ElMessage.success('已清除')
|
||||
|
||||
MfaVue->>API: 重新加载列表
|
||||
```
|
||||
|
||||
#### 4.3 坐席自助解绑流程(P1)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 坐席
|
||||
participant Settings as Settings.vue
|
||||
participant API as apiClient
|
||||
participant Backend as FastAPI Backend
|
||||
participant DB as PostgreSQL
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Settings: 打开设置页 → OTP 管理面板
|
||||
Settings->>API: GET /api/auth/otp-status
|
||||
API->>Backend: get_otp_status()
|
||||
Backend-->>API: { bound: true, enabled: true, last_verified_at }
|
||||
API-->>Settings: 显示「✅ 已绑定」
|
||||
|
||||
User->>Settings: 点击「解绑 OTP」
|
||||
Settings->>Settings: 弹窗:输入当前 OTP 验证码
|
||||
User->>Settings: 输入 6 位码 → 确认
|
||||
|
||||
Settings->>API: POST /api/auth/otp-unbind { otp_code }
|
||||
API->>Backend: unbind_otp()
|
||||
Backend->>Backend: MFAService.verify_code() → 通过
|
||||
Backend->>DB: UPDATE SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
|
||||
Backend->>Redis: clear_verified(employee_id)
|
||||
Backend-->>API: { success: true }
|
||||
API-->>Settings: ElMessage.success('OTP 已解绑')
|
||||
Settings->>Settings: 刷新状态 → 显示「⚠️ 未绑定 — 立即绑定」
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 待确认问题与假设
|
||||
|
||||
| # | 问题 | 假设 | 影响 |
|
||||
|---|------|------|------|
|
||||
| Q1 | 首次绑定能否"跳过"? | **P0 阶段不允许跳过**。简化首版逻辑,堵死无 OTP 直通漏洞。P1 阶段再引入"暂不绑定"按钮。 | 首次绑定向导无跳过按钮 |
|
||||
| Q2 | 扫码登录的坐席是否需要 OTP? | **P0 阶段暂不处理**。扫码登录已有企微身份强验证,且当前 `agent_login` 中对已绑定 OTP 的坐席仍要求 OTP。本增量聚焦账密登录的绑定引导。 | 扫码登录不做额外改动 |
|
||||
| Q3 | OTP verify 绑定场景是否直接返回 token? | **是**。修改 `otp.py:verify_otp`,绑定场景额外返回 token。避免前端二次 login。 | 后端改动量小,前端只需一次请求 |
|
||||
| Q4 | 管理后台旧端点 `/admin/mfa/*` 是否需要保留? | **保留兼容过渡期**。前端切换到新端点,旧端点暂时保留不删,待下个迭代清理。 | 无风险 |
|
||||
| Q5 | `OtpBindPanel.vue` 与已有 `MfaBind.vue` 的关系? | `OtpBindPanel` 是轻量嵌入组件(嵌入登录卡片内),`MfaBind.vue` 是独立全页组件(`/mfa-bind` 路由)。绑定流程 UI 逻辑可复用,但布局不同。**建议新建 `OtpBindPanel` 组件,从 `MfaBind.vue` 提取共享的二维码渲染 + 验证逻辑**。 | 两个组件共存,按场景使用 |
|
||||
|
||||
---
|
||||
|
||||
## Part B: 任务分解
|
||||
|
||||
### 6. 所需依赖包
|
||||
|
||||
**无新增依赖**。所有功能基于已有技术栈:
|
||||
|
||||
```
|
||||
- Vue 3 + Element Plus(前端已有)
|
||||
- Pinia(状态管理已有)
|
||||
- pyotp + qrcode(后端已有 MFAService)
|
||||
- FastAPI + SQLAlchemy Async(后端已有)
|
||||
- redis.asyncio(Redis 客户端已有)
|
||||
```
|
||||
|
||||
### 7. 任务列表(按依赖关系排序)
|
||||
|
||||
| Task ID | 任务名称 | 源文件 | 依赖 | 优先级 |
|
||||
|---------|----------|--------|------|--------|
|
||||
| **T01** | 后端 OTP 绑定流程改造 | `backend/app/api/agents.py`【修改】<br/>`backend/app/api/otp.py`【修改】<br/>`backend/app/schemas/mfa.py`【修改】 | 无 | **P0** |
|
||||
| **T02** | 管理后台 OTP 管理页完整更新 | `frontend-admin/src/api/mfa.ts`【修改】<br/>`frontend-admin/src/views/MfaManage.vue`【修改】<br/>`frontend-admin/src/components/Sidebar.vue`【修改】<br/>`frontend-admin/src/router/index.ts`【修改】 | T01(需后端端点就绪) | **P0** |
|
||||
| **T03** | 坐席端登录页 OTP 绑定面板 | `frontend-agent/src/api/otp.ts`【新增】<br/>`frontend-agent/src/stores/agent.ts`【修改】<br/>`frontend-agent/src/views/Login.vue`【修改】<br/>`frontend-agent/src/components/OtpBindPanel.vue`【新增】 | T01(需后端端点就绪) | **P0** |
|
||||
| **T04** | 坐席端设置页 OTP 管理 | `frontend-agent/src/views/Settings.vue`【新增】<br/>`frontend-agent/src/router/index.ts`【修改】<br/>`frontend-agent/src/api/otp.ts`【修改】 | T03(依赖 otp.ts API 适配层) | **P1** |
|
||||
|
||||
#### T01 详细说明 — 后端 OTP 绑定流程改造
|
||||
|
||||
**修改点 1** — `backend/app/api/agents.py:274`(`agent_login` 函数):
|
||||
```python
|
||||
# 当前:只有 if agent.mfa_enabled: ...
|
||||
# 改造后:添加 else 分支
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
return success_response(data={
|
||||
"require_otp": True,
|
||||
"message": "请输入OTP动态码",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role,
|
||||
})
|
||||
else:
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
else:
|
||||
# ★ 新增:未绑定 OTP → 引导绑定
|
||||
return success_response(data={
|
||||
"require_otp_bind": True,
|
||||
"message": "首次登录请先绑定OTP二次验证",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role,
|
||||
})
|
||||
```
|
||||
|
||||
**修改点 2** — `backend/app/api/otp.py:verify_otp`(第 213-218 行):
|
||||
```python
|
||||
# 当前:mfa_enabled=False → 直接返回 verified=false
|
||||
# 改造后:mfa_enabled=False 且 mfa_secret 存在(已调用过 otp-bind)→ 走绑定校验逻辑
|
||||
if not agent.mfa_enabled and agent.mfa_secret:
|
||||
# ★ 绑定场景:校验 OTP → 设置 enabled + bound_at → 签发 token
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
logger.warning(f"OTP bind verify 验证码错误: agent={agent.user_id}")
|
||||
return success_response(data=MFAVerifyResponse(verified=False, expires_in=0).model_dump())
|
||||
|
||||
now = datetime.now()
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = now
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 签发 token
|
||||
from app.services.token_service import TokenService
|
||||
from app.dependencies import get_redis as _get_redis_dep
|
||||
redis_client = await _get_redis_dep()
|
||||
token_service = TokenService(redis_client)
|
||||
token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=getattr(agent, 'name', '') or '',
|
||||
roles=[agent.role] if agent.role else [],
|
||||
login_source="agent",
|
||||
)
|
||||
|
||||
logger.info(f"OTP bind+verify 成功: agent={agent.user_id}")
|
||||
return success_response(data={
|
||||
"verified": True,
|
||||
"expires_in": MFA_VERIFIED_TTL_SECONDS,
|
||||
"token": token,
|
||||
"user_id": agent.user_id,
|
||||
"name": getattr(agent, 'name', '') or '',
|
||||
"role": agent.role or '',
|
||||
})
|
||||
|
||||
# 原有逻辑:已绑定场景保持不变
|
||||
if not agent.mfa_enabled or not agent.mfa_secret:
|
||||
return success_response(data=MFAVerifyResponse(verified=False, expires_in=0).model_dump())
|
||||
# ... 后续不变
|
||||
```
|
||||
|
||||
**修改点 3** — `backend/app/schemas/mfa.py`:新增 Schema(可选,也可用 dict 直接返回)。
|
||||
|
||||
#### T02 详细说明 — 管理后台 OTP 管理页完整更新
|
||||
|
||||
1. **`mfa.ts`**:API 路径从 `/admin/mfa/users` → `/auth/otp-admin-users`,`/admin/mfa/reset/{id}` → `/auth/otp-admin-reset/{id}`。TS 类型字段名从 `bound`/`bound_at` 改为 `mfa_enabled`/`mfa_bound_at`。
|
||||
2. **`MfaManage.vue`**:字段映射 `row.bound` → `row.mfa_enabled`,`row.bound_at` → `row.mfa_bound_at`;按钮文案"重置 MFA"→"清除绑定";确认弹窗文案按 PRD §4.3 更新。
|
||||
3. **`Sidebar.vue`**:在"📋 运营管理"分组添加 `<el-menu-item index="/mfa-manage">` 菜单项。
|
||||
4. **`router/index.ts`**:`meta.title` 更新为 `'OTP 管理'`。
|
||||
|
||||
#### T03 详细说明 — 坐席端登录页 OTP 绑定面板
|
||||
|
||||
1. **`otp.ts`**:新建 API 适配层,封装 `bindOtp()` / `verifyOtp()` / `getOtpStatus()` / `unbindOtp()`,使用新端点 `/auth/otp-*`。
|
||||
2. **`agent.ts:login()`**:处理 `require_otp_bind` 响应——不再 throw Error,返回 `{ require_otp_bind: true, user_id, name, role }` 供 Login.vue 消费。
|
||||
3. **`Login.vue`**:新增 `requireOtpBind` ref;当 `result.require_otp_bind` 为 true 时,隐藏账密表单 + OTP 输入框,显示 `<OtpBindPanel>`;监听 `bind-success` 事件保存 token 并跳转。
|
||||
4. **`OtpBindPanel.vue`**:新建可复用组件,包含:二维码渲染(`<img :src="data:image/png;base64,...">`)、secret 明文展示 + 复制按钮、6 位验证码输入框、"验证并完成绑定"主按钮、"暂不绑定,稍后设置"次要按钮(P0 阶段隐藏)。
|
||||
|
||||
#### T04 详细说明 — 坐席端设置页 OTP 管理(P1)
|
||||
|
||||
1. **`Settings.vue`**:新建设置页面,含「OTP 二次验证」面板:已绑定时显示状态+解绑按钮;未绑定时显示警告+绑定按钮。解绑流程:弹窗输入 OTP → `POST /auth/otp-unbind`。
|
||||
2. **`router/index.ts`**:新增路由 `{ path: '/settings', component: () => import('@/views/Settings.vue'), meta: { title: '个人设置', requiresAuth: true } }`。
|
||||
3. **`otp.ts`**:补充 `unbindOtp(otpCode)` 和 `getOtpStatus()` 的完整调用。
|
||||
|
||||
### 8. 共享知识
|
||||
|
||||
以下约定适用于所有任务、所有文件:
|
||||
|
||||
```
|
||||
## 响应契约
|
||||
- 所有 API 响应统一格式:{ code: 0, data: {...}, message: "success" }
|
||||
- apiClient 拦截器已自动解包 data 层(Scheme A),前端直接消费 inner data
|
||||
- 错误:{ code: 非0, message: "错误描述" } → 前端 catch 中取 error.message
|
||||
|
||||
## OTP 状态常量
|
||||
- require_otp: true → 已绑定 OTP,需要输入 6 位码验证
|
||||
- require_otp_bind: true → 未绑定 OTP,需要展示绑定面板(二维码 + 输入验证码)
|
||||
- mfa_enabled: true/false → 数据库字段,标记 OTP 是否已启用
|
||||
- mfa_bound_at: datetime → 首次绑定成功时间(NULL 表示从未绑定)
|
||||
- mfa_secret: string/NULL → TOTP 密钥(仅绑定过程中临时存储,解绑后清空)
|
||||
|
||||
## 端点路径规范
|
||||
- 坐席端/管理端统一使用 /auth/otp-* 前缀(新端点)
|
||||
- 旧端点 /mfa/* 、/admin/mfa/* 保留兼容但前端不再调用
|
||||
- nginx 剥离 /api 前缀后,对外即 /api/auth/otp-*
|
||||
|
||||
## 前端命名约定
|
||||
- OtpBindPanel.vue: 嵌入登录卡片内的轻量绑定面板(P0)
|
||||
- MfaBind.vue: 独立全页绑定向导(已有,保留用于 /mfa-bind 路由)
|
||||
- 按钮文案:后端用"清除绑定",坐席端用"解绑 OTP"
|
||||
|
||||
## 错误处理
|
||||
- OTP 验证码错误 → 返回 verified=false(不抛异常),前端显示红色提示,允许重试
|
||||
- 解绑时 OTP 错误 → 后端抛 AppException(INVALID_PARAMETER),前端显示错误消息
|
||||
- 已绑定用户重复调用 otp-bind → 后端拒绝,前端需先判断状态
|
||||
|
||||
## Redis Key
|
||||
- mfa:verified:{employee_id} = "1",TTL = 1800s(30 分钟验证窗口)
|
||||
- 绑定成功后写该 key(等同于已验证)
|
||||
- 解绑/管理员清除时删除该 key
|
||||
```
|
||||
|
||||
### 9. 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
T01["T01: 后端 OTP 绑定流程改造<br/>(agents.py + otp.py + schemas/mfa.py)<br/>P0"]
|
||||
T02["T02: 管理后台 OTP 管理页<br/>(mfa.ts + MfaManage.vue + Sidebar.vue + router)<br/>P0"]
|
||||
T03["T03: 坐席端登录 OTP 绑定面板<br/>(otp.ts + agent.ts + Login.vue + OtpBindPanel.vue)<br/>P0"]
|
||||
T04["T04: 坐席端设置页 OTP 管理<br/>(Settings.vue + router + otp.ts)<br/>P1"]
|
||||
|
||||
T01 --> T02
|
||||
T01 --> T03
|
||||
T03 --> T04
|
||||
```
|
||||
|
||||
> **说明**:T02 和 T03 可并行开发(均依赖 T01,互不依赖)。T04 依赖 T03(需要 `otp.ts` API 适配层)。
|
||||
|
||||
---
|
||||
|
||||
> **文档结束** — 请工程师按 T01 → T02/T03(并行) → T04 顺序实施。
|
||||
@@ -1,7 +1,10 @@
|
||||
# 00 · 标准故障排查手册
|
||||
|
||||
> **版本**: v1.0 | **日期**: 2026-07-07 | **维护人**: 宋献 / 助理
|
||||
> **定位**: 所有故障排查前**首先查看本手册**。本手册整合了原先散落的快速诊断、服务器端诊断、故障排查指南、4 份修复记录、通讯链路诊断、deploy/02 手册、调试验证指南。
|
||||
> **版本**: v1.1 | **日期**: 2026-07-08 | **维护人**: 宋献 / 助理
|
||||
> **定位**: 所有故障排查前**首先查看本手册**。
|
||||
> **最新**: 新增 CASE-20260708-01~06(OTP 路由404 / nginx 404 / 扫码角色 / 用户角色 / 员工端路由 / OTP列表结构)+ nginx 配错急救流程
|
||||
|
||||
| v1.1 | 2026-07-08 | 新增 6 天 7.8 案例 + nginx 急救流程 + 端到端验证更新 |
|
||||
> **前置阅读**: [运维手册(部署/回滚/备份/应急)](../01-项目总览/01-智能IT服务系统运维手册-20260704.md) · [SOP-04 应急响应](../10-项目管理/SOPs-标准流程/SOP-04-应急响应.md)
|
||||
|
||||
---
|
||||
@@ -163,6 +166,63 @@ docker logs wecom_it_backend | grep -i websocket
|
||||
|
||||
---
|
||||
|
||||
### CASE-20260708-01 · 后端路由 404 — OTP 统一路由未注册 ⭐
|
||||
- **现象**:`POST /api/auth/otp-bind` 返回 404;前端 OTP 绑定面板密钥和二维码不显示。
|
||||
- **根因**:部署 `otp.py` 时漏部署 `router.py`,`api_router.include_router(otp_router)` 未执行。本地 `router.py` 包含服务器不存在的模块(`knowledge_iteration` / `approval_queue` / `vision` / `ragflow_ingestion` / `automation`),导入失败导致整个 `router.py` 加载失败。
|
||||
- **修复**:上传 `router.py`,注释掉服务器上不存在的模块导入;同时修复 `otp.py` 中 `@require_role("admin")` 装饰器与显式 `current_user` 参数的冲突(服务器旧版 `require_role` 会自动追加 `current_user`)。
|
||||
- **教训**:修改路由时务必同步部署 `router.py`,否则新端点虽然代码存在但永远不会注册。
|
||||
|
||||
### CASE-20260708-02 · 管理端 API 全部 404 — nginx 正则 location proxy_pass 缺 rewrite
|
||||
- **现象**:`/api/admin/roles`、`/api/admin/dashboard/overview` 等全部返回 404。
|
||||
- **根因**:nginx 配置中 `location ~ ^/api/admin/`(正则匹配)内 `proxy_pass http://backend_api;` 不带尾部斜杠,导致 `/api/` 前缀未剥离,后端收到 `/api/admin/roles` 而非 `/admin/roles`。带尾部斜杠又会报错 `"proxy_pass" cannot have URI part in location given by regular expression`。
|
||||
- **修复**:去掉嵌套正则 location,改为统一 `location /api/` → `proxy_pass http://backend_api/;`(尾部斜杠剥离 /api/ 前缀)。
|
||||
- **教训**:nginx 中正则 location 不能直接用 `proxy_pass` 带 URI;需要时用 `rewrite` 剥离前缀。
|
||||
|
||||
### CASE-20260708-03 · sxn 扫码登录无 admin 权限 — QR 扫描写死 `roles=["agent"]`
|
||||
- **现象**:管理后台扫码登录后,OTP 管理/角色管理返回 403/无权限提示。`sxn` 账密登录正常。
|
||||
- **根因**:`auth_qrcode.py` 扫码自动确认逻辑中,`create_token` 写死了 `roles=["agent"]`,未调用 `get_user_roles()`。
|
||||
- **修复**:扫码确认改为调用 `RoleMappingService.get_user_roles()` 获取真实角色。
|
||||
- **教训**:所有登录路径(账密/扫码/OAuth)的角色获取必须统一走 `get_user_roles()`。
|
||||
|
||||
### CASE-20260708-04 · 用户角色列表为空 — `get_user_roles()` 只查 `user_roles` 表
|
||||
- **现象**:管理后台角色管理页"用户角色分配"表格为空;OTP 管理 API 403。
|
||||
- **根因**:`get_user_roles()` 仅查询 `user_roles` 表,但旧数据(包括 sxn 的 admin)只存在于 `agents.role` 字段。`user_roles` 表为空时返回 `["user"]`。
|
||||
- **修复**:`get_user_roles()` 增加 `agents.role` 回退查询;新增 `GET /admin/roles/user-roles` 端点;前端 `Roles.vue` 加载用户角色分配数据。
|
||||
- **教训**:新旧数据迁移时需确保角色数据完整同步到 `user_roles` 表。
|
||||
|
||||
### CASE-20260708-05 · 企微工作台点"IT支持服务"进坐席登录页 — 员工端口缺失
|
||||
- **现象**:企微工作台 → IT支持服务 → 显示坐席扫码登录页,而非员工 H5 页面。
|
||||
- **根因**:(1) nginx `/itdesk/` 被错误配置为 301 重定向到 `/itagent/`;(2) H5 构建文件未挂载到容器(`docker-compose.yml` 缺少 `./html/h5` 挂载);(3) 改重定向到 `/h5/` 后仍失败,因为 H5 `vite base` 是 `/itdesk/`,OAuth 回调依赖此路径。
|
||||
- **修复**:`/itdesk/` 直接 alias 到 H5 构建目录(`/usr/share/nginx/html/h5/`),不再 301 跳转;`docker-compose.yml` 添加 h5 volume 挂载;根路径 `/` 改为 302 → `/h5/`。
|
||||
- **nginx 关键配置**(alias + try_files SPA 模式):
|
||||
```
|
||||
location /itdesk/ {
|
||||
alias /usr/share/nginx/html/h5/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
注意:fallback 用 `/index.html` 而非 `/itdesk/index.html`——alias 会自动映射。
|
||||
- **教训**:前端 `vite base` 路径必须与 nginx 服务路径一致;OAuth 回调路径不能用 301 重定向。
|
||||
|
||||
### CASE-20260708-06 · 管理端 OTP 用户列表返回错误结构
|
||||
- **现象**:OTP 管理页面提示加载失败,`/auth/otp-admin-users` 返回 403。
|
||||
- **根因**:(1) `admin_list_otp_users` 返回普通数组,前端期望 `{total, items}` 结构;(2) `@require_role("admin")` 因 token 不含 admin 角色返回 403(见 CASE-03/04)。
|
||||
- **修复**:后端改为分页查询返回 `{total, items}`;增加 keyword/bound/page/page_size 参数支持。
|
||||
|
||||
### 附:nginx 配错急救流程(2026-07-08 实战总结)
|
||||
| 步骤 | 操作 |
|
||||
|------|------|
|
||||
| 1 | 备份:`sudo cp /opt/wecom-it-desk/nginx/nginx.conf /tmp/nginx.bak` |
|
||||
| 2 | 修改宿主文件后必须 `docker stop nginx && docker start nginx`(`reload` 有时不生效) |
|
||||
| 3 | 验证容器内配置已同步:`docker exec wecom_it_nginx grep 关键字 /etc/nginx/nginx.conf` |
|
||||
| 4 | 确认语法:`docker exec wecom_it_nginx nginx -t` |
|
||||
| 5 | 检查容器状态:`docker ps --filter name=wecom_it_nginx` |
|
||||
| 6 | 用 `agent-browser` 打开 URL 做端到端验证 |
|
||||
| 7 | 避免用 sed 修改 nginx 配置——`$uri`/`$host` 等变量会被 shell 解释;用 Python 脚本或直接上传文件 |
|
||||
|
||||
---
|
||||
|
||||
## 5 端到端验证完成标准(原《调试验证指南》整合)
|
||||
|
||||
> 宣布完成前,按 §0.2 提供真实证据。
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — Nginx 配置(生产环境 — 2026-07-08 三端正常基准)
|
||||
# =============================================================================
|
||||
# 状态:坐席端 /itagent/ + 管理端 /itadmin/ + 员工端 /itdesk/ 三端正常
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
set_real_ip_from 10.212.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 50m;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/javascript application/xml+rss
|
||||
application/json application/ld+json;
|
||||
|
||||
upstream backend_api {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
location /h5/ {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri /h5/index.html;
|
||||
}
|
||||
location /h5/api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
server_tokens off;
|
||||
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# === 员工端 — H5 直接服务,不能 301 重定向(OAuth 回调依赖此路径)===
|
||||
location /itdesk/ {
|
||||
alias /usr/share/nginx/html/h5/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# === 坐席工作台 ===
|
||||
location /itagent/ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
alias /usr/share/nginx/html/itagent/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# === 管理后台 ===
|
||||
location /itadmin/ {
|
||||
alias /usr/share/nginx/html/itadmin/;
|
||||
index index.html;
|
||||
try_files $uri /itadmin/index.html;
|
||||
}
|
||||
|
||||
# === 统一入口(已弃用)===
|
||||
location /itportal/ {
|
||||
alias /usr/share/nginx/html/itportal/;
|
||||
index index.html;
|
||||
try_files $uri /itportal/index.html;
|
||||
}
|
||||
|
||||
# === 后端 API — /api/ 前缀由 proxy_pass 尾部斜杠剥离 ===
|
||||
location /api/ {
|
||||
proxy_pass http://backend_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# === WebSocket — 不能带尾部斜杠 ===
|
||||
location /ws/ {
|
||||
access_log off;
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# === H5 静态文件 ===
|
||||
location /h5/ {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri /h5/index.html;
|
||||
}
|
||||
|
||||
# === H5 API 代理 ===
|
||||
location /h5/api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# === 根路径 → H5 员工端 ===
|
||||
location = / {
|
||||
return 302 /h5/;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user