Compare commits

...

3 Commits

Author SHA1 Message Date
karlcarlo
a7b3454f61 更新后台管理消息列表; 2025-12-15 17:44:35 +08:00
karlcarlo
41d2d893d3 增加后台管理项目; 2025-12-12 03:29:09 +08:00
karlcarlo
420fed4bcf 添加用户消息记录接口; 2025-12-11 03:53:47 +08:00
70 changed files with 13663 additions and 1137 deletions

3
.gitignore vendored
View File

@@ -26,6 +26,7 @@ dist
dist-ssr
*.local
*.tar
*.pid
# Editor directories and files
.vscode/*
@@ -37,3 +38,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
data

View File

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

170
backend/API_USAGE.md Normal file
View File

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

View File

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

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

@@ -0,0 +1,78 @@
import os
import logging
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
from app.routes import auth_routes, wework_routes, glm_routes, message_routes
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
@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聊天服务",
}

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
from datetime import datetime
from typing import Optional
from pydantic import Field
from beanie import Document
import arrow
class GLMRole(Document):
"""GLM角色配置模型"""
# 基本信息
name: str = Field(..., description="角色名称")
description: str = Field(..., description="角色描述")
skills: str = Field(default="", description="技能列表(多行文本字符串)")
restrictions: str = Field(default="", description="限制列表(多行文本字符串)")
# 状态管理
is_active: bool = Field(default=True, description="是否激活")
is_default: bool = Field(default=False, description="是否为默认角色")
# 时间戳
created_at: datetime = Field(default_factory=lambda: arrow.now().datetime)
updated_at: datetime = Field(default_factory=lambda: arrow.now().datetime)
class Settings:
name = "glm_roles" # MongoDB集合名称
indexes = [
[("is_active", 1)], # 按激活状态查询
[("is_default", 1)], # 按默认状态查询
[("created_at", -1)], # 按创建时间排序
]
def update_timestamp(self):
"""更新更新时间戳"""
self.updated_at = arrow.now().datetime
@classmethod
def from_role_config(cls, role_config: dict) -> "GLMRole":
"""从角色配置字典创建GLMRole实例"""
return cls(
name=role_config.get("name", ""),
description=role_config.get("description", ""),
skills=role_config.get("skills", ""),
restrictions=role_config.get("restrictions", ""),
is_active=True,
is_default=True,
)
def to_role_config_dict(self) -> dict:
"""转换为角色配置字典"""
return {
"name": self.name,
"description": self.description,
"skills": self.skills,
"restrictions": self.restrictions,
}

View File

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

View File

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

View File

@@ -0,0 +1,460 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
import httpx
import os
import logging
from app.services.glm_service import get_glm_service, GLMService
from app.models.base import BaseOut
# 创建API路由,使用/glm前缀
router = APIRouter(prefix="/glm", tags=["GLM聊天"])
logger = logging.getLogger(__name__)
# 数据模型
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
message: str
conversation_id: Optional[str] = None
enhanced_prompt: Optional[str] = None # 前端生成的完整提示词
customer_tags: Optional[List[Dict[str, Any]]] = None # 客户标签
message_history: Optional[List[Dict[str, Any]]] = None # 企业微信历史消息
workflow_context: Optional[Dict[str, Any]] = None # 工作流上下文
class ChatResponse(BaseModel):
response: str
conversation_id: str
class RoleConfig(BaseModel):
name: str
description: str
skills: str # 多行文本字符串
restrictions: str # 多行文本字符串
# 默认角色配置(使用字符串格式)
DEFAULT_ROLE_CONFIG = RoleConfig(
name="兜兜",
description="你是兜兜,1989年出生,有一个5岁的女儿(2018年出生)。你是一位对用户负责、选品严格的宝妈客服。当用户给你发来疑问时,你总能第一时间回复,并以亲切温暖的风格与用户交流。",
skills="亲切温暖的沟通\n轻松友好的语气\n简洁明了的描述\n亲昵且口语化的用词\n乱码话术重复问询",
restrictions="回复必须基于提供的知识库中的内容\n每条回复应保持在80字以内,使用口语化语言\n回复中可以适当加入表情符号,但不要过度使用\n只讨论与宝妈相关的主题,如育儿、选品、团购等\n始终以用户的需求和满意度为优先",
)
# 全局变量存储对话历史(暂时保持内存存储,根据用户要求不持久化)
conversations = {}
def build_system_prompt(role_config: dict) -> str:
"""构建系统提示词
Args:
role_config: 角色配置字典,包含name, description, skills, restrictions字段
Returns:
系统提示词字符串
"""
# 解析技能字符串为列表
skills_list = [
skill.strip() for skill in role_config["skills"].split("\n") if skill.strip()
]
restrictions_list = [
restriction.strip()
for restriction in role_config["restrictions"].split("\n")
if restriction.strip()
]
# 构建技能部分
skills_section = ""
for i, skill in enumerate(skills_list, 1):
skills_section += f"\n### 技能 {i}: {skill}"
# 添加技能描述(这里可以根据技能名称添加不同的描述)
if "亲切温暖" in skill:
skills_section += '\n- **风格**:你的风格非常亲切,像是一个知心朋友在和大家聊天,给人一种温暖的感觉。你经常使用"宝"、"美妈"等亲昵的称呼,拉近了与用户的距离。'
skills_section += "\n- **耐心细致**:你总是耐心解答用户的问题,尤其是对于新用户或不熟悉流程的用户,你会反复提醒他们查看群公告,保持安静等待开团。"
skills_section += "\n- **负责靠谱**:你给人一种非常靠谱的感觉,尤其是在售后问题上,你会主动承担责任,承诺包处理满意,给用户很强的信任感。"
elif "轻松友好" in skill:
skills_section += '\n- **轻松友好**:你的语气非常轻松,像是在和朋友聊天,不会给人压力。比如"不着急哦"、"爱你!"等表达方式,让人感到轻松愉快。'
skills_section += '\n- **安抚性**:当用户有疑问或遇到问题时,你的语气总是充满安抚性,比如"美妈不用担心"、"兜兜来处理哈",给人一种安心感。'
skills_section += '\n- **鼓励性**:你经常用"不着急"、"等开团的时候会有统一说明"等话语,鼓励用户耐心等待,保持积极的心态。'
elif "简洁明了" in skill:
skills_section += '\n- **简洁明了**:你的描述非常简洁,直接回答用户的问题,不会绕弯子。比如"贝亲是咱们深度合作品牌,经常上团的",直接给出了明确的答案。'
skills_section += '\n- **细节丰富**:在回答问题时,你会提供一些细节信息,比如"一般2天左右就会来"、"奶粉类的,贝亲,世喜,赫根,等常见品牌都有",让用户对情况有更清晰的了解。'
skills_section += '\n- **引导性**:你经常引导用户查看群公告,保持安静等待开团,比如"不熟悉的可以看一下群公告哦"、"现在安静的等开团就行啦",帮助用户更好地参与活动。'
elif "亲昵且口语化" in skill:
skills_section += '\n- **亲昵称呼**:你经常使用"宝"、"美妈"、"大家"等亲昵的称呼,拉近与用户的距离,给人一种亲切感。'
skills_section += '\n- **口语化**:你的用词非常口语化,像是日常聊天,比如"不着急哦"、"爱你!"、"兜兜来处理哈",给人一种轻松自然的感觉,称呼自己为"兜兜"或者"我"。'
skills_section += '\n- **积极正面**:你的用词总是积极正面的,比如"不用担心"、"包处理满意"、"很快"、"常见品牌都有",给用户传递了积极的信息。'
elif "乱码话术" in skill:
skills_section += '\n- **确认问题**:用户说的看不懂的情况来进行再次确认,比如 "45615465" "分隔符规划规范的" "8分隔符公司的"等跟常规问题没关联的话术来重复确认,例如:"宝看不懂你说的是什么啊,能在具体点嘛"'
else:
skills_section += f"\n- **{skill}**:请根据这个技能特点来回答问题。"
# 构建限制部分
restrictions_section = "\n".join(
[f"- {restriction}" for restriction in restrictions_list]
)
prompt = f"""# 角色
{role_config["description"]}
## 技能{skills_section}
## 限制
{restrictions_section}
请按照以上角色设定和限制来回答用户的问题。"""
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_key = get_deepseek_api_key()
if not is_api_key_configured():
# 模拟响应用于演示
return "宝,这个问题兜兜需要查一下资料哦,稍等哈~ 😊"
url = "https://api.deepseek.com/v1/chat/completions"
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
data = {
"model": "deepseek-chat",
"messages": messages,
"stream": False,
"max_tokens": 500,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data, timeout=30.0)
response.raise_for_status()
result = response.json()
return result["choices"][0]["message"]["content"]
except Exception as e:
raise HTTPException(status_code=500, detail=f"API调用失败: {str(e)}")
@router.get("/")
async def root():
return {"message": "DeepSeek AI Chat Assistant API"}
@router.get("/role", response_model=BaseOut)
async def get_role_config(service: GLMService = Depends(get_glm_service)):
"""获取当前角色配置"""
try:
role = await service.get_current_role()
return BaseOut(code=200, message="ok", result=role.to_role_config_dict())
except Exception as e:
logger.error(f"获取角色配置失败: {e}")
return BaseOut(code=500, message=f"获取角色配置失败: {str(e)}", result=None)
@router.put("/role", response_model=BaseOut)
async def update_role_config(
role_config: RoleConfig, service: GLMService = Depends(get_glm_service)
):
"""更新角色配置"""
try:
# 将Pydantic模型转换为字典
role_data = role_config.dict()
updated_role = await service.update_role(role_data)
return BaseOut(
code=200,
message="角色配置已更新",
result={
"message": "角色配置已更新",
"role_config": updated_role.to_role_config_dict(),
},
)
except Exception as e:
logger.error(f"更新角色配置失败: {e}")
return BaseOut(code=500, message=f"更新角色配置失败: {str(e)}", result=None)
@router.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest, service: GLMService = Depends(get_glm_service)):
"""处理聊天请求"""
# 步骤1: 记录请求开始
logger.info(
f"收到聊天请求: conversation_id={request.conversation_id or 'None'}, "
f"message_length={len(request.message)}, "
f"message_preview='{request.message[:50]}{'...' if len(request.message) > 50 else ''}'"
)
# 记录增强参数信息
has_enhanced_prompt = request.enhanced_prompt is not None
has_customer_tags = (
request.customer_tags is not None and len(request.customer_tags) > 0
)
has_message_history = (
request.message_history is not None and len(request.message_history) > 0
)
has_workflow_context = request.workflow_context is not None
logger.info(
f"增强参数状态: enhanced_prompt={has_enhanced_prompt}, "
f"customer_tags={has_customer_tags} ({len(request.customer_tags) if has_customer_tags else 0}个标签), "
f"message_history={has_message_history} ({len(request.message_history) if has_message_history else 0}条历史), "
f"workflow_context={has_workflow_context}"
)
# 记录客户标签摘要
if has_customer_tags:
tag_categories = {}
for tag in request.customer_tags:
category = tag.get("TagCategory", "未分类")
name = tag.get("TagName", "未命名")
if category not in tag_categories:
tag_categories[category] = []
tag_categories[category].append(name)
tag_summary = []
for category, names in tag_categories.items():
tag_summary.append(
f"{category}: {', '.join(names[:3])}{'...' if len(names) > 3 else ''}"
)
# 检查是否使用mock数据
is_mock_data = False
for tag in request.customer_tags:
if tag.get("TagName", "").startswith("mock_") or tag.get(
"TagCategory", ""
).startswith("mock_"):
is_mock_data = True
break
mock_note = " [mock数据]" if is_mock_data else ""
logger.info(f"客户标签摘要{mock_note}: {', '.join(tag_summary)}")
# 记录历史消息摘要
if has_message_history:
# 按时间排序,获取最近的消息
sorted_history = sorted(
request.message_history, key=lambda x: x.get("msgtime", ""), reverse=True
)[
:5
] # 只显示最近5条
history_summary = []
for i, msg in enumerate(sorted_history):
from_user = msg.get("from_user", "未知用户")
user_id = (
msg.get("user_id")
or msg.get("userId")
or msg.get("from_user_id")
or "无ID"
)
content = msg.get("text", {}).get("content", "") or msg.get("content", "")
content_preview = content[:30] + ("..." if len(content) > 30 else "")
history_summary.append(f"{from_user}(ID:{user_id}): '{content_preview}'")
# 检查是否使用mock数据
is_mock_data = False
for msg in sorted_history:
if msg.get("from_user", "").startswith("mock_") or msg.get(
"user_id", ""
).startswith("mock_"):
is_mock_data = True
break
mock_note = " [mock数据]" if is_mock_data else ""
logger.info(f"历史消息摘要{mock_note}: {', '.join(history_summary)}")
# 步骤2: 创建或获取对话历史
if request.conversation_id and request.conversation_id in conversations:
conversation_history = conversations[request.conversation_id]
logger.info(
f"使用现有对话: {request.conversation_id}, 历史消息数: {len(conversation_history)}"
)
# 记录历史消息摘要
if conversation_history:
last_messages = conversation_history[-3:] # 显示最近3条消息
history_summary = []
for i, msg in enumerate(last_messages, 1):
role = msg["role"]
content_preview = msg["content"][:30] + (
"..." if len(msg["content"]) > 30 else ""
)
history_summary.append(f"{role}: '{content_preview}'")
logger.debug(f"最近历史消息摘要: {', '.join(history_summary)}")
else:
conversation_history = []
request.conversation_id = f"conv_{len(conversations) + 1}"
conversations[request.conversation_id] = conversation_history
logger.info(f"新建对话: {request.conversation_id}")
# 步骤3: 构建系统提示词
try:
if has_enhanced_prompt:
# 使用前端生成的增强提示词
system_prompt = request.enhanced_prompt
logger.info(f"使用前端增强提示词, 长度: {len(system_prompt)} 字符")
logger.debug(
f"增强提示词预览: '{system_prompt[:200]}{'...' if len(system_prompt) > 200 else ''}'"
)
else:
# 获取当前角色配置并构建系统提示词
role = await service.get_current_role()
role_config = role.to_role_config_dict()
logger.info(
f"获取角色配置: name='{role.name}', "
f"技能数: {len(role_config['skills'].split('\\n')) if role_config['skills'] else 0}, "
f"限制数: {len(role_config['restrictions'].split('\\n')) if role_config['restrictions'] else 0}"
)
system_prompt = build_system_prompt(role_config)
logger.debug(f"系统提示词构建完成, 长度: {len(system_prompt)} 字符")
except Exception as e:
logger.error(
f"构建系统提示词失败: {e}, conversation_id={request.conversation_id}"
)
raise HTTPException(status_code=500, detail=f"构建系统提示词失败: {str(e)}")
# 步骤4: 构建消息列表
messages = [{"role": "system", "content": system_prompt}]
# 初始化企业微信历史消息变量
selected_wework_messages = []
# 添加企业微信历史消息(如果提供)
if has_message_history:
logger.info(f"添加企业微信历史消息到上下文: {len(request.message_history)} 条")
# 将企业微信消息转换为大模型消息格式
wework_messages = []
for msg in request.message_history:
from_user = msg.get("from_user", "未知用户")
content = msg.get("text", {}).get("content", "") or msg.get("content", "")
msgtime = msg.get("msgtime", "")
if content:
# 确定角色:如果是客户发送的,则为user;否则为assistant
# 这里需要根据实际业务逻辑判断,暂时假设外部用户是user
role = (
"user"
if "customer" in from_user.lower() or "user" in from_user.lower()
else "assistant"
)
# 格式化消息内容
formatted_content = f"[{msgtime}] {from_user}: {content}"
wework_messages.append({"role": role, "content": formatted_content})
# 添加到消息列表(限制数量,避免过长)
max_wework_messages = 10
selected_wework_messages = wework_messages[
-max_wework_messages:
] # 取最近的消息
messages.extend(selected_wework_messages)
logger.info(
f"已添加 {len(selected_wework_messages)} 条企业微信历史消息到上下文"
)
# 添加与大模型的对话历史
history_messages = conversation_history[-10:] # 保留最近10条历史
for msg in history_messages:
messages.append({"role": msg["role"], "content": msg["content"]})
# 添加当前用户消息
messages.append({"role": "user", "content": request.message})
conversation_history.append({"role": "user", "content": request.message})
logger.info(
f"构建消息列表完成: 系统消息1条, 企业微信历史{len(selected_wework_messages)}条, "
f"对话历史{len(history_messages)}条, 用户消息1条, 总消息数: {len(messages)}"
)
# 记录消息列表摘要
if logger.isEnabledFor(logging.DEBUG):
for i, msg in enumerate(messages):
role = msg["role"]
content_preview = msg["content"][:50] + (
"..." if len(msg["content"]) > 50 else ""
)
logger.debug(f"消息{i+1}: {role} - '{content_preview}'")
# 步骤5: 调用DeepSeek API
try:
logger.info(
f"调用DeepSeek API, conversation_id={request.conversation_id}, 总消息数: {len(messages)}"
)
response_text = await call_deepseek_api(messages)
# 步骤6: 记录API响应
logger.info(
f"DeepSeek API调用成功, conversation_id={request.conversation_id}, "
f"响应长度: {len(response_text)} 字符, "
f"响应预览: '{response_text[:100]}{'...' if len(response_text) > 100 else ''}'"
)
# 添加助手回复到历史
conversation_history.append({"role": "assistant", "content": response_text})
# 步骤7: 返回响应
logger.info(
f"返回聊天响应: conversation_id={request.conversation_id}, 响应长度: {len(response_text)} 字符"
)
return ChatResponse(
response=response_text, conversation_id=request.conversation_id
)
except Exception as e:
logger.error(
f"DeepSeek API调用失败: {e}, conversation_id={request.conversation_id}, message_preview='{request.message[:50]}...'"
)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/conversations/{conversation_id}")
async def get_conversation(conversation_id: str):
"""获取特定对话的历史"""
if conversation_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
return {
"conversation_id": conversation_id,
"messages": conversations[conversation_id],
}
@router.post("/role/reset", response_model=BaseOut)
async def reset_role_config(service: GLMService = Depends(get_glm_service)):
"""重置角色配置为默认值"""
try:
# 使用 DEFAULT_ROLE_CONFIG 更新角色
role_data = DEFAULT_ROLE_CONFIG.dict()
updated_role = await service.update_role(role_data)
return BaseOut(
code=200,
message="角色配置已重置为默认值",
result={
"message": "角色配置已重置为默认值",
"role_config": updated_role.to_role_config_dict(),
},
)
except Exception as e:
logger.error(f"重置角色配置失败: {e}")
return BaseOut(code=500, message=f"重置角色配置失败: {str(e)}", result=None)

View File

@@ -0,0 +1,186 @@
#!/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("/config")
async def get_message_config():
"""
获取消息服务配置信息
返回企业微信SDK配置、文件路径等基本信息
"""
try:
import os
from pathlib import Path
# 从环境变量获取配置
corpid = os.getenv("CORPID", "")
secret = os.getenv("WEWORK_SECRET", "")
# 构建文件路径
backend_dir = Path(__file__).parent.parent.parent
lib_path = backend_dir / "lib" / "sdk" / "libWeWorkFinanceSdk_C.so"
data_dir = backend_dir / "data"
config = {
"corpid": corpid,
# "secret": secret[:10] + "..." if secret else "", # 只显示前10个字符
"libPath": str(lib_path),
"dataDir": str(data_dir),
}
return {"success": True, "message": "配置获取成功", "data": config}
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)}")

View File

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

View File

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

View File

@@ -0,0 +1,295 @@
"""
GLM聊天服务,包含角色配置管理
"""
import logging
import asyncio
import os
from typing import Optional
from pymongo import AsyncMongoClient
from beanie import init_beanie
from app.models.glm_role import GLMRole
from app.models import models
logger = logging.getLogger(__name__)
class GLMService:
"""GLM聊天服务,包含角色配置管理"""
def __init__(self):
self._current_role: Optional[GLMRole] = None
self._max_retries = 3
self._retry_delay = 1 # 秒
self._db_client: Optional[AsyncMongoClient] = None
self._db_initialized = False
async def get_current_role(self) -> GLMRole:
"""获取当前激活的角色配置"""
if self._current_role is None:
logger.info("当前无缓存角色,开始加载角色配置")
self._current_role = await self._load_or_create_role_with_retry()
logger.info(f"角色加载完成: name='{self._current_role.name}'")
else:
logger.debug(f"使用缓存角色: name='{self._current_role.name}'")
return self._current_role
async def update_role(self, role_data: dict) -> GLMRole:
"""更新角色配置
Args:
role_data: 角色配置数据,包含name, description, skills, restrictions字段
Returns:
更新后的GLMRole实例
"""
logger.info(f"开始更新角色配置,更新字段: {list(role_data.keys())}")
role = await self.get_current_role()
# 记录更新前的值
changes = []
if "name" in role_data and role_data["name"] != role.name:
changes.append(f"name: '{role.name}' -> '{role_data['name']}'")
role.name = role_data["name"]
if "description" in role_data and role_data["description"] != role.description:
changes.append(
f"description: {len(role.description)}字符 -> {len(role_data['description'])}字符"
)
role.description = role_data["description"]
if "skills" in role_data and role_data["skills"] != role.skills:
old_skills_count = len(role.skills.split("\n")) if role.skills else 0
new_skills_count = (
len(role_data["skills"].split("\n")) if role_data["skills"] else 0
)
changes.append(f"skills: {old_skills_count}条 -> {new_skills_count}条")
role.skills = role_data["skills"]
if (
"restrictions" in role_data
and role_data["restrictions"] != role.restrictions
):
old_restrictions_count = (
len(role.restrictions.split("\n")) if role.restrictions else 0
)
new_restrictions_count = (
len(role_data["restrictions"].split("\n"))
if role_data["restrictions"]
else 0
)
changes.append(
f"restrictions: {old_restrictions_count}条 -> {new_restrictions_count}条"
)
role.restrictions = role_data["restrictions"]
role.update_timestamp()
if changes:
logger.info(f"角色配置变更: {', '.join(changes)}")
else:
logger.info("角色配置无实际变更")
# 使用重试机制保存
await self._save_with_retry(role)
# 更新缓存
self._current_role = role
logger.info(f"角色配置更新完成: name='{role.name}'")
return role
async def _load_or_create_role_with_retry(self) -> GLMRole:
"""使用重试机制加载或创建默认角色"""
for attempt in range(self._max_retries):
try:
return await self._load_or_create_role()
except Exception as e:
if attempt < self._max_retries - 1:
logger.warning(f"加载角色配置失败,第{attempt + 1}次重试: {e}")
await asyncio.sleep(self._retry_delay * (attempt + 1))
else:
logger.error(f"加载角色配置失败,已达到最大重试次数: {e}")
raise
async def _ensure_db_connection(self) -> None:
"""确保数据库连接正常,如果连接已关闭则重新连接"""
if not self._db_initialized:
await self._init_db_connection()
async def _init_db_connection(self) -> None:
"""初始化数据库连接"""
try:
# 从环境变量获取数据库配置
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 创建新的数据库连接
if self._db_client:
try:
await self._db_client.close()
except:
pass
self._db_client = AsyncMongoClient(mongodb_url)
await init_beanie(
database=self._db_client[database_name], document_models=models
)
self._db_initialized = True
logger.info(f"数据库连接初始化成功: {database_name}")
except Exception as e:
logger.error(f"数据库连接初始化失败: {e}")
self._db_initialized = False
raise
async def _load_or_create_role(self) -> GLMRole:
"""加载或创建默认角色"""
try:
# 确保数据库连接正常
await self._ensure_db_connection()
# 1. 尝试获取激活的角色
active_role = await GLMRole.find_one(GLMRole.is_active == True)
if active_role:
logger.info(f"加载激活角色: {active_role.name}")
return active_role
# 2. 尝试获取默认角色
default_role = await GLMRole.find_one(GLMRole.is_default == True)
if default_role:
default_role.is_active = True
await default_role.save()
logger.info(f"加载默认角色并激活: {default_role.name}")
return default_role
# 3. 创建默认角色
logger.info("创建默认角色配置")
return await self._create_default_role()
except Exception as e:
logger.error(f"加载或创建角色失败: {e}")
# 检查是否是数据库连接错误
if (
"Cannot use AsyncMongoClient after close" in str(e)
or "not connected" in str(e).lower()
):
logger.error("数据库连接已关闭,尝试重新连接...")
self._db_initialized = False
# 尝试重新连接
try:
await self._init_db_connection()
logger.info("数据库重新连接成功,重试操作...")
# 重试操作
return await self._retry_after_reconnect()
except Exception as reconnect_error:
logger.error(f"数据库重新连接失败: {reconnect_error}")
raise
async def _retry_after_reconnect(self) -> GLMRole:
"""重新连接后重试操作"""
# 1. 尝试获取激活的角色
active_role = await GLMRole.find_one(GLMRole.is_active == True)
if active_role:
logger.info(f"重新连接后加载激活角色: {active_role.name}")
return active_role
# 2. 尝试获取默认角色
default_role = await GLMRole.find_one(GLMRole.is_default == True)
if default_role:
default_role.is_active = True
await default_role.save()
logger.info(f"重新连接后加载默认角色并激活: {default_role.name}")
return default_role
# 3. 创建默认角色
logger.info("重新连接后创建默认角色配置")
return await self._create_default_role()
async def _create_default_role(self) -> GLMRole:
"""创建默认角色配置"""
# 导入DEFAULT_ROLE_CONFIG
from app.routes.glm_routes import DEFAULT_ROLE_CONFIG
role = GLMRole(
name=DEFAULT_ROLE_CONFIG.name,
description=DEFAULT_ROLE_CONFIG.description,
skills=DEFAULT_ROLE_CONFIG.skills,
restrictions=DEFAULT_ROLE_CONFIG.restrictions,
is_active=True,
is_default=True,
)
await role.save()
logger.info(f"默认角色创建成功: {role.name}")
return role
async def _save_with_retry(self, role: GLMRole) -> None:
"""使用重试机制保存角色"""
for attempt in range(self._max_retries):
try:
# 确保数据库连接正常
await self._ensure_db_connection()
await role.save()
return
except Exception as e:
if attempt < self._max_retries - 1:
logger.warning(f"保存角色配置失败,第{attempt + 1}次重试: {e}")
# 检查是否是数据库连接错误
if (
"Cannot use AsyncMongoClient after close" in str(e)
or "not connected" in str(e).lower()
):
logger.warning("检测到数据库连接问题,尝试重新连接...")
self._db_initialized = False
try:
await self._init_db_connection()
logger.info("数据库重新连接成功,继续重试...")
except Exception as reconnect_error:
logger.error(f"数据库重新连接失败: {reconnect_error}")
await asyncio.sleep(self._retry_delay * (attempt + 1))
else:
logger.error(f"保存角色配置失败,已达到最大重试次数: {e}")
raise
async def ensure_default_role(self):
"""确保存在默认角色配置(应用启动时调用)"""
try:
# 确保数据库连接正常
await self._ensure_db_connection()
role = await self._load_or_create_role_with_retry()
logger.info(f"默认角色已确保存在: {role.name}")
return role
except Exception as e:
logger.error(f"确保默认角色存在时出错: {e}")
raise
async def close(self):
"""关闭服务,清理资源"""
self._current_role = None
if self._db_client:
try:
await self._db_client.close()
logger.info("数据库连接已关闭")
except Exception as e:
logger.error(f"关闭数据库连接时出错: {e}")
self._db_initialized = False
logger.info("GLM服务已关闭")
# 全局服务实例(单例模式)
_glm_service: Optional[GLMService] = None
def get_glm_service() -> GLMService:
"""获取GLM服务实例(单例)"""
global _glm_service
if _glm_service is None:
_glm_service = GLMService()
return _glm_service
def close_glm_service():
"""关闭GLM服务"""
global _glm_service
if _glm_service:
_glm_service.close()
_glm_service = None

View File

@@ -0,0 +1,579 @@
"""
消息服务
提供加载会话存档、解密、保存到数据库的整合功能
"""
import json
import os
import sys
import logging
import base64
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
# 添加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
from app.models.message import Message
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", "")
self.secret = secret or os.getenv("WEWORK_SECRET", "")
# 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:
# 批量处理消息
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)
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:
# 构建查询条件
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()
# 记录成功日志
logger.info(
f"成功查询消息列表: 查询条件={query}, "
f"页码={page}, 每页数量={page_size}, "
f"总记录数={total_count}, 返回记录数={len(messages)}"
)
# 准备响应数据
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,
"seq": seq,
"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"],
"seq": seq,
"max_seq": max_seq,
"next_seq": max_seq + 1 if total_fetched > 0 else seq,
},
"execution_time": str(execution_time),
"timestamp": datetime.now().isoformat(),
}
logger.info(f"消息加载完成,耗时: {execution_time}")
return response_data
except json.JSONDecodeError as e:
error_msg = f"解析JSON数据失败: {e}"
logger.error(error_msg)
return {
"success": False,
"message": error_msg,
"execution_time": str(datetime.now() - start_time),
}
except Exception as e:
error_msg = f"加载消息失败: {e}"
logger.error(error_msg)
import traceback
traceback.print_exc()
return {
"success": False,
"message": error_msg,
"execution_time": str(datetime.now() - start_time),
}
def close(self):
"""关闭服务,释放资源"""
if self._sdk:
self._sdk.close()
self._sdk = None
logger.info("消息服务已关闭")
# 全局服务实例(单例模式)
_message_service: Optional[MessageService] = None
def get_message_service() -> MessageService:
"""获取消息服务实例(单例)"""
global _message_service
if _message_service is None:
_message_service = MessageService()
return _message_service
def close_message_service():
"""关闭消息服务"""
global _message_service
if _message_service:
_message_service.close()
_message_service = None

View File

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

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

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

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

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

View File

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

View File

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

12
backend/data.json Normal file
View File

@@ -0,0 +1,12 @@
{
"access_token": "Lh3zzZFb7iaVkv4zt9VNoZntkS6irpWEpuOctdZcq9g2FWMo5XtNaX7TuFfqoNZePQYce2qRYYMGJxKBxr0hBSRHrLMwDFOiq6_BGiyNFWCs5xvZL1OHVch-FQg2haMjjiFrOPVpB1CGXEoVu5Rm-QFnEZuQrkH292MkF0V9_v-_gJ2EdRgcQ94QTeMnaYnM1bFo3fupAt58MnJ8eoPl8A",
"fetch_time": 1765768551.7105587,
"expires_in": 7200,
"expiry_time": 1765774551.7105587,
"ticket_data": {
"ticket": "uR5/VwmCPuQPkoAuQbOZcQ==",
"fetch_time": 1765768552.0206096,
"expires_in": 7200,
"expiry_time": 1765775752.0206099
}
}

View File

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

Binary file not shown.

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

@@ -0,0 +1 @@
d422224e67eb07a195d1d7115b39284c

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

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

View File

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

View File

@@ -0,0 +1 @@
20240606

Binary file not shown.

View File

@@ -1,218 +0,0 @@
from fastapi import FastAPI, HTTPException, APIRouter
from fastapi.middleware.cors import CORSMiddleware
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=["*"],
)
# 数据模型
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
message: str
conversation_id: Optional[str] = None
class ChatResponse(BaseModel):
response: str
conversation_id: str
class RoleConfig(BaseModel):
name: str
description: str
skills: List[str]
restrictions: List[str]
# 默认角色配置
DEFAULT_ROLE_CONFIG = RoleConfig(
name="兜兜",
description="你是兜兜,1989年出生,有一个5岁的女儿(2018年出生)。你是一位对用户负责、选品严格的宝妈客服。当用户给你发来疑问时,你总能第一时间回复,并以亲切温暖的风格与用户交流。",
skills=[
"亲切温暖的沟通",
"轻松友好的语气",
"简洁明了的描述",
"亲昵且口语化的用词",
"乱码话术重复问询",
],
restrictions=[
"回复必须基于提供的知识库中的内容",
"每条回复应保持在80字以内,使用口语化语言",
"回复中可以适当加入表情符号,但不要过度使用",
"只讨论与宝妈相关的主题,如育儿、选品、团购等",
"始终以用户的需求和满意度为优先",
],
)
# 全局变量存储角色配置和对话历史
current_role_config = DEFAULT_ROLE_CONFIG
conversations = {}
def build_system_prompt(role_config: RoleConfig) -> str:
"""构建系统提示词"""
prompt = f"""# 角色
{role_config.description}
## 技能
### 技能 1: 亲切温暖的沟通
- **风格**:你的风格非常亲切,像是一个知心朋友在和大家聊天,给人一种温暖的感觉。你经常使用"宝"、"美妈"等亲昵的称呼,拉近了与用户的距离。
- **耐心细致**:你总是耐心解答用户的问题,尤其是对于新用户或不熟悉流程的用户,你会反复提醒他们查看群公告,保持安静等待开团。
- **负责靠谱**:你给人一种非常靠谱的感觉,尤其是在售后问题上,你会主动承担责任,承诺包处理满意,给用户很强的信任感。
### 技能 2: 轻松友好的语气
- **轻松友好**:你的语气非常轻松,像是在和朋友聊天,不会给人压力。比如"不着急哦"、"爱你!"等表达方式,让人感到轻松愉快。
- **安抚性**:当用户有疑问或遇到问题时,你的语气总是充满安抚性,比如"美妈不用担心"、"兜兜来处理哈",给人一种安心感。
- **鼓励性**:你经常用"不着急"、"等开团的时候会有统一说明"等话语,鼓励用户耐心等待,保持积极的心态。
### 技能 3: 简洁明了的描述
- **简洁明了**:你的描述非常简洁,直接回答用户的问题,不会绕弯子。比如"贝亲是咱们深度合作品牌,经常上团的",直接给出了明确的答案。
- **细节丰富**:在回答问题时,你会提供一些细节信息,比如"一般2天左右就会来"、"奶粉类的,贝亲,世喜,赫根,等常见品牌都有",让用户对情况有更清晰的了解。
- **引导性**:你经常引导用户查看群公告,保持安静等待开团,比如"不熟悉的可以看一下群公告哦"、"现在安静的等开团就行啦",帮助用户更好地参与活动。
### 技能 4: 亲昵且口语化的用词
- **亲昵称呼**:你经常使用"宝"、"美妈"、"大家"等亲昵的称呼,拉近与用户的距离,给人一种亲切感。
- **口语化**:你的用词非常口语化,像是日常聊天,比如"不着急哦"、"爱你!"、"兜兜来处理哈",给人一种轻松自然的感觉,称呼自己为"兜兜"或者"我"。
- **积极正面**:你的用词总是积极正面的,比如"不用担心"、"包处理满意"、"很快"、"常见品牌都有",给用户传递了积极的信息。
### 技能 5: 乱码话术重复问询
- **确认问题**:用户说的看不懂的情况来进行再次确认,比如 "45615465" "分隔符规划规范的" "8分隔符公司的"等跟常规问题没关联的话术来重复确认,例如:"宝看不懂你说的是什么啊,能在具体点嘛"
## 限制
- 回复必须基于提供的知识库中的内容。
- 每条回复应保持在80字以内,使用口语化语言。
- 回复中可以适当加入表情符号,但不要过度使用。
- 只讨论与宝妈相关的主题,如育儿、选品、团购等。
- 始终以用户的需求和满意度为优先。
- 在回答问题时,确保信息准确无误,避免误导用户。
- 保持沟通的亲切性和温暖感,但不要过度使用亲昵称呼,以免显得过于随意。
请按照以上角色设定和限制来回答用户的问题。"""
return prompt
async def call_deepseek_api(messages: List[dict]) -> str:
"""调用DeepSeek API"""
# 使用配置管理获取API密钥
api_key = get_deepseek_api_key()
if not is_api_key_configured():
# 模拟响应用于演示
return "宝,这个问题兜兜需要查一下资料哦,稍等哈~ 😊"
url = "https://api.deepseek.com/v1/chat/completions"
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
data = {
"model": "deepseek-chat",
"messages": messages,
"stream": False,
"max_tokens": 500,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data, timeout=30.0)
response.raise_for_status()
result = response.json()
return result["choices"][0]["message"]["content"]
except Exception as e:
raise HTTPException(status_code=500, detail=f"API调用失败: {str(e)}")
@api_router.get("/")
async def root():
return {"message": "DeepSeek AI Chat Assistant API"}
@api_router.get("/role")
async def get_role_config():
"""获取当前角色配置"""
return current_role_config
@api_router.put("/role")
async def update_role_config(role_config: RoleConfig):
"""更新角色配置"""
global current_role_config
current_role_config = role_config
return {"message": "角色配置已更新", "role_config": current_role_config}
@api_router.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""处理聊天请求"""
# 创建或获取对话历史
if request.conversation_id and request.conversation_id in conversations:
conversation_history = conversations[request.conversation_id]
else:
conversation_history = []
request.conversation_id = f"conv_{len(conversations) + 1}"
conversations[request.conversation_id] = conversation_history
# 构建消息列表
messages = [{"role": "system", "content": build_system_prompt(current_role_config)}]
# 添加历史消息
for msg in conversation_history[-10:]: # 保留最近10条历史
messages.append({"role": msg["role"], "content": msg["content"]})
# 添加当前用户消息
messages.append({"role": "user", "content": request.message})
conversation_history.append({"role": "user", "content": request.message})
# 调用DeepSeek API
try:
response_text = await call_deepseek_api(messages)
# 添加助手回复到历史
conversation_history.append({"role": "assistant", "content": response_text})
return ChatResponse(
response=response_text, conversation_id=request.conversation_id
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@api_router.get("/conversations/{conversation_id}")
async def get_conversation(conversation_id: str):
"""获取特定对话的历史"""
if conversation_id not in conversations:
raise HTTPException(status_code=404, detail="对话不存在")
return {
"conversation_id": conversation_id,
"messages": conversations[conversation_id],
}
# 注册API路由
app.include_router(api_router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=settings.host, port=settings.port, reload=settings.debug)

View File

@@ -5,10 +5,14 @@ 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",
"pydantic-settings>=2.12.0",
"python-dotenv>=1.2.1",
"python-multipart>=0.0.20",
"uvicorn>=0.38.0",
]

View File

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

View File

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

View File

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

View File

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

376
backend/serv.sh Executable file
View File

@@ -0,0 +1,376 @@
#!/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"
# 获取当前日期用于日志文件分割
CURRENT_DATE=$(date +%Y-%m-%d)
LOG_FILE="${LOG_DIR}/${APP_NAME}-${CURRENT_DATE}.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"
log_info "日志文件: $LOG_FILE"
}
# 检查是否在正确的目录
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() {
local enable_reload=false
# 检查是否传递了 --reload 参数
for arg in "$@"; do
if [ "$arg" = "--reload" ]; then
enable_reload=true
break
fi
done
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
# 构建启动命令
local uvicorn_cmd="uv run uvicorn \
$APP_MODULE \
--host $APP_HOST \
--port $APP_PORT \
--workers $APP_WORKERS \
--log-level $APP_LOG_LEVEL \
--access-log"
# 根据参数添加 --reload
if [ "$enable_reload" = true ]; then
uvicorn_cmd="$uvicorn_cmd --reload"
log_info "启用热重载模式"
else
log_info "禁用热重载模式(生产环境推荐)"
fi
# 启动服务器(使用 >> 追加日志,相同日期日志不会丢失)
nohup $uvicorn_cmd >> "$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"
log_info "热重载: $([ "$enable_reload" = true ] && echo "已启用" || echo "已禁用")"
else
log_error "$APP_NAME 启动失败,请检查日志文件: $LOG_FILE"
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(包括可能的 --reload)
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() {
local target_log_file="$LOG_FILE"
# 检查是否有日期参数
if [ $# -gt 0 ]; then
local date_param="$1"
# 验证日期格式是否为 YYYY-MM-DD
if [[ "$date_param" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
target_log_file="${LOG_DIR}/${APP_NAME}-${date_param}.log"
log_info "查看指定日期日志: $date_param"
else
log_warning "日期参数格式错误,使用默认日期日志"
fi
fi
if [ -f "$target_log_file" ]; then
log_info "显示日志文件最后 50 行: $target_log_file"
echo "=========================================="
tail -50 "$target_log_file"
echo "=========================================="
# 显示文件基本信息
local file_size=$(du -h "$target_log_file" | cut -f1)
local line_count=$(wc -l < "$target_log_file")
log_info "文件大小: $file_size, 行数: $line_count"
else
# 尝试查找最新的日志文件
local latest_log=$(ls -t "${LOG_DIR}/${APP_NAME}-"*.log 2>/dev/null | head -1)
if [ -f "$latest_log" ]; then
log_warning "指定日志文件不存在,显示最新日志文件: $latest_log"
log_info "显示日志文件最后 50 行: $latest_log"
echo "=========================================="
tail -50 "$latest_log"
echo "=========================================="
else
log_warning "日志文件不存在: $target_log_file"
log_info "可用的日志文件:"
ls -la "${LOG_DIR}/"*.log 2>/dev/null || echo "无日志文件"
fi
fi
}
# 显示帮助信息
show_help() {
echo "用法: $0 {start|stop|restart|status|logs|help} [--reload] [日期]"
echo ""
echo "服务管理命令:"
echo " start [--reload] 启动 $APP_NAME 服务器(默认禁用热重载)"
echo " stop 停止 $APP_NAME 服务器"
echo " restart [--reload] 重启 $APP_NAME 服务器"
echo " status 查看 $APP_NAME 服务器状态"
echo " logs [YYYY-MM-DD] 查看 $APP_NAME 服务器日志(可指定日期)"
echo " help 显示此帮助信息"
echo ""
echo "可选参数:"
echo " --reload 启用热重载模式(开发环境使用)"
echo " 日期参数 格式: YYYY-MM-DD,用于查看特定日期日志"
echo ""
echo "环境变量配置:"
echo " 所有配置都从 .env 文件读取,包括:"
echo " APP_NAME, APP_MODULE, APP_PORT, APP_WORKERS, APP_LOG_LEVEL 等"
echo ""
echo "日志功能:"
echo " • 日志按日期分割: ${APP_NAME}-YYYY-MM-DD.log"
echo " • 相同日期日志自动追加"
echo " • 支持查看特定日期日志"
echo ""
echo "示例:"
echo " $0 start # 启动服务器(生产环境推荐)"
echo " $0 start --reload # 启动服务器并启用热重载(开发环境)"
echo " $0 restart --reload # 重启服务器并启用热重载"
echo " $0 status # 查看服务器状态"
echo " $0 logs # 查看今天日志"
echo " $0 logs 2025-12-14 # 查看指定日期日志"
}
# 主函数
main() {
# 加载环境变量
load_env
# 初始化配置
init_config
# 执行检查
check_directory
check_uv
check_app_module
setup_logs
# 处理命令行参数
case "$1" in
start)
# 传递剩余参数给 start_server
shift
start_server "$@"
;;
stop)
stop_server
;;
restart)
# 重启时也支持 --reload 参数
shift
restart_server "$@"
;;
status)
status_server
;;
logs)
# 传递可能的日期参数给 tail_logs
shift
tail_logs "$@"
;;
help|--help|-h)
show_help
;;
*)
if [ -z "$1" ]; then
# 默认行为:启动服务器(向后兼容)
log_warning "未指定命令,默认执行启动操作"
log_warning "建议使用: $0 start"
start_server
else
log_error "未知命令: $1"
show_help
exit 1
fi
;;
esac
}
# 脚本入口
main "$@"

View File

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

447
backend/uv.lock generated
View File

@@ -5,32 +5,45 @@ requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "arrow"
version = "1.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
]
[[package]]
@@ -38,266 +51,492 @@ name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "arrow" },
{ name = "beanie" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "uvicorn" },
]
[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" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ 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" },
]

2
backstage/.env.sample Normal file
View File

@@ -0,0 +1,2 @@
# API 基础地址
VITE_API_BASE_URL=http://mp.yizhezhe.com/api

View File

@@ -0,0 +1,32 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
backstage/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>后台管理系统 - 会话存档管理</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

32
backstage/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "backstage",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"daisyui": "^5.5.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.3.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.2.4"
}
}

2380
backstage/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

28
backstage/src/App.jsx Normal file
View File

@@ -0,0 +1,28 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import Layout from './components/layout/Layout'
import Dashboard from './pages/Dashboard'
import Messages from './pages/Messages'
import Settings from './pages/Settings'
import Login from './pages/Login'
import LoadArchive from './pages/LoadArchive'
function App() {
// 简单的认证检查(实际项目中应该使用更完善的认证方案)
const isAuthenticated = true // 暂时设为 true 以便测试
return (
<Router basename="/chat_bm">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={isAuthenticated ? <Layout /> : <Navigate to="/login" />}>
<Route index element={<Dashboard />} />
<Route path="messages" element={<Messages />} />
<Route path="settings" element={<Settings />} />
<Route path="load-archive" element={<LoadArchive />} />
</Route>
</Routes>
</Router>
)
}
export default App

View File

@@ -0,0 +1,102 @@
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useState } from 'react'
const Layout = () => {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(true)
const navigation = [
{ name: '仪表板', href: '/', icon: '🏠', current: location.pathname === '/' },
{ name: '消息管理', href: '/messages', icon: '💬', current: location.pathname === '/messages' },
{ name: '系统设置', href: '/settings', icon: '⚙️', current: location.pathname === '/settings' },
]
return (
<div className="min-h-screen bg-base-100">
{/* 侧边栏 */}
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-base-200 shadow-xl transition-transform duration-300 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className="flex h-16 items-center justify-between border-b border-base-300 px-4">
<div className="flex items-center">
<div className="text-2xl font-bold text-primary">后台管理</div>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="btn btn-ghost btn-sm"
>
✕
</button>
</div>
<nav className="flex-1 space-y-1 p-4">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
item.current
? 'bg-primary text-primary-content'
: 'text-base-content hover:bg-base-300'
}`}
>
<span className="mr-3 text-lg">{item.icon}</span>
{item.name}
</Link>
))}
</nav>
</div>
{/* 主内容区 */}
<div className={`transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-0'}`}>
{/* 顶部导航栏 */}
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-base-300 bg-base-100 px-6 shadow-sm">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="btn btn-ghost btn-sm mr-4"
>
{sidebarOpen ? '◀' : '▶'}
</button>
<h1 className="text-lg font-semibold">
{navigation.find(item => item.current)?.name || '后台管理系统'}
</h1>
</div>
<div className="flex items-center space-x-4">
<button className="btn btn-ghost btn-sm">
🔔
<span className="badge badge-xs badge-primary absolute -top-1 -right-1">3</span>
</button>
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm">
<div className="avatar placeholder">
<div className="bg-neutral text-neutral-content w-8 rounded-full">
<span className="text-xs">AD</span>
</div>
</div>
</div>
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow">
<li><a>个人资料</a></li>
<li><a>修改密码</a></li>
<li className="divider my-1"></li>
<li><a className="text-error">退出登录</a></li>
</ul>
</div>
</div>
</header>
{/* 页面内容 */}
<main className="p-6">
<Outlet />
</main>
{/* 页脚 */}
<footer className="border-t border-base-300 p-4 text-center text-sm text-base-content/70">
<p>© 2025 后台管理系统 - 会话存档管理 | 版本 1.0.0</p>
</footer>
</div>
</div>
)
}
export default Layout

39
backstage/src/index.css Normal file
View File

@@ -0,0 +1,39 @@
@import "tailwindcss";
/* DaisyUI 主题配置 */
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}
/* 自定义样式 */
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}

10
backstage/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,182 @@
import { useNavigate } from 'react-router-dom'
const Dashboard = () => {
const navigate = useNavigate()
const stats = [
{ title: '总消息数', value: '1,234', change: '+12%', icon: '💬', color: 'bg-primary' },
{ title: '今日新增', value: '56', change: '+5%', icon: '📈', color: 'bg-success' },
{ title: '用户数', value: '89', change: '+8%', icon: '👥', color: 'bg-info' },
{ title: '加载成功率', value: '98%', change: '+2%', icon: '✅', color: 'bg-warning' },
]
const recentActivities = [
{ id: 1, user: '张三', action: '加载了会话存档', time: '10分钟前', type: 'success' },
{ id: 2, user: '李四', action: '查看了消息详情', time: '30分钟前', type: 'info' },
{ id: 3, user: '王五', action: '更新了系统设置', time: '1小时前', type: 'warning' },
{ id: 4, user: '赵六', action: '导出了消息数据', time: '2小时前', type: 'success' },
]
return (
<div className="space-y-6">
{/* 欢迎标题 */}
<div>
<h1 className="text-3xl font-bold">欢迎回来,管理员</h1>
<p className="text-base-content/70 mt-2">这里是会话存档管理系统的控制面板</p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<div key={index} className="card bg-base-100 shadow">
<div className="card-body">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-base-content/70">{stat.title}</p>
<p className="text-2xl font-bold mt-1">{stat.value}</p>
<p className="text-xs mt-1">
<span className="text-success">{stat.change}</span> 较昨日
</p>
</div>
<div className={`${stat.color} rounded-lg p-3`}>
<span className="text-2xl">{stat.icon}</span>
</div>
</div>
</div>
</div>
))}
</div>
{/* 快速操作 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 最近活动 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">最近活动</h2>
<div className="space-y-4">
{recentActivities.map((activity) => (
<div key={activity.id} className="flex items-center justify-between border-b border-base-300 pb-3 last:border-0">
<div className="flex items-center">
<div className={`avatar placeholder ${activity.type === 'success' ? 'bg-success' : activity.type === 'warning' ? 'bg-warning' : 'bg-info'}`}>
<div className="text-white w-8 rounded-full">
<span className="text-xs">{activity.user.charAt(0)}</span>
</div>
</div>
<div className="ml-3">
<p className="font-medium">{activity.user}</p>
<p className="text-sm text-base-content/70">{activity.action}</p>
</div>
</div>
<span className="text-sm text-base-content/70">{activity.time}</span>
</div>
))}
</div>
</div>
</div>
{/* 快速操作卡片 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">快速操作</h2>
<div className="grid grid-cols-2 gap-4">
<button
className="btn btn-primary"
onClick={() => navigate('/load-archive')}
>
<span className="mr-2">📥</span>
加载存档
</button>
<button
className="btn btn-outline"
onClick={() => navigate('/messages')}
>
<span className="mr-2">🔍</span>
搜索消息
</button>
<button
className="btn btn-outline"
onClick={() => navigate('/messages')}
>
<span className="mr-2">📋</span>
消息列表
</button>
<button
className="btn btn-outline"
onClick={() => navigate('/settings')}
>
<span className="mr-2">⚙️</span>
系统设置
</button>
</div>
<div className="divider my-4"></div>
<div>
<h3 className="font-medium mb-2">系统状态</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span>后端服务</span>
<span className="badge badge-success">运行中</span>
</div>
<div className="flex justify-between">
<span>数据库连接</span>
<span className="badge badge-success">正常</span>
</div>
<div className="flex justify-between">
<span>API 可用性</span>
<span className="badge badge-success">100%</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 消息类型分布 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">消息类型分布</h2>
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>消息类型</th>
<th>数量</th>
<th>占比</th>
<th>趋势</th>
</tr>
</thead>
<tbody>
<tr>
<td>文本消息</td>
<td>856</td>
<td>69%</td>
<td className="text-success">↗ 增长</td>
</tr>
<tr>
<td>图片消息</td>
<td>234</td>
<td>19%</td>
<td className="text-warning">→ 稳定</td>
</tr>
<tr>
<td>文件消息</td>
<td>98</td>
<td>8%</td>
<td className="text-success">↗ 增长</td>
</tr>
<tr>
<td>语音消息</td>
<td>46</td>
<td>4%</td>
<td className="text-error">↘ 下降</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,499 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { messageApi } from '../services/api'
const LoadArchive = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState({
status: 'idle', // idle, loading, success, error
currentStep: 0,
steps: [
{
id: 'config',
name: '初始化配置',
status: 'pending', // pending, running, success, error
details: null,
startTime: null,
endTime: null,
duration: null
},
{
id: 'fetch_data',
name: '拉取会话数据',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
},
{
id: 'save_db',
name: '保存到数据库',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
},
{
id: 'complete',
name: '完成',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
}
],
config: {
corpid: '',
secret: '',
libPath: '',
rsaKeyPath: '',
dataDir: ''
},
result: null,
error: null,
startTime: null,
endTime: null,
totalDuration: null
})
// 更新步骤状态
const updateStep = (stepId, updates) => {
setProgress(prev => ({
...prev,
steps: prev.steps.map(step =>
step.id === stepId ? { ...step, ...updates } : step
)
}))
}
// 开始加载会话存档
const startLoadArchive = async () => {
const startTime = new Date()
setLoading(true)
setProgress(prev => ({
...prev,
status: 'loading',
startTime,
error: null,
result: null
}))
try {
// 步骤1: 初始化配置
updateStep('config', {
status: 'running',
startTime: new Date(),
details: '正在读取配置信息...'
})
// 获取配置信息
const configResponse = await messageApi.getConfig()
if (!configResponse.success) {
throw new Error(configResponse.message || '获取配置信息失败')
}
const config = configResponse.data
setProgress(prev => ({
...prev,
config
}))
updateStep('config', {
status: 'success',
endTime: new Date(),
duration: 500,
details: `配置加载完成\n企业ID: ${config.corpid}\n动态库路径: ${config.libPath}\n数据目录: ${config.dataDir}`
})
// 步骤3: 拉取会话数据
updateStep('fetch_data', {
status: 'running',
startTime: new Date(),
details: '正在从企业微信拉取会话数据...'
})
// 调用加载API
const loadResponse = await messageApi.loadMessages(0, 1000)
if (!loadResponse.success) {
throw new Error(loadResponse.message || '加载会话数据失败')
}
const result = loadResponse.data
updateStep('fetch_data', {
status: 'success',
endTime: new Date(),
duration: 2000, // 模拟耗时
details: `✓ 数据拉取成功\n共获取 ${result.total_fetched || 0} 条聊天记录`
})
// 步骤5: 保存到数据库
updateStep('save_db', {
status: 'running',
startTime: new Date(),
details: '开始存储消息到数据库...'
})
// 模拟数据库保存过程
await new Promise(resolve => setTimeout(resolve, 1200))
updateStep('save_db', {
status: 'success',
endTime: new Date(),
duration: 1200,
details: `数据库存储完成:\n总计消息: ${result.total_fetched || 0}\n成功存储: ${result.saved_count || 0}\n跳过重复: ${result.duplicates_skipped || 0}\n存储失败: ${result.failed_to_save || 0}`
})
// 步骤6: 完成
updateStep('complete', {
status: 'running',
startTime: new Date(),
details: '正在完成加载流程...'
})
const endTime = new Date()
const totalDuration = endTime - startTime
updateStep('complete', {
status: 'success',
endTime,
duration: 500,
details: `测试完成\n总耗时: ${(totalDuration / 1000).toFixed(2)}秒`
})
setProgress(prev => ({
...prev,
status: 'success',
endTime,
totalDuration,
result
}))
} catch (error) {
console.error('加载会话存档失败:', error)
const currentStep = progress.steps.find(step => step.status === 'running')
if (currentStep) {
updateStep(currentStep.id, {
status: 'error',
endTime: new Date(),
details: `错误: ${error.message}`
})
}
const endTime = new Date()
const totalDuration = endTime - startTime
setProgress(prev => ({
...prev,
status: 'error',
endTime,
totalDuration,
error: error.message
}))
} finally {
setLoading(false)
}
}
// 重置状态
const resetProgress = () => {
setProgress({
status: 'idle',
currentStep: 0,
steps: [
{
id: 'config',
name: '初始化配置',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
},
{
id: 'fetch_data',
name: '拉取会话数据',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
},
{
id: 'save_db',
name: '保存到数据库',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
},
{
id: 'complete',
name: '完成',
status: 'pending',
details: null,
startTime: null,
endTime: null,
duration: null
}
],
config: {
corpid: '',
secret: '',
libPath: '',
rsaKeyPath: '',
dataDir: ''
},
result: null,
error: null,
startTime: null,
endTime: null,
totalDuration: null
})
}
// 获取状态颜色
const getStatusColor = (status) => {
switch (status) {
case 'pending': return 'bg-gray-100 text-gray-800'
case 'running': return 'bg-blue-100 text-blue-800'
case 'success': return 'bg-green-100 text-green-800'
case 'error': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
// 获取状态图标
const getStatusIcon = (status) => {
switch (status) {
case 'pending': return '⏳'
case 'running': return '🔄'
case 'success': return '✅'
case 'error': return '❌'
default: return '⏳'
}
}
// 格式化时间
const formatTime = (time) => {
if (!time) return '--:--:--'
return new Date(time).toLocaleTimeString('zh-CN')
}
// 格式化时长
const formatDuration = (duration) => {
if (!duration) return '--'
return `${(duration / 1000).toFixed(2)}秒`
}
return (
<div className="space-y-6 p-6">
{/* 页面标题和操作 */}
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-bold">加载会话存档</h1>
<p className="text-base-content/70 mt-2">从企业微信加载会话存档数据到数据库</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={startLoadArchive}
disabled={loading || progress.status === 'loading'}
className="btn btn-primary"
>
{loading || progress.status === 'loading' ? (
<>
<span className="loading loading-spinner loading-sm"></span>
加载中...
</>
) : (
<>
<span className="mr-2">🚀</span>
开始加载
</>
)}
</button>
<button
onClick={resetProgress}
disabled={loading || progress.status === 'loading'}
className="btn btn-outline"
>
<span className="mr-2">🔄</span>
重置
</button>
<button
onClick={() => navigate('/messages')}
className="btn btn-outline"
>
<span className="mr-2">←</span>
返回消息管理
</button>
</div>
</div>
{/* 整体状态 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<div className="flex items-center justify-between">
<div>
<h3 className="card-title">加载状态</h3>
<p className="text-sm text-base-content/70">
{progress.status === 'idle' && '等待开始加载...'}
{progress.status === 'loading' && '正在加载会话存档...'}
{progress.status === 'success' && '加载完成!'}
{progress.status === 'error' && '加载失败'}
</p>
</div>
<div className="text-right">
<div className="text-sm text-base-content/70">开始时间</div>
<div className="font-bold">{formatTime(progress.startTime)}</div>
</div>
<div className="text-right">
<div className="text-sm text-base-content/70">结束时间</div>
<div className="font-bold">{formatTime(progress.endTime)}</div>
</div>
<div className="text-right">
<div className="text-sm text-base-content/70">总耗时</div>
<div className="font-bold">{formatDuration(progress.totalDuration)}</div>
</div>
</div>
{progress.error && (
<div className="alert alert-error mt-4">
<span>错误: {progress.error}</span>
</div>
)}
</div>
</div>
{/* 步骤流程 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h3 className="card-title mb-4">加载步骤流程</h3>
<div className="space-y-4">
{progress.steps.map((step, index) => (
<div key={step.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${getStatusColor(step.status)}`}>
<span className="font-bold">{index + 1}</span>
</div>
<div>
<h4 className="font-bold text-lg">{step.name}</h4>
<div className="flex items-center space-x-2 mt-1">
<span className={`text-xs px-2 py-1 rounded-full ${getStatusColor(step.status)}`}>
{getStatusIcon(step.status)} {step.status === 'pending' ? '等待中' :
step.status === 'running' ? '进行中' :
step.status === 'success' ? '成功' : '失败'}
</span>
{step.startTime && (
<span className="text-xs text-gray-500">
开始: {formatTime(step.startTime)}
</span>
)}
{step.endTime && (
<span className="text-xs text-gray-500">
结束: {formatTime(step.endTime)}
</span>
)}
{step.duration && (
<span className="text-xs text-gray-500">
耗时: {formatDuration(step.duration)}
</span>
)}
</div>
</div>
</div>
</div>
{step.details && (
<div className="mt-3 ml-11">
<div className="bg-gray-50 rounded p-3">
<pre className="text-sm whitespace-pre-wrap font-mono">{step.details}</pre>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
{/* 结果详情 */}
{progress.result && (
<div className="card bg-base-100 shadow">
<div className="card-body">
<h3 className="card-title mb-4">加载结果详情</h3>
<div className="overflow-x-auto">
<table className="table table-zebra">
<thead>
<tr>
<th>指标</th>
<th>数值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>拉取消息总数</td>
<td className="font-bold">{progress.result.total_fetched || 0}</td>
<td>从企业微信拉取的消息总数</td>
</tr>
<tr>
<td>加密消息数量</td>
<td className="font-bold">{progress.result.encrypted_count || 0}</td>
<td>需要解密的加密消息数量</td>
</tr>
<tr>
<td>成功解密</td>
<td className="font-bold text-green-600">{progress.result.decrypted_success || 0}</td>
<td>成功解密的加密消息数量</td>
</tr>
<tr>
<td>解密失败</td>
<td className="font-bold text-red-600">{progress.result.decrypted_failed || 0}</td>
<td>解密失败的消息数量</td>
</tr>
<tr>
<td>成功存储</td>
<td className="font-bold text-green-600">{progress.result.saved_count || 0}</td>
<td>成功保存到数据库的消息数量</td>
</tr>
<tr>
<td>跳过重复</td>
<td className="font-bold text-yellow-600">{progress.result.duplicates_skipped || 0}</td>
<td>数据库中已存在,跳过存储的消息数量</td>
</tr>
<tr>
<td>存储失败</td>
<td className="font-bold text-red-600">{progress.result.failed_to_save || 0}</td>
<td>存储到数据库失败的消息数量</td>
</tr>
<tr>
<td>最大seq值</td>
<td className="font-bold">{progress.result.max_seq || 0}</td>
<td>本次拉取的最大seq值,用于下次拉取</td>
</tr>
<tr>
<td>建议下次seq</td>
<td className="font-bold">{progress.result.next_seq || (progress.result.max_seq || 0) + 1}</td>
<td>下次拉取建议使用的seq值</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)
}
export default LoadArchive

View File

@@ -0,0 +1,143 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const Login = () => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!username || !password) {
setError('请输入用户名和密码')
return
}
setLoading(true)
// 模拟登录 API 调用
setTimeout(() => {
setLoading(false)
if (username === 'admin' && password === 'admin123') {
// 登录成功,跳转到首页
navigate('/')
} else {
setError('用户名或密码错误')
}
}, 1500)
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary/10 to-secondary/10 flex items-center justify-center p-4">
<div className="card bg-base-100 shadow-2xl w-full max-w-md">
<div className="card-body">
{/* Logo 和标题 */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary text-primary-content text-2xl font-bold mb-4">
BS
</div>
<h1 className="text-3xl font-bold">后台管理系统</h1>
<p className="text-base-content/70 mt-2">会话存档管理平台</p>
</div>
{/* 登录表单 */}
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="alert alert-error">
<span>{error}</span>
</div>
)}
<div className="form-control">
<label className="label">
<span className="label-text">用户名</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">密码</span>
</label>
<input
type="password"
className="input input-bordered"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
<label className="label">
<a href="#" className="label-text-alt link link-hover">忘记密码?</a>
</label>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="checkbox checkbox-primary" defaultChecked />
<span className="label-text">记住我</span>
</label>
</div>
<div className="form-control mt-6">
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? (
<>
<span className="loading loading-spinner loading-sm"></span>
登录中...
</>
) : (
'登录'
)}
</button>
</div>
</form>
{/* 分隔线 */}
<div className="divider my-6">或</div>
{/* 其他登录方式 */}
<div className="space-y-3">
<button className="btn btn-outline btn-block">
<span className="mr-2">👤</span>
使用访客账户登录
</button>
<button className="btn btn-outline btn-block">
<span className="mr-2">🔑</span>
使用 API Token 登录
</button>
</div>
{/* 底部信息 */}
<div className="mt-8 text-center text-sm text-base-content/70">
<p>默认账户:admin / admin123</p>
<p className="mt-2">© 2025 后台管理系统 | 版本 1.0.0</p>
</div>
</div>
</div>
{/* 背景装饰 */}
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-primary/20 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-secondary/20 rounded-full blur-3xl"></div>
</div>
</div>
)
}
export default Login

View File

@@ -0,0 +1,641 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { messageApi } from '../services/api'
const Messages = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [messages, setMessages] = useState([])
const [pagination, setPagination] = useState({
page: 1,
pageSize: 20,
total: 0,
totalPages: 0
})
const [filters, setFilters] = useState({
msgtype: '',
roomType: '',
search: ''
})
const [stats, setStats] = useState({
today: { text: 0, image: 0, file: 0, voice: 0 },
loadStatus: { lastLoadTime: '', successRate: 0 }
})
// 抽屉状态
const [selectedMessage, setSelectedMessage] = useState(null)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
// 数据格式转换函数
const formatMessage = (backendMsg) => {
// 处理发送者显示名称
let fromDisplay = '未知用户'
if (backendMsg.from_user) {
// 如果是企业微信用户ID,可以尝试映射或截断显示
if (backendMsg.from_user.startsWith('wmx')) {
fromDisplay = `用户${backendMsg.from_user.slice(-6)}`
} else {
fromDisplay = backendMsg.from_user
}
}
// 处理消息内容
let content = '无内容'
if (backendMsg.text && backendMsg.text.content) {
content = backendMsg.text.content
} else if (backendMsg.msgtype === 'image') {
content = '[图片消息]'
} else if (backendMsg.msgtype === 'file') {
content = '[文件消息]'
} else if (backendMsg.msgtype === 'voice') {
content = '[语音消息]'
}
// 处理时间显示
let timeDisplay = '未知时间'
if (backendMsg.msgtime) {
try {
const date = new Date(backendMsg.msgtime)
timeDisplay = date.toLocaleString('zh-CN')
} catch (e) {
timeDisplay = backendMsg.msgtime
}
}
// 处理聊天类型
let roomDisplay = '单聊'
if (backendMsg.roomid) {
roomDisplay = `群聊-${backendMsg.roomid.slice(-8)}`
}
return {
id: backendMsg.msgid || backendMsg._id,
from: fromDisplay,
content: content,
time: timeDisplay,
type: backendMsg.msgtype || 'text',
room: roomDisplay,
rawData: backendMsg // 保留原始数据供详细查看使用
}
}
// 加载消息列表 - 使用React 19的use hook
const loadMessages = async (page = 1, filterParams = {}) => {
setLoading(true)
try {
const currentFilters = { ...filters, ...filterParams }
const params = {
page,
page_size: pagination.pageSize
}
// 如果有消息类型过滤
if (currentFilters.msgtype) {
params.msgtype = currentFilters.msgtype
}
// 如果有群聊过滤
if (currentFilters.roomType === 'group') {
params.roomid = { $exists: true, $ne: null }
} else if (currentFilters.roomType === 'single') {
params.roomid = null
}
// 如果有搜索关键词
if (currentFilters.search) {
params.search = currentFilters.search
}
const response = await messageApi.getMessages(params)
if (response.success) {
const formattedMessages = response.data.messages.map(formatMessage)
setMessages(formattedMessages)
setPagination({
page: response.data.page,
pageSize: response.data.page_size,
total: response.data.total,
totalPages: response.data.total_pages
})
// 更新统计信息
updateStats(formattedMessages)
}
} catch (error) {
console.error('加载消息失败:', error)
alert('加载消息失败,请检查网络连接或后端服务')
} finally {
setLoading(false)
}
}
// 更新统计信息
const updateStats = (messages) => {
// 今日消息统计(简化版,实际应该从后端获取)
const today = new Date().toISOString().split('T')[0]
const todayMessages = messages.filter(msg =>
msg.time.includes(today.replace(/-/g, '/'))
)
const todayStats = {
text: todayMessages.filter(m => m.type === 'text').length,
image: todayMessages.filter(m => m.type === 'image').length,
file: todayMessages.filter(m => m.type === 'file').length,
voice: todayMessages.filter(m => m.type === 'voice').length
}
setStats(prev => ({
...prev,
today: todayStats,
loadStatus: {
lastLoadTime: new Date().toLocaleString('zh-CN'),
successRate: messages.length > 0 ? 95 : 0
}
}))
}
// 获取统计信息用于显示(如果需要)
const getStatsDisplay = () => {
return {
today: stats.today,
loadStatus: stats.loadStatus
}
}
// 跳转到加载会话存档页面
const handleLoadArchive = () => {
navigate('/load-archive')
}
// 搜索处理
const handleSearch = async (e) => {
e.preventDefault()
console.log('执行搜索操作')
await loadMessages(1)
}
// 过滤条件变化处理
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
// 分页处理
const handlePageChange = (page) => {
if (page >= 1 && page <= pagination.totalPages) {
console.log(`切换到第 ${page} 页`)
loadMessages(page)
}
}
// 查看消息详情
const handleViewMessage = (message) => {
setSelectedMessage(message)
setIsDrawerOpen(true)
}
// 关闭抽屉
const closeDrawer = () => {
setIsDrawerOpen(false)
setSelectedMessage(null)
}
// 组件加载时初始化数据
useEffect(() => {
console.log(`Messages组件加载,初始化数据`)
loadMessages(1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 渲染消息内容
const renderMessageContent = () => {
if (!selectedMessage) return null
const { rawData } = selectedMessage
switch (selectedMessage.type) {
case 'text':
return (
<div className="mt-4">
<div className="font-semibold mb-2">消息内容:</div>
<div className="bg-base-200 rounded-lg p-4 whitespace-pre-wrap break-words">
{rawData.text?.content || selectedMessage.content}
</div>
</div>
)
case 'image':
return (
<div className="mt-4">
<div className="font-semibold mb-2">图片消息:</div>
<div className="bg-base-200 rounded-lg p-4">
{rawData.image?.md5sum ? (
<div className="space-y-2">
<div className="text-sm">图片MD5: {rawData.image.md5sum}</div>
<div className="text-sm">图片大小: {rawData.image.filesize || '未知'} bytes</div>
<div className="text-sm text-base-content/70">
(图片文件需要从企业微信服务器下载)
</div>
</div>
) : (
<div className="text-base-content/70">无图片详细信息</div>
)}
</div>
</div>
)
case 'file':
return (
<div className="mt-4">
<div className="font-semibold mb-2">文件消息:</div>
<div className="bg-base-200 rounded-lg p-4">
{rawData.file ? (
<div className="space-y-2">
<div className="text-sm">文件名: {rawData.file.filename || '未知'}</div>
<div className="text-sm">文件大小: {rawData.file.filesize || '未知'} bytes</div>
<div className="text-sm">文件扩展名: {rawData.file.fileext || '未知'}</div>
<div className="text-sm text-base-content/70">
(文件需要从企业微信服务器下载)
</div>
</div>
) : (
<div className="text-base-content/70">无文件详细信息</div>
)}
</div>
</div>
)
case 'voice':
return (
<div className="mt-4">
<div className="font-semibold mb-2">语音消息:</div>
<div className="bg-base-200 rounded-lg p-4">
{rawData.voice ? (
<div className="space-y-2">
<div className="text-sm">语音时长: {rawData.voice.play_length || '未知'} 秒</div>
<div className="text-sm">文件大小: {rawData.voice.filesize || '未知'} bytes</div>
<div className="text-sm text-base-content/70">
(语音文件需要从企业微信服务器下载)
</div>
</div>
) : (
<div className="text-base-content/70">无语音详细信息</div>
)}
</div>
</div>
)
default:
return (
<div className="mt-4">
<div className="font-semibold mb-2">消息内容:</div>
<div className="bg-base-200 rounded-lg p-4">
{selectedMessage.content}
</div>
</div>
)
}
}
return (
<div className="drawer drawer-end">
<input
id="message-drawer"
type="checkbox"
className="drawer-toggle"
checked={isDrawerOpen}
onChange={() => {}} // 只读,通过状态控制
/>
<div className="drawer-content space-y-6">
{/* 页面标题和操作 */}
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-bold">消息管理</h1>
<p className="text-base-content/70 mt-2">管理企业微信会话存档消息</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleLoadArchive}
disabled={loading}
className="btn btn-primary"
>
{loading ? (
<>
<span className="loading loading-spinner loading-sm"></span>
加载中...
</>
) : (
<>
<span className="mr-2">📥</span>
加载会话存档
</>
)}
</button>
{/* <button className="btn btn-outline">
<span className="mr-2">📊</span>
查看统计
</button>
<button className="btn btn-outline">
<span className="mr-2">📤</span>
导出数据
</button> */}
</div>
</div>
{/* 搜索和过滤 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<form onSubmit={handleSearch} className="flex flex-col gap-4 md:flex-row">
<div className="flex-1">
<input
type="text"
placeholder="搜索消息内容、发送者..."
className="input input-bordered w-full"
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</div>
<div className="flex gap-2">
<select
className="select select-bordered"
value={filters.msgtype}
onChange={(e) => handleFilterChange('msgtype', e.target.value)}
>
<option value="">所有消息类型</option>
<option value="text">文本消息</option>
<option value="image">图片消息</option>
<option value="file">文件消息</option>
<option value="voice">语音消息</option>
</select>
<select
className="select select-bordered"
value={filters.roomType}
onChange={(e) => handleFilterChange('roomType', e.target.value)}
>
<option value="">所有聊天类型</option>
<option value="single">单聊</option>
<option value="group">群聊</option>
</select>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? (
<>
<span className="loading loading-spinner loading-sm mr-2"></span>
搜索中...
</>
) : (
<>
<span className="mr-2">🔍</span>
搜索
</>
)}
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => {
setFilters({ msgtype: '', roomType: '', search: '' })
loadMessages(1)
}}
>
重置
</button>
</div>
</form>
</div>
</div>
{/* 消息列表 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>
<label>
<input type="checkbox" className="checkbox" />
</label>
</th>
<th>发送者</th>
<th>消息内容</th>
<th>消息类型</th>
<th>聊天类型</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{messages.map((message) => (
<tr key={message.id}>
<td>
<label>
<input type="checkbox" className="checkbox" />
</label>
</td>
<td>
<div className="flex items-center gap-3">
<div className="avatar placeholder">
<div className="bg-neutral text-neutral-content w-8 rounded-full">
<span className="text-xs">{message.from.charAt(0)}</span>
</div>
</div>
<div>
<div className="font-bold">{message.from}</div>
</div>
</div>
</td>
<td>
<div className="max-w-xs truncate" title={message.content}>
{message.content}
</div>
</td>
<td>
<span className={`badge ${message.type === 'text' ? 'badge-info' : message.type === 'image' ? 'badge-success' : message.type === 'file' ? 'badge-warning' : 'badge-error'}`}>
{message.type === 'text' ? '文本' : message.type === 'image' ? '图片' : message.type === 'file' ? '文件' : '语音'}
</span>
</td>
<td>
<span className={`badge ${message.room.includes('群聊') ? 'badge-primary' : 'badge-secondary'}`}>
{message.room}
</span>
</td>
<td>{message.time}</td>
<td>
<div className="flex gap-2">
<button
className="btn btn-ghost btn-xs"
onClick={() => handleViewMessage(message)}
>
<span className="mr-1">👁️</span>
查看
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="flex items-center justify-between pt-4">
<div className="text-sm text-base-content/70">
{messages.length > 0 ? (
`显示 ${(pagination.page - 1) * pagination.pageSize + 1}-${Math.min(pagination.page * pagination.pageSize, pagination.total)} 条,共 ${pagination.total} 条消息`
) : (
'暂无消息数据'
)}
</div>
{pagination.totalPages > 1 && (
<div className="join">
<button
className="join-item btn btn-sm"
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
>
«
</button>
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
let pageNum = i + 1
if (pagination.totalPages > 5) {
if (pagination.page <= 3) {
pageNum = i + 1
} else if (pagination.page >= pagination.totalPages - 2) {
pageNum = pagination.totalPages - 4 + i
} else {
pageNum = pagination.page - 2 + i
}
}
return (
<button
key={pageNum}
className={`join-item btn btn-sm ${pagination.page === pageNum ? 'btn-active' : ''}`}
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</button>
)
})}
<button
className="join-item btn btn-sm"
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
>
»
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* 抽屉侧边栏 */}
<div className="drawer-side">
<label
htmlFor="message-drawer"
className="drawer-overlay"
onClick={closeDrawer}
></label>
<div className="bg-base-100 w-full max-w-md h-full overflow-y-auto">
{selectedMessage && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">消息详情</h2>
<button
onClick={closeDrawer}
className="btn btn-ghost btn-sm"
>
✕
</button>
</div>
{/* 消息基本信息 */}
<div className="space-y-4">
<div>
<div className="font-semibold mb-1">发送者:</div>
<div className="flex items-center gap-3">
<div className="avatar placeholder">
<div className="bg-neutral text-neutral-content w-10 rounded-full">
<span className="text-sm">{selectedMessage.from.charAt(0)}</span>
</div>
</div>
<div>
<div className="font-bold">{selectedMessage.from}</div>
<div className="text-sm text-base-content/70">
ID: {selectedMessage.rawData.from_user || '未知'}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="font-semibold mb-1">消息类型:</div>
<span className={`badge ${selectedMessage.type === 'text' ? 'badge-info' : selectedMessage.type === 'image' ? 'badge-success' : selectedMessage.type === 'file' ? 'badge-warning' : 'badge-error'}`}>
{selectedMessage.type === 'text' ? '文本' : selectedMessage.type === 'image' ? '图片' : selectedMessage.type === 'file' ? '文件' : '语音'}
</span>
</div>
<div>
<div className="font-semibold mb-1">聊天类型:</div>
<span className={`badge ${selectedMessage.room.includes('群聊') ? 'badge-primary' : 'badge-secondary'}`}>
{selectedMessage.room}
</span>
</div>
</div>
<div>
<div className="font-semibold mb-1">发送时间:</div>
<div className="text-lg">{selectedMessage.time}</div>
<div className="text-sm text-base-content/70">
原始时间戳: {selectedMessage.rawData.msgtime || '未知'}
</div>
</div>
{selectedMessage.rawData.tolist && selectedMessage.rawData.tolist.length > 0 && (
<div>
<div className="font-semibold mb-1">接收者:</div>
<div className="bg-base-200 rounded-lg p-3">
{selectedMessage.rawData.tolist.map((to, index) => (
<div key={index} className="text-sm">
{to}
</div>
))}
</div>
</div>
)}
{/* 消息内容 */}
{renderMessageContent()}
{/* 原始数据 */}
<div className="mt-6">
<div className="collapse collapse-arrow bg-base-200">
<input type="checkbox" />
<div className="collapse-title font-semibold">
查看原始数据
</div>
<div className="collapse-content">
<pre className="text-xs bg-base-300 p-3 rounded-lg overflow-x-auto">
{JSON.stringify(selectedMessage.rawData, null, 2)}
</pre>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 mt-6 pt-4 border-t border-base-300">
<button className="btn btn-primary flex-1">
<span className="mr-2">📋</span>
复制内容
</button>
<button className="btn btn-outline flex-1">
<span className="mr-2">📤</span>
导出
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default Messages

View File

@@ -0,0 +1,270 @@
import { useState } from 'react'
const Settings = () => {
const [apiConfig, setApiConfig] = useState({
baseUrl: 'http://localhost:8000/api',
timeout: 10000,
retryCount: 3,
})
const [systemSettings, setSystemSettings] = useState({
autoLoad: true,
loadInterval: 30,
keepHistory: 90,
theme: 'light',
})
const handleSaveApiConfig = () => {
alert('API 配置已保存!')
}
const handleSaveSystemSettings = () => {
alert('系统设置已保存!')
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div>
<h1 className="text-3xl font-bold">系统设置</h1>
<p className="text-base-content/70 mt-2">配置系统参数和 API 设置</p>
</div>
{/* API 配置 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">API 配置</h2>
<p className="text-base-content/70">配置后端 API 连接参数</p>
<div className="divider my-4"></div>
<div className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">API 基础地址</span>
</label>
<input
type="text"
className="input input-bordered"
value={apiConfig.baseUrl}
onChange={(e) => setApiConfig({...apiConfig, baseUrl: e.target.value})}
placeholder="http://localhost:8000/api"
/>
<label className="label">
<span className="label-text-alt">后端 API 服务的基础地址</span>
</label>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="form-control">
<label className="label">
<span className="label-text">请求超时时间(毫秒)</span>
</label>
<input
type="number"
className="input input-bordered"
value={apiConfig.timeout}
onChange={(e) => setApiConfig({...apiConfig, timeout: parseInt(e.target.value)})}
min="1000"
max="30000"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">重试次数</span>
</label>
<input
type="number"
className="input input-bordered"
value={apiConfig.retryCount}
onChange={(e) => setApiConfig({...apiConfig, retryCount: parseInt(e.target.value)})}
min="0"
max="10"
/>
</div>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" defaultChecked />
<span className="label-text">启用请求日志</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" defaultChecked />
<span className="label-text">启用错误重试</span>
</label>
</div>
<div className="flex justify-end">
<button onClick={handleSaveApiConfig} className="btn btn-primary">
<span className="mr-2">💾</span>
保存 API 配置
</button>
</div>
</div>
</div>
</div>
{/* 系统设置 */}
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">系统设置</h2>
<p className="text-base-content/70">配置系统行为和外观</p>
<div className="divider my-4"></div>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="form-control">
<label className="label">
<span className="label-text">自动加载间隔(分钟)</span>
</label>
<input
type="number"
className="input input-bordered"
value={systemSettings.loadInterval}
onChange={(e) => setSystemSettings({...systemSettings, loadInterval: parseInt(e.target.value)})}
min="1"
max="1440"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">历史数据保留天数</span>
</label>
<input
type="number"
className="input input-bordered"
value={systemSettings.keepHistory}
onChange={(e) => setSystemSettings({...systemSettings, keepHistory: parseInt(e.target.value)})}
min="1"
max="365"
/>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">主题设置</span>
</label>
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="theme"
className="radio radio-primary"
checked={systemSettings.theme === 'light'}
onChange={() => setSystemSettings({...systemSettings, theme: 'light'})}
/>
<span>浅色主题</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="theme"
className="radio radio-primary"
checked={systemSettings.theme === 'dark'}
onChange={() => setSystemSettings({...systemSettings, theme: 'dark'})}
/>
<span>深色主题</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="theme"
className="radio radio-primary"
checked={systemSettings.theme === 'auto'}
onChange={() => setSystemSettings({...systemSettings, theme: 'auto'})}
/>
<span>自动切换</span>
</label>
</div>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
className="toggle toggle-primary"
checked={systemSettings.autoLoad}
onChange={(e) => setSystemSettings({...systemSettings, autoLoad: e.target.checked})}
/>
<span className="label-text">启用自动加载会话存档</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" defaultChecked />
<span className="label-text">启用消息通知</span>
</label>
<label className="label cursor-pointer justify-start gap-3">
<input type="checkbox" className="toggle toggle-primary" defaultChecked />
<span className="label-text">启用数据备份</span>
</label>
</div>
<div className="flex justify-end">
<button onClick={handleSaveSystemSettings} className="btn btn-primary">
<span className="mr-2">💾</span>
保存系统设置
</button>
</div>
</div>
</div>
</div>
{/* 系统信息 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">系统信息</h2>
<div className="space-y-2">
<div className="flex justify-between">
<span>前端版本</span>
<span className="font-mono">1.0.0</span>
</div>
<div className="flex justify-between">
<span>React 版本</span>
<span className="font-mono">19.2.0</span>
</div>
<div className="flex justify-between">
<span>构建时间</span>
<span className="font-mono">2025-12-12</span>
</div>
<div className="flex justify-between">
<span>运行环境</span>
<span className="font-mono">Production</span>
</div>
</div>
</div>
</div>
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">危险操作</h2>
<p className="text-base-content/70 mb-4">请谨慎操作以下功能</p>
<div className="space-y-2">
<button className="btn btn-outline btn-error btn-block justify-start">
<span className="mr-2">🧹</span>
清理所有缓存
</button>
<button className="btn btn-outline btn-error btn-block justify-start">
<span className="mr-2">🔄</span>
重置所有设置
</button>
<button className="btn btn-outline btn-error btn-block justify-start">
<span className="mr-2">🗑️</span>
删除所有数据
</button>
</div>
<div className="mt-4 text-xs text-error">
⚠️ 注意:这些操作不可逆,请确认后再执行
</div>
</div>
</div>
</div>
</div>
)
}
export default Settings

View File

@@ -0,0 +1,127 @@
import axios from 'axios'
// API 基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
// 创建 axios 实例
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 可以在这里添加认证 token
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
apiClient.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('API 请求错误:', error)
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response
switch (status) {
case 401:
// 未授权,跳转到登录页
localStorage.removeItem('auth_token')
window.location.href = '/login'
break
case 403:
console.error('权限不足:', data)
break
case 404:
console.error('资源不存在:', data)
break
case 500:
console.error('服务器内部错误:', data)
break
default:
console.error(`HTTP 错误 ${status}:`, data)
}
} else if (error.request) {
// 请求已发出但没有收到响应
console.error('网络错误,请检查网络连接')
} else {
// 请求配置错误
console.error('请求配置错误:', error.message)
}
return Promise.reject(error)
}
)
// 消息相关 API
export const messageApi = {
// 加载会话存档
loadMessages: (seq = 0, limit = 100) =>
apiClient.post('/messages/load', null, {
params: { seq, limit }
}),
// 获取消息列表
getMessages: (params = {}) =>
apiClient.get('/messages', { params }),
// 获取消息统计
getMessageStats: () =>
apiClient.get('/messages/stats'),
// 获取单个消息详情
getMessageById: (msgid) =>
apiClient.get(`/messages/${msgid}`),
// 获取配置信息
getConfig: () =>
apiClient.get('/messages/config'),
}
// 认证相关 API
export const authApi = {
// 登录
login: (username, password) =>
apiClient.post('/auth/login', { username, password }),
// 获取用户信息
getUserInfo: () =>
apiClient.get('/auth/user'),
// 刷新 token
refreshToken: () =>
apiClient.post('/auth/refresh'),
}
// 系统相关 API
export const systemApi = {
// 获取系统状态
getSystemStatus: () =>
apiClient.get('/system/status'),
// 获取系统配置
getSystemConfig: () =>
apiClient.get('/system/config'),
// 更新系统配置
updateSystemConfig: (config) =>
apiClient.put('/system/config', config),
}
export default apiClient

17
backstage/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react(),
],
base: '/chat_bm',
server: {
port: 3020,
host: '0.0.0.0',
allowedHosts: ['mp.yizhezhe.com']
}
})

View File

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

View File

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

View File

@@ -10,13 +10,13 @@
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.22",
"@tailwindcss/vite": "^4.1.18",
"@wecom/jssdk": "^2.3.3",
"axios": "^1.13.2",
"daisyui": "^5.5.5",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^3.4.0"
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

727
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

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

View File

@@ -0,0 +1,448 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://mp.yizhezhe.com/api';
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
const RolePromptDrawer = ({ isOpen, onClose }) => {
// 引用滚动容器
const drawerContentRef = useRef(null);
// 表单数据状态
const [formData, setFormData] = useState({
name: '',
description: '',
skills: '',
restrictions: ''
});
// 加载和保存状态
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [saveStatus, setSaveStatus] = useState({ type: null, message: '' });
// Popover确认状态
const [showResetConfirm, setShowResetConfirm] = useState(false);
// 加载角色配置
const loadRoleConfig = async () => {
setIsLoading(true);
try {
const response = await apiClient.get('/glm/role');
const result = response.data;
if (result.code === 200 && result.result) {
const roleConfig = result.result;
// 直接使用字符串(后端已返回字符串格式)
setFormData({
name: roleConfig.name || '',
description: roleConfig.description || '',
skills: roleConfig.skills || '',
restrictions: roleConfig.restrictions || ''
});
} else {
throw new Error(result.message || '加载角色配置失败');
}
} catch (error) {
console.error('加载角色配置失败:', error);
setSaveStatus({
type: 'error',
message: `加载失败: ${error.response?.data?.message || error.message}`
});
} finally {
setIsLoading(false);
}
};
// 保存角色配置
const handleSave = async (e) => {
e.preventDefault();
// 验证数据
if (!formData.name.trim() || !formData.description.trim()) {
setSaveStatus({ type: 'error', message: '角色名称和描述不能为空' });
return;
}
setIsSaving(true);
setSaveStatus({ type: 'loading', message: '保存中...' });
try {
// 直接发送字符串(后端期望字符串格式)
const roleConfigToSave = {
name: formData.name.trim(),
description: formData.description.trim(),
skills: formData.skills.trim(),
restrictions: formData.restrictions.trim()
};
const response = await apiClient.put('/glm/role', roleConfigToSave);
const result = response.data;
if (result.code === 200) {
setSaveStatus({ type: 'success', message: '保存成功!' });
// 滚动到顶部,让用户看到提示
if (drawerContentRef.current) {
drawerContentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
// 3秒后清除成功消息
setTimeout(() => {
setSaveStatus({ type: null, message: '' });
}, 3000);
} else {
throw new Error(result.message || '保存角色配置失败');
}
} catch (error) {
console.error('保存角色配置失败:', error);
setSaveStatus({
type: 'error',
message: `保存失败: ${error.response?.data?.message || error.message}`
});
} finally {
setIsSaving(false);
}
};
// 重置按钮点击 - 显示确认Popover
const handleResetButtonClick = () => {
setShowResetConfirm(true);
};
// 取消重置
const handleCancelReset = () => {
setShowResetConfirm(false);
};
// 确认重置到默认角色配置
const handleConfirmReset = async () => {
setShowResetConfirm(false);
setIsResetting(true);
setSaveStatus({ type: 'loading', message: '重置到默认配置中...' });
try {
const response = await apiClient.post('/glm/role/reset');
const result = response.data;
if (result.code === 200) {
setSaveStatus({ type: 'success', message: '已重置为默认角色配置!' });
// 重新加载角色配置
await loadRoleConfig();
// 滚动到顶部,让用户看到提示
if (drawerContentRef.current) {
drawerContentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
// 3秒后清除成功消息
setTimeout(() => {
setSaveStatus({ type: null, message: '' });
}, 3000);
} else {
throw new Error(result.message || '重置角色配置失败');
}
} catch (error) {
console.error('重置角色配置失败:', error);
setSaveStatus({
type: 'error',
message: `重置失败: ${error.response?.data?.message || error.message}`
});
} finally {
setIsResetting(false);
}
};
// 重置表单
const handleReset = () => {
loadRoleConfig();
setSaveStatus({ type: null, message: '' });
};
// 当抽屉打开时加载数据
useEffect(() => {
if (isOpen) {
loadRoleConfig();
setSaveStatus({ type: null, message: '' });
}
}, [isOpen]);
// 点击外部关闭Popover
useEffect(() => {
const handleClickOutside = (event) => {
if (showResetConfirm && !event.target.closest('.reset-confirm-container')) {
setShowResetConfirm(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showResetConfirm]);
// ESC键关闭Popover
useEffect(() => {
const handleEscKey = (event) => {
if (event.key === 'Escape' && showResetConfirm) {
setShowResetConfirm(false);
}
};
document.addEventListener('keydown', handleEscKey);
return () => document.removeEventListener('keydown', handleEscKey);
}, [showResetConfirm]);
// 处理输入变化
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
if (!isOpen) return null;
return (
<div className="fixed right-0 top-0 h-full w-full lg:w-3/5 z-50 bg-white border-l border-gray-200 shadow-2xl overflow-y-auto">
<div ref={drawerContentRef} className="p-4 lg:p-6 h-full overflow-y-auto">
{/* 头部 */}
<div className="flex items-center justify-between mb-4 lg:mb-6 pb-3 lg:pb-4 border-b border-gray-200">
<div>
<h3 className="text-lg lg:text-xl font-bold text-gray-800">角色提示词配置</h3>
<p className="text-xs lg:text-sm text-gray-500 mt-1">配置AI角色的名称、描述、技能和限制</p>
</div>
<button
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
onClick={onClose}
disabled={isSaving}
title="关闭"
>
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 状态提示 */}
{saveStatus.type && (
<div className={`mb-3 lg:mb-4 p-2 lg:p-3 rounded-lg ${
saveStatus.type === 'success' ? 'bg-green-50 border border-green-200 text-green-800' :
saveStatus.type === 'error' ? 'bg-red-50 border border-red-200 text-red-800' :
'bg-blue-50 border border-blue-200 text-blue-800'
}`}>
<div className="flex items-center">
{saveStatus.type === 'loading' && (
<span className="loading loading-spinner loading-xs lg:loading-sm mr-2"></span>
)}
<span className="text-xs lg:text-sm">{saveStatus.message}</span>
</div>
</div>
)}
{/* 加载状态 */}
{isLoading ? (
<div className="flex justify-center items-center h-48 lg:h-64">
<div className="text-center">
<span className="loading loading-spinner loading-md lg:loading-lg text-gray-400"></span>
<p className="mt-3 lg:mt-4 text-sm lg:text-base text-gray-500">加载角色配置中...</p>
</div>
</div>
) : (
/* 表单内容 */
<form onSubmit={handleSave} className="space-y-4 lg:space-y-6">
{/* 角色名称 */}
<div className="space-y-1 lg:space-y-2">
<label className="block text-xs lg:text-sm font-medium text-gray-700">
角色名称
</label>
<input
type="text"
className="w-full px-3 py-2 text-xs lg:text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors focus:outline-none"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="例如:兜兜"
required
disabled={isSaving}
/>
</div>
{/* 角色描述 */}
<div className="space-y-1 lg:space-y-2">
<label className="block text-xs lg:text-sm font-medium text-gray-700">
角色描述
<span className="text-gray-500 font-normal ml-1">(AI角色的背景、性格特点等)</span>
</label>
<textarea
className="w-full px-3 py-2 text-xs lg:text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors h-32 lg:h-40 focus:outline-none"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="描述AI角色的背景、性格特点等..."
required
disabled={isSaving}
/>
</div>
{/* 技能列表 */}
<div className="space-y-1 lg:space-y-2">
<label className="block text-xs lg:text-sm font-medium text-gray-700">
技能列表
<span className="text-gray-500 font-normal ml-1">(每行输入一个技能)</span>
</label>
<textarea
className="w-full px-3 py-2 text-xs lg:text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors font-mono h-36 lg:h-48 focus:outline-none"
value={formData.skills}
onChange={(e) => handleInputChange('skills', e.target.value)}
placeholder="亲切温暖的沟通&#10;轻松友好的语气&#10;简洁明了的描述"
disabled={isSaving}
/>
<p className="text-xs text-gray-500">
每行输入一个技能,空行会被自动过滤
</p>
</div>
{/* 限制列表 */}
<div className="space-y-1 lg:space-y-2">
<label className="block text-xs lg:text-sm font-medium text-gray-700">
限制列表
<span className="text-gray-500 font-normal ml-1">(每行输入一个限制)</span>
</label>
<textarea
className="w-full px-3 py-2 text-xs lg:text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-200 focus:border-blue-200 transition-colors font-mono h-36 lg:h-48 focus:outline-none"
value={formData.restrictions}
onChange={(e) => handleInputChange('restrictions', e.target.value)}
placeholder="回复必须基于提供的知识库中的内容&#10;每条回复应保持在80字以内&#10;只讨论与宝妈相关的主题"
disabled={isSaving}
/>
<p className="text-xs text-gray-500">
每行输入一个限制,空行会被自动过滤
</p>
</div>
{/* 操作按钮 */}
<div className="flex justify-between items-center pt-4 lg:pt-6 border-t border-gray-200">
{/* 左边:重置到默认按钮 */}
<div className="relative reset-confirm-container">
<button
type="button"
className="px-3 py-1.5 lg:px-4 lg:py-2 text-xs lg:text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleResetButtonClick}
disabled={isSaving || isResetting}
>
{isResetting ? (
<span className="flex items-center">
<span className="loading loading-spinner loading-xs mr-1 lg:mr-2"></span>
重置中...
</span>
) : (
'重置到默认'
)}
</button>
{/* Popover确认气泡 */}
{showResetConfirm && (
<div className="absolute bottom-full mb-2 left-0 bg-white border border-gray-300 rounded-lg shadow-xl p-4 w-64 z-50">
{/* 三角形箭头 - 调整到左边 */}
<div className="absolute -bottom-2 left-4 w-4 h-4 bg-white border-r border-b border-gray-300 transform rotate-45"></div>
<p className="text-sm text-gray-700 mb-3">
确定要重置为默认角色配置吗?当前配置将会被覆盖。
</p>
<div className="flex justify-end space-x-2">
<button
type="button"
className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
onClick={handleCancelReset}
>
取消
</button>
<button
type="button"
className="px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
onClick={handleConfirmReset}
>
确定重置
</button>
</div>
</div>
)}
</div>
{/* 右边:其他操作按钮 */}
<div className="flex space-x-2 lg:space-x-3">
<button
type="button"
className="px-3 py-1.5 lg:px-4 lg:py-2 text-xs lg:text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
onClick={handleReset}
disabled={isSaving || isResetting}
>
重置
</button>
<button
type="button"
className="px-3 py-1.5 lg:px-4 lg:py-2 text-xs lg:text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
onClick={onClose}
disabled={isSaving || isResetting}
>
取消
</button>
<button
type="submit"
className="px-3 py-1.5 lg:px-4 lg:py-2 text-xs lg:text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving || isResetting}
>
{isSaving ? (
<span className="flex items-center">
<span className="loading loading-spinner loading-xs mr-1 lg:mr-2"></span>
保存中...
</span>
) : (
'保存配置'
)}
</button>
</div>
</div>
</form>
)}
{/* 使用说明 */}
{!isLoading && (
<div className="mt-6 lg:mt-8 pt-4 lg:pt-6 border-t border-gray-200">
<div className="text-xs lg:text-sm text-gray-600">
<h4 className="font-medium mb-2 lg:mb-3 text-gray-700">使用说明</h4>
<ul className="space-y-1 lg:space-y-2">
<li className="flex items-start">
<span className="text-blue-500 mr-1 lg:mr-2 text-xs">•</span>
<span className="text-xs lg:text-sm">角色配置将影响AI的回复风格和内容限制</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 mr-1 lg:mr-2 text-xs">•</span>
<span className="text-xs lg:text-sm">技能列表定义AI的能力特点,每行输入一个技能</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 mr-1 lg:mr-2 text-xs">•</span>
<span className="text-xs lg:text-sm">限制列表确保AI回复符合业务要求,每行输入一个限制</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 mr-1 lg:mr-2 text-xs">•</span>
<span className="text-xs lg:text-sm">保存后配置将立即生效,影响后续AI回复</span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-1 lg:mr-2 text-xs">•</span>
<span className="text-xs lg:text-sm">点击"重置到默认"可将角色配置恢复为系统默认值</span>
</li>
</ul>
</div>
</div>
)}
</div>
</div>
);
};
export default RolePromptDrawer;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@plugin "daisyui";
/* 自定义滚动条样式 */
::-webkit-scrollbar {
@@ -33,3 +32,64 @@
.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 移动端优化 */
@layer base {
html {
-webkit-tap-highlight-color: transparent;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* 触摸友好的按钮 */
@layer components {
.touch-button {
transform: scale(0.95);
transition: transform 150ms;
}
/* 移动端优化输入框 */
.mobile-input {
font-size: 16px !important; /* 防止iOS缩放 */
}
}
/* 防止移动端长按菜单 */
.no-context-menu {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* 移动端优化滚动 */
.mobile-scroll {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* 防止文本溢出 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 移动端优化模态框 */
.mobile-modal {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}

View File

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

View File

@@ -1,14 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
daisyui: {
themes: ["light", "dark", "cupcake"],
},
}

View File

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

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

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