chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 工具包初始化
|
||||
# =============================================================================
|
||||
# 说明:将 utils/ 目录标记为 Python 包
|
||||
# =============================================================================
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,376 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微消息 AES 加解密工具
|
||||
# =============================================================================
|
||||
# 说明:实现企微回调消息的加解密和签名验证
|
||||
# 参考企微官方加解密库逻辑,使用 Python cryptography 库重写
|
||||
# 加密模式:AES-CBC-256,PKCS7 填充
|
||||
# 签名算法: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: 企微回调配置的 EncodingAESKey(43 位 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
|
||||
Reference in New Issue
Block a user