添加用户消息记录接口;
This commit is contained in:
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)
|
||||
115
backend/app/main.py
Normal file
115
backend/app/main.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
from beanie import init_beanie, PydanticObjectId
|
||||
from app.models import models
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
import os
|
||||
from app.routes import auth_routes, wework_routes, glm_routes, message_routes
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 从环境变量获取数据库配置
|
||||
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
|
||||
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
|
||||
|
||||
# 初始化数据库连接
|
||||
client = AsyncMongoClient(mongodb_url)
|
||||
await init_beanie(
|
||||
database=client[database_name], document_models=models
|
||||
) # 批量注册模型
|
||||
print(f"🗄️ 数据库连接成功: {database_name}")
|
||||
|
||||
yield # 应用在此处进入正常运行状态,接收请求
|
||||
|
||||
# 关闭数据库连接
|
||||
await client.close()
|
||||
print(f"数据库连接关闭")
|
||||
|
||||
|
||||
# 创建FastAPI应用
|
||||
app = FastAPI(
|
||||
title="企业微信认证服务",
|
||||
description="获取和管理企业微信access_token的API服务",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 配置CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(auth_routes.router, prefix="/api")
|
||||
app.include_router(wework_routes.router, prefix="/api")
|
||||
app.include_router(glm_routes.router, prefix="/api")
|
||||
app.include_router(message_routes.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/")
|
||||
async def root():
|
||||
"""
|
||||
根端点,提供API基本信息
|
||||
"""
|
||||
return {
|
||||
"service": "企业微信认证与会话存档服务",
|
||||
"version": "1.0.0",
|
||||
"description": "获取和管理企业微信access_token,会话存档功能,以及DeepSeek AI聊天服务",
|
||||
"endpoints": {
|
||||
"docs": "/api/docs",
|
||||
"redoc": "/api/redoc",
|
||||
"auth": {"token": "/api/auth/token", "token_info": "/api/auth/token/info"},
|
||||
"wework": {
|
||||
"health": "/api/wework/health",
|
||||
"chat": "/api/wework/chat",
|
||||
"media": "/api/wework/media",
|
||||
"decrypt": "/api/wework/decrypt",
|
||||
"config": "/api/wework/config",
|
||||
},
|
||||
"glm": {
|
||||
"root": "/api/glm/",
|
||||
"role": "/api/glm/role",
|
||||
"chat": "/api/glm/chat",
|
||||
"conversations": "/api/glm/conversations/{conversation_id}",
|
||||
},
|
||||
"messages": {
|
||||
"load": "/api/messages/load",
|
||||
"list": "/api/messages/",
|
||||
"stats": "/api/messages/stats",
|
||||
"detail": "/api/messages/{msgid}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数,启动FastAPI服务器
|
||||
"""
|
||||
# 从环境变量获取主机和端口,使用默认值
|
||||
host = os.getenv("APP_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("APP_PORT", "8000"))
|
||||
|
||||
print("启动企业微信认证与会话存档服务...")
|
||||
print(f"API文档地址: http://{host}:{port}/docs")
|
||||
print(f"API根地址: http://{host}:{port}")
|
||||
print("企业微信会话存档API: /api/wework/*")
|
||||
print("DeepSeek AI聊天API: /api/glm/*")
|
||||
print("消息管理API: /api/messages/*")
|
||||
|
||||
uvicorn.run("app.main:app", host=host, port=port, reload=True, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .base import BaseOut
|
||||
from .message import Message
|
||||
|
||||
models = [Message]
|
||||
|
||||
# 在所有模型导入后调用 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
|
||||
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)}"
|
||||
}
|
||||
206
backend/app/routes/glm_routes.py
Normal file
206
backend/app/routes/glm_routes.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import httpx
|
||||
import os
|
||||
|
||||
# 创建API路由,使用/glm前缀
|
||||
router = APIRouter(prefix="/glm", tags=["GLM聊天"])
|
||||
|
||||
|
||||
# 数据模型
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
async def get_role_config():
|
||||
"""获取当前角色配置"""
|
||||
return current_role_config
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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],
|
||||
}
|
||||
162
backend/app/routes/message_routes.py
Normal file
162
backend/app/routes/message_routes.py
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
消息API路由
|
||||
提供加载会话存档、查询消息等功能的API接口
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
import logging
|
||||
|
||||
from app.services.message_service import get_message_service, MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/messages",
|
||||
tags=["messages"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/load")
|
||||
async def load_messages(
|
||||
seq: int = Query(0, description="起始seq值,首次使用0"),
|
||||
limit: int = Query(100, description="拉取数量限制", ge=1, le=1000),
|
||||
service: MessageService = Depends(get_message_service),
|
||||
):
|
||||
"""
|
||||
加载会话存档到数据库
|
||||
|
||||
完整流程:拉取 → 解密 → 保存
|
||||
|
||||
- 从企业微信拉取会话存档
|
||||
- 自动解密所有加密消息
|
||||
- 自动保存到数据库(去重处理)
|
||||
|
||||
参数说明:
|
||||
- seq: 起始序列号,首次拉取使用0,后续使用上次返回的max_seq
|
||||
- limit: 一次拉取的消息数量,最大1000
|
||||
|
||||
返回示例:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "消息加载完成",
|
||||
"data": {
|
||||
"total_fetched": 150,
|
||||
"encrypted_count": 120,
|
||||
"decrypted_success": 115,
|
||||
"decrypted_failed": 5,
|
||||
"saved_count": 145,
|
||||
"duplicates_skipped": 5,
|
||||
"failed_to_save": 0,
|
||||
"max_seq": 12345,
|
||||
"next_seq": 12346
|
||||
},
|
||||
"execution_time": "0:00:02.345678",
|
||||
"timestamp": "2025-12-10T16:44:22.123456"
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
logger.info(f"API调用: 加载消息, seq={seq}, limit={limit}")
|
||||
result = await service.load_messages(seq=seq, limit=limit)
|
||||
|
||||
if not result.get("success", False):
|
||||
raise HTTPException(
|
||||
status_code=500, detail=result.get("message", "加载消息失败")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载消息API调用失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"加载消息失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_messages(
|
||||
page: int = Query(1, description="页码", ge=1),
|
||||
page_size: int = Query(20, description="每页数量", ge=1, le=100),
|
||||
msgtype: Optional[str] = Query(None, description="消息类型过滤"),
|
||||
roomid: Optional[str] = Query(None, description="群聊ID过滤"),
|
||||
from_user: Optional[str] = Query(None, description="发送者过滤"),
|
||||
service: MessageService = Depends(get_message_service),
|
||||
):
|
||||
"""
|
||||
查询消息列表
|
||||
|
||||
支持分页和过滤条件查询
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"API调用: 查询消息列表, page={page}, page_size={page_size}, "
|
||||
f"msgtype={msgtype}, roomid={roomid}, from_user={from_user}"
|
||||
)
|
||||
|
||||
result = await service.get_messages(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
msgtype=msgtype,
|
||||
roomid=roomid,
|
||||
from_user=from_user,
|
||||
)
|
||||
|
||||
if not result.get("success", False):
|
||||
raise HTTPException(
|
||||
status_code=500, detail=result.get("message", "查询消息列表失败")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询消息列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"查询消息列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_message_stats(service: MessageService = Depends(get_message_service)):
|
||||
"""
|
||||
获取消息统计信息
|
||||
|
||||
返回数据库中消息的统计信息
|
||||
"""
|
||||
try:
|
||||
# 这里可以添加统计逻辑
|
||||
# 暂时返回空统计,实际实现需要连接数据库统计
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"total_messages": 0, "message_types": {}, "date_distribution": {}},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取消息统计失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取消息统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{msgid}")
|
||||
async def get_message_by_id(
|
||||
msgid: str, service: MessageService = Depends(get_message_service)
|
||||
):
|
||||
"""
|
||||
根据消息ID获取单个消息详情
|
||||
"""
|
||||
try:
|
||||
# 这里可以添加根据msgid查询消息的逻辑
|
||||
# 暂时返回空,实际实现需要连接数据库查询
|
||||
return {"success": True, "data": None, "message": "消息查询功能待实现"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询消息详情失败 (msgid: {msgid}): {e}")
|
||||
raise HTTPException(status_code=500, detail=f"查询消息详情失败: {str(e)}")
|
||||
|
||||
|
||||
# 应用关闭时的清理
|
||||
@router.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""应用关闭时清理资源"""
|
||||
from app.services.message_service import close_message_service
|
||||
|
||||
close_message_service()
|
||||
logger.info("消息服务已关闭")
|
||||
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)
|
||||
611
backend/app/services/message_service.py
Normal file
611
backend/app/services/message_service.py
Normal file
@@ -0,0 +1,611 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
消息服务
|
||||
提供加载会话存档、解密、保存到数据库的整合功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
import sys
|
||||
|
||||
# 添加scripts目录到路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
|
||||
|
||||
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageService:
|
||||
"""消息服务,提供加载会话存档的整合功能"""
|
||||
|
||||
def __init__(self, corpid: Optional[str] = None, secret: Optional[str] = None):
|
||||
"""
|
||||
初始化消息服务
|
||||
|
||||
Args:
|
||||
corpid: 企业ID,如果为None则从环境变量CORPID读取
|
||||
secret: 聊天内容存档的Secret,如果为None则从环境变量WEWORK_SECRET读取
|
||||
"""
|
||||
self.corpid = corpid or os.getenv("CORPID", "wwdf65802ca25ec195")
|
||||
self.secret = secret or os.getenv(
|
||||
"WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE"
|
||||
)
|
||||
|
||||
# SDK实例(延迟初始化)
|
||||
self._sdk: Optional[WeWorkFinanceSDK] = None
|
||||
|
||||
# RSA私钥路径
|
||||
self.rsa_private_key_path = Path("cert/1_rsa.key")
|
||||
|
||||
logger.info(f"消息服务初始化完成,企业ID: {self.corpid}")
|
||||
|
||||
@property
|
||||
def sdk(self) -> WeWorkFinanceSDK:
|
||||
"""获取SDK实例(延迟初始化)"""
|
||||
if self._sdk is None:
|
||||
try:
|
||||
# 动态库路径
|
||||
lib_path = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "lib"
|
||||
/ "sdk"
|
||||
/ "libWeWorkFinanceSdk_C.so"
|
||||
)
|
||||
if not lib_path.exists():
|
||||
raise FileNotFoundError(f"动态库不存在: {lib_path}")
|
||||
|
||||
self._sdk = WeWorkFinanceSDK(self.corpid, self.secret, str(lib_path))
|
||||
logger.info("企业微信SDK初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信SDK初始化失败: {e}")
|
||||
raise
|
||||
return self._sdk
|
||||
|
||||
def _load_rsa_private_key(self, key_path: str) -> str:
|
||||
"""
|
||||
加载RSA私钥
|
||||
|
||||
Args:
|
||||
key_path: 私钥文件路径
|
||||
|
||||
Returns:
|
||||
私钥内容字符串
|
||||
"""
|
||||
try:
|
||||
with open(key_path, "r") as f:
|
||||
key_content = f.read()
|
||||
return key_content
|
||||
except Exception as e:
|
||||
raise Exception(f"加载RSA私钥失败: {e}")
|
||||
|
||||
def _rsa_decrypt_base64(self, encrypted_data_base64: str, private_key: str) -> str:
|
||||
"""
|
||||
使用RSA私钥解密Base64编码的数据
|
||||
|
||||
Args:
|
||||
encrypted_data_base64: Base64编码的加密数据
|
||||
private_key: RSA私钥字符串
|
||||
|
||||
Returns:
|
||||
解密后的字符串
|
||||
"""
|
||||
try:
|
||||
# 加载私钥
|
||||
private_key_obj = serialization.load_pem_private_key(
|
||||
private_key.encode("utf-8"), password=None, backend=default_backend()
|
||||
)
|
||||
|
||||
# Base64解码
|
||||
encrypted_data = base64.b64decode(encrypted_data_base64)
|
||||
|
||||
# RSA解密 (使用PKCS1v15填充)
|
||||
decrypted_data = private_key_obj.decrypt(encrypted_data, padding.PKCS1v15())
|
||||
|
||||
return decrypted_data.decode("utf-8")
|
||||
|
||||
except ImportError:
|
||||
raise Exception("需要安装cryptography库: pip install cryptography")
|
||||
except Exception as e:
|
||||
raise Exception(f"RSA解密失败: {e}")
|
||||
|
||||
def _decrypt_chat_message(
|
||||
self, encrypt_random_key: str, encrypt_chat_msg: str, publickey_ver: int = None
|
||||
) -> dict:
|
||||
"""
|
||||
解密聊天消息
|
||||
|
||||
Args:
|
||||
encrypt_random_key: 加密的随机密钥(Base64编码)
|
||||
encrypt_chat_msg: 加密的聊天消息
|
||||
publickey_ver: publickey_ver版本号,用于动态匹配私钥
|
||||
|
||||
Returns:
|
||||
解密后的消息字典
|
||||
"""
|
||||
# 动态确定私钥路径
|
||||
actual_key_path = self.rsa_private_key_path
|
||||
|
||||
# 如果提供了publickey_ver,则动态匹配
|
||||
if publickey_ver is not None:
|
||||
dynamic_key_path = f"cert/{publickey_ver}_rsa.key"
|
||||
dynamic_path = Path(dynamic_key_path)
|
||||
if dynamic_path.exists():
|
||||
actual_key_path = dynamic_path
|
||||
|
||||
# 1. 加载RSA私钥
|
||||
private_key = self._load_rsa_private_key(str(actual_key_path))
|
||||
|
||||
# 2. 使用RSA私钥解密encrypt_random_key得到encrypt_key
|
||||
try:
|
||||
encrypt_key = self._rsa_decrypt_base64(encrypt_random_key, private_key)
|
||||
except Exception as e:
|
||||
# 尝试直接使用encrypt_random_key作为encrypt_key(某些情况下可能已经解密)
|
||||
encrypt_key = encrypt_random_key
|
||||
|
||||
# 3. 使用SDK解密聊天消息
|
||||
decrypted_msg = self.sdk.decrypt_data(encrypt_key, encrypt_chat_msg)
|
||||
|
||||
# 4. 解析JSON
|
||||
try:
|
||||
result = json.loads(decrypted_msg)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是JSON,返回原始文本
|
||||
return {"raw_message": decrypted_msg}
|
||||
|
||||
async def _decrypt_chat_data(self, chatdata: List[Dict[str, Any]]) -> tuple:
|
||||
"""
|
||||
解密聊天数据
|
||||
|
||||
Args:
|
||||
chatdata: 原始聊天数据列表
|
||||
|
||||
Returns:
|
||||
(decrypted_chatdata, stats)
|
||||
decrypted_chatdata: 解密后的聊天数据列表
|
||||
stats: 解密统计信息字典
|
||||
"""
|
||||
decrypted_chatdata = []
|
||||
stats = {
|
||||
"total_encrypted_messages": 0,
|
||||
"successful_decrypts": 0,
|
||||
"failed_decrypts": 0,
|
||||
}
|
||||
|
||||
# 检查私钥文件是否存在
|
||||
if not self.rsa_private_key_path.exists():
|
||||
logger.warning(f"RSA私钥文件不存在: {self.rsa_private_key_path}")
|
||||
logger.warning("将跳过解密,保存原始加密数据")
|
||||
return chatdata, stats
|
||||
|
||||
logger.info(f"使用RSA私钥: {self.rsa_private_key_path.absolute()}")
|
||||
logger.info("开始解密聊天数据...")
|
||||
|
||||
for i, msg in enumerate(chatdata):
|
||||
encrypt_random_key = msg.get("encrypt_random_key", "")
|
||||
encrypt_chat_msg = msg.get("encrypt_chat_msg", "")
|
||||
|
||||
# 检查是否有加密数据
|
||||
if not encrypt_random_key or not encrypt_chat_msg:
|
||||
# 没有加密数据,直接添加到结果中
|
||||
decrypted_chatdata.append(msg)
|
||||
continue
|
||||
|
||||
stats["total_encrypted_messages"] += 1
|
||||
|
||||
try:
|
||||
# 获取publickey_ver
|
||||
publickey_ver = msg.get("publickey_ver")
|
||||
|
||||
# 解密消息
|
||||
decrypted_msg = self._decrypt_chat_message(
|
||||
encrypt_random_key,
|
||||
encrypt_chat_msg,
|
||||
publickey_ver,
|
||||
)
|
||||
|
||||
# 创建新的消息对象,保留原始字段但移除加密字段
|
||||
new_msg = {}
|
||||
for key, value in msg.items():
|
||||
if key not in ["encrypt_random_key", "encrypt_chat_msg"]:
|
||||
new_msg[key] = value
|
||||
|
||||
# 将解密后的字段合并到消息中
|
||||
if isinstance(decrypted_msg, dict):
|
||||
for key, value in decrypted_msg.items():
|
||||
new_msg[key] = value
|
||||
|
||||
decrypted_chatdata.append(new_msg)
|
||||
stats["successful_decrypts"] += 1
|
||||
|
||||
# 显示进度
|
||||
if stats["successful_decrypts"] % 100 == 0:
|
||||
logger.info(f" 已解密 {stats['successful_decrypts']} 条消息...")
|
||||
|
||||
except Exception as e:
|
||||
stats["failed_decrypts"] += 1
|
||||
logger.warning(f"解密消息失败 (seq: {msg.get('seq', 'N/A')}): {e}")
|
||||
# 解密失败,跳过此消息(不添加到结果中)
|
||||
continue
|
||||
|
||||
return decrypted_chatdata, stats
|
||||
|
||||
async def _save_messages_to_database(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
将消息保存到数据库
|
||||
|
||||
Args:
|
||||
messages: 消息字典列表
|
||||
|
||||
Returns:
|
||||
存储统计信息字典
|
||||
"""
|
||||
stats = {
|
||||
"total_messages": len(messages),
|
||||
"saved": 0,
|
||||
"skipped_duplicates": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
if not messages:
|
||||
logger.info("没有消息需要存储到数据库")
|
||||
return stats
|
||||
|
||||
logger.info(f"开始将 {len(messages)} 条消息存储到数据库...")
|
||||
|
||||
try:
|
||||
# 导入数据库相关模块
|
||||
from pymongo import AsyncMongoClient
|
||||
from beanie import init_beanie
|
||||
from app.models import models
|
||||
from app.models.message import Message
|
||||
|
||||
# 从环境变量获取数据库配置
|
||||
mongodb_url = os.getenv(
|
||||
"MONGODB_URL",
|
||||
"mongodb://dblink:mgdb4link@localhost:27017/?authSource=admin",
|
||||
)
|
||||
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
|
||||
|
||||
# 初始化数据库连接
|
||||
client = AsyncMongoClient(mongodb_url)
|
||||
await init_beanie(database=client[database_name], document_models=models)
|
||||
|
||||
logger.info(f"数据库连接成功: {database_name}")
|
||||
|
||||
# 批量处理消息
|
||||
batch_size = 100
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i : i + batch_size]
|
||||
batch_msgids = [msg.get("msgid") for msg in batch if msg.get("msgid")]
|
||||
|
||||
# 检查哪些消息已经存在
|
||||
existing_messages = await Message.find(
|
||||
{"msgid": {"$in": batch_msgids}}
|
||||
).to_list()
|
||||
existing_msgids = {msg.msgid for msg in existing_messages}
|
||||
|
||||
# 过滤掉已存在的消息
|
||||
new_messages = []
|
||||
for msg in batch:
|
||||
msgid = msg.get("msgid")
|
||||
if not msgid:
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
|
||||
if msgid in existing_msgids:
|
||||
stats["skipped_duplicates"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# 创建Message实例
|
||||
message_data = {
|
||||
"msgid": msg.get("msgid"),
|
||||
"action": msg.get("action"),
|
||||
"from_user": msg.get(
|
||||
"from"
|
||||
), # 从原始数据的from字段读取,保存为from_user
|
||||
"tolist": msg.get("tolist", []),
|
||||
"roomid": msg.get("roomid"),
|
||||
"text": msg.get("text", {}),
|
||||
"msgtime": msg.get("msgtime"),
|
||||
"msgtype": msg.get("msgtype"),
|
||||
"remark": msg.get("remark"),
|
||||
"seq": msg.get("seq", 1000),
|
||||
"isEnabled": True,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
}
|
||||
|
||||
# 移除None值
|
||||
message_data = {
|
||||
k: v for k, v in message_data.items() if v is not None
|
||||
}
|
||||
|
||||
message = Message(**message_data)
|
||||
new_messages.append(message)
|
||||
except Exception as e:
|
||||
logger.error(f"创建消息实例失败 (msgid: {msgid}): {e}")
|
||||
stats["failed"] += 1
|
||||
|
||||
# 批量插入新消息
|
||||
if new_messages:
|
||||
try:
|
||||
await Message.insert_many(new_messages)
|
||||
stats["saved"] += len(new_messages)
|
||||
logger.info(
|
||||
f" 已存储 {len(new_messages)} 条消息 (进度: {i + len(batch)}/{len(messages)})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"批量插入失败: {e}")
|
||||
stats["failed"] += len(new_messages)
|
||||
|
||||
# 关闭数据库连接
|
||||
await client.close()
|
||||
|
||||
logger.info(f"数据库存储完成:")
|
||||
logger.info(f" 总计消息: {stats['total_messages']}")
|
||||
logger.info(f" 成功存储: {stats['saved']}")
|
||||
logger.info(f" 跳过重复: {stats['skipped_duplicates']}")
|
||||
logger.info(f" 存储失败: {stats['failed']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库存储过程中发生错误: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
stats["failed"] = len(messages)
|
||||
|
||||
return stats
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
msgtype: Optional[str] = None,
|
||||
roomid: Optional[str] = None,
|
||||
from_user: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
查询消息列表,支持分页和过滤
|
||||
|
||||
Args:
|
||||
page: 页码(从1开始)
|
||||
page_size: 每页数量
|
||||
msgtype: 消息类型过滤
|
||||
roomid: 群聊ID过滤
|
||||
from_user: 发送者过滤
|
||||
|
||||
Returns:
|
||||
分页查询结果
|
||||
"""
|
||||
try:
|
||||
# 导入数据库相关模块
|
||||
from pymongo import AsyncMongoClient
|
||||
from beanie import init_beanie
|
||||
from app.models import models
|
||||
from app.models.message import Message
|
||||
|
||||
# 从环境变量获取数据库配置
|
||||
mongodb_url = os.getenv(
|
||||
"MONGODB_URL",
|
||||
"mongodb://dblink:mgdb4link@localhost:27017/?authSource=admin",
|
||||
)
|
||||
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
|
||||
|
||||
# 初始化数据库连接
|
||||
client = AsyncMongoClient(mongodb_url)
|
||||
await init_beanie(database=client[database_name], document_models=models)
|
||||
|
||||
# 构建查询条件
|
||||
query = {}
|
||||
if msgtype:
|
||||
query["msgtype"] = msgtype
|
||||
if roomid:
|
||||
query["roomid"] = roomid
|
||||
if from_user:
|
||||
query["from_user"] = from_user # 使用from_user字段进行查询
|
||||
|
||||
# 计算跳过的文档数量
|
||||
skip_count = (page - 1) * page_size
|
||||
|
||||
# 执行查询
|
||||
messages = await Message.find(
|
||||
query,
|
||||
skip=skip_count,
|
||||
limit=page_size,
|
||||
sort=[("msgtime", -1)], # 按消息时间倒序排列
|
||||
).to_list()
|
||||
|
||||
# 获取总记录数
|
||||
total_count = await Message.find(query).count()
|
||||
|
||||
# 关闭数据库连接
|
||||
await client.close()
|
||||
|
||||
# 准备响应数据
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total_count,
|
||||
"total_pages": (
|
||||
(total_count + page_size - 1) // page_size
|
||||
if page_size > 0
|
||||
else 0
|
||||
),
|
||||
"messages": messages,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询消息列表失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"查询消息列表失败: {str(e)}",
|
||||
"data": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": 0,
|
||||
"total_pages": 0,
|
||||
"messages": [],
|
||||
},
|
||||
}
|
||||
|
||||
async def load_messages(self, seq: int = 0, limit: int = 100) -> Dict[str, Any]:
|
||||
"""
|
||||
加载会话存档到数据库
|
||||
完整流程:拉取 → 解密 → 保存
|
||||
|
||||
Args:
|
||||
seq: 起始seq值,首次使用0
|
||||
limit: 拉取数量限制
|
||||
|
||||
Returns:
|
||||
执行结果和统计信息
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# 从环境变量获取代理配置
|
||||
proxy = os.getenv("WEWORK_PROXY", "")
|
||||
passwd = os.getenv("WEWORK_PROXY_PASSWD", "")
|
||||
timeout = int(os.getenv("WEWORK_TIMEOUT", "10"))
|
||||
|
||||
logger.info(f"开始加载会话存档: seq={seq}, limit={limit}")
|
||||
|
||||
# 1. 拉取会话数据
|
||||
logger.info("步骤1: 从企业微信拉取会话数据...")
|
||||
result_str = self.sdk.get_chat_data(
|
||||
seq=seq, limit=limit, proxy=proxy, passwd=passwd, timeout=timeout
|
||||
)
|
||||
|
||||
# 解析结果
|
||||
result = json.loads(result_str)
|
||||
errcode = result.get("errcode", -1)
|
||||
errmsg = result.get("errmsg", "")
|
||||
|
||||
if errcode != 0:
|
||||
raise Exception(f"拉取会话数据失败: errcode={errcode}, errmsg={errmsg}")
|
||||
|
||||
chatdata = result.get("chatdata", [])
|
||||
total_fetched = len(chatdata)
|
||||
logger.info(f"成功拉取 {total_fetched} 条聊天记录")
|
||||
|
||||
if total_fetched == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "没有新的聊天记录",
|
||||
"data": {
|
||||
"total_fetched": 0,
|
||||
"encrypted_count": 0,
|
||||
"decrypted_success": 0,
|
||||
"decrypted_failed": 0,
|
||||
"saved_count": 0,
|
||||
"duplicates_skipped": 0,
|
||||
"max_seq": seq,
|
||||
"next_seq": seq,
|
||||
},
|
||||
"execution_time": str(datetime.now() - start_time),
|
||||
}
|
||||
|
||||
# 2. 解密消息
|
||||
logger.info("步骤2: 解密加密消息...")
|
||||
decrypted_chatdata, decrypt_stats = await self._decrypt_chat_data(chatdata)
|
||||
|
||||
# 3. 保存到数据库
|
||||
logger.info("步骤3: 保存消息到数据库...")
|
||||
db_stats = await self._save_messages_to_database(decrypted_chatdata)
|
||||
|
||||
# 计算最大seq值
|
||||
max_seq = max(
|
||||
(msg.get("seq", 0) for msg in decrypted_chatdata), default=seq
|
||||
)
|
||||
|
||||
# 准备响应数据
|
||||
execution_time = datetime.now() - start_time
|
||||
|
||||
response_data = {
|
||||
"success": True,
|
||||
"message": "消息加载完成",
|
||||
"data": {
|
||||
"total_fetched": total_fetched,
|
||||
"encrypted_count": decrypt_stats["total_encrypted_messages"],
|
||||
"decrypted_success": decrypt_stats["successful_decrypts"],
|
||||
"decrypted_failed": decrypt_stats["failed_decrypts"],
|
||||
"saved_count": db_stats["saved"],
|
||||
"duplicates_skipped": db_stats["skipped_duplicates"],
|
||||
"failed_to_save": db_stats["failed"],
|
||||
"max_seq": max_seq,
|
||||
"next_seq": max_seq + 1 if total_fetched > 0 else seq,
|
||||
},
|
||||
"execution_time": str(execution_time),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
logger.info(f"消息加载完成,耗时: {execution_time}")
|
||||
return response_data
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"解析JSON数据失败: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"execution_time": str(datetime.now() - start_time),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"加载消息失败: {e}"
|
||||
logger.error(error_msg)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"execution_time": str(datetime.now() - start_time),
|
||||
}
|
||||
|
||||
def close(self):
|
||||
"""关闭服务,释放资源"""
|
||||
if self._sdk:
|
||||
self._sdk.close()
|
||||
self._sdk = None
|
||||
logger.info("消息服务已关闭")
|
||||
|
||||
|
||||
# 全局服务实例(单例模式)
|
||||
_message_service: Optional[MessageService] = None
|
||||
|
||||
|
||||
def get_message_service() -> MessageService:
|
||||
"""获取消息服务实例(单例)"""
|
||||
global _message_service
|
||||
if _message_service is None:
|
||||
_message_service = MessageService()
|
||||
return _message_service
|
||||
|
||||
|
||||
def close_message_service():
|
||||
"""关闭消息服务"""
|
||||
global _message_service
|
||||
if _message_service:
|
||||
_message_service.close()
|
||||
_message_service = None
|
||||
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()
|
||||
Reference in New Issue
Block a user