添加用户消息记录接口;

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

2
.gitignore vendored
View File

@@ -37,3 +37,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
data

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" },
]

View File

@@ -1,2 +1,9 @@
# API 基础地址
VITE_API_BASE_URL=http://chat.nexjs.com/api
VITE_API_BASE_URL=http://mp.yizhezhe.com/api
# 企业微信配置
VITE_WECOM_CORP_ID=your_corp_id_here
VITE_WECOM_AGENT_ID=your_agent_id_here
# 用户标签属性接口基础地址
VITE_CUSTOMER_API_BASE=https://admin.doouba.cn/api

View File

@@ -1,10 +1,14 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#4f46e5" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>兜爸心选 - 企业微信智能客服</title>
<meta name="description" content="兜爸心选企业微信智能客服系统,集成企业微信JSSDK,提供智能客服和消息发送功能" />
</head>
<body>
<div id="root"></div>

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@wecom/jssdk": "^2.3.3",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"daisyui": "^5.5.5",

View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@wecom/jssdk':
specifier: ^2.3.3
version: 2.3.3
autoprefixer:
specifier: ^10.4.22
version: 10.4.22(postcss@8.5.6)
@@ -541,6 +544,9 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@wecom/jssdk@2.3.3':
resolution: {integrity: sha512-bazYSD39MoiaUV1xMVxrOPsTFa37ARY0xXAqDgth4kU/xk8n61o1YiAcdn4dPM+kBnJKOfukFYPjOPQv8K5l+w==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1805,6 +1811,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@wecom/jssdk@2.3.3': {}
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0

View File

@@ -1,301 +1,598 @@
import { useState, useEffect, useRef } from 'react'
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://chat.nexjs.com/api'
import { useState, useEffect, useRef, useCallback } from 'react';
import { useWeCom } from './hooks/useWeCom';
import { workflowService, customerService } from './services/wecomService';
import WorkflowProgress from './components/WorkflowProgress';
import { getStepNames } from './constants/workflowSteps';
import { getSceneDescription } from './constants/wecomScenes';
function App() {
const [messages, setMessages] = useState([])
const [inputMessage, setInputMessage] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [conversationId, setConversationId] = useState(null)
const [roleConfig, setRoleConfig] = useState({
name: "兜兜",
description: "你是兜兜,1989年出生,有一个5岁的女儿(2018年出生)。你是一位对用户负责、选品严格的宝妈客服。"
})
const [isEditingRole, setIsEditingRole] = useState(false)
const messagesEndRef = useRef(null)
// 企业微信 Hook
const weCom = useWeCom();
// 聊天状态
const [messages, setMessages] = useState([]);
// 工作流状态
const [workflowStatus, setWorkflowStatus] = useState({
isRunning: false,
currentStep: null,
progress: {},
result: null,
error: null
});
// 执行计数
const [executionCount, setExecutionCount] = useState(0);
const messagesEndRef = useRef(null);
const hasAutoExecutedRef = useRef(false);
// 自动滚动到最新消息
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom()
}, [messages])
scrollToBottom();
}, [messages]);
// 获取当前角色配置
useEffect(() => {
fetchRoleConfig()
}, [])
const fetchRoleConfig = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/role`)
setRoleConfig(response.data)
} catch (error) {
console.error('获取角色配置失败:', error)
}
}
const updateRoleConfig = async (newConfig) => {
try {
const response = await axios.put(`${API_BASE_URL}/role`, newConfig)
setRoleConfig(response.data.role_config)
setIsEditingRole(false)
} catch (error) {
console.error('更新角色配置失败:', error)
}
}
const sendMessage = async () => {
if (!inputMessage.trim() || isLoading) return
const userMessage = {
role: 'user',
content: inputMessage,
// 添加系统消息
const addSystemMessage = useCallback((content) => {
const systemMessage = {
role: 'system',
content,
timestamp: new Date().toLocaleTimeString()
};
setMessages(prev => [...prev, systemMessage]);
}, []);
// 添加助手消息
const addAssistantMessage = useCallback((content) => {
const assistantMessage = {
role: 'assistant',
content,
timestamp: new Date().toLocaleTimeString()
};
setMessages(prev => [...prev, assistantMessage]);
}, []);
// 处理工作流进度更新
const handleWorkflowProgress = useCallback((stepId, status, data) => {
// 添加进度消息到聊天
const stepNames = getStepNames();
if (status === 'loading') {
if (stepId !== 'auth' && stepId !== 'register' && stepId !== 'customer') { //初始化步骤不显示加载消息
addSystemMessage(`📌 正在${stepNames[stepId]}...`);
}
} else if (status === 'success') {
addSystemMessage(`✅ ${stepNames[stepId]}成功!`);
// 如果是标签步骤成功,单独显示标签消息
if (stepId === 'tags' && data && data.tags && data.tags.length > 0) {
const tagNames = data.tags.map(tag => tag.TagName).join(', ');
addAssistantMessage(`📋 客户标签: ${tagNames}`);
}
// 如果是历史步骤成功,显示历史会话内容
if (stepId === 'history') {
// 格式化并显示历史会话
if (data && data.messages && data.messages.length > 0) {
// 获取客户ID(从之前的步骤中)
const customerId = workflowStatus.progress.customer?.data?.externalUserId;
// 格式化历史会话显示
const formattedHistory = `📜 最近对话记录(${Math.min(data.messages.length, 5)}条):\n\n` +
data.messages
.slice(0, 5) // 只显示最近5条
.map((msg, index) => {
// 确定发送者角色
const role = msg.from_user === customerId ? '客户' : '客服';
// 获取消息内容
const content = msg.text?.content || msg.content || '无文本内容';
// 简化内容,避免过长
const simplifiedContent = content.length > 80
? content.substring(0, 80) + '...'
: content;
return `• ${role}:${simplifiedContent}`;
})
.join('\n');
addAssistantMessage(formattedHistory);
} else {
addAssistantMessage('📜 暂无历史会话记录');
}
// 更新工作流状态
setWorkflowStatus(prev => ({
...prev,
currentStep: stepId,
progress: {
...prev.progress,
[stepId]: {
status,
data,
timestamp: new Date().toISOString()
}
}
}));
return; // 提前返回
}
} else if (status === 'error') {
addSystemMessage(`❌ ${stepNames[stepId]}失败: ${data?.message || '未知错误'}`);
}
setMessages(prev => [...prev, userMessage])
setInputMessage('')
setIsLoading(true)
// 使用函数式更新,避免依赖 workflowStatus.progress
setWorkflowStatus(prev => ({
...prev,
currentStep: stepId,
progress: {
...prev.progress,
[stepId]: {
status,
data,
timestamp: new Date().toISOString()
}
}
}));
}, [addSystemMessage, addAssistantMessage, workflowStatus.progress.customer?.data?.externalUserId]);
// 执行智能回复工作流(不发送消息)
const executeSmartReplyWorkflow = useCallback(async () => {
setMessages([]); // 清空消息
// 使用函数式更新检查是否正在运行
setWorkflowStatus(prev => {
if (prev.isRunning) return prev;
return {
isRunning: true,
currentStep: null,
progress: {},
result: null,
error: null
};
});
setExecutionCount(prev => prev + 1);
addSystemMessage('📌 开始执行智能回复工作流...');
try {
const response = await axios.post(`${API_BASE_URL}/chat`, {
message: inputMessage,
conversation_id: conversationId
})
// 使用不发送消息的工作流版本
const result = await workflowService.executeWorkflowWithoutSend(weCom, handleWorkflowProgress);
const assistantMessage = {
role: 'assistant',
content: response.data.response,
timestamp: new Date().toLocaleTimeString()
setWorkflowStatus(prev => ({
...prev,
isRunning: false,
result,
error: null
}));
// 显示生成的回复
if (result.generatedMessage) {
// 处理 generatedMessage 可能是对象的情况
let messageContent = result.generatedMessage;
if (typeof messageContent === 'object') {
// 如果是发送步骤返回的对象,提取 message 字段
if (messageContent.message) {
messageContent = messageContent.message;
} else if (messageContent.errMsg) {
messageContent = `发送消息失败: ${messageContent.errMsg}`;
} else {
// 转换为字符串作为后备方案
messageContent = JSON.stringify(messageContent);
}
}
addAssistantMessage(`${messageContent}`);
}
// 根据工作流结果和JSSDK状态显示不同的提示语
if (result.success && weCom.sdkReady && !weCom.sdkError) {
// 工作流成功且JSSDK正常
addSystemMessage('✅ 工作流执行完成!已生成智能回复,请点击下方按钮发送到企业微信。');
} else if (result.success && (weCom.sdkError || !weCom.sdkReady)) {
// 工作流成功但JSSDK有问题
addSystemMessage('⚠️ 工作流执行完成,但企业微信JSSDK状态异常。生成的回复可能无法发送到企业微信。');
} else {
// 工作流失败
addSystemMessage('❌ 工作流执行完成,但遇到问题无法生成个性化回复。');
}
setMessages(prev => [...prev, assistantMessage])
setConversationId(response.data.conversation_id)
} catch (error) {
console.error('发送消息失败:', error)
const errorMessage = {
role: 'assistant',
content: '抱歉,兜兜暂时无法回复,请稍后再试~ 😔',
timestamp: new Date().toLocaleTimeString()
console.error('工作流执行失败:', error);
// 检查是否是获取客户ID相关的错误
let errorMessage = error.message;
let userFriendlyMessage = error.message;
if (errorMessage.includes('getCurExternalContact') || errorMessage.includes('entry 值为') || errorMessage.includes('不支持获取客户ID')) {
userFriendlyMessage = `获取客户ID失败: 当前页面不支持获取客户信息\n\n💡 请确保在企业微信的以下页面中打开此应用:\n• 外部联系人聊天页面\n• 客户详情页面\n• 单聊工具栏页面`;
} else if (errorMessage.includes('JSSDK') || errorMessage.includes('register') || errorMessage.includes('SDK')) {
userFriendlyMessage = `企业微信JSSDK错误: ${errorMessage}\n\n请检查企业微信配置或刷新页面重试。`;
} else if (errorMessage.includes('超时')) {
userFriendlyMessage = `操作超时: ${errorMessage}\n\n可能是网络问题或企业微信JSSDK未正确响应。`;
}
setMessages(prev => [...prev, errorMessage])
setWorkflowStatus(prev => ({
...prev,
isRunning: false,
error: errorMessage
}));
addSystemMessage(`❌工作流执行失败: ${userFriendlyMessage}`);
}
}, [weCom, handleWorkflowProgress]);
// 发送智能回复到企业微信
const sendSmartReply = useCallback(async () => {
if (workflowStatus.isRunning || !weCom.sdkReady) return;
setWorkflowStatus(prev => ({
...prev,
isRunning: true,
error: null
}));
addSystemMessage('📌开始发送智能回复到企业微信...');
try {
// 这里应该调用发送消息到企业微信的API
// 暂时使用模拟发送
await new Promise(resolve => setTimeout(resolve, 1000));
// 如果有生成的回复,发送它
let replyContent = '您好!我是兜爸心选的智能客服,有什么可以帮您的吗?';
if (workflowStatus.result?.generatedMessage) {
const generatedMessage = workflowStatus.result.generatedMessage;
if (typeof generatedMessage === 'object' && generatedMessage.message) {
replyContent = generatedMessage.message;
} else if (typeof generatedMessage === 'string') {
replyContent = generatedMessage;
}
}
// 调用企业微信发送消息
const sendResult = await weCom.sendChatMessage(replyContent);
addSystemMessage('✅ 智能回复已成功发送到企业微信!');
} catch (error) {
console.error('发送智能回复失败:', error);
addSystemMessage(`❌发送智能回复失败: ${error.message}`);
setWorkflowStatus(prev => ({
...prev,
error: error.message
}));
} finally {
setIsLoading(false)
setWorkflowStatus(prev => ({
...prev,
isRunning: false
}));
}
}
}, [workflowStatus.isRunning, workflowStatus.result, weCom]);
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
// 检查企业微信配置和首次自动执行
useEffect(() => {
if (!weCom.isConfigured) {
addSystemMessage('⚠️ 企业微信配置缺失,请检查 .env 文件中的 VITE_WECOM_CORP_ID 和 VITE_WECOM_AGENT_ID');
} else if (weCom.sdkError) {
addSystemMessage(`⚠️ 企业微信 SDK 错误: ${weCom.sdkError.message}`);
} else if (weCom.sdkReady && !hasAutoExecutedRef.current) {
addSystemMessage(`✅ 企业微信 SDK 已就绪`);
// 首次自动执行工作流(仅一次)
hasAutoExecutedRef.current = true;
executeSmartReplyWorkflow();
}
}
}, [weCom.isConfigured, weCom.sdkReady, weCom.sdkError]);
const clearConversation = () => {
setMessages([])
setConversationId(null)
}
// 移动端侧边栏状态
const [sidebarOpen, setSidebarOpen] = useState(false);
// 页面场景状态
const [pageContext, setPageContext] = useState(null);
// 自动获取页面场景(不显示在聊天中)
useEffect(() => {
const fetchPageContext = async () => {
if (weCom.sdkReady) {
try {
console.log('开始自动获取页面场景...');
const context = await weCom.getContext();
console.log('获取到的页面场景:', context);
setPageContext(context);
// 不显示在聊天中,只在工作流执行时显示
} catch (error) {
console.error('自动获取页面场景失败:', error);
// 不显示错误在聊天中
}
} else {
console.log('企业微信SDK未就绪,无法获取页面场景');
}
};
fetchPageContext();
}, [weCom.sdkReady, weCom.getContext, weCom]);
return (
<div className="min-h-screen bg-gray-50">
{/* 顶部导航栏 */}
<header className="bg-blue-600 text-white shadow-lg">
<header className="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{/* 移动端菜单按钮 */}
<button
className="lg:hidden w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/20"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
<span className="text-blue-600 text-xl">🤖</span>
</div>
<div>
<h1 className="text-xl font-bold">兜爸 AI 聊天助手</h1>
<p className="text-sm opacity-90">我是{roleConfig.name},很高兴为您服务</p>
<div className="hidden sm:block">
<h1 className="text-xl font-bold">兜爸心选 - 企业微信智能客服</h1>
<p className="text-sm opacity-90">
自动获取客户消息会话与标签,生成个性化智能回复
</p>
</div>
<div className="sm:hidden">
<h1 className="text-lg font-bold">智能客服</h1>
</div>
</div>
<div className="flex items-center space-x-2">
<button
className="px-3 py-1 bg-white/20 rounded text-sm hover:bg-white/30 transition-colors"
onClick={() => setIsEditingRole(true)}
className="hidden sm:inline px-3 py-1 bg-white/20 rounded text-sm hover:bg-white/30 transition-colors"
onClick={executeSmartReplyWorkflow}
disabled={workflowStatus.isRunning || !weCom.sdkReady}
>
编辑角色
{workflowStatus.isRunning ? '执行中...' : '执行工作流'}
</button>
<button
className="px-3 py-1 bg-white/20 rounded text-sm hover:bg-white/30 transition-colors"
onClick={clearConversation}
>
新对话
</button>
{/* 移动端操作按钮 */}
<div className="sm:hidden flex space-x-1">
<button
className="w-8 h-8 flex items-center justify-center bg-white/20 rounded hover:bg-white/30 transition-colors"
onClick={executeSmartReplyWorkflow}
disabled={workflowStatus.isRunning || !weCom.sdkReady}
title="执行工作流"
>
{workflowStatus.isRunning ? '⏳' : '🔄️'}
</button>
</div>
</div>
</div>
</div>
</header>
{/* 聊天区域 */}
<main className="flex-1" style={{ height: 'calc(100vh - 80px - 120px)' }}>
<div className="h-full overflow-y-auto p-4">
{messages.length === 0 ? (
// 欢迎页面
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md">
<div className="w-20 h-20 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-3xl">👋</span>
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-3">欢迎使用, 兜爸 AI 聊天助手</h2>
<p className="text-gray-600 mb-4">
我是{roleConfig.name},一位亲切温暖的宝妈客服。有任何关于育儿、选品、团购的问题,都可以随时问我哦~
</p>
<div className="bg-white rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-500">
💡 提示:开始对话吧,我会以亲切温暖的风格为您解答问题
</p>
</div>
{/* 状态栏 */}
<div className="bg-white border-b border-gray-200">
<div className="container mx-auto px-4 py-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between text-sm space-y-2 sm:space-y-0">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${weCom.sdkReady ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-gray-700">
{weCom.sdkReady ? '企业微信 SDK 已就绪' : '企业微信 SDK 未就绪'}
</span>
</div>
<div className="hidden sm:flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${workflowStatus.isRunning ? 'bg-yellow-500' : workflowStatus.result ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<span className="text-gray-700">
{workflowStatus.isRunning ? '工作流执行中...' : workflowStatus.result ? '工作流已完成' : '工作流待执行'}
</span>
</div>
</div>
) : (
// 聊天消息列表
<div className="max-w-4xl mx-auto space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[70%] rounded-2xl px-4 py-3 ${
message.role === 'user'
? 'bg-blue-600 text-white rounded-br-none'
: 'bg-white border border-gray-200 rounded-bl-none shadow-sm'
}`}>
<div className="text-sm leading-relaxed">
{message.content}
</div>
<div className={`text-xs mt-1 ${
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
}`}>
{message.timestamp}
</div>
</div>
</div>
))}
{/* 加载状态 */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-2xl rounded-bl-none px-4 py-3 shadow-sm">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
<div className="flex items-center space-x-2">
<span className="text-gray-500">
执行次数: {executionCount}
</span>
</div>
)}
</div>
</div>
</main>
</div>
{/* 输入区域 */}
<footer className="border-t border-gray-200 bg-white p-4">
<div className="max-w-4xl mx-auto">
<div className="flex items-stretch border border-gray-300 rounded-lg bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-200 transition-colors overflow-hidden">
<div className="flex-1">
<textarea
className="w-full h-full px-4 py-3 resize-none border-none focus:outline-none focus:ring-0 bg-transparent"
placeholder={`向${roleConfig.name}发送消息... (按 Enter 发送)`}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
rows={2}
disabled={isLoading}
/>
{/* 移动端侧边栏遮罩 */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 主内容区域 */}
<div className="flex h-[calc(100vh-240px)]">
{/* 聊天区域 */}
<div className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto p-2 sm:p-4">
{messages.length === 0 ? (
// 欢迎页面
<div className="flex items-center justify-center h-full px-2">
<div className="text-center max-w-md">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-2xl sm:text-3xl">👋</span>
</div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-3">欢迎使用兜爸心选智能客服</h2>
<p className="text-sm sm:text-base text-gray-600 mb-4">
本系统集成了企业微信智能回复系统,可以自动获取客户历史会话、标签属性,并生成个性化的智能回复。
</p>
<div className="bg-white rounded-lg p-3 sm:p-4 border border-gray-200">
<p className="text-xs sm:text-sm text-gray-500">
💡 系统将自动执行以下流程:
<ol className="mt-2 space-y-1 text-left pl-4">
<li className="text-xs sm:text-sm">1. 获取企业微信认证信息</li>
<li className="text-xs sm:text-sm">2. 初始化智能回复系统</li>
<li className="text-xs sm:text-sm">3. 获取当前客户ID</li>
<li className="text-xs sm:text-sm">4. 查询客户标签属性</li>
<li className="text-xs sm:text-sm">5. 生成个性化回复</li>
</ol>
</p>
</div>
</div>
</div>
) : (
// 聊天消息列表
<div className="max-w-4xl mx-auto space-y-3 sm:space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} ${
message.role === 'system' ? 'justify-center' : ''
}`}
>
<div className={`max-w-[85%] sm:max-w-[70%] rounded-2xl px-3 py-2 sm:px-4 sm:py-3 ${
message.role === 'user'
? 'bg-blue-600 text-white rounded-br-none'
: message.role === 'system'
? 'bg-gray-100 text-gray-700 border border-gray-200'
: 'bg-white border border-gray-200 rounded-bl-none shadow-sm'
}`}>
<div className="text-sm leading-relaxed break-words">
{message.content}
</div>
{/* <div className={`text-xs mt-1 ${
message.role === 'user' ? 'text-blue-100' :
message.role === 'system' ? 'text-gray-500' :
'text-gray-500'
}`}>
{message.timestamp}
</div> */}
</div>
</div>
))}
{/* 加载状态 */}
{workflowStatus.isRunning && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-2xl rounded-bl-none px-4 py-3 shadow-sm">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
</div>
{/* 工作流状态面板 - 桌面端和移动端抽屉 */}
<div className={`hidden lg:block w-80 border-l border-gray-200 bg-white overflow-y-auto ${
sidebarOpen ? '!block fixed right-0 top-0 h-full w-80 z-50 shadow-2xl' : ''
}`}>
<div className="p-4 h-full overflow-y-auto">
{/* 移动端侧边栏头部 */}
{sidebarOpen && (
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
<h3 className="font-bold text-gray-800">工作流状态</h3>
<button
className="p-2 hover:bg-gray-100 rounded"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
<WorkflowProgress
progress={workflowStatus.progress}
includeSend={false}
title={sidebarOpen ? '' : '工作流状态'}
/>
{/* 错误信息 */}
{workflowStatus.error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="font-medium text-red-800 mb-1">工作流错误</div>
<div className="text-sm text-red-600">{workflowStatus.error}</div>
</div>
)}
{/* 企业微信状态 */}
<div className="mt-6 pt-4 border-t border-gray-200">
<h4 className="font-medium text-gray-700 mb-2">企业微信状态</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">SDK 状态:</span>
<span className={weCom.sdkReady ? 'text-green-600' : 'text-red-600'}>
{weCom.sdkReady ? '已就绪' : '未就绪'}
</span>
</div>
{/* 直接显示页面场景 */}
<div className="flex justify-between">
<span className="text-gray-600">页面场景:</span>
<span className="text-blue-600">
{pageContext ? (
pageContext.entry ? getSceneDescription(pageContext.entry) : '未知场景'
) : (
weCom.sdkReady ? '获取中...' : 'SDK未就绪'
)}
</span>
</div>
{weCom.sdkError && (
<div className="text-red-600 text-xs break-words">
错误: {weCom.sdkError.message}
</div>
)}
</div>
</div>
<div className="border-l border-gray-200 flex items-center">
</div>
</div>
</div>
{/* 智能回复按钮区域 */}
{workflowStatus.result && (
<footer className="border-t border-gray-200 bg-white p-3 sm:p-4">
<div className="max-w-4xl mx-auto">
<div className="flex flex-col items-center space-y-2 sm:space-y-3">
<div className="text-xs sm:text-sm text-gray-600 text-center px-2">
{workflowStatus.result?.generatedMessage ? (
<span>已生成智能回复,点击下方按钮发送到企业微信</span>
) : (
<span>执行工作流以生成智能回复</span>
)}
</div>
<button
className="bg-blue-600 text-white px-6 h-full font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors min-w-[6rem]"
onClick={sendMessage}
disabled={!inputMessage.trim() || isLoading}
className="w-full max-w-sm bg-gradient-to-r from-green-500 to-emerald-600 text-white px-4 sm:px-8 py-2 sm:py-3 rounded-lg font-medium hover:from-green-600 hover:to-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
onClick={sendSmartReply}
disabled={workflowStatus.isRunning || !weCom.sdkReady || !workflowStatus.result?.generatedMessage}
>
{isLoading ? (
{workflowStatus.isRunning ? (
<span className="flex items-center justify-center">
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
发送中
<span className="w-3 h-3 sm:w-4 sm:h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
发送中...
</span>
) : (
<span className="flex items-center justify-center space-x-2">
<span>发送</span>
<span className="text-xs opacity-70">↵</span>
<span className="flex items-center justify-center space-x-1 sm:space-x-2">
<span className="text-sm sm:text-base">📤 发送智能回复到企业微信</span>
</span>
)}
</button>
</div>
</div>
</div>
</footer>
{/* 角色编辑模态框 */}
{isEditingRole && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-800">编辑角色配置</h3>
<button
className="text-gray-500 hover:text-gray-700"
onClick={() => setIsEditingRole(false)}
>
✕
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
角色名称
</label>
<input
type="text"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
value={roleConfig.name}
onChange={(e) => setRoleConfig(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
角色描述
</label>
<textarea
className="w-full border border-gray-300 rounded-lg px-3 py-2 h-32 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
value={roleConfig.description}
onChange={(e) => setRoleConfig(prev => ({ ...prev, description: e.target.value }))}
/>
<div className="text-xs text-gray-500 text-center px-2">
点击按钮将生成的智能回复发送到当前企业微信会话
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => setIsEditingRole(false)}
>
取消
</button>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => updateRoleConfig(roleConfig)}
>
保存配置
</button>
</div>
</div>
</div>
</footer>
)}
</div>
)
);
}
export default App
export default App;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { getStepIds, getStepNames, getStepStatusConfig } from '../constants/workflowSteps';
/**
* 工作流进度组件
* 显示工作流各个步骤的状态
*
* @param {Object} props
* @param {Object} props.progress 进度数据对象
* @param {boolean} [props.includeSend=false] 是否包含发送步骤
* @param {string} [props.title='工作流状态'] 组件标题
* @param {string} [props.className=''] 额外的CSS类名
*/
const WorkflowProgress = ({
progress = {},
includeSend = false,
title = '工作流状态',
className = ''
}) => {
const stepIds = getStepIds(includeSend);
const stepNames = getStepNames();
return (
<div className={className}>
{title && <h3 className="font-bold text-gray-800 mb-4">{title}</h3>}
<div className="space-y-3">
{stepIds.map(stepId => {
const stepInfo = progress[stepId];
const statusConfig = stepInfo ? getStepStatusConfig(stepInfo.status) : getStepStatusConfig();
return (
<div key={stepId} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm text-gray-700">{stepNames[stepId]}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${statusConfig.className}`}>
{statusConfig.text}
</span>
</div>
{stepInfo && stepInfo.data && (
<div className="text-xs text-gray-500 mt-1">
{stepId === 'context' && stepInfo.data.scene && (
<div>场景: {stepInfo.data.scene}</div>
)}
{stepId === 'customer' && stepInfo.data.externalUserId && (
<div>客户ID: {stepInfo.data.externalUserId}</div>
)}
{stepId === 'tags' && stepInfo.data.tags && (
<div>标签数: {stepInfo.data.tags.length}</div>
)}
{stepId === 'history' && stepInfo.data && (
<div>消息数: {stepInfo.data.messages?.length || 0}</div>
)}
{stepId === 'generate' && stepInfo.data && (
<div className="truncate">回复已生成</div>
)}
{stepId === 'send' && stepInfo.data && (
<div className="truncate">
发送结果: {stepInfo.data.errCode === 0 ? '成功' : '失败'}
</div>
)}
</div>
)}
{stepInfo && stepInfo.error && (
<div className="text-xs text-red-600 mt-1">{stepInfo.error}</div>
)}
</div>
);
})}
</div>
</div>
);
};
export default WorkflowProgress;

View File

@@ -0,0 +1,76 @@
/**
* 企业微信页面场景类型映射
* 将场景代码转换为友好的中文描述
*/
export const WECOM_SCENE_TYPES = {
// 从联系人详情进入
'contact_profile': '联系人详情',
// 从单聊会话的工具栏进入
'single_chat_tools': '单聊工具栏',
// 从群聊会话的工具栏进入
'group_chat_tools': '群聊工具栏',
// 从会话的聊天附件栏进入
'chat_attachment': '聊天附件栏',
// 从微信客服的工具栏进入
'single_kf_tools': '微信客服工具栏',
// 上下游单聊会话的工具栏
'chain_single_chat_tools': '上下游单聊工具栏',
// 上下游群聊会话的工具栏
'chain_group_chat_tools': '上下游群聊工具栏',
// 从内部群群看板进入
'internal_group_chat_board': '内部群看板'
};
/**
* 获取场景类型的中文描述
* @param {string} sceneCode 场景代码
* @returns {string} 中文描述,如果未知则返回原始代码
*/
export const getSceneDescription = (sceneCode) => {
return WECOM_SCENE_TYPES[sceneCode] || sceneCode;
};
/**
* 获取入口类型的中文描述
* @param {string} entryCode 入口代码
* @returns {string} 中文描述,如果未知则返回原始代码
*/
export const getEntryDescription = (entryCode) => {
const entryMap = {
'contact': '联系人',
'single_chat': '单聊',
'group_chat': '群聊',
'chat': '聊天',
'single_kf': '微信客服',
'chain_single_chat': '上下游单聊',
'chain_group_chat': '上下游群聊',
'internal_group': '内部群'
};
return entryMap[entryCode] || entryCode;
};
/**
* 格式化页面场景信息
* @param {Object} context 页面场景对象
* @returns {Object} 格式化后的场景信息
*/
export const formatPageContext = (context) => {
if (!context) return null;
// 使用 scene 字段,如果不存在则使用 entry 字段作为后备
const sceneCode = context.scene || context.entry;
return {
...context,
entryDisplay: getEntryDescription(context.entry),
sceneDisplay: getSceneDescription(sceneCode)
};
};

View File

@@ -0,0 +1,97 @@
/**
* 工作流步骤常量
* 用于统一管理工作流步骤的名称和配置
*/
export const WORKFLOW_STEPS = {
auth: {
id: 'auth',
name: '获取认证信息',
description: '获取企业微信 Access Token 和 JSAPI Ticket'
},
register: {
id: 'register',
name: '注册企业微信SDK',
description: '初始化智能回复SDK,准备生成个性化回复'
},
customer: {
id: 'customer',
name: '获取客户ID',
description: '获取当前企业微信会话的客户ID'
},
tags: {
id: 'tags',
name: '获取客户标签',
description: '根据客户ID查询客户标签属性'
},
history: {
id: 'history',
name: '获取会话历史',
description: '获取客户的历史会话记录'
},
generate: {
id: 'generate',
name: '生成智能回复',
description: '基于客户标签和历史会话生成个性化的智能回复'
},
send: {
id: 'send',
name: '发送消息到企业微信',
description: '将生成的回复发送到当前企业微信会话'
}
};
/**
* 获取步骤名称映射
* @returns {Object} 步骤ID到名称的映射
*/
export const getStepNames = () => {
const names = {};
Object.values(WORKFLOW_STEPS).forEach(step => {
names[step.id] = step.name;
});
return names;
};
/**
* 获取步骤状态样式
* @param {string} status 步骤状态
* @returns {Object} 包含样式类和显示文本的对象
*/
export const getStepStatusConfig = (status) => {
switch (status) {
case 'success':
return {
className: 'bg-green-100 text-green-800',
text: '成功'
};
case 'error':
return {
className: 'bg-red-100 text-red-800',
text: '失败'
};
case 'loading':
return {
className: 'bg-blue-100 text-blue-800',
text: '进行中'
};
default:
return {
className: 'bg-gray-100 text-gray-800',
text: '待执行'
};
}
};
/**
* 获取所有步骤ID的数组
* @param {boolean} includeSend 是否包含发送步骤
* @returns {Array} 步骤ID数组
*/
export const getStepIds = (includeSend = false) => {
const steps = ['auth', 'register', 'customer', 'tags', 'history', 'generate'];
if (includeSend) {
steps.push('send');
}
return steps;
};

View File

@@ -0,0 +1,375 @@
import { useState, useEffect, useCallback } from 'react';
import * as ww from '@wecom/jssdk';
/**
* 企业微信 JSSDK Hook
* 提供企业微信 JSSDK 的初始化、注册和 API 调用功能
*/
export const useWeCom = () => {
const [sdkReady, setSdkReady] = useState(false);
const [sdkError, setSdkError] = useState(null);
const [isRegistering, setIsRegistering] = useState(false);
// 从环境变量获取配置
const corpId = import.meta.env.VITE_WECOM_CORP_ID;
const agentId = parseInt(import.meta.env.VITE_WECOM_AGENT_ID, 10);
// 检查环境变量
useEffect(() => {
if (!corpId || !agentId) {
setSdkError(new Error('企业微信配置缺失,请检查 .env 文件中的 VITE_WECOM_CORP_ID 和 VITE_WECOM_AGENT_ID'));
}
}, [corpId, agentId]);
// 初始化 JSSDK
const initializeSdk = useCallback(async () => {
if (sdkReady || sdkError) return;
try {
console.log('初始化企业微信 JSSDK...');
console.log('SDK 版本:', ww.SDK_VERSION);
console.log('企业ID:', corpId);
console.log('应用ID:', agentId);
// 检查 SDK 是否可用
if (typeof ww === 'undefined' || ww.SDK_VERSION == null) {
throw new Error('企业微信 JSSDK 未加载成功');
}
setSdkReady(true);
return true;
} catch (error) {
console.error('初始化企业微信 JSSDK 失败:', error);
setSdkError(error);
return false;
}
}, [corpId, agentId, sdkReady, sdkError]);
// 注册 JSSDK
const registerSdk = useCallback(() => {
return new Promise((resolve, reject) => {
if (!sdkReady || isRegistering) return false;
setIsRegistering(true);
setSdkError(null);
console.log('开始注册企业微信 JSSDK...');
// 清理 URL,移除 # 及其后面的部分
const url = window.location.href;
const cleanUrl = url.split('#')[0];
console.log('注册 URL:', cleanUrl);
// 生成签名的函数
const getConfigSignature = async () => {
const timestamp = Math.floor(Date.now() / 1000);
const nonceStr = Math.random().toString(36).substring(2, 15);
return { timestamp, nonceStr, signature: 'temp_corp_signature' };
};
const getAgentConfigSignature = async (url) => {
try {
const cleanUrl = url.split('#')[0];
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://mp.yizhezhe.com/api';
const apiUrl = `${apiBaseUrl}/auth/signature`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: cleanUrl,
timestamp: Math.floor(Date.now() / 1000),
nonceStr: Math.random().toString(36).substring(2, 15)
})
});
const result = await response.json();
if (result.success) {
return result.data;
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('调用签名 API 失败:', error);
// 返回测试签名
const timestamp = Math.floor(Date.now() / 1000);
const nonceStr = Math.random().toString(36).substring(2, 15);
return { timestamp, nonceStr, signature: 'test_signature' };
}
};
// 注册 JSSDK
try {
ww.register({
corpId,
agentId,
jsApiList: ['getContext', 'getCurExternalContact', 'sendChatMessage'],
getConfigSignature,
getAgentConfigSignature,
onConfigSuccess: (res) => {
console.log('JSSDK 注册成功:', res);
setSdkError(null);
setIsRegistering(false);
},
onConfigFail: (err) => {
console.error('JSSDK 注册失败:', err);
let registrationError = new Error(`JSSDK 注册失败: ${err.errMsg || '未知错误'}`);
setSdkError(registrationError);
setIsRegistering(false);
}
});
console.log('企业微信 JSSDK 注册调用完成');
resolve(true);
} catch (error) {
console.error('注册企业微信 JSSDK 时发生错误:', error);
setSdkError(error);
setIsRegistering(false);
reject(error);
}
});
}, [sdkReady, isRegistering, corpId, agentId]);
// 获取页面场景
const getContext = useCallback(() => {
return new Promise((resolve, reject) => {
if (!sdkReady) {
reject(new Error('JSSDK 未就绪'));
return;
}
console.log('开始调用 ww.getContext()...');
console.log('ww 对象:', ww);
console.log('ww.getContext 类型:', typeof ww.getContext);
// 添加超时机制
const timeoutId = setTimeout(() => {
console.warn('获取页面场景超时(3秒),可能JSSDK未正确注册或回调未触发');
reject(new Error('获取页面场景超时:可能JSSDK未正确注册或回调未触发'));
}, 3000);
try {
ww.getContext({
success: (res) => {
clearTimeout(timeoutId);
console.log('获取页面场景成功 - 原始数据:', res);
console.log('获取页面场景成功 - 数据类型:', typeof res);
if (res === undefined || res === null) {
console.warn('获取页面场景返回空数据,可能不在企业微信环境中');
const formattedContext = {
entry: 'unknown',
isValidEntry: false,
validEntries: ['contact_profile', 'single_chat_tools', 'single_kf_tools'],
rawData: res,
message: '获取页面场景返回空数据,可能不在企业微信环境中'
};
resolve(formattedContext);
return;
}
console.log('获取页面场景成功 - 数据键名:', Object.keys(res || {}));
// 企业微信 getContext 返回的数据结构可能是:
// 1. 直接包含 entry 字段的对象
// 2. 包含在 data 字段中的对象
// 3. 其他格式
let contextData = res;
// 如果返回的数据有 data 字段,使用 data 字段
if (res && res.data) {
console.log('检测到 data 字段,使用 data 字段:', res.data);
contextData = res.data;
}
// 根据企业微信官方文档,getContext 只返回 entry 参数
// entry: 进入页面的场景类型
const entry = contextData?.entry || contextData?.entryId || 'unknown';
// 检查 entry 是否有效
const validEntries = ['contact_profile', 'single_chat_tools', 'single_kf_tools'];
const isValidEntry = validEntries.includes(entry);
const formattedContext = {
entry: entry,
isValidEntry: isValidEntry,
validEntries: validEntries,
rawData: res // 保留原始数据用于调试
};
console.log('企业微信 getContext 返回:', {
原始数据: res,
解析后的entry: entry,
是否有效: isValidEntry ? '是' : '否',
有效entry列表: validEntries
});
console.log('格式化后的页面场景数据:', formattedContext);
resolve(formattedContext);
},
fail: (err) => {
clearTimeout(timeoutId);
console.error('获取页面场景失败:', err);
console.error('失败详情 - errCode:', err?.errCode);
console.error('失败详情 - errMsg:', err?.errMsg);
reject(new Error(`获取页面场景失败: ${err?.errMsg || '未知错误'}`));
}
});
} catch (error) {
clearTimeout(timeoutId);
console.error('调用 ww.getContext 时发生异常:', error);
reject(new Error(`调用 getContext 时发生异常: ${error.message}`));
}
});
}, [sdkReady]);
// 获取当前客户ID
const getCurExternalContact = useCallback(() => {
return new Promise((resolve, reject) => {
if (!sdkReady) {
reject(new Error('JSSDK 未就绪'));
return;
}
console.log('开始获取当前客户ID...');
// 添加超时机制
const timeoutId = setTimeout(() => {
console.warn('获取当前客户ID超时(3秒),可能不在外部联系人页面');
reject(new Error('获取当前客户ID超时:可能不在外部联系人聊天页面或JSSDK回调未触发'));
}, 3000);
ww.getCurExternalContact({
success: (res) => {
clearTimeout(timeoutId);
console.log('获取当前客户ID成功:', res);
// 检查返回的数据结构
if (res && res.externalUserId) {
resolve(res);
} else {
console.warn('获取当前客户ID返回的数据格式异常:', res);
// 即使数据格式异常,也返回数据,让上层处理
resolve(res || { externalUserId: 'unknown', data: res });
}
},
fail: (err) => {
clearTimeout(timeoutId);
console.error('获取当前客户ID失败:', err);
// 根据错误类型提供更详细的错误信息
let errorMessage = `获取当前客户ID失败: ${err.errMsg || '未知错误'}`;
if (err.errCode === 10001) {
errorMessage = '获取当前客户ID失败:不在外部联系人聊天页面';
} else if (err.errCode === 10002) {
errorMessage = '获取当前客户ID失败:用户取消了操作';
}
reject(new Error(errorMessage));
},
cancel: () => {
clearTimeout(timeoutId);
console.warn('用户取消了获取当前客户ID操作');
reject(new Error('用户取消了获取当前客户ID操作'));
}
});
});
}, [sdkReady]);
// 发送消息到当前会话
const sendChatMessage = useCallback((content) => {
return new Promise((resolve, reject) => {
if (!sdkReady) {
reject(new Error('JSSDK 未就绪'));
return;
}
ww.sendChatMessage({
msgtype: 'text',
text: {
content
},
success: (res) => {
console.log('发送消息成功:', res);
resolve(res);
},
fail: (err) => {
console.error('发送消息失败:', err);
reject(new Error(`发送消息失败: ${err.errMsg || '未知错误'}`));
},
cancel: () => {
reject(new Error('用户取消了发送消息操作'));
}
});
});
}, [sdkReady]);
// 自动初始化
useEffect(() => {
if (corpId && agentId && !sdkReady && !sdkError) {
initializeSdk();
}
}, [corpId, agentId, sdkReady, sdkError, initializeSdk]);
return {
// 状态
sdkReady,
sdkError,
isRegistering,
corpId,
agentId,
// 方法
initializeSdk,
registerSdk,
getContext,
getCurExternalContact,
sendChatMessage,
// 工具方法
isConfigured: !!corpId && !!agentId
};
};
/**
* 带重试的执行函数
* @param {Function} action 要执行的函数
* @param {number} maxRetries 最大重试次数,默认3次
* @param {Function} onRetry 重试时的回调
* @returns 执行结果
*/
export const executeWithRetry = async (action, maxRetries = 3, onRetry = null) => {
let retryCount = 0;
let lastError = null;
while (retryCount <= maxRetries) {
try {
return await action();
} catch (error) {
lastError = error;
retryCount++;
if (retryCount > maxRetries) {
throw new Error(`操作失败,已重试${maxRetries}次: ${error.message}`);
}
// 指数退避等待
const delay = Math.pow(2, retryCount - 1) * 1000;
console.log(`第${retryCount}次重试,等待${delay}ms后重试...`);
if (onRetry) {
onRetry(retryCount, error, delay);
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
};

View File

@@ -33,3 +33,59 @@
.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 移动端优化 */
@layer base {
html {
-webkit-tap-highlight-color: transparent;
}
body {
@apply antialiased;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* 触摸友好的按钮 */
@layer components {
.touch-button {
@apply active:scale-95 transition-transform duration-150;
}
/* 移动端优化输入框 */
.mobile-input {
@apply text-base;
font-size: 16px !important; /* 防止iOS缩放 */
}
}
/* 防止移动端长按菜单 */
.no-context-menu {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* 移动端优化滚动 */
.mobile-scroll {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* 防止文本溢出 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 移动端优化模态框 */
.mobile-modal {
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}

View File

@@ -0,0 +1,590 @@
import axios from 'axios';
import { executeWithRetry } from '../hooks/useWeCom';
// API 基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://mp.yizhezhe.com/api';
const CUSTOMER_API_BASE = import.meta.env.VITE_CUSTOMER_API_BASE || 'https://admin.doouba.cn/api';
// 创建 axios 实例
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
const customerApiClient = axios.create({
baseURL: CUSTOMER_API_BASE,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
/**
* 认证服务
*/
export const authService = {
/**
* 获取 Access Token
* @returns {Promise} Access Token 数据
*/
getAccessToken: () => executeWithRetry(async () => {
console.log('获取 Access Token...');
const response = await apiClient.get('/auth/token');
if (response.data.success !== true) {
throw new Error(`获取 Access Token 失败: ${response.data.message}`);
}
console.log('Access Token 获取成功:', response.data);
return response.data;
}, 3, (retryCount) => {
console.log(`第${retryCount}次重试获取 Access Token...`);
}),
/**
* 获取 JSAPI Ticket
* @returns {Promise} JSAPI Ticket 数据
*/
getJsapiTicket: () => executeWithRetry(async () => {
console.log('获取 JSAPI Ticket...');
const response = await apiClient.get('/auth/ticket');
if (response.data.success !== true) {
throw new Error(`获取 JSAPI Ticket 失败: ${response.data.message}`);
}
console.log('JSAPI Ticket 获取成功:', response.data);
return response.data;
}, 3, (retryCount) => {
console.log(`第${retryCount}次重试获取 JSAPI Ticket...`);
})
};
/**
* 客户服务
*/
export const customerService = {
/**
* 获取客户标签属性
* @param {string} userId 客户ID
* @returns {Promise} 客户信息(包含标签)
*/
getCustomerTags: (userId) => executeWithRetry(async () => {
console.log(`获取客户 ${userId} 的标签属性...`);
const response = await customerApiClient.get(`/WechatCustomers/${userId}`, {
params: {
includeWechatInfo: true
}
});
if (response.data.code !== 200) {
throw new Error(`获取客户标签失败: ${response.data.msg}`);
}
console.log('客户标签获取成功:', response.data.data);
return response.data.data;
}, 3, (retryCount) => {
console.log(`第${retryCount}次重试获取客户标签...`);
}),
/**
* 根据标签和历史会话生成提示词
* @param {Object} customerInfo 客户信息
* @param {Array} messageHistory 消息历史数据
* @returns {string} 生成的提示词
*/
generatePromptFromTagsAndHistory: (customerInfo, messageHistory = []) => {
if (!customerInfo || !customerInfo.tags) {
return '你好!我是兜爸心选的客服,有什么可以帮您的吗?';
}
const { name, tags, externalUserId } = customerInfo;
const tagNames = tags.map(t => t.TagName).join('、');
const tagCategories = [...new Set(tags.map(t => t.TagCategory))].join('、');
// 1. 基础客户信息
let prompt = `【客户基本信息】\n`;
prompt += `- 姓名:${name || '未知客户'}\n`;
if (tagNames) {
prompt += `- 标签:${tagNames}\n`;
}
if (tagCategories) {
prompt += `- 标签分类:${tagCategories}\n`;
}
// 添加基于标签的个性化内容
const hasXiaohongshu = tags.some(t => t.TagName.includes('小红书'));
const hasYunying = tags.some(t => t.TagCategory === '运营人');
if (hasXiaohongshu) {
prompt += `- 渠道:小红书用户,对性价比比较关注\n`;
}
if (hasYunying) {
const yunyingTags = tags.filter(t => t.TagCategory === '运营人');
if (yunyingTags.length > 0) {
prompt += `- 运营人:${yunyingTags[0].TagName}\n`;
}
}
// 2. 历史会话摘要
prompt += `\n【历史会话摘要】(最近${messageHistory.length}条)\n`;
if (messageHistory.length > 0) {
// 按时间倒序排列(最新的在前)
const sortedHistory = [...messageHistory].sort((a, b) =>
new Date(b.msgtime) - new Date(a.msgtime)
).slice(0, 20); // 确保最多20条
sortedHistory.forEach((msg, index) => {
const time = new Date(msg.msgtime).toLocaleString('zh-CN');
const role = msg.from_user === externalUserId ? '客户' : '客服';
const content = msg.text?.content || msg.content || '无文本内容';
// 简化内容,避免过长
const simplifiedContent = content.length > 50
? content.substring(0, 50) + '...'
: content;
prompt += `${index + 1}. 时间:${time},角色:${role},内容:${simplifiedContent}\n`;
});
// 3. 对话上下文分析
prompt += `\n【对话上下文分析】\n`;
// 分析客户消息
const customerMessages = sortedHistory.filter(msg =>
msg.from_user === externalUserId
);
if (customerMessages.length > 0) {
// 提取关键词
const allContent = customerMessages.map(msg =>
msg.text?.content || msg.content || ''
).join(' ');
// 简单分析(实际可以更复杂)
prompt += `- 客户主要关注:${customerService.analyzeMainTopics(allContent)}\n`;
prompt += `- 消息频率:${customerMessages.length}条历史消息\n`;
// 检查是否有未解决的问题
const hasQuestions = customerMessages.some(msg =>
(msg.text?.content || '').includes('?') ||
(msg.text?.content || '').includes('?')
);
prompt += `- 未解决问题:${hasQuestions ? '有' : '暂无明确未解决问题'}\n`;
}
} else {
prompt += `暂无历史会话记录\n`;
}
// 4. 生成要求
prompt += `\n【生成要求】\n`;
prompt += `基于以上客户信息和历史会话,生成一段亲切、专业的回复。要求:\n`;
prompt += `1. 如果历史会话中有未解决的问题,优先回应\n`;
prompt += `2. 保持对话的连贯性和上下文一致性\n`;
prompt += `3. 体现对客户历史需求的了解和关注\n`;
prompt += `4. 根据客户标签提供个性化服务建议\n`;
prompt += `5. 语气亲切但不失专业,体现兜爸心选的品牌形象\n`;
return prompt;
},
/**
* 简单的话题分析函数
* @param {string} text 文本内容
* @returns {string} 分析出的主要话题
*/
analyzeMainTopics: (text) => {
const topics = [];
const lowerText = text.toLowerCase();
if (lowerText.includes('价格') || lowerText.includes('多少钱')) {
topics.push('价格咨询');
}
if (lowerText.includes('怎么用') || lowerText.includes('使用方法')) {
topics.push('使用指导');
}
if (lowerText.includes('发货') || lowerText.includes('物流')) {
topics.push('物流查询');
}
if (lowerText.includes('质量') || lowerText.includes('品质')) {
topics.push('质量咨询');
}
if (lowerText.includes('售后') || lowerText.includes('退换')) {
topics.push('售后服务');
}
return topics.length > 0 ? topics.join('、') : '一般咨询';
},
/**
* 格式化历史会话用于显示
* @param {Array} messageHistory 消息历史数据
* @param {string} externalUserId 客户ID(用于识别客户消息)
* @param {number} maxMessages 最大显示消息数
* @returns {string} 格式化后的历史会话文本
*/
formatHistoryForDisplay: (messageHistory = [], externalUserId = '', maxMessages = 5) => {
if (!messageHistory || messageHistory.length === 0) {
return '📜 暂无历史会话记录';
}
// 按时间倒序排列(最新的在前)
const sortedHistory = [...messageHistory].sort((a, b) =>
new Date(b.msgtime) - new Date(a.msgtime)
).slice(0, maxMessages);
let formattedText = `📜 最近对话记录(${sortedHistory.length}条):\n\n`;
sortedHistory.forEach((msg) => {
// 确定发送者角色
const role = msg.from_user === externalUserId ? '客户' : '客服';
// 获取消息内容
const content = msg.text?.content || msg.content || '无文本内容';
// 简化内容,避免过长
const simplifiedContent = content.length > 80
? content.substring(0, 80) + '...'
: content;
// 使用点状列表格式,弱化时间显示
formattedText += `• ${role}:${simplifiedContent}\n`;
});
return formattedText;
}
};
/**
* 消息服务
*/
export const messageService = {
/**
* 获取用户消息历史
* @param {string} userId 客户ID
* @param {number} page 页码
* @param {number} pageSize 每页数量
* @returns {Promise} 消息历史数据
*/
getUserMessageHistory: (userId, page = 1, pageSize = 20) => executeWithRetry(async () => {
console.log(`获取用户 ${userId} 的消息历史,页码: ${page}, 每页: ${pageSize}...`);
const response = await apiClient.get('/messages', {
params: {
from_user: userId,
page: page,
page_size: pageSize
}
});
if (!response.data.success) {
throw new Error(`获取消息历史失败: ${response.data.message || '未知错误'}`);
}
console.log('消息历史获取成功:', {
total: response.data.data?.total || 0,
messagesCount: response.data.data?.messages?.length || 0
});
return response.data.data;
}, 3, (retryCount) => {
console.log(`第${retryCount}次重试获取消息历史...`);
})
};
/**
* 大语言模型服务
*/
export const glmService = {
/**
* 发送消息到大语言模型
* @param {string} message 消息内容
* @param {string} conversationId 会话ID(可选)
* @returns {Promise} 模型回复
*/
sendMessage: (message, conversationId = null) => executeWithRetry(async () => {
console.log('发送消息到大语言模型...', { message, conversationId });
const payload = {
message,
conversation_id: conversationId
};
const response = await apiClient.post('/glm/chat', payload);
if (!response.data || !response.data.response) {
throw new Error('大语言模型返回格式错误');
}
console.log('大语言模型回复成功:', response.data);
return response.data;
}, 3, (retryCount) => {
console.log(`第${retryCount}次重试发送消息到大语言模型...`);
}),
/**
* 基于客户标签生成智能回复
* @param {Object} customerInfo 客户信息(包含标签)
* @returns {Promise} 生成的回复
*/
generateReplyFromTags: async (customerInfo) => {
const prompt = customerService.generatePromptFromTags(customerInfo);
console.log('生成的提示词:', prompt);
const result = await glmService.sendMessage(prompt);
return result.response;
},
/**
* 基于客户标签和历史会话生成智能回复
* @param {Object} customerInfo 客户信息(包含标签)
* @param {Array} messageHistory 消息历史数据
* @returns {Promise} 生成的回复
*/
generateReplyFromTagsAndHistory: async (customerInfo, messageHistory = []) => {
const prompt = customerService.generatePromptFromTagsAndHistory(customerInfo, messageHistory);
console.log('生成的提示词(包含历史):', prompt);
const result = await glmService.sendMessage(prompt);
return result.response;
}
};
/**
* 工作流服务 - 整合所有步骤
*/
export const workflowService = {
/**
* 执行工作流但不发送消息(只生成回复)
* @param {Object} weComHook 企业微信 Hook 实例
* @param {Function} onProgress 进度回调
* @returns {Promise} 工作流结果(包含生成的回复)
*/
executeWorkflowWithoutSend: async (weComHook, onProgress = null) => {
const results = {};
try {
// 1. 获取认证信息
if (onProgress) onProgress('auth', 'loading');
const [tokenResult, ticketResult] = await Promise.all([
authService.getAccessToken(),
authService.getJsapiTicket()
]);
results.auth = { token: tokenResult, ticket: ticketResult };
if (onProgress) onProgress('auth', 'success', results.auth);
// 2. 注册企业微信 JSSDK(同步调用)
if (onProgress) onProgress('register', 'loading');
let registerResult;
try {
const registered = weComHook.registerSdk();
registerResult = {
registered,
message: registered ? 'JSSDK注册调用成功' : 'JSSDK注册调用失败'
};
// 如果注册失败,抛出错误停止工作流
if (!registered) {
throw new Error('JSSDK注册失败,无法继续执行需要JSSDK的步骤');
}
} catch (error) {
console.error('JSSDK注册调用时发生错误:', error);
registerResult = {
registered: false,
error: error.message,
message: `注册调用失败: ${error.message}`
};
// 抛出错误停止工作流
throw new Error(`JSSDK注册失败: ${error.message}`);
}
results.register = registerResult;
if (onProgress) onProgress('register', 'success', registerResult);
// 3. 获取客户ID
if (onProgress) onProgress('customer', 'loading');
console.log('开始获取客户ID步骤...');
let customerResult;
try {
// 首先获取页面场景,检查是否在支持获取客户ID的页面
let contextInfo = null;
try {
contextInfo = await weComHook.getContext();
console.log('当前页面场景:', contextInfo);
// 检查是否获取到有效的页面场景数据
if (!contextInfo || contextInfo.entry === 'unknown') {
const errorMessage = `无法获取有效的页面场景信息。可能的原因:\n1. 不在企业微信环境中运行\n2. JSSDK未正确注册\n3. 当前页面不支持获取场景信息\n\n请确保在企业微信中打开此应用,并刷新页面重试。`;
console.error(errorMessage);
throw new Error(errorMessage);
}
// 检查 entry 是否有效
if (!contextInfo.isValidEntry) {
const errorMessage = `当前页面不支持获取客户ID。getCurExternalContact() 接口只在以下页面可用:\n• 外部联系人详情页面 (contact_profile)\n• 单聊工具栏页面 (single_chat_tools)\n• 单聊客服工具栏页面 (single_kf_tools)\n\n当前页面: ${contextInfo.entry}\n请在企业微信外部联系人聊天页面中打开此应用。`;
console.error(errorMessage);
throw new Error(errorMessage);
}
console.log('当前页面支持获取客户ID,entry:', contextInfo.entry);
// 在支持获取客户ID的页面,尝试获取真实客户ID
customerResult = await weComHook.getCurExternalContact()
.then(customerInfo => {
console.log('成功获取到客户ID:', customerInfo);
return {
...customerInfo,
context: contextInfo,
mock: false
};
})
.catch(error => {
console.error('获取客户ID失败:', error);
throw error; // 重新抛出错误,停止工作流
});
} catch (contextError) {
console.error('获取页面场景失败或页面不支持获取客户ID:', contextError);
throw contextError; // 重新抛出错误,停止工作流
}
} catch (error) {
console.error('获取客户ID步骤失败:', error);
// 不再返回模拟数据,而是抛出错误停止工作流
throw new Error(`获取客户ID失败: ${error.message}`);
}
results.customer = customerResult;
if (onProgress) onProgress('customer', 'success', customerResult);
// 4. 获取客户标签
if (onProgress) onProgress('tags', 'loading');
let tagsResult;
if (!customerResult.externalUserId || customerResult.mock) {
console.warn('使用模拟标签数据');
tagsResult = {
name: '模拟客户',
tags: [
{ TagName: '小红书用户', TagCategory: '渠道' },
{ TagName: '高意向客户', TagCategory: '意向度' },
{ TagName: '已购买用户', TagCategory: '客户状态' }
],
mock: true,
message: '使用模拟标签数据'
};
} else {
try {
tagsResult = await customerService.getCustomerTags(customerResult.externalUserId);
} catch (error) {
console.warn('获取客户标签失败,使用模拟数据:', error);
tagsResult = {
name: '客户(标签获取失败)',
tags: [{ TagName: '兜爸心选客户', TagCategory: '默认' }],
mock: true,
error: error.message,
message: '标签获取失败,使用模拟数据'
};
}
}
results.tags = tagsResult;
if (onProgress) onProgress('tags', 'success', tagsResult);
// 5. 获取会话历史
if (onProgress) onProgress('history', 'loading');
let historyResult;
if (!customerResult.externalUserId || customerResult.mock) {
console.warn('使用模拟历史数据');
historyResult = {
messages: [
{
msgid: 'mock_msg_001',
from_user: customerResult.externalUserId || 'mock_customer',
text: { content: '你好,我想了解一下你们的产品' },
msgtime: new Date(Date.now() - 86400000).toISOString(),
msgtype: 'text'
},
{
msgid: 'mock_msg_002',
from_user: '客服',
text: { content: '您好!欢迎咨询兜爸心选产品,请问您想了解哪方面的信息?' },
msgtime: new Date(Date.now() - 86300000).toISOString(),
msgtype: 'text'
}
],
total: 2,
mock: true,
message: '使用模拟历史数据'
};
} else {
try {
historyResult = await messageService.getUserMessageHistory(customerResult.externalUserId, 1, 20);
} catch (error) {
console.warn('获取会话历史失败,使用模拟数据:', error);
historyResult = {
messages: [],
total: 0,
mock: true,
error: error.message,
message: '历史获取失败,使用模拟数据'
};
}
}
results.history = historyResult;
if (onProgress) onProgress('history', 'success', historyResult);
// 6. 生成智能回复
if (onProgress) onProgress('generate', 'loading');
let generatedReply;
try {
// 合并客户信息和标签信息
const customerInfo = {
...customerResult,
...tagsResult,
externalUserId: customerResult.externalUserId
};
// 使用新的函数,包含历史数据
generatedReply = await glmService.generateReplyFromTagsAndHistory(
customerInfo,
historyResult.messages || []
);
} catch (error) {
console.warn('生成智能回复失败,使用默认回复:', error);
generatedReply = `您好!我是兜爸心选的智能客服。抱歉,生成个性化回复时遇到问题:${error.message}。请问您有什么需要帮助的吗?`;
}
results.generate = generatedReply;
if (onProgress) onProgress('generate', 'success', generatedReply);
return {
success: true,
steps: results,
generatedMessage: generatedReply,
timestamp: new Date().toISOString()
};
} catch (error) {
console.error('工作流执行失败:', error);
return {
success: false,
error: error.message,
steps: results,
timestamp: new Date().toISOString()
};
}
}
};
export default {
authService,
customerService,
glmService,
workflowService
};

View File

@@ -4,5 +4,10 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/chat_fe'
base: '/chat_fe',
server: {
port: 3010,
host: '0.0.0.0',
allowedHosts: ['mp.yizhezhe.com']
}
})

546
frontend/web/index.html Normal file
View File

@@ -0,0 +1,546 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业微信 JSSDK 测试</title>
<!-- 引入企业微信 JSSDK -->
<script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.3.3.js"></script>
</head>
<body>
<h1>企业微信 JSSDK 测试页面</h1>
<p>此页面用于测试企业微信 JSSDK 是否加载成功。</p>
<p>如果 JSSDK 加载成功,将会显示 SDK 版本号。</p>
<h2>Access Token(自动获取)</h2>
<div>
<p style="color: #666; font-style: italic;">页面加载时自动获取Access Token...</p>
<div id="tokenResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9;">
<h3>Access Token 结果:</h3>
<pre id="tokenData">获取中...</pre>
</div>
</div>
<h2>JSAPI Ticket(自动获取)</h2>
<div>
<p style="color: #666; font-style: italic;">页面加载时自动获取JSAPI Ticket...</p>
<div id="ticketResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9;">
<h3>JSAPI Ticket 结果:</h3>
<pre id="ticketData">获取中...</pre>
</div>
</div>
<h2>获取页面场景</h2>
<div>
<button id="getContextBtn">获取页面场景</button>
<div id="contextResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;">
<h3>页面场景结果:</h3>
<pre id="contextData"></pre>
</div>
</div>
<h2>获取当前客户ID</h2>
<div>
<button id="getCustomerBtn">获取当前客户ID</button>
<div id="customerResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;">
<h3>当前客户ID结果:</h3>
<pre id="customerData"></pre>
</div>
</div>
<h2>发送消息到当前会话</h2>
<div>
<div style="margin-bottom: 15px;">
<label for="messageContent" style="display: block; margin-bottom: 5px; font-weight: bold;">消息内容:</label>
<textarea id="messageContent" rows="4" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 5px; font-family: monospace;" placeholder="请输入要发送的消息内容...">你好,这是一条测试消息!</textarea>
</div>
<button id="sendMessageBtn">发送消息到当前会话</button>
<div id="messageResult" style="margin-top: 20px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;">
<h3>消息发送结果:</h3>
<pre id="messageData"></pre>
</div>
</div>
<!-- 测试代码 -->
<script>
// 函数定义
async function getConfigSignature(url) {
// 根据 url 生成企业签名
// 生成方法参考 https://developer.work.weixin.qq.com/document/14924
// 暂时返回简单实现,主要关注应用签名
const timestamp = Math.floor(Date.now() / 1000);
const nonceStr = Math.random().toString(36).substring(2, 15);
return { timestamp, nonceStr, signature: 'temp_signature' };
}
async function getAgentConfigSignature(url) {
// 根据 url 生成应用签名
// 注意:企业微信要求url必须是调用JSAPI页面的完整URL,不包含#及其后面部分
try {
// 清理URL,移除#及其后面的部分
const cleanUrl = url.split('#')[0];
console.log('生成签名,原始URL:', url, '清理后URL:', cleanUrl);
// 使用线上地址
const apiBaseUrl = 'http://mp.yizhezhe.com';
const apiUrl = `${apiBaseUrl}/api/auth/signature`;
console.log('调用签名API:', apiUrl);
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: cleanUrl,
timestamp: Math.floor(Date.now() / 1000),
nonceStr: Math.random().toString(36).substring(2, 15)
})
});
const result = await response.json();
if (result.success) {
console.log('签名生成成功:', result.data);
console.log('签名详情:', {
url: cleanUrl,
timestamp: result.data.timestamp,
nonceStr: result.data.nonceStr,
signature: result.data.signature
});
return result.data;
} else {
console.error('签名生成失败:', result.message);
throw new Error(result.message);
}
} catch (error) {
console.error('调用签名API失败:', error);
// 返回一个简单的签名用于测试
const timestamp = Math.floor(Date.now() / 1000);
const nonceStr = Math.random().toString(36).substring(2, 15);
return { timestamp, nonceStr, signature: 'error_signature' };
}
}
// 自动获取 Access Token 函数
async function autoFetchAccessToken() {
console.log('开始自动获取 Access Token...');
const tokenDataElement = document.getElementById('tokenData');
const tokenResultElement = document.getElementById('tokenResult');
try {
// 更新状态
tokenDataElement.textContent = '正在获取...';
tokenDataElement.style.color = '#666';
// 获取 access_token 的 API 端点
const apiUrl = 'http://mp.yizhezhe.com/api/auth/token';
console.log('正在调用 API:', apiUrl);
// 使用 fetch 调用 API
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP 错误: ${response.status}`);
}
const data = await response.json();
console.log('Access Token 获取成功:', data);
// 更新页面显示
if (data.success === true) {
tokenDataElement.textContent = JSON.stringify(data, null, 2);
tokenDataElement.style.color = '#333';
console.log(`Access Token 获取成功!${data.message}`);
console.log('Access Token 数据:', JSON.stringify(data, null, 2));
// 添加成功样式
tokenResultElement.style.borderColor = '#4CAF50';
tokenResultElement.style.backgroundColor = '#f8fff8';
} else {
tokenDataElement.textContent = `获取失败: ${data.message}\n\n返回数据: ${JSON.stringify(data, null, 2)}`;
tokenDataElement.style.color = '#d32f2f';
console.error(`获取 Access Token 失败: ${data.message}`);
// 添加错误样式
tokenResultElement.style.borderColor = '#f44336';
tokenResultElement.style.backgroundColor = '#fff8f8';
}
return data;
} catch (error) {
console.error('获取 Access Token 失败:', error);
console.error('错误详情:', error.message);
// 更新页面显示错误
tokenDataElement.textContent = `错误: ${error.message}\n\n请检查网络连接或后端服务状态。`;
tokenDataElement.style.color = '#d32f2f';
// 添加错误样式
tokenResultElement.style.borderColor = '#f44336';
tokenResultElement.style.backgroundColor = '#fff8f8';
return null;
}
}
// 自动获取 JSAPI Ticket 函数
async function autoFetchJsapiTicket() {
console.log('开始自动获取 JSAPI Ticket...');
const ticketDataElement = document.getElementById('ticketData');
const ticketResultElement = document.getElementById('ticketResult');
try {
// 更新状态
ticketDataElement.textContent = '正在获取...';
ticketDataElement.style.color = '#666';
// 获取 jsapi_ticket 的 API 端点 - 使用线上地址
const apiUrl = 'http://mp.yizhezhe.com/api/auth/ticket';
console.log('正在调用 API:', apiUrl);
// 使用 fetch 调用 API
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP 错误: ${response.status}`);
}
const data = await response.json();
console.log('JSAPI Ticket 获取成功:', data);
// 更新页面显示
if (data.success === true) {
ticketDataElement.textContent = JSON.stringify(data, null, 2);
ticketDataElement.style.color = '#333';
console.log(`JSAPI Ticket 获取成功!${data.message}`);
console.log('JSAPI Ticket 数据:', JSON.stringify(data, null, 2));
// 添加成功样式
ticketResultElement.style.borderColor = '#4CAF50';
ticketResultElement.style.backgroundColor = '#f8fff8';
} else {
ticketDataElement.textContent = `获取失败: ${data.message}\n\n返回数据: ${JSON.stringify(data, null, 2)}`;
ticketDataElement.style.color = '#d32f2f';
console.error(`获取 JSAPI Ticket 失败: ${data.message}`);
// 添加错误样式
ticketResultElement.style.borderColor = '#f44336';
ticketResultElement.style.backgroundColor = '#fff8f8';
}
return data;
} catch (error) {
console.error('获取 JSAPI Ticket 失败:', error);
console.error('错误详情:', error.message);
// 更新页面显示错误
ticketDataElement.textContent = `错误: ${error.message}\n\n请检查网络连接或后端服务状态。`;
ticketDataElement.style.color = '#d32f2f';
// 添加错误样式
ticketResultElement.style.borderColor = '#f44336';
ticketResultElement.style.backgroundColor = '#fff8f8';
return null;
}
}
// DOMContentLoaded 事件处理
document.addEventListener('DOMContentLoaded', function() {
console.log('DOMContentLoaded: 开始初始化页面...');
// 1. 检查并注册 JSSDK
console.log('检查 JSSDK...');
if (typeof ww !== 'undefined') {
console.log('企业微信 JSSDK 版本:', ww.SDK_VERSION);
console.log('JSSDK 对象已加载:', ww);
// 注册 JSSDK(不等待回调,让 JSSDK 自行处理)
console.log('开始注册 JSSDK...');
ww.register({
corpId: 'ww45bb49a9d651b2bb', // 必填,当前用户企业所属企业ID,从.env文件获取
agentId: 1000061, // 必填,当前应用的AgentID,从.env文件获取
jsApiList: ['getContext', 'getCurExternalContact', 'sendChatMessage'], // 必填,需要使用的JSAPI列表
getConfigSignature, // 必填,根据url生成企业签名的回调函数
getAgentConfigSignature // 必填,根据url生成应用签名的回调函数
// 不设置 success 和 fail 回调,让按钮直接调用接口
});
console.log('JSSDK 注册调用完成');
// 2. 自动获取 Access Token 和 JSAPI Ticket
console.log('开始自动获取认证信息...');
autoFetchAccessToken();
autoFetchJsapiTicket();
} else {
console.error('错误: 企业微信 JSSDK 未加载成功!');
console.error('请检查网络或刷新页面。');
}
// 获取页面场景功能
const getContextBtn = document.getElementById('getContextBtn');
const contextResult = document.getElementById('contextResult');
const contextData = document.getElementById('contextData');
if (getContextBtn) {
console.log('找到获取页面场景按钮,绑定点击事件');
// 初始时直接启用按钮
getContextBtn.disabled = false;
getContextBtn.textContent = '获取页面场景';
getContextBtn.addEventListener('click', function() {
console.log('点击获取页面场景按钮');
// 直接调用,不检查注册状态
getContextBtn.disabled = true;
getContextBtn.textContent = '获取中...';
console.log('直接调用 ww.getContext()...');
console.log('检查 ww 对象:', typeof ww);
console.log('检查 ww.getContext:', typeof ww.getContext);
if (typeof ww === 'undefined') {
alert('错误: ww 对象未定义,JSSDK 未加载成功!');
getContextBtn.disabled = false;
getContextBtn.textContent = '获取页面场景';
return;
}
if (typeof ww.getContext !== 'function') {
alert('错误: ww.getContext 不是函数,请检查 jsApiList 是否包含 getContext');
getContextBtn.disabled = false;
getContextBtn.textContent = '获取页面场景';
return;
}
// 调用企业微信 JSSDK 的 getContext 方法
ww.getContext({
success: function(res) {
console.log('获取页面场景成功:', res);
// 显示结果
contextData.textContent = JSON.stringify(res, null, 2);
contextResult.style.display = 'block';
// 解析场景信息
let sceneInfo = '未知场景';
if (res.entry) {
sceneInfo = `入口: ${res.entry}`;
}
if (res.scene) {
sceneInfo += `, 场景: ${res.scene}`;
}
if (res.chatId) {
sceneInfo += `, 聊天ID: ${res.chatId}`;
}
alert(`获取页面场景成功!\n${sceneInfo}`);
},
fail: function(err) {
console.error('获取页面场景失败:', err);
contextData.textContent = `错误: ${JSON.stringify(err, null, 2)}`;
contextResult.style.display = 'block';
alert('获取页面场景失败: ' + (err.errMsg || '未知错误'));
},
complete: function() {
console.log('getContext 调用完成');
getContextBtn.disabled = false;
getContextBtn.textContent = '获取页面场景';
}
});
});
} else {
console.error('未找到获取页面场景按钮!');
}
// 获取当前客户ID功能
const getCustomerBtn = document.getElementById('getCustomerBtn');
const customerResult = document.getElementById('customerResult');
const customerData = document.getElementById('customerData');
if (getCustomerBtn) {
console.log('找到获取当前客户ID按钮,绑定点击事件');
// 初始时直接启用按钮
getCustomerBtn.disabled = false;
getCustomerBtn.textContent = '获取当前客户ID';
getCustomerBtn.addEventListener('click', function() {
console.log('点击获取当前客户ID按钮');
// 直接调用,不检查注册状态
getCustomerBtn.disabled = true;
getCustomerBtn.textContent = '获取中...';
console.log('准备调用 ww.getCurExternalContact()...');
console.log('检查 ww 对象:', typeof ww);
console.log('检查 ww.getCurExternalContact:', typeof ww.getCurExternalContact);
if (typeof ww === 'undefined') {
alert('错误: ww 对象未定义,JSSDK 未加载成功!');
getCustomerBtn.disabled = false;
getCustomerBtn.textContent = '获取当前客户ID';
return;
}
if (typeof ww.getCurExternalContact !== 'function') {
alert('错误: ww.getCurExternalContact 不是函数,请检查 jsApiList 是否包含 getCurExternalContact');
getCustomerBtn.disabled = false;
getCustomerBtn.textContent = '获取当前客户ID';
return;
}
// 调用企业微信 JSSDK 的 getCurExternalContact 方法
ww.getCurExternalContact({
success: function(res) {
console.log('获取当前客户ID成功:', res);
// 显示结果
customerData.textContent = JSON.stringify(res, null, 2);
customerResult.style.display = 'block';
// 解析客户信息
let customerInfo = '未知客户';
if (res.externalUserId) {
customerInfo = `客户ID: ${res.externalUserId}`;
}
if (res.nickname) {
customerInfo += `, 昵称: ${res.nickname}`;
}
if (res.avatar) {
customerInfo += `, 有头像`;
}
if (res.corpName) {
customerInfo += `, 企业: ${res.corpName}`;
}
alert(`获取当前客户ID成功!\n${customerInfo}`);
},
fail: function(err) {
console.error('获取当前客户ID失败:', err);
customerData.textContent = `错误: ${JSON.stringify(err, null, 2)}`;
customerResult.style.display = 'block';
alert('获取当前客户ID失败: ' + (err.errMsg || '未知错误'));
},
cancel: function() {
console.log('用户取消了获取当前客户ID操作');
alert('用户取消了获取当前客户ID操作');
getCustomerBtn.disabled = false;
getCustomerBtn.textContent = '获取当前客户ID';
},
complete: function() {
console.log('getCurExternalContact 调用完成');
getCustomerBtn.disabled = false;
getCustomerBtn.textContent = '获取当前客户ID';
}
});
});
} else {
console.error('未找到获取当前客户ID按钮!');
}
// 发送消息到当前会话功能
const sendMessageBtn = document.getElementById('sendMessageBtn');
const messageResult = document.getElementById('messageResult');
const messageData = document.getElementById('messageData');
const messageContent = document.getElementById('messageContent');
if (sendMessageBtn) {
console.log('找到发送消息按钮,绑定点击事件');
// 初始时直接启用按钮
sendMessageBtn.disabled = false;
sendMessageBtn.textContent = '发送消息到当前会话';
sendMessageBtn.addEventListener('click', function() {
console.log('点击发送消息按钮');
// 获取消息内容
const content = messageContent.value.trim();
if (!content) {
alert('错误: 消息内容不能为空!');
return;
}
// 直接调用,不检查注册状态
sendMessageBtn.disabled = true;
sendMessageBtn.textContent = '发送中...';
console.log('准备调用 ww.sendChatMessage()...');
console.log('检查 ww 对象:', typeof ww);
console.log('检查 ww.sendChatMessage:', typeof ww.sendChatMessage);
console.log('消息内容:', content);
if (typeof ww === 'undefined') {
alert('错误: ww 对象未定义,JSSDK 未加载成功!');
sendMessageBtn.disabled = false;
sendMessageBtn.textContent = '发送消息到当前会话';
return;
}
if (typeof ww.sendChatMessage !== 'function') {
alert('错误: ww.sendChatMessage 不是函数,请检查 jsApiList 是否包含 sendChatMessage');
sendMessageBtn.disabled = false;
sendMessageBtn.textContent = '发送消息到当前会话';
return;
}
// 调用企业微信 JSSDK 的 sendChatMessage 方法
ww.sendChatMessage({
msgtype: 'text',
text: {
content: content
},
success: function(res) {
console.log('发送消息成功:', res);
// 显示结果
messageData.textContent = JSON.stringify(res, null, 2);
messageResult.style.display = 'block';
// 解析发送结果
let resultInfo = '消息发送成功!';
if (res.errCode === 0) {
resultInfo = `消息发送成功!\n错误码: ${res.errCode}, 错误信息: ${res.errMsg || '无'}`;
} else {
resultInfo = `消息发送失败!\n错误码: ${res.errCode}, 错误信息: ${res.errMsg || '未知错误'}`;
}
alert(resultInfo);
},
fail: function(err) {
console.error('发送消息失败:', err);
messageData.textContent = `错误: ${JSON.stringify(err, null, 2)}`;
messageResult.style.display = 'block';
alert('发送消息失败: ' + (err.errMsg || '未知错误'));
},
cancel: function() {
console.log('用户取消了发送消息操作');
alert('用户取消了发送消息操作');
sendMessageBtn.disabled = false;
sendMessageBtn.textContent = '发送消息到当前会话';
},
complete: function() {
console.log('sendChatMessage 调用完成');
sendMessageBtn.disabled = false;
sendMessageBtn.textContent = '发送消息到当前会话';
}
});
});
} else {
console.error('未找到发送消息按钮!');
}
});
</script>
<footer>
<p>企业微信 JSSDK 测试 - 开发环境</p>
</footer>
</body>
</html>