Compare commits
3 Commits
6029da058a
...
a7b3454f61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b3454f61 | ||
|
|
41d2d893d3 | ||
|
|
420fed4bcf |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
@@ -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
170
backend/API_USAGE.md
Normal 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. 错误重试机制
|
||||
68
backend/app/deps/auth_deps.py
Normal file
68
backend/app/deps/auth_deps.py
Normal 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
78
backend/app/main.py
Normal 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聊天服务",
|
||||
}
|
||||
8
backend/app/models/__init__.py
Normal file
8
backend/app/models/__init__.py
Normal 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()
|
||||
7
backend/app/models/base.py
Normal file
7
backend/app/models/base.py
Normal 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
|
||||
56
backend/app/models/glm_role.py
Normal file
56
backend/app/models/glm_role.py
Normal 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,
|
||||
}
|
||||
52
backend/app/models/message.py
Normal file
52
backend/app/models/message.py
Normal 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
|
||||
114
backend/app/routes/auth_routes.py
Normal file
114
backend/app/routes/auth_routes.py
Normal 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)}"
|
||||
}
|
||||
460
backend/app/routes/glm_routes.py
Normal file
460
backend/app/routes/glm_routes.py
Normal 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)
|
||||
186
backend/app/routes/message_routes.py
Normal file
186
backend/app/routes/message_routes.py
Normal 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)}")
|
||||
208
backend/app/routes/wework_routes.py
Normal file
208
backend/app/routes/wework_routes.py
Normal 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("企业微信服务已关闭")
|
||||
318
backend/app/services/auth_service.py
Normal file
318
backend/app/services/auth_service.py
Normal 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)
|
||||
295
backend/app/services/glm_service.py
Normal file
295
backend/app/services/glm_service.py
Normal 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
|
||||
579
backend/app/services/message_service.py
Normal file
579
backend/app/services/message_service.py
Normal 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
|
||||
310
backend/app/services/wework_service.py
Normal file
310
backend/app/services/wework_service.py
Normal 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
28
backend/cert/1_rsa.key
Normal 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
27
backend/cert/2_rsa.key
Normal 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-----
|
||||
9
backend/cert/2_rsa_pub.key
Normal file
9
backend/cert/2_rsa_pub.key
Normal 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-----
|
||||
@@ -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
12
backend/data.json
Normal 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
|
||||
}
|
||||
}
|
||||
152
backend/lib/sdk/WeWorkFinanceSdk_C.h
Normal file
152
backend/lib/sdk/WeWorkFinanceSdk_C.h
Normal 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
|
||||
BIN
backend/lib/sdk/libWeWorkFinanceSdk_C.so
Normal file
BIN
backend/lib/sdk/libWeWorkFinanceSdk_C.so
Normal file
Binary file not shown.
1
backend/lib/sdk/md5.txt
Normal file
1
backend/lib/sdk/md5.txt
Normal file
@@ -0,0 +1 @@
|
||||
d422224e67eb07a195d1d7115b39284c
|
||||
142
backend/lib/sdk/sample.cpp
Normal file
142
backend/lib/sdk/sample.cpp
Normal 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;
|
||||
}
|
||||
148
backend/lib/sdk/tool_testSdk.cpp
Normal file
148
backend/lib/sdk/tool_testSdk.cpp
Normal 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;
|
||||
}
|
||||
1
backend/lib/sdk/version.txt
Normal file
1
backend/lib/sdk/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
20240606
|
||||
BIN
backend/lib/sdk_20240606.tgz
Normal file
BIN
backend/lib/sdk_20240606.tgz
Normal file
Binary file not shown.
218
backend/main.py
218
backend/main.py
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
389
backend/scripts/decrypt_chat_data.py
Normal file
389
backend/scripts/decrypt_chat_data.py
Normal 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())
|
||||
704
backend/scripts/load_chat_data.py
Normal file
704
backend/scripts/load_chat_data.py
Normal 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())
|
||||
430
backend/scripts/wework_ctypes.py
Normal file
430
backend/scripts/wework_ctypes.py
Normal 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}")
|
||||
186
backend/scripts/wework_sdk.py
Normal file
186
backend/scripts/wework_sdk.py
Normal 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
376
backend/serv.sh
Executable 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 "$@"
|
||||
140
backend/tests/test_wework_sdk.py
Normal file
140
backend/tests/test_wework_sdk.py
Normal 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
447
backend/uv.lock
generated
@@ -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
2
backstage/.env.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
# API 基础地址
|
||||
VITE_API_BASE_URL=http://mp.yizhezhe.com/api
|
||||
32
backstage/eslint.config.js
Normal file
32
backstage/eslint.config.js
Normal 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
13
backstage/index.html
Normal 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
32
backstage/package.json
Normal 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
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
28
backstage/src/App.jsx
Normal 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
|
||||
102
backstage/src/components/layout/Layout.jsx
Normal file
102
backstage/src/components/layout/Layout.jsx
Normal 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
39
backstage/src/index.css
Normal 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
10
backstage/src/main.jsx
Normal 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>,
|
||||
)
|
||||
182
backstage/src/pages/Dashboard.jsx
Normal file
182
backstage/src/pages/Dashboard.jsx
Normal 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
|
||||
499
backstage/src/pages/LoadArchive.jsx
Normal file
499
backstage/src/pages/LoadArchive.jsx
Normal 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
|
||||
143
backstage/src/pages/Login.jsx
Normal file
143
backstage/src/pages/Login.jsx
Normal 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
|
||||
641
backstage/src/pages/Messages.jsx
Normal file
641
backstage/src/pages/Messages.jsx
Normal 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
|
||||
270
backstage/src/pages/Settings.jsx
Normal file
270
backstage/src/pages/Settings.jsx
Normal 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
|
||||
127
backstage/src/services/api.js
Normal file
127
backstage/src/services/api.js
Normal 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
17
backstage/vite.config.js
Normal 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']
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
727
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,188 +1,503 @@
|
||||
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, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/chat`, {
|
||||
message: inputMessage,
|
||||
conversation_id: conversationId
|
||||
})
|
||||
};
|
||||
setMessages(prev => [...prev, systemMessage]);
|
||||
}, []);
|
||||
|
||||
// 添加助手消息
|
||||
const addAssistantMessage = useCallback((content) => {
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content: response.data.response,
|
||||
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 || '未知错误'}`);
|
||||
}
|
||||
|
||||
// 使用函数式更新,避免依赖 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 result = await workflowService.executeWorkflowWithoutSend(weCom, handleWorkflowProgress);
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
const clearConversation = () => {
|
||||
setMessages([])
|
||||
setConversationId(null)
|
||||
// 检查企业微信配置和首次自动执行
|
||||
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 [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>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<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="flex items-center space-x-2">
|
||||
<span className="text-gray-500">
|
||||
执行次数: {executionCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端侧边栏遮罩 */}
|
||||
{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)]">
|
||||
{/* 聊天区域 */}
|
||||
<main className="flex-1" style={{ height: 'calc(100vh - 80px - 120px)' }}>
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<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">
|
||||
<div className="flex items-center justify-center h-full px-2">
|
||||
<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 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-2xl font-bold text-gray-800 mb-3">欢迎使用, 兜爸 AI 聊天助手</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
我是{roleConfig.name},一位亲切温暖的宝妈客服。有任何关于育儿、选品、团购的问题,都可以随时问我哦~
|
||||
<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-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
💡 提示:开始对话吧,我会以亲切温暖的风格为您解答问题
|
||||
<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-4">
|
||||
{messages.map((message, index) => (
|
||||
<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 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
className={`flex ${justifyClass}`}
|
||||
>
|
||||
<div className={`max-w-[70%] rounded-2xl px-4 py-3 ${
|
||||
<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">
|
||||
<div className="text-sm leading-relaxed break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
<div className={`text-xs mt-1 ${
|
||||
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
|
||||
{/* <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>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
{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">
|
||||
@@ -198,104 +513,121 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-l border-gray-200 flex items-center">
|
||||
|
||||
{/* 工作流状态面板 - 桌面端和移动端抽屉 */}
|
||||
<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="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="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<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>
|
||||
</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="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}
|
||||
>
|
||||
{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 className="text-xs text-gray-500 text-center px-2">
|
||||
点击按钮将生成的智能回复发送到当前企业微信会话
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 角色提示词配置抽屉 */}
|
||||
<RolePromptDrawer
|
||||
isOpen={rolePromptDrawerOpen}
|
||||
onClose={() => setRolePromptDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
448
frontend/src/components/RolePromptDrawer.jsx
Normal file
448
frontend/src/components/RolePromptDrawer.jsx
Normal 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="亲切温暖的沟通 轻松友好的语气 简洁明了的描述"
|
||||
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="回复必须基于提供的知识库中的内容 每条回复应保持在80字以内 只讨论与宝妈相关的主题"
|
||||
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;
|
||||
77
frontend/src/components/WorkflowProgress.jsx
Normal file
77
frontend/src/components/WorkflowProgress.jsx
Normal 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;
|
||||
76
frontend/src/constants/wecomScenes.js
Normal file
76
frontend/src/constants/wecomScenes.js
Normal 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)
|
||||
};
|
||||
};
|
||||
97
frontend/src/constants/workflowSteps.js
Normal file
97
frontend/src/constants/workflowSteps.js
Normal 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;
|
||||
};
|
||||
406
frontend/src/hooks/useWeCom.js
Normal file
406
frontend/src/hooks/useWeCom.js
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
595
frontend/src/services/wecomService.js
Normal file
595
frontend/src/services/wecomService.js
Normal 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
|
||||
};
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
@@ -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
546
frontend/web/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user