添加用户消息记录接口;

This commit is contained in:
karlcarlo
2025-12-11 03:53:47 +08:00
parent 6029da058a
commit 420fed4bcf
49 changed files with 7574 additions and 426 deletions

View File

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

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

@@ -0,0 +1,115 @@
from contextlib import asynccontextmanager
from pymongo import AsyncMongoClient
from beanie import init_beanie, PydanticObjectId
from app.models import models
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import os
from app.routes import auth_routes, wework_routes, glm_routes, message_routes
@asynccontextmanager
async def lifespan(app: FastAPI):
# 从环境变量获取数据库配置
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(
database=client[database_name], document_models=models
) # 批量注册模型
print(f"🗄️ 数据库连接成功: {database_name}")
yield # 应用在此处进入正常运行状态,接收请求
# 关闭数据库连接
await client.close()
print(f"数据库连接关闭")
# 创建FastAPI应用
app = FastAPI(
title="企业微信认证服务",
description="获取和管理企业微信access_token的API服务",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan,
)
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(auth_routes.router, prefix="/api")
app.include_router(wework_routes.router, prefix="/api")
app.include_router(glm_routes.router, prefix="/api")
app.include_router(message_routes.router, prefix="/api")
@app.get("/api/")
async def root():
"""
根端点,提供API基本信息
"""
return {
"service": "企业微信认证与会话存档服务",
"version": "1.0.0",
"description": "获取和管理企业微信access_token,会话存档功能,以及DeepSeek AI聊天服务",
"endpoints": {
"docs": "/api/docs",
"redoc": "/api/redoc",
"auth": {"token": "/api/auth/token", "token_info": "/api/auth/token/info"},
"wework": {
"health": "/api/wework/health",
"chat": "/api/wework/chat",
"media": "/api/wework/media",
"decrypt": "/api/wework/decrypt",
"config": "/api/wework/config",
},
"glm": {
"root": "/api/glm/",
"role": "/api/glm/role",
"chat": "/api/glm/chat",
"conversations": "/api/glm/conversations/{conversation_id}",
},
"messages": {
"load": "/api/messages/load",
"list": "/api/messages/",
"stats": "/api/messages/stats",
"detail": "/api/messages/{msgid}",
},
},
}
def main():
"""
主函数,启动FastAPI服务器
"""
# 从环境变量获取主机和端口,使用默认值
host = os.getenv("APP_HOST", "0.0.0.0")
port = int(os.getenv("APP_PORT", "8000"))
print("启动企业微信认证与会话存档服务...")
print(f"API文档地址: http://{host}:{port}/docs")
print(f"API根地址: http://{host}:{port}")
print("企业微信会话存档API: /api/wework/*")
print("DeepSeek AI聊天API: /api/glm/*")
print("消息管理API: /api/messages/*")
uvicorn.run("app.main:app", host=host, port=port, reload=True, log_level="info")
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
消息API路由
提供加载会话存档、查询消息等功能的API接口
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends
import logging
from app.services.message_service import get_message_service, MessageService
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/messages",
tags=["messages"],
responses={404: {"description": "Not found"}},
)
@router.post("/load")
async def load_messages(
seq: int = Query(0, description="起始seq值,首次使用0"),
limit: int = Query(100, description="拉取数量限制", ge=1, le=1000),
service: MessageService = Depends(get_message_service),
):
"""
加载会话存档到数据库
完整流程:拉取 → 解密 → 保存
- 从企业微信拉取会话存档
- 自动解密所有加密消息
- 自动保存到数据库(去重处理)
参数说明:
- seq: 起始序列号,首次拉取使用0,后续使用上次返回的max_seq
- limit: 一次拉取的消息数量,最大1000
返回示例:
```json
{
"success": true,
"message": "消息加载完成",
"data": {
"total_fetched": 150,
"encrypted_count": 120,
"decrypted_success": 115,
"decrypted_failed": 5,
"saved_count": 145,
"duplicates_skipped": 5,
"failed_to_save": 0,
"max_seq": 12345,
"next_seq": 12346
},
"execution_time": "0:00:02.345678",
"timestamp": "2025-12-10T16:44:22.123456"
}
```
"""
try:
logger.info(f"API调用: 加载消息, seq={seq}, limit={limit}")
result = await service.load_messages(seq=seq, limit=limit)
if not result.get("success", False):
raise HTTPException(
status_code=500, detail=result.get("message", "加载消息失败")
)
return result
except Exception as e:
logger.error(f"加载消息API调用失败: {e}")
raise HTTPException(status_code=500, detail=f"加载消息失败: {str(e)}")
@router.get("/")
async def get_messages(
page: int = Query(1, description="页码", ge=1),
page_size: int = Query(20, description="每页数量", ge=1, le=100),
msgtype: Optional[str] = Query(None, description="消息类型过滤"),
roomid: Optional[str] = Query(None, description="群聊ID过滤"),
from_user: Optional[str] = Query(None, description="发送者过滤"),
service: MessageService = Depends(get_message_service),
):
"""
查询消息列表
支持分页和过滤条件查询
"""
try:
logger.info(
f"API调用: 查询消息列表, page={page}, page_size={page_size}, "
f"msgtype={msgtype}, roomid={roomid}, from_user={from_user}"
)
result = await service.get_messages(
page=page,
page_size=page_size,
msgtype=msgtype,
roomid=roomid,
from_user=from_user,
)
if not result.get("success", False):
raise HTTPException(
status_code=500, detail=result.get("message", "查询消息列表失败")
)
return result
except Exception as e:
logger.error(f"查询消息列表失败: {e}")
raise HTTPException(status_code=500, detail=f"查询消息列表失败: {str(e)}")
@router.get("/stats")
async def get_message_stats(service: MessageService = Depends(get_message_service)):
"""
获取消息统计信息
返回数据库中消息的统计信息
"""
try:
# 这里可以添加统计逻辑
# 暂时返回空统计,实际实现需要连接数据库统计
return {
"success": True,
"data": {"total_messages": 0, "message_types": {}, "date_distribution": {}},
}
except Exception as e:
logger.error(f"获取消息统计失败: {e}")
raise HTTPException(status_code=500, detail=f"获取消息统计失败: {str(e)}")
@router.get("/{msgid}")
async def get_message_by_id(
msgid: str, service: MessageService = Depends(get_message_service)
):
"""
根据消息ID获取单个消息详情
"""
try:
# 这里可以添加根据msgid查询消息的逻辑
# 暂时返回空,实际实现需要连接数据库查询
return {"success": True, "data": None, "message": "消息查询功能待实现"}
except Exception as e:
logger.error(f"查询消息详情失败 (msgid: {msgid}): {e}")
raise HTTPException(status_code=500, detail=f"查询消息详情失败: {str(e)}")
# 应用关闭时的清理
@router.on_event("shutdown")
async def shutdown_event():
"""应用关闭时清理资源"""
from app.services.message_service import close_message_service
close_message_service()
logger.info("消息服务已关闭")

View File

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

View File

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

View File

@@ -0,0 +1,611 @@
#!/usr/bin/env python3
"""
消息服务
提供加载会话存档、解密、保存到数据库的整合功能
"""
import asyncio
import json
import os
import logging
import base64
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
import sys
# 添加scripts目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts"))
from wework_ctypes import WeWorkFinanceSDK, WeWorkSDKError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
class MessageService:
"""消息服务,提供加载会话存档的整合功能"""
def __init__(self, corpid: Optional[str] = None, secret: Optional[str] = None):
"""
初始化消息服务
Args:
corpid: 企业ID,如果为None则从环境变量CORPID读取
secret: 聊天内容存档的Secret,如果为None则从环境变量WEWORK_SECRET读取
"""
self.corpid = corpid or os.getenv("CORPID", "wwdf65802ca25ec195")
self.secret = secret or os.getenv(
"WEWORK_SECRET", "-Ta6WMWxBhfGolWnnlO15nQckj3DRKAowUOdX2fwvzE"
)
# SDK实例(延迟初始化)
self._sdk: Optional[WeWorkFinanceSDK] = None
# RSA私钥路径
self.rsa_private_key_path = Path("cert/1_rsa.key")
logger.info(f"消息服务初始化完成,企业ID: {self.corpid}")
@property
def sdk(self) -> WeWorkFinanceSDK:
"""获取SDK实例(延迟初始化)"""
if self._sdk is None:
try:
# 动态库路径
lib_path = (
Path(__file__).parent.parent.parent
/ "lib"
/ "sdk"
/ "libWeWorkFinanceSdk_C.so"
)
if not lib_path.exists():
raise FileNotFoundError(f"动态库不存在: {lib_path}")
self._sdk = WeWorkFinanceSDK(self.corpid, self.secret, str(lib_path))
logger.info("企业微信SDK初始化成功")
except Exception as e:
logger.error(f"企业微信SDK初始化失败: {e}")
raise
return self._sdk
def _load_rsa_private_key(self, key_path: str) -> str:
"""
加载RSA私钥
Args:
key_path: 私钥文件路径
Returns:
私钥内容字符串
"""
try:
with open(key_path, "r") as f:
key_content = f.read()
return key_content
except Exception as e:
raise Exception(f"加载RSA私钥失败: {e}")
def _rsa_decrypt_base64(self, encrypted_data_base64: str, private_key: str) -> str:
"""
使用RSA私钥解密Base64编码的数据
Args:
encrypted_data_base64: Base64编码的加密数据
private_key: RSA私钥字符串
Returns:
解密后的字符串
"""
try:
# 加载私钥
private_key_obj = serialization.load_pem_private_key(
private_key.encode("utf-8"), password=None, backend=default_backend()
)
# Base64解码
encrypted_data = base64.b64decode(encrypted_data_base64)
# RSA解密 (使用PKCS1v15填充)
decrypted_data = private_key_obj.decrypt(encrypted_data, padding.PKCS1v15())
return decrypted_data.decode("utf-8")
except ImportError:
raise Exception("需要安装cryptography库: pip install cryptography")
except Exception as e:
raise Exception(f"RSA解密失败: {e}")
def _decrypt_chat_message(
self, encrypt_random_key: str, encrypt_chat_msg: str, publickey_ver: int = None
) -> dict:
"""
解密聊天消息
Args:
encrypt_random_key: 加密的随机密钥(Base64编码)
encrypt_chat_msg: 加密的聊天消息
publickey_ver: publickey_ver版本号,用于动态匹配私钥
Returns:
解密后的消息字典
"""
# 动态确定私钥路径
actual_key_path = self.rsa_private_key_path
# 如果提供了publickey_ver,则动态匹配
if publickey_ver is not None:
dynamic_key_path = f"cert/{publickey_ver}_rsa.key"
dynamic_path = Path(dynamic_key_path)
if dynamic_path.exists():
actual_key_path = dynamic_path
# 1. 加载RSA私钥
private_key = self._load_rsa_private_key(str(actual_key_path))
# 2. 使用RSA私钥解密encrypt_random_key得到encrypt_key
try:
encrypt_key = self._rsa_decrypt_base64(encrypt_random_key, private_key)
except Exception as e:
# 尝试直接使用encrypt_random_key作为encrypt_key(某些情况下可能已经解密)
encrypt_key = encrypt_random_key
# 3. 使用SDK解密聊天消息
decrypted_msg = self.sdk.decrypt_data(encrypt_key, encrypt_chat_msg)
# 4. 解析JSON
try:
result = json.loads(decrypted_msg)
return result
except json.JSONDecodeError:
# 如果不是JSON,返回原始文本
return {"raw_message": decrypted_msg}
async def _decrypt_chat_data(self, chatdata: List[Dict[str, Any]]) -> tuple:
"""
解密聊天数据
Args:
chatdata: 原始聊天数据列表
Returns:
(decrypted_chatdata, stats)
decrypted_chatdata: 解密后的聊天数据列表
stats: 解密统计信息字典
"""
decrypted_chatdata = []
stats = {
"total_encrypted_messages": 0,
"successful_decrypts": 0,
"failed_decrypts": 0,
}
# 检查私钥文件是否存在
if not self.rsa_private_key_path.exists():
logger.warning(f"RSA私钥文件不存在: {self.rsa_private_key_path}")
logger.warning("将跳过解密,保存原始加密数据")
return chatdata, stats
logger.info(f"使用RSA私钥: {self.rsa_private_key_path.absolute()}")
logger.info("开始解密聊天数据...")
for i, msg in enumerate(chatdata):
encrypt_random_key = msg.get("encrypt_random_key", "")
encrypt_chat_msg = msg.get("encrypt_chat_msg", "")
# 检查是否有加密数据
if not encrypt_random_key or not encrypt_chat_msg:
# 没有加密数据,直接添加到结果中
decrypted_chatdata.append(msg)
continue
stats["total_encrypted_messages"] += 1
try:
# 获取publickey_ver
publickey_ver = msg.get("publickey_ver")
# 解密消息
decrypted_msg = self._decrypt_chat_message(
encrypt_random_key,
encrypt_chat_msg,
publickey_ver,
)
# 创建新的消息对象,保留原始字段但移除加密字段
new_msg = {}
for key, value in msg.items():
if key not in ["encrypt_random_key", "encrypt_chat_msg"]:
new_msg[key] = value
# 将解密后的字段合并到消息中
if isinstance(decrypted_msg, dict):
for key, value in decrypted_msg.items():
new_msg[key] = value
decrypted_chatdata.append(new_msg)
stats["successful_decrypts"] += 1
# 显示进度
if stats["successful_decrypts"] % 100 == 0:
logger.info(f" 已解密 {stats['successful_decrypts']} 条消息...")
except Exception as e:
stats["failed_decrypts"] += 1
logger.warning(f"解密消息失败 (seq: {msg.get('seq', 'N/A')}): {e}")
# 解密失败,跳过此消息(不添加到结果中)
continue
return decrypted_chatdata, stats
async def _save_messages_to_database(
self, messages: List[Dict[str, Any]]
) -> Dict[str, int]:
"""
将消息保存到数据库
Args:
messages: 消息字典列表
Returns:
存储统计信息字典
"""
stats = {
"total_messages": len(messages),
"saved": 0,
"skipped_duplicates": 0,
"failed": 0,
}
if not messages:
logger.info("没有消息需要存储到数据库")
return stats
logger.info(f"开始将 {len(messages)} 条消息存储到数据库...")
try:
# 导入数据库相关模块
from pymongo import AsyncMongoClient
from beanie import init_beanie
from app.models import models
from app.models.message import Message
# 从环境变量获取数据库配置
mongodb_url = os.getenv(
"MONGODB_URL",
"mongodb://dblink:mgdb4link@localhost:27017/?authSource=admin",
)
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(database=client[database_name], document_models=models)
logger.info(f"数据库连接成功: {database_name}")
# 批量处理消息
batch_size = 100
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
batch_msgids = [msg.get("msgid") for msg in batch if msg.get("msgid")]
# 检查哪些消息已经存在
existing_messages = await Message.find(
{"msgid": {"$in": batch_msgids}}
).to_list()
existing_msgids = {msg.msgid for msg in existing_messages}
# 过滤掉已存在的消息
new_messages = []
for msg in batch:
msgid = msg.get("msgid")
if not msgid:
stats["failed"] += 1
continue
if msgid in existing_msgids:
stats["skipped_duplicates"] += 1
continue
try:
# 创建Message实例
message_data = {
"msgid": msg.get("msgid"),
"action": msg.get("action"),
"from_user": msg.get(
"from"
), # 从原始数据的from字段读取,保存为from_user
"tolist": msg.get("tolist", []),
"roomid": msg.get("roomid"),
"text": msg.get("text", {}),
"msgtime": msg.get("msgtime"),
"msgtype": msg.get("msgtype"),
"remark": msg.get("remark"),
"seq": msg.get("seq", 1000),
"isEnabled": True,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
# 移除None值
message_data = {
k: v for k, v in message_data.items() if v is not None
}
message = Message(**message_data)
new_messages.append(message)
except Exception as e:
logger.error(f"创建消息实例失败 (msgid: {msgid}): {e}")
stats["failed"] += 1
# 批量插入新消息
if new_messages:
try:
await Message.insert_many(new_messages)
stats["saved"] += len(new_messages)
logger.info(
f" 已存储 {len(new_messages)} 条消息 (进度: {i + len(batch)}/{len(messages)})"
)
except Exception as e:
logger.error(f"批量插入失败: {e}")
stats["failed"] += len(new_messages)
# 关闭数据库连接
await client.close()
logger.info(f"数据库存储完成:")
logger.info(f" 总计消息: {stats['total_messages']}")
logger.info(f" 成功存储: {stats['saved']}")
logger.info(f" 跳过重复: {stats['skipped_duplicates']}")
logger.info(f" 存储失败: {stats['failed']}")
except Exception as e:
logger.error(f"数据库存储过程中发生错误: {e}")
import traceback
traceback.print_exc()
stats["failed"] = len(messages)
return stats
async def get_messages(
self,
page: int = 1,
page_size: int = 20,
msgtype: Optional[str] = None,
roomid: Optional[str] = None,
from_user: Optional[str] = None,
) -> Dict[str, Any]:
"""
查询消息列表,支持分页和过滤
Args:
page: 页码(从1开始)
page_size: 每页数量
msgtype: 消息类型过滤
roomid: 群聊ID过滤
from_user: 发送者过滤
Returns:
分页查询结果
"""
try:
# 导入数据库相关模块
from pymongo import AsyncMongoClient
from beanie import init_beanie
from app.models import models
from app.models.message import Message
# 从环境变量获取数据库配置
mongodb_url = os.getenv(
"MONGODB_URL",
"mongodb://dblink:mgdb4link@localhost:27017/?authSource=admin",
)
database_name = os.getenv("DATABASE_NAME", "douba_chat_dev")
# 初始化数据库连接
client = AsyncMongoClient(mongodb_url)
await init_beanie(database=client[database_name], document_models=models)
# 构建查询条件
query = {}
if msgtype:
query["msgtype"] = msgtype
if roomid:
query["roomid"] = roomid
if from_user:
query["from_user"] = from_user # 使用from_user字段进行查询
# 计算跳过的文档数量
skip_count = (page - 1) * page_size
# 执行查询
messages = await Message.find(
query,
skip=skip_count,
limit=page_size,
sort=[("msgtime", -1)], # 按消息时间倒序排列
).to_list()
# 获取总记录数
total_count = await Message.find(query).count()
# 关闭数据库连接
await client.close()
# 准备响应数据
return {
"success": True,
"data": {
"page": page,
"page_size": page_size,
"total": total_count,
"total_pages": (
(total_count + page_size - 1) // page_size
if page_size > 0
else 0
),
"messages": messages,
},
}
except Exception as e:
logger.error(f"查询消息列表失败: {e}")
import traceback
traceback.print_exc()
return {
"success": False,
"message": f"查询消息列表失败: {str(e)}",
"data": {
"page": page,
"page_size": page_size,
"total": 0,
"total_pages": 0,
"messages": [],
},
}
async def load_messages(self, seq: int = 0, limit: int = 100) -> Dict[str, Any]:
"""
加载会话存档到数据库
完整流程:拉取 → 解密 → 保存
Args:
seq: 起始seq值,首次使用0
limit: 拉取数量限制
Returns:
执行结果和统计信息
"""
start_time = datetime.now()
try:
# 从环境变量获取代理配置
proxy = os.getenv("WEWORK_PROXY", "")
passwd = os.getenv("WEWORK_PROXY_PASSWD", "")
timeout = int(os.getenv("WEWORK_TIMEOUT", "10"))
logger.info(f"开始加载会话存档: seq={seq}, limit={limit}")
# 1. 拉取会话数据
logger.info("步骤1: 从企业微信拉取会话数据...")
result_str = self.sdk.get_chat_data(
seq=seq, limit=limit, proxy=proxy, passwd=passwd, timeout=timeout
)
# 解析结果
result = json.loads(result_str)
errcode = result.get("errcode", -1)
errmsg = result.get("errmsg", "")
if errcode != 0:
raise Exception(f"拉取会话数据失败: errcode={errcode}, errmsg={errmsg}")
chatdata = result.get("chatdata", [])
total_fetched = len(chatdata)
logger.info(f"成功拉取 {total_fetched} 条聊天记录")
if total_fetched == 0:
return {
"success": True,
"message": "没有新的聊天记录",
"data": {
"total_fetched": 0,
"encrypted_count": 0,
"decrypted_success": 0,
"decrypted_failed": 0,
"saved_count": 0,
"duplicates_skipped": 0,
"max_seq": seq,
"next_seq": seq,
},
"execution_time": str(datetime.now() - start_time),
}
# 2. 解密消息
logger.info("步骤2: 解密加密消息...")
decrypted_chatdata, decrypt_stats = await self._decrypt_chat_data(chatdata)
# 3. 保存到数据库
logger.info("步骤3: 保存消息到数据库...")
db_stats = await self._save_messages_to_database(decrypted_chatdata)
# 计算最大seq值
max_seq = max(
(msg.get("seq", 0) for msg in decrypted_chatdata), default=seq
)
# 准备响应数据
execution_time = datetime.now() - start_time
response_data = {
"success": True,
"message": "消息加载完成",
"data": {
"total_fetched": total_fetched,
"encrypted_count": decrypt_stats["total_encrypted_messages"],
"decrypted_success": decrypt_stats["successful_decrypts"],
"decrypted_failed": decrypt_stats["failed_decrypts"],
"saved_count": db_stats["saved"],
"duplicates_skipped": db_stats["skipped_duplicates"],
"failed_to_save": db_stats["failed"],
"max_seq": max_seq,
"next_seq": max_seq + 1 if total_fetched > 0 else seq,
},
"execution_time": str(execution_time),
"timestamp": datetime.now().isoformat(),
}
logger.info(f"消息加载完成,耗时: {execution_time}")
return response_data
except json.JSONDecodeError as e:
error_msg = f"解析JSON数据失败: {e}"
logger.error(error_msg)
return {
"success": False,
"message": error_msg,
"execution_time": str(datetime.now() - start_time),
}
except Exception as e:
error_msg = f"加载消息失败: {e}"
logger.error(error_msg)
import traceback
traceback.print_exc()
return {
"success": False,
"message": error_msg,
"execution_time": str(datetime.now() - start_time),
}
def close(self):
"""关闭服务,释放资源"""
if self._sdk:
self._sdk.close()
self._sdk = None
logger.info("消息服务已关闭")
# 全局服务实例(单例模式)
_message_service: Optional[MessageService] = None
def get_message_service() -> MessageService:
"""获取消息服务实例(单例)"""
global _message_service
if _message_service is None:
_message_service = MessageService()
return _message_service
def close_message_service():
"""关闭消息服务"""
global _message_service
if _message_service:
_message_service.close()
_message_service = None

View File

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