From 400ce3ddcb4bbfbf9756fd7f83ca51cdea14fadb Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 8 Jul 2026 21:54:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20OTP=E9=A6=96=E6=AC=A1=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=20+=20=E4=B8=89=E7=AB=AF=E7=99=BB=E5=BD=95=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E7=AE=A1=E7=90=86=E7=AB=AF=E6=9D=83=E9=99=90=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20(2026-07-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 生产基准配置 --- backend/app/api/admin_roles.py | 38 ++ backend/app/api/agents.py | 42 +- backend/app/api/auth_qrcode.py | 52 +- backend/app/api/otp.py | 114 +++- backend/app/api/router.py | 30 +- backend/app/schemas/mfa.py | 2 + backend/app/services/role_mapping_service.py | 13 +- docker-compose.yml | 42 +- .../05-增量PRD-OTP首次绑定与重置.md | 278 ++++++++++ .../01-OTP首次绑定与重置-系统设计.md | 512 ++++++++++++++++++ docs/09-部署运维/00-标准故障排查手册.md | 64 ++- .../01-nginx-prod-baseline-20260708.conf | 185 +++++++ frontend-admin/src/api/admin.ts | 5 + frontend-admin/src/api/mfa.ts | 59 +- frontend-admin/src/components/Sidebar.vue | 4 + frontend-admin/src/router/index.ts | 16 +- frontend-admin/src/views/Login.vue | 270 +++++---- frontend-admin/src/views/MfaManage.vue | 52 +- frontend-admin/src/views/Roles.vue | 10 +- frontend-agent/src/api/otp.ts | 129 +++++ .../src/components/OtpBindPanel.vue | 478 ++++++++++++++++ frontend-agent/src/router/index.ts | 14 + frontend-agent/src/stores/agent.ts | 22 +- frontend-agent/src/views/Login.vue | 66 ++- frontend-agent/src/views/Settings.vue | 450 +++++++++++++++ nginx.conf | 170 +++--- nginx/nginx.conf | 17 +- 27 files changed, 2822 insertions(+), 312 deletions(-) create mode 100644 docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md create mode 100644 docs/03-技术架构/01-OTP首次绑定与重置-系统设计.md create mode 100644 docs/09-部署运维/01-nginx-prod-baseline-20260708.conf create mode 100644 frontend-agent/src/api/otp.ts create mode 100644 frontend-agent/src/components/OtpBindPanel.vue create mode 100644 frontend-agent/src/views/Settings.vue 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`**:在"📋 运营管理"分组添加 `` 菜单项。 +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 输入框,显示 ``;监听 `bind-success` 事件保存 token 并跳转。 +4. **`OtpBindPanel.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 { - 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 { - 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 } diff --git a/frontend-admin/src/components/Sidebar.vue b/frontend-admin/src/components/Sidebar.vue index 2e1782a..d2b3510 100644 --- a/frontend-admin/src/components/Sidebar.vue +++ b/frontend-admin/src/components/Sidebar.vue @@ -50,6 +50,10 @@ 角色管理
+ + + OTP 管理 + diff --git a/frontend-admin/src/router/index.ts b/frontend-admin/src/router/index.ts index 0649ab8..0bf6fd9 100644 --- a/frontend-admin/src/router/index.ts +++ b/frontend-admin/src/router/index.ts @@ -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 }, + }, ], }, { diff --git a/frontend-admin/src/views/Login.vue b/frontend-admin/src/views/Login.vue index 29be6ab..07b61d4 100644 --- a/frontend-admin/src/views/Login.vue +++ b/frontend-admin/src/views/Login.vue @@ -25,58 +25,62 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)

管理后台

- -
- - + +
+ + + 企微免密登录({{ wecomUserId }}) +
- -