feat:完成大部分数据文件上传解析功能

This commit is contained in:
2026-10-08 08:23:01 -07:00
parent 05c050d2ba
commit bb6a9405db
27 changed files with 2675 additions and 18 deletions
+334
View File
@@ -0,0 +1,334 @@
"""上传流程:保存原文件 → 子进程解析(超时终止)→ 分配表名 → 保存记录;以及查看、删除和过期清理"""
import asyncio
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import BinaryIO
from uuid import uuid4
from loguru import logger
from app.config import PROJECT_ROOT, settings
from app.core.exceptions import BizException
from app.datafiles.parse import ParsedFile, ParsedTable
from app.datafiles.storage import SOURCE_NAME, DataFileStore, file_dir
from app.schemas.base import JsonObject
from app.schemas.data_file import (
DataFileRecord,
DataTable,
RequestDataFile,
RequestDataTable,
)
from app.services.conversation import ConversationStore
MAX_FILES_PER_CONVERSATION = 10
# 解析超时(秒),超时终止子进程;同时最多解析的文件数,其余排队
PARSE_TIMEOUT = 60
PARSE_CONCURRENCY = 2
# 每小时清理一次目录;一小时内创建的目录不删,避免删掉正在上传的文件
SWEEP_INTERVAL = 3600
SWEEP_GRACE = 3600
# 只清理这个格式的目录,DATA_FILE_DIR 配错时也不会删到别的东西
FILE_ID = re.compile(r"^f_[0-9a-f]{32}$")
MAX_NAME_CHARS = 200
CHUNK_BYTES = 1024 * 1024
NOT_FOUND = "文件不存在或已过期"
PARSE_CRASHED = "解析文件时出错,请检查文件内容,或另存为 CSV 后重新上传"
class DataFileService:
def __init__(
self,
store: DataFileStore,
conversations: ConversationStore,
root: Path | None = None,
*,
max_mb: int | None = None,
ttl: int | None = None,
):
self.store = store
self.root = root or settings.data_file_dir
self.max_mb = max_mb or settings.data_file_max_mb
self._conversations = conversations
self._ttl = timedelta(seconds=ttl or settings.data_file_ttl)
self._parsing = asyncio.Semaphore(PARSE_CONCURRENCY)
# 文件数检查、分配表名和写入记录要一起完成,并发上传时才不会超限或重名;单实例部署,进程内的锁就够了
self._saving = asyncio.Lock()
async def upload(
self, user_id: int, conversation_id: str, name: str | None, source: BinaryIO
) -> DataFileRecord:
await self._check_conversation(conversation_id, user_id)
# 先粗查一次,满了就不必上传和解析;写入前在锁里再查一次
_check_count(await self.store.find(conversation_id))
file_id = f"f_{uuid4().hex}"
directory = file_dir(self.root, file_id)
try:
size, sha256 = await asyncio.to_thread(self._save, source, directory)
async with self._parsing:
parsed = await _parse(directory)
async with self._saving:
existing = await self.store.find(conversation_id, include_deleted=True)
_check_count([record for record in existing if not record.deleted])
record = self._record(
file_id,
user_id,
conversation_id,
_clean_name(name),
size,
sha256,
parsed,
_next_table_number(existing),
)
await self.store.insert(record)
except BaseException:
# 失败的上传不留文件,会话内的文件数不变
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
raise
logger.info(
"数据文件已上传:{} 格式 {},{} 张表",
file_id,
record.format,
len(record.tables),
)
return record
async def list_files(
self, user_id: int, conversation_id: str
) -> list[DataFileRecord]:
await self._check_conversation(conversation_id, user_id)
records = await self.store.find(conversation_id)
return [record for record in records if record.owner_id == user_id]
async def get_file(self, user_id: int, file_id: str) -> DataFileRecord:
record = await self.store.get(file_id)
# 他人的文件与不存在同样返回 404
if record is None or record.owner_id != user_id:
raise BizException(404, NOT_FOUND)
return record
async def delete_file(self, user_id: int, file_id: str) -> None:
await self.get_file(user_id, file_id)
await self.store.mark_deleted(file_id)
# Windows 上正在查询的 Parquet 删不掉,留给定时清理
await asyncio.to_thread(
shutil.rmtree, file_dir(self.root, file_id), ignore_errors=True
)
async def request_files(
self, user_id: int, conversation_id: str, attachments: list[JsonObject]
) -> list[RequestDataFile]:
"""本次请求可用的文件,调用方已校验会话归属
attachments 里 kind 为 data_file 的引用必须是本会话未过期的文件,否则 404;其他种类不处理
"""
records = [
record
for record in await self.store.find(conversation_id)
if record.owner_id == user_id
]
attached = set()
for item in attachments:
if item.get("kind") != "data_file":
continue
if not isinstance(file_id := item.get("file_id"), str):
raise BizException(422, "data_file 附件缺少 file_id")
attached.add(file_id)
if attached - {record.file_id for record in records}:
raise BizException(404, NOT_FOUND)
return [
RequestDataFile(
file_id=record.file_id,
name=record.name,
attached_this_turn=record.file_id in attached,
tables=[
RequestDataTable.model_validate(
{
**table.model_dump(),
"path": str(
file_dir(self.root, record.file_id) / table.parquet
),
}
)
for table in record.tables
],
warnings=record.warnings,
)
for record in records
]
async def sweep(self) -> int:
"""删除没有对应记录(过期、已删除、内存模式重启后丢失)的目录,返回删除的个数"""
live = await self.store.live_ids()
return await asyncio.to_thread(self._remove_orphans, live)
async def run_sweeper(self) -> None:
while True:
try:
if removed := await self.sweep():
logger.info("已清理 {} 个过期或删除的数据文件目录", removed)
except Exception as error:
logger.opt(exception=error).error("清理数据文件目录失败")
await asyncio.sleep(SWEEP_INTERVAL)
async def _check_conversation(self, conversation_id: str, user_id: int) -> None:
if not await self._conversations.exists(conversation_id, user_id):
raise BizException(404, "会话不存在或已过期")
def _save(self, source: BinaryIO, directory: Path) -> tuple[int, str]:
"""边写边算大小和 sha256,超过上限立即停止"""
directory.mkdir(parents=True)
digest, size = hashlib.sha256(), 0
with (directory / SOURCE_NAME).open("wb") as target:
while chunk := source.read(CHUNK_BYTES):
size += len(chunk)
if size > self.max_mb * 1024 * 1024:
raise BizException(413, f"文件不能超过 {self.max_mb} MB")
digest.update(chunk)
target.write(chunk)
return size, digest.hexdigest()
def _record(
self,
file_id: str,
user_id: int,
conversation_id: str,
name: str,
size: int,
sha256: str,
parsed: ParsedFile,
first_number: int,
) -> DataFileRecord:
tables, warnings = [], list(parsed.warnings)
for number, table in enumerate(parsed.tables, start=first_number):
table_name = f"t{number}"
tables.append(
DataTable(
table=table_name,
label=_label(name, table),
sheet=table.sheet,
rows=table.rows,
columns=table.columns,
preview=table.preview,
profile=table.profile,
parquet=table.path.name,
)
)
warnings.extend(
warning.model_copy(update={"table": table_name})
for warning in table.warnings
)
now = datetime.now(UTC)
return DataFileRecord(
file_id=file_id,
owner_id=user_id,
conversation_id=conversation_id,
name=name,
format=parsed.format,
encoding=parsed.encoding,
size=size,
sha256=sha256,
created_at=now,
expires_at=now + self._ttl,
tables=tables,
warnings=warnings,
)
def _remove_orphans(self, live: set[str]) -> int:
if not self.root.is_dir():
return 0
cutoff = time.time() - SWEEP_GRACE
removed = 0
for entry in self.root.iterdir():
if (
FILE_ID.match(entry.name)
and entry.name not in live
and entry.is_dir()
and entry.stat().st_mtime < cutoff
):
shutil.rmtree(entry, ignore_errors=True)
removed += 1
return removed
async def _parse(directory: Path) -> ParsedFile:
python, env = _python()
command = [
python,
"-m",
"app.datafiles.worker",
str(directory / SOURCE_NAME),
str(directory),
]
try:
# 超时后 subprocess.run 会终止子进程
completed = await asyncio.to_thread(
subprocess.run,
command,
cwd=PROJECT_ROOT,
env=env,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=PARSE_TIMEOUT,
)
except subprocess.TimeoutExpired:
raise BizException(
422, f"解析超过 {PARSE_TIMEOUT} 秒,文件可能太大或太复杂,请拆分后上传"
) from None
if completed.returncode != 0:
logger.error(
"数据文件解析进程异常退出({}):{}",
completed.returncode,
completed.stderr.decode(errors="replace")[-2000:],
)
raise BizException(422, PARSE_CRASHED)
result = json.loads(completed.stdout)
if error := result.get("error"):
raise BizException(error["status"], error["message"])
return ParsedFile.model_validate(result["file"])
def _python() -> tuple[str, dict[str, str] | None]:
"""Windows 的 venv 里 sys.executable 是启动器,由它再拉起真正的解释器;超时时只杀得掉启动器,
刚启动就被杀时解释器会成为孤儿,一直占着输出管道。和 multiprocessing 一样直接启动基础解释器,
用 __PYVENV_LAUNCHER__ 告诉它所在的 venv
"""
if sys.platform == "win32" and sys._base_executable != sys.executable:
return sys._base_executable, {
**os.environ,
"__PYVENV_LAUNCHER__": sys.executable,
}
return sys.executable, None
def _check_count(records: list[DataFileRecord]) -> None:
if len(records) >= MAX_FILES_PER_CONVERSATION:
raise BizException(
409, f"每个会话最多 {MAX_FILES_PER_CONVERSATION} 个文件,请先删除不用的文件"
)
def _next_table_number(records: list[DataFileRecord]) -> int:
"""表名在会话内递增,已删除文件的表名也不再使用"""
used = (int(table.table[1:]) for record in records for table in record.tables)
return max(used, default=0) + 1
def _clean_name(name: str | None) -> str:
# 有的浏览器会带上本地路径,只保留文件名
base = re.split(r"[\\/]", name or "")[-1].strip()
return base[:MAX_NAME_CHARS] or "未命名文件"
def _label(name: str, table: ParsedTable) -> str:
label = f"{name} / {table.sheet}" if table.sheet else name
return f"{label}({table.part})" if table.part else label