Files
AgentLab/backend/app/files/store.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

552 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""文件的存放、上传上下文、绑定、读权限、删除和清理(docs/design/normal-user-apps.md N9、§3.8)。
**存放。** 按 sha256 内容寻址:data/files/<前两位>/<sha>,只读(0444),和上传表格的原件存档同一种写法
(data/raw_store.put_addressed)。元数据一行一个上传(files 表):同样的内容传两次是两行、一个文件。
**两步。** 先 POST /api/files 拿回引用 {file_id, name, size, sha256, type},再把引用当表单值提交。上传要带上下文:
应用的入口({app_id, entry})、待办({task_id})或蓝图({blueprint_id},试运行、个人应用),只有能用那个入口、能处理
那条待办、能改那个蓝图的人传得上来。
**绑定。** 提交入口(apps/entries.py)、回复待办(process/tasks.py)时核对:引用是提交人自己传的、在这个上下文里传的、
还没被用过,然后绑到这次运行(一次性)。表单里的引用换成登记的那一份(名字、大小、哈希以登记为准),连同 sha256
进运行输入、随封存固定。入口要先占住(claim)再发起运行:发起失败放回去,成功了改成运行 id。
**读。** 绑到运行的,读得到那次运行的人能下载(auth/access.can_read_run);写进记录表那一行的,看得到那一行的人能下载
(records/access.py 的行级可见);没绑定的只有上传的人自己。文件不做遮罩。
**清理。** 24 小时前上传、还没绑定的删掉(调度循环每小时一次,cleanup_tick)。
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy import delete, or_, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.apps.spec import FILE_REF_KEYS
from app.core.config import settings
from app.db.base import SessionLocal, utcnow
from app.db.models_blueprints import StoredFile
from app.files import ALLOWED_MIME_PREFIXES, UNBOUND_TTL_S
logger = logging.getLogger(__name__)
#: 入口提交时先占住文件(发起运行之前),bound_run_id 写成这个前缀加一个随机串;发起成功换成运行 id
CLAIM_PREFIX = "claim:"
#: 上下文里认的几项
CONTEXT_KEYS = ("app_id", "entry", "task_id", "blueprint_id", "field")
#: 扩展名 → 类型(白名单,files.ALLOWED_MIME_PREFIXES)
EXT_MIME = {
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".csv": "text/csv",
".txt": "text/plain",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
}
#: 类型 → 文件开头应有的字节。对不上就拒:扩展名谁都能改,内容得是它说的那种文件
_MAGIC = {
"application/pdf": (b"%PDF",),
"image/png": (b"\x89PNG\r\n\x1a\n",),
"image/jpeg": (b"\xff\xd8\xff",),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": (b"PK\x03\x04",),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": (b"PK\x03\x04",),
"application/msword": (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",),
"application/vnd.ms-excel": (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",),
}
_TEXT_TYPES = ("text/csv", "text/plain")
_SHA = re.compile(r"^[0-9a-f]{64}$")
#: 文件名最长存几个字
NAME_MAX = 300
#: 文件控件(x-widget: file)的值要是引用这个形状;校验不过时表单错误的 code(apps/spec.FORM_ERROR_CODES 的 file)
FILE_ERROR_CODE = "file"
class FileRefused(Exception):
"""上传、绑定、下载、删除不成:status 是 HTTP 状态,code 是问题编号(files.*,白话在 app/plain.py),field 是表单
里哪个字段(绑定时有)。message 直接给人看。"""
def __init__(self, status: int, code: str, message: str, *, field: str | None = None) -> None:
super().__init__(message)
self.status = status
self.code = code
self.message = message
self.field = field
# --------------------------------------------------------------------------
# 存放
# --------------------------------------------------------------------------
def files_root() -> Path:
"""文件的根目录。按调用时的 settings 现算:测试会换数据目录。"""
return settings.data_dir / "files"
def blob_path(sha: str) -> Path:
if not _SHA.match(sha or ""):
raise ValueError("文件哈希的格式不对")
return files_root() / sha[:2] / sha
def read_blob(sha: str) -> bytes | None:
"""取回文件内容并核对哈希:文件不在、内容和哈希对不上都返回 None(不交出被改过的内容)。"""
try:
data = blob_path(sha).read_bytes()
except (OSError, ValueError):
return None
if hashlib.sha256(data).hexdigest() != sha:
logger.warning("文件 %s 的内容和哈希对不上,不交出", sha)
return None
return data
def type_of(filename: str, raw: bytes) -> str:
"""按扩展名定类型,再核对文件开头的字节。不在白名单、对不上、空文件都抛 FileRefused。"""
ext = Path(filename or "").suffix.lower()
mime = EXT_MIME.get(ext)
if mime is None or not mime.startswith(ALLOWED_MIME_PREFIXES):
allowed = "、".join(sorted({e.lstrip(".").upper() for e in EXT_MIME}))
raise FileRefused(415, "files.type_not_allowed", f"只能上传这些类型的文件:{allowed}")
if not raw:
raise FileRefused(422, "files.empty", "文件是空的")
if mime in _TEXT_TYPES:
head = raw[:65536]
if b"\x00" in head or not _decodes(head):
raise FileRefused(415, "files.content_mismatch", f"「{filename}」不是文字文件,和扩展名对不上")
return mime
if not raw.startswith(_MAGIC.get(mime, (b"",))):
raise FileRefused(415, "files.content_mismatch", f"「{filename}」的内容和扩展名对不上,请确认文件没有改过扩展名")
return mime
def _decodes(head: bytes) -> bool:
for encoding in ("utf-8", "gb18030"):
try:
head.decode(encoding)
return True
except UnicodeDecodeError:
# 截在多字节字符中间的那几个字节不算
try:
head[:-3].decode(encoding)
return True
except UnicodeDecodeError:
continue
return False
def ref_of(row: StoredFile) -> dict[str, Any]:
"""登记的那一份引用:表单值、运行输入里放的就是它。"""
return {"file_id": row.id, "name": row.name, "size": row.size, "sha256": row.sha256, "type": row.mime}
def as_ref(value: Any) -> dict[str, Any] | None:
"""表单值 → 引用(字典或 JSON 文字都认);形状不对为 None。"""
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
return None
if not isinstance(value, dict) or not set(value) <= set(FILE_REF_KEYS):
return None
if not isinstance(value.get("file_id"), str) or not value["file_id"]:
return None
return value
async def save(session: AsyncSession, raw: bytes, filename: str, *, uploaded_by: str | None,
context: dict[str, Any]) -> StoredFile:
"""存内容、登记一行。类型按扩展名和内容核对(type_of)。"""
from app.data import raw_store
name = (Path(filename or "").name or "附件").strip()[:NAME_MAX]
mime = type_of(name, raw)
sha, _ = await asyncio.to_thread(raw_store.put_addressed, raw, files_root())
row = StoredFile(sha256=sha, name=name, size=len(raw), mime=mime, uploaded_by=uploaded_by,
context={k: v for k, v in context.items() if k in CONTEXT_KEYS})
session.add(row)
await session.commit()
return row
# --------------------------------------------------------------------------
# 上传的上下文:谁能往哪里传
# --------------------------------------------------------------------------
def parse_context(raw: Any) -> dict[str, str]:
"""POST /api/files 的 context(JSON 文字或对象)→ 只留认得的几项。三种之一都没有的抛 FileRefused。"""
if isinstance(raw, str):
try:
raw = json.loads(raw) if raw.strip() else {}
except ValueError:
raw = None
if not isinstance(raw, dict):
raise FileRefused(422, "files.context_invalid", "上传需要说明是给哪个入口、哪条待办或哪个蓝图用的")
ctx = {k: str(v) for k, v in raw.items() if k in CONTEXT_KEYS and isinstance(v, (str, int)) and str(v).strip()}
if not (("app_id" in ctx and "entry" in ctx) or "task_id" in ctx or "blueprint_id" in ctx):
raise FileRefused(422, "files.context_invalid", "上传需要说明是给哪个入口、哪条待办或哪个蓝图用的")
return ctx
async def check_context(session: AsyncSession, principal: Any, ctx: dict[str, str]) -> dict[str, Any] | None:
"""这个人能不能往这个上下文传文件:能用那个入口、能处理那条待办、能改那个蓝图。交回那个字段的文件控件设置
(x-file:accept、max_mb;没说是哪个字段、字段没有设置时为 None),上传时按它再核一遍。"""
if "task_id" in ctx:
return await _task_context(session, principal, ctx)
if "app_id" in ctx:
return await _entry_context(session, principal, ctx)
return await _blueprint_context(session, principal, ctx)
_NO_ACCESS = "没有往这里上传文件的权限"
async def _entry_context(session: AsyncSession, principal: Any, ctx: dict[str, str]) -> dict[str, Any] | None:
from app.apps.access import app_role, at_least
from app.apps.tree import entry_of, resolve_form
from app.db.models_apps import App, AppVersion
app = await session.get(App, ctx["app_id"])
role = await app_role(session, principal, app) if app is not None else None
if app is None or app.status == "archived" or role is None:
raise FileRefused(404, "files.context_invalid", "应用不存在或已归档")
if not at_least(role, "use"):
raise FileRefused(403, "files.forbidden", _NO_ACCESS)
spec = app.draft or {}
if app.published_version:
version = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none()
spec = (version.spec if version is not None else None) or spec
entry = entry_of(spec, ctx["entry"])
if entry is None:
raise FileRefused(404, "files.context_invalid", f"应用里没有入口「{ctx['entry']}」")
return _field_setting(resolve_form(spec, entry.get("form")), ctx.get("field"))
async def _task_context(session: AsyncSession, principal: Any, ctx: dict[str, str]) -> dict[str, Any] | None:
from app.db.models_process import Task, TaskAssignment
from app.process.tasks import handles
task = await session.get(Task, ctx["task_id"])
if task is None or task.status != "pending":
raise FileRefused(404, "files.context_invalid", "待办不存在或已经处理完了")
if principal.mode != "off":
rows = list((await session.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id))).scalars())
if not handles(rows, principal):
raise FileRefused(403, "files.forbidden", _NO_ACCESS)
return _field_setting(task.form, ctx.get("field"))
async def _blueprint_context(session: AsyncSession, principal: Any, ctx: dict[str, str]) -> dict[str, Any] | None:
from app.db.models_blueprints import Blueprint
from app.blueprints.store import BLUEPRINT_GONE, can_access
bp = await session.get(Blueprint, ctx["blueprint_id"])
# 同读写蓝图(blueprints/store.can_access):拥有者;共享蓝图另有管理员、它发布出来的应用的管理者。看不到的和不存在的
# 一个说法(同 store.ensure_access「看不到的一律 404」):回 403 就等于告诉人这张蓝图在
if bp is None or (principal.mode != "off" and not (
(bp.scope != "shared" or principal.at_least("builder")) and await can_access(session, principal, bp))):
raise FileRefused(404, "files.context_invalid", BLUEPRINT_GONE)
return None
def _field_setting(form: Any, name: str | None) -> dict[str, Any] | None:
props = form.get("properties") if isinstance(form, dict) else None
prop = props.get(name) if isinstance(props, dict) and name else None
if not isinstance(prop, dict):
return None
setting = prop.get("x-file")
return setting if isinstance(setting, dict) else {}
def accept_problem(setting: dict[str, Any] | None, *, name: str, mime: str, size: int) -> str | None:
"""文件控件设置(x-file:accept 类型或扩展名列表、max_mb)核一个文件:不合返回原因。"""
if not setting:
return None
accept = [str(a).lower() for a in setting.get("accept") or [] if isinstance(a, str)]
if accept:
ext = Path(name).suffix.lower()
if not any(a == mime or a == ext or (a.endswith("/*") and mime.startswith(a[:-1])) for a in accept):
return f"这里只收这些类型的文件:{'、'.join(accept)}"
max_mb = setting.get("max_mb")
if isinstance(max_mb, (int, float)) and not isinstance(max_mb, bool) and size > max_mb * 1024 * 1024:
return f"这里的文件不能超过 {max_mb:g} MB"
return None
# --------------------------------------------------------------------------
# 绑定
# --------------------------------------------------------------------------
#: 还没绑到记录上:JSON 列显式写过 None 的存的是 JSON 的 null,不是 SQL 的 NULL,两种都算
_NO_RECORD = text("(files.bound_record IS NULL OR CAST(files.bound_record AS TEXT) = 'null')")
def file_fields(form: Any) -> list[str]:
"""表单里的文件字段(x-widget: file)。"""
props = form.get("properties") if isinstance(form, dict) else None
if not isinstance(props, dict):
return []
return [name for name, prop in props.items() if isinstance(prop, dict) and prop.get("x-widget") == "file"]
def _context_matches(row: StoredFile, expected: dict[str, str]) -> bool:
got = row.context or {}
return all(str(got.get(k) or "") == v for k, v in expected.items())
def _field_label(form: Any, name: str) -> str:
prop = ((form or {}).get("properties") or {}).get(name) or {}
return str(prop.get("title") or name)
async def _check(session: AsyncSession, form: Any, values: dict[str, Any], *, principal: Any,
context: dict[str, str]) -> list[tuple[str, StoredFile]]:
"""表单里每个填了的文件字段:引用要对得上一行登记、是提交人自己传的、在这个上下文里传的、还没被用过。
交回 [(字段, 那一行)];有一个不对就抛 FileRefused(field 是那个字段)。"""
out: list[tuple[str, StoredFile]] = []
seen: set[str] = set()
for name in file_fields(form):
value = values.get(name)
if value in (None, ""):
continue
label = _field_label(form, name)
ref = as_ref(value)
if ref is None:
raise FileRefused(422, "files.ref_invalid", f"「{label}」需要先上传文件", field=name)
row = await session.get(StoredFile, ref["file_id"])
if row is None:
raise FileRefused(422, "files.not_found", f"「{label}」的文件不在了(超过 24 小时没提交会被清理),请重新上传",
field=name)
if row.uploaded_by != getattr(principal, "username", None):
raise FileRefused(422, "files.not_yours", f"「{label}」的文件不是你上传的,请重新上传", field=name)
if ref.get("sha256") not in (None, row.sha256) or row.id in seen:
raise FileRefused(422, "files.ref_invalid", f"「{label}」的文件引用对不上,请重新上传", field=name)
if row.bound_run_id is not None or row.bound_record is not None:
raise FileRefused(409, "files.already_bound", f"「{label}」的文件已经提交过一次,请重新上传", field=name)
if not _context_matches(row, context):
raise FileRefused(422, "files.context_mismatch", f"「{label}」的文件是在别处上传的,请在这里重新上传",
field=name)
seen.add(row.id)
out.append((name, row))
return out
@dataclass
class Claim:
"""入口提交时占住的文件:发起运行成功后 finish(绑到运行),失败 release(放回去)。"""
token: str
ids: list[str] = field(default_factory=list)
async def claim(form: Any, values: dict[str, Any], *, principal: Any, context: dict[str, str]
) -> tuple[dict[str, Any], Claim]:
"""核对表单里的文件引用并占住它们(自己的会话、立刻提交,两次并发提交只有一次占得到)。交回换成登记引用的表单值
和占位。没有文件字段时什么都不做。"""
from app.db.base import new_id
token = Claim(token=f"{CLAIM_PREFIX}{new_id()[:20]}")
if not file_fields(form):
return values, token
async with SessionLocal() as session:
checked = await _check(session, form, values, principal=principal, context=context)
out = dict(values)
for name, row in checked:
done = await session.execute(update(StoredFile).where(
StoredFile.id == row.id, StoredFile.bound_run_id.is_(None), _NO_RECORD)
.values(bound_run_id=token.token))
if done.rowcount != 1:
await session.rollback()
raise FileRefused(409, "files.already_bound",
f"「{_field_label(form, name)}」的文件已经提交过一次,请重新上传", field=name)
token.ids.append(row.id)
out[name] = ref_of(row)
await session.commit()
return out, token
async def finish(token: Claim, run_id: str) -> None:
if not token.ids:
return
async with SessionLocal() as session:
await session.execute(update(StoredFile).where(StoredFile.bound_run_id == token.token)
.values(bound_run_id=run_id))
await session.commit()
async def release(token: Claim) -> None:
if not token.ids:
return
async with SessionLocal() as session:
await session.execute(update(StoredFile).where(StoredFile.bound_run_id == token.token)
.values(bound_run_id=None))
await session.commit()
async def bind_in(session: AsyncSession, form: Any, values: dict[str, Any], *, principal: Any, run_id: str,
context: dict[str, str]) -> dict[str, Any]:
"""在调用方的会话里把表单里的文件绑到一次已有的运行(回复待办):和回复一起提交。交回换成登记引用的表单值。"""
checked = await _check(session, form, values, principal=principal, context=context)
out = dict(values)
for name, row in checked:
done = await session.execute(update(StoredFile).where(
StoredFile.id == row.id, StoredFile.bound_run_id.is_(None), _NO_RECORD)
.values(bound_run_id=run_id))
if done.rowcount != 1:
raise FileRefused(409, "files.already_bound",
f"「{_field_label(form, name)}」的文件已经提交过一次,请重新上传", field=name)
out[name] = ref_of(row)
return out
async def bound_files(run_id: str) -> dict[str, dict[str, Any]]:
"""这次运行绑定的文件:{file_id: 引用}。用到 file_text 的节点执行前读一次(files/text.prepare),只认这些。"""
from app.db.models import Run
async with SessionLocal() as session:
out = {row.id: ref_of(row) for row in (await session.execute(
select(StoredFile).where(StoredFile.bound_run_id == run_id))).scalars()}
# 入口提交是先占住、发起成功后才改成运行 id(claim → finish):运行开头的节点可能赶在改之前执行。
# 占着的文件只要出现在这次运行的输入里、是发起人在这个应用里传的,就是这次提交占的那几个
run = await session.get(Run, run_id)
wanted = [ref["file_id"] for v in ((run.input if run is not None else None) or {}).values()
if (ref := as_ref(v)) is not None and ref["file_id"] not in out]
if wanted and run is not None:
origin = run.origin or {}
for row in (await session.execute(select(StoredFile).where(
StoredFile.id.in_(wanted), StoredFile.bound_run_id.like(f"{CLAIM_PREFIX}%")))).scalars():
ctx = row.context or {}
same = (ctx.get("app_id") and ctx.get("app_id") == run.app_id) or (
ctx.get("blueprint_id") and ctx.get("blueprint_id") == origin.get("blueprint_id"))
if row.uploaded_by == run.started_by and same:
out[row.id] = ref_of(row)
return out
# --------------------------------------------------------------------------
# 读权限、删除
# --------------------------------------------------------------------------
async def readable(session: AsyncSession, principal: Any, row: StoredFile) -> bool:
"""能不能下载这个文件:关登录全部放行;上传的人自己;读得到绑定的那次运行;看得到绑定的那一行记录。"""
from app.api.deps import can_read_run
if principal.mode == "off":
return True
if principal.username and row.uploaded_by == principal.username:
return True
run_id = row.bound_run_id
if run_id and not run_id.startswith(CLAIM_PREFIX) and await can_read_run(principal, run_id):
return True
if isinstance(row.bound_record, dict):
return await _record_visible(session, principal, row.bound_record)
return False
async def _record_visible(session: AsyncSession, principal: Any, bound: dict[str, Any]) -> bool:
from app.db.models_blueprints import RecordsTable
from app.records import store as records_store
from app.records.access import scope_of
from app.records.tables import columns_of, path_of
table = await session.get(RecordsTable, str(bound.get("table_id") or ""))
if table is None:
return False
scope = await scope_of(session, principal, table.app_id)
if scope == "all":
return True
if scope != "mine":
return False
try:
got = await asyncio.to_thread(records_store.row_by_id, path_of(table), columns_of(table),
str(bound.get("row_id") or ""))
except records_store.RecordsRefused:
return False
return got is not None and got.get("_submitted_by") == principal.username
async def remove(session: AsyncSession, principal: Any, file_id: str) -> None:
"""删一个文件:只能删自己传的、还没绑定的。内容没有别的登记引用时一起删掉。"""
row = await session.get(StoredFile, file_id)
if row is None or row.uploaded_by != getattr(principal, "username", None):
raise FileRefused(404, "files.not_found", "文件不存在,或者不是你上传的")
if row.bound_run_id is not None or row.bound_record is not None:
raise FileRefused(409, "files.bound", "文件已经提交了,不能删除:它是那次运行的证据")
sha = row.sha256
await session.delete(row)
await session.commit()
await _drop_unreferenced(session, {sha})
async def _drop_unreferenced(session: AsyncSession, shas: set[str]) -> int:
"""这些内容没有任何登记引用了就删文件(连同抽出的文字缓存)。返回删了几个。"""
if not shas:
return 0
used = set((await session.execute(select(StoredFile.sha256).where(StoredFile.sha256.in_(list(shas))))).scalars())
gone = 0
for sha in shas - used:
from app.files.text import drop_cache
try:
blob_path(sha).unlink()
gone += 1
except FileNotFoundError:
pass
except (OSError, ValueError):
logger.warning("文件 %s 删不掉,下次清理再试", sha, exc_info=True)
drop_cache(sha)
return gone
# --------------------------------------------------------------------------
# 清理
# --------------------------------------------------------------------------
#: 调度循环里多久清理一次(秒)
CLEANUP_EVERY_S = 3600
_last_cleanup: datetime | None = None
async def cleanup(now: datetime | None = None, *, ttl_s: int | None = None) -> int:
"""删掉 ttl_s(缺省 24 小时)之前上传、还没绑定的文件(占着却没发起成功的也算),返回删了几行。"""
now = now or utcnow()
cutoff = now - timedelta(seconds=UNBOUND_TTL_S if ttl_s is None else ttl_s)
async with SessionLocal() as session:
stale = list((await session.execute(select(StoredFile.id, StoredFile.sha256).where(
or_(StoredFile.bound_run_id.is_(None), StoredFile.bound_run_id.like(f"{CLAIM_PREFIX}%")),
_NO_RECORD, StoredFile.uploaded_at < cutoff))).all())
if not stale:
return 0
await session.execute(delete(StoredFile).where(StoredFile.id.in_([i for i, _ in stale])))
await session.commit()
await _drop_unreferenced(session, {sha for _, sha in stale})
logger.info("清理了 %d 个超过时限没有提交的上传文件", len(stale))
return len(stale)
async def cleanup_tick(now: datetime | None = None) -> int:
"""调度循环每一拍调;每小时真正清理一次。"""
global _last_cleanup
now = now or utcnow()
if _last_cleanup is not None and (now - _last_cleanup).total_seconds() < CLEANUP_EVERY_S:
return 0
_last_cleanup = now
return await cleanup(now)