feat: OTP首次绑定 + 三端登录修复 + 管理端权限修复 (2026-07-08)
OTP首次绑定: - 新增统一 OTP 路由 /auth/otp-* (otp.py + router.py) - 坐席端 OTP 绑定面板 (OtpBindPanel.vue) - 管理端 OTP 管理列表 (MfaManage.vue) - agent_login 签发半认证 token 支持首次绑定流程 三端登录修复: - 坐席/管理端去掉'返回扫码登录'按钮 - 管理端改为二维码始终可见+轮询扫码状态 - 员工端 /itdesk/ 改为 alias 直接服务 H5 (不再301重定向) - docker-compose 添加 h5 volume 挂载 管理端权限修复: - 扫码登录改用 get_user_roles() 替代写死 roles=['agent'] - get_user_roles() 增加 agents.role 回退 - 新增 GET /admin/roles/user-roles 端点 - 角色管理页加载用户角色分配数据 文档更新: - OTP PRD + 系统设计文档 - 故障排查手册 v1.1 (新增6案例) - nginx 生产基准配置
This commit is contained in:
@@ -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. 用户角色分配/撤销
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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 统一校验逻辑)
|
||||
# 验证 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
|
||||
|
||||
@@ -150,6 +150,7 @@ async def scan_qrcode(
|
||||
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
|
||||
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""处理企微 OAuth2 扫码回调。
|
||||
|
||||
@@ -186,12 +187,61 @@ async def scan_qrcode(
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.process_scan(ticket=final_ticket, code=final_code)
|
||||
|
||||
# ==========================================================================
|
||||
# 扫码后自动确认(auto-confirm)
|
||||
# ==========================================================================
|
||||
# 原设计:requester 需已登录坐席调 /confirm 来授权新登录
|
||||
# 问题:首次登录时电脑端无人登录,没有合法 current_user 可调 confirm
|
||||
# 修正:扫码即确认,直接为扫码的企微用户签发 token
|
||||
# ==========================================================================
|
||||
from app.services.token_service import TokenService
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
token_service = TokenService(redis_client)
|
||||
|
||||
# 获取用户的真实角色(而非写死 agent)
|
||||
role_service = RoleMappingService(db)
|
||||
user_roles = await role_service.get_user_roles(result["employee_id"])
|
||||
logger.info(
|
||||
f"扫码登录角色: employee_id={result['employee_id']}, "
|
||||
f"roles={user_roles}"
|
||||
)
|
||||
|
||||
auto_token = await token_service.create_token(
|
||||
employee_id=result["employee_id"],
|
||||
name=result["name"],
|
||||
roles=user_roles,
|
||||
avatar=result.get("avatar", ""),
|
||||
login_source="qrcode_scan",
|
||||
)
|
||||
|
||||
confirm_payload = {
|
||||
"token": auto_token,
|
||||
"confirmed_at": datetime.now().isoformat(),
|
||||
"roles": user_roles,
|
||||
"employee_id": result["employee_id"],
|
||||
"name": result["name"],
|
||||
}
|
||||
CONFIRM_TTL = 60
|
||||
await redis_client.setex(
|
||||
f"qrcode:confirm:{final_ticket}",
|
||||
CONFIRM_TTL,
|
||||
json.dumps(confirm_payload, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"扫码自动确认: ticket={final_ticket[:8]}..., "
|
||||
f"employee_id={result['employee_id']}, name={result['name']}"
|
||||
)
|
||||
|
||||
# GET 请求(企微 OAuth 回调)→ 重定向到前端选择页
|
||||
# 因为企微 OAuth 流程不在这个端点完成最终登录,只标记 scanned,
|
||||
# 等用户在坐席端点 confirm 后才能拿到 token。
|
||||
# 但企微 WebView 期望看到跳转后的页面,所以这里给个提示页。
|
||||
from fastapi.responses import HTMLResponse
|
||||
if code is not None and ticket is not None and body is None:
|
||||
if final_code is not None and final_ticket is not None and body is None:
|
||||
# GET 模式:渲染一个 "扫码成功" 的 HTML 提示页 + 引导用户到登录页
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
+93
-21
@@ -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})
|
||||
|
||||
@@ -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文档摄入"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.role import Role
|
||||
from app.models.role_mapping_rule import RoleMappingRule
|
||||
from app.models.user_role import UserRole
|
||||
from app.models.agent import Agent
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -59,26 +60,32 @@ class RoleMappingService:
|
||||
async def get_user_roles(self, employee_id: str) -> List[str]:
|
||||
"""获取用户的角色列表。
|
||||
|
||||
查询 user_roles 表,返回用户拥有的角色标识列表。
|
||||
查询 user_roles 表,同时回退到 agents.role 字段作为补充。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
List[str]: 角色标识列表(如 ["user", "agent"])
|
||||
List[str]: 角色标识列表(如 ["user", "agent", "admin"])
|
||||
"""
|
||||
stmt = (
|
||||
select(Role.name)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == employee_id)
|
||||
.where(
|
||||
# 过滤已过期的角色
|
||||
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > datetime.now())
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
roles = [row[0] for row in result.all()]
|
||||
|
||||
# 回退到 agents.role 字段(兼容旧数据)
|
||||
agent_stmt = select(Agent.role).where(Agent.user_id == employee_id)
|
||||
agent_result = await self.db.execute(agent_stmt)
|
||||
agent_role = agent_result.scalar_one_or_none()
|
||||
if agent_role and agent_role not in roles:
|
||||
roles.append(agent_role)
|
||||
|
||||
# 如果没有角色,添加默认的 user 角色
|
||||
if not roles:
|
||||
roles = ["user"]
|
||||
|
||||
+41
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# 增量 PRD — OTP 首次绑定流程及管理后台清除功能
|
||||
|
||||
> **文档版本**: v1.0(增量)
|
||||
> **创建日期**: 2026-07-06
|
||||
> **产品经理**: Alice
|
||||
> **状态**: 待评审
|
||||
> **关联文档**:
|
||||
> - `docs/02-产品需求/04-增量PRD-三端认证重构.md` — 母 PRD(认证重构,AUTH-P1-3 预留"首次绑定OTP引导")
|
||||
> - `docs/02-产品需求/02-产品需求文档PRD-v1.2-20260704.md` — 主 PRD
|
||||
> - `backend/app/api/otp.py` — OTP 六个端点已实现
|
||||
> - `backend/app/api/agents.py:274` — `mfa_enabled=False` 时直通登录(无绑定引导)
|
||||
> - `frontend-agent/src/views/Login.vue` — OTP 输入框已实现 `v-if="requireOtp"`,无绑定流程
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档目标
|
||||
|
||||
本增量 PRD 旨在为 **坐席端 OTP 首次绑定引导** 与 **管理后台 OTP 清除功能** 提供完整的交互定义。解决当前三大缺口:
|
||||
|
||||
1. 新坐席 `mfa_enabled=False` → 账密通过后直通工作台,无绑定引导途径
|
||||
2. 坐席 OTP 丢失(换手机 / Authenticator 误删)后无法自助重新绑定
|
||||
3. 管理端缺少「清除已绑定 OTP」管理入口(后端已有 `/otp-admin-reset`,前端未接入)
|
||||
|
||||
> **注意**:后端六个 OTP 端点(bind / verify / unbind / status / admin-reset / admin-users)已在 `backend/app/api/otp.py` 实现完成,本 PRD 聚焦**前端交互与流程设计**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|------|
|
||||
| 项目名称 | `wecom_it_smart_desk` |
|
||||
| 文档语言 | 中文 |
|
||||
| Programming Language | Vite + Vue3 + ElementPlus(前端不变) |
|
||||
| 原始需求 | 三端认证重构(PRD-04)已确定 OTP 接口与路由,本增量补充首次绑定引导与管理员重置的前端交互 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 产品目标
|
||||
|
||||
1. **坐席首次登录闭环**:新坐席账密验证通过后自动引导绑定 OTP,绑定成功后才能进入工作台——堵死"无 OTP 直通"漏洞
|
||||
2. **丢失恢复路径**:坐席 OTP 丢失后,通过管理后台申请重置 → 重新走首次绑定流程
|
||||
3. **管理员可见可控**:管理员可在后台查看全量坐席 OTP 绑定状态,并一键清除指定坐席的绑定
|
||||
|
||||
### 2.2 用户故事
|
||||
|
||||
| ID | 角色 | 用户故事 | 优先级 |
|
||||
|----|------|----------|--------|
|
||||
| US-OTP-1 | 新坐席 (new agent) | 作为首次登录的 IT 坐席,我希望账密验证通过后系统自动弹出二维码引导我绑定 OTP,扫码+输入验证码后即可进入工作台 | P0 |
|
||||
| US-OTP-2 | 已有 OTP 的坐席 (agent) | 作为已绑定 OTP 的坐席,我登录时输入账密后直接弹出 OTP 输入框验证,不要展示首次绑定流程 | P0 |
|
||||
| US-OTP-3 | OTP 丢失的坐席 (agent) | 作为手机丢失/Authenticator 误删的坐席,我希望先在管理后台申请重置 OTP,然后下次登录时重新走绑定流程,最后能自助解绑+重新绑定 | P1 |
|
||||
| US-OTP-4 | 管理员 (admin) | 作为管理员,我希望在管理后台查看所有坐席的 OTP 绑定状态,并能在坐席丢失 OTP 时一键清除其绑定,使其下次登录强制重新绑定 | P0 |
|
||||
| US-OTP-5 | 坐席 (agent) | 作为已绑定 OTP 的坐席,我希望在设置页面能自助解绑 OTP(需验证当前 OTP 码),解绑后下次登录直接进入首次绑定流程 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 需求池
|
||||
|
||||
### 3.1 P0 — 必须实现
|
||||
|
||||
| ID | 需求 | 说明 / 验收标准 |
|
||||
|----|------|-----------------|
|
||||
| OTP-P0-1 | 首次登录 OTP 绑定引导 | 坐席账密验证通过 → 后端返回 `require_otp_bind: true`(而非 `require_otp: true`)→ 前端弹出 OTP 绑定面板(二维码 + secret 明文 + 6 位验证码输入框 + 确认按钮)→ 调用 `/api/auth/otp-bind` 获取 secret/QR → 用户扫码后输入 6 位码 → 调用 `/api/auth/otp-verify` → 验证通过后 `mfa_enabled=True` → 签发完整 token → 进入工作台 |
|
||||
| OTP-P0-2 | 管理后台 OTP 绑定状态列表 | 管理后台新增「OTP 管理」页面,列表展示:坐席姓名、employee_id、OTP 绑定状态(已绑定/未绑定)、绑定时间、最后验证时间。调用 `GET /api/auth/otp-admin-users` |
|
||||
| OTP-P0-3 | 管理后台清除 OTP 绑定 | 管理员可在 OTP 管理页面点击「清除绑定」按钮 → 二次确认弹窗 → 调用 `POST /api/auth/otp-admin-reset/{employee_id}` → 该坐席下次登录强制走首次绑定流程 |
|
||||
| OTP-P0-4 | 后端区分"需要OTP验证"和"需要OTP绑定"两种状态 | `POST /agents/login` 在 `mfa_enabled=False` 时,不直接签发 token,而是返回 `require_otp_bind: true`;前端据此展示绑定面板。`mfa_enabled=True` 时保持现有 `require_otp: true` |
|
||||
|
||||
### 3.2 P1 — 应该实现
|
||||
|
||||
| ID | 需求 | 说明 / 验收标准 |
|
||||
|----|------|-----------------|
|
||||
| OTP-P1-1 | 坐席端自助解绑+重新绑定 | 坐席在「个人设置」页面看到 OTP 绑定状态,可点击「解绑 OTP」→ 输入当前 OTP 验证码 → 调用 `POST /api/auth/otp-unbind` → 解绑成功后 `mfa_enabled=False` → 下次登录自动进入首次绑定流程(复用 OTP-P0-1 面板) |
|
||||
| OTP-P1-2 | 重新绑定流程 | 坐席已解绑后,登录时走 OTP-P0-1 绑定引导(与首次登录完全相同)。也可在设置页面提供「重新绑定」入口,主动触发绑定流程 |
|
||||
| OTP-P1-3 | 绑定流程中的"跳过"选项 | 首次绑定向导中提供"暂不绑定,稍后设置"按钮 → 跳过绑定直接进入工作台 → 在导航栏/设置页显示警告徽章提醒完成绑定 → 限制跳过次数或有效期(如仅可跳过 1 次) |
|
||||
|
||||
### 3.3 P2 — 锦上添花
|
||||
|
||||
| ID | 需求 | 说明 / 验收标准 |
|
||||
|----|------|-----------------|
|
||||
| OTP-P2-1 | OTP 绑定操作日志 | 管理员可查看 OTP 绑定/解绑/清除操作记录(操作人、操作时间、操作类型、目标坐席),调用现有后端日志即可 |
|
||||
| OTP-P2-2 | 批量清除 OTP | 管理员可多选坐席后批量清除 OTP 绑定 |
|
||||
| OTP-P2-3 | 绑定截止日强制提醒 | 跳过绑定的坐席在 N 天后强制弹出绑定面板,不允许继续跳过 |
|
||||
|
||||
---
|
||||
|
||||
## 4. UI 设计稿说明
|
||||
|
||||
> 原型图目录:`docs/04-原型设计/prototypes-原型图/`
|
||||
|
||||
### 4.1 坐席端:首次绑定引导面板(新增)
|
||||
|
||||
**触发时机**:`handleLogin` 收到 `require_otp_bind: true` 后展示。
|
||||
|
||||
**布局**(在现有 `agent-login-v1.html` 登录卡片内,替换 OTP 输入区):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🛠️ IT智能服务台 │
|
||||
│ 坐席工作台 │
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🔐 首次登录 — 绑定 OTP 二次验证 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [二维码 QR Code PNG] │ │
|
||||
│ │ 200×200 px │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ 请使用 Google Authenticator │
|
||||
│ 或 Microsoft Authenticator 扫码 │
|
||||
│ │
|
||||
│ ── 或手动输入密钥 ── │
|
||||
│ 密钥:XXXX XXXX XXXX XXXX │
|
||||
│ [📋 复制] │
|
||||
│ │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ 输入 6 位验证码 │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ 验证并完成绑定 ] ← 主 CTA │
|
||||
│ [ 暂不绑定,稍后设置 ] ← 次要 │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 坐席端:登录页 OTP 验证(已有,无需改动)
|
||||
|
||||
保持现有 `agent-login-v1.html` 的 OTP 输入框逻辑:
|
||||
- `v-if="requireOtp"` 渲染 6 位 OTP 输入框
|
||||
- 按键文案「验证 OTP」
|
||||
|
||||
### 4.3 管理端:OTP 管理页面(新增)
|
||||
|
||||
**入口**:「坐席管理」→ 新增 Tab「OTP 绑定状态」,或在左侧菜单新增「OTP 管理」。
|
||||
|
||||
**列表字段**:
|
||||
|
||||
| 列名 | 数据来源 |
|
||||
|------|----------|
|
||||
| 坐席姓名 | `GET /otp-admin-users` → `name` |
|
||||
| 企微 ID | `employee_id` |
|
||||
| OTP 状态 | `mfa_enabled` → 「已绑定」/「未绑定」标签 |
|
||||
| 绑定时间 | `mfa_bound_at` |
|
||||
| 最后验证 | `mfa_last_verified_at` |
|
||||
| 操作 | 「清除绑定」按钮(仅 `mfa_enabled=true` 时可用) |
|
||||
|
||||
**清除确认弹窗**:
|
||||
```
|
||||
⚠️ 确认清除 OTP 绑定?
|
||||
坐席「张三 (zhangsan)」的 OTP 二次验证将被清除。
|
||||
该坐席下次登录时需重新绑定。
|
||||
|
||||
[取消] [确认清除]
|
||||
```
|
||||
|
||||
### 4.4 坐席端:个人设置页 OTP 管理(P1,新增)
|
||||
|
||||
在坐席端「设置」页新增「OTP 二次验证」面板:
|
||||
|
||||
- 已绑定状态:显示「✅ 已绑定|绑定时间:xxxx-xx-xx」,提供「解绑 OTP」按钮
|
||||
- 解绑操作:弹出输入框要求输入当前 6 位 OTP 验证码 → 确认后调用 `/api/auth/otp-unbind`
|
||||
- 未绑定状态:显示「⚠️ 未绑定 — [立即绑定]」按钮 → 弹出与首次登录相同的绑定面板
|
||||
|
||||
---
|
||||
|
||||
## 5. 交互流程
|
||||
|
||||
### 5.1 首次绑定流程(P0 核心流程)
|
||||
|
||||
```
|
||||
坐席打开 /itagent/login
|
||||
→ 选择"账号密码登录"
|
||||
→ 输入账号、密码 → 点击登录
|
||||
→ POST /agents/login {user_id, password}
|
||||
→ 后端返回:{ require_otp_bind: true, user_id, name }
|
||||
→ 前端展示 OTP 绑定面板(隐藏账密表单)
|
||||
→ 前端调用 POST /api/auth/otp-bind → 获得 { secret, otpauth_url, qr_code_base64 }
|
||||
→ 渲染二维码 + secret 明文
|
||||
→ 坐席用 Authenticator 扫码(或手动输入 secret)
|
||||
→ Authenticator 生成 6 位 TOTP 码
|
||||
→ 坐席在输入框中输入 6 位码 → 点击"验证并完成绑定"
|
||||
→ 前端调用 POST /api/auth/otp-verify { otp_code }
|
||||
→ 后端:校验通过 → mfa_enabled=True, mfa_bound_at=now → 返回 { verified: true }
|
||||
→ 前端再次调用 POST /agents/login {user_id, password}(或后端在 verify 成功后直接签发 token)
|
||||
→ 进入工作台
|
||||
```
|
||||
|
||||
### 5.2 OTP 丢失恢复流程(P0 + P1)
|
||||
|
||||
```
|
||||
坐席发现手机丢失/Authenticator 误删
|
||||
→ 联系管理员(企微/电话)
|
||||
→ 管理员登录管理后台 → OTP 管理页面
|
||||
→ 找到该坐席 → 点击"清除绑定" → 确认
|
||||
→ POST /api/auth/otp-admin-reset/{employee_id}
|
||||
→ 坐席下次登录时 mfa_enabled=False → 触发首次绑定流程(5.1)
|
||||
```
|
||||
|
||||
### 5.3 坐席自助解绑+重新绑定流程(P1)
|
||||
|
||||
```
|
||||
坐席正常登录进入工作台
|
||||
→ 打开"设置" → OTP 二次验证
|
||||
→ 点击"解绑 OTP"
|
||||
→ 弹窗输入当前 6 位 OTP 验证码 → 确认
|
||||
→ POST /api/auth/otp-unbind { otp_code }
|
||||
→ 解绑成功 → mfa_enabled=False
|
||||
→ 下次登录自动进入首次绑定流程(5.1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键设计决策
|
||||
|
||||
### 6.1 后端响应区分 `require_otp` vs `require_otp_bind`
|
||||
|
||||
`POST /agents/login` 当前逻辑(`agents.py:274`):
|
||||
|
||||
```python
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
return {"require_otp": True, ...} # 已有 OTP → 要求验证
|
||||
else:
|
||||
# 校验 OTP → 签发 token
|
||||
# 当 mfa_enabled=False 时,直接 fall through 到签发 token ← 问题所在
|
||||
```
|
||||
|
||||
**建议改为**:
|
||||
|
||||
```python
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
return {"require_otp": True, "message": "请输入OTP动态码", ...}
|
||||
else:
|
||||
# 校验 OTP → 签发 token
|
||||
else:
|
||||
# 未绑定 OTP → 引导绑定
|
||||
return {"require_otp_bind": True, "message": "首次登录请先绑定OTP二次验证", "user_id": agent.user_id, "name": agent.name}
|
||||
```
|
||||
|
||||
### 6.2 绑定面板是"替换登录表单"还是"新页面跳转"
|
||||
|
||||
**建议**:在登录卡片内**替换**(隐藏账密表单,显示绑定面板),保持用户在同一页面上下文内,避免跳转带来的 token 状态管理复杂度。绑定成功后直接跳转工作台。
|
||||
|
||||
### 6.3 OTP 绑定成功后是否需要重新调用 login
|
||||
|
||||
**建议**:`POST /api/auth/otp-verify` 在 `mfa_enabled=False` 的绑定场景下,验证成功后**直接返回完整 token**(类似于注册+登录合并),避免前端二次调用 login。后端 `otp.py:verify_otp` 中当 `mfa_enabled` 从 False 变为 True 时,除了写 Redis 标记,还应签发 JWT token 一并返回。
|
||||
|
||||
---
|
||||
|
||||
## 7. 待确认问题
|
||||
|
||||
| # | 问题 | 建议 | 影响 |
|
||||
|---|------|------|------|
|
||||
| Q1 | 首次绑定能否"跳过"? | 建议允许跳过 1 次(P1),在导航栏持续提醒"请完成 OTP 绑定",3 天后强制弹出绑定面板。**也可 P0 阶段先不允许跳过**,简化首版逻辑。 | 用户体验 vs 安全刚性 |
|
||||
| Q2 | 扫码登录的坐席是否需要 OTP? | 三端认证重构决策3 要求「所有登录均需 OTP」。如果是,扫码登录也需要区分 `require_otp` vs `require_otp_bind`。但扫码登录本身已是企微身份强验证,再叠加 OTP 可能过度。**建议与架构师确认**。 | 扫码登录流程复杂度 |
|
||||
| Q3 | OTP verify 绑定场景是否直接返回 token? | 建议是(见 §6.3),避免前端二次调用 login 的状态同步问题。需要修改 `otp.py:verify_otp`:绑定场景(首次)额外返回 token。 | 后端改动量小 |
|
||||
| Q4 | 管理后台的 OTP 清除操作是否需要审计日志? | 建议 P2 补充,P0 阶段先记 logger。后端已有 `logger.info`(如 `otp.py:327`)。 | 合规性 |
|
||||
| Q5 | 绑定面板中 secret 是否明文展示? | 建议默认展示(带复制按钮),用折叠/展开切换"手动输入密钥"区域。这是标准 TOTP 实践,Google/Microsoft Authenticator 都支持手动输入。 | 无安全风险(secret 仅在绑定过程中临时展示) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 与母 PRD 的衔接关系
|
||||
|
||||
| 母 PRD 条目 | 衔接 |
|
||||
|-------------|------|
|
||||
| AUTH-P1-3「首次绑定 OTP 引导」 | 本增量 PRD 将其从 P1 升级为 **P0**,并给出完整交互定义 |
|
||||
| AUTH-P0-5 OTP 接口统一 | 复用已有 `/api/auth/otp-bind` / `otp-verify` / `otp-unbind` / `otp-status` |
|
||||
| AUTH-P0-4 OTP 输入框渲染时机 | 补充:首次绑定时渲染的是**绑定面板**(二维码+输入框),而非仅 OTP 输入框 |
|
||||
| CTRT-P0-1 响应契约 | 所有 OTP 接口均遵循统一信封,前端直接消费 inner data |
|
||||
|
||||
---
|
||||
|
||||
> **文档结束** — 本增量 PRD 作为三端认证重构的子增量,聚焦 OTP 生命周期完整体验。待评审确认待确认问题后,移交架构师进行前端方案设计。
|
||||
@@ -0,0 +1,512 @@
|
||||
# 系统架构设计 — OTP 首次绑定与重置
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-07-06
|
||||
> **架构师**: Bob(高见远)
|
||||
> **关联 PRD**: `docs/02-产品需求/05-增量PRD-OTP首次绑定与重置.md`
|
||||
> **关联母文档**: `docs/02-产品需求/04-增量PRD-三端认证重构.md`
|
||||
|
||||
---
|
||||
|
||||
## Part A: 系统设计
|
||||
|
||||
### 1. 实现方案与框架选型
|
||||
|
||||
#### 1.1 核心技术挑战
|
||||
|
||||
| 挑战 | 描述 | 策略 |
|
||||
|------|------|------|
|
||||
| **登录态区分** | 后端需区分"需要 OTP 验证"(已绑定)和"需要 OTP 绑定"(未绑定)两种状态 | `agents.py` 新增 `else` 分支,返回 `require_otp_bind: true` |
|
||||
| **绑定即登录** | OTP 首次绑定成功后,用户不应重新输入账密 | `otp-verify` 绑定场景下直接签发 token 返回 |
|
||||
| **前端 API 迁移** | 现有坐席/管理端 API 适配层使用旧端点 `/mfa/*`、`/admin/mfa/*`,需迁移到统一 `/auth/otp-*` | 逐步迁移,保留旧端点兼容过渡期 |
|
||||
| **绑定面板嵌入** | PRD 要求绑定面板嵌入登录卡片内(非独立页面) | 新建 `OtpBindPanel.vue` 组件,在 `Login.vue` 中 `v-if="requireOtpBind"` 条件渲染 |
|
||||
|
||||
#### 1.2 框架与库(沿用现有栈,无新增)
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|----------|------|
|
||||
| 后端框架 | FastAPI + SQLAlchemy Async | 已有,不新增 |
|
||||
| OTP 服务 | `pyotp` + `qrcode` | 已有 `MFAService`,不新增 |
|
||||
| 前端坐席端 | Vite + Vue 3 + Element Plus + Pinia | 已有,不新增 |
|
||||
| 前端管理端 | Vite + Vue 3 + Element Plus + Pinia | 已有,不新增 |
|
||||
| Redis | `redis.asyncio` | 已有,用于 MFA 验证状态缓存 |
|
||||
|
||||
#### 1.3 架构模式
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ nginx (反向代理) │
|
||||
│ /api/auth/otp-* → backend │
|
||||
└────────────┬────────────────────────────┬────────────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ frontend-agent │ │ frontend-admin │
|
||||
│ (坐席端 SPA) │ │ (管理端 SPA) │
|
||||
│ │ │ │
|
||||
│ Login.vue │ │ MfaManage.vue │
|
||||
│ ├─ 账密表单 │ │ ├─ 列表+搜索 │
|
||||
│ ├─ OTP 输入框 │ │ ├─ 清除绑定按钮 │
|
||||
│ └─ OtpBindPanel │ │ └─ 确认弹窗 │
|
||||
│ (新增组件) │ │ │
|
||||
└────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
└──────────┬──────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ backend (FastAPI) │
|
||||
│ │
|
||||
│ agents.py │
|
||||
│ └─ agent_login: │
|
||||
│ mfa_enabled? │
|
||||
│ ├─ True+无码 │
|
||||
│ │ → require_otp│
|
||||
│ ├─ True+有码 │
|
||||
│ │ → 验证→token │
|
||||
│ └─ False │
|
||||
│ → require_ │
|
||||
│ otp_bind ★ │
|
||||
│ │
|
||||
│ otp.py │
|
||||
│ ├─ otp-bind │
|
||||
│ ├─ otp-verify ★ │
|
||||
│ │ (绑定场景返token)│
|
||||
│ ├─ otp-unbind │
|
||||
│ ├─ otp-status │
|
||||
│ ├─ otp-admin-users│
|
||||
│ └─ otp-admin-reset│
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 文件列表
|
||||
|
||||
#### 2.1 后端
|
||||
|
||||
| 相对路径 | 操作 | 说明 |
|
||||
|----------|------|------|
|
||||
| `backend/app/api/agents.py` | **【修改】** | `agent_login` 增加 `else` 分支:`mfa_enabled=False` → `require_otp_bind: true` |
|
||||
| `backend/app/api/otp.py` | **【修改】** | `verify_otp` 绑定场景(`mfa_enabled=False`)校验成功后设置 `mfa_enabled=True`、`mfa_bound_at=now`,并签发返回 token |
|
||||
| `backend/app/schemas/mfa.py` | **【修改】** | 新增 `MFAVerifyBindResponse`(含 `token` 字段) |
|
||||
|
||||
#### 2.2 前端 — 坐席端
|
||||
|
||||
| 相对路径 | 操作 | 说明 |
|
||||
|----------|------|------|
|
||||
| `frontend-agent/src/api/otp.ts` | **【新增】** | 统一 OTP API 适配层(bind / verify / unbind / status),使用新端点 `/auth/otp-*` |
|
||||
| `frontend-agent/src/stores/agent.ts` | **【修改】** | `login()` 方法处理 `require_otp_bind` 响应(不再 throw Error,返回标记供 UI 消费) |
|
||||
| `frontend-agent/src/views/Login.vue` | **【修改】** | 新增 `requireOtpBind` 状态 + `OtpBindPanel` 组件渲染 |
|
||||
| `frontend-agent/src/components/OtpBindPanel.vue` | **【新增】** | 可复用 OTP 绑定面板(二维码 + secret + 验证码输入 + 确认按钮 + "暂不绑定"按钮) |
|
||||
| `frontend-agent/src/views/Settings.vue` | **【新增】** | 坐席设置页,含 OTP 管理面板(状态显示 + 解绑 + 重新绑定)— P1 |
|
||||
| `frontend-agent/src/router/index.ts` | **【修改】** | 新增 `/settings` 路由 — P1 |
|
||||
|
||||
#### 2.3 前端 — 管理端
|
||||
|
||||
| 相对路径 | 操作 | 说明 |
|
||||
|----------|------|------|
|
||||
| `frontend-admin/src/api/mfa.ts` | **【修改】** | 切换 API 路径到 `/auth/otp-admin-users` 和 `/auth/otp-admin-reset`,更新 TS 类型字段名 |
|
||||
| `frontend-admin/src/views/MfaManage.vue` | **【修改】** | 更新字段映射(`mfa_enabled`/`mfa_bound_at`/`mfa_last_verified_at`),"重置 MFA"→"清除绑定",更新确认弹窗文案 |
|
||||
| `frontend-admin/src/components/Sidebar.vue` | **【修改】** | 在"运营管理"分区下新增「OTP 管理」菜单项 |
|
||||
| `frontend-admin/src/router/index.ts` | **【修改】** | 路由标题更新为「OTP 管理」 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据结构与接口
|
||||
|
||||
#### 3.1 后端 Schema 变更
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class MFAVerifyRequest {
|
||||
+str otp_code
|
||||
}
|
||||
|
||||
class MFAVerifyResponse {
|
||||
+bool verified
|
||||
+int expires_in
|
||||
}
|
||||
|
||||
class MFAVerifyBindResponse {
|
||||
+bool verified
|
||||
+int expires_in
|
||||
+str token
|
||||
+str user_id
|
||||
+str name
|
||||
+str role
|
||||
}
|
||||
|
||||
class MFABindStartResponse {
|
||||
+str secret
|
||||
+str otpauth_url
|
||||
+str qr_code_base64
|
||||
}
|
||||
|
||||
class MFAStatusResponse {
|
||||
+bool bound
|
||||
+bool enabled
|
||||
+datetime last_verified_at
|
||||
}
|
||||
|
||||
MFAVerifyRequest --> MFAVerifyResponse : "mfa_enabled=True → 仅验证"
|
||||
MFAVerifyRequest --> MFAVerifyBindResponse : "mfa_enabled=False → 绑定+签发token"
|
||||
```
|
||||
|
||||
#### 3.2 核心 API 契约
|
||||
|
||||
| 端点 | 方法 | 鉴权 | 说明 | 变更 |
|
||||
|------|------|------|------|------|
|
||||
| `/api/auth/otp-status` | GET | `get_current_user` | 查询绑定状态 | 无变更 |
|
||||
| `/api/auth/otp-bind` | POST | `get_current_user` | 生成 secret + QR | 无变更 |
|
||||
| `/api/auth/otp-verify` | POST | `get_current_user` | 校验 OTP → **绑定场景额外返回 token** | **★ 行为变更** |
|
||||
| `/api/auth/otp-unbind` | POST | `get_current_user` | 用户主动解绑 | 无变更 |
|
||||
| `/api/auth/otp-admin-users` | GET | `require_role("admin")` | 管理员查看全量坐席 OTP 状态 | 无变更 |
|
||||
| `/api/auth/otp-admin-reset/{employee_id}` | POST | `require_role("admin")` | 管理员清除指定坐席 OTP | 无变更 |
|
||||
| `/api/agents/login` | POST | 无(登录接口) | **mfa_enabled=False → require_otp_bind** | **★ 行为变更** |
|
||||
|
||||
#### 3.3 `POST /api/agents/login` 响应契约变更
|
||||
|
||||
**变更前**(当前 `agents.py:274`):
|
||||
```json
|
||||
// mfa_enabled=True 且未传 otp_code
|
||||
{ "code": 0, "data": { "require_otp": true, "message": "请输入OTP动态码", "user_id": "...", "name": "...", "role": "..." } }
|
||||
|
||||
// mfa_enabled=False → 直接签发 token(问题)
|
||||
{ "code": 0, "data": { "token": "...", "user_id": "...", ... } }
|
||||
```
|
||||
|
||||
**变更后**:
|
||||
```json
|
||||
// mfa_enabled=True 且未传 otp_code — 不变
|
||||
{ "code": 0, "data": { "require_otp": true, "message": "请输入OTP动态码", "user_id": "...", "name": "...", "role": "..." } }
|
||||
|
||||
// mfa_enabled=False — 新增 ★
|
||||
{ "code": 0, "data": { "require_otp_bind": true, "message": "首次登录请先绑定OTP二次验证", "user_id": "...", "name": "...", "role": "..." } }
|
||||
```
|
||||
|
||||
#### 3.4 `POST /api/auth/otp-verify` 响应契约变更
|
||||
|
||||
**变更前**(当前 `otp.py:verify_otp`):
|
||||
```json
|
||||
// mfa_enabled=False → 直接返回 verified=false
|
||||
{ "code": 0, "data": { "verified": false, "expires_in": 0 } }
|
||||
```
|
||||
|
||||
**变更后**:
|
||||
```json
|
||||
// mfa_enabled=False + 校验通过 → 绑定并签发 token ★
|
||||
{ "code": 0, "data": { "verified": true, "expires_in": 1800, "token": "...", "user_id": "...", "name": "...", "role": "..." } }
|
||||
|
||||
// mfa_enabled=True + 校验通过 → 不变
|
||||
{ "code": 0, "data": { "verified": true, "expires_in": 1800 } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 程序调用流程
|
||||
|
||||
#### 4.1 首次登录 OTP 绑定流程(P0 核心)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 坐席
|
||||
participant LoginVue as Login.vue
|
||||
participant AgentStore as agentStore
|
||||
participant OtpBind as OtpBindPanel.vue
|
||||
participant API as apiClient
|
||||
participant Backend as FastAPI Backend
|
||||
participant Redis as Redis
|
||||
|
||||
User->>LoginVue: 输入账号密码 → 点击登录
|
||||
LoginVue->>AgentStore: login(userId, password, undefined)
|
||||
AgentStore->>API: POST /api/agents/login {user_id, password}
|
||||
API->>Backend: agent_login()
|
||||
|
||||
Note over Backend: agent.mfa_enabled == False
|
||||
Backend-->>API: { require_otp_bind: true, user_id, name, role }
|
||||
API-->>AgentStore: { require_otp_bind: true, ... }
|
||||
AgentStore-->>LoginVue: return { require_otp_bind: true }
|
||||
|
||||
Note over LoginVue: 隐藏账密表单<br/>显示 OtpBindPanel
|
||||
LoginVue->>OtpBind: requireOtpBind = true
|
||||
|
||||
OtpBind->>API: POST /api/auth/otp-bind
|
||||
API->>Backend: bind_otp()
|
||||
Note over Backend: 生成 secret + QR
|
||||
Backend-->>API: { secret, otpauth_url, qr_code_base64 }
|
||||
API-->>OtpBind: { secret, otpauth_url, qr_code_base64 }
|
||||
|
||||
Note over OtpBind: 渲染二维码 + secret 明文
|
||||
User->>User: 用 Authenticator 扫码(或手动输入 secret)
|
||||
User->>OtpBind: 输入 6 位验证码 → 点击"验证并完成绑定"
|
||||
|
||||
OtpBind->>API: POST /api/auth/otp-verify { otp_code }
|
||||
API->>Backend: verify_otp()
|
||||
|
||||
Note over Backend: mfa_enabled=False (绑定场景)<br/>校验 TOTP → 通过<br/>设置 mfa_enabled=True<br/>设置 mfa_bound_at=now
|
||||
|
||||
Backend->>Redis: mark_verified(employee_id, TTL=1800)
|
||||
Backend->>Backend: 签发 JWT token
|
||||
Backend-->>API: { verified: true, token, user_id, name, role }
|
||||
API-->>OtpBind: { verified: true, token, ... }
|
||||
|
||||
OtpBind-->>LoginVue: emit('bind-success', { token, user_id, name })
|
||||
LoginVue->>AgentStore: 保存 token → 设置 agentInfo
|
||||
LoginVue->>LoginVue: router.push('/workspace')
|
||||
```
|
||||
|
||||
#### 4.2 管理后台清除 OTP 绑定流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant MfaVue as MfaManage.vue
|
||||
participant API as apiClient
|
||||
participant Backend as FastAPI Backend
|
||||
participant DB as PostgreSQL
|
||||
participant Redis as Redis
|
||||
|
||||
Admin->>MfaVue: 访问 OTP 管理页面
|
||||
MfaVue->>API: GET /api/auth/otp-admin-users
|
||||
API->>Backend: admin_list_otp_users()
|
||||
Backend->>DB: SELECT * FROM agents
|
||||
DB-->>Backend: agent rows
|
||||
Backend-->>API: [{employee_id, name, mfa_enabled, mfa_bound_at, ...}]
|
||||
API-->>MfaVue: 渲染表格
|
||||
|
||||
Admin->>MfaVue: 点击某坐席的「清除绑定」
|
||||
MfaVue->>MfaVue: ElMessageBox 二次确认弹窗
|
||||
Admin->>MfaVue: 确认清除
|
||||
|
||||
MfaVue->>API: POST /api/auth/otp-admin-reset/{employee_id}
|
||||
API->>Backend: admin_reset_otp(employee_id)
|
||||
Backend->>DB: UPDATE agents SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
|
||||
Backend->>Redis: clear_verified(employee_id)
|
||||
Backend-->>API: { success: true }
|
||||
API-->>MfaVue: ElMessage.success('已清除')
|
||||
|
||||
MfaVue->>API: 重新加载列表
|
||||
```
|
||||
|
||||
#### 4.3 坐席自助解绑流程(P1)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 坐席
|
||||
participant Settings as Settings.vue
|
||||
participant API as apiClient
|
||||
participant Backend as FastAPI Backend
|
||||
participant DB as PostgreSQL
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Settings: 打开设置页 → OTP 管理面板
|
||||
Settings->>API: GET /api/auth/otp-status
|
||||
API->>Backend: get_otp_status()
|
||||
Backend-->>API: { bound: true, enabled: true, last_verified_at }
|
||||
API-->>Settings: 显示「✅ 已绑定」
|
||||
|
||||
User->>Settings: 点击「解绑 OTP」
|
||||
Settings->>Settings: 弹窗:输入当前 OTP 验证码
|
||||
User->>Settings: 输入 6 位码 → 确认
|
||||
|
||||
Settings->>API: POST /api/auth/otp-unbind { otp_code }
|
||||
API->>Backend: unbind_otp()
|
||||
Backend->>Backend: MFAService.verify_code() → 通过
|
||||
Backend->>DB: UPDATE SET mfa_secret=NULL, mfa_enabled=False, mfa_bound_at=NULL
|
||||
Backend->>Redis: clear_verified(employee_id)
|
||||
Backend-->>API: { success: true }
|
||||
API-->>Settings: ElMessage.success('OTP 已解绑')
|
||||
Settings->>Settings: 刷新状态 → 显示「⚠️ 未绑定 — 立即绑定」
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 待确认问题与假设
|
||||
|
||||
| # | 问题 | 假设 | 影响 |
|
||||
|---|------|------|------|
|
||||
| Q1 | 首次绑定能否"跳过"? | **P0 阶段不允许跳过**。简化首版逻辑,堵死无 OTP 直通漏洞。P1 阶段再引入"暂不绑定"按钮。 | 首次绑定向导无跳过按钮 |
|
||||
| Q2 | 扫码登录的坐席是否需要 OTP? | **P0 阶段暂不处理**。扫码登录已有企微身份强验证,且当前 `agent_login` 中对已绑定 OTP 的坐席仍要求 OTP。本增量聚焦账密登录的绑定引导。 | 扫码登录不做额外改动 |
|
||||
| Q3 | OTP verify 绑定场景是否直接返回 token? | **是**。修改 `otp.py:verify_otp`,绑定场景额外返回 token。避免前端二次 login。 | 后端改动量小,前端只需一次请求 |
|
||||
| Q4 | 管理后台旧端点 `/admin/mfa/*` 是否需要保留? | **保留兼容过渡期**。前端切换到新端点,旧端点暂时保留不删,待下个迭代清理。 | 无风险 |
|
||||
| Q5 | `OtpBindPanel.vue` 与已有 `MfaBind.vue` 的关系? | `OtpBindPanel` 是轻量嵌入组件(嵌入登录卡片内),`MfaBind.vue` 是独立全页组件(`/mfa-bind` 路由)。绑定流程 UI 逻辑可复用,但布局不同。**建议新建 `OtpBindPanel` 组件,从 `MfaBind.vue` 提取共享的二维码渲染 + 验证逻辑**。 | 两个组件共存,按场景使用 |
|
||||
|
||||
---
|
||||
|
||||
## Part B: 任务分解
|
||||
|
||||
### 6. 所需依赖包
|
||||
|
||||
**无新增依赖**。所有功能基于已有技术栈:
|
||||
|
||||
```
|
||||
- Vue 3 + Element Plus(前端已有)
|
||||
- Pinia(状态管理已有)
|
||||
- pyotp + qrcode(后端已有 MFAService)
|
||||
- FastAPI + SQLAlchemy Async(后端已有)
|
||||
- redis.asyncio(Redis 客户端已有)
|
||||
```
|
||||
|
||||
### 7. 任务列表(按依赖关系排序)
|
||||
|
||||
| Task ID | 任务名称 | 源文件 | 依赖 | 优先级 |
|
||||
|---------|----------|--------|------|--------|
|
||||
| **T01** | 后端 OTP 绑定流程改造 | `backend/app/api/agents.py`【修改】<br/>`backend/app/api/otp.py`【修改】<br/>`backend/app/schemas/mfa.py`【修改】 | 无 | **P0** |
|
||||
| **T02** | 管理后台 OTP 管理页完整更新 | `frontend-admin/src/api/mfa.ts`【修改】<br/>`frontend-admin/src/views/MfaManage.vue`【修改】<br/>`frontend-admin/src/components/Sidebar.vue`【修改】<br/>`frontend-admin/src/router/index.ts`【修改】 | T01(需后端端点就绪) | **P0** |
|
||||
| **T03** | 坐席端登录页 OTP 绑定面板 | `frontend-agent/src/api/otp.ts`【新增】<br/>`frontend-agent/src/stores/agent.ts`【修改】<br/>`frontend-agent/src/views/Login.vue`【修改】<br/>`frontend-agent/src/components/OtpBindPanel.vue`【新增】 | T01(需后端端点就绪) | **P0** |
|
||||
| **T04** | 坐席端设置页 OTP 管理 | `frontend-agent/src/views/Settings.vue`【新增】<br/>`frontend-agent/src/router/index.ts`【修改】<br/>`frontend-agent/src/api/otp.ts`【修改】 | T03(依赖 otp.ts API 适配层) | **P1** |
|
||||
|
||||
#### T01 详细说明 — 后端 OTP 绑定流程改造
|
||||
|
||||
**修改点 1** — `backend/app/api/agents.py:274`(`agent_login` 函数):
|
||||
```python
|
||||
# 当前:只有 if agent.mfa_enabled: ...
|
||||
# 改造后:添加 else 分支
|
||||
if agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
return success_response(data={
|
||||
"require_otp": True,
|
||||
"message": "请输入OTP动态码",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role,
|
||||
})
|
||||
else:
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
else:
|
||||
# ★ 新增:未绑定 OTP → 引导绑定
|
||||
return success_response(data={
|
||||
"require_otp_bind": True,
|
||||
"message": "首次登录请先绑定OTP二次验证",
|
||||
"user_id": agent.user_id,
|
||||
"name": agent.name,
|
||||
"role": agent.role,
|
||||
})
|
||||
```
|
||||
|
||||
**修改点 2** — `backend/app/api/otp.py:verify_otp`(第 213-218 行):
|
||||
```python
|
||||
# 当前:mfa_enabled=False → 直接返回 verified=false
|
||||
# 改造后:mfa_enabled=False 且 mfa_secret 存在(已调用过 otp-bind)→ 走绑定校验逻辑
|
||||
if not agent.mfa_enabled and agent.mfa_secret:
|
||||
# ★ 绑定场景:校验 OTP → 设置 enabled + bound_at → 签发 token
|
||||
if not MFAService.verify_code(agent.mfa_secret, body.otp_code):
|
||||
logger.warning(f"OTP bind verify 验证码错误: agent={agent.user_id}")
|
||||
return success_response(data=MFAVerifyResponse(verified=False, expires_in=0).model_dump())
|
||||
|
||||
now = datetime.now()
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = now
|
||||
agent.mfa_last_verified_at = now
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
await MFAService.mark_verified(redis, agent.user_id, MFA_VERIFIED_TTL_SECONDS)
|
||||
|
||||
# 签发 token
|
||||
from app.services.token_service import TokenService
|
||||
from app.dependencies import get_redis as _get_redis_dep
|
||||
redis_client = await _get_redis_dep()
|
||||
token_service = TokenService(redis_client)
|
||||
token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=getattr(agent, 'name', '') or '',
|
||||
roles=[agent.role] if agent.role else [],
|
||||
login_source="agent",
|
||||
)
|
||||
|
||||
logger.info(f"OTP bind+verify 成功: agent={agent.user_id}")
|
||||
return success_response(data={
|
||||
"verified": True,
|
||||
"expires_in": MFA_VERIFIED_TTL_SECONDS,
|
||||
"token": token,
|
||||
"user_id": agent.user_id,
|
||||
"name": getattr(agent, 'name', '') or '',
|
||||
"role": agent.role or '',
|
||||
})
|
||||
|
||||
# 原有逻辑:已绑定场景保持不变
|
||||
if not agent.mfa_enabled or not agent.mfa_secret:
|
||||
return success_response(data=MFAVerifyResponse(verified=False, expires_in=0).model_dump())
|
||||
# ... 后续不变
|
||||
```
|
||||
|
||||
**修改点 3** — `backend/app/schemas/mfa.py`:新增 Schema(可选,也可用 dict 直接返回)。
|
||||
|
||||
#### T02 详细说明 — 管理后台 OTP 管理页完整更新
|
||||
|
||||
1. **`mfa.ts`**:API 路径从 `/admin/mfa/users` → `/auth/otp-admin-users`,`/admin/mfa/reset/{id}` → `/auth/otp-admin-reset/{id}`。TS 类型字段名从 `bound`/`bound_at` 改为 `mfa_enabled`/`mfa_bound_at`。
|
||||
2. **`MfaManage.vue`**:字段映射 `row.bound` → `row.mfa_enabled`,`row.bound_at` → `row.mfa_bound_at`;按钮文案"重置 MFA"→"清除绑定";确认弹窗文案按 PRD §4.3 更新。
|
||||
3. **`Sidebar.vue`**:在"📋 运营管理"分组添加 `<el-menu-item index="/mfa-manage">` 菜单项。
|
||||
4. **`router/index.ts`**:`meta.title` 更新为 `'OTP 管理'`。
|
||||
|
||||
#### T03 详细说明 — 坐席端登录页 OTP 绑定面板
|
||||
|
||||
1. **`otp.ts`**:新建 API 适配层,封装 `bindOtp()` / `verifyOtp()` / `getOtpStatus()` / `unbindOtp()`,使用新端点 `/auth/otp-*`。
|
||||
2. **`agent.ts:login()`**:处理 `require_otp_bind` 响应——不再 throw Error,返回 `{ require_otp_bind: true, user_id, name, role }` 供 Login.vue 消费。
|
||||
3. **`Login.vue`**:新增 `requireOtpBind` ref;当 `result.require_otp_bind` 为 true 时,隐藏账密表单 + OTP 输入框,显示 `<OtpBindPanel>`;监听 `bind-success` 事件保存 token 并跳转。
|
||||
4. **`OtpBindPanel.vue`**:新建可复用组件,包含:二维码渲染(`<img :src="data:image/png;base64,...">`)、secret 明文展示 + 复制按钮、6 位验证码输入框、"验证并完成绑定"主按钮、"暂不绑定,稍后设置"次要按钮(P0 阶段隐藏)。
|
||||
|
||||
#### T04 详细说明 — 坐席端设置页 OTP 管理(P1)
|
||||
|
||||
1. **`Settings.vue`**:新建设置页面,含「OTP 二次验证」面板:已绑定时显示状态+解绑按钮;未绑定时显示警告+绑定按钮。解绑流程:弹窗输入 OTP → `POST /auth/otp-unbind`。
|
||||
2. **`router/index.ts`**:新增路由 `{ path: '/settings', component: () => import('@/views/Settings.vue'), meta: { title: '个人设置', requiresAuth: true } }`。
|
||||
3. **`otp.ts`**:补充 `unbindOtp(otpCode)` 和 `getOtpStatus()` 的完整调用。
|
||||
|
||||
### 8. 共享知识
|
||||
|
||||
以下约定适用于所有任务、所有文件:
|
||||
|
||||
```
|
||||
## 响应契约
|
||||
- 所有 API 响应统一格式:{ code: 0, data: {...}, message: "success" }
|
||||
- apiClient 拦截器已自动解包 data 层(Scheme A),前端直接消费 inner data
|
||||
- 错误:{ code: 非0, message: "错误描述" } → 前端 catch 中取 error.message
|
||||
|
||||
## OTP 状态常量
|
||||
- require_otp: true → 已绑定 OTP,需要输入 6 位码验证
|
||||
- require_otp_bind: true → 未绑定 OTP,需要展示绑定面板(二维码 + 输入验证码)
|
||||
- mfa_enabled: true/false → 数据库字段,标记 OTP 是否已启用
|
||||
- mfa_bound_at: datetime → 首次绑定成功时间(NULL 表示从未绑定)
|
||||
- mfa_secret: string/NULL → TOTP 密钥(仅绑定过程中临时存储,解绑后清空)
|
||||
|
||||
## 端点路径规范
|
||||
- 坐席端/管理端统一使用 /auth/otp-* 前缀(新端点)
|
||||
- 旧端点 /mfa/* 、/admin/mfa/* 保留兼容但前端不再调用
|
||||
- nginx 剥离 /api 前缀后,对外即 /api/auth/otp-*
|
||||
|
||||
## 前端命名约定
|
||||
- OtpBindPanel.vue: 嵌入登录卡片内的轻量绑定面板(P0)
|
||||
- MfaBind.vue: 独立全页绑定向导(已有,保留用于 /mfa-bind 路由)
|
||||
- 按钮文案:后端用"清除绑定",坐席端用"解绑 OTP"
|
||||
|
||||
## 错误处理
|
||||
- OTP 验证码错误 → 返回 verified=false(不抛异常),前端显示红色提示,允许重试
|
||||
- 解绑时 OTP 错误 → 后端抛 AppException(INVALID_PARAMETER),前端显示错误消息
|
||||
- 已绑定用户重复调用 otp-bind → 后端拒绝,前端需先判断状态
|
||||
|
||||
## Redis Key
|
||||
- mfa:verified:{employee_id} = "1",TTL = 1800s(30 分钟验证窗口)
|
||||
- 绑定成功后写该 key(等同于已验证)
|
||||
- 解绑/管理员清除时删除该 key
|
||||
```
|
||||
|
||||
### 9. 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
T01["T01: 后端 OTP 绑定流程改造<br/>(agents.py + otp.py + schemas/mfa.py)<br/>P0"]
|
||||
T02["T02: 管理后台 OTP 管理页<br/>(mfa.ts + MfaManage.vue + Sidebar.vue + router)<br/>P0"]
|
||||
T03["T03: 坐席端登录 OTP 绑定面板<br/>(otp.ts + agent.ts + Login.vue + OtpBindPanel.vue)<br/>P0"]
|
||||
T04["T04: 坐席端设置页 OTP 管理<br/>(Settings.vue + router + otp.ts)<br/>P1"]
|
||||
|
||||
T01 --> T02
|
||||
T01 --> T03
|
||||
T03 --> T04
|
||||
```
|
||||
|
||||
> **说明**:T02 和 T03 可并行开发(均依赖 T01,互不依赖)。T04 依赖 T03(需要 `otp.ts` API 适配层)。
|
||||
|
||||
---
|
||||
|
||||
> **文档结束** — 请工程师按 T01 → T02/T03(并行) → T04 顺序实施。
|
||||
@@ -1,7 +1,10 @@
|
||||
# 00 · 标准故障排查手册
|
||||
|
||||
> **版本**: v1.0 | **日期**: 2026-07-07 | **维护人**: 宋献 / 助理
|
||||
> **定位**: 所有故障排查前**首先查看本手册**。本手册整合了原先散落的快速诊断、服务器端诊断、故障排查指南、4 份修复记录、通讯链路诊断、deploy/02 手册、调试验证指南。
|
||||
> **版本**: v1.1 | **日期**: 2026-07-08 | **维护人**: 宋献 / 助理
|
||||
> **定位**: 所有故障排查前**首先查看本手册**。
|
||||
> **最新**: 新增 CASE-20260708-01~06(OTP 路由404 / nginx 404 / 扫码角色 / 用户角色 / 员工端路由 / OTP列表结构)+ nginx 配错急救流程
|
||||
|
||||
| v1.1 | 2026-07-08 | 新增 6 天 7.8 案例 + nginx 急救流程 + 端到端验证更新 |
|
||||
> **前置阅读**: [运维手册(部署/回滚/备份/应急)](../01-项目总览/01-智能IT服务系统运维手册-20260704.md) · [SOP-04 应急响应](../10-项目管理/SOPs-标准流程/SOP-04-应急响应.md)
|
||||
|
||||
---
|
||||
@@ -163,6 +166,63 @@ docker logs wecom_it_backend | grep -i websocket
|
||||
|
||||
---
|
||||
|
||||
### CASE-20260708-01 · 后端路由 404 — OTP 统一路由未注册 ⭐
|
||||
- **现象**:`POST /api/auth/otp-bind` 返回 404;前端 OTP 绑定面板密钥和二维码不显示。
|
||||
- **根因**:部署 `otp.py` 时漏部署 `router.py`,`api_router.include_router(otp_router)` 未执行。本地 `router.py` 包含服务器不存在的模块(`knowledge_iteration` / `approval_queue` / `vision` / `ragflow_ingestion` / `automation`),导入失败导致整个 `router.py` 加载失败。
|
||||
- **修复**:上传 `router.py`,注释掉服务器上不存在的模块导入;同时修复 `otp.py` 中 `@require_role("admin")` 装饰器与显式 `current_user` 参数的冲突(服务器旧版 `require_role` 会自动追加 `current_user`)。
|
||||
- **教训**:修改路由时务必同步部署 `router.py`,否则新端点虽然代码存在但永远不会注册。
|
||||
|
||||
### CASE-20260708-02 · 管理端 API 全部 404 — nginx 正则 location proxy_pass 缺 rewrite
|
||||
- **现象**:`/api/admin/roles`、`/api/admin/dashboard/overview` 等全部返回 404。
|
||||
- **根因**:nginx 配置中 `location ~ ^/api/admin/`(正则匹配)内 `proxy_pass http://backend_api;` 不带尾部斜杠,导致 `/api/` 前缀未剥离,后端收到 `/api/admin/roles` 而非 `/admin/roles`。带尾部斜杠又会报错 `"proxy_pass" cannot have URI part in location given by regular expression`。
|
||||
- **修复**:去掉嵌套正则 location,改为统一 `location /api/` → `proxy_pass http://backend_api/;`(尾部斜杠剥离 /api/ 前缀)。
|
||||
- **教训**:nginx 中正则 location 不能直接用 `proxy_pass` 带 URI;需要时用 `rewrite` 剥离前缀。
|
||||
|
||||
### CASE-20260708-03 · sxn 扫码登录无 admin 权限 — QR 扫描写死 `roles=["agent"]`
|
||||
- **现象**:管理后台扫码登录后,OTP 管理/角色管理返回 403/无权限提示。`sxn` 账密登录正常。
|
||||
- **根因**:`auth_qrcode.py` 扫码自动确认逻辑中,`create_token` 写死了 `roles=["agent"]`,未调用 `get_user_roles()`。
|
||||
- **修复**:扫码确认改为调用 `RoleMappingService.get_user_roles()` 获取真实角色。
|
||||
- **教训**:所有登录路径(账密/扫码/OAuth)的角色获取必须统一走 `get_user_roles()`。
|
||||
|
||||
### CASE-20260708-04 · 用户角色列表为空 — `get_user_roles()` 只查 `user_roles` 表
|
||||
- **现象**:管理后台角色管理页"用户角色分配"表格为空;OTP 管理 API 403。
|
||||
- **根因**:`get_user_roles()` 仅查询 `user_roles` 表,但旧数据(包括 sxn 的 admin)只存在于 `agents.role` 字段。`user_roles` 表为空时返回 `["user"]`。
|
||||
- **修复**:`get_user_roles()` 增加 `agents.role` 回退查询;新增 `GET /admin/roles/user-roles` 端点;前端 `Roles.vue` 加载用户角色分配数据。
|
||||
- **教训**:新旧数据迁移时需确保角色数据完整同步到 `user_roles` 表。
|
||||
|
||||
### CASE-20260708-05 · 企微工作台点"IT支持服务"进坐席登录页 — 员工端口缺失
|
||||
- **现象**:企微工作台 → IT支持服务 → 显示坐席扫码登录页,而非员工 H5 页面。
|
||||
- **根因**:(1) nginx `/itdesk/` 被错误配置为 301 重定向到 `/itagent/`;(2) H5 构建文件未挂载到容器(`docker-compose.yml` 缺少 `./html/h5` 挂载);(3) 改重定向到 `/h5/` 后仍失败,因为 H5 `vite base` 是 `/itdesk/`,OAuth 回调依赖此路径。
|
||||
- **修复**:`/itdesk/` 直接 alias 到 H5 构建目录(`/usr/share/nginx/html/h5/`),不再 301 跳转;`docker-compose.yml` 添加 h5 volume 挂载;根路径 `/` 改为 302 → `/h5/`。
|
||||
- **nginx 关键配置**(alias + try_files SPA 模式):
|
||||
```
|
||||
location /itdesk/ {
|
||||
alias /usr/share/nginx/html/h5/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
注意:fallback 用 `/index.html` 而非 `/itdesk/index.html`——alias 会自动映射。
|
||||
- **教训**:前端 `vite base` 路径必须与 nginx 服务路径一致;OAuth 回调路径不能用 301 重定向。
|
||||
|
||||
### CASE-20260708-06 · 管理端 OTP 用户列表返回错误结构
|
||||
- **现象**:OTP 管理页面提示加载失败,`/auth/otp-admin-users` 返回 403。
|
||||
- **根因**:(1) `admin_list_otp_users` 返回普通数组,前端期望 `{total, items}` 结构;(2) `@require_role("admin")` 因 token 不含 admin 角色返回 403(见 CASE-03/04)。
|
||||
- **修复**:后端改为分页查询返回 `{total, items}`;增加 keyword/bound/page/page_size 参数支持。
|
||||
|
||||
### 附:nginx 配错急救流程(2026-07-08 实战总结)
|
||||
| 步骤 | 操作 |
|
||||
|------|------|
|
||||
| 1 | 备份:`sudo cp /opt/wecom-it-desk/nginx/nginx.conf /tmp/nginx.bak` |
|
||||
| 2 | 修改宿主文件后必须 `docker stop nginx && docker start nginx`(`reload` 有时不生效) |
|
||||
| 3 | 验证容器内配置已同步:`docker exec wecom_it_nginx grep 关键字 /etc/nginx/nginx.conf` |
|
||||
| 4 | 确认语法:`docker exec wecom_it_nginx nginx -t` |
|
||||
| 5 | 检查容器状态:`docker ps --filter name=wecom_it_nginx` |
|
||||
| 6 | 用 `agent-browser` 打开 URL 做端到端验证 |
|
||||
| 7 | 避免用 sed 修改 nginx 配置——`$uri`/`$host` 等变量会被 shell 解释;用 Python 脚本或直接上传文件 |
|
||||
|
||||
---
|
||||
|
||||
## 5 端到端验证完成标准(原《调试验证指南》整合)
|
||||
|
||||
> 宣布完成前,按 §0.2 提供真实证据。
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — Nginx 配置(生产环境 — 2026-07-08 三端正常基准)
|
||||
# =============================================================================
|
||||
# 状态:坐席端 /itagent/ + 管理端 /itadmin/ + 员工端 /itdesk/ 三端正常
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
set_real_ip_from 10.212.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 50m;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/javascript application/xml+rss
|
||||
application/json application/ld+json;
|
||||
|
||||
upstream backend_api {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
location /h5/ {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri /h5/index.html;
|
||||
}
|
||||
location /h5/api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
server_tokens off;
|
||||
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# === 员工端 — H5 直接服务,不能 301 重定向(OAuth 回调依赖此路径)===
|
||||
location /itdesk/ {
|
||||
alias /usr/share/nginx/html/h5/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# === 坐席工作台 ===
|
||||
location /itagent/ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
alias /usr/share/nginx/html/itagent/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# === 管理后台 ===
|
||||
location /itadmin/ {
|
||||
alias /usr/share/nginx/html/itadmin/;
|
||||
index index.html;
|
||||
try_files $uri /itadmin/index.html;
|
||||
}
|
||||
|
||||
# === 统一入口(已弃用)===
|
||||
location /itportal/ {
|
||||
alias /usr/share/nginx/html/itportal/;
|
||||
index index.html;
|
||||
try_files $uri /itportal/index.html;
|
||||
}
|
||||
|
||||
# === 后端 API — /api/ 前缀由 proxy_pass 尾部斜杠剥离 ===
|
||||
location /api/ {
|
||||
proxy_pass http://backend_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# === WebSocket — 不能带尾部斜杠 ===
|
||||
location /ws/ {
|
||||
access_log off;
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# === H5 静态文件 ===
|
||||
location /h5/ {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri /h5/index.html;
|
||||
}
|
||||
|
||||
# === H5 API 代理 ===
|
||||
location /h5/api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# === 根路径 → H5 员工端 ===
|
||||
location = / {
|
||||
return 302 /h5/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 管理后台 MFA 管理 API 适配层 (Phase 2.4)
|
||||
// 企微IT智能服务台 — 管理后台 OTP 管理 API 适配层 (Phase 2.4 → 迁移)
|
||||
// =============================================================================
|
||||
// 说明:封装 /api/admin/mfa/* 管理员视角的端点
|
||||
// 对应后端: backend/app/api/mfa.py (Phase 2.1, task #17) admin_router 部分
|
||||
// 说明:封装 /api/auth/otp-admin-* 管理员视角的端点
|
||||
// 对应后端: backend/app/api/auth.py admin OTP 端点
|
||||
//
|
||||
// 管理员端点:
|
||||
// GET /api/admin/mfa/users — 列出所有用户 MFA 状态
|
||||
// POST /api/admin/mfa/reset/{employee_id} — 重置指定用户 MFA(丢手机兜底)
|
||||
// GET /api/auth/otp-admin-users — 列出所有用户 OTP 状态
|
||||
// POST /api/auth/otp-admin-reset/{employee_id} — 清除指定用户 OTP 绑定(丢手机兜底)
|
||||
//
|
||||
// 用户视角的 5 个端点(/api/mfa/*)由 frontend-agent 端 mfa.ts 封装
|
||||
// 管理后台如需代理用户操作(管理员自己绑定)也可引用 frontend-agent 的 API
|
||||
// 用户视角的端点(/api/auth/otp-*)由 frontend-agent 端 otp.ts 封装
|
||||
//
|
||||
// 鉴权:
|
||||
// - 全部用 require_role("admin")(管理员)
|
||||
// - 响应格式: {code: 0, data: {}, message: "success"} 业务码 0 表示成功
|
||||
// - CTRT 拦截器已解包,API 函数直接返回 inner data
|
||||
//
|
||||
// 典型管理员场景:
|
||||
// 1. 进入 /mfa-manage → 调 GET /api/admin/mfa/users → 表格展示
|
||||
// 2. 搜索 + 过滤 + 分页(支持按 bound/姓名/employee_id)
|
||||
// 3. 点"重置 MFA" → 调 POST /api/admin/mfa/reset/{employee_id}
|
||||
// 4. 弹 ElMessageBox 二次确认(防误操作) → 调重置端点
|
||||
// 1. 进入 /mfa-manage → 调 GET /api/auth/otp-admin-users → 表格展示
|
||||
// 2. 搜索 + 过滤 + 分页(支持按 mfa_enabled/姓名/employee_id)
|
||||
// 3. 点"清除绑定" → 调 POST /api/auth/otp-admin-reset/{employee_id}
|
||||
// 4. 弹 ElMessageBox 二次确认(防误操作) → 调清除端点
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 单个用户的 MFA 状态条目 */
|
||||
/** 单个用户的 OTP 状态条目 */
|
||||
export interface MfaUserStatus {
|
||||
/** 员工 ID(企微 userid) */
|
||||
employee_id: string
|
||||
@@ -37,19 +36,17 @@ export interface MfaUserStatus {
|
||||
name?: string
|
||||
/** 角色列表 */
|
||||
roles?: string[]
|
||||
/** 是否已绑定 MFA */
|
||||
bound: boolean
|
||||
/** 是否已启用 MFA(与 bound 等价) */
|
||||
enabled: boolean
|
||||
/** 是否已绑定 OTP (MFA) */
|
||||
mfa_enabled: boolean
|
||||
/** 首次绑定时间(ISO 8601,可空) */
|
||||
bound_at?: string | null
|
||||
mfa_bound_at?: string | null
|
||||
/** 最近一次验证成功时间(ISO 8601,可空) */
|
||||
last_verified_at?: string | null
|
||||
}
|
||||
|
||||
/** GET /api/admin/mfa/users 响应 */
|
||||
/** GET /api/auth/otp-admin-users 响应 */
|
||||
export interface MfaUserListData {
|
||||
/** 用户 MFA 状态列表 */
|
||||
/** 用户 OTP 状态列表 */
|
||||
items: MfaUserStatus[]
|
||||
/** 总数 */
|
||||
total: number
|
||||
@@ -59,7 +56,7 @@ export interface MfaUserListData {
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** GET /api/admin/mfa/users 查询参数 */
|
||||
/** GET /api/auth/otp-admin-users 查询参数 */
|
||||
export interface MfaUserListParams {
|
||||
/** 按姓名或 employee_id 模糊搜索 */
|
||||
keyword?: string
|
||||
@@ -71,9 +68,9 @@ export interface MfaUserListParams {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** POST /api/admin/mfa/reset/{employee_id} 响应 */
|
||||
/** POST /api/auth/otp-admin-reset/{employee_id} 响应 */
|
||||
export interface MfaAdminResetData {
|
||||
/** 重置是否成功 */
|
||||
/** 清除是否成功 */
|
||||
success: boolean
|
||||
}
|
||||
|
||||
@@ -82,30 +79,30 @@ export interface MfaAdminResetData {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 1) 列出所有用户的 MFA 状态(支持搜索 + 过滤 + 分页)
|
||||
* 1) 列出所有用户的 OTP 状态(支持搜索 + 过滤 + 分页)
|
||||
* 管理员在 /mfa-manage 页面调这个
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 分页数据
|
||||
* @returns 分页数据(CTRT 已解包,直接返回 inner data)
|
||||
*/
|
||||
export async function listMfaUsers(
|
||||
params: MfaUserListParams = {}
|
||||
): Promise<MfaUserListData> {
|
||||
const response: AxiosResponse = await apiClient.get('/admin/mfa/users', { params })
|
||||
const response: MfaUserListData = await apiClient.get('/auth/otp-admin-users', { params })
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) 重置指定员工的 MFA 绑定(管理员特权,无 OTP 验证)
|
||||
* 2) 清除指定员工的 OTP 绑定(管理员特权,无 OTP 验证)
|
||||
* 使用场景:
|
||||
* - 员工丢手机/换手机 → 管理员在后台"重置 MFA"按钮
|
||||
* - 员工丢手机/换手机 → 管理员在后台"清除绑定"按钮
|
||||
*
|
||||
* @param employeeId 员工 ID(企微 userid)
|
||||
* @returns 重置结果
|
||||
* @returns 清除结果(CTRT 已解包,直接返回 inner data)
|
||||
*/
|
||||
export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/admin/mfa/reset/${encodeURIComponent(employeeId)}`
|
||||
const response: MfaAdminResetData = await apiClient.post(
|
||||
`/auth/otp-admin-reset/${encodeURIComponent(employeeId)}`
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>角色管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/mfa-manage">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>OTP 管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 🔗 系统集成 -->
|
||||
<div class="menu-section-title">🔗 系统集成</div>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,51 +25,55 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
|
||||
<p class="login-subtitle">管理后台</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式选择(企微已登录时显示) -->
|
||||
<div v-if="showLoginOptions" class="login-options">
|
||||
<p class="login-options-title">选择登录方式</p>
|
||||
<div class="login-options-btns">
|
||||
<!-- 企微免密登录(仅企微内管理员可见) -->
|
||||
<div v-if="wecomUserId" style="text-align: center; margin-bottom: 12px;">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="option-btn"
|
||||
:loading="wecomQuickLoading"
|
||||
@click="handleWecomQuickLogin"
|
||||
class="option-btn"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-icon><Key /></el-icon>
|
||||
企微免密登录
|
||||
企微免密登录({{ wecomUserId }})
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 企微扫码登录面板(始终可见,与坐席端一致) -->
|
||||
<div class="qr-login">
|
||||
<div class="qr-container">
|
||||
<div v-if="qrLoading" class="qr-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
<div v-else-if="qrCode" class="qr-code">
|
||||
<img :src="qrCode" alt="企微扫码登录" />
|
||||
</div>
|
||||
<div v-else class="qr-error">
|
||||
<p>获取二维码失败</p>
|
||||
<el-button size="small" @click="fetchQrCode">重试</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-hint">请使用企业微信扫码登录</p>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="divider">
|
||||
<span>其他登录方式</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
class="option-btn"
|
||||
@click="showQrLogin"
|
||||
class="password-login-btn"
|
||||
@click="showPasswordPanel = true"
|
||||
>
|
||||
<el-icon><Key /></el-icon>
|
||||
企微扫码登录
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
class="option-btn"
|
||||
@click="showPasswordLogin"
|
||||
>
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>🔐</span>
|
||||
账号密码登录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企微扫码登录面板 -->
|
||||
<div v-if="showQrPanel" class="qr-login">
|
||||
<p class="qr-title">企微扫码登录</p>
|
||||
<div class="qr-code">
|
||||
<img v-if="qrCode" :src="qrCode" alt="扫码登录二维码" />
|
||||
<div v-else class="qr-loading">加载中...</div>
|
||||
</div>
|
||||
<p class="qr-hint">请使用企业微信扫码登录</p>
|
||||
<el-button link type="primary" @click="showPasswordLogin">其他登录方式</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<!-- 账号密码登录表单(与二维码同时展示,无需返回按钮) -->
|
||||
<div v-if="showPasswordPanel" class="password-login">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="loginForm"
|
||||
@@ -124,6 +128,7 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
@@ -149,12 +154,12 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
||||
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
||||
|
||||
// ==========================================================================
|
||||
// Store
|
||||
@@ -186,14 +191,8 @@ const errorMsg = ref<string>('')
|
||||
// 企微智能检测相关状态(PRD v1.5 §4.5)
|
||||
// ==========================================================================
|
||||
|
||||
/** 是否显示登录方式选择 */
|
||||
const showLoginOptions = ref(false)
|
||||
|
||||
/** 是否显示扫码登录面板 */
|
||||
const showQrPanel = ref(false)
|
||||
|
||||
/** 是否显示账号密码登录 */
|
||||
const showPasswordPanel = ref(true)
|
||||
const showPasswordPanel = ref(false)
|
||||
|
||||
/** 企微用户ID(用于免密登录) */
|
||||
const wecomUserId = ref('')
|
||||
@@ -204,6 +203,14 @@ const wecomQuickLoading = ref(false)
|
||||
/** 企微二维码 */
|
||||
const qrCode = ref('')
|
||||
|
||||
/** 二维码加载中 */
|
||||
const qrLoading = ref(false)
|
||||
|
||||
/** 轮询定时器 */
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
/** 当前二维码票据 */
|
||||
let currentTicket = ''
|
||||
|
||||
/** 企微检测中 */
|
||||
const wecomChecking = ref(false)
|
||||
|
||||
@@ -236,10 +243,8 @@ async function checkWecomClient(): Promise<void> {
|
||||
// 首先尝试使用 UA 检测
|
||||
const isWxWork = /wxwork/i.test(navigator.userAgent)
|
||||
|
||||
// 不在企微环境,直接显示账号密码登录
|
||||
// 不在企微环境
|
||||
if (!isWxWork) {
|
||||
showLoginOptions.value = false
|
||||
showPasswordPanel.value = true
|
||||
wecomChecking.value = false
|
||||
return
|
||||
}
|
||||
@@ -256,7 +261,7 @@ async function checkWecomClient(): Promise<void> {
|
||||
params: { url: currentUrl }
|
||||
})
|
||||
|
||||
const jsConfig = configResponse
|
||||
const jsConfig: any = configResponse
|
||||
console.log('[Admin Login] 获取到企微 JS-SDK 配置:', jsConfig)
|
||||
|
||||
// 使用 wx.config 配置企微 JS-SDK
|
||||
@@ -296,34 +301,26 @@ async function checkWecomClient(): Promise<void> {
|
||||
const roleResponse = await apiClient.get('/wecom/check-role', {
|
||||
params: { userid: userId }
|
||||
})
|
||||
const roleData = roleResponse
|
||||
const roleData: any = roleResponse
|
||||
console.log('[Admin Login] 用户角色:', roleData)
|
||||
|
||||
// 如果是管理员,显示免密登录选项
|
||||
if (roleData.role === 'admin') {
|
||||
showLoginOptions.value = true
|
||||
showPasswordPanel.value = false
|
||||
// 企微内管理员可免密登录(按钮将显示在顶部)
|
||||
} else {
|
||||
// 非管理员,显示普通登录
|
||||
showLoginOptions.value = false
|
||||
showPasswordPanel.value = true
|
||||
// 非管理员,使用二维码和密码登录
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 检测失败,显示普通登录
|
||||
console.warn('[Admin Login] 企微 JS-SDK 检测失败:', e)
|
||||
// 检测失败,显示登录选项
|
||||
showLoginOptions.value = true
|
||||
showPasswordPanel.value = true
|
||||
}
|
||||
} else {
|
||||
// 没有企微 JS-SDK
|
||||
showLoginOptions.value = true
|
||||
showPasswordPanel.value = true
|
||||
console.log('[Admin Login] 无企微 JS-SDK,使用默认登录')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('企微客户端检测失败:', error)
|
||||
showLoginOptions.value = false
|
||||
showPasswordPanel.value = true
|
||||
} finally {
|
||||
wecomChecking.value = false
|
||||
}
|
||||
@@ -338,14 +335,12 @@ async function handleWecomQuickLogin(): Promise<void> {
|
||||
try {
|
||||
const apiClient = (await import('@/api')).default
|
||||
|
||||
// 调用后端企微免密登录接口
|
||||
const response = await apiClient.post('/auth_wecom/jsdk-login', {
|
||||
// 拦截器已统一返回 inner data(CTRT-03),response 即 {token, employee_id, name, ...}
|
||||
const result: any = await apiClient.post('/auth_wecom/jsdk-login', {
|
||||
userid: wecomUserId.value,
|
||||
login_source: 'wecom_jsdk_admin'
|
||||
})
|
||||
|
||||
const result = response
|
||||
|
||||
if (result?.token) {
|
||||
const { token, employee_id, name } = result
|
||||
|
||||
@@ -373,36 +368,72 @@ async function handleWecomQuickLogin(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示扫码登录
|
||||
* 获取扫码登录二维码
|
||||
*/
|
||||
async function showQrLogin(): Promise<void> {
|
||||
showLoginOptions.value = false
|
||||
showQrPanel.value = true
|
||||
showPasswordPanel.value = false
|
||||
|
||||
// 获取二维码
|
||||
async function fetchQrCode(): Promise<void> {
|
||||
qrLoading.value = true
|
||||
try {
|
||||
const apiClient = (await import('@/api')).default
|
||||
const response = await apiClient.post('/auth_qrcode/create')
|
||||
const result = response
|
||||
if (result.code === 0) {
|
||||
qrCode.value = result.data.qrcode_png_base64
|
||||
? `data:image/png;base64,${result.data.qrcode_png_base64}`
|
||||
: result.data.qrcode_url
|
||||
const result: any = await apiClient.post('/auth_qrcode/create')
|
||||
if (result.qrcode_png_base64) {
|
||||
qrCode.value = `data:image/png;base64,${result.qrcode_png_base64}`
|
||||
} else if (result.qrcode_url) {
|
||||
qrCode.value = result.qrcode_url
|
||||
}
|
||||
// 保存票据并启动轮询
|
||||
if (result.ticket) {
|
||||
currentTicket = result.ticket
|
||||
startPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取二维码失败:', error)
|
||||
} finally {
|
||||
qrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示账号密码登录
|
||||
* 轮询扫码状态
|
||||
*/
|
||||
function showPasswordLogin(): Promise<void> {
|
||||
showLoginOptions.value = false
|
||||
showQrPanel.value = false
|
||||
showPasswordPanel.value = true
|
||||
return Promise.resolve()
|
||||
async function pollQrCode(): Promise<void> {
|
||||
if (!currentTicket) return
|
||||
try {
|
||||
const apiClient = (await import('@/api')).default
|
||||
const result: any = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
|
||||
if (!result) return
|
||||
|
||||
const { status, token, employee_id, name } = result
|
||||
|
||||
if (status === 'confirmed' && token) {
|
||||
stopPolling()
|
||||
localStorage.setItem('admin_token', token)
|
||||
if (employee_id) localStorage.setItem('admin_user_id', employee_id)
|
||||
adminStore.token = token
|
||||
adminStore.adminUserId = employee_id
|
||||
ElMessage.success('扫码登录成功')
|
||||
router.push('/')
|
||||
} else if (status === 'expired') {
|
||||
stopPolling()
|
||||
qrCode.value = ''
|
||||
ElMessage.warning('二维码已过期,请刷新')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('轮询扫码状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动轮询(每 2 秒) */
|
||||
function startPolling(): void {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(pollQrCode, 2000)
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,9 +466,15 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检测企微客户端状态
|
||||
// 页面加载时检测企微客户端状态 + 获取二维码
|
||||
onMounted(() => {
|
||||
checkWecomClient()
|
||||
fetchQrCode()
|
||||
})
|
||||
|
||||
// 页面卸载时停止轮询
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -590,4 +627,43 @@ onMounted(() => {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 二维码容器 */
|
||||
.qr-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qr-error {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.divider span {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 账号密码登录按钮 */
|
||||
.password-login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 账号密码登录面板 */
|
||||
.password-login {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — MFA 管理页 (Phase 2.4, task #20)
|
||||
企微IT智能服务台 — OTP 管理页 (Phase 2.4, task #20 → 迁移)
|
||||
=============================================================================
|
||||
说明:管理员管理所有用户 MFA 绑定的页面
|
||||
说明:管理员管理所有用户 OTP 绑定的页面
|
||||
|
||||
功能:
|
||||
- 表格列出所有用户的 MFA 状态(已绑/未绑)
|
||||
- 表格列出所有用户的 OTP 状态(已绑/未绑)
|
||||
- 搜索(姓名/employee_id)+ 过滤(已绑/未绑/全部)+ 分页
|
||||
- "重置 MFA" 按钮 → 调 POST /api/admin/mfa/reset/{employee_id}
|
||||
- "清除绑定" 按钮 → 调 POST /api/auth/otp-admin-reset/{employee_id}
|
||||
(无 OTP 验证,管理员特权,用于员工丢手机兜底)
|
||||
- 重置前 ElMessageBox 二次确认(防误操作)
|
||||
- 清除前 ElMessageBox 二次确认(防误操作)
|
||||
|
||||
设计要点:
|
||||
- 不在表格里直接显示 secret(安全考虑)
|
||||
- 状态列用 el-tag 颜色区分:已绑=success,未绑=info
|
||||
- 重置按钮在已绑行才显示(未绑无需重置)
|
||||
- 清除按钮仅在 mfa_enabled=true 时显示(未绑无需清除)
|
||||
- "最近验证时间"列给管理员做审计参考
|
||||
-->
|
||||
<template>
|
||||
@@ -22,8 +22,8 @@
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">MFA 管理</div>
|
||||
<div class="page-desc">管理所有用户的动态令牌(MFA)绑定状态,丢手机兜底重置</div>
|
||||
<div class="page-title">OTP 管理</div>
|
||||
<div class="page-desc">管理所有用户的动态令牌(OTP)绑定状态,丢手机兜底清除</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 用户 MFA 状态表格 -->
|
||||
<!-- 用户 OTP 状态表格 -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="data-table-wrapper">
|
||||
<el-table
|
||||
@@ -99,16 +99,16 @@
|
||||
<span v-if="!row.roles || row.roles.length === 0" class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="MFA 状态" min-width="100">
|
||||
<el-table-column label="OTP 状态" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.bound ? 'success' : 'info'" size="small">
|
||||
{{ row.bound ? '已绑定' : '未绑定' }}
|
||||
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
|
||||
{{ row.mfa_enabled ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bound_at" label="首次绑定时间" min-width="160">
|
||||
<el-table-column prop="mfa_bound_at" label="首次绑定时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.bound_at">{{ formatTime(row.bound_at) }}</span>
|
||||
<span v-if="row.mfa_bound_at">{{ formatTime(row.mfa_bound_at) }}</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -121,14 +121,14 @@
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.bound"
|
||||
v-if="row.mfa_enabled"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
:disabled="resettingId === row.employee_id"
|
||||
@click="handleResetMfa(row)"
|
||||
>
|
||||
{{ resettingId === row.employee_id ? '重置中...' : '重置 MFA' }}
|
||||
{{ resettingId === row.employee_id ? '清除中...' : '清除绑定' }}
|
||||
</el-button>
|
||||
<span v-else class="text-muted">无需操作</span>
|
||||
</template>
|
||||
@@ -168,7 +168,7 @@ import type { MfaUserStatus } from '@/api/mfa'
|
||||
// ============================================================================
|
||||
const loading = ref<boolean>(false)
|
||||
const users = ref<MfaUserStatus[]>([])
|
||||
const resettingId = ref<string>('') // 正在重置的 employee_id(给按钮 loading 用)
|
||||
const resettingId = ref<string>('') // 正在清除的 employee_id(给按钮 loading 用)
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
@@ -185,10 +185,10 @@ const pagination = reactive({
|
||||
// 计算:已绑/未绑数量(基于当前页数据)
|
||||
// ============================================================================
|
||||
const boundCount = computed<number>(() =>
|
||||
users.value.filter((u) => u.bound).length
|
||||
users.value.filter((u) => u.mfa_enabled).length
|
||||
)
|
||||
const unboundCount = computed<number>(() =>
|
||||
users.value.filter((u) => !u.bound).length
|
||||
users.value.filter((u) => !u.mfa_enabled).length
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
@@ -216,7 +216,7 @@ async function loadUsers(): Promise<void> {
|
||||
// 静默失败,使用空数据
|
||||
users.value = []
|
||||
pagination.total = 0
|
||||
const msg = err?.response?.data?.message || err?.message || '加载用户 MFA 列表失败'
|
||||
const msg = err?.response?.data?.message || err?.message || '加载用户 OTP 列表失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -244,18 +244,18 @@ function handleSizeChange(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定用户的 MFA
|
||||
* 清除指定用户的 OTP 绑定
|
||||
* 二次确认 → 调 API → 刷新列表
|
||||
*/
|
||||
async function handleResetMfa(row: MfaUserStatus): Promise<void> {
|
||||
const name = row.name || row.employee_id
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认重置 ${name} 的 MFA 绑定?\n\n重置后该用户需要重新绑定才能使用 MFA 功能(用于员工丢手机兜底)。\n此操作不可恢复,请谨慎操作。`,
|
||||
'重置 MFA 确认',
|
||||
`确认清除 OTP 绑定?\n\n坐席「${name} (${row.employee_id})」的 OTP 二次验证将被清除。\n该坐席下次登录时需重新绑定。`,
|
||||
'清除 OTP 绑定确认',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认重置',
|
||||
confirmButtonText: '确认清除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
}
|
||||
@@ -268,11 +268,11 @@ async function handleResetMfa(row: MfaUserStatus): Promise<void> {
|
||||
resettingId.value = row.employee_id
|
||||
try {
|
||||
await resetMfa(row.employee_id)
|
||||
ElMessage.success(`已重置 ${name} 的 MFA 绑定`)
|
||||
ElMessage.success(`已清除 ${name} 的 OTP 绑定`)
|
||||
// 刷新当前页
|
||||
await loadUsers()
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || '重置失败'
|
||||
const msg = err?.response?.data?.message || err?.message || '清除失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
resettingId.value = ''
|
||||
|
||||
@@ -350,6 +350,7 @@ import {
|
||||
assignRole,
|
||||
revokeRole,
|
||||
getRoleMappingRules,
|
||||
getUserRoleAssignments,
|
||||
createRoleMappingRule,
|
||||
deleteRoleMappingRule,
|
||||
} from '@/api/admin'
|
||||
@@ -409,14 +410,15 @@ const ruleForm = reactive({
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rolesRes, rulesRes] = await Promise.all([
|
||||
const [rolesRes, rulesRes, userRolesRes] = await Promise.all([
|
||||
getRoles(),
|
||||
getRoleMappingRules(),
|
||||
getUserRoleAssignments(),
|
||||
])
|
||||
roles.value = rolesRes.data.data
|
||||
mappingRules.value = rulesRes.data.data
|
||||
userRoles.value = userRolesRes.data.data || []
|
||||
} catch {
|
||||
// 使用默认 demo 数据
|
||||
roles.value = getDefaultRoles()
|
||||
mappingRules.value = getDefaultMappingRules()
|
||||
} finally {
|
||||
@@ -657,12 +659,14 @@ async function handleDeleteRule(rule: RoleMappingRule): Promise<void> {
|
||||
/** 刷新全部数据 */
|
||||
async function loadData(): Promise<void> {
|
||||
try {
|
||||
const [rolesRes, rulesRes] = await Promise.all([
|
||||
const [rolesRes, rulesRes, userRolesRes] = await Promise.all([
|
||||
getRoles(),
|
||||
getRoleMappingRules(),
|
||||
getUserRoleAssignments(),
|
||||
])
|
||||
roles.value = rolesRes.data.data
|
||||
mappingRules.value = rulesRes.data.data
|
||||
userRoles.value = userRolesRes.data.data || []
|
||||
} catch {
|
||||
// 静默失败,保留现有数据
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — OTP 绑定 API 适配层 (T03)
|
||||
// =============================================================================
|
||||
// 说明:封装 /auth/otp-* 端点,供坐席端登录绑定面板使用
|
||||
// 对应后端:backend/app/api/auth.py (OTP 绑定/验证/状态/解绑)
|
||||
//
|
||||
// 端点:
|
||||
// POST /auth/otp-bind — 生成 secret + 二维码(首次绑定)
|
||||
// POST /auth/otp-verify — 输入 OTP 完成绑定/验证
|
||||
// GET /auth/otp-status — 查询 OTP 绑定状态
|
||||
// POST /auth/otp-unbind — 解绑 OTP(需当前 OTP 码)
|
||||
//
|
||||
// 响应契约(CTRT):apiClient 拦截器已自动解包 inner data,
|
||||
// 前端直接消费返回字段,无需访问 .data / .data.data。
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** POST /auth/otp-bind 响应 */
|
||||
export interface OtpBindData {
|
||||
/** TOTP 共享密钥(base32) */
|
||||
secret: string
|
||||
/** otpauth:// URI */
|
||||
otpauth_url: string
|
||||
/** 二维码 PNG base64(不含 data: 前缀) */
|
||||
qr_code_base64: string
|
||||
}
|
||||
|
||||
/** POST /auth/otp-verify 请求体 */
|
||||
export interface OtpVerifyRequest {
|
||||
/** 6 位 OTP 动态码 */
|
||||
otp_code: string
|
||||
}
|
||||
|
||||
/** POST /auth/otp-verify 响应 */
|
||||
export interface OtpVerifyData {
|
||||
/** 验证是否通过 */
|
||||
verified: boolean
|
||||
/** 登录 token(首次绑定成功后返回) */
|
||||
token?: string
|
||||
/** 用户 ID */
|
||||
user_id?: string
|
||||
/** 用户姓名 */
|
||||
name?: string
|
||||
/** 用户角色 */
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** GET /auth/otp-status 响应 */
|
||||
export interface OtpStatusData {
|
||||
/** 是否已绑定 */
|
||||
bound: boolean
|
||||
/** 是否已启用 */
|
||||
enabled: boolean
|
||||
/** 最近一次验证成功时间(ISO 8601,可空) */
|
||||
last_verified_at?: string | null
|
||||
}
|
||||
|
||||
/** POST /auth/otp-unbind 请求体 */
|
||||
export interface OtpUnbindRequest {
|
||||
/** 6 位 OTP 动态码(解绑前需验证当前 OTP) */
|
||||
otp_code: string
|
||||
}
|
||||
|
||||
/** POST /auth/otp-unbind 响应 */
|
||||
export interface OtpUnbindData {
|
||||
/** 解绑是否成功 */
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 1) 绑定 OTP — 生成 secret + 二维码
|
||||
* 坐席首次登录时调用,获取 TOTP 密钥和二维码
|
||||
*
|
||||
* 注意:此端点要求先通过账密验证(后端会校验临时凭证)
|
||||
*
|
||||
* @returns OTP 绑定信息(secret + otpauth_url + base64 PNG)
|
||||
*/
|
||||
export async function bindOtp(): Promise<OtpBindData> {
|
||||
const response: AxiosResponse = await apiClient.post('/auth/otp-bind')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) 验证 OTP 并完成绑定
|
||||
* 用户扫码后输入 6 位验证码,验证通过后完成绑定
|
||||
* 如果是登录流程中的首次绑定,返回 token 等登录信息
|
||||
*
|
||||
* @param otpCode - 6 位 OTP 动态码
|
||||
* @returns 验证结果(verified + 可选的登录 token)
|
||||
*/
|
||||
export async function verifyOtp(otpCode: string): Promise<OtpVerifyData> {
|
||||
const body: OtpVerifyRequest = { otp_code: otpCode }
|
||||
const response: AxiosResponse = await apiClient.post('/auth/otp-verify', body)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) 查询 OTP 绑定状态
|
||||
* 用于路由守卫或设置页判断当前用户的 OTP 状态
|
||||
*
|
||||
* @returns OTP 状态(bound + enabled + last_verified_at)
|
||||
*/
|
||||
export async function getOtpStatus(): Promise<OtpStatusData> {
|
||||
const response: AxiosResponse = await apiClient.get('/auth/otp-status')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 4) 解绑 OTP
|
||||
* 用户主动关闭 OTP,需先输入当前 OTP 码确认
|
||||
*
|
||||
* @param otpCode - 6 位 OTP 动态码(防误操作)
|
||||
* @returns 解绑结果
|
||||
*/
|
||||
export async function unbindOtp(otpCode: string): Promise<OtpUnbindData> {
|
||||
const body: OtpUnbindRequest = { otp_code: otpCode }
|
||||
const response: AxiosResponse = await apiClient.post('/auth/otp-unbind', body)
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
<!-- =============================================================================
|
||||
// IT智能服务台 — OTP 首次绑定面板 (T03)
|
||||
// =============================================================================
|
||||
// 说明:坐席首次登录时展示的 OTP 绑定面板,嵌入登录卡片内。
|
||||
// 功能:
|
||||
// - 展示二维码(供 Authenticator 扫码)
|
||||
// - 展示 secret 密钥(手动输入备用)
|
||||
// - 6 位验证码输入 + 验证并完成绑定
|
||||
// - P0 阶段不显示"暂不绑定"按钮(P1 再加)
|
||||
//
|
||||
// Props: userId, name
|
||||
// Emits: bind-success(token, userId, name, role), cancel
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="otp-bind-panel">
|
||||
<!-- 标题 -->
|
||||
<div class="bind-title">
|
||||
<h2>🔐 首次登录 — 绑定 OTP 二次验证</h2>
|
||||
<p v-if="name" class="bind-subtitle">欢迎,{{ name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="bind-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>正在生成密钥...</p>
|
||||
</div>
|
||||
|
||||
<!-- 绑定内容 -->
|
||||
<div v-else class="bind-content">
|
||||
<!-- 二维码 -->
|
||||
<div class="qr-section">
|
||||
<div class="qr-wrapper">
|
||||
<img
|
||||
v-if="qrCodeBase64"
|
||||
:src="'data:image/png;base64,' + qrCodeBase64"
|
||||
alt="OTP 二维码"
|
||||
class="qr-image"
|
||||
/>
|
||||
<div v-else class="qr-placeholder">
|
||||
<el-icon :size="48"><PictureFilled /></el-icon>
|
||||
<p>二维码加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-hint">请使用 Google Authenticator 或 Microsoft Authenticator 扫码</p>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="divider-line">
|
||||
<span>或手动输入密钥</span>
|
||||
</div>
|
||||
|
||||
<!-- Secret 密钥展示 -->
|
||||
<div class="secret-section">
|
||||
<div class="secret-display">
|
||||
<code class="secret-text">{{ secret }}</code>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="copied ? 'success' : 'default'"
|
||||
class="copy-btn"
|
||||
@click="copySecret"
|
||||
>
|
||||
{{ copied ? '✅ 已复制' : '📋 复制' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div class="verify-section">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="verifyForm"
|
||||
:rules="verifyRules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleVerify"
|
||||
>
|
||||
<el-form-item label="验证码" prop="otpCode">
|
||||
<el-input
|
||||
v-model="verifyForm.otpCode"
|
||||
placeholder="请输入 6 位验证码"
|
||||
size="large"
|
||||
maxlength="6"
|
||||
show-word-limit
|
||||
@keydown.enter="handleVerify"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="bind-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="verifying"
|
||||
:disabled="verifying || verifyForm.otpCode.length !== 6"
|
||||
class="verify-btn"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ verifying ? '验证中...' : '验证并完成绑定' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- P0: 不显示"暂不绑定"按钮(P1 再加) -->
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="errorMsg = ''"
|
||||
class="bind-error"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Loading, PictureFilled } from '@element-plus/icons-vue'
|
||||
import { bindOtp, verifyOtp } from '@/api/otp'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props & Emits
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const props = defineProps<{
|
||||
/** 用户 ID */
|
||||
userId: string
|
||||
/** 用户姓名 */
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 绑定成功事件 */
|
||||
(e: 'bind-success', token: string, userId: string, name: string, role: string): void
|
||||
/** 取消绑定事件 */
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 状态
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 加载中(获取二维码) */
|
||||
const loading = ref(true)
|
||||
|
||||
/** 验证中 */
|
||||
const verifying = ref(false)
|
||||
|
||||
/** 二维码 base64 */
|
||||
const qrCodeBase64 = ref('')
|
||||
|
||||
/** TOTP 密钥 */
|
||||
const secret = ref('')
|
||||
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref<string>('')
|
||||
|
||||
/** 复制状态 */
|
||||
const copied = ref(false)
|
||||
|
||||
/** 验证码表单 */
|
||||
const verifyForm = reactive({
|
||||
otpCode: '',
|
||||
})
|
||||
|
||||
/** 验证码校验规则 */
|
||||
const verifyRules: FormRules = {
|
||||
otpCode: [
|
||||
{ required: true, message: '请输入 6 位验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^\d{6}$/,
|
||||
message: '验证码只能包含数字',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOtpBind()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取 OTP 绑定信息(密钥 + 二维码)
|
||||
*/
|
||||
async function fetchOtpBind(): Promise<void> {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const data = await bindOtp()
|
||||
secret.value = data.secret
|
||||
qrCodeBase64.value = data.qr_code_base64
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '获取绑定信息失败'
|
||||
console.error('[OtpBindPanel] 获取绑定信息失败:', error)
|
||||
errorMsg.value = errMsg
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制密钥到剪贴板
|
||||
*/
|
||||
async function copySecret(): Promise<void> {
|
||||
if (!secret.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
copied.value = true
|
||||
ElMessage.success('密钥已复制到剪贴板')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
// 降级方案:使用传统方式复制
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = secret.value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
copied.value = true
|
||||
ElMessage.success('密钥已复制到剪贴板')
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制密钥')
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 OTP 并完成绑定
|
||||
*/
|
||||
async function handleVerify(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
verifying.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const data = await verifyOtp(verifyForm.otpCode.trim())
|
||||
|
||||
if (data.verified) {
|
||||
ElMessage.success('OTP 绑定成功')
|
||||
emit(
|
||||
'bind-success',
|
||||
data.token || '',
|
||||
data.user_id || props.userId,
|
||||
data.name || props.name,
|
||||
data.role || ''
|
||||
)
|
||||
} else {
|
||||
// verified=false:验证码错误,不抛异常,提示用户重试
|
||||
errorMsg.value = '验证码错误,请重新输入'
|
||||
verifyForm.otpCode = ''
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '验证失败,请重试'
|
||||
console.error('[OtpBindPanel] 验证失败:', error)
|
||||
errorMsg.value = errMsg
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
企微浅色扁平风格(accent #07C160)
|
||||
========================================================================== */
|
||||
|
||||
.otp-bind-panel {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ---- 标题 ---- */
|
||||
.bind-title {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.bind-title h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bind-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #606266);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- 加载 ---- */
|
||||
.bind-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 0;
|
||||
color: var(--text-tertiary, #909399);
|
||||
}
|
||||
|
||||
.bind-loading .el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.bind-loading p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- 二维码区域 ---- */
|
||||
.qr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e4e7ed;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
}
|
||||
|
||||
.qr-placeholder p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- 分隔线 ---- */
|
||||
.divider-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.divider-line::before,
|
||||
.divider-line::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e4e7ed;
|
||||
}
|
||||
|
||||
.divider-line span {
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- 密钥区域 ---- */
|
||||
.secret-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.secret-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.secret-text {
|
||||
flex: 1;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 验证码输入 ---- */
|
||||
.verify-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.verify-section .el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #606266);
|
||||
}
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.bind-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.verify-btn {
|
||||
width: 100%;
|
||||
background-color: #07c160;
|
||||
border-color: #07c160;
|
||||
}
|
||||
|
||||
.verify-btn:hover,
|
||||
.verify-btn:focus {
|
||||
background-color: #06ad56;
|
||||
border-color: #06ad56;
|
||||
}
|
||||
|
||||
.verify-btn:active {
|
||||
background-color: #059a4d;
|
||||
border-color: #059a4d;
|
||||
}
|
||||
|
||||
/* ---- 错误提示 ---- */
|
||||
.bind-error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -57,6 +57,20 @@ const routes = [
|
||||
component: () => import('@/views/automation/SessionWorkbench.vue'),
|
||||
meta: { title: '自动化会话', requiresAuth: true },
|
||||
},
|
||||
// Tier1 知识库迭代 — 独立审批队列(D7)
|
||||
{
|
||||
path: '/approval-queue',
|
||||
name: 'ApprovalQueue',
|
||||
component: () => import('@/views/ApprovalQueue.vue'),
|
||||
meta: { title: '独立审批队列', requiresAuth: true },
|
||||
},
|
||||
// T04 — 坐席端个人设置页(含 OTP 管理)
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '个人设置', requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,6 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
|
||||
// 登录前先清除旧数据,避免切换账号时显示旧用户信息
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(PORTAL_TOKEN_KEY)
|
||||
localStorage.removeItem(AGENT_USER_ID_KEY)
|
||||
token.value = null
|
||||
agentUserId.value = null
|
||||
@@ -91,7 +90,26 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
|
||||
const data = await apiLogin(inputUserId, password, otpCode)
|
||||
|
||||
// 检查是否需要 OTP 验证
|
||||
// 检查是否需要 OTP 首次绑定(优先级高于 require_otp)
|
||||
if ('require_otp_bind' in data && data.require_otp_bind) {
|
||||
// 保存半认证 token(BUG-001 修复后新增),供后续 otp-bind/otp-verify 携带鉴权
|
||||
if (data.token) {
|
||||
token.value = data.token
|
||||
agentUserId.value = data.user_id
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
localStorage.setItem(AGENT_USER_ID_KEY, data.user_id)
|
||||
}
|
||||
// 不抛异常,返回绑定信息供 Login.vue 展示 OtpBindPanel
|
||||
logging.value = false
|
||||
return {
|
||||
require_otp_bind: true,
|
||||
user_id: data.user_id,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要 OTP 验证(已绑定用户二次验证)
|
||||
if ('require_otp' in data && data.require_otp) {
|
||||
// 返回 data,让 Login.vue 处理 require_otp
|
||||
logging.value = false
|
||||
|
||||
@@ -59,15 +59,8 @@
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录表单 -->
|
||||
<!-- 账号密码登录表单(与二维码同时展示,无需返回按钮) -->
|
||||
<div v-if="showPasswordLogin" class="password-login">
|
||||
<el-button
|
||||
size="small"
|
||||
class="back-btn"
|
||||
@click="handleBackToQrCode"
|
||||
>
|
||||
← 返回扫码登录
|
||||
</el-button>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
@@ -125,6 +118,15 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- OTP 首次绑定面板 -->
|
||||
<OtpBindPanel
|
||||
v-if="requireOtpBind"
|
||||
:user-id="otpBindUser.user_id"
|
||||
:name="otpBindUser.name"
|
||||
@bind-success="onBindSuccess"
|
||||
@cancel="onBindCancel"
|
||||
/>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
@@ -153,6 +155,7 @@ import { User, Lock, Key, Loading } from '@element-plus/icons-vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import apiClient from '@/api/index'
|
||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { connect: connectWebSocket } = useWebSocket()
|
||||
@@ -180,6 +183,12 @@ const loginForm = reactive({
|
||||
/** 是否需要 OTP 验证 */
|
||||
const requireOtp = ref(false)
|
||||
|
||||
/** 是否需要 OTP 首次绑定 */
|
||||
const requireOtpBind = ref(false)
|
||||
|
||||
/** OTP 绑定用户信息(来自 require_otp_bind 响应) */
|
||||
const otpBindUser = ref<{ user_id: string; name: string; role: string } | null>(null)
|
||||
|
||||
/** 登录中状态 */
|
||||
const logging = ref(false)
|
||||
|
||||
@@ -316,6 +325,20 @@ async function handleLogin(): Promise<void> {
|
||||
loginForm.otpCode.trim() || undefined
|
||||
)
|
||||
|
||||
if (result && result.require_otp_bind) {
|
||||
// 首次登录需绑定 OTP:隐藏表单,展示绑定面板
|
||||
requireOtpBind.value = true
|
||||
otpBindUser.value = {
|
||||
user_id: result.user_id,
|
||||
name: result.name,
|
||||
role: result.role,
|
||||
}
|
||||
showQrLoginPanel.value = false
|
||||
showPasswordLogin.value = false
|
||||
logging.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (result && result.require_otp) {
|
||||
requireOtp.value = true
|
||||
loginForm.otpCode = ''
|
||||
@@ -342,6 +365,33 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP 绑定成功回调
|
||||
* 保存 token 并跳转到工作台
|
||||
*/
|
||||
function onBindSuccess(token: string, userId: string, name: string, role: string): void {
|
||||
localStorage.setItem('agent_token', token)
|
||||
localStorage.setItem('agent_user_id', userId)
|
||||
|
||||
agentStore.token = token
|
||||
agentStore.agentUserId = userId
|
||||
agentStore.agentInfo = { user_id: userId, name, status: 'online' }
|
||||
|
||||
ElMessage.success('OTP 绑定成功,已登录')
|
||||
connectWebSocket()
|
||||
router.push('/workspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP 绑定取消回调
|
||||
* 返回扫码登录面板
|
||||
*/
|
||||
function onBindCancel(): void {
|
||||
requireOtpBind.value = false
|
||||
otpBindUser.value = null
|
||||
showQrLoginPanel.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// === OAuth 重定向计数清除 ===
|
||||
const existingToken = localStorage.getItem('agent_token')
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端个人设置页 (T04)
|
||||
// =============================================================================
|
||||
// 说明:坐席端个人设置页面,包含「OTP 二次验证」管理面板。
|
||||
// 功能:
|
||||
// - 查询 OTP 绑定状态(getOtpStatus)
|
||||
// - 已绑定时显示状态 + 解绑按钮
|
||||
// - 未绑定时显示警告 + 绑定按钮(复用 OtpBindPanel)
|
||||
//
|
||||
// 依赖:
|
||||
// - @/api/otp:getOtpStatus / unbindOtp
|
||||
// - @/components/OtpBindPanel.vue:可复用的 OTP 首次绑定面板
|
||||
// - @/stores/agent:useAgentStore(获取 userId / agentName)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h1 class="page-title">个人设置</h1>
|
||||
|
||||
<!-- ==========================================================================
|
||||
OTP 二次验证面板
|
||||
========================================================================== -->
|
||||
<el-card class="settings-card otp-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">🔐 OTP 二次验证</span>
|
||||
<el-tag
|
||||
:type="otpStatus.bound ? 'success' : 'warning'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ otpStatus.bound ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="statusLoading" class="status-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>正在查询状态...</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-else class="otp-content">
|
||||
<!-- 已绑定状态 -->
|
||||
<template v-if="otpStatus.bound">
|
||||
<div class="otp-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">绑定状态</span>
|
||||
<span class="info-value">
|
||||
<el-icon class="check-icon"><CircleCheckFilled /></el-icon>
|
||||
OTP 二次验证已启用
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="otpStatus.last_verified_at" class="info-row">
|
||||
<span class="info-label">最近验证</span>
|
||||
<span class="info-value info-time">
|
||||
{{ formatTime(otpStatus.last_verified_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="otp-actions">
|
||||
<p class="action-hint">
|
||||
解绑后将关闭二次验证保护,建议保持开启以增强账户安全。
|
||||
</p>
|
||||
<el-button
|
||||
type="danger"
|
||||
:loading="unbinding"
|
||||
:disabled="unbinding"
|
||||
@click="handleUnbind"
|
||||
>
|
||||
{{ unbinding ? '解绑中...' : '解绑 OTP' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 未绑定状态 -->
|
||||
<template v-else>
|
||||
<div class="otp-warning">
|
||||
<el-icon class="warning-icon"><WarningFilled /></el-icon>
|
||||
<div class="warning-text">
|
||||
<p class="warning-title">您的账户尚未绑定 OTP 二次验证</p>
|
||||
<p class="warning-desc">
|
||||
绑定后每次登录需输入动态验证码,有效防止账户被盗。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="otp-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="bind-btn"
|
||||
@click="showBindDialog = true"
|
||||
>
|
||||
立即绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ==========================================================================
|
||||
绑定 OTP 弹窗
|
||||
========================================================================== -->
|
||||
<el-dialog
|
||||
v-model="showBindDialog"
|
||||
title="绑定 OTP 二次验证"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<OtpBindPanel
|
||||
:user-id="agentStore.userId"
|
||||
:name="agentStore.agentName"
|
||||
@bind-success="onBindSuccess"
|
||||
@cancel="showBindDialog = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Loading, CircleCheckFilled, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { getOtpStatus, unbindOtp } from '@/api/otp'
|
||||
import type { OtpStatusData } from '@/api/otp'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 状态
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** OTP 绑定状态 */
|
||||
const otpStatus = reactive<OtpStatusData>({
|
||||
bound: false,
|
||||
enabled: false,
|
||||
last_verified_at: null,
|
||||
})
|
||||
|
||||
/** 状态加载中 */
|
||||
const statusLoading = ref(true)
|
||||
|
||||
/** 解绑中 */
|
||||
const unbinding = ref(false)
|
||||
|
||||
/** 是否显示绑定弹窗 */
|
||||
const showBindDialog = ref(false)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOtpStatus()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 查询 OTP 绑定状态
|
||||
*/
|
||||
async function fetchOtpStatus(): Promise<void> {
|
||||
statusLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await getOtpStatus()
|
||||
otpStatus.bound = data.bound
|
||||
otpStatus.enabled = data.enabled
|
||||
otpStatus.last_verified_at = data.last_verified_at ?? null
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '获取 OTP 状态失败'
|
||||
console.error('[Settings] 获取 OTP 状态失败:', error)
|
||||
ElMessage.error(errMsg)
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑 OTP
|
||||
* 弹窗输入当前 6 位 OTP 验证码,验证通过后解绑
|
||||
*/
|
||||
async function handleUnbind(): Promise<void> {
|
||||
try {
|
||||
// ElMessageBox.prompt 弹出输入框
|
||||
const { value: otpCode } = await ElMessageBox.prompt(
|
||||
'请输入当前 6 位 OTP 验证码以确认解绑',
|
||||
'解绑 OTP 二次验证',
|
||||
{
|
||||
confirmButtonText: '确认解绑',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '请输入 6 位验证码',
|
||||
inputType: 'text',
|
||||
inputValidator: (val: string) => {
|
||||
if (!val || val.trim().length !== 6) {
|
||||
return '请输入 6 位验证码'
|
||||
}
|
||||
if (!/^\d{6}$/.test(val.trim())) {
|
||||
return '验证码只能包含数字'
|
||||
}
|
||||
return true
|
||||
},
|
||||
// 危险操作样式
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
// 输入框限制
|
||||
inputErrorMessage: '验证码格式不正确',
|
||||
}
|
||||
)
|
||||
|
||||
// 用户取消了
|
||||
if (otpCode === undefined) return
|
||||
|
||||
unbinding.value = true
|
||||
|
||||
const data = await unbindOtp(otpCode.trim())
|
||||
|
||||
if (data.success) {
|
||||
ElMessage.success('OTP 已解绑,二次验证已关闭')
|
||||
// 刷新状态
|
||||
await fetchOtpStatus()
|
||||
} else {
|
||||
ElMessage.error('解绑失败,请重试')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// ElMessageBox 取消时会抛出 'cancel' 字符串
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
|
||||
const errMsg = error instanceof Error ? error.message : '解绑失败,请重试'
|
||||
console.error('[Settings] 解绑 OTP 失败:', error)
|
||||
ElMessage.error(errMsg)
|
||||
} finally {
|
||||
unbinding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定成功回调
|
||||
*/
|
||||
async function onBindSuccess(): Promise<void> {
|
||||
showBindDialog.value = false
|
||||
ElMessage.success('OTP 二次验证绑定成功')
|
||||
// 刷新状态
|
||||
await fetchOtpStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 8601 时间为本地可读格式
|
||||
*/
|
||||
function formatTime(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
企微浅色扁平风格(accent #07C160)
|
||||
========================================================================== */
|
||||
|
||||
.settings-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 48px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 24px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- 卡片 ---- */
|
||||
.settings-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-card__header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.settings-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
}
|
||||
|
||||
/* ---- 加载 ---- */
|
||||
.status-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 0;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #909399);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-loading .el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ---- OTP 信息 ---- */
|
||||
.otp-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
min-width: 72px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #606266);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #303133);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #07c160;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-time {
|
||||
color: var(--text-tertiary, #909399);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 警告区域 ---- */
|
||||
.otp-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fdf6ec;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #faecd8;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: #e6a23c;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.warning-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #606266);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- 操作区域 ---- */
|
||||
.otp-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 绑定按钮 — 企微绿 */
|
||||
.bind-btn {
|
||||
background-color: #07c160;
|
||||
border-color: #07c160;
|
||||
}
|
||||
|
||||
.bind-btn:hover,
|
||||
.bind-btn:focus {
|
||||
background-color: #06ad56;
|
||||
border-color: #06ad56;
|
||||
}
|
||||
|
||||
.bind-btn:active {
|
||||
background-color: #059a4d;
|
||||
border-color: #059a4d;
|
||||
}
|
||||
</style>
|
||||
+72
-96
@@ -1,5 +1,5 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Nginx 配置(公司内网服务器版)
|
||||
# 企微智能IT支持服务台 — Nginx 配置(公司内网服务器版)
|
||||
# =============================================================================
|
||||
# 适用场景:独立域名 itsupport.servyou.com.cn,公司内网 DNS 解析
|
||||
# 与 NAS 版的区别:
|
||||
@@ -8,25 +8,29 @@
|
||||
# 3. 真实 IP 直接从 $remote_addr 获取(无 CF 代理层)
|
||||
# 4. 预留 HTTPS 配置注释(如公司有统一 SSL 终端)
|
||||
# =============================================================================
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 日志格式
|
||||
# ------------------------------------------------------------------
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
|
||||
# ------------------------------------------------------------------
|
||||
set_real_ip_from 10.0.0.0/8; # 内网 A 类(代理/WAF 出口)
|
||||
set_real_ip_from 172.16.0.0/12; # 内网 B 类
|
||||
set_real_ip_from 192.168.0.0/16; # 内网 C 类
|
||||
set_real_ip_from 10.212.0.0/16; # VPN 网段
|
||||
real_ip_header X-Forwarded-For; # 从 X-Forwarded-For 取最后一个非信任 IP
|
||||
real_ip_recursive on; # 递归剥离已信任代理 IP
|
||||
# ------------------------------------------------------------------
|
||||
# 基础配置
|
||||
# ------------------------------------------------------------------
|
||||
@@ -35,8 +39,7 @@ http {
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 50m; # 支持文件上传(企微媒体文件)
|
||||
|
||||
client_max_body_size 50m;
|
||||
# ------------------------------------------------------------------
|
||||
# Gzip 压缩(前端静态资源)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -46,112 +49,114 @@ http {
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/javascript application/xml+rss
|
||||
application/json application/ld+json;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 安全响应头
|
||||
# ------------------------------------------------------------------
|
||||
# 隐藏 nginx 版本号
|
||||
server_tokens off;
|
||||
|
||||
# 基础安全头(应用到所有响应)
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "0" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
|
||||
# =================================================================
|
||||
# 上游服务定义(Docker 内部网络)
|
||||
# =================================================================
|
||||
upstream backend_api {
|
||||
server backend:8000;
|
||||
server wecom_it_backend:8000;
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# HTTP 服务(监听 80 端口)
|
||||
# =================================================================
|
||||
# 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80
|
||||
# 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径
|
||||
# =================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 安全头
|
||||
# ------------------------------------------------------------------
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
# =================================================================
|
||||
# HTTPS — 443 端口(主服务)
|
||||
# =================================================================
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 健康检查端点
|
||||
# ------------------------------------------------------------------
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline';";
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "unsafe-none" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
server_tokens off;
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# H5 员工端 — /itdesk/
|
||||
# ------------------------------------------------------------------
|
||||
location /itdesk/ {
|
||||
alias /usr/share/nginx/html/itdesk/;
|
||||
index index.html;
|
||||
try_files $uri /itdesk/index.html;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 坐席工作台 — /itagent/
|
||||
# ------------------------------------------------------------------
|
||||
location /itagent/ {
|
||||
alias /usr/share/nginx/html/itagent/;
|
||||
index index.html;
|
||||
try_files $uri /itagent/index.html;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 管理后台 — /itadmin/
|
||||
# ------------------------------------------------------------------
|
||||
location /itadmin/ {
|
||||
allow 10.0.0.0/8;
|
||||
allow 172.16.0.0/12;
|
||||
allow 192.168.0.0/16;
|
||||
allow 10.212.0.0/16;
|
||||
allow 10.240.0.0/16;
|
||||
allow 117.147.35.138;
|
||||
allow 218.75.34.87;
|
||||
allow 43.174.152.34;
|
||||
deny all;
|
||||
alias /usr/share/nginx/html/itadmin/;
|
||||
index index.html;
|
||||
try_files $uri /itadmin/index.html;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 统一入口 Portal — /itportal/
|
||||
# ------------------------------------------------------------------
|
||||
location /itportal/ {
|
||||
alias /usr/share/nginx/html/itportal/;
|
||||
index index.html;
|
||||
try_files $uri /itportal/index.html;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 后端 API — /api/
|
||||
# ------------------------------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://backend_api/;
|
||||
location ~ ^/api/admin/ {
|
||||
allow 10.0.0.0/8;
|
||||
allow 172.16.0.0/12;
|
||||
allow 192.168.0.0/16;
|
||||
allow 10.212.0.0/16;
|
||||
deny all;
|
||||
proxy_pass http://backend_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# 内网直连,如前端有 SSL 终端则改为 https
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置(AI 回复可能较慢)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket — /ws/(坐席端实时通信)
|
||||
# ------------------------------------------------------------------
|
||||
proxy_pass http://backend_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location /ws/ {
|
||||
access_log off; # P0-#4: 关闭 WS 路径日志,避免 token 泄露
|
||||
access_log off;
|
||||
proxy_pass http://backend_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -159,39 +164,10 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400s; # WebSocket 长连接
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 企微回调 — /api/wecom/callback(接收企微消息推送)
|
||||
# ------------------------------------------------------------------
|
||||
# 企微验证回调 URL 时使用 GET,后续消息推送使用 POST
|
||||
# 此路径已包含在 /api/ 的代理规则中,无需单独配置
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 默认路径 — 重定向到 H5 员工端
|
||||
# ------------------------------------------------------------------
|
||||
location = / {
|
||||
return 302 /itdesk/;
|
||||
return 302 /itportal/;
|
||||
}
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# HTTPS 配置(按需启用)
|
||||
# =================================================================
|
||||
# 如果需要本机直接提供 HTTPS(不走公司统一 SSL 终端),
|
||||
# 取消下方注释并配置 SSL 证书路径
|
||||
#
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name itsupport.servyou.com.cn;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
#
|
||||
# # 其余 location 配置与上方 HTTP server 相同
|
||||
# ...
|
||||
# }
|
||||
}
|
||||
|
||||
+10
-7
@@ -65,14 +65,17 @@ http {
|
||||
# =================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
listen 443 ssl;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
|
||||
# SSL 证书配置(临时禁用,等待证书路径修复)
|
||||
# ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
# SSL 证书配置(使用通配符证书 *.servyou.com.cn)
|
||||
ssl_certificate /etc/nginx/ssl/servyou.com.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/servyou.com.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# H5 员工端 — /itdesk/
|
||||
|
||||
Reference in New Issue
Block a user