diff --git a/backend/alembic/versions/043_knowledge_iteration.py b/backend/alembic/versions/043_knowledge_iteration.py new file mode 100644 index 0000000..0cbe301 --- /dev/null +++ b/backend/alembic/versions/043_knowledge_iteration.py @@ -0,0 +1,66 @@ +"""add knowledge iteration tables: conversation_annotations and knowledge_suggestions + +Revision ID: 043_knowledge_iteration +Revises: 042_add_employee_avatar_updated_at +Create Date: 2026-07-06 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '043_knowledge_iteration' +down_revision: Union[str, None] = '042_add_employee_avatar_updated_at' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # -------------------------------------------------------------------------- + # 1. 创建会话标注表 conversation_annotations + # -------------------------------------------------------------------------- + op.create_table( + 'conversation_annotations', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('conversation_id', sa.String(36), nullable=False, index=True), + sa.Column('agent_id', sa.String(36), nullable=False), + sa.Column('message_id', sa.String(36), nullable=False), + sa.Column('feedback', sa.String(20), nullable=False), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_annotation_conversation', 'conversation_annotations', ['conversation_id']) + op.create_index('idx_annotation_message', 'conversation_annotations', ['message_id']) + + # -------------------------------------------------------------------------- + # 2. 创建知识库优化建议表 knowledge_suggestions + # -------------------------------------------------------------------------- + op.create_table( + 'knowledge_suggestions', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('suggestion_type', sa.String(20), nullable=False, server_default='new_faq'), + sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True), + sa.Column('title', sa.String(256), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('category', sa.String(64), nullable=False, server_default='其他'), + sa.Column('tags', sa.JSON(), nullable=False, server_default='[]'), + sa.Column('source_type', sa.String(30), nullable=False), + sa.Column('source_data', sa.JSON(), nullable=True), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('reject_reason', sa.Text(), nullable=True), + sa.Column('reviewer_id', sa.String(36), nullable=True), + sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_suggestion_status', 'knowledge_suggestions', ['status']) + op.create_index('idx_suggestion_type', 'knowledge_suggestions', ['suggestion_type']) + op.create_index('idx_suggestion_created', 'knowledge_suggestions', ['created_at']) + + +def downgrade() -> None: + op.drop_table('knowledge_suggestions') + op.drop_table('conversation_annotations') diff --git a/backend/alembic/versions/044_automation.py b/backend/alembic/versions/044_automation.py new file mode 100644 index 0000000..cfcf204 --- /dev/null +++ b/backend/alembic/versions/044_automation.py @@ -0,0 +1,174 @@ +"""add automation tables (阶段5 自动化闭环): auto_sessions / auto_actions / +auto_approval_tickets / auto_scenario_configs / auto_rule_versions / +auto_action_logs / auto_mapping_cache + +Revision ID: 044_automation +Revises: 043_knowledge_iteration +Create Date: 2026-07-10 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '044_automation' +down_revision: Union[str, None] = '043_knowledge_iteration' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # -------------------------------------------------------------------------- + # 1. 自动化处置会话 auto_sessions + # -------------------------------------------------------------------------- + op.create_table( + 'auto_sessions', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('conversation_id', sa.String(36), nullable=True, index=True), + sa.Column('employee_id', sa.String(64), nullable=False, index=True), + sa.Column('agent_id', sa.String(64), nullable=True, index=True), + sa.Column('scenario_key', sa.String(64), nullable=True, index=True), + sa.Column('status', sa.String(20), nullable=False, server_default='created', index=True), + sa.Column('mode', sa.String(20), nullable=False, server_default='real_exec'), + sa.Column('confidence', sa.Float(), nullable=False, server_default='0.0'), + sa.Column('intent', sa.JSON(), nullable=True), + sa.Column('current_action_id', sa.String(36), nullable=True), + sa.Column('title', sa.String(256), nullable=False, server_default=''), + sa.Column('auto_close_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('closed_by', sa.String(64), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_session_employee', 'auto_sessions', ['employee_id']) + op.create_index('idx_auto_session_status', 'auto_sessions', ['status']) + + # -------------------------------------------------------------------------- + # 2. 处置动作 auto_actions + # -------------------------------------------------------------------------- + op.create_table( + 'auto_actions', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('session_id', sa.String(36), nullable=False, index=True), + sa.Column('action_index', sa.Integer(), nullable=False, server_default='0'), + sa.Column('action_type', sa.String(64), nullable=False, server_default=''), + sa.Column('adapter', sa.String(32), nullable=False, server_default=''), + sa.Column('risk_level', sa.String(16), nullable=False, server_default='read'), + sa.Column('title', sa.String(256), nullable=False, server_default=''), + sa.Column('description', sa.Text(), nullable=False, server_default=''), + sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('result', sa.JSON(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('approved_by', sa.String(64), nullable=True), + sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_action_session', 'auto_actions', ['session_id']) + op.create_index('idx_auto_action_status', 'auto_actions', ['status']) + + # -------------------------------------------------------------------------- + # 3. 审批单 auto_approval_tickets + # -------------------------------------------------------------------------- + op.create_table( + 'auto_approval_tickets', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('action_id', sa.String(36), nullable=False, index=True), + sa.Column('session_id', sa.String(36), nullable=False, index=True), + sa.Column('approver_id', sa.String(64), nullable=True), + sa.Column('channel', sa.String(16), nullable=False, server_default='agent'), + sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('decision_note', sa.Text(), nullable=True), + sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_approval_action', 'auto_approval_tickets', ['action_id']) + op.create_index('idx_auto_approval_session', 'auto_approval_tickets', ['session_id']) + + # -------------------------------------------------------------------------- + # 4. 场景配置 auto_scenario_configs + # -------------------------------------------------------------------------- + op.create_table( + 'auto_scenario_configs', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('scenario_key', sa.String(64), nullable=False, unique=True, index=True), + sa.Column('name', sa.String(128), nullable=False, server_default=''), + sa.Column('description', sa.Text(), nullable=False, server_default=''), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('trigger_conditions', sa.JSON(), nullable=True), + sa.Column('actions', sa.JSON(), nullable=True), + sa.Column('approval_strategy', sa.JSON(), nullable=True), + sa.Column('current_version_id', sa.String(36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_scenario_key', 'auto_scenario_configs', ['scenario_key']) + + # -------------------------------------------------------------------------- + # 5. 规则版本 auto_rule_versions + # -------------------------------------------------------------------------- + op.create_table( + 'auto_rule_versions', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('scenario_key', sa.String(64), nullable=False, index=True), + sa.Column('version', sa.Integer(), nullable=False, server_default='1'), + sa.Column('content', sa.JSON(), nullable=True), + sa.Column('status', sa.String(20), nullable=False, server_default='draft', index=True), + sa.Column('canary_percent', sa.Integer(), nullable=False, server_default='100'), + sa.Column('created_by', sa.String(64), nullable=True), + sa.Column('remark', sa.Text(), nullable=False, server_default=''), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_rule_version_scenario', 'auto_rule_versions', ['scenario_key']) + + # -------------------------------------------------------------------------- + # 6. 外部调用审计日志 auto_action_logs + # -------------------------------------------------------------------------- + op.create_table( + 'auto_action_logs', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('session_id', sa.String(36), nullable=True, index=True), + sa.Column('action_id', sa.String(36), nullable=True, index=True), + sa.Column('employee_id', sa.String(64), nullable=True), + sa.Column('event', sa.String(128), nullable=False, server_default=''), + sa.Column('direction', sa.String(8), nullable=False, server_default='out'), + sa.Column('system', sa.String(32), nullable=False, server_default='internal'), + sa.Column('request', sa.JSON(), nullable=True), + sa.Column('response', sa.JSON(), nullable=True), + sa.Column('status', sa.String(32), nullable=False, server_default=''), + sa.Column('latency_ms', sa.Integer(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_action_log_session', 'auto_action_logs', ['session_id']) + op.create_index('idx_auto_action_log_action', 'auto_action_logs', ['action_id']) + + # -------------------------------------------------------------------------- + # 7. 映射缓存 auto_mapping_cache + # -------------------------------------------------------------------------- + op.create_table( + 'auto_mapping_cache', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('employee_id', sa.String(64), nullable=False, index=True), + sa.Column('source', sa.String(32), nullable=False, server_default='lianruan'), + sa.Column('mapped_data', sa.JSON(), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('idx_auto_mapping_employee', 'auto_mapping_cache', ['employee_id']) + + +def downgrade() -> None: + op.drop_table('auto_mapping_cache') + op.drop_table('auto_action_logs') + op.drop_table('auto_rule_versions') + op.drop_table('auto_scenario_configs') + op.drop_table('auto_approval_tickets') + op.drop_table('auto_actions') + op.drop_table('auto_sessions') diff --git a/backend/app/api/admin_users.py b/backend/app/api/admin_users.py new file mode 100644 index 0000000..571cac4 --- /dev/null +++ b/backend/app/api/admin_users.py @@ -0,0 +1,367 @@ +# ============================================================================= +# 企微IT智能服务台 — 管理员用户管理 API +# ============================================================================= +# 说明:管理员用户的 CRUD API +# 端点: +# GET /api/admin/users — 获取管理员列表 +# POST /api/admin/users — 创建管理员 +# GET /api/admin/users/{id} — 获取管理员详情 +# PUT /api/admin/users/{id} — 更新管理员 +# DELETE /api/admin/users/{id} — 删除管理员 +# POST /api/admin/users/{id}/reset-password — 重置密码 +# ============================================================================= + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import UserInfo, get_current_user, require_role +from app.schemas.admin_user import ( + AdminUserCreateRequest, + AdminUserListResponse, + AdminUserResetPasswordRequest, + AdminUserResponse, + AdminUserUpdateRequest, +) +from app.services.admin_user_service import AdminUserService +from app.utils.response import AppException, success_response +from app.utils.error_codes import ErrorCode + +logger = logging.getLogger(__name__) + +# 创建路由器 +router = APIRouter(prefix="/admin/users", tags=["管理员用户管理"]) + + +# ============================================================================= +# 0. GET /api/admin/users/me — 获取当前登录用户信息 +# ============================================================================= +@router.get("/me", response_model=None) +async def get_current_admin_user( + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取当前登录的管理员用户信息。 + + 无需额外权限,任何已登录用户都可以访问。 + + Args: + current_user: 当前用户 + db: 数据库会话 + + Returns: + 当前用户信息 + """ + service = AdminUserService(db) + agent = await service.get_user_by_user_id(current_user.employee_id) + + if not agent: + raise AppException(ErrorCode.NOT_FOUND, "用户不存在") + + return success_response(data=AdminUserResponse( + id=agent.id, + user_id=agent.user_id, + name=agent.name, + role=agent.role, + is_active=agent.status == "online", + mfa_enabled=agent.mfa_enabled, + mfa_bound_at=agent.mfa_bound_at, + created_at=agent.created_at, + updated_at=agent.updated_at, + ).model_dump()) + + +# ============================================================================= +# 1. GET /api/admin/users — 获取管理员列表 +# ============================================================================= +@router.get("", response_model=None) +async def list_admin_users( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + is_active: Optional[bool] = Query(None, description="是否激活(true=在线,false=离线)"), + current_user: UserInfo = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """获取管理员用户列表。 + + 需要 admin 或 super_admin 角色。 + + Args: + page: 页码 + page_size: 每页数量 + is_active: 按激活状态过滤 + current_user: 当前用户 + db: 数据库会话 + + Returns: + 管理员列表 + """ + service = AdminUserService(db) + items, total = await service.list_admin_users( + page=page, + page_size=page_size, + is_active=is_active, + ) + + # 转换为响应格式 + user_responses = [] + for agent in items: + user_responses.append(AdminUserResponse( + id=agent.id, + user_id=agent.user_id, + name=agent.name, + role=agent.role, + is_active=agent.status == "online", + mfa_enabled=agent.mfa_enabled, + mfa_bound_at=agent.mfa_bound_at, + created_at=agent.created_at, + updated_at=agent.updated_at, + )) + + return success_response(data=AdminUserListResponse( + items=user_responses, + total=total, + ).model_dump()) + + +# ============================================================================= +# 2. POST /api/admin/users — 创建管理员 +# ============================================================================= +@router.post("", response_model=None) +async def create_admin_user( + body: AdminUserCreateRequest, + current_user: UserInfo = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + """创建管理员用户。 + + 需要 super_admin 角色。 + + Args: + body: 创建请求 + current_user: 当前用户 + db: 数据库会话 + + Returns: + 创建的用户信息 + """ + service = AdminUserService(db) + + try: + agent = await service.create_admin_user( + user_id=body.user_id, + name=body.name, + role=body.role, + password=body.password, + ) + await db.commit() + + return success_response(data=AdminUserResponse( + id=agent.id, + user_id=agent.user_id, + name=agent.name, + role=agent.role, + is_active=agent.status == "online", + mfa_enabled=agent.mfa_enabled, + mfa_bound_at=agent.mfa_bound_at, + created_at=agent.created_at, + updated_at=agent.updated_at, + ).model_dump()) + + except AppException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"创建管理员失败: {e}") + raise AppException(ErrorCode.INTERNAL_ERROR, "创建管理员失败") + + +# ============================================================================= +# 3. GET /api/admin/users/{id} — 获取管理员详情 +# ============================================================================= +@router.get("/{id}", response_model=None) +async def get_admin_user( + id: str, + current_user: UserInfo = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """获取管理员用户详情。 + + 需要 admin 或 super_admin 角色。 + + Args: + id: 用户ID + current_user: 当前用户 + db: 数据库会话 + + Returns: + 用户详情 + """ + service = AdminUserService(db) + agent = await service.get_user_by_id(id) + + if not agent: + raise AppException(ErrorCode.NOT_FOUND, "用户不存在") + + return success_response(data=AdminUserResponse( + id=agent.id, + user_id=agent.user_id, + name=agent.name, + role=agent.role, + is_active=agent.status == "online", + mfa_enabled=agent.mfa_enabled, + mfa_bound_at=agent.mfa_bound_at, + created_at=agent.created_at, + updated_at=agent.updated_at, + ).model_dump()) + + +# ============================================================================= +# 4. PUT /api/admin/users/{id} — 更新管理员 +# ============================================================================= +@router.put("/{id}", response_model=None) +async def update_admin_user( + id: str, + body: AdminUserUpdateRequest, + current_user: UserInfo = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """更新管理员用户。 + + 需要 admin 或 super_admin 角色。 + - admin 角色只能更新普通 admin + - super_admin 角色可以更新所有用户 + + Args: + id: 用户ID + body: 更新请求 + current_user: 当前用户 + db: 数据库会话 + + Returns: + 更新后的用户信息 + """ + # 权限检查:非 super_admin 不能修改 super_admin + if "super_admin" not in current_user.roles: + target = await AdminUserService(db).get_user_by_id(id) + if target and target.role == "super_admin": + raise AppException(ErrorCode.FORBIDDEN, "无法修改超级管理员") + + service = AdminUserService(db) + + try: + agent = await service.update_admin_user( + id=id, + name=body.name, + role=body.role, + is_active=body.is_active, + ) + await db.commit() + + return success_response(data=AdminUserResponse( + id=agent.id, + user_id=agent.user_id, + name=agent.name, + role=agent.role, + is_active=agent.status == "online", + mfa_enabled=agent.mfa_enabled, + mfa_bound_at=agent.mfa_bound_at, + created_at=agent.created_at, + updated_at=agent.updated_at, + ).model_dump()) + + except AppException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"更新管理员失败: {e}") + raise AppException(ErrorCode.INTERNAL_ERROR, "更新管理员失败") + + +# ============================================================================= +# 5. DELETE /api/admin/users/{id} — 删除管理员 +# ============================================================================= +@router.delete("/{id}", response_model=None) +async def delete_admin_user( + id: str, + current_user: UserInfo = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + """删除管理员用户。 + + 需要 super_admin 角色。 + + Args: + id: 用户ID + current_user: 当前用户 + db: 数据库会话 + + Returns: + 删除结果 + """ + service = AdminUserService(db) + + try: + await service.delete_admin_user(id) + await db.commit() + + return success_response(data={"message": "删除成功"}) + + except AppException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"删除管理员失败: {e}") + raise AppException(ErrorCode.INTERNAL_ERROR, "删除管理员失败") + + +# ============================================================================= +# 6. POST /api/admin/users/{id}/reset-password — 重置密码 +# ============================================================================= +@router.post("/{id}/reset-password", response_model=None) +async def reset_password( + id: str, + body: AdminUserResetPasswordRequest, + current_user: UserInfo = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """重置管理员密码。 + + 需要 admin 或 super_admin 角色。 + + Args: + id: 用户ID + body: 重置请求 + current_user: 当前用户 + db: 数据库会话 + + Returns: + 重置结果 + """ + # 权限检查:非 super_admin 不能重置 super_admin 的密码 + if "super_admin" not in current_user.roles: + target = await AdminUserService(db).get_user_by_id(id) + if target and target.role == "super_admin": + raise AppException(ErrorCode.FORBIDDEN, "无法重置超级管理员密码") + + service = AdminUserService(db) + + try: + await service.reset_password(id, body.new_password) + await db.commit() + + return success_response(data={"message": "密码重置成功"}) + + except AppException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.error(f"重置密码失败: {e}") + raise AppException(ErrorCode.INTERNAL_ERROR, "重置密码失败") diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 4a435d5..e81e7c5 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db -from app.dependencies import get_current_user, require_role +from app.dependencies import get_current_user, require_role, dep_wecom_service from app.models.agent import Agent from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate from app.services.wecom_service import WecomService @@ -177,6 +177,8 @@ async def agent_login( # - 企微验证失败(用户不存在) → 拒绝登录 # - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证 wecom_verified = False + # 默认空头像,企微验证成功时覆盖;确保在 wecom 不可达(降级)时仍可安全引用 + avatar = "" try: redis_client_verify = _get_redis() try: @@ -188,6 +190,14 @@ async def agent_login( real_name = user_info.get("name", "") if real_name: body.name = real_name + # 【P1-02】每次坐席登录也强制更新头像(与 H5 登录保持一致,统一走 avatar_service) + avatar = user_info.get("avatar", "") + if avatar: + try: + from app.services.avatar_service import sync_employee_avatar + await sync_employee_avatar(db, redis_client_verify, body.user_id, avatar) + except Exception as e: + logger.warning(f"同步员工头像失败(不阻塞登录): user_id={body.user_id}, error={e}") logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}") finally: try: @@ -258,15 +268,18 @@ async def agent_login( logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}") # 2. MFA 二次验证(已绑定 MFA 的坐席/管理员) - # v1.5: 坐席和管理员都需要 OTP 验证 + # 决策3(三端认证重构 AUTH-04):移除「企微已登录+角色→免密直接进入」分支, + # 所有登录方式(扫码/账密/企微验证)均需 OTP 验证,统一安全水位。 + # 执行MFA验证 if agent.mfa_enabled: if not body.otp_code: - # 需要 OTP 验证,返回 require_otp 标记 + # 需要 OTP 验证,返回 require_otp 标记(必须包含role字段,否则前端校验会失败) return success_response(data={ "require_otp": True, "message": "请输入OTP动态码", "user_id": agent.user_id, "name": agent.name, + "role": agent.role, # 必须包含role字段,供前端校验权限 }) else: # 验证 OTP 码 @@ -292,6 +305,7 @@ async def agent_login( employee_id=body.user_id, name=body.name, roles=roles, + avatar=avatar, login_source="agent", ) @@ -398,148 +412,6 @@ async def list_agents( return success_response(data={"items": items}) -# -------------------------------------------------------------------------- -# OTP 绑定接口 -# -------------------------------------------------------------------------- -@router.post("/agents/otp-bind") -async def bind_agent_otp( - agent: Agent = Depends(get_current_agent), - db: AsyncSession = Depends(get_db), -): - """为当前坐席生成 OTP 密钥和二维码。 - - 生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。 - 返回二维码(base64编码)和密钥,供用户手动输入备用。 - - Returns: - Dict: 二维码图片(base64)和密钥 - """ - try: - # v0.7.1: 用 mfa_secret 替代 otp_secret - # 检查是否已绑定 - if agent.mfa_secret: - # 已绑定,返回现有密钥的二维码 - totp = pyotp.TOTP(agent.mfa_secret) - else: - # 生成新密钥 - secret = pyotp.random_base32() - agent.mfa_secret = secret - # mfa_enabled 保持 False,等待首次验证后启用 - db.add(agent) - await db.flush() - totp = pyotp.TOTP(secret) - - # 生成 otpauth:// URI - otpauth_uri = totp.provisioning_uri( - name=f"IT支持服务:{agent.name}", - issuer_name="IT支持服务", - ) - - # 生成二维码图片 - qr = qrcode.make(otpauth_uri) - buffer = io.BytesIO() - qr.save(buffer, format="PNG") - qr_base64 = base64.b64encode(buffer.getvalue()).decode() - - logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...") - - return success_response(data={ - "qr_code": f"data:image/png;base64,{qr_base64}", - "secret": agent.mfa_secret, - }) - - except AppException: - raise - except Exception as e: - logger.error(f"OTP绑定异常: {e}", exc_info=True) - raise AppException(1007, f"OTP绑定失败: {str(e)}") - - -@router.post("/agents/otp-verify") -async def verify_agent_otp( - body: AgentLogin, # 复用 AgentLogin,otp_code 为必填 - db: AsyncSession = Depends(get_db), -): - """验证并启用 OTP。 - - 用户输入 OTP 码验证成功后,启用 OTP。 - 首次验证成功后 otp_enabled 设为 1。 - - Args: - body.otp_code: 用户输入的 OTP 码(必填) - - Returns: - Dict: 验证结果 - """ - try: - # 查找坐席 - stmt = select(Agent).where(Agent.user_id == body.user_id) - result = await db.execute(stmt) - agent = result.scalars().first() - - if not agent or not agent.mfa_secret: - raise AppException(1008, "请先绑定OTP") - - # 验证 OTP 码 - totp = pyotp.TOTP(agent.mfa_secret) - if not totp.verify(body.otp_code, valid_window=1): - raise AppException(1006, "OTP验证码错误") - - # 验证成功,启用 MFA - agent.mfa_enabled = True - agent.mfa_bound_at = datetime.now() - agent.mfa_last_verified_at = datetime.now() - agent.updated_at = datetime.now() - db.add(agent) - await db.flush() - - logger.info(f"OTP验证成功并启用: agent={agent.user_id}") - - return success_response(data={ - "mfa_enabled": True, - "message": "OTP验证成功,已启用", - }) - - except AppException: - raise - except Exception as e: - logger.error(f"OTP验证异常: {e}", exc_info=True) - raise AppException(1009, f"OTP验证失败: {str(e)}") - - -@router.post("/agents/otp-unbind") -async def unbind_agent_otp( - agent: Agent = Depends(get_current_agent), - db: AsyncSession = Depends(get_db), -): - """解绑 OTP。 - - 解绑后 mfa_secret 和 mfa_enabled 都清空。 - 需要管理员操作。 - - Returns: - Dict: 解绑结果 - """ - try: - agent.mfa_secret = None - agent.mfa_enabled = False - agent.mfa_bound_at = None - agent.mfa_last_verified_at = None - agent.updated_at = datetime.now() - db.add(agent) - await db.flush() - - logger.info(f"OTP解绑: agent={agent.user_id}") - - return success_response(data={"message": "OTP已解绑"}) - - except AppException: - raise - except Exception as e: - logger.error(f"OTP解绑异常: {e}", exc_info=True) - raise AppException(1010, f"OTP解绑失败: {str(e)}") - - # -------------------------------------------------------------------------- # 本地密码管理接口(P0-#5) # -------------------------------------------------------------------------- @@ -595,6 +467,72 @@ async def update_agent_password( raise AppException(1014, f"密码更新失败: {str(e)}") +# ============================================================================ +# 忘记密码 - 企微扫码重置 +# ============================================================================ + +class AgentPasswordResetByWecom(BaseModel): + """通过企微扫码重置密码请求 Schema""" + code: str = Field(..., description="企微OAuth2授权码") + new_password: str = Field(..., min_length=6, max_length=128, description="新密码") + + +@router.post("/agents/password/reset-by-wecom") +async def reset_password_by_wecom( + body: AgentPasswordResetByWecom, + db: AsyncSession = Depends(get_db), + wecom_service: WecomService = Depends(dep_wecom_service), +): + """通过企微扫码验证后重置密码。 + + 适用于坐席忘记原密码的情况。通过企微OAuth2扫码验证身份后, + 无需旧密码即可重置密码。 + + #91 新增端点。 + + Args: + body.code: 企微OAuth2授权码 + body.new_password: 新密码(6-128位) + + Returns: + Dict: 重置结果 + """ + try: + # 1. 用 code 换取员工身份 + user_info = await wecom_service.get_oauth_user_info(body.code) + employee_id = user_info.get("userid", "") + + if not employee_id: + raise AppException(2007, "OAuth2授权失败:未获取到员工ID") + + # 2. 查询该员工是否是坐席 + from sqlalchemy import select + from app.models.agent import Agent + + stmt = select(Agent).where(Agent.user_id == employee_id) + result = await db.execute(stmt) + agent = result.scalar_one_or_none() + + if not agent: + raise AppException(1015, "该员工不是坐席,无法重置密码") + + # 3. 重置密码 + agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + agent.updated_at = datetime.now() + db.add(agent) + await db.flush() + + logger.info(f"密码已通过企微扫码重置: agent={agent.user_id}") + + return success_response(data={"message": "密码已重置"}) + + except AppException: + raise + except Exception as e: + logger.error(f"密码重置异常: {e}", exc_info=True) + raise AppException(1016, f"密码重置失败: {str(e)}") + + # ============================================================================ # 企微 OAuth2 一键登录(坐席端) # ============================================================================ @@ -749,5 +687,5 @@ async def oauth_callback( "user_id": agent.user_id, "name": employee_name or agent.name, "role": agent.role, - "require_otp": agent.otp_secret is not None, + "require_otp": agent.mfa_secret is not None, }) diff --git a/backend/app/api/auth_qrcode.py b/backend/app/api/auth_qrcode.py index 771056f..3621751 100644 --- a/backend/app/api/auth_qrcode.py +++ b/backend/app/api/auth_qrcode.py @@ -198,22 +198,46 @@ async def scan_qrcode(
-请在刚才打开登录页的浏览器中
-点击 「确认登录」 按钮完成登录
-本页可关闭
+ +请在电脑端的登录页面点击
「确认登录」 按钮完成登录