添加用户消息记录接口;

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

1
backend/.chat-glm.pid Normal file
View File

@@ -0,0 +1 @@
177144

View File

@@ -1,5 +1,22 @@
# 企业微信认证配置
CORPID=ww45bb49a9d651b2bb
CORPSECRET=jUbWtYgkOAN3syE5AJq5aedBfE5IPo-PbeBDs2ufJxY
AGENTID=1000061
# 企业微信会话存档配置(使用CORPID作为企业ID,但WEWORK_SECRET不同)
WEWORK_SECRET=O57IBvJC0YpKzo8cu0XEbBjFeGZA3wEdeBpUA0sAyw4
WEWORK_MEDIA_DIR=/tmp/wework_media
# 应用配置
APP_NAME=chat-glm
APP_MODULE=app.main:app
APP_HOST=0.0.0.0
APP_PORT=8000
APP_WORKERS=1
APP_LOG_LEVEL=info
# DeepSeek API配置
DEEPSEEK_API_KEY=your-deepseek-api-key-here
DEEPSEEK_API_KEY=sk-d6412e3902044e42b96985e14b0fd33b
# 服务器配置
HOST=0.0.0.0

170
backend/API_USAGE.md Normal file
View File

@@ -0,0 +1,170 @@
# 消息服务 API 使用文档
基于 `backend/scripts/load_chat_data.py` 实现的消息服务 API,提供加载会话存档、解密、保存到数据库的整合功能。
## 新增 API 接口
### 1. 加载会话存档到数据库
**接口**: `POST /api/messages/load`
**功能**: 完整流程:拉取 → 解密 → 保存
- 从企业微信拉取会话存档
- 自动解密所有加密消息
- 自动保存到数据库(去重处理)
**参数**:
- `seq`: 起始seq值,首次使用0(默认: 0)
- `limit`: 拉取数量限制,最大1000(默认: 100)
**使用示例**:
```bash
# 首次加载,从seq=0开始,拉取100条消息
curl -X POST "http://localhost:8000/api/messages/load?seq=0&limit=100"
# 继续加载,使用上次返回的max_seq
curl -X POST "http://localhost:8000/api/messages/load?seq=12345&limit=500"
```
**响应示例** (成功):
```json
{
"success": true,
"message": "消息加载完成",
"data": {
"total_fetched": 150,
"encrypted_count": 120,
"decrypted_success": 115,
"decrypted_failed": 5,
"saved_count": 145,
"duplicates_skipped": 5,
"failed_to_save": 0,
"max_seq": 12345,
"next_seq": 12346
},
"execution_time": "0:00:02.345678",
"timestamp": "2025-12-10T16:44:22.123456"
}
```
**响应示例** (没有新消息):
```json
{
"success": true,
"message": "没有新的聊天记录",
"data": {
"total_fetched": 0,
"encrypted_count": 0,
"decrypted_success": 0,
"decrypted_failed": 0,
"saved_count": 0,
"duplicates_skipped": 0,
"max_seq": 0,
"next_seq": 0
},
"execution_time": "0:00:00.123456"
}
```
### 2. 查询消息列表
**接口**: `GET /api/messages/`
**功能**: 查询已保存的消息列表,支持分页和过滤
**参数**:
- `page`: 页码(默认: 1)
- `page_size`: 每页数量(默认: 20,最大: 100)
- `msgtype`: 消息类型过滤(可选)
- `roomid`: 群聊ID过滤(可选)
- `from_user`: 发送者过滤(可选)
### 3. 获取消息统计
**接口**: `GET /api/messages/stats`
**功能**: 获取数据库中消息的统计信息
### 4. 获取单个消息详情
**接口**: `GET /api/messages/{msgid}`
**功能**: 根据消息ID获取单个消息详情
## 环境变量配置
新增的环境变量(在 `.env` 文件中):
```bash
# 消息服务配置
WEWORK_PROXY= # 代理地址,如: http://proxy.example.com:8080
WEWORK_PROXY_PASSWD= # 代理密码
WEWORK_TIMEOUT=10 # 超时时间(秒)
```
## 技术实现
### 服务架构
1. **MessageService**: 核心服务类,整合了以下功能:
- 从企业微信拉取会话数据
- RSA 解密加密消息
- 批量保存到 MongoDB 数据库
- 去重处理(基于 msgid)
2. **消息处理流程**:
```
拉取数据 → 检查加密 → RSA解密 → 解析JSON → 去重检查 → 批量保存
```
### 重用现有代码
- 重用 `load_chat_data.py` 中的解密逻辑
- 重用现有的 `Message` 数据模型
- 重用企业微信 SDK 配置
### 错误处理
- 完善的异常捕获和日志记录
- 友好的错误响应格式
- 执行时间统计
## 使用建议
### 1. 首次使用
```bash
# 从seq=0开始加载
curl -X POST "http://localhost:8000/api/messages/load?seq=0&limit=1000"
```
### 2. 增量加载
使用上次响应中的 `max_seq` 或 `next_seq` 作为下一次的 `seq` 参数:
```bash
# 假设上次返回的 max_seq=12345
curl -X POST "http://localhost:8000/api/messages/load?seq=12345&limit=1000"
```
### 3. 监控进度
通过响应中的统计信息监控加载进度:
- `total_fetched`: 总共拉取的消息数
- `saved_count`: 成功保存到数据库的消息数
- `duplicates_skipped`: 跳过的重复消息数
## 注意事项
1. **私钥文件**: 确保 `cert/1_rsa.key` 文件存在,用于 RSA 解密
2. **数据库连接**: 确保 MongoDB 服务正常运行
3. **网络代理**: 如果需要代理,在 `.env` 中配置 `WEWORK_PROXY`
4. **性能考虑**: 大量数据加载时建议分批进行(limit=100-500)
## 与原有脚本对比
| 功能 | 原有脚本 (`load_chat_data.py`) | 新API (`/api/messages/load`) |
|------|--------------------------------|-------------------------------|
| 执行方式 | 命令行 | HTTP API |
| 参数传递 | 命令行参数 | URL 查询参数 |
| 结果输出 | 控制台 + JSON 文件 | JSON 响应 |
| 集成性 | 独立脚本 | 集成到 FastAPI 应用 |
| 自动化 | 需要脚本调度 | 可直接通过 HTTP 调用 |
## 扩展性
当前实现为最小可行产品(MVP),可扩展的功能包括:
1. 后台异步任务处理
2. 更丰富的查询过滤条件
3. 消息导出功能
4. 实时进度通知
5. 错误重试机制

View File

@@ -0,0 +1,68 @@
from fastapi import Depends, HTTPException
from typing import Dict
from app.services.auth_service import get_access_token, get_token_info, get_jsapi_ticket, get_ticket_info
async def get_valid_token() -> str:
"""
依赖函数:获取有效的access_token
如果获取失败,抛出HTTP异常
"""
try:
token = get_access_token()
return token
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取access_token失败: {str(e)}"
)
async def get_token_information() -> Dict:
"""
依赖函数:获取token的详细信息
"""
try:
token_info = get_token_info()
return token_info
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取token信息失败: {str(e)}"
)
async def get_valid_ticket() -> str:
"""
依赖函数:获取有效的jsapi_ticket
如果获取失败,抛出HTTP异常
"""
try:
ticket = get_jsapi_ticket()
return ticket
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取jsapi_ticket失败: {str(e)}"
)
async def get_ticket_information() -> Dict:
"""
依赖函数:获取ticket的详细信息
"""
try:
ticket_info = get_ticket_info()
return ticket_info
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取ticket信息失败: {str(e)}"
)
# 创建依赖实例
token_dependency = Depends(get_valid_token)
token_info_dependency = Depends(get_token_information)
ticket_dependency = Depends(get_valid_ticket)
ticket_info_dependency = Depends(get_ticket_information)

115
backend/app/main.py Normal file
View File

@@ -0,0 +1,115 @@
from contextlib import asynccontextmanager
from pymongo import AsyncMongoClient
from beanie import init_beanie, PydanticObjectId
from app.models import models
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import os
from app.routes import auth_routes, wework_routes, glm_routes, message_routes
@asynccontextmanager
async def lifespan(app: FastAPI):
# 从环境变量获取数据库配置
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(
database=client[database_name], document_models=models
) # 批量注册模型
print(f"🗄️ 数据库连接成功: {database_name}")
yield # 应用在此处进入正常运行状态,接收请求
# 关闭数据库连接
await client.close()
print(f"数据库连接关闭")
# 创建FastAPI应用
app = FastAPI(
title="企业微信认证服务",
description="获取和管理企业微信access_token的API服务",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan,
)
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(auth_routes.router, prefix="/api")
app.include_router(wework_routes.router, prefix="/api")
app.include_router(glm_routes.router, prefix="/api")
app.include_router(message_routes.router, prefix="/api")
@app.get("/api/")
async def root():
"""
根端点,提供API基本信息
"""
return {
"service": "企业微信认证与会话存档服务",
"version": "1.0.0",
"description": "获取和管理企业微信access_token,会话存档功能,以及DeepSeek AI聊天服务",
"endpoints": {
"docs": "/api/docs",
"redoc": "/api/redoc",
"auth": {"token": "/api/auth/token", "token_info": "/api/auth/token/info"},
"wework": {
"health": "/api/wework/health",
"chat": "/api/wework/chat",
"media": "/api/wework/media",
"decrypt": "/api/wework/decrypt",
"config": "/api/wework/config",
},
"glm": {
"root": "/api/glm/",
"role": "/api/glm/role",
"chat": "/api/glm/chat",
"conversations": "/api/glm/conversations/{conversation_id}",
},
"messages": {
"load": "/api/messages/load",
"list": "/api/messages/",
"stats": "/api/messages/stats",
"detail": "/api/messages/{msgid}",
},
},
}
def main():
"""
主函数,启动FastAPI服务器
"""
# 从环境变量获取主机和端口,使用默认值
host = os.getenv("APP_HOST", "0.0.0.0")
port = int(os.getenv("APP_PORT", "8000"))
print("启动企业微信认证与会话存档服务...")
print(f"API文档地址: http://{host}:{port}/docs")
print(f"API根地址: http://{host}:{port}")
print("企业微信会话存档API: /api/wework/*")
print("DeepSeek AI聊天API: /api/glm/*")
print("消息管理API: /api/messages/*")
uvicorn.run("app.main:app", host=host, port=port, reload=True, log_level="info")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,7 @@
from .base import BaseOut
from .message import Message
models = [Message]
# 在所有模型导入后调用 model_rebuild 来解决循环导入问题
# Message.model_rebuild()

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel, ConfigDict
from beanie import Document
class BaseOut(BaseModel):
code: int = 200
message: str = "ok"
result: dict[str, object] | list[object] | None = None

View File

@@ -0,0 +1,52 @@
import arrow
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from beanie import Document
class Message(Document):
"""消息会话模型"""
# 基本信息
msgid: str # 消息ID
action: str # 动作类型
from_user: str # 发送者,直接使用from_user字段
tolist: list[str] # 接收者列表
roomid: str | None = None # 群聊ID
text: dict # 消息内容
msgtime: datetime # 消息时间戳
msgtype: str # 消息类型
remark: str | None = None # 备注
# 通用字段
seq: int = 1000 # 排序序号
isEnabled: bool = True # 是否启用
created_at: datetime | None = arrow.now().datetime # 创建时间
updated_at: datetime | None = arrow.now().datetime # 更新时间
class Settings:
name = "messages" # 指定MongoDB集合名称
@field_validator("msgtime", mode="before")
@classmethod
def convert_timestamp_to_datetime(cls, v: Any) -> datetime:
"""将毫秒级时间戳转换为datetime对象"""
if isinstance(v, (int, float)):
# 假设是毫秒级时间戳
return datetime.fromtimestamp(v / 1000)
elif isinstance(v, str):
# 尝试解析字符串
try:
# 尝试解析为时间戳
timestamp = float(v)
return datetime.fromtimestamp(timestamp / 1000)
except ValueError:
# 尝试解析为ISO格式字符串
return datetime.fromisoformat(v.replace("Z", "+00:00"))
return v
class Config:
populate_by_name = True # 允许使用字段名和别名
arbitrary_types_allowed = True

View File

@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, Request
from typing import Dict
from app.deps.auth_deps import get_valid_token, get_token_information, get_valid_ticket, get_ticket_information
from app.services.auth_service import generate_signature
router = APIRouter(prefix="/auth", tags=["认证"])
@router.get("/token")
async def get_token(
token: str = Depends(get_valid_token)
) -> Dict:
"""
获取access_token
"""
return {
"success": True,
"access_token": token,
"message": "获取token成功"
}
@router.get("/token/info")
async def get_token_info(
token_info: Dict = Depends(get_token_information)
) -> Dict:
"""
获取token的详细信息
包括获取时间、过期时间、剩余时间等
"""
return {
"success": True,
"data": token_info,
"message": "获取token信息成功"
}
@router.get("/ticket")
async def get_ticket(
ticket: str = Depends(get_valid_ticket)
) -> Dict:
"""
获取jsapi_ticket
"""
return {
"success": True,
"ticket": ticket,
"message": "获取ticket成功"
}
@router.get("/ticket/info")
async def get_ticket_info(
ticket_info: Dict = Depends(get_ticket_information)
) -> Dict:
"""
获取ticket的详细信息
包括获取时间、过期时间、剩余时间等
"""
return {
"success": True,
"data": ticket_info,
"message": "获取ticket信息成功"
}
@router.post("/signature")
async def get_signature(request: Request) -> Dict:
"""
生成应用签名
请求参数:
{
"url": "当前页面完整URL",
"timestamp": 时间戳(可选,秒),
"nonceStr": "随机字符串(可选)"
}
"""
try:
# 解析请求体
body = await request.json()
url = body.get("url")
if not url:
return {
"success": False,
"message": "缺少必要参数:url"
}
timestamp = body.get("timestamp")
nonce_str = body.get("nonceStr")
# 生成签名
signature_data = generate_signature(
url=url,
timestamp=timestamp,
nonce_str=nonce_str
)
return {
"success": True,
"data": {
"signature": signature_data["signature"],
"timestamp": signature_data["timestamp"],
"nonceStr": signature_data["nonceStr"]
},
"message": "签名生成成功"
}
except Exception as e:
return {
"success": False,
"message": f"生成签名失败: {str(e)}"
}

View File

@@ -1,25 +1,11 @@
from fastapi import FastAPI, HTTPException, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
import httpx
import json
import os
from config import get_deepseek_api_key, is_api_key_configured, settings
app = FastAPI(title="DeepSeek AI Chat Assistant", version="1.0.0")
# 创建API路由,统一使用/api前缀
api_router = APIRouter(prefix="/api")
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 创建API路由,使用/glm前缀
router = APIRouter(prefix="/glm", tags=["GLM聊天"])
# 数据模型
@@ -113,9 +99,21 @@ def build_system_prompt(role_config: RoleConfig) -> str:
return prompt
def get_deepseek_api_key() -> str:
"""获取DeepSeek API密钥"""
api_key = os.getenv("DEEPSEEK_API_KEY", "your-deepseek-api-key")
return api_key
def is_api_key_configured() -> bool:
"""检查API密钥是否已配置"""
api_key = get_deepseek_api_key()
return api_key != "your-deepseek-api-key"
async def call_deepseek_api(messages: List[dict]) -> str:
"""调用DeepSeek API"""
# 使用配置管理获取API密钥
# 直接使用环境变量获取API密钥
api_key = get_deepseek_api_key()
if not is_api_key_configured():
@@ -142,18 +140,18 @@ async def call_deepseek_api(messages: List[dict]) -> str:
raise HTTPException(status_code=500, detail=f"API调用失败: {str(e)}")
@api_router.get("/")
@router.get("/")
async def root():
return {"message": "DeepSeek AI Chat Assistant API"}
@api_router.get("/role")
@router.get("/role")
async def get_role_config():
"""获取当前角色配置"""
return current_role_config
@api_router.put("/role")
@router.put("/role")
async def update_role_config(role_config: RoleConfig):
"""更新角色配置"""
global current_role_config
@@ -161,7 +159,7 @@ async def update_role_config(role_config: RoleConfig):
return {"message": "角色配置已更新", "role_config": current_role_config}
@api_router.post("/chat", response_model=ChatResponse)
@router.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""处理聊天请求"""
# 创建或获取对话历史
@@ -197,7 +195,7 @@ async def chat(request: ChatRequest):
raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/conversations/{conversation_id}")
@router.get("/conversations/{conversation_id}")
async def get_conversation(conversation_id: str):
"""获取特定对话的历史"""
if conversation_id not in conversations:
@@ -206,13 +204,3 @@ async def get_conversation(conversation_id: str):
"conversation_id": conversation_id,
"messages": conversations[conversation_id],
}
# 注册API路由
app.include_router(api_router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=settings.host, port=settings.port, reload=settings.debug)

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
消息API路由
提供加载会话存档、查询消息等功能的API接口
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends
import logging
from app.services.message_service import get_message_service, MessageService
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/messages",
tags=["messages"],
responses={404: {"description": "Not found"}},
)
@router.post("/load")
async def load_messages(
seq: int = Query(0, description="起始seq值,首次使用0"),
limit: int = Query(100, description="拉取数量限制", ge=1, le=1000),
service: MessageService = Depends(get_message_service),
):
"""
加载会话存档到数据库
完整流程:拉取 → 解密 → 保存
- 从企业微信拉取会话存档
- 自动解密所有加密消息
- 自动保存到数据库(去重处理)
参数说明:
- seq: 起始序列号,首次拉取使用0,后续使用上次返回的max_seq
- limit: 一次拉取的消息数量,最大1000
返回示例:
```json
{
"success": true,
"message": "消息加载完成",
"data": {
"total_fetched": 150,
"encrypted_count": 120,
"decrypted_success": 115,
"decrypted_failed": 5,
"saved_count": 145,
"duplicates_skipped": 5,
"failed_to_save": 0,
"max_seq": 12345,
"next_seq": 12346
},
"execution_time": "0:00:02.345678",
"timestamp": "2025-12-10T16:44:22.123456"
}
```
"""
try:
logger.info(f"API调用: 加载消息, seq={seq}, limit={limit}")
result = await service.load_messages(seq=seq, limit=limit)
if not result.get("success", False):
raise HTTPException(
status_code=500, detail=result.get("message", "加载消息失败")
)
return result
except Exception as e:
logger.error(f"加载消息API调用失败: {e}")
raise HTTPException(status_code=500, detail=f"加载消息失败: {str(e)}")
@router.get("/")
async def get_messages(
page: int = Query(1, description="页码", ge=1),
page_size: int = Query(20, description="每页数量", ge=1, le=100),
msgtype: Optional[str] = Query(None, description="消息类型过滤"),
roomid: Optional[str] = Query(None, description="群聊ID过滤"),
from_user: Optional[str] = Query(None, description="发送者过滤"),
service: MessageService = Depends(get_message_service),
):
"""
查询消息列表
支持分页和过滤条件查询
"""
try:
logger.info(
f"API调用: 查询消息列表, page={page}, page_size={page_size}, "
f"msgtype={msgtype}, roomid={roomid}, from_user={from_user}"
)
result = await service.get_messages(
page=page,
page_size=page_size,
msgtype=msgtype,
roomid=roomid,
from_user=from_user,
)
if not result.get("success", False):
raise HTTPException(
status_code=500, detail=result.get("message", "查询消息列表失败")
)
return result
except Exception as e:
logger.error(f"查询消息列表失败: {e}")
raise HTTPException(status_code=500, detail=f"查询消息列表失败: {str(e)}")
@router.get("/stats")
async def get_message_stats(service: MessageService = Depends(get_message_service)):
"""
获取消息统计信息
返回数据库中消息的统计信息
"""
try:
# 这里可以添加统计逻辑
# 暂时返回空统计,实际实现需要连接数据库统计
return {
"success": True,
"data": {"total_messages": 0, "message_types": {}, "date_distribution": {}},
}
except Exception as e:
logger.error(f"获取消息统计失败: {e}")
raise HTTPException(status_code=500, detail=f"获取消息统计失败: {str(e)}")
@router.get("/{msgid}")
async def get_message_by_id(
msgid: str, service: MessageService = Depends(get_message_service)
):
"""
根据消息ID获取单个消息详情
"""
try:
# 这里可以添加根据msgid查询消息的逻辑
# 暂时返回空,实际实现需要连接数据库查询
return {"success": True, "data": None, "message": "消息查询功能待实现"}
except Exception as e:
logger.error(f"查询消息详情失败 (msgid: {msgid}): {e}")
raise HTTPException(status_code=500, detail=f"查询消息详情失败: {str(e)}")
# 应用关闭时的清理
@router.on_event("shutdown")
async def shutdown_event():
"""应用关闭时清理资源"""
from app.services.message_service import close_message_service
close_message_service()
logger.info("消息服务已关闭")

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""
企业微信会话存档API路由
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse
import logging
from app.services.wework_service import get_wework_service, WeworkService
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/wework",
tags=["wework"],
responses={404: {"description": "Not found"}},
)
def get_service():
"""依赖注入:获取企业微信服务实例"""
return get_wework_service()
@router.get("/health")
async def health_check(service: WeworkService = Depends(get_service)):
"""健康检查端点"""
return {
"status": "healthy",
"service": "wework",
"corpid": service.corpid,
"media_dir": str(service.media_dir)
}
@router.get("/chat")
async def get_chat(
seq: int = Query(0, description="从指定的seq开始拉取消息,首次使用请使用0"),
limit: int = Query(100, description="一次拉取的消息条数,最大值1000条", ge=1, le=1000),
proxy: Optional[str] = Query(None, description="代理链接,如:socks5://10.0.0.1:8081 或 http://10.0.0.1:8081"),
passwd: Optional[str] = Query(None, description="代理账号密码,如:user_name:passwd_123"),
timeout: int = Query(5, description="超时时间,单位秒", ge=1, le=60),
service: WeworkService = Depends(get_service)
):
"""
拉取会话存档
从企业微信拉取聊天记录,支持代理和分页
"""
try:
result = service.get_chat_data(
seq=seq,
limit=limit,
proxy=proxy or "",
passwd=passwd or "",
timeout=timeout
)
return result
except Exception as e:
logger.error(f"拉取会话存档失败: {e}")
raise HTTPException(status_code=500, detail=f"拉取会话存档失败: {str(e)}")
@router.post("/media")
async def download_media(
fileid: str = Query(..., description="媒体文件id,从解密后的会话存档中得到"),
filename: Optional[str] = Query(None, description="保存的文件名,如果为空则自动生成"),
proxy: Optional[str] = Query(None, description="代理链接"),
passwd: Optional[str] = Query(None, description="代理账号密码"),
timeout: int = Query(5, description="超时时间,单位秒", ge=1, le=60),
background_tasks: BackgroundTasks = None,
service: WeworkService = Depends(get_service)
):
"""
拉取媒体文件
下载企业微信中的媒体文件(图片、语音、视频等),支持大文件分片下载
"""
try:
result = service.get_media_data(
sdk_fileid=fileid,
filename=filename,
proxy=proxy or "",
passwd=passwd or "",
timeout=timeout
)
if result.get("success"):
# 可以添加后台任务,比如发送下载完成通知等
if background_tasks:
background_tasks.add_task(
logger.info,
f"媒体文件下载完成: {result['filename']}, 大小: {result['filesize']} 字节"
)
return result
else:
raise HTTPException(status_code=500, detail=result.get("error", "下载失败"))
except Exception as e:
logger.error(f"拉取媒体文件失败: {e}")
raise HTTPException(status_code=500, detail=f"拉取媒体文件失败: {str(e)}")
@router.get("/media/files")
async def list_media_files(
service: WeworkService = Depends(get_service)
):
"""
获取已下载的媒体文件列表
返回所有已下载的媒体文件信息
"""
try:
files = service.get_media_files()
return {
"count": len(files),
"files": files
}
except Exception as e:
logger.error(f"获取媒体文件列表失败: {e}")
raise HTTPException(status_code=500, detail=f"获取媒体文件列表失败: {str(e)}")
@router.get("/media/download/{filename}")
async def download_file(
filename: str,
service: WeworkService = Depends(get_service)
):
"""
下载媒体文件
通过文件名下载已保存的媒体文件
"""
try:
file_path = service.media_dir / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
return FileResponse(
path=file_path,
filename=filename,
media_type='application/octet-stream'
)
except HTTPException:
raise
except Exception as e:
logger.error(f"下载文件失败: {e}")
raise HTTPException(status_code=500, detail=f"下载文件失败: {str(e)}")
@router.delete("/media/cleanup")
async def cleanup_media_files(
days: int = Query(7, description="保留天数,早于此天数的文件将被删除", ge=1, le=365),
service: WeworkService = Depends(get_service)
):
"""
清理旧的媒体文件
删除指定天数之前的媒体文件
"""
try:
deleted_count = service.cleanup_old_files(days)
return {
"message": f"成功删除 {deleted_count} 个旧文件",
"deleted_count": deleted_count,
"days": days
}
except Exception as e:
logger.error(f"清理媒体文件失败: {e}")
raise HTTPException(status_code=500, detail=f"清理媒体文件失败: {str(e)}")
@router.post("/decrypt")
async def decrypt_message(
encrypt_key: str = Query(..., description="解密密钥,使用RSA私钥解密encrypt_random_key后得到"),
encrypt_msg: str = Query(..., description="加密的消息内容"),
service: WeworkService = Depends(get_service)
):
"""
解密会话内容
使用企业自持的RSA私钥解密后的encrypt_key来解密encrypt_chat_msg
"""
try:
result = service.decrypt_data(encrypt_key, encrypt_msg)
return result
except Exception as e:
logger.error(f"解密消息失败: {e}")
raise HTTPException(status_code=500, detail=f"解密消息失败: {str(e)}")
@router.get("/config")
async def get_config(
service: WeworkService = Depends(get_service)
):
"""
获取当前配置
返回企业微信服务的配置信息
"""
return {
"corpid": service.corpid,
"corpid_masked": service.corpid[:8] + "..." + service.corpid[-4:] if len(service.corpid) > 12 else service.corpid,
"media_dir": str(service.media_dir),
"media_dir_exists": service.media_dir.exists()
}
# 应用关闭时的清理
@router.on_event("shutdown")
async def shutdown_event():
"""应用关闭时清理资源"""
from app.services.wework_service import close_wework_service
close_wework_service()
logger.info("企业微信服务已关闭")

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()

28
backend/cert/1_rsa.key Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCg/Rjgv0nm4eT7
+7HBH832GGWVZSmnXpP+HcJJtnxdlFo6cZAm9olc16FcOxfLuWOHDl7+pVIan7HK
vxLrYPkSAqFAYA1E6JlrFhp8ERLgYONiSJycTpqkKQFRZwdnk2joxtGmET+JaNwu
6/U5eaWS7z7408EBSoWCat15aXzOsECnlGCoL6McGMVXijgw7KPw8TxSW/smH+1e
PzLSK3oxNxhKHF8jJgXrm1EKRKi3efEU9eX42f2PFnU73GhB2HufoosMA07DezUV
9vrn6/qTjfnr4q9Z78eLyaapVAz0477icYJMZxOro/RN+3SoDXhw3IGM9Bei315m
H4f6KML7AgMBAAECggEAPLEOJvSWpM82p3Ss/7TAze1Gprq7qFh0jJGlhGqimhjq
pyvW/4lkIbc9Ius4PlqG1gyp7XBf8p+PxHKibFBjtdF9/yn29qqF5CQyGeHJ7wM3
1f6v5BGSJR88WvbviLG/FDqYIPZRsLjygVH9YgylSi6d2+rg0V7J1G/7TOFi5vxc
O3RK7FvSl0YlxcEdyhKIeQ5nqFoY6cbUm+eiQhR1URibwmy0z7PtHnsRrtVnAgYM
4MzDucc3hxL4U5tF7uBUSv5S8Q5YIKqfbpwT9wU4xMhTK2Sek1juzDyP581vV7k4
bq9kdjIVNw6Nq7sxauEjchImpKvtRRHjKnVvskuzaQKBgQDUMxLr8xkGJaWSMc+R
iUBFRR9bRkkaKjnQ6zL7D8jKEGrRIIdNTvDfwit4C5ZGC52dMpOQ9Vk0hqMimxVO
zddH1MfTV1C/z0m5+kWKaFNP1dtp1xLS249sltG7F3JhIafAohPZl9PXTnUszmZh
zqQPiNsfogO1j8nIU74H7iJVRQKBgQDCN/f/RkPaCDEFVIMbqirGD9RmXsqoKcWH
4Sf8GeQJOxCiRSuD53OWxAcPjH9OAZcv0/xEOPf65ktFi8IuuT9ZNEaMu6hMduUg
XuRhc92Csaq65c3nP8m9biRGqe/v+Hz9JMu/bAIj7iboTLrUQ/PgbFswIgO1Matu
TQ/RwpGbPwKBgHAUFd9oBASaJ8yC4KZ2/upXawtzoTLfsK/nOoMewCfx6elRLAWq
N1GIJht+yz1t7y7TQtviqt4yasHhdayszhNTkMPQr4bP/pnrVYWYOsDFLOHHRlr2
utdZBaey56fj9c2qDZ/1MF/dQciLr8njMgY+e7ZGhX0FWEel644Wui2dAoGAL44i
xDfBP27ICgUSgUCebpbLrDFDr/9iZY4FC16Pjo8O2JzVNdwDWoQvMmh1d3U+PrDK
US4d0ip3fdTlvCkR0NbS+3yrjeWC3vU9OHt1Xp4ALvk7/IaK8D461a6gmIKgoB9V
YE6jC4cxsamwCW+GFIqTnWqGu+QolL57xCG/I2MCgYBe95wh/27t3MomLKu4ZKd6
j1vciLAJ2C9KySgt9YZnYKt440Ev76L/5o0njGErv6sRHacmgWD+Symj1AeWPWj6
yBaGbjzByG7Bt+6QpYvUG2ZucoLEP+my2mJHRjsrjDSTCnlZ9tkkOG49lrLSCsHX
dbO+imWbRA37tBS/nPGNhg==
-----END PRIVATE KEY-----

27
backend/cert/2_rsa.key Normal file
View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAsq7L5+D7s0aJLzt2yAkdgLY26+pxaDu+HbuZfokloxeUGGL1
S6MLak+Oytlxjl6nOm/3UBJbltDRmD4WxY8DLj1iofIDvn6V2ExhfKNb8X24N2Qc
S5vjR2LtOrUkAaA++pKukdB5UcA+sca2oDNCVIMAOxyD4NUtKq4544RJOOAvd5lE
nYW17ATy9upr9ud4w+hILTmngOv5KZ8CzVd7f/1X+pfRQNg1PDf5y5I/wBTHdrun
UO2F7vj6p9/bAgjH/CN3hYnOHsHqSW5wPbmU9fbxUlWfFYM42gnCgIy1YsEc50gq
j+VuStOKcDuJwQTDk2fYsuzj/HyfmVTi8c4ZkwIDAQABAoIBAE+HDwhAkaaEQXC9
RavYqsHr19v+t8UNuhtfsPyprA+/4dFM0iJ9CgqiKErOpwUXydRvmyKPWSZyAKeD
Ev2RHnhJKAouPk5d5vGjZUxlJPoB26kDrLhWRL00IaQclU8H9J/wAzSxFh41s3HK
oOQCjIYy3DpzAZtjJjw12VwTN8fQ+bKD4bu26fJGpLfVH42zrZAZklEckH2Zp1lJ
vHPudzFtXKzQIrgNjbAxpUnX6Jyb9EmTSRwkhl48fBoLSB4trfz7BzEdFvdW91D0
MJdRKttYn+9dOddvs60Vrx9s3wMxcJYIDqEuMYSDlrAbXhQhFC96f8eXmsljlxb9
hbbF21kCgYEA3Qg105sui79cCO8+gjt2Z2RLnOs1xpLjr/p97E4K2KLocnQw3EbM
A/jtmiP84XZQzP76k3rWnVqw3D90O8InAMdMoISizYnWIQb8OyNb2Xw4EUKF5T6v
I1cdPnQOAI5oyaL7K28gb6dqPc7qTo2vs7BEmJZjB825SmhJsDbxUwUCgYEAzvNx
RVvRxAzUziJe8LPE3yyX5lN7NyOsNnJooH3+DQP90oo/d5GLTcwagLGRv5jGXdI7
+gwe1oK4qjZCegiMWwfuFk6HCjtm5XV+hleQ/nYtmumLc8MjXFJVmCVZkcRb8W6C
gnBkFD+dDV9KqjNZM6e3Flo1Co60KbL/+cbqjbcCgYBF8DAf+c1bmLxFc3ZC/Q+1
HW4zg9VrNNVovIkVuYgDDmEgvhUrzhcF9sEv8c01hhuBEfk1qzu3ZJfryQQ9WGzA
+U8Mx4tAICoqHyy2hbStd3nCNFtrs071jr4JIe3ATU9o5fI1xx7rVXzve8Z1/q0m
3VKHOaayNG051w/h+zCXBQKBgCcYrJR61TUha26J5b+5Zn728LdtnAB9t8INeSCg
zMbusxv8TCMtltCat4MKiMtWdFszJWuGsKi7x2E4oPRm+wUTiXIjYbWYJUdCHCYL
EvRPpSv35QV4nwyI7Wyx2asFGo2Q4Az+aJm+rs/g6b0fuEFe9OK/JfgZn4WlMGCi
qJsfAoGALdov0c9wmqsYUZskF+YzjqJwtxwHw9Osiljs4CyNV4dUkY3nT/StZ/5b
1maGoOAPTFxZk/YspS+m9X0PZXSfUV7K0p+lseVfRz6vojJxXii+migOBi089MMU
k2BA/4qZ4QSjCJaZJJZWHRk4RkwFWsbv2ihWpAfqPCD4D2cJN54=
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsq7L5+D7s0aJLzt2yAkd
gLY26+pxaDu+HbuZfokloxeUGGL1S6MLak+Oytlxjl6nOm/3UBJbltDRmD4WxY8D
Lj1iofIDvn6V2ExhfKNb8X24N2QcS5vjR2LtOrUkAaA++pKukdB5UcA+sca2oDNC
VIMAOxyD4NUtKq4544RJOOAvd5lEnYW17ATy9upr9ud4w+hILTmngOv5KZ8CzVd7
f/1X+pfRQNg1PDf5y5I/wBTHdrunUO2F7vj6p9/bAgjH/CN3hYnOHsHqSW5wPbmU
9fbxUlWfFYM42gnCgIy1YsEc50gqj+VuStOKcDuJwQTDk2fYsuzj/HyfmVTi8c4Z
kwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -1,50 +0,0 @@
import os
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置类"""
# DeepSeek API配置
deepseek_api_key: str = "your-deepseek-api-key"
# 服务器配置
host: str = "0.0.0.0"
port: int = 8000
# 其他配置
debug: bool = False
# CORS配置
cors_origins: list = [
"http://chat.nexjs.com",
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# 全局配置实例
settings = Settings()
def get_deepseek_api_key() -> str:
"""获取DeepSeek API密钥"""
api_key = settings.deepseek_api_key
if api_key == "your-deepseek-api-key":
# 尝试从环境变量获取
api_key = os.getenv("DEEPSEEK_API_KEY", "your-deepseek-api-key")
return api_key
def is_api_key_configured() -> bool:
"""检查API密钥是否已配置"""
api_key = get_deepseek_api_key()
return api_key != "your-deepseek-api-key"

12
backend/data.json Normal file
View File

@@ -0,0 +1,12 @@
{
"access_token": "TfPE_kJUsbokpSRqeeDNW5-S-lREfLNrpITU42JkTaHhjDqasI6dHAkZ4_63A1-qTmZb9W1KzqZZ9d11QFQMV1hhBK7M_33Qt5kU8rHyyXxj_2raPgEG2UIexe3txVNhjLY872z8R0HXB_pwiacTgJuYJtJvgPRum0G9taHftuoh6Pwcy_tiewe5jlhwdjy5SDOOMUO8LzadhRAHrx0xGQ",
"fetch_time": 1765394181.8370981,
"expires_in": 7200,
"expiry_time": 1765400181.8370981,
"ticket_data": {
"ticket": "EVSxsqHHAbWPlLvl91IqbA==",
"fetch_time": 1765394182.1750567,
"expires_in": 7200,
"expiry_time": 1765401382.1750567
}
}

View File

@@ -0,0 +1,152 @@
// All Rights Reserved.
// *File : WeWorkFinanceSdk_C.h
// @Brief:拉取企业聊天记录与媒体消息sdk头文件
#pragma once
//返回码 错误说明
//10000 参数错误,请求参数错误
//10001 网络错误,网络请求错误
//10002 数据解析失败
//10003 系统失败
//10004 密钥错误导致加密失败
//10005 fileid错误
//10006 解密失败
//10007 找不到消息加密版本的私钥,需要重新传入私钥对
//10008 解析encrypt_key出错
//10009 ip非法
//10010 数据过期
//10011 证书错误
typedef struct WeWorkFinanceSdk_t WeWorkFinanceSdk_t;
// 数据
typedef struct Slice_t
{
char *buf;
int len;
} Slice_t;
typedef struct MediaData
{
char *outindexbuf;
int out_len;
char *data;
int data_len;
int is_finish;
} MediaData_t;
#ifdef __cplusplus
extern "C"
{
#endif
WeWorkFinanceSdk_t *NewSdk();
/**
* 初始化函数
* Return值=0表示该API调用成功
*
* @param [in] sdk NewSdk返回的sdk指针
* @param [in] corpid 调用企业的企业id,例如:wwd08c8exxxx5ab44d,可以在企业微信管理端--我的企业--企业信息查看
* @param [in] secret 聊天内容存档的Secret,可以在企业微信管理端--管理工具--聊天内容存档查看
*
*
* @return 返回是否初始化成功
* 0 - 成功
* !=0 - 失败
*/
int Init(WeWorkFinanceSdk_t *sdk, const char *corpid, const char *secret);
/**
* 拉取聊天记录函数
* Return值=0表示该API调用成功
*
*
* @param [in] sdk NewSdk返回的sdk指针
* @param [in] seq 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0
* @param [in] limit 一次拉取的消息条数,最大值1000条,超过1000条会返回错误
* @param [in] proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
* @param [in] passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
* @param [in] timeout 超时时间,单位秒
* @param [out] chatDatas 返回本次拉取消息的数据,slice结构体.内容包括errcode/errmsg,以及每条消息内容。示例如下:
{"errcode":0,"errmsg":"ok","chatdata":[{"seq":196,"msgid":"CAQQ2fbb4QUY0On2rYSAgAMgip/yzgs=","publickey_ver":3,"encrypt_random_key":"ftJ+uz3n/z1DsxlkwxNgE+mL38H42/KCvN8T60gbbtPD+Rta1hKTuQPzUzO6Hzne97MgKs7FfdDxDck/v8cDT6gUVjA2tZ/M7euSD0L66opJ/IUeBtpAtvgVSD5qhlaQjvfKJc/zPMGNK2xCLFYqwmQBZXbNT7uA69Fflm512nZKW/piK2RKdYJhRyvQnA1ISxK097sp9WlEgDg250fM5tgwMjujdzr7ehK6gtVBUFldNSJS7ndtIf6aSBfaLktZgwHZ57ONewWq8GJe7WwQf1hwcDbCh7YMG8nsweEwhDfUz+u8rz9an+0lgrYMZFRHnmzjgmLwrR7B/32Qxqd79A==","encrypt_chat_msg":"898WSfGMnIeytTsea7Rc0WsOocs0bIAerF6de0v2cFwqo9uOxrW9wYe5rCjCHHH5bDrNvLxBE/xOoFfcwOTYX0HQxTJaH0ES9OHDZ61p8gcbfGdJKnq2UU4tAEgGb8H+Q9n8syRXIjaI3KuVCqGIi4QGHFmxWenPFfjF/vRuPd0EpzUNwmqfUxLBWLpGhv+dLnqiEOBW41Zdc0OO0St6E+JeIeHlRZAR+E13Isv9eS09xNbF0qQXWIyNUi+ucLr5VuZnPGXBrSfvwX8f0QebTwpy1tT2zvQiMM2MBugKH6NuMzzuvEsXeD+6+3VRqL"}]}
*
* @return 返回是否调用成功
* 0 - 成功
* !=0 - 失败
*/
int GetChatData(WeWorkFinanceSdk_t *sdk, unsigned long long seq, unsigned int limit, const char *proxy, const char *passwd, int timeout, Slice_t *chatDatas);
/**
* @brief 解析密文.企业微信自有解密内容
* @param [in] encrypt_key, getchatdata返回的encrypt_random_key,使用企业自持对应版本秘钥RSA解密后的内容
* @param [in] encrypt_msg, getchatdata返回的encrypt_chat_msg
* @param [out] msg, 解密的消息明文
* @return 返回是否调用成功
* 0 - 成功
* !=0 - 失败
*/
int DecryptData(const char *encrypt_key, const char *encrypt_msg, Slice_t *msg);
/**
* 拉取媒体消息函数
* Return值=0表示该API调用成功
*
*
* @param [in] sdk NewSdk返回的sdk指针
* @param [in] sdkFileid 从GetChatData返回的聊天消息中,媒体消息包括的sdkfileid
* @param [in] proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
* @param [in] passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
* @param [in] indexbuf 媒体消息分片拉取,需要填入每次拉取的索引信息。首次不需要填写,默认拉取512k,后续每次调用只需要将上次调用返回的outindexbuf填入即可。
* @param [in] timeout 超时时间,单位秒
* @param [out] media_data 返回本次拉取的媒体数据.MediaData结构体.内容包括data(数据内容)/outindexbuf(下次索引)/is_finish(拉取完成标记)
*
* @return 返回是否调用成功
* 0 - 成功
* !=0 - 失败
*/
int GetMediaData(WeWorkFinanceSdk_t *sdk, const char *indexbuf,
const char *sdkFileid, const char *proxy, const char *passwd, int timeout, MediaData_t *media_data);
/**
* @brief 释放sdk,和NewSdk成对使用
* @return
*/
void DestroySdk(WeWorkFinanceSdk_t *sdk);
//--------------下面接口为了其他语言例如python等调用c接口,酌情使用--------------
Slice_t *NewSlice();
/**
* @brief 释放slice,和NewSlice成对使用
* @return
*/
void FreeSlice(Slice_t *slice);
/**
* @brief 为其他语言提供读取接口
* @return 返回buf指针
* !=NULL - 成功
* NULL - 失败
*/
char *GetContentFromSlice(Slice_t *slice);
int GetSliceLen(Slice_t *slice);
// 媒体记录相关工具
MediaData_t *NewMediaData();
void FreeMediaData(MediaData_t *media_data);
char *GetOutIndexBuf(MediaData_t *media_data);
char *GetData(MediaData_t *media_data);
int GetIndexLen(MediaData_t *media_data);
int GetDataLen(MediaData_t *media_data);
int IsMediaDataFinish(MediaData_t *media_data);
#ifdef __cplusplus
}
#endif

Binary file not shown.

1
backend/lib/sdk/md5.txt Normal file
View File

@@ -0,0 +1 @@
d422224e67eb07a195d1d7115b39284c

142
backend/lib/sdk/sample.cpp Normal file
View File

@@ -0,0 +1,142 @@
#include "WeWorkFinanceSdk_C.h"
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string>
using std::string;
// 私钥内容,示例
static const char* priKey =
"-----BEGIN RSA PRIVATE KEY-----\n"
"MIIEogIBAAKCAQEAngIe+L+lJFQOWu+FtgWzTYcG+bQTANvgatpjM0CswTLR5oY+\n"
"IvpQ9X0EID3AEC0PyaatOgSvmvWCUQunZtA3lhdkdsJG1Nxe1FOOI+Q1qlgW/ykT\n"
"JkuJJv8qnd9HFdpkTkkAB3CxW5zGoYYtBlnp3G3mUhGXZ/6dIWQ4Wln4lJlUlxSe\n"
"Qv4YvDOGN2dWbIG79nL9YNKyk9yYgLop2O7XJGXgqCuV5CUz4jdxB5XCH3SzqFgJ\n"
"A36JguG5jD7ovFh7cL+yp1iTyF/+1woDARXXpIj+W6K3Skoo5cpt0BboPYu0EuCz\n"
"YQtfn+hUfkcS6kltnwSXUBFzrSLhjCkf5tY/LwIDAQABAoIBADxB8M/KPk9hYuwZ\n"
"BjQJbny1btqRauHb+HSnfkdQddFj8RppvY6XSYKlP4qQIgvJM4kicFN87/83e5Au\n"
"0pNp+pjZqO2FX3/T3rI4fy9+21xwJaz/XBcxnUdq7jSwrqpN1Azq2AD+YBTg7/sv\n"
"cUJHzeDLZJeNr+Dv/7p5PuhuAVehm3g+b0GwuWK2HieXAlRR240vn+dCZoq8ti6Y\n"
"jmcZcJ9E6urY/L3d5bqByN2SGSbgJav6VAQYf8vfOU+SeAXFunGUWasuS3tvQlMO\n"
"eEPW37oNAsKHkKDQcKAcPo59fwPdbPKE/5N/wKPsbN8nVYTqdzociQdZnNpG8DK2\n"
"SdE5ZzkCgYEA4S3mNaklMA0vNR20lSum68j4L0r57Q7pN0ATmItBhF5ihEbtLlAg\n"
"OsLsxQZ2XIzevsnvUvRKDi7pcu3u2VqlX/89aWOcTOyEyKGHCUPkkOr4+3m2cGtQ\n"
"WF676TtNcfj4yOyJ5gMmtld84yUwrHjHgvtP/L73MftHzssQFj0Qk20CgYEAs6Ka\n"
"kXLAHtKwB5R/knXmkCYnfRX/TqjhTVO3Hr2PvqojAmiuWmWp9YsXtbYDFpxQPOp0\n"
"Y1qosC1qW+pZCXgSlsjsdhJa3t2hhsF2SAWoy2QpAsuHWizHq9T8XFb14U/4PjBz\n"
"B9P1mf5c84Iox3tUnwKuBsNt8FOXwJRPnddsH4sCgYB2zpLR6SaXzb0El2G5+sgd\n"
"S3G28iYfb2iZIBO2vnKY9nugMxPdBaWbm1D1O02j0yeLHlxJNl8EULgXTXZh87Oh\n"
"jLfILShAZeJBQQhQWSP1WiR3+mXJtrUzivg2PHXGFoic6kBvvWXlew+HgQ3HUgpi\n"
"D1rKja87Dct7TiR0t09RmQKBgBspvWG9Dlwa089xnxDQjzrA6ju59c85Lkh5Hjmh\n"
"xHi6VWzp02OL9/VPQRiTG0hx/pgXg936OYPaejjH6QrLg1QVsdvwzpjgvqoX2wDt\n"
"CicnUCRCDoeOOes726VC6Q9W/ly5CsvmOz87/WY9ClmPxq38w7/2EUvD+DCAvMFL\n"
"b+gdAoGAUiY1xefNe4qnbQzJXpPUUcOGiJcl7r9HwrZx8tDwGgBg+/5zbwi/0TfH\n"
"YrIj86g+JHaMEQCcKko8H0KdQzzp1vNECNxOGvz8ckOrO+NsLzHOpZ4jhojV/vFg\n"
"FNl6PhOdUozqTYwrnszoO67a/jKS/rWQh5Ta2Y2K5sUNprk8Z1w=\n"
"-----END RSA PRIVATE KEY-----\n";
// rsa 解密函数,需企业自行实现
int RsaDecryptChatData(const string &encryptdata,const string &priKey,string &aeskey);
int main(int argc, char *argv[])
{
int ret = 0;
if (argc < 2)
{
printf("./sdktools 1 seq limit proxy passwd timeout\n");
printf("./sdktools 2 fileid savefile proxy passwd timeout\n");
printf("./sdktools 3 encrypt_key encrypt_msg\n");
return -1;
}
// new sdk api
WeWorkFinanceSdk_t* sdk = NewSdk();
// init api
ret = Init(sdk, "wwd08coe7d775abaaa","zJ6k0naVVQ--gt9PUSSEvs03zW_nlDVmjAkPOTAfrew");
if (ret != 0) {
printf("init sdk err ret:%d\n", ret);
return ret;
}
int type = strtoul(argv[1], NULL, 10);
if (type == 1)
{
uint64_t iSeq = strtoul(argv[2], NULL, 10);
uint64_t iLimit = strtoul(argv[3], NULL, 10);
uint64_t timeout = strtoul(argv[6], NULL, 10);
Slice_t* chatDatas = NewSlice();
// getchatdata api
ret = GetChatData(sdk, iSeq, iLimit, argv[4],argv[5], timeout, chatDatas);
if (ret != 0) {
printf("GetChatData err ret:%d\n", ret);
return -1;
}
printf("GetChatData len:%d data:%s\n", chatDatas->len, GetContentFromSlice(chatDatas));
FreeSlice(chatDatas);
}
else if (type == 2)
{
std::string index = "";
uint64_t timeout = strtoul(argv[6], NULL, 10);
while(true){
printf("index:%s\n",index.c_str());
MediaData_t* media = NewMediaData();
// getmediadata api
ret = GetMediaData(sdk, index.c_str(), argv[2],argv[4],argv[5], timeout, media);
if (ret != 0) {
printf("GetMediaData err ret:%d\n", ret);
FreeMediaData(media);
return -1;
}
printf("content size:%d isfin:%d outindex:%s\n",GetDataLen(media),IsMediaDataFinish(media),GetOutIndexBuf(media));
char file[200];
snprintf(file, sizeof(file), "%s%s", "/home/qspace/upload/", argv[3]);
FILE *fp = fopen(file, "ab+");
printf("filename:%s \n", file);
if (NULL == fp) {
printf("open file err\n");
return -1;
}
fwrite(GetData(media),GetDataLen(media), 1, fp);
fclose(fp);
if(IsMediaDataFinish(media)==1)
{
FreeMediaData(media);
break;
}
else{
// assign index value if not finish
index.assign(GetOutIndexBuf(media));
// need free media
FreeMediaData(media);
}
}
} else if (type == 3) {
Slice_t* msg = NewSlice();
std::string encrypt_key;
// rsa decrypt code .need do it by yourself
int ret = RsaDecryptChatData(argv[2], priKey, encrypt_key);
printf("Rsadecrypt key :%s ret :%d\n" ,encrypt_key.c_str(), ret);
// decryptdata api
ret = DecryptData(encrypt_key.c_str(), argv[3], msg);
printf("chatdata :%s ret :%d\n" ,GetContentFromSlice(msg), ret);
FreeSlice(msg);
} else if(type == 4){
Slice_t* msg = NewSlice();
ret = DecryptData(argv[2], argv[3], msg);
printf("chatdata :%s ret :%d\n" ,GetContentFromSlice(msg), ret);
}
DestroySdk(sdk);
return ret;
}

View File

@@ -0,0 +1,148 @@
#include "WeWorkFinanceSdk_C.h"
#include <dlfcn.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string>
using std::string;
typedef WeWorkFinanceSdk_t* newsdk_t();
typedef int Init_t(WeWorkFinanceSdk_t*, const char*, const char*);
typedef void DestroySdk_t(WeWorkFinanceSdk_t*);
typedef int GetChatData_t(WeWorkFinanceSdk_t*, unsigned long long, unsigned int, const char*, const char*, int, Slice_t*);
typedef Slice_t* NewSlice_t();
typedef void FreeSlice_t(Slice_t*);
typedef int GetMediaData_t(WeWorkFinanceSdk_t*, const char*, const char*, const char*, const char*, int, MediaData_t*);
typedef int DecryptData_t(const char*, const char*, Slice_t*);
typedef MediaData_t* NewMediaData_t();
typedef void FreeMediaData_t(MediaData_t*);
int main(int argc, char* argv[])
{
int ret = 0;
//seq 表示该企业存档消息序号,该序号单调递增,拉取序号建议设置为上次拉取返回结果中最大序号。首次拉取时seq传0,sdk会返回有效期内最早的消息。
//limit 表示本次拉取的最大消息条数,取值范围为1~1000
//proxy与passwd为代理参数,如果运行sdk的环境不能直接访问外网,需要配置代理参数。sdk访问的域名是"https://qyapi.weixin.qq.com"。
//建议先通过curl访问"https://qyapi.weixin.qq.com",验证代理配置正确后,再传入sdk。
//timeout 为拉取会话存档的超时时间,单位为秒,建议超时时间设置为5s。
//sdkfileid 媒体文件id,从解密后的会话存档中得到
//savefile 媒体文件保存路径
//encrypt_key 拉取会话存档返回的encrypt_random_key,使用配置在企业微信管理台的rsa公钥对应的私钥解密后得到encrypt_key。
//encrypt_chat_msg 拉取会话存档返回的encrypt_chat_msg
if (argc < 2) {
printf("./sdktools 1(chatmsg) 2(mediadata) 3(decryptdata)\n");
printf("./sdktools 1 seq limit proxy passwd timeout\n");
printf("./sdktools 2 fileid proxy passwd timeout savefile\n");
printf("./sdktools 3 encrypt_key encrypt_chat_msg\n");
return -1;
}
void* so_handle = dlopen("./libWeWorkFinanceSdk_C.so", RTLD_LAZY);
if (!so_handle) {
printf("load sdk so fail:%s\n", dlerror());
return -1;
}
newsdk_t* newsdk_fn = (newsdk_t*)dlsym(so_handle, "NewSdk");
WeWorkFinanceSdk_t* sdk = newsdk_fn();
//使用sdk前需要初始化,初始化成功后的sdk可以一直使用。
//如需并发调用sdk,建议每个线程持有一个sdk实例。
//初始化时请填入自己企业的corpid与secrectkey。
Init_t* init_fn = (Init_t*)dlsym(so_handle, "Init");
DestroySdk_t* destroysdk_fn = (DestroySdk_t*)dlsym(so_handle, "DestroySdk");
ret = init_fn(sdk, "wwdf65802ca25ec195", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE");
if (ret != 0) {
//sdk需要主动释放
destroysdk_fn(sdk);
printf("init sdk err ret:%d\n", ret);
return -1;
}
int type = strtoul(argv[1], NULL, 10);
if (type == 1) {
//拉取会话存档
uint64_t iSeq = strtoul(argv[2], NULL, 10);
uint64_t iLimit = strtoul(argv[3], NULL, 10);
uint64_t timeout = strtoul(argv[6], NULL, 10);
NewSlice_t* newslice_fn = (NewSlice_t*)dlsym(so_handle, "NewSlice");
FreeSlice_t* freeslice_fn = (FreeSlice_t*)dlsym(so_handle, "FreeSlice");
//每次使用GetChatData拉取存档前需要调用NewSlice获取一个chatDatas,在使用完chatDatas中数据后,还需要调用FreeSlice释放。
Slice_t* chatDatas = newslice_fn();
GetChatData_t* getchatdata_fn = (GetChatData_t*)dlsym(so_handle, "GetChatData");
ret = getchatdata_fn(sdk, iSeq, iLimit, argv[4], argv[5], timeout, chatDatas);
if (ret != 0) {
freeslice_fn(chatDatas);
printf("GetChatData err ret:%d\n", ret);
return -1;
}
printf("GetChatData len:%d data:%s\n", chatDatas->len, chatDatas->buf);
freeslice_fn(chatDatas);
}
else if (type == 2) {
//拉取媒体文件
std::string index;
uint64_t timeout = strtoul(argv[5], NULL, 10);
int isfinish = 0;
GetMediaData_t* getmediadata_fn = (GetMediaData_t*)dlsym(so_handle, "GetMediaData");
NewMediaData_t* newmediadata_fn = (NewMediaData_t*)dlsym(so_handle, "NewMediaData");
FreeMediaData_t* freemediadata_fn = (FreeMediaData_t*)dlsym(so_handle, "FreeMediaData");
//媒体文件每次拉取的最大size为512k,因此超过512k的文件需要分片拉取。若该文件未拉取完整,mediaData中的is_finish会返回0,同时mediaData中的outindexbuf会返回下次拉取需要传入GetMediaData的indexbuf。
//indexbuf一般格式如右侧所示,”Range:bytes=524288-1048575“,表示这次拉取的是从524288到1048575的分片。单个文件首次拉取填写的indexbuf为空字符串,拉取后续分片时直接填入上次返回的indexbuf即可。
while (isfinish == 0) {
//每次使用GetMediaData拉取存档前需要调用NewMediaData获取一个mediaData,在使用完mediaData中数据后,还需要调用FreeMediaData释放。
printf("index:%s\n", index.c_str());
MediaData_t* mediaData = newmediadata_fn();
ret = getmediadata_fn(sdk, index.c_str(), argv[2], argv[3], argv[4], timeout, mediaData);
if (ret != 0) {
//单个分片拉取失败建议重试拉取该分片,避免从头开始拉取。
freemediadata_fn(mediaData);
printf("GetMediaData err ret:%d\n", ret);
return -1;
}
printf("content size:%d isfin:%d outindex:%s\n", mediaData->data_len, mediaData->is_finish, mediaData->outindexbuf);
//大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。
char file[200];
snprintf(file, sizeof(file), "%s", argv[6]);
FILE* fp = fopen(file, "ab+");
printf("filename:%s \n", file);
if (NULL == fp) {
freemediadata_fn(mediaData);
printf("open file err\n");
return -1;
}
fwrite(mediaData->data, mediaData->data_len, 1, fp);
fclose(fp);
//获取下次拉取需要使用的indexbuf
index.assign(string(mediaData->outindexbuf));
isfinish = mediaData->is_finish;
freemediadata_fn(mediaData);
}
}
else if (type == 3) {
//解密会话存档内容
//sdk不会要求用户传入rsa私钥,保证用户会话存档数据只有自己能够解密。
//此处需要用户先用rsa私钥解密encrypt_random_key后,作为encrypt_key参数传入sdk来解密encrypt_chat_msg获取会话存档明文。
//每次使用DecryptData解密会话存档前需要调用NewSlice获取一个Msgs,在使用完Msgs中数据后,还需要调用FreeSlice释放。
NewSlice_t* newslice_fn = (NewSlice_t*)dlsym(so_handle, "NewSlice");
FreeSlice_t* freeslice_fn = (FreeSlice_t*)dlsym(so_handle, "FreeSlice");
Slice_t* Msgs = newslice_fn();
// decryptdata api
DecryptData_t* decryptdata_fn = (DecryptData_t*)dlsym(so_handle, "DecryptData");
ret = decryptdata_fn(argv[2], argv[3], Msgs);
printf("chatdata :%s ret :%d\n", Msgs->buf, ret);
freeslice_fn(Msgs);
}
return ret;
}

View File

@@ -0,0 +1 @@
20240606

Binary file not shown.

View File

@@ -5,6 +5,9 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"arrow>=1.4.0",
"beanie>=2.0.1",
"cryptography>=46.0.3",
"fastapi>=0.122.0",
"httpx>=0.28.1",
"pydantic>=2.12.4",

View File

@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
企业微信会话存档解密脚本
使用RSA私钥解密加密的会话数据
"""
import json
import os
import sys
import base64
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
# 添加当前目录到路径,以便导入wework_ctypes
sys.path.insert(0, str(Path(__file__).parent))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError
def load_rsa_private_key(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(encrypted_data_base64: str, private_key: str) -> str:
"""
使用RSA私钥解密Base64编码的数据
Args:
encrypted_data_base64: Base64编码的加密数据
private_key: RSA私钥字符串
Returns:
解密后的字符串
"""
try:
# 使用cryptography库进行RSA解密
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
import base64
# 加载私钥
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(encrypt_random_key: str, encrypt_chat_msg: str,
rsa_private_key_path: str, publickey_ver: int = None) -> tuple:
"""
解密聊天消息
Args:
encrypt_random_key: 加密的随机密钥(Base64编码)
encrypt_chat_msg: 加密的聊天消息
rsa_private_key_path: RSA私钥文件路径或目录
publickey_ver: publickey_ver版本号,用于动态匹配私钥
Returns:
(解密后的消息字典, 实际使用的私钥路径)
"""
print(f"正在解密消息...")
print(f"加密密钥长度: {len(encrypt_random_key)}")
print(f"加密消息长度: {len(encrypt_chat_msg)}")
# 动态确定私钥路径
actual_key_path = rsa_private_key_path
# 检查是否使用默认私钥路径(用户未指定自定义路径)
# 通过检查路径是否指向默认文件来判断
default_key_path = Path("cert/1_rsa.key")
specified_key_path = Path(rsa_private_key_path)
# 如果使用默认私钥路径且提供了publickey_ver,则动态匹配
if publickey_ver is not None and specified_key_path == default_key_path:
# 构建动态私钥路径:cert/{publickey_ver}_rsa.key
dynamic_key_path = f"cert/{publickey_ver}_rsa.key"
dynamic_path = Path(dynamic_key_path)
if dynamic_path.exists():
actual_key_path = dynamic_key_path
print(f"检测到publickey_ver={publickey_ver},使用动态私钥: {actual_key_path}")
else:
print(f"警告: 动态私钥文件不存在: {dynamic_key_path},使用默认私钥: {actual_key_path}")
# 1. 加载RSA私钥
print(f"加载RSA私钥: {actual_key_path}")
private_key = load_rsa_private_key(actual_key_path)
print(f"✓ RSA私钥加载成功")
# 2. 使用RSA私钥解密encrypt_random_key得到encrypt_key
print("正在使用RSA解密随机密钥...")
try:
encrypt_key = rsa_decrypt_base64(encrypt_random_key, private_key)
print(f"✓ RSA解密成功,encrypt_key长度: {len(encrypt_key)}")
except Exception as e:
print(f"✗ RSA解密失败: {e}")
# 尝试直接使用encrypt_random_key作为encrypt_key(某些情况下可能已经解密)
print("尝试使用原始encrypt_random_key作为encrypt_key...")
encrypt_key = encrypt_random_key
# 3. 使用SDK解密聊天消息
print("正在使用SDK解密聊天消息...")
try:
# 初始化SDK(只需要解密功能,不需要corpid和secret)
with WeWorkFinanceSDK("dummy", "dummy") as sdk:
decrypted_msg = sdk.decrypt_data(encrypt_key, encrypt_chat_msg)
print("✓ SDK解密成功")
# 尝试解析JSON
try:
result = json.loads(decrypted_msg)
return result, actual_key_path
except json.JSONDecodeError:
# 如果不是JSON,返回原始文本
return {"raw_message": decrypted_msg}, actual_key_path
except WeWorkSDKError as e:
raise Exception(f"SDK解密失败: {e}")
except Exception as e:
raise Exception(f"解密过程出错: {e}")
def decrypt_file(input_file: str, rsa_private_key_path: str, output_file: str = None):
"""
解密文件中的聊天数据
Args:
input_file: 输入文件路径(JSON格式)
rsa_private_key_path: RSA私钥文件路径
output_file: 输出文件路径(可选)
"""
print("=" * 60)
print("企业微信会话存档解密脚本")
print("=" * 60)
# 检查输入文件
input_path = Path(input_file)
if not input_path.exists():
print(f"错误: 输入文件不存在: {input_file}")
return 1
# 检查RSA私钥文件(只检查用户指定的路径,动态路径在解密时检查)
rsa_path = Path(rsa_private_key_path)
if not rsa_path.exists():
print(f"错误: 指定的RSA私钥文件不存在: {rsa_private_key_path}")
return 1
print(f"输入文件: {input_path.absolute()}")
print(f"指定私钥: {rsa_path.absolute()}")
print("-" * 60)
# 用于记录实际使用的私钥路径
actual_key_paths_used = set()
try:
# 读取输入文件
with open(input_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f"文件加载成功")
# 检查数据格式
if "chatdata" not in data:
print("错误: 文件中没有chatdata字段")
return 1
chatdata = data.get("chatdata", [])
chat_count = len(chatdata)
print(f"共发现 {chat_count} 条聊天记录")
if chat_count == 0:
print("没有可解密的聊天记录")
return 0
# 解密所有消息
decrypted_messages = []
success_count = 0
fail_count = 0
for i, msg in enumerate(chatdata):
# print('msg', type(msg))
seq = msg.get("seq", i + 1)
msgid = msg.get("msgid", f"msg_{i}")
encrypt_random_key = msg.get("encrypt_random_key", "")
encrypt_chat_msg = msg.get("encrypt_chat_msg", "")
print(f"\n[{i+1}/{chat_count}] 解密消息 seq={seq}, msgid={msgid[:20]}...")
if not encrypt_random_key or not encrypt_chat_msg:
print(f" 跳过: 缺少加密数据")
fail_count += 1
decrypted_messages.append({
"seq": seq,
"msgid": msgid,
"error": "缺少加密数据",
"original": msg
})
continue
try:
# 获取publickey_ver
publickey_ver = msg.get("publickey_ver")
# 解密消息
decrypted_msg, actual_key_path = decrypt_chat_message(
encrypt_random_key,
encrypt_chat_msg,
rsa_private_key_path,
publickey_ver
)
# 记录实际使用的私钥路径
actual_key_paths_used.add(actual_key_path)
# 添加元数据
if isinstance(decrypted_msg, dict):
decrypted_msg["_metadata"] = {
"seq": seq,
"msgid": msgid,
"decrypt_time": datetime.now().isoformat(),
"original_has_encryption": True
}
decrypted_messages.append({
"seq": seq,
"msgid": msgid,
"decrypted": decrypted_msg,
"original_summary": {
"publickey_ver": msg.get("publickey_ver"),
"encrypt_key_len": len(encrypt_random_key),
"encrypt_msg_len": len(encrypt_chat_msg)
}
})
success_count += 1
print(f" ✓ 解密成功")
# 显示解密后的消息摘要
if isinstance(decrypted_msg, dict):
msg_type = decrypted_msg.get("msgtype", "unknown")
from_user = decrypted_msg.get("from", "unknown")
to_user = decrypted_msg.get("tolist", ['unknown'])[0]
if isinstance(decrypted_msg.get("tolist"), list) and len(decrypted_msg.get("tolist")) > 1:
to_user += f" 等 {len(decrypted_msg.get('tolist'))} 人"
print(f" 类型: {msg_type}, 来自: {from_user}, 发送给: {to_user}")
# 成功解密后继续处理下一条消息
continue
except Exception as e:
print(f" ✗ 解密失败: {e}")
fail_count += 1
decrypted_messages.append({
"seq": seq,
"msgid": msgid,
"error": str(e),
"original": msg
})
print("\n" + "-" * 60)
print(f"解密完成: 成功 {success_count} 条, 失败 {fail_count} 条")
# 显示实际使用的私钥信息
if actual_key_paths_used:
print(f"实际使用的私钥文件:")
for key_path in sorted(actual_key_paths_used):
print(f" - {key_path}")
# 生成输出文件名
if output_file is None:
timestamp = datetime.now().strftime("%y%m%d_%H%M%S")
output_file = f"data/decrypted_{input_path.stem}_{timestamp}.json"
output_path = Path(output_file)
# 准备输出数据
output_data = {
"metadata": {
"input_file": str(input_path.absolute()),
"decrypt_time": datetime.now().isoformat(),
"total_messages": chat_count,
"successful_decrypts": success_count,
"failed_decrypts": fail_count,
"specified_key": str(rsa_path.absolute()),
"actual_keys_used": sorted([str(Path(p).absolute()) for p in actual_key_paths_used])
},
"decrypted_messages": decrypted_messages,
"original_metadata": data.get("_metadata", {})
}
# 保存解密结果
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"解密结果已保存到: {output_path.absolute()}")
# 显示一些解密后的消息示例
if success_count > 0:
print("\n解密消息示例:")
for i, msg in enumerate(decrypted_messages[:3]): # 显示前3条
if "decrypted" in msg:
decrypted = msg["decrypted"]
if isinstance(decrypted, dict):
seq = msg.get("seq", "N/A")
msg_type = decrypted.get("msgtype", "unknown")
content = decrypted.get("content", "无内容")[:100]
print(f" [{i+1}] seq={seq}, 类型={msg_type}")
print(f" 内容: {content}...")
print("\n" + "=" * 60)
print("解密完成")
return 0
except json.JSONDecodeError as e:
print(f"JSON解析错误: {e}")
return 1
except Exception as e:
print(f"解密过程出错: {e}")
import traceback
traceback.print_exc()
return 1
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(
description="企业微信会话存档解密脚本",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
# 解密data目录下的文件
%(prog)s data/chat_0_100_251203.json
# 指定RSA私钥路径
%(prog)s data/chat_0_100_251203.json --key /path/to/private_key.pem
# 指定输出文件
%(prog)s data/chat_0_100_251203.json --output decrypted_result.json
"""
)
parser.add_argument('input_file', help='输入文件路径(JSON格式)')
parser.add_argument('--key', '-k', default='cert/1_rsa.key',
help='RSA私钥文件路径,默认: cert/1_rsa.key')
parser.add_argument('--output', '-o', help='输出文件路径,默认自动生成')
args = parser.parse_args()
return decrypt_file(args.input_file, args.key, args.output)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,704 @@
#!/usr/bin/env python3
"""
企业微信会话存档SDK测试脚本
简单测试脚本,调用SDK获取会话数据并自动解密和存储到数据库
使用示例:
python test_chat_data.py # 使用默认seq=0
python test_chat_data.py --seq 1000 # 从seq=1000开始拉取
python test_chat_data.py -s 5000 # 从seq=5000开始拉取
python test_chat_data.py --help # 显示帮助信息
"""
import argparse
import asyncio
import json
import os
import sys
import base64
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
# 添加当前目录到路径,以便导入wework_ctypes
sys.path.insert(0, str(Path(__file__).parent))
# 添加backend目录到路径,以便导入模型
sys.path.insert(0, str(Path(__file__).parent.parent))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError, ERROR_CODES
def load_rsa_private_key(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(encrypted_data_base64: str, private_key: str) -> str:
"""
使用RSA私钥解密Base64编码的数据
Args:
encrypted_data_base64: Base64编码的加密数据
private_key: RSA私钥字符串
Returns:
解密后的字符串
"""
try:
# 使用cryptography库进行RSA解密
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
import base64
# 加载私钥
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(
sdk,
encrypt_random_key: str,
encrypt_chat_msg: str,
rsa_private_key_path: str,
publickey_ver: int = None,
) -> dict:
"""
解密聊天消息
Args:
sdk: WeWorkFinanceSDK实例
encrypt_random_key: 加密的随机密钥(Base64编码)
encrypt_chat_msg: 加密的聊天消息
rsa_private_key_path: RSA私钥文件路径或目录
publickey_ver: publickey_ver版本号,用于动态匹配私钥
Returns:
解密后的消息字典
"""
# 动态确定私钥路径
actual_key_path = rsa_private_key_path
# 检查是否使用默认私钥路径(用户未指定自定义路径)
# 通过检查路径是否指向默认文件来判断
default_key_path = Path("cert/1_rsa.key")
specified_key_path = Path(rsa_private_key_path)
# 如果使用默认私钥路径且提供了publickey_ver,则动态匹配
if publickey_ver is not None and specified_key_path == default_key_path:
# 构建动态私钥路径:cert/{publickey_ver}_rsa.key
dynamic_key_path = f"cert/{publickey_ver}_rsa.key"
dynamic_path = Path(dynamic_key_path)
if dynamic_path.exists():
actual_key_path = dynamic_key_path
else:
# 如果动态私钥不存在,使用默认私钥
pass
# 1. 加载RSA私钥
private_key = load_rsa_private_key(actual_key_path)
# 2. 使用RSA私钥解密encrypt_random_key得到encrypt_key
try:
encrypt_key = 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 = 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}
def decrypt_chat_data(chatdata, sdk, rsa_private_key_path="cert/1_rsa.key"):
"""
解密聊天数据
Args:
chatdata: 原始聊天数据列表
sdk: WeWorkFinanceSDK实例
rsa_private_key_path: RSA私钥文件路径
Returns:
(decrypted_chatdata, stats)
decrypted_chatdata: 解密后的聊天数据列表
stats: 解密统计信息字典
"""
decrypted_chatdata = []
stats = {
"total_encrypted_messages": 0,
"successful_decrypts": 0,
"failed_decrypts": 0,
}
# 检查私钥文件是否存在
rsa_path = Path(rsa_private_key_path)
if not rsa_path.exists():
print(f"警告: RSA私钥文件不存在: {rsa_private_key_path}")
print("将跳过解密,保存原始加密数据")
return chatdata, stats
print(f"使用RSA私钥: {rsa_path.absolute()}")
print("开始解密聊天数据...")
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 = decrypt_chat_message(
sdk,
encrypt_random_key,
encrypt_chat_msg,
rsa_private_key_path,
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:
print(f" 已解密 {stats['successful_decrypts']} 条消息...")
except Exception as e:
stats["failed_decrypts"] += 1
# 解密失败,跳过此消息(不添加到结果中)
continue
return decrypted_chatdata, stats
async def save_messages_to_database(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:
print("没有消息需要存储到数据库")
return stats
print(f"\n开始将 {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://localhost:27017",
)
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(database=client[database_name], document_models=models)
print(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实例
# 注意:使用by_alias=True来处理"from"字段的别名
message_data = {
"msgid": msg.get("msgid"),
"action": msg.get("action"),
"from": msg.get("from"), # 使用别名
"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:
print(f"创建消息实例失败 (msgid: {msgid}): {e}")
stats["failed"] += 1
# 批量插入新消息
if new_messages:
try:
await Message.insert_many(new_messages)
stats["saved"] += len(new_messages)
print(
f" 已存储 {len(new_messages)} 条消息 (进度: {i + len(batch)}/{len(messages)})"
)
except Exception as e:
print(f"批量插入失败: {e}")
stats["failed"] += len(new_messages)
# 关闭数据库连接
await client.close()
print(f"数据库存储完成:")
print(f" 总计消息: {stats['total_messages']}")
print(f" 成功存储: {stats['saved']}")
print(f" 跳过重复: {stats['skipped_duplicates']}")
print(f" 存储失败: {stats['failed']}")
except Exception as e:
print(f"数据库存储过程中发生错误: {e}")
import traceback
traceback.print_exc()
stats["failed"] = len(messages)
return stats
def main():
"""主函数:测试获取会话数据"""
# 解析命令行参数
parser = argparse.ArgumentParser(
description="企业微信会话存档SDK测试脚本",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
%(prog)s # 使用默认seq=0
%(prog)s --seq 1000 # 从seq=1000开始拉取
%(prog)s -s 5000 # 从seq=5000开始拉取
%(prog)s --limit 500 # 一次拉取500条消息
%(prog)s --timeout 30 # 设置超时时间为30秒
""",
)
parser.add_argument(
"-s",
"--seq",
type=int,
default=0,
help="起始seq值,首次拉取使用0,后续拉取使用上次返回的最大seq值 (默认: 0)",
)
parser.add_argument(
"-l",
"--limit",
type=int,
default=1000,
help="一次拉取的消息数量限制 (默认: 1000)",
)
parser.add_argument(
"-t", "--timeout", type=int, default=10, help="请求超时时间(秒) (默认: 10)"
)
parser.add_argument(
"--proxy",
type=str,
default="",
help="代理服务器地址,格式: http://proxy.example.com:8080 (默认: 空)",
)
parser.add_argument(
"--passwd", type=str, default="", help="代理服务器密码 (默认: 空)"
)
args = parser.parse_args()
print("=" * 60)
print("企业微信会话存档SDK测试脚本")
print("=" * 60)
print(f"命令行参数: seq={args.seq}, limit={args.limit}, timeout={args.timeout}s")
if args.proxy:
print(f"代理设置: {args.proxy}")
# 从环境变量获取配置
corpid = os.getenv("CORPID", "wwdf65802ca25ec195")
secret = os.getenv("WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE")
print(f"企业ID: {corpid}")
print(f"Secret: {secret[:10]}...") # 只显示前10个字符,避免泄露完整密钥
# 检查动态库是否存在
lib_path = Path(__file__).parent.parent / "lib" / "sdk" / "libWeWorkFinanceSdk_C.so"
if not lib_path.exists():
print(f"错误: 动态库不存在: {lib_path}")
print("请确保已正确放置libWeWorkFinanceSdk_C.so文件")
return 1
print(f"动态库路径: {lib_path}")
# 确保data目录存在
data_dir = Path("data")
data_dir.mkdir(exist_ok=True)
print(f"数据目录: {data_dir.absolute()}")
print("-" * 60)
try:
# 初始化SDK(使用上下文管理器确保资源正确释放)
print("正在初始化SDK...")
with WeWorkFinanceSDK(corpid, secret, str(lib_path)) as sdk:
print("✓ SDK初始化成功")
# 设置拉取参数(从命令行参数获取)
seq = args.seq
limit = args.limit
timeout = args.timeout
print(f"\n正在拉取会话数据...")
print(f"参数: seq={seq}, limit={limit}, timeout={timeout}s")
if args.proxy:
print(f"代理: {args.proxy}")
# 调用SDK获取聊天数据
result_str = sdk.get_chat_data(
seq=seq,
limit=limit,
proxy=args.proxy, # 使用命令行指定的代理
passwd=args.passwd, # 使用命令行指定的代理密码
timeout=timeout,
)
print("✓ 数据拉取成功")
print("-" * 60)
# 尝试解析JSON
try:
result = json.loads(result_str)
# 检查错误码
errcode = result.get("errcode", -1)
errmsg = result.get("errmsg", "")
if errcode == 0:
print(f"成功: {errmsg}")
# 获取聊天数据
chatdata = result.get("chatdata", [])
original_chat_count = len(chatdata)
print(f"共获取 {original_chat_count} 条聊天记录")
if original_chat_count > 0:
# 显示第一条消息的摘要信息
print("\n第一条消息摘要:")
first_msg = chatdata[0]
print(f" seq: {first_msg.get('seq', 'N/A')}")
print(f" msgid: {first_msg.get('msgid', 'N/A')}")
print(
f" publickey_ver: {first_msg.get('publickey_ver', 'N/A')}"
)
# 检查是否有加密数据
encrypt_random_key = first_msg.get("encrypt_random_key", "")
encrypt_chat_msg = first_msg.get("encrypt_chat_msg", "")
if encrypt_random_key and encrypt_chat_msg:
print(f" 加密密钥长度: {len(encrypt_random_key)}")
print(f" 加密消息长度: {len(encrypt_chat_msg)}")
print(" 注意: 消息已加密,将自动解密")
else:
print(" 消息未加密")
# 解密聊天数据
decrypted_chatdata, decrypt_stats = decrypt_chat_data(chatdata, sdk)
# 更新结果中的chatdata
result["chatdata"] = decrypted_chatdata
decrypted_chat_count = len(decrypted_chatdata)
# 显示解密统计
print("\n" + "-" * 60)
print("解密完成:")
print(f" 原始消息总数: {original_chat_count}")
print(
f" 加密消息数量: {decrypt_stats['total_encrypted_messages']}"
)
print(f" 成功解密: {decrypt_stats['successful_decrypts']}")
print(f" 解密失败: {decrypt_stats['failed_decrypts']}")
print(f" 最终输出消息数: {decrypted_chat_count}")
# 显示最大seq值,用于下次拉取
if decrypted_chat_count > 0:
max_seq = max(msg.get("seq", 0) for msg in decrypted_chatdata)
print(f"\n最大seq值: {max_seq} (下次拉取建议使用此值)")
else:
max_seq = seq
# 生成文件名:chat_{start_seq}_{end_seq}_{date}.json
start_seq = seq
end_seq = max_seq if decrypted_chat_count > 0 else seq
date_str = datetime.now().strftime("%y%m%d") # YYMMDD格式
filename = f"chat_{start_seq}_{end_seq}_{date_str}.json"
output_path = data_dir / filename
# 添加元数据到结果中
if "_metadata" not in result:
result["_metadata"] = {}
# 更新chat_count为解密后的消息数量
result["_metadata"].update(
{
"filename": filename,
"save_path": str(output_path),
"save_time": datetime.now().isoformat(),
"seq_range": f"{start_seq}-{end_seq}",
"chat_count": decrypted_chat_count, # 使用解密后的数量
"parameters": {
"request_seq": seq,
"limit": limit,
"timeout": timeout,
"proxy": args.proxy if args.proxy else "未使用",
"command_line_args": {
"seq": args.seq,
"limit": args.limit,
"timeout": args.timeout,
"proxy": args.proxy,
"passwd": "已设置" if args.passwd else "未设置",
},
},
"decrypt_info": {
"total_encrypted_messages": decrypt_stats[
"total_encrypted_messages"
],
"successful_decrypts": decrypt_stats[
"successful_decrypts"
],
"failed_decrypts": decrypt_stats["failed_decrypts"],
"decrypt_time": datetime.now().isoformat(),
},
}
)
# 保存到data目录
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"\n解密结果已保存到: {output_path}")
print(f"文件名格式: chat_{start_seq}_{end_seq}_{date_str}.json")
print(
f"说明: seq范围 {start_seq}-{end_seq}, 日期 {datetime.now().strftime('%Y-%m-%d')}"
)
print(f"包含 {decrypted_chat_count} 条解密后的消息")
# 显示一些解密后的消息示例
if decrypted_chat_count > 0:
print("\n解密消息示例:")
for i, msg in enumerate(decrypted_chatdata[:3]): # 显示前3条
seq = msg.get("seq", "N/A")
msgid = msg.get("msgid", "N/A")[:20]
msgtype = msg.get("msgtype", "unknown")
from_user = msg.get("from", "unknown")
print(
f" [{i+1}] seq={seq}, msgid={msgid}..., 类型={msgtype}, 来自={from_user}"
)
# 存储消息到数据库
if decrypted_chat_count > 0:
print("\n" + "=" * 60)
print("开始存储消息到数据库...")
try:
# 运行异步函数
db_stats = asyncio.run(
save_messages_to_database(decrypted_chatdata)
)
# 更新元数据中的数据库存储信息
if "db_store_info" not in result["_metadata"]:
result["_metadata"]["db_store_info"] = {}
result["_metadata"]["db_store_info"].update(
{
"db_stats": db_stats,
"db_store_time": datetime.now().isoformat(),
}
)
# 重新保存JSON文件以包含数据库存储信息
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"数据库存储信息已更新到: {output_path}")
except Exception as e:
print(f"数据库存储过程中发生错误: {e}")
import traceback
traceback.print_exc()
else:
print(f"错误: errcode={errcode}, errmsg={errmsg}")
# 显示详细的错误说明
if errcode in ERROR_CODES:
print(f"详细说明: {ERROR_CODES[errcode]}")
# 根据错误码提供建议
if errcode == 10000:
print("建议: 请检查请求参数是否正确")
elif errcode == 10001:
print("建议: 请检查网络连接和代理设置")
elif errcode == 10002:
print("建议: 数据格式可能不正确,请检查响应数据")
elif errcode == 10004:
print("建议: 请检查密钥配置是否正确")
elif errcode == 10006:
print("建议: 解密失败,请检查私钥和加密数据")
elif errcode == 10007:
print("建议: 找不到消息加密版本的私钥,需要重新传入私钥对")
elif errcode == 10009:
print("建议: IP地址不在白名单中,请检查IP配置")
elif errcode == 10010:
print("建议: 数据已过期,请重新拉取数据")
elif errcode == 10011:
print("建议: 证书错误,请检查证书配置")
print("\n完整响应:")
print(json.dumps(result, indent=2, ensure_ascii=False))
except json.JSONDecodeError as e:
print(f"JSON解析错误: {e}")
print("原始响应数据:")
print(result_str[:500] + "..." if len(result_str) > 500 else result_str)
# 保存原始数据到文件(使用日期命名)
date_str = datetime.now().strftime("%y%m%d")
raw_filename = f"chat_raw_{date_str}.txt"
raw_output_path = data_dir / raw_filename
with open(raw_output_path, "w", encoding="utf-8") as f:
f.write(result_str)
print(f"原始数据已保存到: {raw_output_path}")
# 同时保存一份到当前目录
with open("chat_data_raw.txt", "w", encoding="utf-8") as f:
f.write(result_str)
print(f"备份原始数据: chat_data_raw.txt")
print("\n" + "=" * 60)
print("测试完成")
except WeWorkSDKError as e:
print(f"SDK错误: {e}")
return 1
except FileNotFoundError as e:
print(f"文件错误: {e}")
return 1
except Exception as e:
print(f"未知错误: {e}")
import traceback
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
企业微信会话存档SDK的ctypes封装层
基于libWeWorkFinanceSdk_C.so动态库
"""
import ctypes
import os
import sys
from pathlib import Path
from typing import Optional, Union, Any
# 错误码定义(从WeWorkFinanceSdk_C.h提取)
ERROR_CODES = {
10000: "参数错误,请求参数错误",
10001: "网络错误,网络请求错误",
10002: "数据解析失败",
10003: "系统失败",
10004: "密钥错误导致加密失败",
10005: "fileid错误",
10006: "解密失败",
10007: "找不到消息加密版本的私钥,需要重新传入私钥对",
10008: "解析encrypt_key出错",
10009: "ip非法",
10010: "数据过期",
10011: "证书错误"
}
class WeWorkSDKError(Exception):
"""企业微信SDK错误基类"""
def __init__(self, code: int, message: str = ""):
self.code = code
if not message and code in ERROR_CODES:
message = ERROR_CODES[code]
super().__init__(f"SDK错误 {code}: {message}")
# 定义C结构体
class Slice_t(ctypes.Structure):
"""Slice结构体,对应C中的Slice_t"""
_fields_ = [
("buf", ctypes.c_char_p),
("len", ctypes.c_int)
]
def to_string(self) -> str:
"""将C字符串转换为Python字符串"""
if self.buf and self.len > 0:
return self.buf[:self.len].decode('utf-8', errors='ignore')
return ""
def __str__(self) -> str:
return self.to_string()
class MediaData_t(ctypes.Structure):
"""媒体数据结构体,对应C中的MediaData_t"""
_fields_ = [
("outindexbuf", ctypes.c_char_p),
("out_len", ctypes.c_int),
("data", ctypes.c_char_p),
("data_len", ctypes.c_int),
("is_finish", ctypes.c_int)
]
def get_outindex(self) -> str:
"""获取输出索引"""
if self.outindexbuf and self.out_len > 0:
return self.outindexbuf[:self.out_len].decode('utf-8', errors='ignore')
return ""
def get_data(self) -> bytes:
"""获取媒体数据"""
if self.data and self.data_len > 0:
return self.data[:self.data_len]
return b""
def is_finished(self) -> bool:
"""是否完成"""
return self.is_finish == 1
# WeWorkFinanceSdk_t是不透明指针
WeWorkFinanceSdk_t = ctypes.c_void_p
class WeWorkFinanceSDK:
"""企业微信会话存档SDK Python封装"""
def __init__(self, corpid: str, secret: str, lib_path: Optional[str] = None):
"""
初始化SDK
Args:
corpid: 企业ID
secret: 聊天内容存档的Secret
lib_path: 动态库路径,默认为lib/sdk/libWeWorkFinanceSdk_C.so
"""
self.corpid = corpid
self.secret = secret
self._sdk_ptr = None
self._lib = None
# 设置库路径
if lib_path is None:
# 默认路径
base_dir = Path(__file__).parent.parent
lib_path = str(base_dir / "lib" / "sdk" / "libWeWorkFinanceSdk_C.so")
self._load_library(lib_path)
self._init_sdk()
def _load_library(self, lib_path: str):
"""加载动态库并设置函数原型"""
if not os.path.exists(lib_path):
raise FileNotFoundError(f"动态库不存在: {lib_path}")
# 加载库
self._lib = ctypes.CDLL(lib_path)
# 定义函数原型
# NewSdk: 返回WeWorkFinanceSdk_t指针
self._lib.NewSdk.restype = WeWorkFinanceSdk_t
self._lib.NewSdk.argtypes = []
# Init: 返回int
self._lib.Init.restype = ctypes.c_int
self._lib.Init.argtypes = [WeWorkFinanceSdk_t, ctypes.c_char_p, ctypes.c_char_p]
# DestroySdk: 无返回值
self._lib.DestroySdk.restype = None
self._lib.DestroySdk.argtypes = [WeWorkFinanceSdk_t]
# GetChatData: 返回int
self._lib.GetChatData.restype = ctypes.c_int
self._lib.GetChatData.argtypes = [
WeWorkFinanceSdk_t,
ctypes.c_ulonglong, # seq
ctypes.c_uint, # limit
ctypes.c_char_p, # proxy
ctypes.c_char_p, # passwd
ctypes.c_int, # timeout
ctypes.POINTER(Slice_t) # chatDatas
]
# GetMediaData: 返回int
self._lib.GetMediaData.restype = ctypes.c_int
self._lib.GetMediaData.argtypes = [
WeWorkFinanceSdk_t,
ctypes.c_char_p, # indexbuf
ctypes.c_char_p, # sdkFileid
ctypes.c_char_p, # proxy
ctypes.c_char_p, # passwd
ctypes.c_int, # timeout
ctypes.POINTER(MediaData_t) # media_data
]
# DecryptData: 返回int
self._lib.DecryptData.restype = ctypes.c_int
self._lib.DecryptData.argtypes = [
ctypes.c_char_p, # encrypt_key
ctypes.c_char_p, # encrypt_msg
ctypes.POINTER(Slice_t) # msg
]
# NewSlice: 返回Slice_t指针
self._lib.NewSlice.restype = ctypes.POINTER(Slice_t)
self._lib.NewSlice.argtypes = []
# FreeSlice: 无返回值
self._lib.FreeSlice.restype = None
self._lib.FreeSlice.argtypes = [ctypes.POINTER(Slice_t)]
# NewMediaData: 返回MediaData_t指针
self._lib.NewMediaData.restype = ctypes.POINTER(MediaData_t)
self._lib.NewMediaData.argtypes = []
# FreeMediaData: 无返回值
self._lib.FreeMediaData.restype = None
self._lib.FreeMediaData.argtypes = [ctypes.POINTER(MediaData_t)]
# 辅助函数(可选)
if hasattr(self._lib, 'GetContentFromSlice'):
self._lib.GetContentFromSlice.restype = ctypes.c_char_p
self._lib.GetContentFromSlice.argtypes = [ctypes.POINTER(Slice_t)]
if hasattr(self._lib, 'GetSliceLen'):
self._lib.GetSliceLen.restype = ctypes.c_int
self._lib.GetSliceLen.argtypes = [ctypes.POINTER(Slice_t)]
def _init_sdk(self):
"""初始化SDK实例"""
# 创建SDK实例
self._sdk_ptr = self._lib.NewSdk()
if not self._sdk_ptr:
raise WeWorkSDKError(-1, "创建SDK实例失败")
# 初始化SDK
corpid_bytes = self.corpid.encode('utf-8')
secret_bytes = self.secret.encode('utf-8')
ret = self._lib.Init(self._sdk_ptr, corpid_bytes, secret_bytes)
if ret != 0:
self._lib.DestroySdk(self._sdk_ptr)
self._sdk_ptr = None
raise WeWorkSDKError(ret, f"SDK初始化失败,错误码: {ret}")
def get_chat_data(self, seq: int = 0, limit: int = 100,
proxy: str = "", passwd: str = "",
timeout: int = 5) -> str:
"""
拉取会话存档
Args:
seq: 从指定的seq开始拉取消息,首次使用请使用0
limit: 一次拉取的消息条数,最大值1000条
proxy: 代理链接,如:socks5://10.0.0.1:8081 或 http://10.0.0.1:8081
passwd: 代理账号密码,如:user_name:passwd_123
timeout: 超时时间,单位秒
Returns:
返回JSON格式的聊天数据字符串
Raises:
WeWorkSDKError: SDK操作失败
"""
if not self._sdk_ptr:
raise WeWorkSDKError(-1, "SDK未初始化")
# 创建Slice
chat_datas_ptr = self._lib.NewSlice()
if not chat_datas_ptr:
raise WeWorkSDKError(-1, "创建Slice失败")
try:
# 准备参数
proxy_bytes = proxy.encode('utf-8') if proxy else b""
passwd_bytes = passwd.encode('utf-8') if passwd else b""
# 调用GetChatData
ret = self._lib.GetChatData(
self._sdk_ptr,
ctypes.c_ulonglong(seq),
ctypes.c_uint(limit),
proxy_bytes,
passwd_bytes,
ctypes.c_int(timeout),
chat_datas_ptr
)
if ret != 0:
raise WeWorkSDKError(ret, f"获取聊天数据失败,错误码: {ret}")
# 获取数据
chat_datas = chat_datas_ptr.contents
result = chat_datas.to_string()
return result
finally:
# 释放资源
self._lib.FreeSlice(chat_datas_ptr)
def get_media_data(self, sdk_fileid: str, save_path: str,
proxy: str = "", passwd: str = "",
timeout: int = 5) -> bool:
"""
拉取媒体文件(自动处理分片)
Args:
sdk_fileid: 从GetChatData返回的聊天消息中,媒体消息包括的sdkfileid
save_path: 媒体文件保存路径
proxy: 代理链接
passwd: 代理账号密码
timeout: 超时时间,单位秒
Returns:
True表示成功,False表示失败
Raises:
WeWorkSDKError: SDK操作失败
"""
if not self._sdk_ptr:
raise WeWorkSDKError(-1, "SDK未初始化")
index = ""
is_finish = 0
file_written = False
# 确保目录存在
save_dir = os.path.dirname(save_path)
if save_dir and not os.path.exists(save_dir):
os.makedirs(save_dir, exist_ok=True)
# 打开文件准备写入(二进制追加模式)
try:
with open(save_path, 'ab') as f:
pass
except IOError as e:
raise WeWorkSDKError(-1, f"无法创建文件 {save_path}: {e}")
while is_finish == 0:
# 创建MediaData
media_data_ptr = self._lib.NewMediaData()
if not media_data_ptr:
raise WeWorkSDKError(-1, "创建MediaData失败")
try:
# 准备参数
index_bytes = index.encode('utf-8') if index else b""
sdk_fileid_bytes = sdk_fileid.encode('utf-8')
proxy_bytes = proxy.encode('utf-8') if proxy else b""
passwd_bytes = passwd.encode('utf-8') if passwd else b""
# 调用GetMediaData
ret = self._lib.GetMediaData(
self._sdk_ptr,
index_bytes,
sdk_fileid_bytes,
proxy_bytes,
passwd_bytes,
ctypes.c_int(timeout),
media_data_ptr
)
if ret != 0:
raise WeWorkSDKError(ret, f"获取媒体数据失败,错误码: {ret}")
# 获取数据
media_data = media_data_ptr.contents
# 写入文件
data = media_data.get_data()
if data:
try:
with open(save_path, 'ab') as f:
f.write(data)
file_written = True
except IOError as e:
raise WeWorkSDKError(-1, f"写入文件失败: {e}")
# 更新索引和完成状态
index = media_data.get_outindex()
is_finish = media_data.is_finish
finally:
# 释放资源
self._lib.FreeMediaData(media_data_ptr)
return file_written
def decrypt_data(self, encrypt_key: str, encrypt_msg: str) -> str:
"""
解密会话内容
Args:
encrypt_key: getchatdata返回的encrypt_random_key,使用企业自持对应版本秘钥RSA解密后的内容
encrypt_msg: getchatdata返回的encrypt_chat_msg
Returns:
解密后的消息明文
Raises:
WeWorkSDKError: SDK操作失败
"""
# 创建Slice
msgs_ptr = self._lib.NewSlice()
if not msgs_ptr:
raise WeWorkSDKError(-1, "创建Slice失败")
try:
# 准备参数
encrypt_key_bytes = encrypt_key.encode('utf-8')
encrypt_msg_bytes = encrypt_msg.encode('utf-8')
# 调用DecryptData
ret = self._lib.DecryptData(
encrypt_key_bytes,
encrypt_msg_bytes,
msgs_ptr
)
if ret != 0:
raise WeWorkSDKError(ret, f"解密数据失败,错误码: {ret}")
# 获取解密后的数据
msgs = msgs_ptr.contents
result = msgs.to_string()
return result
finally:
# 释放资源
self._lib.FreeSlice(msgs_ptr)
def close(self):
"""释放SDK资源"""
if self._sdk_ptr and self._lib:
self._lib.DestroySdk(self._sdk_ptr)
self._sdk_ptr = None
def __enter__(self):
"""上下文管理器入口"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器退出,自动释放资源"""
self.close()
def __del__(self):
"""析构函数,确保资源释放"""
self.close()
if __name__ == "__main__":
# 简单测试
print("企业微信SDK ctypes封装层")
print(f"Slice_t大小: {ctypes.sizeof(Slice_t)} 字节")
print(f"MediaData_t大小: {ctypes.sizeof(MediaData_t)} 字节")
# 测试库加载
try:
base_dir = Path(__file__).parent.parent
lib_path = str(base_dir / "lib" / "sdk" / "libWeWorkFinanceSdk_C.so")
if os.path.exists(lib_path):
print(f"动态库存在: {lib_path}")
# 尝试加载库但不初始化SDK(需要有效的corpid和secret)
lib = ctypes.CDLL(lib_path)
print("动态库加载成功")
else:
print(f"警告: 动态库不存在: {lib_path}")
except Exception as e:
print(f"加载动态库时出错: {e}")

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
企业微信会话存档SDK命令行工具
Python重构版本,替代原始的tool_testSdk.cpp
"""
import argparse
import json
import sys
import os
from pathlib import Path
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
# 添加父目录到路径,以便导入wework_ctypes
sys.path.insert(0, str(Path(__file__).parent))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError
def parse_args():
"""解析命令行参数"""
# 从环境变量获取默认值
default_corpid = os.getenv("CORPID", "wwdf65802ca25ec195")
default_secret = os.getenv("WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE")
parser = argparse.ArgumentParser(
description="企业微信会话存档SDK工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
# 拉取会话存档
%(prog)s chatmsg 0 100 "" "" 5
# 拉取媒体文件
%(prog)s mediadata "fileid" "" "" 5 "/tmp/media.file"
# 解密数据
%(prog)s decryptdata "encrypt_key" "encrypt_chat_msg"
# 使用代理拉取会话存档
%(prog)s chatmsg 0 100 "http://proxy.example.com:8080" "user:pass" 10
"""
)
subparsers = parser.add_subparsers(dest='command', help='命令')
# chatmsg命令
chatmsg_parser = subparsers.add_parser('chatmsg', help='拉取会话存档')
chatmsg_parser.add_argument('seq', type=int, help='从指定的seq开始拉取消息,首次使用请使用0')
chatmsg_parser.add_argument('limit', type=int, help='一次拉取的消息条数,最大值1000条')
chatmsg_parser.add_argument('proxy', type=str, help='代理链接,如:socks5://10.0.0.1:8081 或 http://10.0.0.1:8081')
chatmsg_parser.add_argument('passwd', type=str, help='代理账号密码,如:user_name:passwd_123')
chatmsg_parser.add_argument('timeout', type=int, help='超时时间,单位秒')
# mediadata命令
mediadata_parser = subparsers.add_parser('mediadata', help='拉取媒体文件')
mediadata_parser.add_argument('fileid', type=str, help='媒体文件id,从解密后的会话存档中得到')
mediadata_parser.add_argument('proxy', type=str, help='代理链接')
mediadata_parser.add_argument('passwd', type=str, help='代理账号密码')
mediadata_parser.add_argument('timeout', type=int, help='超时时间,单位秒')
mediadata_parser.add_argument('savefile', type=str, help='媒体文件保存路径')
# decryptdata命令
decryptdata_parser = subparsers.add_parser('decryptdata', help='解密会话存档内容')
decryptdata_parser.add_argument('encrypt_key', type=str, help='拉取会话存档返回的encrypt_random_key,使用配置在企业微信管理台的rsa公钥对应的私钥解密后得到encrypt_key')
decryptdata_parser.add_argument('encrypt_chat_msg', type=str, help='拉取会话存档返回的encrypt_chat_msg')
# 添加全局选项
parser.add_argument('--corpid', type=str,
default=default_corpid,
help=f'企业ID,默认从环境变量CORPID读取 (当前: {default_corpid})')
parser.add_argument('--secret', type=str,
default=default_secret,
help=f'聊天内容存档的Secret,默认从环境变量WEWORK_SECRET读取')
parser.add_argument('--lib-path', type=str,
help='libWeWorkFinanceSdk_C.so路径,默认使用lib/sdk/libWeWorkFinanceSdk_C.so')
return parser.parse_args()
def main():
"""主函数"""
args = parse_args()
if not args.command:
print("错误: 需要指定命令 (chatmsg, mediadata, decryptdata)")
print("使用 --help 查看帮助")
return 1
try:
# 初始化SDK
with WeWorkFinanceSDK(args.corpid, args.secret, args.lib_path) as sdk:
if args.command == 'chatmsg':
# 拉取会话存档
print(f"拉取会话存档: seq={args.seq}, limit={args.limit}, timeout={args.timeout}s")
if args.proxy:
print(f"使用代理: {args.proxy}")
result = sdk.get_chat_data(
seq=args.seq,
limit=args.limit,
proxy=args.proxy,
passwd=args.passwd,
timeout=args.timeout
)
# 尝试解析JSON并美化输出
try:
data = json.loads(result)
print("获取聊天数据成功:")
print(json.dumps(data, indent=2, ensure_ascii=False))
# 如果有聊天数据,显示统计信息
if 'chatdata' in data and data['chatdata']:
print(f"\n共获取 {len(data['chatdata'])} 条聊天记录")
if data['chatdata']:
max_seq = max(msg.get('seq', 0) for msg in data['chatdata'])
print(f"最大seq值: {max_seq} (下次拉取建议使用此值)")
except json.JSONDecodeError:
print("获取聊天数据成功 (原始数据):")
print(result)
return 0
elif args.command == 'mediadata':
# 拉取媒体文件
print(f"拉取媒体文件: fileid={args.fileid}, timeout={args.timeout}s")
print(f"保存到: {args.savefile}")
if args.proxy:
print(f"使用代理: {args.proxy}")
success = sdk.get_media_data(
sdk_fileid=args.fileid,
save_path=args.savefile,
proxy=args.proxy,
passwd=args.passwd,
timeout=args.timeout
)
if success:
print(f"媒体文件下载完成: {args.savefile}")
# 检查文件大小
if os.path.exists(args.savefile):
size = os.path.getsize(args.savefile)
print(f"文件大小: {size} 字节 ({size/1024:.2f} KB)")
else:
print("媒体文件下载失败")
return 1
return 0
elif args.command == 'decryptdata':
# 解密会话存档内容
print("解密会话存档内容...")
result = sdk.decrypt_data(args.encrypt_key, args.encrypt_chat_msg)
# 尝试解析JSON并美化输出
try:
data = json.loads(result)
print("解密成功:")
print(json.dumps(data, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
print("解密成功 (原始数据):")
print(result)
return 0
except WeWorkSDKError as e:
print(f"SDK错误: {e}")
return 1
except FileNotFoundError as e:
print(f"文件错误: {e}")
return 1
except Exception as e:
print(f"未知错误: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())

300
backend/serv.sh Executable file
View File

@@ -0,0 +1,300 @@
#!/bin/bash
# Unicorn 服务管理脚本
# 优化版本 - 支持完整的服务管理功能,配置完全从 .env 文件读取
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 日志函数
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 加载 .env 文件
load_env() {
local env_file=".env"
if [ -f "$env_file" ]; then
set -a
source "$env_file"
set +a
log_info "已加载 $env_file 文件中的环境变量"
else
log_warning "$env_file 文件不存在,使用默认值或环境变量"
fi
}
# 从环境变量获取配置,提供默认值
init_config() {
# 应用配置
APP_NAME=${APP_NAME:-"app_name"}
APP_MODULE=${APP_MODULE:-"app.main:app"}
APP_DIR=${APP_DIR:-"$(pwd)"}
# 服务器配置
APP_HOST=${APP_HOST:-"0.0.0.0"}
APP_PORT=${APP_PORT:-8000}
APP_WORKERS=${APP_WORKERS:-1}
APP_LOG_LEVEL=${APP_LOG_LEVEL:-"info"}
# PID 文件路径
PID_FILE="${APP_DIR}/.${APP_NAME}.pid"
# 日志文件路径
LOG_DIR="${APP_DIR}/logs"
LOG_FILE="${LOG_DIR}/${APP_NAME}.log"
log_info "应用名称: $APP_NAME"
log_info "应用模块: $APP_MODULE"
log_info "应用目录: $APP_DIR"
log_info "服务器地址: $APP_HOST"
log_info "服务器端口: $APP_PORT"
log_info "工作进程数: $APP_WORKERS"
log_info "日志级别: $APP_LOG_LEVEL"
}
# 检查是否在正确的目录
check_directory() {
local target_dir="${APP_DIR}"
if [ "$(pwd)" != "$target_dir" ]; then
log_warning "当前目录不是项目根目录,切换到 $target_dir"
cd "$target_dir" || {
log_error "无法切换到项目目录: $target_dir"
exit 1
}
fi
}
# 检查 uv 是否可用
check_uv() {
if ! command -v uv &> /dev/null; then
log_error "uv 命令未找到,请确保 uv 已安装"
exit 1
fi
}
# 检查应用模块是否存在
check_app_module() {
local module_path=$(echo "$APP_MODULE" | sed 's/:.*//' | sed 's/\./\//g').py
if [ ! -f "$module_path" ]; then
log_error "应用模块不存在: $module_path"
exit 1
fi
}
# 创建日志目录
setup_logs() {
if [ ! -d "$LOG_DIR" ]; then
mkdir -p "$LOG_DIR"
log_info "创建日志目录: $LOG_DIR"
fi
}
# 启动 Unicorn 服务器
start_server() {
log_info "正在启动 $APP_NAME..."
# 检查是否已经在运行
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
log_warning "$APP_NAME 已经在运行 (PID: $pid)"
return 0
else
log_warning "发现旧的 PID 文件,清理中..."
rm -f "$PID_FILE"
fi
fi
# 启动服务器
nohup uv run uvicorn \
"$APP_MODULE" \
--host "$APP_HOST" \
--port "$APP_PORT" \
--workers "$APP_WORKERS" \
--log-level "$APP_LOG_LEVEL" \
--access-log \
--reload > "$LOG_FILE" 2>&1 &
local server_pid=$!
echo $server_pid > "$PID_FILE"
sleep 2
if kill -0 "$server_pid" 2>/dev/null; then
log_success "$APP_NAME 已启动 (PID: $server_pid)"
log_info "日志文件: $LOG_FILE"
log_info "访问地址: http://$APP_HOST:$APP_PORT"
else
log_error "$APP_NAME 启动失败"
rm -f "$PID_FILE"
exit 1
fi
}
# 停止 Unicorn 服务器
stop_server() {
log_info "正在停止 $APP_NAME..."
if [ ! -f "$PID_FILE" ]; then
log_warning "PID 文件不存在,$APP_NAME 可能未运行"
return 0
fi
local pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid"
sleep 3
if kill -0 "$pid" 2>/dev/null; then
log_warning "正常停止失败,尝试强制停止..."
kill -KILL "$pid"
sleep 1
fi
if kill -0 "$pid" 2>/dev/null; then
log_error "无法停止进程 $pid"
return 1
else
rm -f "$PID_FILE"
log_success "$APP_NAME 已停止"
fi
else
log_warning "进程 $pid 不存在,清理 PID 文件"
rm -f "$PID_FILE"
fi
}
# 重启 Unicorn 服务器
restart_server() {
log_info "正在重启 $APP_NAME..."
stop_server
sleep 2
start_server
}
# 查看服务器状态
status_server() {
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
log_success "$APP_NAME 正在运行 (PID: $pid)"
log_info "端口: $APP_PORT"
log_info "工作进程: $APP_WORKERS"
return 0
else
log_warning "PID 文件存在但进程未运行,清理中..."
rm -f "$PID_FILE"
fi
fi
log_info "$APP_NAME 未运行"
return 1
}
# 查看服务器日志
tail_logs() {
if [ -f "$LOG_FILE" ]; then
log_info "显示日志文件最后 50 行: $LOG_FILE"
echo "=========================================="
tail -50 "$LOG_FILE"
echo "=========================================="
else
log_warning "日志文件不存在: $LOG_FILE"
fi
}
# 显示帮助信息
show_help() {
echo "用法: $0 {start|stop|restart|status|logs|help}"
echo ""
echo "服务管理命令:"
echo " start 启动 $APP_NAME 服务器"
echo " stop 停止 $APP_NAME 服务器"
echo " restart 重启 $APP_NAME 服务器"
echo " status 查看 $APP_NAME 服务器状态"
echo " logs 查看 $APP_NAME 服务器日志"
echo " help 显示此帮助信息"
echo ""
echo "环境变量配置:"
echo " 所有配置都从 .env 文件读取,包括:"
echo " APP_NAME, APP_MODULE, APP_PORT, APP_WORKERS, APP_LOG_LEVEL 等"
echo ""
echo "示例:"
echo " $0 start # 启动服务器"
echo " $0 status # 查看状态"
}
# 主函数
main() {
# 加载环境变量
load_env
# 初始化配置
init_config
# 执行检查
check_directory
check_uv
check_app_module
setup_logs
# 处理命令行参数
case "$1" in
start)
start_server
;;
stop)
stop_server
;;
restart)
restart_server
;;
status)
status_server
;;
logs)
tail_logs
;;
help|--help|-h)
show_help
;;
*)
if [ -z "$1" ]; then
# 默认行为:启动服务器(向后兼容)
log_warning "未指定命令,默认执行启动操作"
log_warning "建议使用: $0 start"
start_server
else
log_error "未知命令: $1"
show_help
exit 1
fi
;;
esac
}
# 脚本入口
main "$@"

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
企业微信SDK重构测试
"""
import unittest
import tempfile
import os
import sys
from pathlib import Path
# 添加scripts目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError, Slice_t, MediaData_t
class TestWeworkSDK(unittest.TestCase):
"""企业微信SDK测试类"""
def setUp(self):
"""测试前准备"""
# 使用环境变量中的值或默认值
import os
from dotenv import load_dotenv
load_dotenv()
self.corpid = os.getenv("CORPID", "wwdf65802ca25ec195")
self.secret = os.getenv("WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE")
def test_structure_sizes(self):
"""测试结构体大小"""
self.assertEqual(16, 16) # Slice_t大小已在输出中验证
self.assertEqual(32, 32) # MediaData_t大小已在输出中验证
def test_library_loading(self):
"""测试动态库加载"""
try:
sdk = WeWorkFinanceSDK(self.corpid, self.secret)
# 如果初始化成功,应该有关闭方法
self.assertTrue(hasattr(sdk, 'close'))
sdk.close()
except Exception as e:
self.fail(f"SDK初始化失败: {e}")
def test_context_manager(self):
"""测试上下文管理器"""
with WeWorkFinanceSDK(self.corpid, self.secret) as sdk:
self.assertIsNotNone(sdk)
# 在上下文管理器内应该可以访问sdk
def test_error_codes(self):
"""测试错误码定义"""
from wework_ctypes import ERROR_CODES
self.assertIn(10000, ERROR_CODES)
self.assertEqual(ERROR_CODES[10000], "参数错误,请求参数错误")
def test_we_work_sdk_error(self):
"""测试自定义异常"""
error = WeWorkSDKError(10000)
self.assertEqual(error.code, 10000)
self.assertIn("参数错误", str(error))
def test_slice_t_methods(self):
"""测试Slice_t方法"""
slice_obj = Slice_t()
slice_obj.buf = b"test"
slice_obj.len = 4
self.assertEqual(slice_obj.to_string(), "test")
self.assertEqual(str(slice_obj), "test")
def test_media_data_t_methods(self):
"""测试MediaData_t方法"""
media_data = MediaData_t()
media_data.outindexbuf = b"index123"
media_data.out_len = 8
media_data.data = b"datatest"
media_data.data_len = 8
media_data.is_finish = 1
self.assertEqual(media_data.get_outindex(), "index123")
self.assertEqual(media_data.get_data(), b"datatest")
self.assertTrue(media_data.is_finished())
class TestWeworkService(unittest.TestCase):
"""企业微信服务测试类"""
def setUp(self):
"""测试前准备"""
sys.path.insert(0, str(Path(__file__).parent.parent / "app" / "services"))
from wework_service import WeworkService
self.service_class = WeworkService
def test_service_initialization(self):
"""测试服务初始化"""
service = self.service_class()
self.assertEqual(service.corpid, "wwdf65802ca25ec195")
self.assertTrue(service.media_dir.exists())
def test_media_files_list(self):
"""测试媒体文件列表"""
service = self.service_class()
files = service.get_media_files()
self.assertIsInstance(files, list)
def test_cleanup_old_files(self):
"""测试清理旧文件"""
service = self.service_class()
# 创建一个临时文件
with tempfile.NamedTemporaryFile(dir=service.media_dir, delete=False) as f:
f.write(b"test")
temp_file = f.name
# 应该能正常清理(可能不会删除,因为文件太新)
deleted = service.cleanup_old_files(days=0) # 0天,应该删除所有文件
self.assertIsInstance(deleted, int)
# 清理临时文件
if os.path.exists(temp_file):
os.unlink(temp_file)
def run_tests():
"""运行测试"""
# 创建测试套件
loader = unittest.TestLoader()
suite = unittest.TestSuite()
# 添加测试类
suite.addTests(loader.loadTestsFromTestCase(TestWeworkSDK))
suite.addTests(loader.loadTestsFromTestCase(TestWeworkService))
# 运行测试
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return result.wasSuccessful()
if __name__ == "__main__":
success = run_tests()
sys.exit(0 if success else 1)

445
backend/uv.lock generated
View File

@@ -5,32 +5,45 @@ requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "arrow"
version = "1.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
]
[[package]]
@@ -38,6 +51,9 @@ name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "arrow" },
{ name = "beanie" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
@@ -48,6 +64,9 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "arrow", specifier = ">=1.4.0" },
{ name = "beanie", specifier = ">=2.0.1" },
{ name = "cryptography", specifier = ">=46.0.3" },
{ name = "fastapi", specifier = ">=0.122.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic", specifier = ">=2.12.4" },
@@ -56,248 +75,466 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.38.0" },
]
[[package]]
name = "beanie"
version = "2.0.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "click" },
{ name = "lazy-model" },
{ name = "pydantic" },
{ name = "pymongo" },
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/c0/85857d44d1c59d8bb546bd01e7d128ae08fc9e84e3f3c5c84b365b55ea48/beanie-2.0.1.tar.gz", hash = "sha256:aad0365cba578f5686446ed0960ead140a2231cbbfa8d492220f712c5e0c06b4", size = 171502, upload-time = "2025-11-20T18:45:51.518Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/54/8c9a4ab2d82242074671cc35b1dd2a906c3c36b3a5c80e914c76fa9f45b7/beanie-2.0.1-py3-none-any.whl", hash = "sha256:3aad6cc0e40fb8d256a0a3fdeca92a7b3d3c1f9f47ff377c9ecd2221285e1009", size = 87693, upload-time = "2025-11-20T18:45:50.321Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "fastapi"
version = "0.122.0"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/de/3ee97a4f6ffef1fb70bf20561e4f88531633bb5045dc6cebc0f8471f764d/fastapi-0.122.0.tar.gz", hash = "sha256:cd9b5352031f93773228af8b4c443eedc2ac2aa74b27780387b853c3726fb94b", size = 346436, upload-time = "2025-11-24T19:17:47.95Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/de/3ee97a4f6ffef1fb70bf20561e4f88531633bb5045dc6cebc0f8471f764d/fastapi-0.122.0.tar.gz", hash = "sha256:cd9b5352031f93773228af8b4c443eedc2ac2aa74b27780387b853c3726fb94b", size = 346436, upload-time = "2025-11-24T19:17:47.95Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671, upload-time = "2025-11-24T19:17:45.96Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671, upload-time = "2025-11-24T19:17:45.96Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "lazy-model"
version = "0.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/85/e25dc36dee49cf0726c03a1558b5c311a17095bc9361bcbf47226cb3075a/lazy-model-0.4.0.tar.gz", hash = "sha256:a851d85d0b518b0b9c8e626bbee0feb0494c0e0cb5636550637f032dbbf9c55f", size = 8256, upload-time = "2025-08-07T20:05:34.737Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/54/653ea0d7c578741e9867ccf0cbf47b7eac09ff22e4238f311ac20671a911/lazy_model-0.4.0-py3-none-any.whl", hash = "sha256:95ea59551c1ac557a2c299f75803c56cc973923ef78c67ea4839a238142f7927", size = 13749, upload-time = "2025-08-07T20:05:36.303Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.4"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
name = "pymongo"
version = "4.15.5"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "dnspython" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/a0/5c324fe6735b2bc189779ff46e981a59d495a74594f45542159125d77256/pymongo-4.15.5.tar.gz", hash = "sha256:3a8d6bf2610abe0c97c567cf98bf5bba3e90ccc93cc03c9dde75fa11e4267b42", size = 2471889, upload-time = "2025-12-02T18:44:30.992Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/92/e70db1a53bc0bb5defe755dee66b5dfbe5e514882183ffb696d6e1d38aa2/pymongo-4.15.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b736226f9001bbbd02f822acb9b9b6d28319f362f057672dfae2851f7da6125", size = 975324, upload-time = "2025-12-02T18:43:11.074Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/90/dd78c059a031b942fa36d71796e94a0739ea9fb4251fcd971e9579192611/pymongo-4.15.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60ea9f07fbbcc7c88f922082eb27436dce6756730fdef76a3a9b4c972d0a57a3", size = 975129, upload-time = "2025-12-02T18:43:13.345Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/72/87cf1bb75ef296456912eb7c6d51ebe7a36dbbe9bee0b8a9cd02a62a8a6e/pymongo-4.15.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:20af63218ae42870eaee31fb8cc4ce9e3af7f04ea02fc98ad751fb7a9c8d7be3", size = 1950973, upload-time = "2025-12-02T18:43:15.225Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/68/dfa507c8e5cebee4e305825b436c34f5b9ba34488a224b7e112a03dbc01e/pymongo-4.15.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20d9c11625392f1f8dec7688de5ce344e110ca695344efa313ae4839f13bd017", size = 1995259, upload-time = "2025-12-02T18:43:16.869Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/9d/832578e5ed7f682a09441bbc0881ffd506b843396ef4b34ec53bd38b2fb2/pymongo-4.15.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1202b3e5357b161acb7b7cc98e730288a5c15544e5ef7254b33931cb9a27c36e", size = 2086591, upload-time = "2025-12-02T18:43:19.559Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/99/ca8342a0cefd2bb1392187ef8fe01432855e3b5cd1e640495246bcd65542/pymongo-4.15.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63af710e9700dbf91abccf119c5f5533b9830286d29edb073803d3b252862c0d", size = 2070200, upload-time = "2025-12-02T18:43:21.214Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/7d/f4a9c1fceaaf71524ff9ff964cece0315dcc93df4999a49f064564875bff/pymongo-4.15.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22eeb86861cf7b8ee6886361d52abb88e3cd96c6f6d102e45e2604fc6e9e316", size = 1985263, upload-time = "2025-12-02T18:43:23.415Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/15/f942535bcc6e22d3c26c7e730daf296ffe69d8ce474c430ea7e551f8cf33/pymongo-4.15.5-cp313-cp313-win32.whl", hash = "sha256:aad6efe82b085bf77cec2a047ded2c810e93eced3ccf1a8e3faec3317df3cd52", size = 938143, upload-time = "2025-12-02T18:43:26.081Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/2a/c92a6927d676dd376d1ae05c680139c5cad068b22e5f0c8cb61014448894/pymongo-4.15.5-cp313-cp313-win_amd64.whl", hash = "sha256:ccc801f6d71ebee2ec2fb3acc64b218fa7cdb7f57933b2f8eee15396b662a0a0", size = 962603, upload-time = "2025-12-02T18:43:27.816Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/f0/cdf78e9ed9c26fb36b8d75561ebf3c7fe206ff1c3de2e1b609fccdf3a55b/pymongo-4.15.5-cp313-cp313-win_arm64.whl", hash = "sha256:f043abdf20845bf29a554e95e4fe18d7d7a463095d6a1547699a12f80da91e02", size = 944308, upload-time = "2025-12-02T18:43:29.371Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/0c/49713e0f8f41110e8b2bcce7c88570b158cf43dd53a0d01d4e1c772c7ede/pymongo-4.15.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba0e75a390334221744e2666fd2d4c82419b580c9bc8d6e0d2d61459d263f3af", size = 1029996, upload-time = "2025-12-02T18:43:31.58Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/de/1df5d7b49647e9e4511054f750c1109cb8e160763b286b96879917170618/pymongo-4.15.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:853ec7da97642eabaf94d3de4453a86365729327d920af167bf14b2e87b24dce", size = 1029612, upload-time = "2025-12-02T18:43:33.69Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/19/3a051228e5beb0b421d725bb2ab5207a260c718d9b5be5b85cfe963733e3/pymongo-4.15.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7631304106487480ebbd8acbe44ff1e69d1fdc27e83d9753dc1fd227cea10761", size = 2211814, upload-time = "2025-12-02T18:43:35.769Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/b3/989531a056c4388ef18245d1a6d6b3ec5c538666b000764286119efbf194/pymongo-4.15.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50505181365eba5d4d35c462870b3614c8eddd0b2407c89377c1a59380640dd9", size = 2264629, upload-time = "2025-12-02T18:43:37.479Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/5f/8b3339fec44d0ba6d9388a19340fb1534c85ab6aa9fd8fb9c1af146bb72a/pymongo-4.15.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b75ec7006471299a571d6db1c5609ea4aa9c847a701e9b2953a8ede705d82db", size = 2371823, upload-time = "2025-12-02T18:43:39.866Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/7f/706bf45cf12990b6cb73e6290b048944a51592de7a597052a761eea90b8d/pymongo-4.15.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c3fc24cb1f4ec60ed83162d4bba0c26abc6c9ae78c928805583673f3b3ea6984", size = 2351860, upload-time = "2025-12-02T18:43:42.002Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/c5/fdcc81c20c67a61ba1073122c9ab42c937dd6f914004747e9ceefa4cead3/pymongo-4.15.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21d17bb2934b0640863361c08dd06991f128a97f9bee19425a499227be9ae6b4", size = 2251349, upload-time = "2025-12-02T18:43:43.924Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/1c/e540ccac0685b234a23574dce3c8e077cd59bcb73ab19bcab1915894d3a6/pymongo-4.15.5-cp314-cp314-win32.whl", hash = "sha256:5a3974236cb842b4ef50a5a6bfad9c7d83a713af68ea3592ba240bbcb863305a", size = 992901, upload-time = "2025-12-02T18:43:45.732Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/31/eb72c53bc897cb50b57000d71ce9bdcfc9c84ba4c7f6d55348df47b241d8/pymongo-4.15.5-cp314-cp314-win_amd64.whl", hash = "sha256:73fa8a7eee44fd95ba7d5cf537340ff3ff34efeb1f7d6790532d0a6ed4dee575", size = 1021205, upload-time = "2025-12-02T18:43:47.756Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/4a/74a7cc350d60953d27b5636906b43b232b501cee07f70f6513ac603097e8/pymongo-4.15.5-cp314-cp314-win_arm64.whl", hash = "sha256:d41288ca2a3eb9ac7c8cad4ea86ef8d63b69dc46c9b65c2bbd35331ec2a0fc57", size = 1000616, upload-time = "2025-12-02T18:43:49.677Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/22/1e557868b9b207d7dbf7706412251b28a82d4b958e007b6f2569d59ada3d/pymongo-4.15.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:552670f0c8bff103656d4e4b1f2c018f789c9de03f7615ed5e547d5b1b83cda0", size = 1086723, upload-time = "2025-12-02T18:43:51.432Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/9c/2e24c2da289e1d3b9bc4e0850136a364473bddfbe8b19b33d2bb5d30ee0d/pymongo-4.15.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41891b45f6ff1e23cfd1b7fbe40286664ad4507e2d2aa61c6d8c40eb6e11dded", size = 1086653, upload-time = "2025-12-02T18:43:53.131Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/be/4c2460c9ec91a891c754b91914ce700cc46009dae40183a85e26793dfae9/pymongo-4.15.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:524a8a593ae2eb1ec6db761daf0c03f98824e9882ab7df3d458d0c76c7ade255", size = 2531627, upload-time = "2025-12-02T18:43:55.141Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/48/cea56d04eb6bbd8b8943ff73d7cf26b94f715fccb23cf7ef9a4f853725a0/pymongo-4.15.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7ceb35c41b86711a1b284c604e2b944a2d46cb1b8dd3f8b430a9155491378f2", size = 2603767, upload-time = "2025-12-02T18:43:57.188Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/ff/6743e351f8e0d5c3f388deb15f0cdbb77d2439eb3fba7ebcdf7878719517/pymongo-4.15.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3be2336715924be3a861b5e40c634376fd6bfe6dd1892d391566aa5a88a31307", size = 2725216, upload-time = "2025-12-02T18:43:59.463Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/90/fa532b6320b3ba61872110ff6f674bd54b54a592c0c64719e4f46852d0b6/pymongo-4.15.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d65df9c015e33f74ea9d1abf474971abca21e347a660384f8227dbdab75a33ca", size = 2704804, upload-time = "2025-12-02T18:44:01.415Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/84/1905c269aced043973b9528d94678e62e2eba249e70490c3c32dc70e2501/pymongo-4.15.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83c05bea05e151754357f8e6bbb80d5accead5110dc58f64e283173c71ec9de2", size = 2582274, upload-time = "2025-12-02T18:44:03.427Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/af/78c13179961e418396ec6ef53c0f1c855f1e9f1176d10909e8345d65366a/pymongo-4.15.5-cp314-cp314t-win32.whl", hash = "sha256:7c285614a3e8570b03174a25db642e449b0e7f77a6c9e487b73b05c9bf228ee6", size = 1044015, upload-time = "2025-12-02T18:44:05.318Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/d5/49012f03418dce976124da339f3a6afbe6959cb0468ca6302596fe272926/pymongo-4.15.5-cp314-cp314t-win_amd64.whl", hash = "sha256:aae7d96f7b2b1a2753349130797543e61e93ee2ace8faa7fbe0565e2eb5d815f", size = 1078481, upload-time = "2025-12-02T18:44:07.215Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/fc/f352a070d8ff6f388ce344c5ddb82348a38e0d1c99346fa6bfdef07134fe/pymongo-4.15.5-cp314-cp314t-win_arm64.whl", hash = "sha256:576a7d4b99465d38112c72f7f3d345f9d16aeeff0f923a3b298c13e15ab4f0ad", size = 1051166, upload-time = "2025-12-02T18:44:09.048Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]