A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过
This commit is contained in:
@@ -1014,3 +1014,71 @@ async def ragflow_retrieval(
|
|||||||
return success_response(data={"error": e.message, "error_code": "config_missing"})
|
return success_response(data={"error": e.message, "error_code": "config_missing"})
|
||||||
except RagflowError as e:
|
except RagflowError as e:
|
||||||
return success_response(data={"error": e.message, "error_code": "api_error"})
|
return success_response(data={"error": e.message, "error_code": "api_error"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- POST /api/admin/users/{employee_id}/revoke-token ----------
|
||||||
|
@router.post("/users/{employee_id}/revoke-token")
|
||||||
|
async def revoke_user_token(
|
||||||
|
employee_id: str,
|
||||||
|
admin: Agent = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""强制撤销指定用户的登录Token(管理员操作)。
|
||||||
|
|
||||||
|
清除该用户在 Redis 中的所有 Token,使其被迫下线。
|
||||||
|
同时记录审计日志。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
employee_id: 要撤销 Token 的用户 ID(企微 UserID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
撤销结果
|
||||||
|
"""
|
||||||
|
from app.dependencies import get_redis
|
||||||
|
from app.services.audit_log_service import record_audit_log
|
||||||
|
|
||||||
|
redis_client = await get_redis()
|
||||||
|
|
||||||
|
# 搜索可能的 Token key 模式
|
||||||
|
# 1. user:token:* - 统一格式
|
||||||
|
# 2. agent:token:* - 坐席端
|
||||||
|
# 3. employee:token:* - 员工端
|
||||||
|
revoked_count = 0
|
||||||
|
patterns = ["user:token:*", "agent:token:*", "employee:token:*"]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = await redis_client.scan(cursor, match=pattern, count=100)
|
||||||
|
for key in keys:
|
||||||
|
token_data = await redis_client.get(key)
|
||||||
|
if token_data:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
data = json.loads(token_data)
|
||||||
|
if data.get("employee_id") == employee_id:
|
||||||
|
await redis_client.delete(key)
|
||||||
|
revoked_count += 1
|
||||||
|
logger.info(f"撤销 Token: key={key}, employee_id={employee_id}")
|
||||||
|
except (json.JSONDecodeError, Exception):
|
||||||
|
pass
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 记录审计日志
|
||||||
|
await record_audit_log(
|
||||||
|
db=db,
|
||||||
|
employee_id=admin.employee_id,
|
||||||
|
action="revoke_token",
|
||||||
|
resource="user",
|
||||||
|
resource_id=employee_id,
|
||||||
|
details={"revoked_count": revoked_count, "operator": admin.employee_id},
|
||||||
|
result="success",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return success_response(data={
|
||||||
|
"employee_id": employee_id,
|
||||||
|
"revoked_count": revoked_count,
|
||||||
|
"message": f"已撤销 {revoked_count} 个 Token" if revoked_count > 0 else "未找到该用户的有效 Token",
|
||||||
|
})
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from fastapi import APIRouter, Depends, Path
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
from app.dependencies import dep_redis, get_current_user, UserInfo
|
from app.dependencies import dep_redis, get_current_user, UserInfo
|
||||||
from app.schemas.qrcode import (
|
from app.schemas.qrcode import (
|
||||||
QrcodeConfirmRequest,
|
QrcodeConfirmRequest,
|
||||||
@@ -139,30 +141,85 @@ async def poll_qrcode(
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
# GET|POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/scan", response_model=None)
|
@router.api_route("/scan", methods=["GET", "POST"], response_model=None)
|
||||||
async def scan_qrcode(
|
async def scan_qrcode(
|
||||||
body: QrcodeScanRequest,
|
body: Optional[QrcodeScanRequest] = None,
|
||||||
|
ticket: Optional[str] = Query(None, description="扫码登录票据(兼容旧参数名)"),
|
||||||
|
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
|
||||||
|
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
|
||||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||||
):
|
):
|
||||||
"""处理企微 OAuth2 扫码回调。
|
"""处理企微 OAuth2 扫码回调。
|
||||||
|
|
||||||
|
企微 OAuth2 标准回调走 **GET** 带 query 参数 `?code=xxx&state=<ticket>`,
|
||||||
|
本端点同时支持 GET 和 POST(POST 兼容内部调用 / 旧前端代码)。
|
||||||
|
|
||||||
|
GET 模式 (企微 OAuth2 标准回调):
|
||||||
|
- ticket ← query.state
|
||||||
|
- code ← query.code
|
||||||
|
- 自动 302 跳转到 /itdesk/ 或 /itadmin/ 或 /itagent/(按角色)
|
||||||
|
POST 模式 (内部调用):
|
||||||
|
- ticket ← body.ticket
|
||||||
|
- code ← body.code
|
||||||
|
|
||||||
无需鉴权(此端点被企微服务器回调,带 code + ticket)。
|
无需鉴权(此端点被企微服务器回调,带 code + ticket)。
|
||||||
用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。
|
用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。
|
||||||
|
|
||||||
dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。
|
dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。
|
||||||
|
|
||||||
Args:
|
|
||||||
body: 包含 ticket 和 code
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict: 统一响应格式,data 字段是 QrcodeScanResponse
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
service = _get_qrcode_service(redis_client)
|
# 1. 解析参数:POST 用 body,GET 用 query
|
||||||
result = await service.process_scan(ticket=body.ticket, code=body.code)
|
if body is not None:
|
||||||
|
final_ticket = body.ticket
|
||||||
|
final_code = body.code
|
||||||
|
else:
|
||||||
|
# 优先用 state(企微 OAuth 标准),回退到 ticket(兼容旧调用)
|
||||||
|
final_ticket = state or ticket
|
||||||
|
final_code = code
|
||||||
|
|
||||||
|
if not final_ticket or not final_code:
|
||||||
|
logger.warning(f"扫码参数缺失: ticket={final_ticket!r}, code={final_code!r}")
|
||||||
|
raise AppException(1000, "缺少 ticket 或 code 参数")
|
||||||
|
|
||||||
|
service = _get_qrcode_service(redis_client)
|
||||||
|
result = await service.process_scan(ticket=final_ticket, code=final_code)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# GET 模式:渲染一个 "扫码成功" 的 HTML 提示页 + 引导用户到登录页
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>扫码成功</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; }}
|
||||||
|
.card {{ background: #1e293b; border-radius: 16px; padding: 40px 32px; max-width: 400px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }}
|
||||||
|
h1 {{ color: #34d399; margin: 0 0 16px 0; font-size: 24px; }}
|
||||||
|
p {{ color: #94a3b8; margin: 8px 0; line-height: 1.6; }}
|
||||||
|
.ico {{ font-size: 56px; margin-bottom: 16px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">✅</div>
|
||||||
|
<h1>扫码成功</h1>
|
||||||
|
<p>请在刚才打开登录页的浏览器中</p>
|
||||||
|
<p>点击 <strong style="color:#60a5fa">「确认登录」</strong> 按钮完成登录</p>
|
||||||
|
<p style="margin-top:24px;font-size:13px;color:#64748b">本页可关闭</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
return HTMLResponse(content=html, status_code=200)
|
||||||
|
|
||||||
|
# POST 模式:返回 JSON
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"success": result["success"],
|
"success": result["success"],
|
||||||
"message": result["message"],
|
"message": result["message"],
|
||||||
@@ -173,7 +230,7 @@ async def scan_qrcode(
|
|||||||
logger.warning(f"扫码业务错误: {ve}")
|
logger.warning(f"扫码业务错误: {ve}")
|
||||||
raise AppException(1003, str(ve))
|
raise AppException(1003, str(ve))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"扫码处理异常: ticket={body.ticket[:8]}..., error={e}", exc_info=True)
|
logger.error(f"扫码处理异常: error={e}", exc_info=True)
|
||||||
raise AppException(1005, f"扫码处理失败: {str(e)}")
|
raise AppException(1005, f"扫码处理失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +242,7 @@ async def confirm_qrcode(
|
|||||||
body: QrcodeConfirmRequest,
|
body: QrcodeConfirmRequest,
|
||||||
current_user: UserInfo = Depends(get_current_user),
|
current_user: UserInfo = Depends(get_current_user),
|
||||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""处理当前已登录坐席的扫码确认授权。
|
"""处理当前已登录坐席的扫码确认授权。
|
||||||
|
|
||||||
@@ -213,6 +271,24 @@ async def confirm_qrcode(
|
|||||||
otp_code=body.otp_code,
|
otp_code=body.otp_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 记录扫码登录日志(成功)
|
||||||
|
from app.services.audit_log_service import record_audit_log
|
||||||
|
await record_audit_log(
|
||||||
|
db=db,
|
||||||
|
employee_id=result["employee_id"],
|
||||||
|
action="qrcode_login",
|
||||||
|
resource="auth",
|
||||||
|
resource_id=result["employee_id"],
|
||||||
|
details={
|
||||||
|
"name": result["name"],
|
||||||
|
"roles": result["roles"],
|
||||||
|
"confirmed_by": current_user.employee_id,
|
||||||
|
"login_method": "qrcode_confirm",
|
||||||
|
},
|
||||||
|
result="success",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"token": result["token"],
|
"token": result["token"],
|
||||||
"employee_id": result["employee_id"],
|
"employee_id": result["employee_id"],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -35,6 +36,7 @@ from app.database import get_db
|
|||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.models.user_role import UserRole
|
from app.models.user_role import UserRole
|
||||||
from app.services.wecom_service import WecomService
|
from app.services.wecom_service import WecomService
|
||||||
|
from app.services.audit_log_service import record_audit_log
|
||||||
from app.utils.response import AppException
|
from app.utils.response import AppException
|
||||||
from app.dependencies import get_redis
|
from app.dependencies import get_redis
|
||||||
|
|
||||||
@@ -46,6 +48,15 @@ router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"])
|
|||||||
OAUTH_STATE_TTL = 300
|
OAUTH_STATE_TTL = 300
|
||||||
# SSO token 长度
|
# SSO token 长度
|
||||||
SSO_TOKEN_BYTES = 32
|
SSO_TOKEN_BYTES = 32
|
||||||
|
# Token TTL 常量(8小时)
|
||||||
|
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
|
||||||
|
# 企微 API 超时设置(秒)
|
||||||
|
WECOM_API_TIMEOUT = 10
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 企微环境检测(使用统一工具模块)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
from app.utils.wecom_auth import require_wecom_ua as _require_wework_ua
|
||||||
|
|
||||||
|
|
||||||
def _sso_enabled() -> bool:
|
def _sso_enabled() -> bool:
|
||||||
@@ -101,6 +112,9 @@ async def sso_init(
|
|||||||
Args:
|
Args:
|
||||||
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
||||||
"""
|
"""
|
||||||
|
# 后端第二道防线:非企微环境拒绝授权
|
||||||
|
_require_wework_ua(request)
|
||||||
|
|
||||||
if not _sso_enabled():
|
if not _sso_enabled():
|
||||||
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
||||||
|
|
||||||
@@ -116,103 +130,229 @@ async def sso_init(
|
|||||||
str(state_payload).encode("utf-8"),
|
str(state_payload).encode("utf-8"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. 拼企微 OAuth URL
|
# 2. 拼企微 OAuth URL(回调URL中包含next参数,用于state失效时仍能知道目标路径)
|
||||||
callback_url = _get_oauth_callback_url(request)
|
callback_url = _get_oauth_callback_url(request)
|
||||||
oauth_url = _build_oauth_url(state, callback_url)
|
# 在回调URL中添加next参数
|
||||||
|
separator = "&" if "?" in callback_url else "?"
|
||||||
|
callback_url_with_next = f"{callback_url}{separator}next={urllib.parse.quote(next)}"
|
||||||
|
oauth_url = _build_oauth_url(state, callback_url_with_next)
|
||||||
|
|
||||||
logger.info(f"SSO init: state={state[:8]}..., next={next}")
|
logger.info(f"SSO init: state={state[:8]}..., next={next}")
|
||||||
return RedirectResponse(url=oauth_url, status_code=302)
|
return RedirectResponse(url=oauth_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_error_redirect_url(error_code: str, error_msg: str, next_path: str = "/itdesk/") -> str:
|
||||||
|
"""生成 OAuth 错误重定向 URL。
|
||||||
|
|
||||||
|
异常时重定向到前端错误页面(ErrorPage),而不是返回 JSON 错误。
|
||||||
|
ErrorPage 读取 code 和 message 参数显示友好错误提示。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_code: 错误码
|
||||||
|
error_msg: 错误信息
|
||||||
|
next_path: 原始请求的目标路径(保留但不再用于决定重定向)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
base = getattr(settings, "wecom_sso_callback_base", None)
|
||||||
|
if not base:
|
||||||
|
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
|
||||||
|
|
||||||
|
# 重定向到 Portal 的 ErrorPage,带错误参数
|
||||||
|
# ErrorPage 期望格式:?code=xxx&message=yyy
|
||||||
|
return f"{base.rstrip('/')}/itportal/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sso/callback")
|
@router.get("/sso/callback")
|
||||||
async def sso_callback(
|
async def sso_callback(
|
||||||
code: str = Query(..., description="企微 OAuth2 授权 code"),
|
request: Request,
|
||||||
state: str = Query(..., description="防 CSRF state"),
|
code: Optional[str] = Query(None, description="企微 OAuth2 授权 code"),
|
||||||
|
state: Optional[str] = Query(None, description="防 CSRF state"),
|
||||||
|
errcode: Optional[int] = Query(None, description="企微 OAuth 错误码"),
|
||||||
|
errmsg: Optional[str] = Query(None, description="企微 OAuth 错误信息"),
|
||||||
|
next: Optional[str] = Query(None, description="原始请求的目标路径(可选,用于错误时重定向)"),
|
||||||
redis_client = Depends(get_redis),
|
redis_client = Depends(get_redis),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。"""
|
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。
|
||||||
# 1. 校验 state(防 CSRF)
|
|
||||||
state_key = f"wecom_sso:state:{state}"
|
|
||||||
state_raw = await redis_client.get(state_key)
|
|
||||||
if not state_raw:
|
|
||||||
raise AppException(1002, "SSO state 已过期或无效, 请重新进入")
|
|
||||||
|
|
||||||
# 删除 state(一次性)
|
异常时重定向到前端错误页面,避免白屏。
|
||||||
await redis_client.delete(state_key)
|
所有未处理的异常都会记录详细日志(包含 traceback)。
|
||||||
|
|
||||||
import ast
|
Args:
|
||||||
state_data = ast.literal_eval(state_raw.decode("utf-8"))
|
next: 原始请求的目标路径,用于错误重定向。如果 state 验证失败,使用此参数决定重定向位置。
|
||||||
next_path = state_data.get("next", "/itdesk/")
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# 默认 next 路径
|
||||||
|
next_path = next or "/itdesk/"
|
||||||
|
|
||||||
# 2. 用 code 换 userid
|
|
||||||
wecom = WecomService(redis_client)
|
|
||||||
try:
|
try:
|
||||||
oauth_info = await wecom.get_oauth_user_info(code)
|
# 后端第二道防线:非企微环境拒绝回调
|
||||||
user_id = oauth_info.get("userid", "")
|
_require_wework_ua(request)
|
||||||
if not user_id:
|
|
||||||
raise AppException(1003, "企微 OAuth 返回 userid 为空")
|
|
||||||
|
|
||||||
user_info = await wecom.get_user_info(user_id)
|
# 0. 处理企微返回的错误(用户拒绝授权等)
|
||||||
name = user_info.get("name", user_id)
|
if errcode is not None:
|
||||||
except Exception as e:
|
msg = errmsg or "用户取消授权或授权失败"
|
||||||
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
logger.warning(f"SSO callback 企微返回错误: errcode={errcode}, errmsg={errmsg}")
|
||||||
raise AppException(1004, f"企微身份识别失败: {str(e)}")
|
return RedirectResponse(url=_get_error_redirect_url(f"wecom_{errcode}", msg, next_path), status_code=302)
|
||||||
finally:
|
|
||||||
|
# 1. 校验必要参数
|
||||||
|
if not code or not state:
|
||||||
|
logger.warning(f"SSO callback 缺少必要参数: code={bool(code)}, state={bool(state)}")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("missing_params", "授权参数不完整,请重试", next_path), status_code=302)
|
||||||
|
|
||||||
|
# 2. 校验 state(防 CSRF)
|
||||||
|
state_key = f"wecom_sso:state:{state}"
|
||||||
try:
|
try:
|
||||||
await wecom.close()
|
state_raw = await redis_client.get(state_key)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.error(f"SSO callback Redis 获取 state 失败: {e}")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("redis_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
|
||||||
|
|
||||||
# 3. 查 role (user/agent/admin)
|
if not state_raw:
|
||||||
role_stmt = (
|
logger.warning(f"SSO callback state 过期: state={state[:8]}...")
|
||||||
select(Role)
|
return RedirectResponse(url=_get_error_redirect_url("state_expired", "授权已过期,请重新进入", next_path), status_code=302)
|
||||||
.join(UserRole, Role.id == UserRole.role_id)
|
|
||||||
.where(UserRole.employee_id == user_id)
|
|
||||||
)
|
|
||||||
role_result = await db.execute(role_stmt)
|
|
||||||
roles = role_result.scalars().all()
|
|
||||||
|
|
||||||
if not roles:
|
# 删除 state(一次性)
|
||||||
# 没有绑定角色: 跳"无权限"页
|
try:
|
||||||
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
|
await redis_client.delete(state_key)
|
||||||
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
|
except Exception as e:
|
||||||
|
logger.warning(f"SSO callback 删除 state 失败: {e}") # 不阻塞流程
|
||||||
|
|
||||||
# 4. 选最高权限角色 (admin > agent > user)
|
# 解析 state 数据(添加异常处理)
|
||||||
role_priority = {"admin": 3, "agent": 2, "user": 1}
|
import ast
|
||||||
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
|
import json
|
||||||
role_name = best_role.name
|
try:
|
||||||
|
state_data = json.loads(state_raw.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, AttributeError) as e:
|
||||||
|
# 兼容旧格式(使用 ast.literal_eval)
|
||||||
|
try:
|
||||||
|
state_data = ast.literal_eval(state_raw.decode("utf-8"))
|
||||||
|
except (ValueError, SyntaxError) as e2:
|
||||||
|
logger.error(f"SSO callback state 解析失败: {e2}")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("state_invalid", "授权信息无效,请重新进入", next_path), status_code=302)
|
||||||
|
|
||||||
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
|
# 优先使用 state 中存储的 next,fallback 到 URL 参数
|
||||||
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
|
next_path = state_data.get("next", next_path)
|
||||||
sso_payload = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"name": name,
|
|
||||||
"role": role_name,
|
|
||||||
"created_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
import json
|
|
||||||
await redis_client.setex(
|
|
||||||
f"wecom_sso:token:{sso_token}",
|
|
||||||
8 * 3600, # 8 小时
|
|
||||||
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. 跳转到 next + token
|
# 3. 用 code 换 userid
|
||||||
separator = "&" if "?" in next_path else "?"
|
wecom = WecomService(redis_client)
|
||||||
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
|
try:
|
||||||
|
oauth_info = await wecom.get_oauth_user_info(code)
|
||||||
|
user_id = oauth_info.get("userid", "")
|
||||||
|
if not user_id:
|
||||||
|
logger.warning("SSO callback 企微返回 userid 为空")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("empty_userid", "无法获取您的企业微信身份,请重试", next_path), status_code=302)
|
||||||
|
|
||||||
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
|
user_info = await wecom.get_user_info(user_id)
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
name = user_info.get("name", user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("api_failed", f"企业微信服务异常: {str(e)}"), status_code=302)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await wecom.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. 查 role (user/agent/admin)
|
||||||
|
try:
|
||||||
|
role_stmt = (
|
||||||
|
select(Role)
|
||||||
|
.join(UserRole, Role.id == UserRole.role_id)
|
||||||
|
.where(UserRole.employee_id == user_id)
|
||||||
|
)
|
||||||
|
role_result = await db.execute(role_stmt)
|
||||||
|
roles = role_result.scalars().all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSO callback 查询角色失败: {e}")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("db_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
|
||||||
|
|
||||||
|
if not roles:
|
||||||
|
# 没有绑定角色: 跳"无权限"页
|
||||||
|
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
|
||||||
|
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
|
||||||
|
|
||||||
|
# 4. 选最高权限角色 (admin > agent > user)
|
||||||
|
role_priority = {"admin": 3, "agent": 2, "user": 1}
|
||||||
|
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
|
||||||
|
role_name = best_role.name
|
||||||
|
|
||||||
|
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
|
||||||
|
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
|
||||||
|
sso_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"name": name,
|
||||||
|
"role": role_name,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
await redis_client.setex(
|
||||||
|
f"wecom_sso:token:{sso_token}",
|
||||||
|
TOKEN_TTL_SECONDS,
|
||||||
|
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSO callback 存储 token 失败: {e}")
|
||||||
|
return RedirectResponse(url=_get_error_redirect_url("redis_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
|
||||||
|
|
||||||
|
# 6. 记录登录日志
|
||||||
|
try:
|
||||||
|
await record_audit_log(
|
||||||
|
db=db,
|
||||||
|
employee_id=user_id,
|
||||||
|
action="sso_login",
|
||||||
|
resource="auth",
|
||||||
|
resource_id=user_id,
|
||||||
|
details={"name": name, "role": role_name, "login_method": "wecom_sso"},
|
||||||
|
result="success",
|
||||||
|
request=None, # callback 请求没有直接可用的 request 对象
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"SSO callback 记录登录日志失败: {e}") # 不阻塞登录流程
|
||||||
|
|
||||||
|
# 7. 跳转到 next + token
|
||||||
|
separator = "&" if "?" in next_path else "?"
|
||||||
|
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
|
||||||
|
|
||||||
|
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 捕获所有未处理的异常,记录详细日志(包含 traceback)并重定向到错误页
|
||||||
|
error_details = {
|
||||||
|
"error": str(e),
|
||||||
|
"error_type": type(e).__name__,
|
||||||
|
"code": code[:8] + "..." if code else None,
|
||||||
|
"state": state[:8] + "..." if state else None,
|
||||||
|
"next": next_path,
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
f"SSO callback 未处理的异常: {error_details}\n"
|
||||||
|
f"traceback: {traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=_get_error_redirect_url("oauth_failed", "登录过程出现异常,请重试"),
|
||||||
|
status_code=302
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sso/verify")
|
@router.get("/sso/verify")
|
||||||
async def sso_verify(
|
async def sso_verify(
|
||||||
|
request: Request,
|
||||||
sso_token: str = Query(..., description="SSO token"),
|
sso_token: str = Query(..., description="SSO token"),
|
||||||
redis_client = Depends(get_redis),
|
redis_client = Depends(get_redis),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。"""
|
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。
|
||||||
|
|
||||||
|
后端第二道防线:非企微环境拒绝验证。
|
||||||
|
"""
|
||||||
|
# 后端第二道防线:非企微环境拒绝验证
|
||||||
|
_require_wework_ua(request)
|
||||||
|
|
||||||
import json
|
import json
|
||||||
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
||||||
if not token_raw:
|
if not token_raw:
|
||||||
@@ -226,3 +366,162 @@ async def sso_verify(
|
|||||||
"code": 0,
|
"code": 0,
|
||||||
"data": payload,
|
"data": payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh_token(
|
||||||
|
token: str = Query(..., description="当前 Bearer token"),
|
||||||
|
redis_client = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""刷新 Token TTL。
|
||||||
|
|
||||||
|
前端在 Token 过期前 5 分钟自动调用此接口,实现静默刷新。
|
||||||
|
如果 Token 无效或已过期,返回 401 错误。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
刷新成功:{ code: 0, data: { token: "新token", expires_in: 28800 } }
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 1. 尝试统一格式 Token
|
||||||
|
token_key = f"user:token:{token}"
|
||||||
|
token_data_raw = await redis_client.get(token_key)
|
||||||
|
|
||||||
|
if token_data_raw:
|
||||||
|
try:
|
||||||
|
user_info = json.loads(token_data_raw)
|
||||||
|
# 更新最后活跃时间
|
||||||
|
user_info["last_active"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# 延长 TTL(重新设置 8 小时)
|
||||||
|
await redis_client.setex(
|
||||||
|
token_key,
|
||||||
|
TOKEN_TTL_SECONDS,
|
||||||
|
json.dumps(user_info, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Token 刷新成功: employee_id={user_info.get('employee_id')}")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token, # 复用同一个 token,只延长 TTL
|
||||||
|
"expires_in": TOKEN_TTL_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. 尝试旧格式 Token (employee:token)
|
||||||
|
employee_key = f"employee:token:{token}"
|
||||||
|
employee_id = await redis_client.get(employee_key)
|
||||||
|
if employee_id:
|
||||||
|
# 延长 TTL
|
||||||
|
await redis_client.expire(employee_key, TOKEN_TTL_SECONDS)
|
||||||
|
logger.info(f"Token 刷新成功(employee): employee_id={employee_id}")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"expires_in": TOKEN_TTL_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 尝试旧格式 Token (agent:token)
|
||||||
|
agent_key = f"agent:token:{token}"
|
||||||
|
agent_id = await redis_client.get(agent_key)
|
||||||
|
if agent_id:
|
||||||
|
await redis_client.expire(agent_key, TOKEN_TTL_SECONDS)
|
||||||
|
logger.info(f"Token 刷新成功(agent): agent_id={agent_id}")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"expires_in": TOKEN_TTL_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Token 无效或已过期
|
||||||
|
logger.warning(f"Token 刷新失败: token 不存在或已过期")
|
||||||
|
raise AppException(401, "Token 已过期,请重新登录")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 别名路由:支持前端 /api/auth/refresh 调用(与 /api/auth_wecom/refresh 等效)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 前端 H5/坐席/管理后台调用 /api/auth/refresh,后端响应 /api/auth_wecom/refresh
|
||||||
|
# 为兼容前端习惯,添加此别名路由
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 创建别名路由器(无 prefix)
|
||||||
|
alias_router = APIRouter(tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
|
@alias_router.post("/auth/refresh")
|
||||||
|
async def refresh_token_alias(
|
||||||
|
token: str = Query(..., description="当前 Bearer token"),
|
||||||
|
redis_client = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""Token 刷新接口别名。
|
||||||
|
|
||||||
|
前端调用 /api/auth/refresh,后端实际处理逻辑与 /api/auth_wecom/refresh 相同。
|
||||||
|
这是为了兼容前端的调用习惯。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
刷新成功:{ code: 0, data: { token: "新token", expires_in: 28800 } }
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 1. 尝试统一格式 Token
|
||||||
|
token_key = f"user:token:{token}"
|
||||||
|
token_data_raw = await redis_client.get(token_key)
|
||||||
|
|
||||||
|
if token_data_raw:
|
||||||
|
try:
|
||||||
|
user_info = json.loads(token_data_raw)
|
||||||
|
user_info["last_active"] = datetime.now().isoformat()
|
||||||
|
await redis_client.setex(
|
||||||
|
token_key,
|
||||||
|
TOKEN_TTL_SECONDS,
|
||||||
|
json.dumps(user_info, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
logger.info(f"Token 刷新成功(alias): employee_id={user_info.get('employee_id')}")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"expires_in": TOKEN_TTL_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. 尝试旧格式 Token
|
||||||
|
employee_key = f"employee:token:{token}"
|
||||||
|
employee_id = await redis_client.get(employee_key)
|
||||||
|
if employee_id:
|
||||||
|
await redis_client.expire(employee_key, TOKEN_TTL_SECONDS)
|
||||||
|
logger.info(f"Token 刷新成功(alias employee): employee_id={employee_id}")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"expires_in": TOKEN_TTL_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 尝试 agent token
|
||||||
|
agent_key = f"agent:token:{token}"
|
||||||
|
agent_id = await redis_client.get(agent_key)
|
||||||
|
if agent_id:
|
||||||
|
await redis_client.expire(agent_key, TOKEN_TTL_SECONDS)
|
||||||
|
logger.info(f"Token 刷新成功(alias agent): agent_id={agent_id}")
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"expires_in": TOKEN_TTL_SECONDS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning(f"Token 刷新失败(alias): token 不存在或已过期")
|
||||||
|
raise AppException(401, "Token 已过期,请重新登录")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.schemas.conversation import (
|
|||||||
ConversationStatusUpdate,
|
ConversationStatusUpdate,
|
||||||
InviteParticipantRequest,
|
InviteParticipantRequest,
|
||||||
JoinConversationRequest,
|
JoinConversationRequest,
|
||||||
|
UpdateTagsRequest,
|
||||||
)
|
)
|
||||||
from app.services.session_service import SessionService
|
from app.services.session_service import SessionService
|
||||||
from app.services.wecom_service import WecomService
|
from app.services.wecom_service import WecomService
|
||||||
@@ -38,6 +39,9 @@ from app.utils.response import AppException, success_response
|
|||||||
# 坐席认证依赖(从 agents.py 导入)
|
# 坐席认证依赖(从 agents.py 导入)
|
||||||
from app.api.agents import get_current_agent
|
from app.api.agents import get_current_agent
|
||||||
|
|
||||||
|
# RBAC 权限装饰器
|
||||||
|
from app.dependencies import require_role, require_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 创建路由器
|
# 创建路由器
|
||||||
@@ -48,6 +52,7 @@ router = APIRouter()
|
|||||||
# GET /api/conversations — 获取坐席会话列表(全局可见)
|
# GET /api/conversations — 获取坐席会话列表(全局可见)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.get("/conversations")
|
@router.get("/conversations")
|
||||||
|
@require_permission("conversation", "read", "all")
|
||||||
async def list_conversations(
|
async def list_conversations(
|
||||||
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
|
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
|
||||||
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
|
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
|
||||||
@@ -142,6 +147,7 @@ async def list_conversations(
|
|||||||
# GET /api/conversations/{id} — 获取会话详情
|
# GET /api/conversations/{id} — 获取会话详情
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.get("/conversations/{conversation_id}")
|
@router.get("/conversations/{conversation_id}")
|
||||||
|
@require_permission("conversation", "read", "all")
|
||||||
async def get_conversation(
|
async def get_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -166,6 +172,7 @@ async def get_conversation(
|
|||||||
# POST /api/conversations/{id}/assign — 坐席接单
|
# POST /api/conversations/{id}/assign — 坐席接单
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/assign")
|
@router.post("/conversations/{conversation_id}/assign")
|
||||||
|
@require_permission("conversation", "update", "all")
|
||||||
async def assign_conversation(
|
async def assign_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: ConversationAssign,
|
body: ConversationAssign,
|
||||||
@@ -216,6 +223,7 @@ async def assign_conversation(
|
|||||||
# POST /api/conversations/{id}/resolve — 结单
|
# POST /api/conversations/{id}/resolve — 结单
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/resolve")
|
@router.post("/conversations/{conversation_id}/resolve")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def resolve_conversation(
|
async def resolve_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -259,6 +267,7 @@ async def resolve_conversation(
|
|||||||
# POST /api/conversations/{id}/pin — 置顶/取消置顶
|
# POST /api/conversations/{id}/pin — 置顶/取消置顶
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/pin")
|
@router.post("/conversations/{conversation_id}/pin")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def toggle_pin(
|
async def toggle_pin(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -285,6 +294,7 @@ async def toggle_pin(
|
|||||||
# POST /api/conversations/{id}/todo — 代办/取消代办
|
# POST /api/conversations/{id}/todo — 代办/取消代办
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/todo")
|
@router.post("/conversations/{conversation_id}/todo")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def toggle_todo(
|
async def toggle_todo(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -311,6 +321,7 @@ async def toggle_todo(
|
|||||||
# POST /api/conversations/{id}/transfer — 转接
|
# POST /api/conversations/{id}/transfer — 转接
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/transfer")
|
@router.post("/conversations/{conversation_id}/transfer")
|
||||||
|
@require_permission("conversation", "update", "all")
|
||||||
async def transfer_conversation(
|
async def transfer_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: ConversationAssign,
|
body: ConversationAssign,
|
||||||
@@ -342,6 +353,7 @@ async def transfer_conversation(
|
|||||||
# POST /api/conversations/{id}/grab — 接手会话(抢单)
|
# POST /api/conversations/{id}/grab — 接手会话(抢单)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/grab")
|
@router.post("/conversations/{conversation_id}/grab")
|
||||||
|
@require_permission("conversation", "update", "all")
|
||||||
async def grab_conversation(
|
async def grab_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -439,6 +451,7 @@ async def grab_conversation(
|
|||||||
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
|
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/invite")
|
@router.post("/conversations/{conversation_id}/invite")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def invite_collaborator(
|
async def invite_collaborator(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: ConversationInvite,
|
body: ConversationInvite,
|
||||||
@@ -484,6 +497,7 @@ async def invite_collaborator(
|
|||||||
# POST /api/conversations/{id}/leave — 退出协作
|
# POST /api/conversations/{id}/leave — 退出协作
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/leave")
|
@router.post("/conversations/{conversation_id}/leave")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def leave_collaboration(
|
async def leave_collaboration(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -529,6 +543,7 @@ async def leave_collaboration(
|
|||||||
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
|
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/invite-participant")
|
@router.post("/conversations/{conversation_id}/invite-participant")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def invite_participant(
|
async def invite_participant(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: InviteParticipantRequest,
|
body: InviteParticipantRequest,
|
||||||
@@ -587,6 +602,7 @@ async def invite_participant(
|
|||||||
# POST /api/conversations/{id}/join — 被邀请人加入会话
|
# POST /api/conversations/{id}/join — 被邀请人加入会话
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/join")
|
@router.post("/conversations/{conversation_id}/join")
|
||||||
|
@require_permission("conversation", "update", "all")
|
||||||
async def join_conversation(
|
async def join_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: JoinConversationRequest,
|
body: JoinConversationRequest,
|
||||||
@@ -622,6 +638,7 @@ async def join_conversation(
|
|||||||
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
|
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
|
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def remove_participant(
|
async def remove_participant(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -659,6 +676,7 @@ async def remove_participant(
|
|||||||
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
|
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/leave-participant")
|
@router.post("/conversations/{conversation_id}/leave-participant")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def leave_as_participant(
|
async def leave_as_participant(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: JoinConversationRequest,
|
body: JoinConversationRequest,
|
||||||
@@ -686,3 +704,60 @@ async def leave_as_participant(
|
|||||||
|
|
||||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||||
return success_response(data=response_data)
|
return success_response(data=response_data)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# POST /api/conversations/{conversation_id}/tags — 保存会话标签
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
@router.post("/conversations/{conversation_id}/tags")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
|
async def update_conversation_tags(
|
||||||
|
conversation_id: str,
|
||||||
|
body: UpdateTagsRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_agent: Agent = Depends(get_current_agent),
|
||||||
|
):
|
||||||
|
"""保存会话标签。
|
||||||
|
|
||||||
|
坐席可以为会话添加/更新标签,如问题分类、优先级、情绪状态等。
|
||||||
|
标签以 JSON 形式存储在会话的 tags 字段中。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: 会话ID
|
||||||
|
body: 标签更新请求,包含 tags 字典
|
||||||
|
current_agent: 当前坐席(通过认证依赖注入)
|
||||||
|
db: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的会话详情
|
||||||
|
"""
|
||||||
|
# 1. 验证会话存在性
|
||||||
|
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
conversation = result.scalars().first()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise AppException("会话不存在", code=404)
|
||||||
|
|
||||||
|
# 2. 合并现有标签(如果有)
|
||||||
|
existing_tags = {}
|
||||||
|
if conversation.tags:
|
||||||
|
existing_tags = (
|
||||||
|
dict(conversation.tags) if isinstance(conversation.tags, dict) else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 合并新旧标签(body.tags 覆盖同名 key)
|
||||||
|
merged_tags = {**existing_tags, **body.tags}
|
||||||
|
|
||||||
|
# 4. 保存到数据库
|
||||||
|
conversation.tags = merged_tags
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(conversation)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"坐席 {current_agent.id} 更新会话 {conversation_id} 标签: {merged_tags}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 返回更新后的会话
|
||||||
|
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||||
|
return success_response(data=response_data)
|
||||||
|
|||||||
+152
-2
@@ -10,17 +10,19 @@
|
|||||||
# 6. POST /api/conversations/{id}/mark-read — 标记已读
|
# 6. POST /api/conversations/{id}/mark-read — 标记已读
|
||||||
# 7. POST /api/messages/image — 上传图片
|
# 7. POST /api/messages/image — 上传图片
|
||||||
# 8. POST /api/messages/file — 上传文件
|
# 8. POST /api/messages/file — 上传文件
|
||||||
|
# 9. GET /api/conversations/{id}/messages/search — 搜索消息(MSG-P1-04)
|
||||||
# 消息发送需同时:存数据库 + 调用企微API发送给员工
|
# 消息发送需同时:存数据库 + 调用企微API发送给员工
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -29,7 +31,12 @@ from app.models.conversation import Conversation
|
|||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.schemas.message import MessageCreate, MessageResponse
|
from app.schemas.message import MessageCreate, MessageResponse
|
||||||
from app.api.agents import get_current_agent
|
from app.api.agents import get_current_agent
|
||||||
|
|
||||||
|
# RBAC 权限装饰器
|
||||||
|
from app.dependencies import require_permission
|
||||||
|
|
||||||
from app.services.wecom_service import WecomService
|
from app.services.wecom_service import WecomService
|
||||||
|
from app.services.ws_manager import manager
|
||||||
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
|
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -48,6 +55,7 @@ RECALLABLE_WINDOW_MINUTES = 2
|
|||||||
# GET /api/conversations/{id}/messages — 获取会话消息列表
|
# GET /api/conversations/{id}/messages — 获取会话消息列表
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.get("/conversations/{conversation_id}/messages")
|
@router.get("/conversations/{conversation_id}/messages")
|
||||||
|
@require_permission("conversation", "read", "all")
|
||||||
async def list_messages(
|
async def list_messages(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
|
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
|
||||||
@@ -129,6 +137,7 @@ async def list_messages(
|
|||||||
# POST /api/conversations/{id}/messages — 坐席发送消息
|
# POST /api/conversations/{id}/messages — 坐席发送消息
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/messages")
|
@router.post("/conversations/{conversation_id}/messages")
|
||||||
|
@require_permission("conversation", "create", "all")
|
||||||
async def send_message(
|
async def send_message(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
body: MessageCreate,
|
body: MessageCreate,
|
||||||
@@ -184,6 +193,7 @@ async def send_message(
|
|||||||
status="sending", # 初始状态为发送中
|
status="sending", # 初始状态为发送中
|
||||||
recallable_until=recallable_until,
|
recallable_until=recallable_until,
|
||||||
is_read=True, # 坐席自己发的消息默认已读
|
is_read=True, # 坐席自己发的消息默认已读
|
||||||
|
server_timestamp=int(time.time() * 1000), # [MSG-P0-03] 服务端时间戳(毫秒)
|
||||||
)
|
)
|
||||||
db.add(message)
|
db.add(message)
|
||||||
|
|
||||||
@@ -235,6 +245,7 @@ async def send_message(
|
|||||||
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
|
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.get("/conversations/{conversation_id}/messages/poll")
|
@router.get("/conversations/{conversation_id}/messages/poll")
|
||||||
|
@require_permission("conversation", "read", "all")
|
||||||
async def poll_messages(
|
async def poll_messages(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
|
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
|
||||||
@@ -297,6 +308,7 @@ async def poll_messages(
|
|||||||
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
|
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/messages/{message_id}/recall")
|
@router.post("/messages/{message_id}/recall")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def recall_message(
|
async def recall_message(
|
||||||
message_id: str,
|
message_id: str,
|
||||||
agent: Agent = Depends(get_current_agent),
|
agent: Agent = Depends(get_current_agent),
|
||||||
@@ -342,8 +354,31 @@ async def recall_message(
|
|||||||
# 将消息内容置为空,表示已撤回
|
# 将消息内容置为空,表示已撤回
|
||||||
message.content = "[消息已撤回]"
|
message.content = "[消息已撤回]"
|
||||||
message.status = "recalled"
|
message.status = "recalled"
|
||||||
|
message.is_recalled = True # MSG-P1-01: 标记为已撤回
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
# MSG-P1-01: 通过 WebSocket 广播撤回事件给所有参与者
|
||||||
|
conv_stmt = select(Conversation).where(Conversation.id == message.conversation_id)
|
||||||
|
conv_result = await db.execute(conv_stmt)
|
||||||
|
conversation = conv_result.scalars().first()
|
||||||
|
if conversation:
|
||||||
|
participant_ids = []
|
||||||
|
if conversation.assigned_agent_id:
|
||||||
|
participant_ids.append(conversation.assigned_agent_id)
|
||||||
|
if conversation.employee_id:
|
||||||
|
participant_ids.append(conversation.employee_id)
|
||||||
|
# 广播撤回事件
|
||||||
|
await manager.broadcast_message_status(
|
||||||
|
conv_id=message.conversation_id,
|
||||||
|
msg_id=message.id,
|
||||||
|
status="recalled",
|
||||||
|
participant_ids=participant_ids,
|
||||||
|
extra={
|
||||||
|
"recall_by": agent.user_id,
|
||||||
|
"recall_at": datetime.now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return success_response(message="消息撤回成功")
|
return success_response(message="消息撤回成功")
|
||||||
|
|
||||||
|
|
||||||
@@ -351,6 +386,7 @@ async def recall_message(
|
|||||||
# DELETE /api/messages/{id} — 删除消息
|
# DELETE /api/messages/{id} — 删除消息
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.delete("/messages/{message_id}")
|
@router.delete("/messages/{message_id}")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
message_id: str,
|
message_id: str,
|
||||||
agent: Agent = Depends(get_current_agent),
|
agent: Agent = Depends(get_current_agent),
|
||||||
@@ -394,6 +430,7 @@ async def delete_message(
|
|||||||
# POST /api/conversations/{id}/mark-read — 标记已读
|
# POST /api/conversations/{id}/mark-read — 标记已读
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.post("/conversations/{conversation_id}/mark-read")
|
@router.post("/conversations/{conversation_id}/mark-read")
|
||||||
|
@require_permission("conversation", "update", "own")
|
||||||
async def mark_read(
|
async def mark_read(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
agent: Agent = Depends(get_current_agent),
|
agent: Agent = Depends(get_current_agent),
|
||||||
@@ -557,4 +594,117 @@ async def upload_message_file(
|
|||||||
"file_size": file_size,
|
"file_size": file_size,
|
||||||
"content_type": file.content_type,
|
"content_type": file.content_type,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# GET /api/conversations/{id}/messages/search — 搜索消息(MSG-P1-04)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
@router.get("/conversations/{conversation_id}/messages/search")
|
||||||
|
async def search_messages(
|
||||||
|
conversation_id: str,
|
||||||
|
keyword: str = Query(..., description="搜索关键词"),
|
||||||
|
limit: int = Query(20, ge=1, le=100, description="返回结果数量限制"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""搜索会话消息(按关键词)。
|
||||||
|
|
||||||
|
使用 LIKE 查询匹配消息内容,支持模糊搜索。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: 会话ID
|
||||||
|
keyword: 搜索关键词
|
||||||
|
limit: 返回结果数量限制
|
||||||
|
db: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 统一响应格式,包含匹配的消息列表
|
||||||
|
"""
|
||||||
|
# 校验会话存在
|
||||||
|
conv_id_str = str(conversation_id)
|
||||||
|
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
|
||||||
|
conv_result = await db.execute(conv_stmt)
|
||||||
|
conversation = conv_result.scalars().first()
|
||||||
|
if not conversation:
|
||||||
|
raise ERR_CONVERSATION_NOT_FOUND
|
||||||
|
|
||||||
|
# 构建搜索查询(使用 LIKE 进行模糊匹配)
|
||||||
|
# 排除已撤回的消息
|
||||||
|
search_pattern = f"%{keyword}%"
|
||||||
|
stmt = (
|
||||||
|
select(Message)
|
||||||
|
.where(Message.conversation_id == conv_id_str)
|
||||||
|
.where(Message.is_recalled == False) # 排除已撤回的消息
|
||||||
|
.where(Message.content.ilike(search_pattern)) # 不区分大小写匹配
|
||||||
|
.order_by(Message.created_at.desc()) # 最新消息在前
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
messages = list(result.scalars().all())
|
||||||
|
|
||||||
|
# 转换为响应格式
|
||||||
|
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
"items": items,
|
||||||
|
"total": len(items),
|
||||||
|
"keyword": keyword,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# POST /api/conversations/{id}/typing — 发送 typing 事件(MSG-P1-03)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
@router.post("/conversations/{conversation_id}/typing")
|
||||||
|
@require_permission("conversation", "read", "all")
|
||||||
|
async def send_typing_event(
|
||||||
|
conversation_id: str,
|
||||||
|
agent: Agent = Depends(get_current_agent),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""发送 typing 事件,通知对方正在输入。
|
||||||
|
|
||||||
|
通过 WebSocket 广播 typing 事件给会话参与者。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: 会话ID
|
||||||
|
agent: 当前坐席
|
||||||
|
db: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 统一响应格式
|
||||||
|
"""
|
||||||
|
# 校验会话存在
|
||||||
|
conv_id_str = str(conversation_id)
|
||||||
|
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
|
||||||
|
conv_result = await db.execute(conv_stmt)
|
||||||
|
conversation = conv_result.scalars().first()
|
||||||
|
if not conversation:
|
||||||
|
raise ERR_CONVERSATION_NOT_FOUND
|
||||||
|
|
||||||
|
# 构建参与者列表
|
||||||
|
participant_ids = []
|
||||||
|
if conversation.assigned_agent_id:
|
||||||
|
participant_ids.append(conversation.assigned_agent_id)
|
||||||
|
if conversation.employee_id:
|
||||||
|
participant_ids.append(conversation.employee_id)
|
||||||
|
|
||||||
|
# 广播 typing 事件(排除发送者本人)
|
||||||
|
payload = {
|
||||||
|
"type": "typing",
|
||||||
|
"conv_id": conv_id_str,
|
||||||
|
"sender_id": agent.user_id,
|
||||||
|
"sender_name": agent.name or "坐席",
|
||||||
|
}
|
||||||
|
|
||||||
|
for pid in participant_ids:
|
||||||
|
if pid != agent.user_id: # 不发给自己
|
||||||
|
if pid in manager.active_connections:
|
||||||
|
await manager.send_to_agent(pid, payload)
|
||||||
|
elif pid in manager.employee_connections:
|
||||||
|
await manager.send_to_employee(pid, payload)
|
||||||
|
|
||||||
|
return success_response(message="typing 事件已发送")
|
||||||
+52
-13
@@ -12,16 +12,22 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.models.agent import Agent
|
||||||
from app.services.token_service import TokenService
|
from app.services.token_service import TokenService
|
||||||
from app.utils.response import AppException
|
from app.utils.response import AppException
|
||||||
|
|
||||||
|
# 延迟导入 get_current_agent 以避免循环依赖
|
||||||
|
def _get_current_agent():
|
||||||
|
from app.api.agents import get_current_agent
|
||||||
|
return get_current_agent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# HTTP Bearer 认证方案
|
# HTTP Bearer 认证方案
|
||||||
@@ -324,30 +330,63 @@ def require_permission(
|
|||||||
def decorator(func):
|
def decorator(func):
|
||||||
sig = inspect.signature(func)
|
sig = inspect.signature(func)
|
||||||
params = list(sig.parameters.values())
|
params = list(sig.parameters.values())
|
||||||
params.append(
|
param_names = {p.name for p in params}
|
||||||
inspect.Parameter(
|
|
||||||
'current_user',
|
# 智能检测参数名:优先使用函数已定义的参数名
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
# 支持 current_user (通用/管理端) 和 current_agent (坐席端)
|
||||||
annotation=UserInfo,
|
if 'current_agent' in param_names:
|
||||||
default=Depends(get_current_user),
|
param_name = 'current_agent'
|
||||||
|
param_annotation = Agent
|
||||||
|
param_default = Depends(_get_current_agent())
|
||||||
|
else:
|
||||||
|
param_name = 'current_user'
|
||||||
|
param_annotation = UserInfo
|
||||||
|
param_default = Depends(get_current_user)
|
||||||
|
|
||||||
|
# 检查是否需要添加参数
|
||||||
|
needs_param = param_name not in param_names
|
||||||
|
if needs_param:
|
||||||
|
params.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
param_name,
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=param_annotation,
|
||||||
|
default=param_default,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
new_sig = sig.replace(parameters=params)
|
new_sig = sig.replace(parameters=params)
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
current_user = kwargs.pop('current_user')
|
# 提取注入的用户/坐席信息
|
||||||
|
current_user = kwargs.pop(param_name)
|
||||||
|
|
||||||
# 拉用户所有角色的 permissions
|
# 拉用户所有角色的 permissions
|
||||||
# 注: UserInfo.roles 是角色名列表,permissions 是 {role: [perm]} 字典
|
# 注: UserInfo.roles 是角色名列表,permissions 是 {role: [perm]} 字典
|
||||||
# 首次实现简化: 角色判断 + admin 通配符
|
# 首次实现简化: 角色判断 + admin 通配符
|
||||||
# 完整实现需要查 DB 拉 permissions,见 rbac_service.check_permission
|
# 完整实现需要查 DB 拉 permissions,见 rbac_service.check_permission
|
||||||
|
|
||||||
user_roles = set(current_user.roles or [])
|
# 支持两种类型:
|
||||||
|
# 1. UserInfo (H5/管理端): 有 roles 属性 (List[str])
|
||||||
|
# 2. Agent (坐席端): 有 role 属性 (str)
|
||||||
|
if hasattr(current_user, 'roles'):
|
||||||
|
user_roles = set(current_user.roles or [])
|
||||||
|
user_id = current_user.employee_id
|
||||||
|
elif hasattr(current_user, 'role'):
|
||||||
|
# Agent 类型:role 是字符串,直接作为角色
|
||||||
|
user_roles = {current_user.role} if current_user.role else set()
|
||||||
|
user_id = current_user.user_id
|
||||||
|
else:
|
||||||
|
# 兼容:没有 roles 或 role 属性的情况
|
||||||
|
user_roles = set()
|
||||||
|
user_id = getattr(current_user, 'user_id', 'unknown')
|
||||||
|
|
||||||
|
# 保存 user_id 供后续使用
|
||||||
|
current_user._rbac_user_id = user_id
|
||||||
|
|
||||||
# 1. admin 角色直通(通配符 *:*:all)
|
# 1. admin 角色直通(通配符 *:*:all)
|
||||||
if "admin" in user_roles:
|
if "admin" in user_roles:
|
||||||
return await func(*args, current_user=current_user, **kwargs)
|
return await func(*args, **{param_name: current_user}, **kwargs)
|
||||||
|
|
||||||
# 2. 其他角色: 走 rbac_service.check_permission
|
# 2. 其他角色: 走 rbac_service.check_permission
|
||||||
# 简化: 这里只看角色名,不查 DB(性能考虑)
|
# 简化: 这里只看角色名,不查 DB(性能考虑)
|
||||||
@@ -372,7 +411,7 @@ def require_permission(
|
|||||||
|
|
||||||
if not has_perm:
|
if not has_perm:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"用户 {current_user.employee_id} 权限不足: "
|
f"用户 {user_id} 权限不足: "
|
||||||
f"角色 {list(user_roles)}, 缺 {perm_string}"
|
f"角色 {list(user_roles)}, 缺 {perm_string}"
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -380,7 +419,7 @@ def require_permission(
|
|||||||
detail=f"权限不足: 需要 {perm_string}",
|
detail=f"权限不足: 需要 {perm_string}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return await func(*args, current_user=current_user, **kwargs)
|
return await func(*args, **{param_name: current_user}, **kwargs)
|
||||||
|
|
||||||
wrapper.__signature__ = new_sig
|
wrapper.__signature__ = new_sig
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ ROLE_PERMISSIONS: Dict[str, Set[Tuple[str, str, str]]] = {
|
|||||||
("conversation", "read", "own"),
|
("conversation", "read", "own"),
|
||||||
("conversation", "read", "all"), # 看所有未分配的会话(坐席工作台需要)
|
("conversation", "read", "all"), # 看所有未分配的会话(坐席工作台需要)
|
||||||
("conversation", "update", "own"),
|
("conversation", "update", "own"),
|
||||||
|
("conversation", "update", "all"), # 抢单需要能更新其他坐席的会话
|
||||||
("conversation", "create", "all"),
|
("conversation", "create", "all"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+136
-12
@@ -4,7 +4,7 @@
|
|||||||
// 说明:创建 Axios 实例,配置:
|
// 说明:创建 Axios 实例,配置:
|
||||||
// 1. 请求基础 URL
|
// 1. 请求基础 URL
|
||||||
// 2. 请求拦截器(添加认证头等)
|
// 2. 请求拦截器(添加认证头等)
|
||||||
// 3. 响应拦截器(统一错误处理)
|
// 3. 响应拦截器(统一错误处理 + Token 静默刷新)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@@ -29,6 +29,129 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// AUTH-P0-02: Token 静默刷新机制
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Token 存储键名
|
||||||
|
const TOKEN_KEY = 'agent_token'
|
||||||
|
|
||||||
|
// OAuth2 重定向计数器 key
|
||||||
|
const OAUTH_REDIRECT_COUNT_KEY = 'oauth_redirect_count'
|
||||||
|
// 最大允许重定向次数
|
||||||
|
const OAUTH_MAX_REDIRECT_COUNT = 3
|
||||||
|
|
||||||
|
// 401 处理锁(防止并发请求同时触发多次认证过期处理)
|
||||||
|
let _authExpiredPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
// Token 刷新锁(防止多个并发请求同时触发刷新)
|
||||||
|
let _refreshPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默刷新 Token
|
||||||
|
* 在 Token 过期前 5 分钟自动调用,成功后返回 true
|
||||||
|
*/
|
||||||
|
async function silentRefreshToken(): Promise<boolean> {
|
||||||
|
// 如果已有刷新在进行中,等待同一个 Promise
|
||||||
|
if (_refreshPromise) {
|
||||||
|
return _refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
if (!token) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API] 尝试静默刷新 Token...')
|
||||||
|
|
||||||
|
// 创建刷新 Promise 并缓存
|
||||||
|
_refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/auth/refresh', null, {
|
||||||
|
params: { token },
|
||||||
|
timeout: 5000, // 刷新接口 5 秒超时
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
// 刷新成功,Token TTL 已延长
|
||||||
|
console.log('[API] Token 静默刷新成功')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[API] Token 刷新失败:', response.data.message)
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[API] Token 刷新异常:', e)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
_refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return _refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认证过期/未授权
|
||||||
|
* AUTH-P0-02: 增强 401 处理,先尝试刷新 Token,成功则重放请求
|
||||||
|
*/
|
||||||
|
async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
|
||||||
|
const label = source === 'http401' ? 'HTTP 401' : '业务码 1002'
|
||||||
|
|
||||||
|
// 如果已有 401 正在处理中,复用同一个 Promise,避免多次处理
|
||||||
|
if (_authExpiredPromise) {
|
||||||
|
console.warn(`[API] ${label} 未授权 — 已有处理进行中,等待完成`)
|
||||||
|
return _authExpiredPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[API] ${label} 未授权,尝试刷新 Token...`)
|
||||||
|
|
||||||
|
// 创建处理 Promise 并缓存(去重用)
|
||||||
|
_authExpiredPromise = (async () => {
|
||||||
|
try {
|
||||||
|
// 第一步:尝试静默刷新 Token
|
||||||
|
const refreshSuccess = await silentRefreshToken()
|
||||||
|
|
||||||
|
if (refreshSuccess) {
|
||||||
|
console.log('[API] Token 刷新成功,不需要跳转')
|
||||||
|
_authExpiredPromise = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:刷新失败,清除凭证并跳转登录
|
||||||
|
console.warn('[API] Token 刷新失败,清除凭证并跳转登录')
|
||||||
|
|
||||||
|
// 清除本地 token
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem('portal_token')
|
||||||
|
|
||||||
|
// 跳转登录页
|
||||||
|
ElMessage.warning('登录已过期,请重新登录')
|
||||||
|
|
||||||
|
// 防循环检测:超过最大重定向次数时停止跳转
|
||||||
|
const currentCount = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
||||||
|
if (currentCount >= OAUTH_MAX_REDIRECT_COUNT) {
|
||||||
|
console.error('[API] 登录重定向次数超限,疑似无限循环,停止重定向')
|
||||||
|
ElMessage.error('登录状态异常,请刷新页面重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[API] 登录重定向计数: ${currentCount}/${OAUTH_MAX_REDIRECT_COUNT}`)
|
||||||
|
|
||||||
|
// 动态导入避免循环依赖
|
||||||
|
import('@/router').then(router => {
|
||||||
|
router.default.push('/login')
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[API] 401 处理失败:', e)
|
||||||
|
} finally {
|
||||||
|
// 处理完成后清除锁,允许未来的 401 重新触发
|
||||||
|
_authExpiredPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return _authExpiredPromise
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -36,7 +159,7 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
// 从 localStorage 获取坐席 token,添加到请求头
|
// 从 localStorage 获取坐席 token,添加到请求头
|
||||||
const token = localStorage.getItem('agent_token')
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -63,13 +186,10 @@ apiClient.interceptors.response.use(
|
|||||||
// 业务错误:显示错误消息
|
// 业务错误:显示错误消息
|
||||||
ElMessage.error(res.message || '请求失败')
|
ElMessage.error(res.message || '请求失败')
|
||||||
|
|
||||||
// 特殊错误码处理
|
// AUTH-P0-02: 特殊错误码处理 - 尝试刷新 Token
|
||||||
if (res.code === 1002) {
|
if (res.code === 1002) {
|
||||||
// 未授权:跳转到登录页
|
// 未授权:先尝试刷新 Token
|
||||||
// 动态导入避免循环依赖
|
handleAuthExpired('biz1002')
|
||||||
import('@/router').then(router => {
|
|
||||||
router.default.push('/login')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 rejected Promise,让调用方的 catch 能捕获
|
// 返回 rejected Promise,让调用方的 catch 能捕获
|
||||||
@@ -79,7 +199,7 @@ apiClient.interceptors.response.use(
|
|||||||
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据)
|
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据)
|
||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
// 网络错误或服务器错误(HTTP 状态码非 2xx)
|
// 网络错误或服务器错误(HTTP 状态码非 2xx)
|
||||||
let message = '网络异常,请稍后重试'
|
let message = '网络异常,请稍后重试'
|
||||||
|
|
||||||
@@ -87,7 +207,9 @@ apiClient.interceptors.response.use(
|
|||||||
// 服务器返回了错误状态码
|
// 服务器返回了错误状态码
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
message = '未授权,请重新登录'
|
// AUTH-P0-02: 先尝试静默刷新 Token,成功则重放请求
|
||||||
|
await handleAuthExpired('http401')
|
||||||
|
// 不显示通用提示,因为会自动处理
|
||||||
break
|
break
|
||||||
case 403:
|
case 403:
|
||||||
message = '拒绝访问'
|
message = '拒绝访问'
|
||||||
@@ -106,8 +228,10 @@ apiClient.interceptors.response.use(
|
|||||||
message = '请求超时,请稍后重试'
|
message = '请求超时,请稍后重试'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示错误提示
|
// 显示错误提示(401 时不显示通用提示,因为会自动处理)
|
||||||
ElMessage.error(message)
|
if (!error.response || error.response.status !== 401) {
|
||||||
|
ElMessage.error(message)
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ const routes = [
|
|||||||
title: '正在加载...',
|
title: '正在加载...',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 错误提示页面(OAuth2 回调异常兜底)
|
||||||
|
path: '/error',
|
||||||
|
name: 'ErrorPage',
|
||||||
|
component: () => import('@/views/ErrorPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '登录失败',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 错误提示页面(/login/error 别名,兼容后端重定向)
|
||||||
|
path: '/login/error',
|
||||||
|
name: 'LoginError',
|
||||||
|
component: () => import('@/views/ErrorPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '登录失败',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 404 页面
|
// 404 页面
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<!-- OAuth2 登录失败错误页面 -->
|
||||||
|
<div class="portal-error">
|
||||||
|
<div class="error-content">
|
||||||
|
<!-- 错误图标 -->
|
||||||
|
<div class="error-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75.75 0 000 1.5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录失败标题 -->
|
||||||
|
<h2 class="error-title">登录失败</h2>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<p class="error-message">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<!-- 重新登录按钮 -->
|
||||||
|
<button class="back-button" @click="retryLogin">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||||
|
<path fill-rule="evenodd" d="M4.5 9.75a6 6 0 0111.573-2.226 3.75 3.75 0 014.133 4.303A4.5 4.5 0 0118 20.25H6.75a5.25 5.25 0 01-2.23-10.004 6.072 6.072 0 01-.02-.496z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
重新登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 从路由 query 获取错误码和错误信息
|
||||||
|
const errorCode = computed(() => {
|
||||||
|
const code = route.query.code as string
|
||||||
|
return code || 'Error'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 错误信息:优先使用 message 参数,否则根据错误码生成友好提示
|
||||||
|
const errorMessage = computed(() => {
|
||||||
|
const message = route.query.message as string
|
||||||
|
if (message) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
// 根据错误码生成友好提示
|
||||||
|
const code = errorCode.value
|
||||||
|
const errorMessages: Record<string, string> = {
|
||||||
|
'oauth_failed': '登录过程出现异常,请重试',
|
||||||
|
'missing_params': '授权参数不完整,请重试',
|
||||||
|
'state_expired': '授权已过期,请重新进入',
|
||||||
|
'api_failed': '企业微信服务异常,请重试',
|
||||||
|
'empty_userid': '无法获取您的企业微信身份,请重试',
|
||||||
|
'wecom_48001': '应用权限不足,请联系管理员',
|
||||||
|
'wecom_50001': '授权已失效,请重新进入',
|
||||||
|
}
|
||||||
|
return errorMessages[code] || '认证过程中出现错误,请重试'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新登录:跳转回首页重新发起 OAuth2 流程
|
||||||
|
const retryLogin = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 如果没有错误信息,重定向回入口
|
||||||
|
if (!route.query.code && !route.query.message) {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 页面容器 */
|
||||||
|
.portal-error {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误内容 */
|
||||||
|
.error-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误图标 - 企微绿色 */
|
||||||
|
.error-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
color: #10b981;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录失败标题 */
|
||||||
|
.error-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误信息 */
|
||||||
|
.error-message {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 360px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重新登录按钮 - 企微绿色主题 */
|
||||||
|
.back-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #10b981;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
playwright>=1.40.0
|
||||||
|
pyotp>=2.9.0
|
||||||
|
requests>=2.28.0
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
"""
|
||||||
|
JumpServer 运维工具集 — 统一入口
|
||||||
|
|
||||||
|
整合能力:
|
||||||
|
1. exec — 远程命令执行 (单命令/批量/并行)
|
||||||
|
2. upload — 文件上传 (base64 通道, 小文件)
|
||||||
|
3. download — 文件下载 (base64 通道, 小文件)
|
||||||
|
4. batch — 批量命令复用会话 (一次登录, 多命令)
|
||||||
|
|
||||||
|
技术栈:
|
||||||
|
- Playwright (登录 + cookies)
|
||||||
|
- REST API (Connection Token)
|
||||||
|
- plink PTY (命令执行)
|
||||||
|
- base64 编码 (文件传输)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python jms_ops.py exec -c "hostname"
|
||||||
|
python jms_ops.py exec -c "hostname" -c "uptime" -c "docker ps"
|
||||||
|
python jms_ops.py exec -c "hostname" -c "uptime" --parallel
|
||||||
|
python jms_ops.py upload local_file.txt /tmp/remote_file.txt
|
||||||
|
python jms_ops.py download /tmp/remote_file.txt local_file.txt
|
||||||
|
python jms_ops.py batch -f commands.txt
|
||||||
|
"""
|
||||||
|
import sys, os, json, base64, time, pyotp, re, subprocess, threading, argparse, hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 配置
|
||||||
|
# ============================================================
|
||||||
|
# 动态路径:基于环境变量或自动检测
|
||||||
|
_WORKBUDDY_DIR = Path(os.environ.get("WORKBUDDY_DIR", Path.home() / ".workbuddy"))
|
||||||
|
_SKILL_DIR = Path(os.environ.get("JP_SKILL_DIR", _WORKBUDDY_DIR / "skills" / "jumpserver-automation-shareable"))
|
||||||
|
CONFIG_PATH = _SKILL_DIR / "config" / "jumpserver_config.json"
|
||||||
|
OTP_SECRET_PATH = _SKILL_DIR / "scripts" / "otp_secret.key"
|
||||||
|
OUTPUT_DIR = _SKILL_DIR / "scripts" / "webcli_output"
|
||||||
|
USER_DATA_DIR = str(Path(os.environ.get("TEMP", os.path.join(str(Path.home()), "AppData", "Local", "Temp"))) / "chrome-jumpserver-v10")
|
||||||
|
CDP_URL = os.environ.get("CDP_URL", "http://localhost:9224")
|
||||||
|
|
||||||
|
SSH_GATEWAY = "jumpserver.dc.servyou-it.com"
|
||||||
|
SSH_PORT = "2222"
|
||||||
|
PLINK_EXE = r"C:\Program Files\PuTTY\plink.exe"
|
||||||
|
|
||||||
|
# ANSI 转义码 (含 CSI + OSC)
|
||||||
|
ANSI_ESCAPE_RE = re.compile(
|
||||||
|
r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))'
|
||||||
|
)
|
||||||
|
# shell prompt: [admin@host ~]$
|
||||||
|
PROMPT_RE = re.compile(r'\[[^\]]+@[^\]]+\][^\$#]*[\$#]\s*$')
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 配置加载
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f: cfg = json.load(f)
|
||||||
|
return cfg["url"].rstrip("/"), cfg.get("username", "sxn"), base64.b64decode(cfg["password"]).decode("utf-8")
|
||||||
|
|
||||||
|
def load_otp():
|
||||||
|
return OTP_SECRET_PATH.read_text().strip() if OTP_SECRET_PATH.exists() else None
|
||||||
|
|
||||||
|
def strip_ansi(text):
|
||||||
|
return ANSI_ESCAPE_RE.sub('', text)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 浏览器登录 + REST API (获取 Connection Token)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def ensure_logged_in(context, url, username, password, otp_code):
|
||||||
|
"""确保 workbench tab 已登录"""
|
||||||
|
workbench = None
|
||||||
|
for pg in context.pages:
|
||||||
|
if 'workbench' in pg.url.lower():
|
||||||
|
workbench = pg
|
||||||
|
break
|
||||||
|
if not workbench:
|
||||||
|
workbench = context.new_page()
|
||||||
|
workbench.goto(f"{url}/users/login/", wait_until="networkidle", timeout=30000)
|
||||||
|
workbench.wait_for_timeout(2000)
|
||||||
|
if workbench.locator('input[name="username"]').count() > 0:
|
||||||
|
workbench.fill('input[name="username"]', username)
|
||||||
|
workbench.fill('input[type="password"]', password)
|
||||||
|
workbench.click('button[type="submit"]')
|
||||||
|
workbench.wait_for_timeout(3000)
|
||||||
|
if workbench.locator('input[name="code"]').count() > 0:
|
||||||
|
workbench.locator('input[name="code"]').fill(str(otp_code))
|
||||||
|
workbench.locator('#submit_button, button[type="submit"]').first.click()
|
||||||
|
for i in range(40):
|
||||||
|
if workbench.locator('input[name="code"]').count() == 0:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
body_text = workbench.evaluate("() => document.body ? document.body.innerText : ''")
|
||||||
|
if any(kw in body_text for kw in ['验证码错误', 'OTP 错误', '已过期', 'expired', 'Invalid']):
|
||||||
|
break
|
||||||
|
except: pass
|
||||||
|
workbench.wait_for_timeout(500)
|
||||||
|
for _ in range(60):
|
||||||
|
if 'workbench' in workbench.url.lower():
|
||||||
|
break
|
||||||
|
workbench.wait_for_timeout(500)
|
||||||
|
return workbench
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection_tokens(count=1):
|
||||||
|
"""
|
||||||
|
获取 N 个 Connection Token
|
||||||
|
|
||||||
|
一次 Playwright 登录 → REST API 创建 N 个 token
|
||||||
|
返回: [(token_id, token_secret), ...]
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
url, username, password = load_config()
|
||||||
|
otp = pyotp.TOTP(load_otp()).now() if load_otp() else None
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp(CDP_URL)
|
||||||
|
contexts = browser.contexts
|
||||||
|
context = contexts[0] if contexts else browser.new_context()
|
||||||
|
except Exception as e:
|
||||||
|
context = p.chromium.launch_persistent_context(
|
||||||
|
USER_DATA_DIR, headless=False, channel="chrome",
|
||||||
|
args=["--no-sandbox", "--remote-debugging-port=9224"],
|
||||||
|
ignore_https_errors=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
workbench = ensure_logged_in(context, url, username, password, otp)
|
||||||
|
if not workbench:
|
||||||
|
print("❌ 登录失败")
|
||||||
|
return []
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
csrf_token = None
|
||||||
|
for c in context.cookies():
|
||||||
|
session.cookies.set(c['name'], c['value'], domain=c.get('domain', ''), path=c.get('path', '/'))
|
||||||
|
if c['name'] == 'jms_csrftoken':
|
||||||
|
csrf_token = c['value']
|
||||||
|
if csrf_token:
|
||||||
|
session.headers['X-CSRFToken'] = csrf_token
|
||||||
|
|
||||||
|
# 获取资产
|
||||||
|
resp = session.get(f"{url}/api/v1/perms/users/assets/", params={"offset": 0, "limit": 100})
|
||||||
|
assets = resp.json() if resp.status_code == 200 else []
|
||||||
|
if isinstance(assets, dict): assets = assets.get("results", [])
|
||||||
|
target_asset = next((a for a in assets if "hz-oa-ai-g-dataquery" in a.get("hostname", "").lower()), None)
|
||||||
|
if not target_asset:
|
||||||
|
print("❌ 未找到目标资产")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 获取系统用户
|
||||||
|
resp = session.get(f"{url}/api/v1/perms/users/assets/{target_asset['id']}/system-users/")
|
||||||
|
system_users = resp.json() if resp.status_code == 200 else []
|
||||||
|
if isinstance(system_users, dict): system_users = system_users.get("results", [])
|
||||||
|
target_su = next((su for su in system_users if "admin" in su.get("name", "").lower()), None)
|
||||||
|
if not target_su and system_users: target_su = system_users[0]
|
||||||
|
if not target_su:
|
||||||
|
print("❌ 未找到系统用户")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 创建 count 个 token
|
||||||
|
tokens = []
|
||||||
|
for i in range(count):
|
||||||
|
resp = session.post(
|
||||||
|
f"{url}/api/v1/authentication/connection-token/",
|
||||||
|
json={"asset": target_asset['id'], "system_user": target_su['id'], "connect_method": "ssh_client"}
|
||||||
|
)
|
||||||
|
if resp.status_code == 201:
|
||||||
|
td = resp.json()
|
||||||
|
tokens.append((td['id'], td['secret']))
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PlinkSession — plink PTY 会话管理器
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class PlinkSession:
|
||||||
|
"""
|
||||||
|
plink PTY 会话管理器
|
||||||
|
|
||||||
|
一次启动 plink, 在同一个会话里执行多个命令。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ssh_user, ssh_password, connect_timeout=30, verbose=True):
|
||||||
|
self.ssh_user = ssh_user
|
||||||
|
self.ssh_password = ssh_password
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
self.verbose = verbose
|
||||||
|
self.proc = None
|
||||||
|
self.stdout_chunks = []
|
||||||
|
self.stderr_chunks = []
|
||||||
|
self.stop_reading = threading.Event()
|
||||||
|
self._stdout_thread = None
|
||||||
|
self._stderr_thread = None
|
||||||
|
self._ready = False
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""启动 plink, 等待 shell prompt 就绪"""
|
||||||
|
if self.verbose:
|
||||||
|
print(f" 🚀 启动 plink PTY 会话...")
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[PLINK_EXE, '-ssh', '-P', SSH_PORT, '-t', '-pw', self.ssh_password, f'{self.ssh_user}@{SSH_GATEWAY}'],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 后台线程逐块读取
|
||||||
|
def reader(stream, buf_list):
|
||||||
|
fd = stream.fileno()
|
||||||
|
while not self.stop_reading.is_set():
|
||||||
|
try:
|
||||||
|
chunk = os.read(fd, 4096)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf_list.append(chunk)
|
||||||
|
|
||||||
|
self._stdout_thread = threading.Thread(target=reader, args=(self.proc.stdout, self.stdout_chunks), daemon=True)
|
||||||
|
self._stderr_thread = threading.Thread(target=reader, args=(self.proc.stderr, self.stderr_chunks), daemon=True)
|
||||||
|
self._stdout_thread.start()
|
||||||
|
self._stderr_thread.start()
|
||||||
|
|
||||||
|
# 等待 shell prompt 就绪
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < self.connect_timeout:
|
||||||
|
clean = strip_ansi(self._get_stdout())
|
||||||
|
stderr_clean = strip_ansi(self._get_stderr())
|
||||||
|
combined = clean + '\n' + stderr_clean
|
||||||
|
|
||||||
|
if 'Press Return to begin session' in combined:
|
||||||
|
self._send('\r\n')
|
||||||
|
time.sleep(0.5)
|
||||||
|
elif PROMPT_RE.search(clean):
|
||||||
|
# 设置终端宽度, 防止长行折行
|
||||||
|
self._send('stty cols 1000 2>/dev/null\r\n')
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.stdout_chunks.clear()
|
||||||
|
self._ready = True
|
||||||
|
if self.verbose:
|
||||||
|
print(f" ✅ 会话就绪 ({time.time()-start:.1f}s)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f" ❌ 会话超时 ({self.connect_timeout}s)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_stdout(self):
|
||||||
|
return b''.join(self.stdout_chunks).decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
def _get_stderr(self):
|
||||||
|
return b''.join(self.stderr_chunks).decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
def _send(self, text):
|
||||||
|
if self.proc and self.proc.stdin:
|
||||||
|
self.proc.stdin.write(text.encode('utf-8'))
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
|
||||||
|
def run_command(self, command, timeout=15):
|
||||||
|
"""
|
||||||
|
在当前会话中执行一个命令
|
||||||
|
|
||||||
|
返回: {"success": bool, "output": str, "elapsed": float, "timed_out": bool}
|
||||||
|
"""
|
||||||
|
if not self._ready:
|
||||||
|
return {"success": False, "output": "", "elapsed": 0, "timed_out": False, "error": "session not ready"}
|
||||||
|
|
||||||
|
offset = len(self._get_stdout())
|
||||||
|
cmd_start = time.time()
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
print(f" 💻 [{time.strftime('%H:%M:%S')}] 发送: {command[:80]}")
|
||||||
|
self._send(f'{command}\r\n')
|
||||||
|
|
||||||
|
# 轮询检测 prompt 重新出现
|
||||||
|
while time.time() - cmd_start < timeout:
|
||||||
|
new_stdout = self._get_stdout()[offset:]
|
||||||
|
new_clean = strip_ansi(new_stdout)
|
||||||
|
|
||||||
|
lines = new_clean.split('\n')
|
||||||
|
for line in lines[-4:]:
|
||||||
|
stripped = line.strip()
|
||||||
|
if command in stripped:
|
||||||
|
continue
|
||||||
|
if PROMPT_RE.search(stripped):
|
||||||
|
elapsed = time.time() - cmd_start
|
||||||
|
# 提取命令输出
|
||||||
|
output_lines = []
|
||||||
|
found_echo = False
|
||||||
|
for l in lines:
|
||||||
|
s = l.strip()
|
||||||
|
if not found_echo:
|
||||||
|
if command in s:
|
||||||
|
found_echo = True
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if PROMPT_RE.search(s):
|
||||||
|
break
|
||||||
|
if s:
|
||||||
|
output_lines.append(s)
|
||||||
|
|
||||||
|
if not output_lines and not found_echo:
|
||||||
|
for i, l in enumerate(lines):
|
||||||
|
s = l.strip()
|
||||||
|
if command in s:
|
||||||
|
for j in range(i + 1, len(lines)):
|
||||||
|
s2 = lines[j].strip()
|
||||||
|
if PROMPT_RE.search(s2):
|
||||||
|
break
|
||||||
|
if s2:
|
||||||
|
output_lines.append(s2)
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"output": '\n'.join(output_lines),
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"timed_out": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 超时
|
||||||
|
elapsed = time.time() - cmd_start
|
||||||
|
new_stdout = self._get_stdout()[offset:]
|
||||||
|
if self.verbose:
|
||||||
|
print(f" ⏰ 超时 ({elapsed:.1f}s)")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"output": strip_ansi(new_stdout).strip(),
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"timed_out": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""发送 exit, 关闭会话"""
|
||||||
|
if self._ready and self.proc:
|
||||||
|
try:
|
||||||
|
self._send('exit\r\n')
|
||||||
|
time.sleep(0.5)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
self.stop_reading.set()
|
||||||
|
if self._stdout_thread:
|
||||||
|
self._stdout_thread.join(timeout=2)
|
||||||
|
if self._stderr_thread:
|
||||||
|
self._stderr_thread.join(timeout=2)
|
||||||
|
|
||||||
|
if self.proc:
|
||||||
|
try: self.proc.stdin.close()
|
||||||
|
except: pass
|
||||||
|
try: self.proc.wait(timeout=3)
|
||||||
|
except:
|
||||||
|
try: self.proc.kill(); self.proc.wait(timeout=2)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 命令执行: exec / batch
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def cmd_exec(commands, cmd_timeout=15, parallel=False):
|
||||||
|
"""执行命令 (单条或多条, 串行或并行)"""
|
||||||
|
os.makedirs(str(OUTPUT_DIR), exist_ok=True)
|
||||||
|
total_start = time.time()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if not parallel:
|
||||||
|
# 串行: 一个 token + 一个会话
|
||||||
|
print(f"模式: 串行 ({len(commands)} 命令, 超时 {cmd_timeout}s/命令)")
|
||||||
|
tokens = get_connection_tokens(1)
|
||||||
|
if not tokens:
|
||||||
|
print("❌ 获取 token 失败")
|
||||||
|
return []
|
||||||
|
|
||||||
|
token_id, token_secret = tokens[0]
|
||||||
|
session = PlinkSession(f"JMS-{token_id}", token_secret)
|
||||||
|
if not session.connect():
|
||||||
|
print("❌ 会话启动失败")
|
||||||
|
return [{"command": c, "success": False, "output": "", "elapsed": 0, "timed_out": False} for c in commands]
|
||||||
|
|
||||||
|
for i, cmd in enumerate(commands, 1):
|
||||||
|
print(f"\n--- [{i}/{len(commands)}] ---")
|
||||||
|
result = session.run_command(cmd, timeout=cmd_timeout)
|
||||||
|
result["command"] = cmd
|
||||||
|
result["index"] = i
|
||||||
|
status = "✅" if result["success"] else ("⏰" if result.get("timed_out") else "❌")
|
||||||
|
print(f" {status} 完成 ({result['elapsed']:.1f}s)")
|
||||||
|
if result["output"]:
|
||||||
|
for line in result["output"].split("\n"):
|
||||||
|
if line.strip():
|
||||||
|
print(f" | {line.strip()[:120]}")
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 并行: N 个 token + N 个会话
|
||||||
|
print(f"模式: 并行 ({len(commands)} 命令, 超时 {cmd_timeout}s/命令)")
|
||||||
|
tokens = get_connection_tokens(len(commands))
|
||||||
|
if len(tokens) < len(commands):
|
||||||
|
print(f"⚠️ 只获取到 {len(tokens)}/{len(commands)} 个 token, 降级为串行")
|
||||||
|
return cmd_exec(commands, cmd_timeout=cmd_timeout, parallel=False)
|
||||||
|
|
||||||
|
def execute_single(idx, cmd, tid, tsec):
|
||||||
|
session = PlinkSession(f"JMS-{tid}", tsec, verbose=False)
|
||||||
|
if not session.connect():
|
||||||
|
return {"command": cmd, "success": False, "output": "", "elapsed": 0, "timed_out": False, "index": idx}
|
||||||
|
result = session.run_command(cmd, timeout=cmd_timeout)
|
||||||
|
result["command"] = cmd
|
||||||
|
result["index"] = idx
|
||||||
|
session.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
threads = []
|
||||||
|
thread_results = [None] * len(commands)
|
||||||
|
|
||||||
|
def worker(idx, cmd, tid, tsec):
|
||||||
|
thread_results[idx] = execute_single(idx, cmd, tid, tsec)
|
||||||
|
|
||||||
|
for i, cmd in enumerate(commands):
|
||||||
|
tid, tsec = tokens[i]
|
||||||
|
t = threading.Thread(target=worker, args=(i, cmd, tid, tsec))
|
||||||
|
threads.append(t)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=cmd_timeout + 60)
|
||||||
|
|
||||||
|
results = [r for r in thread_results if r is not None]
|
||||||
|
results.sort(key=lambda x: x.get("index", 0))
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
status = "✅" if r.get("success") else "❌"
|
||||||
|
print(f" [{r.get('index')}] {status} {r.get('elapsed', 0):.1f}s — {r.get('command', '')[:50]}")
|
||||||
|
|
||||||
|
total_elapsed = time.time() - total_start
|
||||||
|
success_count = sum(1 for r in results if r.get("success"))
|
||||||
|
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f"汇总: {success_count}/{len(commands)} 成功, 总耗时 {total_elapsed:.1f}s")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
|
||||||
|
# 保存报告
|
||||||
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
report_path = OUTPUT_DIR / f"exec_{timestamp}.md"
|
||||||
|
lines = [
|
||||||
|
f"# JumpServer 执行报告",
|
||||||
|
f"",
|
||||||
|
f"**时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
f"**模式**: {'并行' if parallel else '串行'}",
|
||||||
|
f"**命令数**: {len(commands)}",
|
||||||
|
f"**成功**: {success_count}/{len(commands)}",
|
||||||
|
f"**总耗时**: {total_elapsed:.1f}s",
|
||||||
|
f"",
|
||||||
|
]
|
||||||
|
for r in results:
|
||||||
|
status = "✅" if r.get("success") else "❌"
|
||||||
|
lines.append(f"## [{r.get('index', '?')}] {status} {r.get('elapsed', 0):.1f}s — {r.get('command', '')[:60]}")
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f"```")
|
||||||
|
lines.append(r.get("output", "(空)"))
|
||||||
|
lines.append(f"```")
|
||||||
|
lines.append(f"")
|
||||||
|
report_path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
print(f"💾 报告: {report_path}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 文件传输: upload / download (base64 通道)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def cmd_upload(local_path, remote_path, cmd_timeout=60):
|
||||||
|
"""
|
||||||
|
上传本地文件到远程 (base64 编码, 分块发送)
|
||||||
|
|
||||||
|
适用: 小文件 (配置/脚本/文本, <100KB)
|
||||||
|
大文件建议用 elFinder Web UI
|
||||||
|
"""
|
||||||
|
local_file = Path(local_path)
|
||||||
|
if not local_file.exists():
|
||||||
|
print(f"❌ 本地文件不存在: {local_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
local_data = local_file.read_bytes()
|
||||||
|
local_md5 = hashlib.md5(local_data).hexdigest()
|
||||||
|
b64_data = base64.b64encode(local_data).decode('ascii')
|
||||||
|
|
||||||
|
print(f"📤 上传: {local_path} → {remote_path}")
|
||||||
|
print(f" 原始: {len(local_data)} bytes, base64: {len(b64_data)} chars, MD5: {local_md5[:12]}...")
|
||||||
|
|
||||||
|
# 分块 (每块 500 chars, PTY 宽度 1000 留余量)
|
||||||
|
CHUNK_SIZE = 500
|
||||||
|
chunks = [b64_data[i:i+CHUNK_SIZE] for i in range(0, len(b64_data), CHUNK_SIZE)]
|
||||||
|
print(f" 分 {len(chunks)} 块发送")
|
||||||
|
|
||||||
|
# 获取 token + 启动会话
|
||||||
|
tokens = get_connection_tokens(1)
|
||||||
|
if not tokens:
|
||||||
|
print("❌ 获取 token 失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token_id, token_secret = tokens[0]
|
||||||
|
session = PlinkSession(f"JMS-{token_id}", token_secret)
|
||||||
|
if not session.connect():
|
||||||
|
print("❌ 会话启动失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 清空目标文件
|
||||||
|
session.run_command(f'> {remote_path}', timeout=5)
|
||||||
|
|
||||||
|
# 2. 逐块追加
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
cmd = f"echo '{chunk}' | base64 -d >> {remote_path}"
|
||||||
|
r = session.run_command(cmd, timeout=10)
|
||||||
|
if not r["success"]:
|
||||||
|
print(f" ❌ 块 {i+1}/{len(chunks)} 发送失败")
|
||||||
|
return False
|
||||||
|
if (i+1) % 20 == 0 or (i+1) == len(chunks):
|
||||||
|
print(f" 📦 已发送 {i+1}/{len(chunks)} 块")
|
||||||
|
|
||||||
|
# 3. 验证大小
|
||||||
|
r = session.run_command(f'wc -c < {remote_path}', timeout=5)
|
||||||
|
if r["success"]:
|
||||||
|
remote_size = int(r["output"].strip()) if r["output"].strip().isdigit() else -1
|
||||||
|
if remote_size == len(local_data):
|
||||||
|
print(f" ✅ 上传成功! 大小匹配 ({remote_size} bytes)")
|
||||||
|
|
||||||
|
# 4. MD5 验证 (可选, 文本文件可能因换行符不匹配)
|
||||||
|
r2 = session.run_command(f'md5sum {remote_path}', timeout=5)
|
||||||
|
if r2["success"]:
|
||||||
|
remote_md5 = r2["output"].split()[0]
|
||||||
|
if remote_md5 == local_md5:
|
||||||
|
print(f" ✅ MD5 匹配! 文件完整")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ MD5 不匹配 (本地 {local_md5[:12]}, 远程 {remote_md5[:12]})")
|
||||||
|
print(f" 可能原因: PTY 换行符转换, 文本内容应可读")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ❌ 大小不匹配 (本地 {len(local_data)}, 远程 {remote_size})")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ 无法验证远程文件大小")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_download(remote_path, local_path, cmd_timeout=30):
|
||||||
|
"""
|
||||||
|
下载远程文件到本地 (base64 编码, marker 提取)
|
||||||
|
|
||||||
|
适用: 小文件
|
||||||
|
"""
|
||||||
|
print(f"📥 下载: {remote_path} → {local_path}")
|
||||||
|
|
||||||
|
# 获取 token + 启动会话
|
||||||
|
tokens = get_connection_tokens(1)
|
||||||
|
if not tokens:
|
||||||
|
print("❌ 获取 token 失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token_id, token_secret = tokens[0]
|
||||||
|
session = PlinkSession(f"JMS-{token_id}", token_secret)
|
||||||
|
if not session.connect():
|
||||||
|
print("❌ 会话启动失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 用 marker 包围 base64 输出, 精确提取
|
||||||
|
cmd = f'echo "<<<B64_START>>>" && base64 {remote_path} && echo "<<<B64_END>>>"'
|
||||||
|
result = session.run_command(cmd, timeout=cmd_timeout)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
print(f"❌ 命令失败: {result.get('output', '')[:200]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
output = result["output"]
|
||||||
|
if '<<<B64_START>>>' in output and '<<<B64_END>>>' in output:
|
||||||
|
b64_part = output.split('<<<B64_START>>>', 1)[1]
|
||||||
|
b64_part = b64_part.split('<<<B64_END>>>', 1)[0]
|
||||||
|
b64_clean = ''.join(b64_part.split())
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_data = base64.b64decode(b64_clean)
|
||||||
|
local_file = Path(local_path)
|
||||||
|
local_file.write_bytes(file_data)
|
||||||
|
local_md5 = hashlib.md5(file_data).hexdigest()
|
||||||
|
print(f" ✅ 下载成功! {len(file_data)} bytes, MD5: {local_md5[:12]}...")
|
||||||
|
print(f" 💾 已保存: {local_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ base64 解码失败: {e}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f" ❌ 未找到 marker (文件可能不存在)")
|
||||||
|
print(f" 输出: {output[:200]}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CLI
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="JumpServer 运维工具集 — 远程命令执行 + 文件传输",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
示例:
|
||||||
|
# 远程命令执行
|
||||||
|
%(prog)s exec -c "hostname"
|
||||||
|
%(prog)s exec -c "hostname" -c "uptime" -c "docker ps"
|
||||||
|
%(prog)s exec -c "hostname" -c "uptime" --parallel
|
||||||
|
|
||||||
|
# 批量命令 (从文件)
|
||||||
|
%(prog)s batch -f commands.txt
|
||||||
|
|
||||||
|
# 文件传输
|
||||||
|
%(prog)s upload local_config.conf /tmp/remote_config.conf
|
||||||
|
%(prog)s download /etc/nginx/nginx.conf ./nginx.conf.bak
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="subcommand", help="子命令")
|
||||||
|
|
||||||
|
# exec
|
||||||
|
p_exec = subparsers.add_parser("exec", help="远程命令执行 (单条/多条/并行)")
|
||||||
|
p_exec.add_argument("-c", "--command", action="append", required=True, help="远程命令 (可多次指定)")
|
||||||
|
p_exec.add_argument("--cmd-timeout", type=int, default=15, help="每命令超时秒数 (默认 15)")
|
||||||
|
p_exec.add_argument("--parallel", action="store_true", help="并行模式 (每命令独立 token+会话)")
|
||||||
|
|
||||||
|
# batch
|
||||||
|
p_batch = subparsers.add_parser("batch", help="批量命令 (从文件读取)")
|
||||||
|
p_batch.add_argument("-f", "--file", required=True, help="命令文件 (每行一个, # 开头为注释)")
|
||||||
|
p_batch.add_argument("--cmd-timeout", type=int, default=15, help="每命令超时秒数")
|
||||||
|
p_batch.add_argument("--parallel", action="store_true", help="并行模式")
|
||||||
|
|
||||||
|
# upload
|
||||||
|
p_upload = subparsers.add_parser("upload", help="文件上传 (base64 通道, 小文件)")
|
||||||
|
p_upload.add_argument("local", help="本地文件路径")
|
||||||
|
p_upload.add_argument("remote", help="远程文件路径")
|
||||||
|
|
||||||
|
# download
|
||||||
|
p_download = subparsers.add_parser("download", help="文件下载 (base64 通道, 小文件)")
|
||||||
|
p_download.add_argument("remote", help="远程文件路径")
|
||||||
|
p_download.add_argument("local", help="本地保存路径")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.subcommand == "exec":
|
||||||
|
results = cmd_exec(args.command, cmd_timeout=args.cmd_timeout, parallel=args.parallel)
|
||||||
|
success = all(r.get("success") for r in results) if results else False
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
elif args.subcommand == "batch":
|
||||||
|
commands = []
|
||||||
|
for line in Path(args.file).read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
commands.append(line)
|
||||||
|
if not commands:
|
||||||
|
print("❌ 命令文件为空")
|
||||||
|
sys.exit(1)
|
||||||
|
results = cmd_exec(commands, cmd_timeout=args.cmd_timeout, parallel=args.parallel)
|
||||||
|
success = all(r.get("success") for r in results) if results else False
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
elif args.subcommand == "upload":
|
||||||
|
success = cmd_upload(args.local, args.remote)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
elif args.subcommand == "download":
|
||||||
|
success = cmd_download(args.remote, args.local)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user