chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — 工具包初始化
# =============================================================================
# 说明:将 utils/ 目录标记为 Python 包
# =============================================================================
+143
View File
@@ -0,0 +1,143 @@
# =============================================================================
# 企微IT智能服务台 — 统一响应格式工具
# =============================================================================
# 说明:定义所有 API 的统一响应格式和异常处理
# 格式:{code: 0, data: {}, message: "success"}
# code=0 表示成功,非0表示错误(1000+通用/2000+企微/3000+业务)
# =============================================================================
from typing import Any, Dict, Optional
from fastapi import Request
from fastapi.responses import JSONResponse
# --------------------------------------------------------------------------
# 统一响应函数
# --------------------------------------------------------------------------
def success_response(data: Any = None, message: str = "success") -> Dict[str, Any]:
"""构建成功响应。
所有 API 成功时都应使用此函数返回统一格式。
Args:
data: 业务数据(可以是字典、列表、None等)
message: 成功消息(默认 "success"
Returns:
Dict[str, Any]: 统一格式的响应字典
示例: {"code": 0, "data": {...}, "message": "success"}
"""
return {
"code": 0,
"data": data,
"message": message,
}
def error_response(code: int, message: str, data: Any = None) -> Dict[str, Any]:
"""构建错误响应。
所有 API 错误时都应使用此函数返回统一格式。
Args:
code: 错误码(1000+通用/2000+企微/3000+业务)
message: 错误消息
data: 附加数据(可选,如验证错误详情)
Returns:
Dict[str, Any]: 统一格式的错误响应字典
示例: {"code": 1001, "data": null, "message": "参数错误"}
"""
return {
"code": code,
"data": data,
"message": message,
}
# --------------------------------------------------------------------------
# 业务异常类
# --------------------------------------------------------------------------
class AppException(Exception):
"""业务异常基类。
在业务逻辑中抛出此异常,全局异常处理器会自动转换为统一响应格式。
避免在每个路由函数中重复写 try/except 和响应构造代码。
Attributes:
code: 错误码
message: 错误消息
data: 附加数据
"""
def __init__(self, code: int, message: str, data: Any = None):
"""初始化业务异常。
Args:
code: 错误码
message: 错误消息
data: 附加数据
"""
self.code = code
self.message = message
self.data = data
super().__init__(self.message)
# --------------------------------------------------------------------------
# 预定义错误常量
# --------------------------------------------------------------------------
# 错误码规范:
# 0 = 成功
# 1000+ = 通用错误(参数错误、未授权等)
# 2000+ = 企微 API 错误
# 3000+ = 业务逻辑错误
# --- 通用错误 (1000+) ---
ERR_PARAMS = AppException(1001, "参数错误")
ERR_UNAUTHORIZED = AppException(1002, "未授权")
ERR_NOT_FOUND = AppException(1003, "资源不存在")
ERR_FORBIDDEN = AppException(1004, "无权限访问")
ERR_INTERNAL = AppException(1005, "服务器内部错误")
# --- 企微 API 错误 (2000+) ---
ERR_WECOM_TOKEN = AppException(2001, "企微 access_token 获取失败")
ERR_WECOM_SEND = AppException(2002, "企微消息发送失败")
ERR_WECOM_DECRYPT = AppException(2003, "企微消息解密失败")
ERR_WECOM_ENCRYPT = AppException(2004, "企微消息加密失败")
ERR_WECOM_VERIFY = AppException(2005, "企微回调签名验证失败")
ERR_WECOM_USER_INFO = AppException(2006, "企微用户信息获取失败")
# --- 业务逻辑错误 (3000+) ---
ERR_AGENT_OFFLINE = AppException(3001, "坐席不在线")
ERR_CONVERSATION_RESOLVED = AppException(3002, "会话已结单")
ERR_CONVERSATION_NOT_FOUND = AppException(3003, "会话不存在")
ERR_AGENT_NOT_FOUND = AppException(3004, "坐席不存在")
ERR_AGENT_BUSY = AppException(3005, "坐席已满负荷,无法接单")
ERR_DUPLICATE_ASSIGN = AppException(3006, "会话已分配坐席")
ERR_GRAB_NO_AGENT = AppException(3011, "该会话尚未分配坐席,请使用接单功能")
ERR_GRAB_SELF = AppException(3012, "不能接手自己的会话")
ERR_GRAB_NOT_SERVING = AppException(3013, "只能接手服务中的会话")
# --------------------------------------------------------------------------
# 全局异常处理器
# --------------------------------------------------------------------------
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
"""AppException 全局异常处理器。
当业务逻辑抛出 AppException 时,FastAPI 自动调用此处理器,
将异常转换为统一响应格式返回给前端。
Args:
request: 请求对象(FastAPI 自动传入)
exc: 业务异常对象(FastAPI 自动传入)
Returns:
JSONResponse: 统一格式的错误响应
"""
return JSONResponse(
status_code=200, # 业务错误仍返回 HTTP 200,通过 code 区分
content=error_response(exc.code, exc.message, exc.data),
)
+123
View File
@@ -0,0 +1,123 @@
# =============================================================================
# 企微IT智能服务台 — access_token 缓存管理器
# =============================================================================
# 说明:管理企微 access_token 的获取和缓存
# 1. 优先从 Redis 缓存获取
# 2. 缓存不存在或即将过期则重新获取
# 3. access_token 有效期 7200 秒,提前 300 秒刷新
# =============================================================================
import logging
from typing import Optional
import httpx
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
class TokenManager:
"""企微 access_token 缓存管理器。
封装 access_token 的获取、缓存和自动刷新逻辑。
使用 Redis 作为缓存存储,避免频繁调用企微 API。
Attributes:
redis: Redis 异步客户端
corp_id: 企业ID
corp_secret: 应用Secret
"""
# Redis 缓存 key
CACHE_KEY = "wecom:access_token"
# access_token 有效期(秒)
TOKEN_EXPIRES = 7200
# 提前刷新时间(秒)
BUFFER_SECONDS = 300
def __init__(self, redis_client: aioredis.Redis):
"""初始化 token 管理器。
Args:
redis_client: Redis 异步客户端实例
"""
self.redis = redis_client
self.corp_id = settings.wecom_corp_id
self.corp_secret = settings.wecom_secret
self.client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=10.0))
async def get_token(self) -> str:
"""获取 access_token。
优先从 Redis 缓存获取,缓存未命中则调用企微 API 获取。
Returns:
str: access_token 字符串
Raises:
Exception: 获取失败
"""
# 1. 尝试从缓存获取
cached = await self.redis.get(self.CACHE_KEY)
if cached:
logger.debug("从缓存获取 access_token")
return cached.decode("utf-8")
# 2. 缓存未命中,刷新 token
return await self._refresh_token()
async def _refresh_token(self) -> str:
"""调用企微 API 刷新 access_token。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET
Returns:
str: 新获取的 access_token
Raises:
Exception: 获取失败
"""
logger.info("刷新 access_token")
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.corp_secret,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode") != 0:
error_msg = result.get("errmsg", "未知错误")
logger.error(f"获取 access_token 失败: {error_msg}")
raise Exception(f"企微API错误: {error_msg}")
access_token = result["access_token"]
expires_in = result.get("expires_in", self.TOKEN_EXPIRES)
# 缓存到 Redis,TTL = 有效期 - 提前刷新时间
cache_ttl = max(expires_in - self.BUFFER_SECONDS, 60)
await self.redis.setex(self.CACHE_KEY, cache_ttl, access_token)
logger.info(f"access_token 刷新成功,TTL={cache_ttl}")
return access_token
except httpx.HTTPError as e:
logger.error(f"获取 access_token 网络错误: {e}")
raise Exception(f"网络错误: {e}") from e
async def invalidate(self) -> None:
"""手动使缓存失效。
当检测到 token 过期或无效时调用,强制下次请求刷新。
"""
await self.redis.delete(self.CACHE_KEY)
logger.info("access_token 缓存已手动清除")
async def close(self) -> None:
"""关闭 HTTP 客户端。"""
await self.client.aclose()
+376
View File
@@ -0,0 +1,376 @@
# =============================================================================
# 企微IT智能服务台 — 企微消息 AES 加解密工具
# =============================================================================
# 说明:实现企微回调消息的加解密和签名验证
# 参考企微官方加解密库逻辑,使用 Python cryptography 库重写
# 加密模式:AES-CBC-256PKCS7 填充
# 签名算法:SHA1(sort(token, timestamp, nonce, encrypt))
# =============================================================================
import base64
import hashlib
import json
import logging
import secrets
import string
import struct
import time
import xml.etree.ElementTree as ET
from typing import Dict, Optional, Tuple
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
class WecomCrypto:
"""企微消息加解密工具类。
实现企微回调消息的完整加解密流程:
1. 签名验证:SHA1(sort(token, timestamp, nonce, encrypt))
2. AES 解密:CBC 模式,PKCS7 去填充
3. AES 加密:CBC 模式,PKCS7 填充
Attributes:
token: 企微回调配置的 Token
aes_key: 解码后的 AES 密钥(32 字节)
corp_id: 企业ID
"""
def __init__(self, token: str, encoding_aes_key: str, corp_id: str):
"""初始化加解密工具。
Args:
token: 企微回调配置的 Token(用于签名验证)
encoding_aes_key: 企微回调配置的 EncodingAESKey43 位 Base64 字符串)
corp_id: 企业ID(用于消息体中的校验)
"""
self.token = token
# EncodingAESKey 是 43 位 Base64 编码字符串
# 解码时需要先补上 1 个 "=" 使其成为合法的 Base64 字符串
# 解码后得到 32 字节的 AES 密钥(AES-256
self.aes_key = base64.b64decode(encoding_aes_key + "=")
# AES CBC 模式的 IV(初始化向量)取密钥的前 16 字节
self.iv = self.aes_key[:16]
self.corp_id = corp_id
# --------------------------------------------------------------------------
# 签名验证
# --------------------------------------------------------------------------
def verify_signature(
self, signature: str, timestamp: str, nonce: str, encrypt: str
) -> bool:
"""验证企微回调签名。
企微的签名算法:SHA1(sort([token, timestamp, nonce, encrypt]))
将 token、timestamp、nonce、encrypt 四个字符串字典序排列后拼接,计算 SHA1。
Args:
signature: 企微传来的签名(msg_signature 参数)
timestamp: 时间戳
nonce: 随机数
encrypt: 加密的消息内容
Returns:
bool: 签名是否验证通过
"""
# 将四个参数按字典序排列
sort_list = sorted([self.token, timestamp, nonce, encrypt])
# 拼接为一个字符串
concat_str = "".join(sort_list)
# 计算 SHA1 哈希值
computed = hashlib.sha1(concat_str.encode("utf-8")).hexdigest()
# 与传入的签名比较
is_valid = computed == signature
if not is_valid:
logger.warning(
f"签名验证失败: computed={computed}, received={signature}"
)
return is_valid
def generate_signature(
self, timestamp: str, nonce: str, encrypt: str
) -> str:
"""生成签名(用于加密响应消息时)。
Args:
timestamp: 时间戳
nonce: 随机数
encrypt: 加密后的消息内容
Returns:
str: SHA1 签名字符串
"""
sort_list = sorted([self.token, timestamp, nonce, encrypt])
concat_str = "".join(sort_list)
return hashlib.sha1(concat_str.encode("utf-8")).hexdigest()
# --------------------------------------------------------------------------
# AES 解密
# --------------------------------------------------------------------------
def decrypt(self, encrypted_text: str) -> str:
"""AES-CBC 解密企微加密消息。
解密流程:
1. Base64 解码得到密文
2. AES-CBC 解密
3. 去除 PKCS7 填充
4. 提取明文内容(格式:random(16) + msg_len(4) + msg + corp_id
Args:
encrypted_text: Base64 编码的加密消息
Returns:
str: 解密后的明文 XML 字符串
Raises:
ValueError: 解密失败时抛出
"""
try:
# 1. Base64 解码得到密文字节
encrypted_data = base64.b64decode(encrypted_text)
# 2. AES-CBC 解密
cipher = Cipher(
algorithms.AES(self.aes_key),
modes.CBC(self.iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize()
# 3. 去除 PKCS7 填充
# PKCS7: 填充字节的值等于填充的字节数
# 例如填充了 5 个字节,则每个字节的值都是 0x05
pad_len = decrypted_data[-1]
# 校验填充是否合法(填充值应等于填充长度)
if pad_len < 1 or pad_len > 32:
raise ValueError(f"无效的 PKCS7 填充值: {pad_len}")
# 检查所有填充字节是否一致
for i in range(pad_len):
if decrypted_data[-(i + 1)] != pad_len:
raise ValueError("PKCS7 填充校验失败")
# 去除填充部分
plaintext_data = decrypted_data[:-pad_len]
# 4. 提取明文内容
# 企微加密格式:random(16字节) + msg_len(4字节网络序) + msg + corp_id
# 跳过前 16 字节随机串
msg_len = struct.unpack("!I", plaintext_data[16:20])[0]
msg_content = plaintext_data[20 : 20 + msg_len].decode("utf-8")
from_corp_id = plaintext_data[20 + msg_len :].decode("utf-8")
# 5. 校验 corp_id 是否匹配
if from_corp_id != self.corp_id:
raise ValueError(
f"corp_id 不匹配: expected={self.corp_id}, got={from_corp_id}"
)
logger.debug(f"AES 解密成功, 明文长度: {len(msg_content)}")
return msg_content
except Exception as e:
logger.error(f"AES 解密失败: {e}")
raise ValueError(f"消息解密失败: {e}") from e
# --------------------------------------------------------------------------
# AES 加密
# --------------------------------------------------------------------------
def encrypt(self, plaintext: str) -> str:
"""AES-CBC 加密消息(用于被动回复)。
加密流程:
1. 构造明文:random(16) + msg_len(4) + msg + corp_id
2. PKCS7 填充
3. AES-CBC 加密
4. Base64 编码
Args:
plaintext: 要加密的明文字符串
Returns:
str: Base64 编码的加密结果
"""
try:
# 1. 构造明文字节
# 16 字节随机串(增加密文随机性,防止相同明文产生相同密文)
random_str = secrets.token_bytes(16)
# 消息内容字节
msg_bytes = plaintext.encode("utf-8")
# 消息长度(4 字节网络序,大端)
msg_len = struct.pack("!I", len(msg_bytes))
# corp_id 字节
corp_id_bytes = self.corp_id.encode("utf-8")
# 拼接
plaintext_data = random_str + msg_len + msg_bytes + corp_id_bytes
# 2. PKCS7 填充
# 块大小为 32 字节(AES-256 的块大小实际是 16 字节,
# 但企微官方实现使用 32 字节块做 PKCS7 填充)
block_size = 32
pad_len = block_size - (len(plaintext_data) % block_size)
# 填充字节的值等于填充的字节数
plaintext_data += bytes([pad_len] * pad_len)
# 3. AES-CBC 加密
cipher = Cipher(
algorithms.AES(self.aes_key),
modes.CBC(self.iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted_data = encryptor.update(plaintext_data) + encryptor.finalize()
# 4. Base64 编码
result = base64.b64encode(encrypted_data).decode("utf-8")
logger.debug(f"AES 加密成功, 密文长度: {len(result)}")
return result
except Exception as e:
logger.error(f"AES 加密失败: {e}")
raise ValueError(f"消息加密失败: {e}") from e
# --------------------------------------------------------------------------
# 解密回调消息(完整流程)
# --------------------------------------------------------------------------
def decrypt_message(
self,
xml_body: str,
msg_signature: str,
timestamp: str,
nonce: str,
) -> Dict[str, str]:
"""解密企微回调消息的完整流程。
流程:
1. 从 XML 中提取 Encrypt 字段
2. 验证签名
3. AES 解密
4. 解析明文 XML,返回消息内容字典
Args:
xml_body: 企微 POST 的 XML 请求体
msg_signature: 企微传来的签名
timestamp: 时间戳
nonce: 随机数
Returns:
Dict[str, str]: 解密后的消息内容,包含 from_user_name, content, msg_type 等
Raises:
ValueError: 签名验证失败或解密失败
"""
# 1. 解析 XML,提取 Encrypt 字段
try:
root = ET.fromstring(xml_body)
encrypt_node = root.find("Encrypt")
if encrypt_node is None:
raise ValueError("XML 中未找到 Encrypt 字段")
encrypt = encrypt_node.text or ""
except ET.ParseError as e:
raise ValueError(f"XML 解析失败: {e}") from e
# 2. 验证签名
if not self.verify_signature(msg_signature, timestamp, nonce, encrypt):
raise ValueError("签名验证失败")
# 3. AES 解密
plaintext_xml = self.decrypt(encrypt)
# 4. 解析明文 XML
try:
plain_root = ET.fromstring(plaintext_xml)
result = {}
# 提取常见字段
for child in plain_root:
result[child.tag] = child.text or ""
logger.info(f"消息解密成功: from={result.get('FromUserName', 'unknown')}")
return result
except ET.ParseError as e:
raise ValueError(f"明文 XML 解析失败: {e}") from e
# --------------------------------------------------------------------------
# 加密响应消息(完整流程)
# --------------------------------------------------------------------------
def encrypt_message(
self, reply_content: str, nonce: Optional[str] = None
) -> str:
"""加密响应消息的完整流程(用于被动回复)。
流程:
1. AES 加密消息内容
2. 生成签名
3. 构造响应 XML
Args:
reply_content: 要回复的消息内容
nonce: 随机数(可选,默认自动生成)
Returns:
str: 加密后的 XML 响应字符串
"""
# 生成时间戳和随机数
timestamp = str(int(time.time()))
if nonce is None:
nonce = "".join(
secrets.choice(string.ascii_letters + string.digits)
for _ in range(10)
)
# AES 加密
encrypt = self.encrypt(reply_content)
# 生成签名
signature = self.generate_signature(timestamp, nonce, encrypt)
# 构造响应 XML
response_xml = (
f"<xml>"
f"<Encrypt><![CDATA[{encrypt}]]></Encrypt>"
f"<MsgSignature><![CDATA[{signature}]]></MsgSignature>"
f"<TimeStamp>{timestamp}</TimeStamp>"
f"<Nonce><![CDATA[{nonce}]]></Nonce>"
f"</xml>"
)
logger.debug("响应消息加密完成")
return response_xml
# --------------------------------------------------------------------------
# 验证 URL 有效性(GET 请求解密 echostr
# --------------------------------------------------------------------------
def decrypt_echostr(
self,
msg_signature: str,
timestamp: str,
nonce: str,
echostr: str,
) -> str:
"""解密企微回调验证的 echostr。
企微配置回调 URL 时会发送 GET 请求,需要:
1. 验证签名
2. 解密 echostr
3. 返回明文 echostr
Args:
msg_signature: 企微传来的签名
timestamp: 时间戳
nonce: 随机数
echostr: 加密的验证字符串
Returns:
str: 解密后的 echostr 明文
Raises:
ValueError: 签名验证失败或解密失败
"""
# 验证签名
if not self.verify_signature(msg_signature, timestamp, nonce, echostr):
raise ValueError("回调URL验证签名失败")
# 解密 echostr
plaintext = self.decrypt(echostr)
logger.info("回调URL验证成功,echostr 解密完成")
return plaintext