Files
get_wechat/chatlog_fastAPI/routers/files.py
yuanzhipeng 646efa132e ```
feat(api): 添加万川平台模型配置获取和同步功能

- 新增 getWanchuanModelConfig 函数,按模型编码获取平台模型配置
- 新增 syncWanchuanModelToSettings 函数,从万川平台拉取模型配置并写入后端 AI 设置
- 支持按用途分多个模型编码(generic/vision/voice)分别同步配置
- 配置失败时跳过对应字段,不影响其他模型同步

feat(settings): 重构AI模型配置界面支持多模块分组

- 将AI配置按话题分析、报告生成、视觉、语音四个模块分组展示
- 每个模块独立配置接口地址、密钥和模型名称
- 添加从万川平台获取配置的按钮和同步功能
- 优化配置状态指示和错误提示信息

refactor(config): 扩展AI配置支持独立的语音视觉报告网关

- 新增 voice_base_url/voice_api_key 配置项
- 新增 vision_base_url/vision_api_key 配置项
- 新增 summary_base_url/summary_api_key 配置项
- 留空时回退到 ai_base_url/ai_api_key 兼容单网关场景

refactor(http): 统一使用共享HTTP客户端减少连接开销

- 替换各处 httpx.AsyncClient 为 shared_client
- 在 lifespan 中正确关闭共享客户端资源
- 优化 get_current_wxid 和 health 检查中的HTTP请求

refactor(ai): 按用途缓存AI客户端支持不同网关配置

- 重构 get_openai_client 支持按(base_url, api_key)缓存
- 新增 get_client_for 函数按用途获取对应客户端
- 支持语音、视觉、报告等不同用途使用独立网关和密钥
```
2026-06-24 20:34:10 +08:00

191 lines
6.5 KiB
Python

import mimetypes
import os
import re
import shutil
import sqlite3
import tempfile
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, StreamingResponse
from config import settings
from services.chatlog_client import chatlog_client
from services.http_client import shared_client
router = APIRouter(prefix="/api/files", tags=["files"])
OFFICE_MEDIA_TYPES = {
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf",
".dwg": "application/acad",
}
def _connect_hardlink_db(hardlink_db: Path) -> sqlite3.Connection:
"""
chatlog may keep hardlink.db open. Copying a tiny snapshot avoids transient
"unable to open database file" errors on Windows while keeping reads safe.
"""
tmp = Path(tempfile.gettempdir()) / f"chatlab_hardlink_{os.getpid()}_{hardlink_db.stat().st_mtime_ns}.db"
if not tmp.exists() or tmp.stat().st_size != hardlink_db.stat().st_size:
shutil.copy2(hardlink_db, tmp)
con = sqlite3.connect(tmp)
con.row_factory = sqlite3.Row
return con
def _safe_download_name(name: str, fallback: str) -> str:
name = (name or fallback).replace("\r", "").replace("\n", "").strip()
return name or fallback
def _content_disposition(filename: str) -> str:
quoted = quote(filename)
ascii_fallback = re.sub(r"[^A-Za-z0-9._-]+", "_", filename) or "download"
return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quoted}"
def _guess_media_type(filename: str, fallback: str = "") -> str:
ext = Path(filename or "").suffix.lower()
return OFFICE_MEDIA_TYPES.get(ext) or mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream"
async def _proxy_chatlog_file(md5: str, filename: str = ""):
url = f"{settings.chatlog_base_url}/file/{quote(md5, safe='')}"
try:
client = shared_client()
resp = await client.get(url, timeout=30)
except Exception:
return None
if resp.status_code != 200 or resp.content == b'"media not found"':
return None
headers = {
"Content-Length": str(len(resp.content)),
"X-ChatLab-File-Source": "chatlog",
}
if filename:
headers["Content-Disposition"] = _content_disposition(filename)
media_type = _guess_media_type(filename, resp.headers.get("content-type") or "")
return StreamingResponse(iter([resp.content]), media_type=media_type, headers=headers)
def _xwechat_roots_from_hardlink_db(hardlink_db: Path) -> list[Path]:
roots: list[Path] = []
try:
con = _connect_hardlink_db(hardlink_db)
row = con.execute("SELECT ValueStdStr FROM db_info WHERE Key='uuid'").fetchone()
raw = row["ValueStdStr"] if row else ""
except Exception:
raw = ""
if raw:
m = re.search(r"([A-Za-z]:\\[^|]+?xwechat_files)", raw)
if m:
roots.append(Path(m.group(1)))
roots.extend([
Path.home() / "xwechat_files",
Path.home() / "Documents" / "WeChat Files",
])
uniq: list[Path] = []
seen = set()
for root in roots:
s = str(root).lower()
if s not in seen:
uniq.append(root)
seen.add(s)
return uniq
def _find_local_file(hardlink_db: Path, md5: str, requested_name: str = "") -> Path | None:
try:
con = _connect_hardlink_db(hardlink_db)
row = con.execute(
"""
SELECT md5, file_name, file_size, dir1, dir2
FROM file_hardlink_info_v4
WHERE md5=?
ORDER BY _rowid_ DESC
LIMIT 1
""",
(md5,),
).fetchone()
except Exception:
row = None
if not row:
return None
names = [requested_name, row["file_name"]]
names = [n for n in names if n]
size = int(row["file_size"] or 0)
roots = _xwechat_roots_from_hardlink_db(hardlink_db)
for root in roots:
if not root.exists():
continue
for name in names:
for candidate in root.rglob(name):
try:
if candidate.is_file() and (not size or candidate.stat().st_size == size):
return candidate
except Exception:
continue
if size:
# Fallback by size in the common file store. This is intentionally limited
# to msg/file to avoid scanning unrelated huge trees for every request.
for file_root in root.glob("*/msg/file"):
if not file_root.exists():
continue
for candidate in file_root.rglob("*"):
try:
if candidate.is_file() and candidate.stat().st_size == size:
if not names or candidate.name in names:
return candidate
except Exception:
continue
return None
@router.get("/{md5}")
async def get_file(md5: str, filename: str = Query("")):
md5 = md5.strip()
if not re.fullmatch(r"[0-9a-fA-F]{8,64}", md5):
raise HTTPException(400, "文件 md5 不合法")
filename = _safe_download_name(filename, md5)
proxied = await _proxy_chatlog_file(md5, filename)
if proxied:
return proxied
db_paths = await chatlog_client.get_db_paths()
hardlink_paths = db_paths.get("media") or []
for raw_path in hardlink_paths:
hardlink_db = Path(raw_path)
if not hardlink_db.exists():
continue
local_file = _find_local_file(hardlink_db, md5, filename)
if local_file:
media_type = _guess_media_type(filename or local_file.name)
return FileResponse(
path=str(local_file),
filename=filename or local_file.name,
media_type=media_type,
headers={
"Content-Disposition": _content_disposition(filename or local_file.name),
"Content-Length": str(local_file.stat().st_size),
"X-ChatLab-File-Source": "local-hardlink",
},
)
raise HTTPException(404, "原文件未找到,可能未解密或已清理")