添加用户消息记录接口;

This commit is contained in:
karlcarlo
2025-12-11 03:53:47 +08:00
parent 6029da058a
commit 420fed4bcf
49 changed files with 7574 additions and 426 deletions

View File

@@ -0,0 +1,318 @@
import os
import json
import time
import hashlib
import random
import string
from typing import Dict, Optional
import httpx
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
CACHE_FILE = "data.json"
TOKEN_EXPIRY_MINUTES = 100 # 100分钟过期
TICKET_EXPIRY_SECONDS = 7200 # ticket过期时间7200秒
class WeChatAuthService:
def __init__(self):
self.corpid = os.getenv("CORPID")
self.corpsecret = os.getenv("CORPSECRET")
if not self.corpid or not self.corpsecret:
raise ValueError("CORPID or CORPSECRET not found in .env file")
self.api_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
def _load_cache(self) -> Optional[Dict]:
"""从缓存文件加载token数据"""
try:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
print(f"加载缓存失败: {e}")
return None
def _save_cache(self, token_data: Dict) -> None:
"""保存token数据到缓存文件"""
try:
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(token_data, f, ensure_ascii=False, indent=2)
except IOError as e:
print(f"保存缓存失败: {e}")
def _is_token_valid(self, token_data: Dict) -> bool:
"""检查token是否有效(未过期)"""
if not token_data:
return False
current_time = time.time()
fetch_time = token_data.get('fetch_time', 0)
expiry_seconds = TOKEN_EXPIRY_MINUTES * 60
# 检查是否在有效期内
return current_time < (fetch_time + expiry_seconds)
def _is_ticket_valid(self, ticket_data: Dict) -> bool:
"""检查ticket是否有效(未过期)"""
if not ticket_data:
return False
current_time = time.time()
fetch_time = ticket_data.get('fetch_time', 0)
expiry_seconds = ticket_data.get('expires_in', TICKET_EXPIRY_SECONDS)
# 检查是否在有效期内
return current_time < (fetch_time + expiry_seconds)
def _fetch_new_token(self) -> Dict:
"""从企业微信API获取新的token"""
try:
response = httpx.get(self.api_url, timeout=10.0)
response.raise_for_status()
result = response.json()
if result.get('errcode') != 0:
raise Exception(f"API错误: {result.get('errmsg', '未知错误')}")
access_token = result.get('access_token')
expires_in = result.get('expires_in', 7200) # 默认7200秒
token_data = {
'access_token': access_token,
'fetch_time': time.time(),
'expires_in': expires_in,
'expiry_time': time.time() + (TOKEN_EXPIRY_MINUTES * 60)
}
return token_data
except httpx.RequestError as e:
raise Exception(f"请求失败: {e}")
except httpx.HTTPStatusError as e:
raise Exception(f"HTTP错误: {e}")
def get_access_token(self) -> str:
"""获取有效的access_token"""
# 尝试从缓存加载
cached_data = self._load_cache()
# 检查缓存是否有效
if cached_data and self._is_token_valid(cached_data):
print("使用缓存的token")
return cached_data['access_token']
# 获取新token
print("获取新的token...")
new_token_data = self._fetch_new_token()
# 保存到缓存
self._save_cache(new_token_data)
# 计算并显示过期时间
expiry_time = new_token_data['expiry_time']
expiry_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expiry_time))
print(f"Token获取成功,过期时间: {expiry_str}")
return new_token_data['access_token']
def get_token_info(self) -> Dict:
"""获取token详细信息"""
cached_data = self._load_cache()
if not cached_data:
return {"status": "no_token", "message": "未找到token"}
current_time = time.time()
fetch_time = cached_data.get('fetch_time', 0)
expiry_time = cached_data.get('expiry_time', 0)
is_valid = self._is_token_valid(cached_data)
time_remaining = max(0, expiry_time - current_time)
return {
"status": "valid" if is_valid else "expired",
"access_token": cached_data.get('access_token', ''),
"fetch_time": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(fetch_time)),
"expiry_time": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expiry_time)),
"time_remaining_minutes": round(time_remaining / 60, 2),
"is_valid": is_valid
}
def _fetch_new_ticket(self, access_token: str) -> Dict:
"""从企业微信API获取新的jsapi_ticket"""
try:
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token={access_token}&type=agent_config"
response = httpx.get(api_url, timeout=10.0)
response.raise_for_status()
result = response.json()
if result.get('errcode') != 0:
raise Exception(f"获取ticket API错误: {result.get('errmsg', '未知错误')}")
ticket = result.get('ticket')
expires_in = result.get('expires_in', TICKET_EXPIRY_SECONDS)
ticket_data = {
'ticket': ticket,
'fetch_time': time.time(),
'expires_in': expires_in,
'expiry_time': time.time() + expires_in
}
return ticket_data
except httpx.RequestError as e:
raise Exception(f"请求ticket失败: {e}")
except httpx.HTTPStatusError as e:
raise Exception(f"HTTP错误: {e}")
def get_jsapi_ticket(self) -> str:
"""获取有效的jsapi_ticket"""
# 先获取access_token
access_token = self.get_access_token()
# 尝试从缓存加载
cached_data = self._load_cache()
# 检查ticket缓存是否有效
if cached_data and 'ticket_data' in cached_data:
ticket_data = cached_data['ticket_data']
if self._is_ticket_valid(ticket_data):
print("使用缓存的ticket")
return ticket_data['ticket']
# 获取新ticket
print("获取新的ticket...")
new_ticket_data = self._fetch_new_ticket(access_token)
# 更新缓存
if cached_data is None:
cached_data = {}
cached_data['ticket_data'] = new_ticket_data
self._save_cache(cached_data)
# 计算并显示过期时间
expiry_time = new_ticket_data['expiry_time']
expiry_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expiry_time))
print(f"Ticket获取成功,过期时间: {expiry_str}")
return new_ticket_data['ticket']
def get_ticket_info(self) -> Dict:
"""获取ticket详细信息"""
cached_data = self._load_cache()
if not cached_data or 'ticket_data' not in cached_data:
return {"status": "no_ticket", "message": "未找到ticket"}
ticket_data = cached_data['ticket_data']
current_time = time.time()
fetch_time = ticket_data.get('fetch_time', 0)
expiry_time = ticket_data.get('expiry_time', 0)
is_valid = self._is_ticket_valid(ticket_data)
time_remaining = max(0, expiry_time - current_time)
return {
"status": "valid" if is_valid else "expired",
"ticket": ticket_data.get('ticket', ''),
"fetch_time": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(fetch_time)),
"expiry_time": time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expiry_time)),
"time_remaining_seconds": round(time_remaining, 2),
"is_valid": is_valid
}
def generate_signature(self, url: str, timestamp: int = None, nonce_str: str = None) -> Dict:
"""生成应用签名
根据企业微信官方文档生成签名:
1. 获取jsapi_ticket
2. 生成随机字符串noncestr
3. 生成时间戳timestamp
4. 拼接字符串:jsapi_ticket、noncestr、timestamp、url
5. 按字典序排序后拼接
6. SHA1加密生成签名
Args:
url: 当前页面的完整URL
timestamp: 时间戳(秒),如果不提供则自动生成
nonce_str: 随机字符串,如果不提供则自动生成
Returns:
包含签名信息的字典
"""
# 获取jsapi_ticket
jsapi_ticket = self.get_jsapi_ticket()
# 生成时间戳(秒)
if timestamp is None:
timestamp = int(time.time())
# 生成随机字符串
if nonce_str is None:
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
# 按字典序排序参数
params = {
'jsapi_ticket': jsapi_ticket,
'noncestr': nonce_str,
'timestamp': timestamp,
'url': url
}
# 按key排序后拼接字符串
param_str = '&'.join([f"{k}={params[k]}" for k in sorted(params.keys())])
# SHA1加密
signature = hashlib.sha1(param_str.encode('utf-8')).hexdigest()
# 打印签名相关信息以便校验
print("\n" + "="*60)
print("签名生成信息:")
print("="*60)
print(f"jsapi_ticket: {jsapi_ticket}")
print(f"noncestr: {nonce_str}")
print(f"timestamp: {timestamp}")
print(f"url: {url}")
print(f"param_str (排序后): {param_str}")
print(f"signature (SHA1): {signature}")
print("="*60 + "\n")
return {
'signature': signature,
'timestamp': timestamp,
'nonceStr': nonce_str,
'url': url,
'jsapi_ticket': jsapi_ticket
}
# 创建全局实例
auth_service = WeChatAuthService()
def get_access_token() -> str:
"""获取access_token的便捷函数"""
return auth_service.get_access_token()
def get_token_info() -> Dict:
"""获取token信息的便捷函数"""
return auth_service.get_token_info()
def get_jsapi_ticket() -> str:
"""获取jsapi_ticket的便捷函数"""
return auth_service.get_jsapi_ticket()
def get_ticket_info() -> Dict:
"""获取ticket信息的便捷函数"""
return auth_service.get_ticket_info()
def generate_signature(url: str, timestamp: int = None, nonce_str: str = None) -> Dict:
"""生成应用签名的便捷函数"""
return auth_service.generate_signature(url, timestamp, nonce_str)

View File

@@ -0,0 +1,611 @@
#!/usr/bin/env python3
"""
消息服务
提供加载会话存档、解密、保存到数据库的整合功能
"""
import asyncio
import json
import os
import logging
import base64
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
import sys
# 添加scripts目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
class MessageService:
"""消息服务,提供加载会话存档的整合功能"""
def __init__(self, corpid: Optional[str] = None, secret: Optional[str] = None):
"""
初始化消息服务
Args:
corpid: 企业ID,如果为None则从环境变量CORPID读取
secret: 聊天内容存档的Secret,如果为None则从环境变量WEWORK_SECRET读取
"""
self.corpid = corpid or os.getenv("CORPID", "wwdf65802ca25ec195")
self.secret = secret or os.getenv(
"WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE"
)
# SDK实例(延迟初始化)
self._sdk: Optional[WeWorkFinanceSDK] = None
# RSA私钥路径
self.rsa_private_key_path = Path("cert/1_rsa.key")
logger.info(f"消息服务初始化完成,企业ID: {self.corpid}")
@property
def sdk(self) -> WeWorkFinanceSDK:
"""获取SDK实例(延迟初始化)"""
if self._sdk is None:
try:
# 动态库路径
lib_path = (
Path(__file__).parent.parent.parent
/ "lib"
/ "sdk"
/ "libWeWorkFinanceSdk_C.so"
)
if not lib_path.exists():
raise FileNotFoundError(f"动态库不存在: {lib_path}")
self._sdk = WeWorkFinanceSDK(self.corpid, self.secret, str(lib_path))
logger.info("企业微信SDK初始化成功")
except Exception as e:
logger.error(f"企业微信SDK初始化失败: {e}")
raise
return self._sdk
def _load_rsa_private_key(self, key_path: str) -> str:
"""
加载RSA私钥
Args:
key_path: 私钥文件路径
Returns:
私钥内容字符串
"""
try:
with open(key_path, "r") as f:
key_content = f.read()
return key_content
except Exception as e:
raise Exception(f"加载RSA私钥失败: {e}")
def _rsa_decrypt_base64(self, encrypted_data_base64: str, private_key: str) -> str:
"""
使用RSA私钥解密Base64编码的数据
Args:
encrypted_data_base64: Base64编码的加密数据
private_key: RSA私钥字符串
Returns:
解密后的字符串
"""
try:
# 加载私钥
private_key_obj = serialization.load_pem_private_key(
private_key.encode("utf-8"), password=None, backend=default_backend()
)
# Base64解码
encrypted_data = base64.b64decode(encrypted_data_base64)
# RSA解密 (使用PKCS1v15填充)
decrypted_data = private_key_obj.decrypt(encrypted_data, padding.PKCS1v15())
return decrypted_data.decode("utf-8")
except ImportError:
raise Exception("需要安装cryptography库: pip install cryptography")
except Exception as e:
raise Exception(f"RSA解密失败: {e}")
def _decrypt_chat_message(
self, encrypt_random_key: str, encrypt_chat_msg: str, publickey_ver: int = None
) -> dict:
"""
解密聊天消息
Args:
encrypt_random_key: 加密的随机密钥(Base64编码)
encrypt_chat_msg: 加密的聊天消息
publickey_ver: publickey_ver版本号,用于动态匹配私钥
Returns:
解密后的消息字典
"""
# 动态确定私钥路径
actual_key_path = self.rsa_private_key_path
# 如果提供了publickey_ver,则动态匹配
if publickey_ver is not None:
dynamic_key_path = f"cert/{publickey_ver}_rsa.key"
dynamic_path = Path(dynamic_key_path)
if dynamic_path.exists():
actual_key_path = dynamic_path
# 1. 加载RSA私钥
private_key = self._load_rsa_private_key(str(actual_key_path))
# 2. 使用RSA私钥解密encrypt_random_key得到encrypt_key
try:
encrypt_key = self._rsa_decrypt_base64(encrypt_random_key, private_key)
except Exception as e:
# 尝试直接使用encrypt_random_key作为encrypt_key(某些情况下可能已经解密)
encrypt_key = encrypt_random_key
# 3. 使用SDK解密聊天消息
decrypted_msg = self.sdk.decrypt_data(encrypt_key, encrypt_chat_msg)
# 4. 解析JSON
try:
result = json.loads(decrypted_msg)
return result
except json.JSONDecodeError:
# 如果不是JSON,返回原始文本
return {"raw_message": decrypted_msg}
async def _decrypt_chat_data(self, chatdata: List[Dict[str, Any]]) -> tuple:
"""
解密聊天数据
Args:
chatdata: 原始聊天数据列表
Returns:
(decrypted_chatdata, stats)
decrypted_chatdata: 解密后的聊天数据列表
stats: 解密统计信息字典
"""
decrypted_chatdata = []
stats = {
"total_encrypted_messages": 0,
"successful_decrypts": 0,
"failed_decrypts": 0,
}
# 检查私钥文件是否存在
if not self.rsa_private_key_path.exists():
logger.warning(f"RSA私钥文件不存在: {self.rsa_private_key_path}")
logger.warning("将跳过解密,保存原始加密数据")
return chatdata, stats
logger.info(f"使用RSA私钥: {self.rsa_private_key_path.absolute()}")
logger.info("开始解密聊天数据...")
for i, msg in enumerate(chatdata):
encrypt_random_key = msg.get("encrypt_random_key", "")
encrypt_chat_msg = msg.get("encrypt_chat_msg", "")
# 检查是否有加密数据
if not encrypt_random_key or not encrypt_chat_msg:
# 没有加密数据,直接添加到结果中
decrypted_chatdata.append(msg)
continue
stats["total_encrypted_messages"] += 1
try:
# 获取publickey_ver
publickey_ver = msg.get("publickey_ver")
# 解密消息
decrypted_msg = self._decrypt_chat_message(
encrypt_random_key,
encrypt_chat_msg,
publickey_ver,
)
# 创建新的消息对象,保留原始字段但移除加密字段
new_msg = {}
for key, value in msg.items():
if key not in ["encrypt_random_key", "encrypt_chat_msg"]:
new_msg[key] = value
# 将解密后的字段合并到消息中
if isinstance(decrypted_msg, dict):
for key, value in decrypted_msg.items():
new_msg[key] = value
decrypted_chatdata.append(new_msg)
stats["successful_decrypts"] += 1
# 显示进度
if stats["successful_decrypts"] % 100 == 0:
logger.info(f" 已解密 {stats['successful_decrypts']} 条消息...")
except Exception as e:
stats["failed_decrypts"] += 1
logger.warning(f"解密消息失败 (seq: {msg.get('seq', 'N/A')}): {e}")
# 解密失败,跳过此消息(不添加到结果中)
continue
return decrypted_chatdata, stats
async def _save_messages_to_database(
self, messages: List[Dict[str, Any]]
) -> Dict[str, int]:
"""
将消息保存到数据库
Args:
messages: 消息字典列表
Returns:
存储统计信息字典
"""
stats = {
"total_messages": len(messages),
"saved": 0,
"skipped_duplicates": 0,
"failed": 0,
}
if not messages:
logger.info("没有消息需要存储到数据库")
return stats
logger.info(f"开始将 {len(messages)} 条消息存储到数据库...")
try:
# 导入数据库相关模块
from pymongo import AsyncMongoClient
from beanie import init_beanie
from app.models import models
from app.models.message import Message
# 从环境变量获取数据库配置
mongodb_url = os.getenv(
"MONGODB_URL",
"mongodb://dblink:mgdb4link@localhost:27017/?authSource=admin",
)
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(database=client[database_name], document_models=models)
logger.info(f"数据库连接成功: {database_name}")
# 批量处理消息
batch_size = 100
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
batch_msgids = [msg.get("msgid") for msg in batch if msg.get("msgid")]
# 检查哪些消息已经存在
existing_messages = await Message.find(
{"msgid": {"$in": batch_msgids}}
).to_list()
existing_msgids = {msg.msgid for msg in existing_messages}
# 过滤掉已存在的消息
new_messages = []
for msg in batch:
msgid = msg.get("msgid")
if not msgid:
stats["failed"] += 1
continue
if msgid in existing_msgids:
stats["skipped_duplicates"] += 1
continue
try:
# 创建Message实例
message_data = {
"msgid": msg.get("msgid"),
"action": msg.get("action"),
"from_user": msg.get(
"from"
), # 从原始数据的from字段读取,保存为from_user
"tolist": msg.get("tolist", []),
"roomid": msg.get("roomid"),
"text": msg.get("text", {}),
"msgtime": msg.get("msgtime"),
"msgtype": msg.get("msgtype"),
"remark": msg.get("remark"),
"seq": msg.get("seq", 1000),
"isEnabled": True,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
# 移除None值
message_data = {
k: v for k, v in message_data.items() if v is not None
}
message = Message(**message_data)
new_messages.append(message)
except Exception as e:
logger.error(f"创建消息实例失败 (msgid: {msgid}): {e}")
stats["failed"] += 1
# 批量插入新消息
if new_messages:
try:
await Message.insert_many(new_messages)
stats["saved"] += len(new_messages)
logger.info(
f" 已存储 {len(new_messages)} 条消息 (进度: {i + len(batch)}/{len(messages)})"
)
except Exception as e:
logger.error(f"批量插入失败: {e}")
stats["failed"] += len(new_messages)
# 关闭数据库连接
await client.close()
logger.info(f"数据库存储完成:")
logger.info(f" 总计消息: {stats['total_messages']}")
logger.info(f" 成功存储: {stats['saved']}")
logger.info(f" 跳过重复: {stats['skipped_duplicates']}")
logger.info(f" 存储失败: {stats['failed']}")
except Exception as e:
logger.error(f"数据库存储过程中发生错误: {e}")
import traceback
traceback.print_exc()
stats["failed"] = len(messages)
return stats
async def get_messages(
self,
page: int = 1,
page_size: int = 20,
msgtype: Optional[str] = None,
roomid: Optional[str] = None,
from_user: Optional[str] = None,
) -> Dict[str, Any]:
"""
查询消息列表,支持分页和过滤
Args:
page: 页码(从1开始)
page_size: 每页数量
msgtype: 消息类型过滤
roomid: 群聊ID过滤
from_user: 发送者过滤
Returns:
分页查询结果
"""
try:
# 导入数据库相关模块
from pymongo import AsyncMongoClient
from beanie import init_beanie
from app.models import models
from app.models.message import Message
# 从环境变量获取数据库配置
mongodb_url = os.getenv(
"MONGODB_URL",
"mongodb://dblink:mgdb4link@localhost:27017/?authSource=admin",
)
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(database=client[database_name], document_models=models)
# 构建查询条件
query = {}
if msgtype:
query["msgtype"] = msgtype
if roomid:
query["roomid"] = roomid
if from_user:
query["from_user"] = from_user # 使用from_user字段进行查询
# 计算跳过的文档数量
skip_count = (page - 1) * page_size
# 执行查询
messages = await Message.find(
query,
skip=skip_count,
limit=page_size,
sort=[("msgtime", -1)], # 按消息时间倒序排列
).to_list()
# 获取总记录数
total_count = await Message.find(query).count()
# 关闭数据库连接
await client.close()
# 准备响应数据
return {
"success": True,
"data": {
"page": page,
"page_size": page_size,
"total": total_count,
"total_pages": (
(total_count + page_size - 1) // page_size
if page_size > 0
else 0
),
"messages": messages,
},
}
except Exception as e:
logger.error(f"查询消息列表失败: {e}")
import traceback
traceback.print_exc()
return {
"success": False,
"message": f"查询消息列表失败: {str(e)}",
"data": {
"page": page,
"page_size": page_size,
"total": 0,
"total_pages": 0,
"messages": [],
},
}
async def load_messages(self, seq: int = 0, limit: int = 100) -> Dict[str, Any]:
"""
加载会话存档到数据库
完整流程:拉取 → 解密 → 保存
Args:
seq: 起始seq值,首次使用0
limit: 拉取数量限制
Returns:
执行结果和统计信息
"""
start_time = datetime.now()
try:
# 从环境变量获取代理配置
proxy = os.getenv("WEWORK_PROXY", "")
passwd = os.getenv("WEWORK_PROXY_PASSWD", "")
timeout = int(os.getenv("WEWORK_TIMEOUT", "10"))
logger.info(f"开始加载会话存档: seq={seq}, limit={limit}")
# 1. 拉取会话数据
logger.info("步骤1: 从企业微信拉取会话数据...")
result_str = self.sdk.get_chat_data(
seq=seq, limit=limit, proxy=proxy, passwd=passwd, timeout=timeout
)
# 解析结果
result = json.loads(result_str)
errcode = result.get("errcode", -1)
errmsg = result.get("errmsg", "")
if errcode != 0:
raise Exception(f"拉取会话数据失败: errcode={errcode}, errmsg={errmsg}")
chatdata = result.get("chatdata", [])
total_fetched = len(chatdata)
logger.info(f"成功拉取 {total_fetched} 条聊天记录")
if total_fetched == 0:
return {
"success": True,
"message": "没有新的聊天记录",
"data": {
"total_fetched": 0,
"encrypted_count": 0,
"decrypted_success": 0,
"decrypted_failed": 0,
"saved_count": 0,
"duplicates_skipped": 0,
"max_seq": seq,
"next_seq": seq,
},
"execution_time": str(datetime.now() - start_time),
}
# 2. 解密消息
logger.info("步骤2: 解密加密消息...")
decrypted_chatdata, decrypt_stats = await self._decrypt_chat_data(chatdata)
# 3. 保存到数据库
logger.info("步骤3: 保存消息到数据库...")
db_stats = await self._save_messages_to_database(decrypted_chatdata)
# 计算最大seq值
max_seq = max(
(msg.get("seq", 0) for msg in decrypted_chatdata), default=seq
)
# 准备响应数据
execution_time = datetime.now() - start_time
response_data = {
"success": True,
"message": "消息加载完成",
"data": {
"total_fetched": total_fetched,
"encrypted_count": decrypt_stats["total_encrypted_messages"],
"decrypted_success": decrypt_stats["successful_decrypts"],
"decrypted_failed": decrypt_stats["failed_decrypts"],
"saved_count": db_stats["saved"],
"duplicates_skipped": db_stats["skipped_duplicates"],
"failed_to_save": db_stats["failed"],
"max_seq": max_seq,
"next_seq": max_seq + 1 if total_fetched > 0 else seq,
},
"execution_time": str(execution_time),
"timestamp": datetime.now().isoformat(),
}
logger.info(f"消息加载完成,耗时: {execution_time}")
return response_data
except json.JSONDecodeError as e:
error_msg = f"解析JSON数据失败: {e}"
logger.error(error_msg)
return {
"success": False,
"message": error_msg,
"execution_time": str(datetime.now() - start_time),
}
except Exception as e:
error_msg = f"加载消息失败: {e}"
logger.error(error_msg)
import traceback
traceback.print_exc()
return {
"success": False,
"message": error_msg,
"execution_time": str(datetime.now() - start_time),
}
def close(self):
"""关闭服务,释放资源"""
if self._sdk:
self._sdk.close()
self._sdk = None
logger.info("消息服务已关闭")
# 全局服务实例(单例模式)
_message_service: Optional[MessageService] = None
def get_message_service() -> MessageService:
"""获取消息服务实例(单例)"""
global _message_service
if _message_service is None:
_message_service = MessageService()
return _message_service
def close_message_service():
"""关闭消息服务"""
global _message_service
if _message_service:
_message_service.close()
_message_service = None

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
企业微信会话存档服务
集成到FastAPI项目中
"""
import json
import os
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime
# 添加scripts目录到路径
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError
logger = logging.getLogger(__name__)
class WeworkService:
"""企业微信会话存档服务"""
def __init__(self, corpid: Optional[str] = None, secret: Optional[str] = None):
"""
初始化企业微信服务
Args:
corpid: 企业ID,如果为None则从环境变量CORPID读取
secret: 聊天内容存档的Secret,如果为None则从环境变量WEWORK_SECRET读取
"""
self.corpid = corpid or os.getenv("CORPID", "wwdf65802ca25ec195")
self.secret = secret or os.getenv("WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE")
# 媒体文件保存目录
self.media_dir = Path(os.getenv("WEWORK_MEDIA_DIR", "/tmp/wework_media"))
self.media_dir.mkdir(parents=True, exist_ok=True)
# SDK实例(延迟初始化)
self._sdk: Optional[WeWorkFinanceSDK] = None
logger.info(f"企业微信服务初始化完成,企业ID: {self.corpid}")
logger.info(f"媒体文件保存目录: {self.media_dir}")
@property
def sdk(self) -> WeWorkFinanceSDK:
"""获取SDK实例(延迟初始化)"""
if self._sdk is None:
try:
self._sdk = WeWorkFinanceSDK(self.corpid, self.secret)
logger.info("企业微信SDK初始化成功")
except Exception as e:
logger.error(f"企业微信SDK初始化失败: {e}")
raise
return self._sdk
def get_chat_data(self, seq: int = 0, limit: int = 100,
proxy: str = "", passwd: str = "",
timeout: int = 5) -> Dict[str, Any]:
"""
拉取会话存档
Args:
seq: 从指定的seq开始拉取消息,首次使用请使用0
limit: 一次拉取的消息条数,最大值1000条
proxy: 代理链接
passwd: 代理账号密码
timeout: 超时时间,单位秒
Returns:
包含聊天数据的字典
Raises:
WeWorkSDKError: SDK操作失败
"""
logger.info(f"拉取会话存档: seq={seq}, limit={limit}, timeout={timeout}s")
try:
result_str = self.sdk.get_chat_data(seq, limit, proxy, passwd, timeout)
# 解析JSON结果
result = json.loads(result_str)
# 添加元数据
result["_metadata"] = {
"timestamp": datetime.now().isoformat(),
"params": {
"seq": seq,
"limit": limit,
"timeout": timeout,
"has_proxy": bool(proxy)
}
}
# 如果有聊天数据,记录统计信息
if "chatdata" in result and result["chatdata"]:
chat_count = len(result["chatdata"])
max_seq = max(msg.get("seq", 0) for msg in result["chatdata"])
logger.info(f"成功拉取 {chat_count} 条聊天记录,最大seq: {max_seq}")
# 提取消息类型统计
msg_types = {}
for msg in result["chatdata"]:
# 这里需要根据实际消息格式解析消息类型
pass
return result
except json.JSONDecodeError as e:
logger.error(f"解析聊天数据JSON失败: {e}")
raise WeWorkSDKError(-1, f"解析聊天数据失败: {e}")
def get_media_data(self, sdk_fileid: str, filename: Optional[str] = None,
proxy: str = "", passwd: str = "",
timeout: int = 5) -> Dict[str, Any]:
"""
拉取媒体文件
Args:
sdk_fileid: 媒体文件id
filename: 保存的文件名,如果为None则自动生成
proxy: 代理链接
passwd: 代理账号密码
timeout: 超时时间,单位秒
Returns:
包含文件信息的字典
Raises:
WeWorkSDKError: SDK操作失败
"""
logger.info(f"拉取媒体文件: fileid={sdk_fileid}, timeout={timeout}s")
# 生成文件名
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"media_{timestamp}_{sdk_fileid[:8]}.bin"
save_path = self.media_dir / filename
try:
success = self.sdk.get_media_data(
sdk_fileid=sdk_fileid,
save_path=str(save_path),
proxy=proxy,
passwd=passwd,
timeout=timeout
)
if success and save_path.exists():
file_size = save_path.stat().st_size
logger.info(f"媒体文件下载完成: {filename}, 大小: {file_size} 字节")
return {
"success": True,
"filename": filename,
"filepath": str(save_path),
"filesize": file_size,
"download_time": datetime.now().isoformat(),
"fileid": sdk_fileid
}
else:
logger.error(f"媒体文件下载失败: {filename}")
return {
"success": False,
"error": "下载失败",
"fileid": sdk_fileid
}
except Exception as e:
logger.error(f"拉取媒体文件时出错: {e}")
raise
def decrypt_data(self, encrypt_key: str, encrypt_msg: str) -> Dict[str, Any]:
"""
解密会话内容
Args:
encrypt_key: 解密密钥
encrypt_msg: 加密的消息
Returns:
包含解密数据的字典
Raises:
WeWorkSDKError: SDK操作失败
"""
logger.info("解密会话存档内容")
try:
result_str = self.sdk.decrypt_data(encrypt_key, encrypt_msg)
# 解析JSON结果
result = json.loads(result_str)
# 添加元数据
result["_metadata"] = {
"timestamp": datetime.now().isoformat(),
"decrypted": True
}
logger.info("解密成功")
return result
except json.JSONDecodeError as e:
logger.error(f"解析解密数据JSON失败: {e}")
# 如果不是JSON,返回原始文本
return {
"success": True,
"decrypted_data": result_str,
"is_json": False,
"_metadata": {
"timestamp": datetime.now().isoformat(),
"decrypted": True
}
}
def get_media_files(self) -> List[Dict[str, Any]]:
"""
获取已下载的媒体文件列表
Returns:
媒体文件信息列表
"""
files = []
for file_path in self.media_dir.glob("*"):
if file_path.is_file():
stat = file_path.stat()
files.append({
"filename": file_path.name,
"filepath": str(file_path),
"filesize": stat.st_size,
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
return sorted(files, key=lambda x: x["modified"], reverse=True)
def cleanup_old_files(self, days: int = 7) -> int:
"""
清理旧的媒体文件
Args:
days: 保留天数
Returns:
删除的文件数量
"""
cutoff_time = datetime.now().timestamp() - (days * 24 * 3600)
deleted_count = 0
for file_path in self.media_dir.glob("*"):
if file_path.is_file() and file_path.stat().st_mtime < cutoff_time:
try:
file_path.unlink()
deleted_count += 1
logger.info(f"删除旧文件: {file_path.name}")
except Exception as e:
logger.error(f"删除文件失败 {file_path.name}: {e}")
return deleted_count
def close(self):
"""关闭服务,释放资源"""
if self._sdk:
self._sdk.close()
self._sdk = None
logger.info("企业微信SDK已关闭")
def __enter__(self):
"""上下文管理器入口"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器退出,自动释放资源"""
self.close()
# 全局服务实例(单例模式)
_wework_service: Optional[WeworkService] = None
def get_wework_service() -> WeworkService:
"""获取企业微信服务实例(单例)"""
global _wework_service
if _wework_service is None:
_wework_service = WeworkService()
return _wework_service
def close_wework_service():
"""关闭企业微信服务"""
global _wework_service
if _wework_service:
_wework_service.close()
_wework_service = None
if __name__ == "__main__":
# 简单测试
import logging
logging.basicConfig(level=logging.INFO)
service = WeworkService()
print(f"企业微信服务初始化成功")
print(f"企业ID: {service.corpid}")
print(f"媒体目录: {service.media_dir}")
# 测试获取媒体文件列表
files = service.get_media_files()
print(f"现有媒体文件: {len(files)} 个")
service.close()