diff --git a/.gitignore b/.gitignore index 2c2dbeb..29a2577 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ dist-ssr *.njsproj *.sln *.sw? + +data \ No newline at end of file diff --git a/backend/.chat-glm.pid b/backend/.chat-glm.pid new file mode 100644 index 0000000..bebd571 --- /dev/null +++ b/backend/.chat-glm.pid @@ -0,0 +1 @@ +177144 diff --git a/backend/.env.example b/backend/.env.example index 3f207d7..b677dc0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/API_USAGE.md b/backend/API_USAGE.md new file mode 100644 index 0000000..600bc18 --- /dev/null +++ b/backend/API_USAGE.md @@ -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. 错误重试机制 diff --git a/backend/app/deps/auth_deps.py b/backend/app/deps/auth_deps.py new file mode 100644 index 0000000..2f1e7e7 --- /dev/null +++ b/backend/app/deps/auth_deps.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3e785b9 --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..a59252f --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +from .base import BaseOut +from .message import Message + +models = [Message] + +# 在所有模型导入后调用 model_rebuild 来解决循环导入问题 +# Message.model_rebuild() diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..ebdf238 --- /dev/null +++ b/backend/app/models/base.py @@ -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 diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..5effc2b --- /dev/null +++ b/backend/app/models/message.py @@ -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 diff --git a/backend/app/routes/auth_routes.py b/backend/app/routes/auth_routes.py new file mode 100644 index 0000000..dbb5e36 --- /dev/null +++ b/backend/app/routes/auth_routes.py @@ -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)}" + } diff --git a/backend/main.py b/backend/app/routes/glm_routes.py similarity index 89% rename from backend/main.py rename to backend/app/routes/glm_routes.py index b3c1941..277e259 100644 --- a/backend/main.py +++ b/backend/app/routes/glm_routes.py @@ -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) diff --git a/backend/app/routes/message_routes.py b/backend/app/routes/message_routes.py new file mode 100644 index 0000000..a476dbc --- /dev/null +++ b/backend/app/routes/message_routes.py @@ -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("消息服务已关闭") diff --git a/backend/app/routes/wework_routes.py b/backend/app/routes/wework_routes.py new file mode 100644 index 0000000..7b945e0 --- /dev/null +++ b/backend/app/routes/wework_routes.py @@ -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("企业微信服务已关闭") diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..6cfba7f --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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) diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py new file mode 100644 index 0000000..1d2923e --- /dev/null +++ b/backend/app/services/message_service.py @@ -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 diff --git a/backend/app/services/wework_service.py b/backend/app/services/wework_service.py new file mode 100644 index 0000000..83142b0 --- /dev/null +++ b/backend/app/services/wework_service.py @@ -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() diff --git a/backend/cert/1_rsa.key b/backend/cert/1_rsa.key new file mode 100644 index 0000000..75af0c3 --- /dev/null +++ b/backend/cert/1_rsa.key @@ -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----- \ No newline at end of file diff --git a/backend/cert/2_rsa.key b/backend/cert/2_rsa.key new file mode 100644 index 0000000..bd2e13f --- /dev/null +++ b/backend/cert/2_rsa.key @@ -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----- diff --git a/backend/cert/2_rsa_pub.key b/backend/cert/2_rsa_pub.key new file mode 100644 index 0000000..8e583db --- /dev/null +++ b/backend/cert/2_rsa_pub.key @@ -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----- diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index 6d3b3c5..0000000 --- a/backend/config.py +++ /dev/null @@ -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" diff --git a/backend/data.json b/backend/data.json new file mode 100644 index 0000000..18988de --- /dev/null +++ b/backend/data.json @@ -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 + } +} \ No newline at end of file diff --git a/backend/lib/sdk/WeWorkFinanceSdk_C.h b/backend/lib/sdk/WeWorkFinanceSdk_C.h new file mode 100644 index 0000000..2838ce8 --- /dev/null +++ b/backend/lib/sdk/WeWorkFinanceSdk_C.h @@ -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 diff --git a/backend/lib/sdk/libWeWorkFinanceSdk_C.so b/backend/lib/sdk/libWeWorkFinanceSdk_C.so new file mode 100644 index 0000000..00c1b5c Binary files /dev/null and b/backend/lib/sdk/libWeWorkFinanceSdk_C.so differ diff --git a/backend/lib/sdk/md5.txt b/backend/lib/sdk/md5.txt new file mode 100644 index 0000000..62a932b --- /dev/null +++ b/backend/lib/sdk/md5.txt @@ -0,0 +1 @@ +d422224e67eb07a195d1d7115b39284c diff --git a/backend/lib/sdk/sample.cpp b/backend/lib/sdk/sample.cpp new file mode 100644 index 0000000..035613f --- /dev/null +++ b/backend/lib/sdk/sample.cpp @@ -0,0 +1,142 @@ +#include "WeWorkFinanceSdk_C.h" +#include +#include +#include +#include +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; +} \ No newline at end of file diff --git a/backend/lib/sdk/tool_testSdk.cpp b/backend/lib/sdk/tool_testSdk.cpp new file mode 100644 index 0000000..e214545 --- /dev/null +++ b/backend/lib/sdk/tool_testSdk.cpp @@ -0,0 +1,148 @@ +#include "WeWorkFinanceSdk_C.h" +#include +#include +#include +#include +#include +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 ΪÀ­ȡ»Ự´浵µij¬ʱʱ¼䣬µ¥λΪÃ룬½¨Ò鳬ʱʱ¼äÉèÖÃΪ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; +} diff --git a/backend/lib/sdk/version.txt b/backend/lib/sdk/version.txt new file mode 100644 index 0000000..ab2ff39 --- /dev/null +++ b/backend/lib/sdk/version.txt @@ -0,0 +1 @@ +20240606 diff --git a/backend/lib/sdk_20240606.tgz b/backend/lib/sdk_20240606.tgz new file mode 100644 index 0000000..4042b52 Binary files /dev/null and b/backend/lib/sdk_20240606.tgz differ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0c1dabd..cd4c2c7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/backend/scripts/decrypt_chat_data.py b/backend/scripts/decrypt_chat_data.py new file mode 100644 index 0000000..c0e1dfd --- /dev/null +++ b/backend/scripts/decrypt_chat_data.py @@ -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()) diff --git a/backend/scripts/load_chat_data.py b/backend/scripts/load_chat_data.py new file mode 100644 index 0000000..0fdc0de --- /dev/null +++ b/backend/scripts/load_chat_data.py @@ -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()) diff --git a/backend/scripts/wework_ctypes.py b/backend/scripts/wework_ctypes.py new file mode 100644 index 0000000..0ad4e00 --- /dev/null +++ b/backend/scripts/wework_ctypes.py @@ -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}") diff --git a/backend/scripts/wework_sdk.py b/backend/scripts/wework_sdk.py new file mode 100644 index 0000000..c2e379e --- /dev/null +++ b/backend/scripts/wework_sdk.py @@ -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()) diff --git a/backend/serv.sh b/backend/serv.sh new file mode 100755 index 0000000..dcbfa0a --- /dev/null +++ b/backend/serv.sh @@ -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 "$@" diff --git a/backend/tests/test_wework_sdk.py b/backend/tests/test_wework_sdk.py new file mode 100644 index 0000000..6e71c61 --- /dev/null +++ b/backend/tests/test_wework_sdk.py @@ -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) diff --git a/backend/uv.lock b/backend/uv.lock index 3d72e81..7c43726 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] diff --git a/frontend/.env.example b/frontend/.env.example index d3d9d43..6a25067 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 diff --git a/frontend/index.html b/frontend/index.html index c20fbd3..07175d1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,14 @@ - + - - frontend + + + + + 兜爸心选 - 企业微信智能客服 +
diff --git a/frontend/package.json b/frontend/package.json index d0d8533..c2c7bd9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1b65b0a..7b235f1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4931b73..9bc6068 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
{/* 顶部导航栏 */} -
+
+ {/* 移动端菜单按钮 */} + +
🤖
-
-

兜爸 AI 聊天助手

-

我是{roleConfig.name},很高兴为您服务

+
+

兜爸心选 - 企业微信智能客服

+

+ 自动获取客户消息会话与标签,生成个性化智能回复 +

+
+
+

智能客服

- + {/* 移动端操作按钮 */} +
+ +
- {/* 聊天区域 */} -
-
- {messages.length === 0 ? ( - // 欢迎页面 -
-
-
- 👋 -
-

欢迎使用, 兜爸 AI 聊天助手

-

- 我是{roleConfig.name},一位亲切温暖的宝妈客服。有任何关于育儿、选品、团购的问题,都可以随时问我哦~ -

-
-

- 💡 提示:开始对话吧,我会以亲切温暖的风格为您解答问题 -

-
+ {/* 状态栏 */} +
+
+
+
+
+
+ + {weCom.sdkReady ? '企业微信 SDK 已就绪' : '企业微信 SDK 未就绪'} + +
+ +
+
+ + {workflowStatus.isRunning ? '工作流执行中...' : workflowStatus.result ? '工作流已完成' : '工作流待执行'} +
- ) : ( - // 聊天消息列表 -
- {messages.map((message, index) => ( -
-
-
- {message.content} -
-
- {message.timestamp} -
-
-
- ))} - - {/* 加载状态 */} - {isLoading && ( -
-
-
-
-
-
-
-
-
- )} - -
+ +
+ + 执行次数: {executionCount} +
- )} +
-
+
- {/* 输入区域 */} -