diff --git a/backend/app/api/admin_roles.py b/backend/app/api/admin_roles.py
index c53fcf6..f3b8975 100644
--- a/backend/app/api/admin_roles.py
+++ b/backend/app/api/admin_roles.py
@@ -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. 用户角色分配/撤销
# ==========================================================================
diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py
index 041773e..14d5e10 100644
--- a/backend/app/api/agents.py
+++ b/backend/app/api/agents.py
@@ -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验证
+ # 决策4(AUTH-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 统一校验逻辑)
- if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
- raise AppException(1006, "OTP验证码错误,请重新输入")
+ # 验证 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 token(get_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
diff --git a/backend/app/api/auth_qrcode.py b/backend/app/api/auth_qrcode.py
index 3621751..adc1c10 100644
--- a/backend/app/api/auth_qrcode.py
+++ b/backend/app/api/auth_qrcode.py
@@ -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"""
diff --git a/backend/app/api/otp.py b/backend/app/api/otp.py
index e4cfc7a..fddc1d0 100644
--- a/backend/app/api/otp.py
+++ b/backend/app/api/otp.py
@@ -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})
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index a3317f5..fb176dd 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -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文档摄入"])
diff --git a/backend/app/schemas/mfa.py b/backend/app/schemas/mfa.py
index c0fe744..e69d9e7 100644
--- a/backend/app/schemas/mfa.py
+++ b/backend/app/schemas/mfa.py
@@ -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")
# --------------------------------------------------------------------------
diff --git a/backend/app/services/role_mapping_service.py b/backend/app/services/role_mapping_service.py
index b505cab..dc63c4c 100644
--- a/backend/app/services/role_mapping_service.py
+++ b/backend/app/services/role_mapping_service.py
@@ -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"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 886253f..bf2ad72 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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}
# Redis(Docker 内部网络)
- - 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
diff --git a/docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md b/docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md
new file mode 100644
index 0000000..c3f9414
--- /dev/null
+++ b/docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md
@@ -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 生命周期完整体验。待评审确认待确认问题后,移交架构师进行前端方案设计。
diff --git a/docs/03-技术架构/01-OTP首次绑定与重置-系统设计.md b/docs/03-技术架构/01-OTP首次绑定与重置-系统设计.md
new file mode 100644
index 0000000..febf491
--- /dev/null
+++ b/docs/03-技术架构/01-OTP首次绑定与重置-系统设计.md
@@ -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: 隐藏账密表单
显示 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 (绑定场景)
校验 TOTP → 通过
设置 mfa_enabled=True
设置 mfa_bound_at=now
+
+ Backend->>Redis: mark_verified(employee_id, TTL=1800)
+ Backend->>Backend: 签发 JWT token
+ Backend-->>API: { verified: true, token, user_id, name, role }
+ API-->>OtpBind: { verified: true, token, ... }
+
+ OtpBind-->>LoginVue: emit('bind-success', { token, user_id, name })
+ LoginVue->>AgentStore: 保存 token → 设置 agentInfo
+ LoginVue->>LoginVue: router.push('/workspace')
+```
+
+#### 4.2 管理后台清除 OTP 绑定流程
+
+```mermaid
+sequenceDiagram
+ participant Admin as 管理员
+ participant MfaVue as MfaManage.vue
+ participant API as apiClient
+ participant Backend as FastAPI Backend
+ participant DB as PostgreSQL
+ participant Redis as Redis
+
+ Admin->>MfaVue: 访问 OTP 管理页面
+ MfaVue->>API: GET /api/auth/otp-admin-users
+ API->>Backend: admin_list_otp_users()
+ Backend->>DB: SELECT * FROM agents
+ DB-->>Backend: agent rows
+ Backend-->>API: [{employee_id, name, mfa_enabled, mfa_bound_at, ...}]
+ API-->>MfaVue: 渲染表格
+
+ Admin->>MfaVue: 点击某坐席的「清除绑定」
+ MfaVue->>MfaVue: ElMessageBox 二次确认弹窗
+ Admin->>MfaVue: 确认清除
+
+ MfaVue->>API: POST /api/auth/otp-admin-reset/{employee_id}
+ API->>Backend: admin_reset_otp(employee_id)
+ Backend->>DB: UPDATE agents SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
+ Backend->>Redis: clear_verified(employee_id)
+ Backend-->>API: { success: true }
+ API-->>MfaVue: ElMessage.success('已清除')
+
+ MfaVue->>API: 重新加载列表
+```
+
+#### 4.3 坐席自助解绑流程(P1)
+
+```mermaid
+sequenceDiagram
+ participant User as 坐席
+ participant Settings as Settings.vue
+ participant API as apiClient
+ participant Backend as FastAPI Backend
+ participant DB as PostgreSQL
+ participant Redis as Redis
+
+ User->>Settings: 打开设置页 → OTP 管理面板
+ Settings->>API: GET /api/auth/otp-status
+ API->>Backend: get_otp_status()
+ Backend-->>API: { bound: true, enabled: true, last_verified_at }
+ API-->>Settings: 显示「✅ 已绑定」
+
+ User->>Settings: 点击「解绑 OTP」
+ Settings->>Settings: 弹窗:输入当前 OTP 验证码
+ User->>Settings: 输入 6 位码 → 确认
+
+ Settings->>API: POST /api/auth/otp-unbind { otp_code }
+ API->>Backend: unbind_otp()
+ Backend->>Backend: MFAService.verify_code() → 通过
+ Backend->>DB: UPDATE SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
+ Backend->>Redis: clear_verified(employee_id)
+ Backend-->>API: { success: true }
+ API-->>Settings: ElMessage.success('OTP 已解绑')
+ Settings->>Settings: 刷新状态 → 显示「⚠️ 未绑定 — 立即绑定」
+```
+
+---
+
+### 5. 待确认问题与假设
+
+| # | 问题 | 假设 | 影响 |
+|---|------|------|------|
+| Q1 | 首次绑定能否"跳过"? | **P0 阶段不允许跳过**。简化首版逻辑,堵死无 OTP 直通漏洞。P1 阶段再引入"暂不绑定"按钮。 | 首次绑定向导无跳过按钮 |
+| Q2 | 扫码登录的坐席是否需要 OTP? | **P0 阶段暂不处理**。扫码登录已有企微身份强验证,且当前 `agent_login` 中对已绑定 OTP 的坐席仍要求 OTP。本增量聚焦账密登录的绑定引导。 | 扫码登录不做额外改动 |
+| Q3 | OTP verify 绑定场景是否直接返回 token? | **是**。修改 `otp.py:verify_otp`,绑定场景额外返回 token。避免前端二次 login。 | 后端改动量小,前端只需一次请求 |
+| Q4 | 管理后台旧端点 `/admin/mfa/*` 是否需要保留? | **保留兼容过渡期**。前端切换到新端点,旧端点暂时保留不删,待下个迭代清理。 | 无风险 |
+| Q5 | `OtpBindPanel.vue` 与已有 `MfaBind.vue` 的关系? | `OtpBindPanel` 是轻量嵌入组件(嵌入登录卡片内),`MfaBind.vue` 是独立全页组件(`/mfa-bind` 路由)。绑定流程 UI 逻辑可复用,但布局不同。**建议新建 `OtpBindPanel` 组件,从 `MfaBind.vue` 提取共享的二维码渲染 + 验证逻辑**。 | 两个组件共存,按场景使用 |
+
+---
+
+## Part B: 任务分解
+
+### 6. 所需依赖包
+
+**无新增依赖**。所有功能基于已有技术栈:
+
+```
+- Vue 3 + Element Plus(前端已有)
+- Pinia(状态管理已有)
+- pyotp + qrcode(后端已有 MFAService)
+- FastAPI + SQLAlchemy Async(后端已有)
+- redis.asyncio(Redis 客户端已有)
+```
+
+### 7. 任务列表(按依赖关系排序)
+
+| Task ID | 任务名称 | 源文件 | 依赖 | 优先级 |
+|---------|----------|--------|------|--------|
+| **T01** | 后端 OTP 绑定流程改造 | `backend/app/api/agents.py`【修改】
`backend/app/api/otp.py`【修改】
`backend/app/schemas/mfa.py`【修改】 | 无 | **P0** |
+| **T02** | 管理后台 OTP 管理页完整更新 | `frontend-admin/src/api/mfa.ts`【修改】
`frontend-admin/src/views/MfaManage.vue`【修改】
`frontend-admin/src/components/Sidebar.vue`【修改】
`frontend-admin/src/router/index.ts`【修改】 | T01(需后端端点就绪) | **P0** |
+| **T03** | 坐席端登录页 OTP 绑定面板 | `frontend-agent/src/api/otp.ts`【新增】
`frontend-agent/src/stores/agent.ts`【修改】
`frontend-agent/src/views/Login.vue`【修改】
`frontend-agent/src/components/OtpBindPanel.vue`【新增】 | T01(需后端端点就绪) | **P0** |
+| **T04** | 坐席端设置页 OTP 管理 | `frontend-agent/src/views/Settings.vue`【新增】
`frontend-agent/src/router/index.ts`【修改】
`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`**:在"📋 运营管理"分组添加 ``)、secret 明文展示 + 复制按钮、6 位验证码输入框、"验证并完成绑定"主按钮、"暂不绑定,稍后设置"次要按钮(P0 阶段隐藏)。
+
+#### T04 详细说明 — 坐席端设置页 OTP 管理(P1)
+
+1. **`Settings.vue`**:新建设置页面,含「OTP 二次验证」面板:已绑定时显示状态+解绑按钮;未绑定时显示警告+绑定按钮。解绑流程:弹窗输入 OTP → `POST /auth/otp-unbind`。
+2. **`router/index.ts`**:新增路由 `{ path: '/settings', component: () => import('@/views/Settings.vue'), meta: { title: '个人设置', requiresAuth: true } }`。
+3. **`otp.ts`**:补充 `unbindOtp(otpCode)` 和 `getOtpStatus()` 的完整调用。
+
+### 8. 共享知识
+
+以下约定适用于所有任务、所有文件:
+
+```
+## 响应契约
+- 所有 API 响应统一格式:{ code: 0, data: {...}, message: "success" }
+- apiClient 拦截器已自动解包 data 层(Scheme A),前端直接消费 inner data
+- 错误:{ code: 非0, message: "错误描述" } → 前端 catch 中取 error.message
+
+## OTP 状态常量
+- require_otp: true → 已绑定 OTP,需要输入 6 位码验证
+- require_otp_bind: true → 未绑定 OTP,需要展示绑定面板(二维码 + 输入验证码)
+- mfa_enabled: true/false → 数据库字段,标记 OTP 是否已启用
+- mfa_bound_at: datetime → 首次绑定成功时间(NULL 表示从未绑定)
+- mfa_secret: string/NULL → TOTP 密钥(仅绑定过程中临时存储,解绑后清空)
+
+## 端点路径规范
+- 坐席端/管理端统一使用 /auth/otp-* 前缀(新端点)
+- 旧端点 /mfa/* 、/admin/mfa/* 保留兼容但前端不再调用
+- nginx 剥离 /api 前缀后,对外即 /api/auth/otp-*
+
+## 前端命名约定
+- OtpBindPanel.vue: 嵌入登录卡片内的轻量绑定面板(P0)
+- MfaBind.vue: 独立全页绑定向导(已有,保留用于 /mfa-bind 路由)
+- 按钮文案:后端用"清除绑定",坐席端用"解绑 OTP"
+
+## 错误处理
+- OTP 验证码错误 → 返回 verified=false(不抛异常),前端显示红色提示,允许重试
+- 解绑时 OTP 错误 → 后端抛 AppException(INVALID_PARAMETER),前端显示错误消息
+- 已绑定用户重复调用 otp-bind → 后端拒绝,前端需先判断状态
+
+## Redis Key
+- mfa:verified:{employee_id} = "1",TTL = 1800s(30 分钟验证窗口)
+- 绑定成功后写该 key(等同于已验证)
+- 解绑/管理员清除时删除该 key
+```
+
+### 9. 任务依赖图
+
+```mermaid
+graph TD
+ T01["T01: 后端 OTP 绑定流程改造
(agents.py + otp.py + schemas/mfa.py)
P0"]
+ T02["T02: 管理后台 OTP 管理页
(mfa.ts + MfaManage.vue + Sidebar.vue + router)
P0"]
+ T03["T03: 坐席端登录 OTP 绑定面板
(otp.ts + agent.ts + Login.vue + OtpBindPanel.vue)
P0"]
+ T04["T04: 坐席端设置页 OTP 管理
(Settings.vue + router + otp.ts)
P1"]
+
+ T01 --> T02
+ T01 --> T03
+ T03 --> T04
+```
+
+> **说明**:T02 和 T03 可并行开发(均依赖 T01,互不依赖)。T04 依赖 T03(需要 `otp.ts` API 适配层)。
+
+---
+
+> **文档结束** — 请工程师按 T01 → T02/T03(并行) → T04 顺序实施。
diff --git a/docs/09-部署运维/00-标准故障排查手册.md b/docs/09-部署运维/00-标准故障排查手册.md
index 25cdf3a..b00b073 100644
--- a/docs/09-部署运维/00-标准故障排查手册.md
+++ b/docs/09-部署运维/00-标准故障排查手册.md
@@ -1,7 +1,10 @@
# 00 · 标准故障排查手册
-> **版本**: v1.0 | **日期**: 2026-07-07 | **维护人**: 宋献 / 助理
-> **定位**: 所有故障排查前**首先查看本手册**。本手册整合了原先散落的快速诊断、服务器端诊断、故障排查指南、4 份修复记录、通讯链路诊断、deploy/02 手册、调试验证指南。
+> **版本**: v1.1 | **日期**: 2026-07-08 | **维护人**: 宋献 / 助理
+> **定位**: 所有故障排查前**首先查看本手册**。
+> **最新**: 新增 CASE-20260708-01~06(OTP 路由404 / nginx 404 / 扫码角色 / 用户角色 / 员工端路由 / OTP列表结构)+ nginx 配错急救流程
+
+| v1.1 | 2026-07-08 | 新增 6 天 7.8 案例 + nginx 急救流程 + 端到端验证更新 |
> **前置阅读**: [运维手册(部署/回滚/备份/应急)](../01-项目总览/01-智能IT服务系统运维手册-20260704.md) · [SOP-04 应急响应](../10-项目管理/SOPs-标准流程/SOP-04-应急响应.md)
---
@@ -163,6 +166,63 @@ docker logs wecom_it_backend | grep -i websocket
---
+### CASE-20260708-01 · 后端路由 404 — OTP 统一路由未注册 ⭐
+- **现象**:`POST /api/auth/otp-bind` 返回 404;前端 OTP 绑定面板密钥和二维码不显示。
+- **根因**:部署 `otp.py` 时漏部署 `router.py`,`api_router.include_router(otp_router)` 未执行。本地 `router.py` 包含服务器不存在的模块(`knowledge_iteration` / `approval_queue` / `vision` / `ragflow_ingestion` / `automation`),导入失败导致整个 `router.py` 加载失败。
+- **修复**:上传 `router.py`,注释掉服务器上不存在的模块导入;同时修复 `otp.py` 中 `@require_role("admin")` 装饰器与显式 `current_user` 参数的冲突(服务器旧版 `require_role` 会自动追加 `current_user`)。
+- **教训**:修改路由时务必同步部署 `router.py`,否则新端点虽然代码存在但永远不会注册。
+
+### CASE-20260708-02 · 管理端 API 全部 404 — nginx 正则 location proxy_pass 缺 rewrite
+- **现象**:`/api/admin/roles`、`/api/admin/dashboard/overview` 等全部返回 404。
+- **根因**:nginx 配置中 `location ~ ^/api/admin/`(正则匹配)内 `proxy_pass http://backend_api;` 不带尾部斜杠,导致 `/api/` 前缀未剥离,后端收到 `/api/admin/roles` 而非 `/admin/roles`。带尾部斜杠又会报错 `"proxy_pass" cannot have URI part in location given by regular expression`。
+- **修复**:去掉嵌套正则 location,改为统一 `location /api/` → `proxy_pass http://backend_api/;`(尾部斜杠剥离 /api/ 前缀)。
+- **教训**:nginx 中正则 location 不能直接用 `proxy_pass` 带 URI;需要时用 `rewrite` 剥离前缀。
+
+### CASE-20260708-03 · sxn 扫码登录无 admin 权限 — QR 扫描写死 `roles=["agent"]`
+- **现象**:管理后台扫码登录后,OTP 管理/角色管理返回 403/无权限提示。`sxn` 账密登录正常。
+- **根因**:`auth_qrcode.py` 扫码自动确认逻辑中,`create_token` 写死了 `roles=["agent"]`,未调用 `get_user_roles()`。
+- **修复**:扫码确认改为调用 `RoleMappingService.get_user_roles()` 获取真实角色。
+- **教训**:所有登录路径(账密/扫码/OAuth)的角色获取必须统一走 `get_user_roles()`。
+
+### CASE-20260708-04 · 用户角色列表为空 — `get_user_roles()` 只查 `user_roles` 表
+- **现象**:管理后台角色管理页"用户角色分配"表格为空;OTP 管理 API 403。
+- **根因**:`get_user_roles()` 仅查询 `user_roles` 表,但旧数据(包括 sxn 的 admin)只存在于 `agents.role` 字段。`user_roles` 表为空时返回 `["user"]`。
+- **修复**:`get_user_roles()` 增加 `agents.role` 回退查询;新增 `GET /admin/roles/user-roles` 端点;前端 `Roles.vue` 加载用户角色分配数据。
+- **教训**:新旧数据迁移时需确保角色数据完整同步到 `user_roles` 表。
+
+### CASE-20260708-05 · 企微工作台点"IT支持服务"进坐席登录页 — 员工端口缺失
+- **现象**:企微工作台 → IT支持服务 → 显示坐席扫码登录页,而非员工 H5 页面。
+- **根因**:(1) nginx `/itdesk/` 被错误配置为 301 重定向到 `/itagent/`;(2) H5 构建文件未挂载到容器(`docker-compose.yml` 缺少 `./html/h5` 挂载);(3) 改重定向到 `/h5/` 后仍失败,因为 H5 `vite base` 是 `/itdesk/`,OAuth 回调依赖此路径。
+- **修复**:`/itdesk/` 直接 alias 到 H5 构建目录(`/usr/share/nginx/html/h5/`),不再 301 跳转;`docker-compose.yml` 添加 h5 volume 挂载;根路径 `/` 改为 302 → `/h5/`。
+- **nginx 关键配置**(alias + try_files SPA 模式):
+ ```
+ location /itdesk/ {
+ alias /usr/share/nginx/html/h5/;
+ index index.html;
+ try_files $uri $uri/ /index.html;
+ }
+ ```
+ 注意:fallback 用 `/index.html` 而非 `/itdesk/index.html`——alias 会自动映射。
+- **教训**:前端 `vite base` 路径必须与 nginx 服务路径一致;OAuth 回调路径不能用 301 重定向。
+
+### CASE-20260708-06 · 管理端 OTP 用户列表返回错误结构
+- **现象**:OTP 管理页面提示加载失败,`/auth/otp-admin-users` 返回 403。
+- **根因**:(1) `admin_list_otp_users` 返回普通数组,前端期望 `{total, items}` 结构;(2) `@require_role("admin")` 因 token 不含 admin 角色返回 403(见 CASE-03/04)。
+- **修复**:后端改为分页查询返回 `{total, items}`;增加 keyword/bound/page/page_size 参数支持。
+
+### 附:nginx 配错急救流程(2026-07-08 实战总结)
+| 步骤 | 操作 |
+|------|------|
+| 1 | 备份:`sudo cp /opt/wecom-it-desk/nginx/nginx.conf /tmp/nginx.bak` |
+| 2 | 修改宿主文件后必须 `docker stop nginx && docker start nginx`(`reload` 有时不生效) |
+| 3 | 验证容器内配置已同步:`docker exec wecom_it_nginx grep 关键字 /etc/nginx/nginx.conf` |
+| 4 | 确认语法:`docker exec wecom_it_nginx nginx -t` |
+| 5 | 检查容器状态:`docker ps --filter name=wecom_it_nginx` |
+| 6 | 用 `agent-browser` 打开 URL 做端到端验证 |
+| 7 | 避免用 sed 修改 nginx 配置——`$uri`/`$host` 等变量会被 shell 解释;用 Python 脚本或直接上传文件 |
+
+---
+
## 5 端到端验证完成标准(原《调试验证指南》整合)
> 宣布完成前,按 §0.2 提供真实证据。
diff --git a/docs/09-部署运维/01-nginx-prod-baseline-20260708.conf b/docs/09-部署运维/01-nginx-prod-baseline-20260708.conf
new file mode 100644
index 0000000..3d57323
--- /dev/null
+++ b/docs/09-部署运维/01-nginx-prod-baseline-20260708.conf
@@ -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/;
+ }
+ }
+}
diff --git a/frontend-admin/src/api/admin.ts b/frontend-admin/src/api/admin.ts
index 0e90576..c354b87 100644
--- a/frontend-admin/src/api/admin.ts
+++ b/frontend-admin/src/api/admin.ts
@@ -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
diff --git a/frontend-admin/src/api/mfa.ts b/frontend-admin/src/api/mfa.ts
index cb83567..eeadebd 100644
--- a/frontend-admin/src/api/mfa.ts
+++ b/frontend-admin/src/api/mfa.ts
@@ -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
管理后台
- -