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:
Simon
2026-07-08 21:54:57 +08:00
parent 6f0fbbb066
commit 400ce3ddcb
27 changed files with 2822 additions and 312 deletions
+38
View File
@@ -129,6 +129,44 @@ async def get_roles(
return success_response(data=[r.model_dump() for r in role_list])
# ==========================================================================
# 1.5. 用户角色分配列表
# ==========================================================================
# ---------- GET /api/admin/roles/user-roles ----------
@router.get("/user-roles")
async def list_user_role_assignments(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有用户角色分配记录。
Returns:
List of user role assignments with employee_id, role info, source, etc.
"""
stmt = (
select(UserRole, Role)
.join(Role, UserRole.role_id == Role.id)
.order_by(UserRole.assigned_at.desc().nulls_last())
)
result = await db.execute(stmt)
rows = result.all()
assignments = []
for user_role, role in rows:
assignments.append({
"employee_id": user_role.employee_id,
"role_name": role.name,
"role_display_name": role.display_name or role.name,
"source": user_role.source or "manual",
"assigned_by": user_role.assigned_by or "",
"assigned_at": user_role.assigned_at.isoformat() if user_role.assigned_at else None,
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
})
return success_response(data=assignments)
# ==========================================================================
# 2. 用户角色分配/撤销
# ==========================================================================
+32 -6
View File
@@ -267,13 +267,15 @@ async def agent_login(
await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. MFA 二次验证(已绑定 MFA 的坐席/管理员
# 决策3三端认证重构 AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
# 2. MFA 二次验证(三端认证重构 AUTH-04/AUTH-05
# 决策3(AUTH-04):移除「企微已登录+角色→免密直接进入」分支,
# 所有登录方式(扫码/账密/企微验证)均需 OTP 验证,统一安全水位。
# 执行MFA验证
# 决策4AUTH-05):区分两种 OTP 状态——
# - mfa_enabled=True → 已绑定,需验证 OTP 动态码
# - mfa_enabled=False → 未绑定,引导首次绑定流程
if agent.mfa_enabled:
# 已绑定 OTP → 要求验证或校验码
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记(必须包含role字段,否则前端校验会失败)
return success_response(data={
"require_otp": True,
"message": "请输入OTP动态码",
@@ -281,10 +283,34 @@ async def agent_login(
"name": agent.name,
"role": agent.role, # 必须包含role字段,供前端校验权限
})
else:
# 验证 OTP 码(决策3:复用 MFAService 统一校验逻辑)
# 验证 OTP 码(复用 MFAService 统一校验逻辑)
if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误,请重新输入")
else:
# 未绑定 OTP → 引导首次绑定(AUTH-05
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
# 这些端点需要 Bearer tokenget_current_user 认证),否则流程完全阻断
from app.services.token_service import TokenService
from app.dependencies import get_redis
redis_client = await get_redis()
token_service = TokenService(redis_client)
bind_token = await token_service.create_token(
employee_id=agent.user_id,
name=agent.name,
roles=["agent"],
avatar=avatar,
login_source="agent_pending_otp",
)
return success_response(data={
"require_otp_bind": True,
"message": "首次登录请先绑定OTP二次验证",
"user_id": agent.user_id,
"name": agent.name,
"role": agent.role,
"token": bind_token,
})
# 3. 生成随机 token(使用统一格式)
from app.services.token_service import TokenService
+51 -1
View File
@@ -150,6 +150,7 @@ async def scan_qrcode(
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
redis_client: aioredis.Redis = Depends(dep_redis),
db: AsyncSession = Depends(get_db),
):
"""处理企微 OAuth2 扫码回调。
@@ -186,12 +187,61 @@ async def scan_qrcode(
service = _get_qrcode_service(redis_client)
result = await service.process_scan(ticket=final_ticket, code=final_code)
# ==========================================================================
# 扫码后自动确认(auto-confirm
# ==========================================================================
# 原设计:requester 需已登录坐席调 /confirm 来授权新登录
# 问题:首次登录时电脑端无人登录,没有合法 current_user 可调 confirm
# 修正:扫码即确认,直接为扫码的企微用户签发 token
# ==========================================================================
from app.services.token_service import TokenService
from app.services.role_mapping_service import RoleMappingService
import json
from datetime import datetime
token_service = TokenService(redis_client)
# 获取用户的真实角色(而非写死 agent)
role_service = RoleMappingService(db)
user_roles = await role_service.get_user_roles(result["employee_id"])
logger.info(
f"扫码登录角色: employee_id={result['employee_id']}, "
f"roles={user_roles}"
)
auto_token = await token_service.create_token(
employee_id=result["employee_id"],
name=result["name"],
roles=user_roles,
avatar=result.get("avatar", ""),
login_source="qrcode_scan",
)
confirm_payload = {
"token": auto_token,
"confirmed_at": datetime.now().isoformat(),
"roles": user_roles,
"employee_id": result["employee_id"],
"name": result["name"],
}
CONFIRM_TTL = 60
await redis_client.setex(
f"qrcode:confirm:{final_ticket}",
CONFIRM_TTL,
json.dumps(confirm_payload, ensure_ascii=False),
)
logger.info(
f"扫码自动确认: ticket={final_ticket[:8]}..., "
f"employee_id={result['employee_id']}, name={result['name']}"
)
# GET 请求(企微 OAuth 回调)→ 重定向到前端选择页
# 因为企微 OAuth 流程不在这个端点完成最终登录,只标记 scanned,
# 等用户在坐席端点 confirm 后才能拿到 token。
# 但企微 WebView 期望看到跳转后的页面,所以这里给个提示页。
from fastapi.responses import HTMLResponse
if code is not None and ticket is not None and body is None:
if final_code is not None and final_ticket is not None and body is None:
# GET 模式:渲染一个 "扫码成功" 的 HTML 提示页 + 引导用户到登录页
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
+93 -21
View File
@@ -28,7 +28,7 @@ from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -200,36 +200,80 @@ async def verify_otp(
):
"""校验 6 位码,在 Redis 写 30 分钟复用标记。
行为
- 校验通过 → mfa:verified:{employee_id}=1 TTL 1800s
+ 更新 mfa_last_verified_at
- 校验失败 → verified=false(不抛异常,前端可重试)
两种场景(三端认证重构 AUTH-05)
- 已绑定(mfa_enabled=True → 常规验证,写 Redis 标记
- 首次绑定(mfa_enabled=False 但有 mfa_secret)→ 验证后启用 MFA 并直接签发 token
- 未初始化(无 mfa_secret)→ 返回 verified=false
Returns:
success_response({verified, expires_in})
success_response({verified, expires_in, token?})
"""
agent = await _require_agent(db, current_user)
if not agent.mfa_enabled or not agent.mfa_secret:
# 用户还没绑定 OTP,直接返回 verified=false(前端可据此跳转绑定流程)
# 场景1: 未初始化 OTP(无 secret)→ 无法验证
if not agent.mfa_secret:
return success_response(data=MFAVerifyResponse(
verified=False,
expires_in=0,
).model_dump())
).model_dump(exclude={"token"}))
# 校验
# 校验 OTP 码(两种场景共用)
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
logger.warning(f"OTP verify 验证码错误: agent={agent.user_id}")
return success_response(data=MFAVerifyResponse(
verified=False,
expires_in=0,
).model_dump())
).model_dump(exclude={"token"}))
# OTP 码校验通过
# 场景2: 首次绑定(mfa_enabled=False 但有 secret
# 行为:启用 MFA + 记录绑定时间 + 写 Redis 标记 + 直接签发 token
# 避免前端还需要二次调用 login
is_first_bind = not agent.mfa_enabled
now = datetime.now()
if is_first_bind:
agent.mfa_enabled = True
agent.mfa_bound_at = now
agent.mfa_last_verified_at = now
db.add(agent)
await db.flush()
# 写 Redis 复用标记
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
# 签发 token(直接复用 agents 登录的 token 创建逻辑)
from app.services.token_service import TokenService
token_service = TokenService(redis)
token = await token_service.create_token(
employee_id=agent.user_id,
name=agent.name or current_user.name,
roles=current_user.roles,
avatar=getattr(current_user, "avatar", None),
login_source=getattr(current_user, "login_source", "agent"),
)
logger.info(f"OTP 首次绑定成功并签发 token: agent={agent.user_id}")
return success_response(data={
**MFAVerifyResponse(
verified=True,
expires_in=MFA_VERIFIED_TTL_SECONDS,
).model_dump(),
"token": token,
"user_id": agent.user_id,
"name": agent.name or current_user.name,
"role": agent.role,
"is_first_bind": True,
})
# 场景3: 已绑定常规验证(mfa_enabled=True
# 写 Redis 复用标记(与 require_high_risk_otp 共用 key
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
# 更新最后验证时间
now = datetime.now()
agent.mfa_last_verified_at = now
db.add(agent)
await db.flush()
@@ -239,7 +283,7 @@ async def verify_otp(
return success_response(data=MFAVerifyResponse(
verified=True,
expires_in=MFA_VERIFIED_TTL_SECONDS,
).model_dump())
).model_dump(exclude={"token"}))
# =============================================================================
@@ -295,7 +339,6 @@ async def unbind_otp(
@require_role("admin")
async def admin_reset_otp(
employee_id: str,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(_get_redis),
):
@@ -335,20 +378,49 @@ async def admin_reset_otp(
@router.get("/otp-admin-users", response_model=None)
@require_role("admin")
async def admin_list_otp_users(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
keyword: str = None,
bound: str = None,
page: int = 1,
page_size: int = 20,
):
"""管理员查看全部坐席的 OTP 绑定状态。
"""管理员查看全部坐席的 OTP 绑定状态(支持搜索/过滤/分页)
Query params:
keyword: 搜索姓名或 employee_id(模糊匹配)
bound: "true"=已绑定, "false"=未绑定, 空=全部
page: 页码(默认 1)
page_size: 每页条数(默认 20
Returns:
success_response([{employee_id, name, mfa_enabled, mfa_bound_at,
mfa_last_verified_at}, ...])
success_response({total, items: [{employee_id, name, mfa_enabled,
mfa_bound_at, mfa_last_verified_at}, ...]})
"""
stmt = select(Agent).order_by(Agent.user_id)
# 构建查询
stmt = select(Agent)
# 搜索过滤
if keyword:
stmt = stmt.where(
Agent.user_id.ilike(f"%{keyword}%") |
Agent.name.ilike(f"%{keyword}%")
)
if bound == "true":
stmt = stmt.where(Agent.mfa_enabled == True)
elif bound == "false":
stmt = stmt.where(Agent.mfa_enabled == False)
# 先查总数
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
# 分页
stmt = stmt.order_by(Agent.user_id).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
agents = result.scalars().all()
users = [
items = [
{
"employee_id": a.user_id,
"name": getattr(a, "name", "") or "",
@@ -361,4 +433,4 @@ async def admin_list_otp_users(
for a in agents
]
return success_response(data=users)
return success_response(data={"total": total, "items": items})
+26 -4
View File
@@ -29,7 +29,10 @@ from app.api.admin_roles import router as admin_roles_router
from app.api.admin.security_comparison import router as security_comparison_router
from app.api.approval import router as approval_router
from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
# from app.api.knowledge_iteration import router as knowledge_iteration_router # P2-13 知识库自动迭代 (暂未完成)
from app.api.knowledge_iteration import router as knowledge_iteration_router # Tier1 知识库自动迭代
from app.api.approval_queue import router as approval_queue_router # Tier1 独立审批队列
from app.api.vision import router as vision_router # Tier1 视觉理解
from app.api.ragflow_ingestion import router as ragflow_router # Tier1 RAGFlow文档摄入
# 创建 API 路由器
# 所有子路由都会挂载到这个路由器上
@@ -274,11 +277,30 @@ api_router.include_router(admin_users_router, tags=["管理员用户管理"])
from app.api.evaluations import router as evaluations_router
api_router.include_router(evaluations_router, tags=["满意度评价"])
# 知识库自动迭代 API (P2-13)
# 知识库自动迭代 API (Tier1 挂载)
# POST /api/admin/knowledge-iteration/analyze — 触发分析
# GET /api/admin/knowledge-iteration/suggestions — 获取建议列表
# GET /api/admin/knowledge-iteration/suggestions — 获取建议列表(支持audience/confidence筛选)
# GET /api/admin/knowledge-iteration/suggestions/{id} — 获取建议详情
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve — 审核通过
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject — 审核拒绝
# POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite — 改写提案
# POST /api/admin/knowledge-iteration/suggestions/{id}/queue — 放入队列
# POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve — 队列中审批
# GET /api/admin/knowledge-iteration/stats — 获取统计
# api_router.include_router(knowledge_iteration_router, prefix="/admin/knowledge-iteration", tags=["知识库自动迭代"]) # 暂未完成
api_router.include_router(knowledge_iteration_router, prefix="/admin/knowledge-iteration", tags=["知识库自动迭代"])
# 独立审批队列 API (Tier1)
# GET /api/admin/approval-queue/queued — 队列列表
# GET /api/admin/approval-queue/queued/stats — 队列统计
# POST /api/admin/approval-queue/queued/{id}/dequeue-approve — 队列中审批通过
api_router.include_router(approval_queue_router, prefix="/admin/approval-queue", tags=["独立审批队列"])
# 视觉理解 API (Tier1)
# POST /api/vision/analyze — 分析截图(multipart: image + conversation_id
# GET /api/vision/models — 可用视觉模型列表
api_router.include_router(vision_router, prefix="/api/vision", tags=["视觉理解"])
# RAGFlow 文档摄入 API (Tier1)
# POST /api/ragflow/ingest — 上传文档触发RAGFlow处理
# GET /api/ragflow/tasks/{task_id} — 查询处理状态
api_router.include_router(ragflow_router, prefix="/api/ragflow", tags=["RAGFlow文档摄入"])
+2
View File
@@ -90,10 +90,12 @@ class MFAVerifyResponse(BaseModel):
Attributes:
verified: 验证是否通过
expires_in: 验证状态在 Redis 里的剩余秒数(1800s 滑动窗口)
token: 首次绑定时签发的登录 token(可选,仅首次绑定场景返回)
"""
verified: bool = Field(..., description="验证是否通过")
expires_in: int = Field(..., description="Redis 验证标记剩余秒数(秒)")
token: Optional[str] = Field(None, description="首次绑定成功后签发的登录 token")
# --------------------------------------------------------------------------
+10 -3
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.role import Role
from app.models.role_mapping_rule import RoleMappingRule
from app.models.user_role import UserRole
from app.models.agent import Agent
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
@@ -59,26 +60,32 @@ class RoleMappingService:
async def get_user_roles(self, employee_id: str) -> List[str]:
"""获取用户的角色列表。
查询 user_roles 表,返回用户拥有的角色标识列表
查询 user_roles 表,同时回退到 agents.role 字段作为补充
Args:
employee_id: 企微 UserID
Returns:
List[str]: 角色标识列表(如 ["user", "agent"]
List[str]: 角色标识列表(如 ["user", "agent", "admin"]
"""
stmt = (
select(Role.name)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == employee_id)
.where(
# 过滤已过期的角色
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > datetime.now())
)
)
result = await self.db.execute(stmt)
roles = [row[0] for row in result.all()]
# 回退到 agents.role 字段(兼容旧数据)
agent_stmt = select(Agent.role).where(Agent.user_id == employee_id)
agent_result = await self.db.execute(agent_stmt)
agent_role = agent_result.scalar_one_or_none()
if agent_role and agent_role not in roles:
roles.append(agent_role)
# 如果没有角色,添加默认的 user 角色
if not roles:
roles = ["user"]
+41 -1
View File
@@ -64,6 +64,32 @@ services:
max-size: "10m"
max-file: "3"
# --------------------------------------------------------------------------
# Neo4j 5 — 知识图谱存储(社区版)
# --------------------------------------------------------------------------
neo4j:
image: neo4j:5-community
container_name: wecom_it_neo4j
restart: unless-stopped
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD}
NEO4J_PLUGINS: '["apoc"]'
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
healthcheck:
test: ["CMD-SHELL", "cypher-shell -u neo4j -p ${NEO4J_PASSWORD} 'RETURN 1'"]
interval: 10s
timeout: 5s
retries: 10
networks:
- it-desk-internal
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# --------------------------------------------------------------------------
# FastAPI 后端 — 核心业务服务
# --------------------------------------------------------------------------
@@ -84,7 +110,8 @@ services:
# 数据库(Docker 内部网络,用容器名通信)
- DATABASE_URL=postgresql://${POSTGRES_USER:-wecom}:${POSTGRES_PASSWORD:-wecom_secret}@postgres:5432/${POSTGRES_DB:-wecom_it_desk}
# RedisDocker 内部网络)
- REDIS_URL=redis://redis:6379/0
# Redis URL - 密码中的特殊字符需要URL编码 (#→%23)
- REDIS_URL=redis://:R3d%21s%402026%23Secure@redis:6379/0
# CORS
- CORS_ORIGINS=${CORS_ORIGINS:-http://itsupport.servyou.com.cn}
# AI 服务(Dify
@@ -95,6 +122,13 @@ services:
- DIFY_WINGMAN_API_URL=${DIFY_WINGMAN_API_URL:-}
- DIFY_WINGMAN_API_KEY=${DIFY_WINGMAN_API_KEY:-}
- DIFY_WINGMAN_TIMEOUT=${DIFY_WINGMAN_TIMEOUT:-30}
# Neo4j 知识图谱
- NEO4J_URI=${NEO4J_URI:-bolt://neo4j:7687}
- NEO4J_USER=${NEO4J_USER:-neo4j}
- NEO4J_PASSWORD=${NEO4J_PASSWORD}
- NEO4J_DATABASE=${NEO4J_DATABASE:-neo4j}
- NEO4J_MAX_CONNECTION_LIFETIME=${NEO4J_MAX_CONNECTION_LIFETIME:-3600}
- NEO4J_MAX_CONNECTION_POOL_SIZE=${NEO4J_MAX_CONNECTION_POOL_SIZE:-50}
# Mock 登录(生产环境默认关闭,如需临时调试请在 .env 中显式设置为 true)
- MOCK_LOGIN_ENABLED=${MOCK_LOGIN_ENABLED:-false}
# 服务配置
@@ -109,6 +143,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
neo4j:
condition: service_healthy
command: >
/bin/sh -c "
echo '>>> 启动 API 服务 (跳过迁移)...' &&
@@ -175,5 +211,9 @@ volumes:
name: wecom_it_postgres_data
redis_data:
name: wecom_it_redis_data
neo4j_data:
name: wecom_it_neo4j_data
neo4j_logs:
name: wecom_it_neo4j_logs
backend-uploads:
name: wecom_it_backend_uploads
@@ -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.asyncioRedis 客户端已有)
```
### 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 = 1800s30 分钟验证窗口)
- 绑定成功后写该 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~06OTP 路由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/;
}
}
}
+5
View File
@@ -375,6 +375,11 @@ export function getRoles(): Promise<{ data: { code: number; data: Role[]; messag
return apiClient.get('/admin/roles')
}
/** 获取用户角色分配列表 */
export function getUserRoleAssignments(): Promise<{ data: { code: number; data: any[]; message: string } }> {
return apiClient.get('/admin/roles/user-roles')
}
/** 手动分配角色给用户 */
export function assignRole(
data: RoleAssignRequest
+28 -31
View File
@@ -1,35 +1,34 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台 MFA 管理 API 适配层 (Phase 2.4)
// 企微IT智能服务台 — 管理后台 OTP 管理 API 适配层 (Phase 2.4 → 迁移)
// =============================================================================
// 说明:封装 /api/admin/mfa/* 管理员视角的端点
// 对应后端: backend/app/api/mfa.py (Phase 2.1, task #17) admin_router 部分
// 说明:封装 /api/auth/otp-admin-* 管理员视角的端点
// 对应后端: backend/app/api/auth.py admin OTP 端点
//
// 管理员端点:
// GET /api/admin/mfa/users — 列出所有用户 MFA 状态
// POST /api/admin/mfa/reset/{employee_id} 重置指定用户 MFA(丢手机兜底)
// GET /api/auth/otp-admin-users — 列出所有用户 OTP 状态
// POST /api/auth/otp-admin-reset/{employee_id} — 清除指定用户 OTP 绑定(丢手机兜底)
//
// 用户视角的 5 个端点(/api/mfa/*)由 frontend-agent 端 mfa.ts 封装
// 管理后台如需代理用户操作(管理员自己绑定)也可引用 frontend-agent 的 API
// 用户视角的端点(/api/auth/otp-*)由 frontend-agent 端 otp.ts 封装
//
// 鉴权:
// - 全部用 require_role("admin")(管理员)
// - 响应格式: {code: 0, data: {}, message: "success"} 业务码 0 表示成功
// - CTRT 拦截器已解包,API 函数直接返回 inner data
//
// 典型管理员场景:
// 1. 进入 /mfa-manage → 调 GET /api/admin/mfa/users → 表格展示
// 2. 搜索 + 过滤 + 分页(支持按 bound/姓名/employee_id)
// 3. 点"重置 MFA" → 调 POST /api/admin/mfa/reset/{employee_id}
// 4. 弹 ElMessageBox 二次确认(防误操作) → 调重置端点
// 1. 进入 /mfa-manage → 调 GET /api/auth/otp-admin-users → 表格展示
// 2. 搜索 + 过滤 + 分页(支持按 mfa_enabled/姓名/employee_id)
// 3. 点"清除绑定" → 调 POST /api/auth/otp-admin-reset/{employee_id}
// 4. 弹 ElMessageBox 二次确认(防误操作) → 调清除端点
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义
// --------------------------------------------------------------------------
/** 单个用户的 MFA 状态条目 */
/** 单个用户的 OTP 状态条目 */
export interface MfaUserStatus {
/** 员工 ID(企微 userid) */
employee_id: string
@@ -37,19 +36,17 @@ export interface MfaUserStatus {
name?: string
/** 角色列表 */
roles?: string[]
/** 是否已绑定 MFA */
bound: boolean
/** 是否已启用 MFA(与 bound 等价) */
enabled: boolean
/** 是否已绑定 OTP (MFA) */
mfa_enabled: boolean
/** 首次绑定时间(ISO 8601,可空) */
bound_at?: string | null
mfa_bound_at?: string | null
/** 最近一次验证成功时间(ISO 8601,可空) */
last_verified_at?: string | null
}
/** GET /api/admin/mfa/users 响应 */
/** GET /api/auth/otp-admin-users 响应 */
export interface MfaUserListData {
/** 用户 MFA 状态列表 */
/** 用户 OTP 状态列表 */
items: MfaUserStatus[]
/** 总数 */
total: number
@@ -59,7 +56,7 @@ export interface MfaUserListData {
page_size: number
}
/** GET /api/admin/mfa/users 查询参数 */
/** GET /api/auth/otp-admin-users 查询参数 */
export interface MfaUserListParams {
/** 按姓名或 employee_id 模糊搜索 */
keyword?: string
@@ -71,9 +68,9 @@ export interface MfaUserListParams {
page_size?: number
}
/** POST /api/admin/mfa/reset/{employee_id} 响应 */
/** POST /api/auth/otp-admin-reset/{employee_id} 响应 */
export interface MfaAdminResetData {
/** 重置是否成功 */
/** 清除是否成功 */
success: boolean
}
@@ -82,30 +79,30 @@ export interface MfaAdminResetData {
// --------------------------------------------------------------------------
/**
* 1) 列出所有用户的 MFA 状态(支持搜索 + 过滤 + 分页)
* 1) 列出所有用户的 OTP 状态(支持搜索 + 过滤 + 分页)
* 管理员在 /mfa-manage 页面调这个
*
* @param params 查询参数
* @returns 分页数据
* @returns 分页数据(CTRT 已解包,直接返回 inner data)
*/
export async function listMfaUsers(
params: MfaUserListParams = {}
): Promise<MfaUserListData> {
const response: AxiosResponse = await apiClient.get('/admin/mfa/users', { params })
const response: MfaUserListData = await apiClient.get('/auth/otp-admin-users', { params })
return response
}
/**
* 2) 重置指定员工的 MFA 绑定(管理员特权,无 OTP 验证)
* 2) 清除指定员工的 OTP 绑定(管理员特权,无 OTP 验证)
* 使用场景:
* - 员工丢手机/换手机 → 管理员在后台"重置 MFA"按钮
* - 员工丢手机/换手机 → 管理员在后台"清除绑定"按钮
*
* @param employeeId 员工 ID(企微 userid)
* @returns 重置结果
* @returns 清除结果(CTRT 已解包,直接返回 inner data)
*/
export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
const response: AxiosResponse = await apiClient.post(
`/admin/mfa/reset/${encodeURIComponent(employeeId)}`
const response: MfaAdminResetData = await apiClient.post(
`/auth/otp-admin-reset/${encodeURIComponent(employeeId)}`
)
return response
}
@@ -50,6 +50,10 @@
<el-icon><Key /></el-icon>
<span>角色管理</span>
</el-menu-item>
<el-menu-item index="/mfa-manage">
<el-icon><Key /></el-icon>
<span>OTP 管理</span>
</el-menu-item>
<!-- 🔗 系统集成 -->
<div class="menu-section-title">🔗 系统集成</div>
+15 -1
View File
@@ -144,7 +144,7 @@ const routes = [
path: 'mfa-manage',
name: 'MfaManage',
component: () => import('@/views/MfaManage.vue'),
meta: { title: 'MFA 管理', requiresAuth: true },
meta: { title: 'OTP 管理', requiresAuth: true },
},
{
// 满意度评价统计
@@ -181,6 +181,20 @@ const routes = [
component: () => import('@/views/dashboard/AutoMetrics.vue'),
meta: { title: '自动化指标', requiresAuth: true },
},
{
// Tier1 知识库迭代 — 提案管理(D7)
path: 'knowledge-iteration',
name: 'KnowledgeIteration',
component: () => import('@/views/KnowledgeIteration.vue'),
meta: { title: '知识迭代管理', requiresAuth: true },
},
{
// Tier1 知识库迭代 — RAGFlow 文档摄入(通道 C / P1-5
path: 'ragflow-ingestion',
name: 'RagflowIngestion',
component: () => import('@/views/RagflowIngestion.vue'),
meta: { title: 'RAGFlow文档导入', requiresAuth: true },
},
],
},
{
+156 -80
View File
@@ -25,51 +25,55 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
<p class="login-subtitle">管理后台</p>
</div>
<!-- 登录方式选择企微已登录时显示 -->
<div v-if="showLoginOptions" class="login-options">
<p class="login-options-title">选择登录方式</p>
<div class="login-options-btns">
<!-- 企微免密登录企微内管理员可见 -->
<div v-if="wecomUserId" style="text-align: center; margin-bottom: 12px;">
<el-button
type="primary"
size="large"
class="option-btn"
:loading="wecomQuickLoading"
@click="handleWecomQuickLogin"
class="option-btn"
style="width: 100%;"
>
<el-icon><Key /></el-icon>
企微免密登录
企微免密登录{{ wecomUserId }}
</el-button>
</div>
<!-- 企微扫码登录面板始终可见与坐席端一致 -->
<div class="qr-login">
<div class="qr-container">
<div v-if="qrLoading" class="qr-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>加载中...</p>
</div>
<div v-else-if="qrCode" class="qr-code">
<img :src="qrCode" alt="企微扫码登录" />
</div>
<div v-else class="qr-error">
<p>获取二维码失败</p>
<el-button size="small" @click="fetchQrCode">重试</el-button>
</div>
</div>
<p class="qr-hint">请使用企业微信扫码登录</p>
<!-- 分隔线 -->
<div class="divider">
<span>其他登录方式</span>
</div>
<el-button
size="large"
class="option-btn"
@click="showQrLogin"
class="password-login-btn"
@click="showPasswordPanel = true"
>
<el-icon><Key /></el-icon>
企微扫码登录
</el-button>
<el-button
size="large"
class="option-btn"
@click="showPasswordLogin"
>
<el-icon><Lock /></el-icon>
<span>🔐</span>
账号密码登录
</el-button>
</div>
</div>
<!-- 企微扫码登录面板 -->
<div v-if="showQrPanel" class="qr-login">
<p class="qr-title">企微扫码登录</p>
<div class="qr-code">
<img v-if="qrCode" :src="qrCode" alt="扫码登录二维码" />
<div v-else class="qr-loading">加载中...</div>
</div>
<p class="qr-hint">请使用企业微信扫码登录</p>
<el-button link type="primary" @click="showPasswordLogin">其他登录方式</el-button>
</div>
<!-- 登录表单 -->
<!-- 账号密码登录表单与二维码同时展示无需返回按钮 -->
<div v-if="showPasswordPanel" class="password-login">
<el-form
ref="formRef"
:model="loginForm"
@@ -124,6 +128,7 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 错误提示 -->
<el-alert
@@ -149,12 +154,12 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
// ==========================================================================
// 依赖导入
// ==========================================================================
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAdminStore } from '@/stores/admin'
import type { FormInstance, FormRules } from 'element-plus'
import { User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
// ==========================================================================
// Store
@@ -186,14 +191,8 @@ const errorMsg = ref<string>('')
// 企微智能检测相关状态(PRD v1.5 §4.5
// ==========================================================================
/** 是否显示登录方式选择 */
const showLoginOptions = ref(false)
/** 是否显示扫码登录面板 */
const showQrPanel = ref(false)
/** 是否显示账号密码登录 */
const showPasswordPanel = ref(true)
const showPasswordPanel = ref(false)
/** 企微用户ID(用于免密登录) */
const wecomUserId = ref('')
@@ -204,6 +203,14 @@ const wecomQuickLoading = ref(false)
/** 企微二维码 */
const qrCode = ref('')
/** 二维码加载中 */
const qrLoading = ref(false)
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 当前二维码票据 */
let currentTicket = ''
/** 企微检测中 */
const wecomChecking = ref(false)
@@ -236,10 +243,8 @@ async function checkWecomClient(): Promise<void> {
// 首先尝试使用 UA 检测
const isWxWork = /wxwork/i.test(navigator.userAgent)
// 不在企微环境,直接显示账号密码登录
// 不在企微环境
if (!isWxWork) {
showLoginOptions.value = false
showPasswordPanel.value = true
wecomChecking.value = false
return
}
@@ -256,7 +261,7 @@ async function checkWecomClient(): Promise<void> {
params: { url: currentUrl }
})
const jsConfig = configResponse
const jsConfig: any = configResponse
console.log('[Admin Login] 获取到企微 JS-SDK 配置:', jsConfig)
// 使用 wx.config 配置企微 JS-SDK
@@ -296,34 +301,26 @@ async function checkWecomClient(): Promise<void> {
const roleResponse = await apiClient.get('/wecom/check-role', {
params: { userid: userId }
})
const roleData = roleResponse
const roleData: any = roleResponse
console.log('[Admin Login] 用户角色:', roleData)
// 如果是管理员,显示免密登录选项
if (roleData.role === 'admin') {
showLoginOptions.value = true
showPasswordPanel.value = false
// 企微内管理员可免密登录(按钮将显示在顶部)
} else {
// 非管理员,显示普通登录
showLoginOptions.value = false
showPasswordPanel.value = true
// 非管理员,使用二维码和密码登录
}
}
} catch (e) {
// 检测失败,显示普通登录
console.warn('[Admin Login] 企微 JS-SDK 检测失败:', e)
// 检测失败,显示登录选项
showLoginOptions.value = true
showPasswordPanel.value = true
}
} else {
// 没有企微 JS-SDK
showLoginOptions.value = true
showPasswordPanel.value = true
console.log('[Admin Login] 无企微 JS-SDK,使用默认登录')
}
} catch (error) {
console.error('企微客户端检测失败:', error)
showLoginOptions.value = false
showPasswordPanel.value = true
} finally {
wecomChecking.value = false
}
@@ -338,14 +335,12 @@ async function handleWecomQuickLogin(): Promise<void> {
try {
const apiClient = (await import('@/api')).default
// 调用后端企微免密登录接口
const response = await apiClient.post('/auth_wecom/jsdk-login', {
// 拦截器已统一返回 inner dataCTRT-03),response 即 {token, employee_id, name, ...}
const result: any = await apiClient.post('/auth_wecom/jsdk-login', {
userid: wecomUserId.value,
login_source: 'wecom_jsdk_admin'
})
const result = response
if (result?.token) {
const { token, employee_id, name } = result
@@ -373,36 +368,72 @@ async function handleWecomQuickLogin(): Promise<void> {
}
/**
* 显示扫码登录
* 获取扫码登录二维码
*/
async function showQrLogin(): Promise<void> {
showLoginOptions.value = false
showQrPanel.value = true
showPasswordPanel.value = false
// 获取二维码
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
try {
const apiClient = (await import('@/api')).default
const response = await apiClient.post('/auth_qrcode/create')
const result = response
if (result.code === 0) {
qrCode.value = result.data.qrcode_png_base64
? `data:image/png;base64,${result.data.qrcode_png_base64}`
: result.data.qrcode_url
const result: any = await apiClient.post('/auth_qrcode/create')
if (result.qrcode_png_base64) {
qrCode.value = `data:image/png;base64,${result.qrcode_png_base64}`
} else if (result.qrcode_url) {
qrCode.value = result.qrcode_url
}
// 保存票据并启动轮询
if (result.ticket) {
currentTicket = result.ticket
startPolling()
}
} catch (error) {
console.error('获取二维码失败:', error)
} finally {
qrLoading.value = false
}
}
/**
* 显示账号密码登录
* 轮询扫码状态
*/
function showPasswordLogin(): Promise<void> {
showLoginOptions.value = false
showQrPanel.value = false
showPasswordPanel.value = true
return Promise.resolve()
async function pollQrCode(): Promise<void> {
if (!currentTicket) return
try {
const apiClient = (await import('@/api')).default
const result: any = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
if (!result) return
const { status, token, employee_id, name } = result
if (status === 'confirmed' && token) {
stopPolling()
localStorage.setItem('admin_token', token)
if (employee_id) localStorage.setItem('admin_user_id', employee_id)
adminStore.token = token
adminStore.adminUserId = employee_id
ElMessage.success('扫码登录成功')
router.push('/')
} else if (status === 'expired') {
stopPolling()
qrCode.value = ''
ElMessage.warning('二维码已过期,请刷新')
}
} catch (error) {
console.warn('轮询扫码状态失败:', error)
}
}
/** 启动轮询(每 2 秒) */
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollQrCode, 2000)
}
/** 停止轮询 */
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/**
@@ -435,9 +466,15 @@ async function handleLogin(): Promise<void> {
}
}
// 页面加载时检测企微客户端状态
// 页面加载时检测企微客户端状态 + 获取二维码
onMounted(() => {
checkWecomClient()
fetchQrCode()
})
// 页面卸载时停止轮询
onUnmounted(() => {
stopPolling()
})
</script>
@@ -590,4 +627,43 @@ onMounted(() => {
color: var(--text-muted);
margin-bottom: 12px;
}
/* 二维码容器 */
.qr-container {
margin-bottom: 12px;
}
.qr-error {
color: var(--text-muted);
text-align: center;
padding: 20px 0;
}
/* 分隔线 */
.divider {
display: flex;
align-items: center;
margin: 16px 0;
color: var(--text-muted);
font-size: 13px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--border);
}
.divider span {
padding: 0 12px;
}
/* 账号密码登录按钮 */
.password-login-btn {
width: 100%;
}
/* 账号密码登录面板 */
.password-login {
margin-top: 8px;
}
</style>
+26 -26
View File
@@ -1,20 +1,20 @@
<!--
=============================================================================
企微IT智能服务台 MFA 管理页 (Phase 2.4, task #20)
企微IT智能服务台 OTP 管理页 (Phase 2.4, task #20 迁移)
=============================================================================
说明:管理员管理所有用户 MFA 绑定的页面
说明:管理员管理所有用户 OTP 绑定的页面
功能:
- 表格列出所有用户的 MFA 状态(已绑/未绑)
- 表格列出所有用户的 OTP 状态(已绑/未绑)
- 搜索(姓名/employee_id)+ 过滤(已绑/未绑/全部)+ 分页
- "重置 MFA" 按钮 POST /api/admin/mfa/reset/{employee_id}
- "清除绑定" 按钮 POST /api/auth/otp-admin-reset/{employee_id}
( OTP 验证,管理员特权,用于员工丢手机兜底)
- 重置 ElMessageBox 二次确认(防误操作)
- 清除 ElMessageBox 二次确认(防误操作)
设计要点:
- 不在表格里直接显示 secret(安全考虑)
- 状态列用 el-tag 颜色区分:已绑=success,未绑=info
- 重置按钮在已绑行才显示(未绑无需重置)
- 清除按钮仅在 mfa_enabled=true 显示(未绑无需清除)
- "最近验证时间"列给管理员做审计参考
-->
<template>
@@ -22,8 +22,8 @@
<!-- 页面标题 -->
<div class="page-header">
<div>
<div class="page-title">MFA 管理</div>
<div class="page-desc">管理所有用户的动态令牌(MFA)绑定状态,丢手机兜底重置</div>
<div class="page-title">OTP 管理</div>
<div class="page-desc">管理所有用户的动态令牌(OTP)绑定状态,丢手机兜底清除</div>
</div>
</div>
@@ -68,7 +68,7 @@
</div>
<!-- ============================================================ -->
<!-- 用户 MFA 状态表格 -->
<!-- 用户 OTP 状态表格 -->
<!-- ============================================================ -->
<div class="data-table-wrapper">
<el-table
@@ -99,16 +99,16 @@
<span v-if="!row.roles || row.roles.length === 0" class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="MFA 状态" min-width="100">
<el-table-column label="OTP 状态" min-width="100">
<template #default="{ row }">
<el-tag :type="row.bound ? 'success' : 'info'" size="small">
{{ row.bound ? '已绑定' : '未绑定' }}
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
{{ row.mfa_enabled ? '已绑定' : '未绑定' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="bound_at" label="首次绑定时间" min-width="160">
<el-table-column prop="mfa_bound_at" label="首次绑定时间" min-width="160">
<template #default="{ row }">
<span v-if="row.bound_at">{{ formatTime(row.bound_at) }}</span>
<span v-if="row.mfa_bound_at">{{ formatTime(row.mfa_bound_at) }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
@@ -121,14 +121,14 @@
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.bound"
v-if="row.mfa_enabled"
type="danger"
size="small"
link
:disabled="resettingId === row.employee_id"
@click="handleResetMfa(row)"
>
{{ resettingId === row.employee_id ? '重置中...' : '重置 MFA' }}
{{ resettingId === row.employee_id ? '清除中...' : '清除绑定' }}
</el-button>
<span v-else class="text-muted">无需操作</span>
</template>
@@ -168,7 +168,7 @@ import type { MfaUserStatus } from '@/api/mfa'
// ============================================================================
const loading = ref<boolean>(false)
const users = ref<MfaUserStatus[]>([])
const resettingId = ref<string>('') // 正在重置的 employee_id(给按钮 loading 用)
const resettingId = ref<string>('') // 正在清除的 employee_id(给按钮 loading 用)
const filters = reactive({
keyword: '',
@@ -185,10 +185,10 @@ const pagination = reactive({
// 计算:已绑/未绑数量(基于当前页数据)
// ============================================================================
const boundCount = computed<number>(() =>
users.value.filter((u) => u.bound).length
users.value.filter((u) => u.mfa_enabled).length
)
const unboundCount = computed<number>(() =>
users.value.filter((u) => !u.bound).length
users.value.filter((u) => !u.mfa_enabled).length
)
// ============================================================================
@@ -216,7 +216,7 @@ async function loadUsers(): Promise<void> {
// 静默失败,使用空数据
users.value = []
pagination.total = 0
const msg = err?.response?.data?.message || err?.message || '加载用户 MFA 列表失败'
const msg = err?.response?.data?.message || err?.message || '加载用户 OTP 列表失败'
ElMessage.error(msg)
} finally {
loading.value = false
@@ -244,18 +244,18 @@ function handleSizeChange(): void {
}
/**
* 重置指定用户的 MFA
* 清除指定用户的 OTP 绑定
* 二次确认 → 调 API → 刷新列表
*/
async function handleResetMfa(row: MfaUserStatus): Promise<void> {
const name = row.name || row.employee_id
try {
await ElMessageBox.confirm(
`确认重置 ${name} 的 MFA 绑定?\n\n重置后该用户需要重新绑定才能使用 MFA 功能(用于员工丢手机兜底)。\n此操作不可恢复,请谨慎操作`,
'重置 MFA 确认',
`确认清除 OTP 绑定?\n\n坐席「${name} (${row.employee_id})」的 OTP 二次验证将被清除。\n该坐席下次登录时需重新绑定`,
'清除 OTP 绑定确认',
{
type: 'warning',
confirmButtonText: '确认重置',
confirmButtonText: '确认清除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
}
@@ -268,11 +268,11 @@ async function handleResetMfa(row: MfaUserStatus): Promise<void> {
resettingId.value = row.employee_id
try {
await resetMfa(row.employee_id)
ElMessage.success(`重置 ${name}MFA 绑定`)
ElMessage.success(`清除 ${name}OTP 绑定`)
// 刷新当前页
await loadUsers()
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '重置失败'
const msg = err?.response?.data?.message || err?.message || '清除失败'
ElMessage.error(msg)
} finally {
resettingId.value = ''
+7 -3
View File
@@ -350,6 +350,7 @@ import {
assignRole,
revokeRole,
getRoleMappingRules,
getUserRoleAssignments,
createRoleMappingRule,
deleteRoleMappingRule,
} from '@/api/admin'
@@ -409,14 +410,15 @@ const ruleForm = reactive({
onMounted(async () => {
loading.value = true
try {
const [rolesRes, rulesRes] = await Promise.all([
const [rolesRes, rulesRes, userRolesRes] = await Promise.all([
getRoles(),
getRoleMappingRules(),
getUserRoleAssignments(),
])
roles.value = rolesRes.data.data
mappingRules.value = rulesRes.data.data
userRoles.value = userRolesRes.data.data || []
} catch {
// 使用默认 demo 数据
roles.value = getDefaultRoles()
mappingRules.value = getDefaultMappingRules()
} finally {
@@ -657,12 +659,14 @@ async function handleDeleteRule(rule: RoleMappingRule): Promise<void> {
/** 刷新全部数据 */
async function loadData(): Promise<void> {
try {
const [rolesRes, rulesRes] = await Promise.all([
const [rolesRes, rulesRes, userRolesRes] = await Promise.all([
getRoles(),
getRoleMappingRules(),
getUserRoleAssignments(),
])
roles.value = rolesRes.data.data
mappingRules.value = rulesRes.data.data
userRoles.value = userRolesRes.data.data || []
} catch {
// 静默失败,保留现有数据
}
+129
View File
@@ -0,0 +1,129 @@
// =============================================================================
// 企微IT智能服务台 — OTP 绑定 API 适配层 (T03)
// =============================================================================
// 说明:封装 /auth/otp-* 端点,供坐席端登录绑定面板使用
// 对应后端:backend/app/api/auth.py (OTP 绑定/验证/状态/解绑)
//
// 端点:
// POST /auth/otp-bind — 生成 secret + 二维码(首次绑定)
// POST /auth/otp-verify — 输入 OTP 完成绑定/验证
// GET /auth/otp-status — 查询 OTP 绑定状态
// POST /auth/otp-unbind — 解绑 OTP(需当前 OTP 码)
//
// 响应契约(CTRT):apiClient 拦截器已自动解包 inner data
// 前端直接消费返回字段,无需访问 .data / .data.data。
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义
// --------------------------------------------------------------------------
/** POST /auth/otp-bind 响应 */
export interface OtpBindData {
/** TOTP 共享密钥(base32 */
secret: string
/** otpauth:// URI */
otpauth_url: string
/** 二维码 PNG base64(不含 data: 前缀) */
qr_code_base64: string
}
/** POST /auth/otp-verify 请求体 */
export interface OtpVerifyRequest {
/** 6 位 OTP 动态码 */
otp_code: string
}
/** POST /auth/otp-verify 响应 */
export interface OtpVerifyData {
/** 验证是否通过 */
verified: boolean
/** 登录 token(首次绑定成功后返回) */
token?: string
/** 用户 ID */
user_id?: string
/** 用户姓名 */
name?: string
/** 用户角色 */
role?: string
}
/** GET /auth/otp-status 响应 */
export interface OtpStatusData {
/** 是否已绑定 */
bound: boolean
/** 是否已启用 */
enabled: boolean
/** 最近一次验证成功时间(ISO 8601,可空) */
last_verified_at?: string | null
}
/** POST /auth/otp-unbind 请求体 */
export interface OtpUnbindRequest {
/** 6 位 OTP 动态码(解绑前需验证当前 OTP) */
otp_code: string
}
/** POST /auth/otp-unbind 响应 */
export interface OtpUnbindData {
/** 解绑是否成功 */
success: boolean
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 1) 绑定 OTP — 生成 secret + 二维码
* 坐席首次登录时调用,获取 TOTP 密钥和二维码
*
* 注意:此端点要求先通过账密验证(后端会校验临时凭证)
*
* @returns OTP 绑定信息(secret + otpauth_url + base64 PNG
*/
export async function bindOtp(): Promise<OtpBindData> {
const response: AxiosResponse = await apiClient.post('/auth/otp-bind')
return response
}
/**
* 2) 验证 OTP 并完成绑定
* 用户扫码后输入 6 位验证码,验证通过后完成绑定
* 如果是登录流程中的首次绑定,返回 token 等登录信息
*
* @param otpCode - 6 位 OTP 动态码
* @returns 验证结果(verified + 可选的登录 token
*/
export async function verifyOtp(otpCode: string): Promise<OtpVerifyData> {
const body: OtpVerifyRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/auth/otp-verify', body)
return response
}
/**
* 3) 查询 OTP 绑定状态
* 用于路由守卫或设置页判断当前用户的 OTP 状态
*
* @returns OTP 状态(bound + enabled + last_verified_at
*/
export async function getOtpStatus(): Promise<OtpStatusData> {
const response: AxiosResponse = await apiClient.get('/auth/otp-status')
return response
}
/**
* 4) 解绑 OTP
* 用户主动关闭 OTP,需先输入当前 OTP 码确认
*
* @param otpCode - 6 位 OTP 动态码(防误操作)
* @returns 解绑结果
*/
export async function unbindOtp(otpCode: string): Promise<OtpUnbindData> {
const body: OtpUnbindRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/auth/otp-unbind', body)
return response
}
@@ -0,0 +1,478 @@
<!-- =============================================================================
// IT智能服务台 — OTP 首次绑定面板 (T03)
// =============================================================================
// 说明:坐席首次登录时展示的 OTP 绑定面板,嵌入登录卡片内。
// 功能:
// - 展示二维码(供 Authenticator 扫码)
// - 展示 secret 密钥(手动输入备用)
// - 6 位验证码输入 + 验证并完成绑定
// - P0 阶段不显示"暂不绑定"按钮(P1 再加)
//
// Props: userId, name
// Emits: bind-success(token, userId, name, role), cancel
// ============================================================================= -->
<template>
<div class="otp-bind-panel">
<!-- 标题 -->
<div class="bind-title">
<h2>🔐 首次登录 绑定 OTP 二次验证</h2>
<p v-if="name" class="bind-subtitle">欢迎{{ name }}</p>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="bind-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>正在生成密钥...</p>
</div>
<!-- 绑定内容 -->
<div v-else class="bind-content">
<!-- 二维码 -->
<div class="qr-section">
<div class="qr-wrapper">
<img
v-if="qrCodeBase64"
:src="'data:image/png;base64,' + qrCodeBase64"
alt="OTP 二维码"
class="qr-image"
/>
<div v-else class="qr-placeholder">
<el-icon :size="48"><PictureFilled /></el-icon>
<p>二维码加载失败</p>
</div>
</div>
<p class="qr-hint">请使用 Google Authenticator Microsoft Authenticator 扫码</p>
</div>
<!-- 分隔线 -->
<div class="divider-line">
<span>或手动输入密钥</span>
</div>
<!-- Secret 密钥展示 -->
<div class="secret-section">
<div class="secret-display">
<code class="secret-text">{{ secret }}</code>
<el-button
size="small"
:type="copied ? 'success' : 'default'"
class="copy-btn"
@click="copySecret"
>
{{ copied ? '✅ 已复制' : '📋 复制' }}
</el-button>
</div>
</div>
<!-- 验证码输入 -->
<div class="verify-section">
<el-form
ref="formRef"
:model="verifyForm"
:rules="verifyRules"
label-position="top"
@submit.prevent="handleVerify"
>
<el-form-item label="验证码" prop="otpCode">
<el-input
v-model="verifyForm.otpCode"
placeholder="请输入 6 位验证码"
size="large"
maxlength="6"
show-word-limit
@keydown.enter="handleVerify"
/>
</el-form-item>
</el-form>
</div>
<!-- 操作按钮 -->
<div class="bind-actions">
<el-button
type="primary"
size="large"
:loading="verifying"
:disabled="verifying || verifyForm.otpCode.length !== 6"
class="verify-btn"
@click="handleVerify"
>
{{ verifying ? '验证中...' : '验证并完成绑定' }}
</el-button>
</div>
<!-- P0: 不显示"暂不绑定"按钮P1 再加 -->
</div>
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="true"
@close="errorMsg = ''"
class="bind-error"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Loading, PictureFilled } from '@element-plus/icons-vue'
import { bindOtp, verifyOtp } from '@/api/otp'
// --------------------------------------------------------------------------
// Props & Emits
// --------------------------------------------------------------------------
const props = defineProps<{
/** 用户 ID */
userId: string
/** 用户姓名 */
name: string
}>()
const emit = defineEmits<{
/** 绑定成功事件 */
(e: 'bind-success', token: string, userId: string, name: string, role: string): void
/** 取消绑定事件 */
(e: 'cancel'): void
}>()
// --------------------------------------------------------------------------
// 状态
// --------------------------------------------------------------------------
const formRef = ref<FormInstance>()
/** 加载中(获取二维码) */
const loading = ref(true)
/** 验证中 */
const verifying = ref(false)
/** 二维码 base64 */
const qrCodeBase64 = ref('')
/** TOTP 密钥 */
const secret = ref('')
/** 错误信息 */
const errorMsg = ref<string>('')
/** 复制状态 */
const copied = ref(false)
/** 验证码表单 */
const verifyForm = reactive({
otpCode: '',
})
/** 验证码校验规则 */
const verifyRules: FormRules = {
otpCode: [
{ required: true, message: '请输入 6 位验证码', trigger: 'blur' },
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
{
pattern: /^\d{6}$/,
message: '验证码只能包含数字',
trigger: 'blur',
},
],
}
// --------------------------------------------------------------------------
// 生命周期
// --------------------------------------------------------------------------
onMounted(async () => {
await fetchOtpBind()
})
// --------------------------------------------------------------------------
// 方法
// --------------------------------------------------------------------------
/**
* 获取 OTP 绑定信息(密钥 + 二维码)
*/
async function fetchOtpBind(): Promise<void> {
loading.value = true
errorMsg.value = ''
try {
const data = await bindOtp()
secret.value = data.secret
qrCodeBase64.value = data.qr_code_base64
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : '获取绑定信息失败'
console.error('[OtpBindPanel] 获取绑定信息失败:', error)
errorMsg.value = errMsg
} finally {
loading.value = false
}
}
/**
* 复制密钥到剪贴板
*/
async function copySecret(): Promise<void> {
if (!secret.value) return
try {
await navigator.clipboard.writeText(secret.value)
copied.value = true
ElMessage.success('密钥已复制到剪贴板')
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// 降级方案:使用传统方式复制
const textarea = document.createElement('textarea')
textarea.value = secret.value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
copied.value = true
ElMessage.success('密钥已复制到剪贴板')
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
ElMessage.error('复制失败,请手动复制密钥')
}
document.body.removeChild(textarea)
}
}
/**
* 验证 OTP 并完成绑定
*/
async function handleVerify(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
verifying.value = true
errorMsg.value = ''
try {
const data = await verifyOtp(verifyForm.otpCode.trim())
if (data.verified) {
ElMessage.success('OTP 绑定成功')
emit(
'bind-success',
data.token || '',
data.user_id || props.userId,
data.name || props.name,
data.role || ''
)
} else {
// verified=false:验证码错误,不抛异常,提示用户重试
errorMsg.value = '验证码错误,请重新输入'
verifyForm.otpCode = ''
}
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : '验证失败,请重试'
console.error('[OtpBindPanel] 验证失败:', error)
errorMsg.value = errMsg
} finally {
verifying.value = false
}
}
</script>
<style scoped>
/* ==========================================================================
企微浅色扁平风格(accent #07C160
========================================================================== */
.otp-bind-panel {
padding: 8px 0;
}
/* ---- 标题 ---- */
.bind-title {
text-align: center;
margin-bottom: 24px;
}
.bind-title h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #303133);
margin: 0 0 8px 0;
line-height: 1.4;
}
.bind-subtitle {
font-size: 14px;
color: var(--text-secondary, #606266);
margin: 0;
}
/* ---- 加载 ---- */
.bind-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 0;
color: var(--text-tertiary, #909399);
}
.bind-loading .el-icon {
font-size: 32px;
}
.bind-loading p {
margin: 0;
font-size: 14px;
}
/* ---- 二维码区域 ---- */
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
}
.qr-wrapper {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 12px;
border: 2px solid #e4e7ed;
overflow: hidden;
margin-bottom: 12px;
}
.qr-image {
width: 180px;
height: 180px;
display: block;
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--text-placeholder, #c0c4cc);
}
.qr-placeholder p {
margin: 0;
font-size: 13px;
}
.qr-hint {
font-size: 13px;
color: var(--text-tertiary, #909399);
margin: 0;
text-align: center;
line-height: 1.5;
}
/* ---- 分隔线 ---- */
.divider-line {
display: flex;
align-items: center;
width: 100%;
margin: 0 0 20px 0;
color: var(--text-placeholder, #c0c4cc);
font-size: 13px;
}
.divider-line::before,
.divider-line::after {
content: '';
flex: 1;
height: 1px;
background: #e4e7ed;
}
.divider-line span {
padding: 0 12px;
white-space: nowrap;
}
/* ---- 密钥区域 ---- */
.secret-section {
margin-bottom: 20px;
}
.secret-display {
display: flex;
align-items: center;
gap: 8px;
background: #f5f7fa;
border-radius: 8px;
padding: 10px 12px;
border: 1px solid #e4e7ed;
}
.secret-text {
flex: 1;
font-family: 'Courier New', 'Consolas', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #303133);
letter-spacing: 1px;
word-break: break-all;
user-select: all;
}
.copy-btn {
flex-shrink: 0;
}
/* ---- 验证码输入 ---- */
.verify-section {
margin-bottom: 20px;
}
:deep(.verify-section .el-form-item__label) {
font-weight: 500;
color: var(--text-secondary, #606266);
}
/* ---- 操作按钮 ---- */
.bind-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.verify-btn {
width: 100%;
background-color: #07c160;
border-color: #07c160;
}
.verify-btn:hover,
.verify-btn:focus {
background-color: #06ad56;
border-color: #06ad56;
}
.verify-btn:active {
background-color: #059a4d;
border-color: #059a4d;
}
/* ---- 错误提示 ---- */
.bind-error {
margin-top: 16px;
}
</style>
+14
View File
@@ -57,6 +57,20 @@ const routes = [
component: () => import('@/views/automation/SessionWorkbench.vue'),
meta: { title: '自动化会话', requiresAuth: true },
},
// Tier1 知识库迭代 — 独立审批队列(D7)
{
path: '/approval-queue',
name: 'ApprovalQueue',
component: () => import('@/views/ApprovalQueue.vue'),
meta: { title: '独立审批队列', requiresAuth: true },
},
// T04 — 坐席端个人设置页(含 OTP 管理)
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '个人设置', requiresAuth: true },
},
]
// --------------------------------------------------------------------------
+20 -2
View File
@@ -83,7 +83,6 @@ export const useAgentStore = defineStore('agent', () => {
// 登录前先清除旧数据,避免切换账号时显示旧用户信息
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(PORTAL_TOKEN_KEY)
localStorage.removeItem(AGENT_USER_ID_KEY)
token.value = null
agentUserId.value = null
@@ -91,7 +90,26 @@ export const useAgentStore = defineStore('agent', () => {
const data = await apiLogin(inputUserId, password, otpCode)
// 检查是否需要 OTP 验证
// 检查是否需要 OTP 首次绑定(优先级高于 require_otp
if ('require_otp_bind' in data && data.require_otp_bind) {
// 保存半认证 token(BUG-001 修复后新增),供后续 otp-bind/otp-verify 携带鉴权
if (data.token) {
token.value = data.token
agentUserId.value = data.user_id
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(AGENT_USER_ID_KEY, data.user_id)
}
// 不抛异常,返回绑定信息供 Login.vue 展示 OtpBindPanel
logging.value = false
return {
require_otp_bind: true,
user_id: data.user_id,
name: data.name,
role: data.role,
}
}
// 检查是否需要 OTP 验证(已绑定用户二次验证)
if ('require_otp' in data && data.require_otp) {
// 返回 data,让 Login.vue 处理 require_otp
logging.value = false
+58 -8
View File
@@ -59,15 +59,8 @@
</el-button>
</div>
<!-- 账号密码登录表单 -->
<!-- 账号密码登录表单与二维码同时展示无需返回按钮 -->
<div v-if="showPasswordLogin" class="password-login">
<el-button
size="small"
class="back-btn"
@click="handleBackToQrCode"
>
返回扫码登录
</el-button>
<el-form
ref="formRef"
@@ -125,6 +118,15 @@
</el-form>
</div>
<!-- OTP 首次绑定面板 -->
<OtpBindPanel
v-if="requireOtpBind"
:user-id="otpBindUser.user_id"
:name="otpBindUser.name"
@bind-success="onBindSuccess"
@cancel="onBindCancel"
/>
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
@@ -153,6 +155,7 @@ import { User, Lock, Key, Loading } from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import { useWebSocket } from '@/composables/useWebSocket'
import apiClient from '@/api/index'
import OtpBindPanel from '@/components/OtpBindPanel.vue'
const router = useRouter()
const { connect: connectWebSocket } = useWebSocket()
@@ -180,6 +183,12 @@ const loginForm = reactive({
/** 是否需要 OTP 验证 */
const requireOtp = ref(false)
/** 是否需要 OTP 首次绑定 */
const requireOtpBind = ref(false)
/** OTP 绑定用户信息(来自 require_otp_bind 响应) */
const otpBindUser = ref<{ user_id: string; name: string; role: string } | null>(null)
/** 登录中状态 */
const logging = ref(false)
@@ -316,6 +325,20 @@ async function handleLogin(): Promise<void> {
loginForm.otpCode.trim() || undefined
)
if (result && result.require_otp_bind) {
// 首次登录需绑定 OTP:隐藏表单,展示绑定面板
requireOtpBind.value = true
otpBindUser.value = {
user_id: result.user_id,
name: result.name,
role: result.role,
}
showQrLoginPanel.value = false
showPasswordLogin.value = false
logging.value = false
return
}
if (result && result.require_otp) {
requireOtp.value = true
loginForm.otpCode = ''
@@ -342,6 +365,33 @@ async function handleLogin(): Promise<void> {
}
}
/**
* OTP 绑定成功回调
* 保存 token 并跳转到工作台
*/
function onBindSuccess(token: string, userId: string, name: string, role: string): void {
localStorage.setItem('agent_token', token)
localStorage.setItem('agent_user_id', userId)
agentStore.token = token
agentStore.agentUserId = userId
agentStore.agentInfo = { user_id: userId, name, status: 'online' }
ElMessage.success('OTP 绑定成功,已登录')
connectWebSocket()
router.push('/workspace')
}
/**
* OTP 绑定取消回调
* 返回扫码登录面板
*/
function onBindCancel(): void {
requireOtpBind.value = false
otpBindUser.value = null
showQrLoginPanel.value = true
}
onMounted(async () => {
// === OAuth 重定向计数清除 ===
const existingToken = localStorage.getItem('agent_token')
+450
View File
@@ -0,0 +1,450 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席端个人设置页 (T04)
// =============================================================================
// 说明:坐席端个人设置页面,包含「OTP 二次验证」管理面板。
// 功能:
// - 查询 OTP 绑定状态(getOtpStatus
// - 已绑定时显示状态 + 解绑按钮
// - 未绑定时显示警告 + 绑定按钮(复用 OtpBindPanel
//
// 依赖:
// - @/api/otpgetOtpStatus / unbindOtp
// - @/components/OtpBindPanel.vue:可复用的 OTP 首次绑定面板
// - @/stores/agentuseAgentStore(获取 userId / agentName
// ============================================================================= -->
<template>
<div class="settings-page">
<h1 class="page-title">个人设置</h1>
<!-- ==========================================================================
OTP 二次验证面板
========================================================================== -->
<el-card class="settings-card otp-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">🔐 OTP 二次验证</span>
<el-tag
:type="otpStatus.bound ? 'success' : 'warning'"
size="small"
effect="plain"
>
{{ otpStatus.bound ? '已绑定' : '未绑定' }}
</el-tag>
</div>
</template>
<!-- 加载中 -->
<div v-if="statusLoading" class="status-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在查询状态...</span>
</div>
<!-- 内容区域 -->
<div v-else class="otp-content">
<!-- 已绑定状态 -->
<template v-if="otpStatus.bound">
<div class="otp-info">
<div class="info-row">
<span class="info-label">绑定状态</span>
<span class="info-value">
<el-icon class="check-icon"><CircleCheckFilled /></el-icon>
OTP 二次验证已启用
</span>
</div>
<div v-if="otpStatus.last_verified_at" class="info-row">
<span class="info-label">最近验证</span>
<span class="info-value info-time">
{{ formatTime(otpStatus.last_verified_at) }}
</span>
</div>
</div>
<el-divider />
<div class="otp-actions">
<p class="action-hint">
解绑后将关闭二次验证保护建议保持开启以增强账户安全
</p>
<el-button
type="danger"
:loading="unbinding"
:disabled="unbinding"
@click="handleUnbind"
>
{{ unbinding ? '解绑中...' : '解绑 OTP' }}
</el-button>
</div>
</template>
<!-- 未绑定状态 -->
<template v-else>
<div class="otp-warning">
<el-icon class="warning-icon"><WarningFilled /></el-icon>
<div class="warning-text">
<p class="warning-title">您的账户尚未绑定 OTP 二次验证</p>
<p class="warning-desc">
绑定后每次登录需输入动态验证码有效防止账户被盗
</p>
</div>
</div>
<el-divider />
<div class="otp-actions">
<el-button
type="primary"
class="bind-btn"
@click="showBindDialog = true"
>
立即绑定
</el-button>
</div>
</template>
</div>
</el-card>
<!-- ==========================================================================
绑定 OTP 弹窗
========================================================================== -->
<el-dialog
v-model="showBindDialog"
title="绑定 OTP 二次验证"
width="520px"
:close-on-click-modal="false"
:close-on-press-escape="false"
destroy-on-close
>
<OtpBindPanel
:user-id="agentStore.userId"
:name="agentStore.agentName"
@bind-success="onBindSuccess"
@cancel="showBindDialog = false"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading, CircleCheckFilled, WarningFilled } from '@element-plus/icons-vue'
import { getOtpStatus, unbindOtp } from '@/api/otp'
import type { OtpStatusData } from '@/api/otp'
import { useAgentStore } from '@/stores/agent'
import OtpBindPanel from '@/components/OtpBindPanel.vue'
// --------------------------------------------------------------------------
// Store
// --------------------------------------------------------------------------
const agentStore = useAgentStore()
// --------------------------------------------------------------------------
// 状态
// --------------------------------------------------------------------------
/** OTP 绑定状态 */
const otpStatus = reactive<OtpStatusData>({
bound: false,
enabled: false,
last_verified_at: null,
})
/** 状态加载中 */
const statusLoading = ref(true)
/** 解绑中 */
const unbinding = ref(false)
/** 是否显示绑定弹窗 */
const showBindDialog = ref(false)
// --------------------------------------------------------------------------
// 生命周期
// --------------------------------------------------------------------------
onMounted(async () => {
await fetchOtpStatus()
})
// --------------------------------------------------------------------------
// 方法
// --------------------------------------------------------------------------
/**
* 查询 OTP 绑定状态
*/
async function fetchOtpStatus(): Promise<void> {
statusLoading.value = true
try {
const data = await getOtpStatus()
otpStatus.bound = data.bound
otpStatus.enabled = data.enabled
otpStatus.last_verified_at = data.last_verified_at ?? null
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : '获取 OTP 状态失败'
console.error('[Settings] 获取 OTP 状态失败:', error)
ElMessage.error(errMsg)
} finally {
statusLoading.value = false
}
}
/**
* 解绑 OTP
* 弹窗输入当前 6 位 OTP 验证码,验证通过后解绑
*/
async function handleUnbind(): Promise<void> {
try {
// ElMessageBox.prompt 弹出输入框
const { value: otpCode } = await ElMessageBox.prompt(
'请输入当前 6 位 OTP 验证码以确认解绑',
'解绑 OTP 二次验证',
{
confirmButtonText: '确认解绑',
cancelButtonText: '取消',
inputPlaceholder: '请输入 6 位验证码',
inputType: 'text',
inputValidator: (val: string) => {
if (!val || val.trim().length !== 6) {
return '请输入 6 位验证码'
}
if (!/^\d{6}$/.test(val.trim())) {
return '验证码只能包含数字'
}
return true
},
// 危险操作样式
confirmButtonClass: 'el-button--danger',
// 输入框限制
inputErrorMessage: '验证码格式不正确',
}
)
// 用户取消了
if (otpCode === undefined) return
unbinding.value = true
const data = await unbindOtp(otpCode.trim())
if (data.success) {
ElMessage.success('OTP 已解绑,二次验证已关闭')
// 刷新状态
await fetchOtpStatus()
} else {
ElMessage.error('解绑失败,请重试')
}
} catch (error: unknown) {
// ElMessageBox 取消时会抛出 'cancel' 字符串
if (error === 'cancel' || error === 'close') {
return
}
const errMsg = error instanceof Error ? error.message : '解绑失败,请重试'
console.error('[Settings] 解绑 OTP 失败:', error)
ElMessage.error(errMsg)
} finally {
unbinding.value = false
}
}
/**
* 绑定成功回调
*/
async function onBindSuccess(): Promise<void> {
showBindDialog.value = false
ElMessage.success('OTP 二次验证绑定成功')
// 刷新状态
await fetchOtpStatus()
}
/**
* 格式化 ISO 8601 时间为本地可读格式
*/
function formatTime(isoString: string): string {
try {
const date = new Date(isoString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return isoString
}
}
</script>
<style scoped>
/* ==========================================================================
企微浅色扁平风格(accent #07C160
========================================================================== */
.settings-page {
max-width: 720px;
margin: 0 auto;
padding: 24px 20px 48px;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: var(--text-primary, #303133);
margin: 0 0 24px 0;
line-height: 1.4;
}
/* ---- 卡片 ---- */
.settings-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
}
.settings-card :deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
}
.settings-card :deep(.el-card__body) {
padding: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #303133);
}
/* ---- 加载 ---- */
.status-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 24px 0;
justify-content: center;
color: var(--text-tertiary, #909399);
font-size: 14px;
}
.status-loading .el-icon {
font-size: 18px;
}
/* ---- OTP 信息 ---- */
.otp-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-row {
display: flex;
align-items: center;
gap: 12px;
}
.info-label {
min-width: 72px;
font-size: 14px;
color: var(--text-secondary, #606266);
flex-shrink: 0;
}
.info-value {
font-size: 14px;
color: var(--text-primary, #303133);
display: flex;
align-items: center;
gap: 6px;
}
.check-icon {
color: #07c160;
font-size: 16px;
}
.info-time {
color: var(--text-tertiary, #909399);
font-size: 13px;
}
/* ---- 警告区域 ---- */
.otp-warning {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: #fdf6ec;
border-radius: 8px;
border: 1px solid #faecd8;
}
.warning-icon {
color: #e6a23c;
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.warning-text {
flex: 1;
}
.warning-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #303133);
margin: 0 0 4px 0;
}
.warning-desc {
font-size: 13px;
color: var(--text-secondary, #606266);
margin: 0;
line-height: 1.5;
}
/* ---- 操作区域 ---- */
.otp-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.action-hint {
font-size: 13px;
color: var(--text-tertiary, #909399);
margin: 0;
line-height: 1.5;
}
/* 绑定按钮 — 企微绿 */
.bind-btn {
background-color: #07c160;
border-color: #07c160;
}
.bind-btn:hover,
.bind-btn:focus {
background-color: #06ad56;
border-color: #06ad56;
}
.bind-btn:active {
background-color: #059a4d;
border-color: #059a4d;
}
</style>
+72 -96
View File
@@ -1,5 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版)
# 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版)
# =============================================================================
# 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析
# 与 NAS 版的区别:
@@ -8,25 +8,29 @@
# 3. 真实 IP 直接从 $remote_addr 获取(无 CF 代理层)
# 4. 预留 HTTPS 配置注释(如公司有统一 SSL 终端)
# =============================================================================
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;
# ------------------------------------------------------------------
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
# ------------------------------------------------------------------
set_real_ip_from 10.0.0.0/8; # 内网 A 类(代理/WAF 出口)
set_real_ip_from 172.16.0.0/12; # 内网 B 类
set_real_ip_from 192.168.0.0/16; # 内网 C 类
set_real_ip_from 10.212.0.0/16; # VPN 网段
real_ip_header X-Forwarded-For; # 从 X-Forwarded-For 取最后一个非信任 IP
real_ip_recursive on; # 递归剥离已信任代理 IP
# ------------------------------------------------------------------
# 基础配置
# ------------------------------------------------------------------
@@ -35,8 +39,7 @@ http {
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50m; # 支持文件上传(企微媒体文件)
client_max_body_size 50m;
# ------------------------------------------------------------------
# Gzip 压缩(前端静态资源)
# ------------------------------------------------------------------
@@ -46,112 +49,114 @@ http {
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/xml+rss
application/json application/ld+json;
# ------------------------------------------------------------------
# 安全响应头
# ------------------------------------------------------------------
# 隐藏 nginx 版本号
server_tokens off;
# 基础安全头(应用到所有响应)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# =================================================================
# 上游服务定义(Docker 内部网络)
# =================================================================
upstream backend_api {
server backend:8000;
server wecom_it_backend:8000;
}
# =================================================================
# HTTP 服务(监听 80 端口)
# =================================================================
# 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80
# 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径
# =================================================================
server {
listen 80;
server_name itsupport.servyou.com.cn;
# ------------------------------------------------------------------
# 安全头
# ------------------------------------------------------------------
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
return 301 https://$host$request_uri;
}
}
# =================================================================
# HTTPS — 443 端口(主服务)
# =================================================================
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;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline';";
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "unsafe-none" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
server_tokens off;
location = /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# ------------------------------------------------------------------
# H5 员工端 — /itdesk/
# ------------------------------------------------------------------
location /itdesk/ {
alias /usr/share/nginx/html/itdesk/;
index index.html;
try_files $uri /itdesk/index.html;
}
# ------------------------------------------------------------------
# 坐席工作台 — /itagent/
# ------------------------------------------------------------------
location /itagent/ {
alias /usr/share/nginx/html/itagent/;
index index.html;
try_files $uri /itagent/index.html;
}
# ------------------------------------------------------------------
# 管理后台 — /itadmin/
# ------------------------------------------------------------------
location /itadmin/ {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 10.212.0.0/16;
allow 10.240.0.0/16;
allow 117.147.35.138;
allow 218.75.34.87;
allow 43.174.152.34;
deny all;
alias /usr/share/nginx/html/itadmin/;
index index.html;
try_files $uri /itadmin/index.html;
}
# ------------------------------------------------------------------
# 统一入口 Portal — /itportal/
# ------------------------------------------------------------------
location /itportal/ {
alias /usr/share/nginx/html/itportal/;
index index.html;
try_files $uri /itportal/index.html;
}
# ------------------------------------------------------------------
# 后端 API — /api/
# ------------------------------------------------------------------
location /api/ {
proxy_pass http://backend_api/;
location ~ ^/api/admin/ {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 10.212.0.0/16;
deny all;
proxy_pass http://backend_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 内网直连,如前端有 SSL 终端则改为 https
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置(AI 回复可能较慢)
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ------------------------------------------------------------------
# WebSocket — /ws/(坐席端实时通信)
# ------------------------------------------------------------------
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;
}
location /ws/ {
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
access_log off;
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -159,39 +164,10 @@ http {
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; # WebSocket 长连接
proxy_read_timeout 86400s;
}
# ------------------------------------------------------------------
# 企微回调 — /api/wecom/callback(接收企微消息推送)
# ------------------------------------------------------------------
# 企微验证回调 URL 时使用 GET,后续消息推送使用 POST
# 此路径已包含在 /api/ 的代理规则中,无需单独配置
# ------------------------------------------------------------------
# 默认路径 — 重定向到 H5 员工端
# ------------------------------------------------------------------
location = / {
return 302 /itdesk/;
return 302 /itportal/;
}
}
# =================================================================
# HTTPS 配置(按需启用)
# =================================================================
# 如果需要本机直接提供 HTTPS(不走公司统一 SSL 终端),
# 取消下方注释并配置 SSL 证书路径
#
# server {
# listen 443 ssl;
# 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;
#
# # 其余 location 配置与上方 HTTP server 相同
# ...
# }
}
+10 -7
View File
@@ -65,14 +65,17 @@ http {
# =================================================================
server {
listen 80;
server_name _;
listen 443 ssl;
server_name itsupport.servyou.com.cn;
# SSL 证书配置(临时禁用,等待证书路径修复
# 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 ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# ssl_prefer_server_ciphers off;
# SSL 证书配置(使用通配符证书 *.servyou.com.cn
ssl_certificate /etc/nginx/ssl/servyou.com.cn.crt;
ssl_certificate_key /etc/nginx/ssl/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;
# ------------------------------------------------------------------
# H5 员工端 — /itdesk/