Files
AgentLab/backend/app/api/files.py
ThearchyHelios 277982ead3 普通用户建应用(后端):
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表
- 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号
- 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period)
- 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
2026-10-15 12:09:29 +08:00

107 lines
5.1 KiB
Python
Raw Permalink 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 §3.8)。存放、绑定、读权限都在 app/files/store.py。
- POST /api/files:multipart,file + context(JSON:{app_id, entry} | {task_id} | {blueprint_id},可带 field 说明是哪个
字段),交回引用 {file_id, name, size, sha256, type}。大小上限同上传表格(max_upload_mb),类型按白名单、按内容核对;
能用那个入口、能处理那条待办、能改那个蓝图的人才传得上来;
- GET /api/files/{id}:下载。读得到绑定的那次运行、看得到绑定的那一行记录的人,以及上传的人自己;
- DELETE /api/files/{id}:只能删自己传的、还没提交的。
"""
from __future__ import annotations
import asyncio
import dataclasses
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Form, Header, UploadFile
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import Principal, actor_of, current_principal
from app.db.base import get_session
from app.db.models_blueprints import StoredFile
from app.files import store
router = APIRouter(prefix="/api/files", tags=["files"])
#: 后台预抽取文字的任务:留着引用,不让它们半路被回收
_WARMING: set[asyncio.Task[Any]] = set()
def _principal(x_actor: str | None) -> Principal:
"""这次请求的身份。关登录时 username 换成自报的署名(X-Actor):上传人、只能删自己的都按它认(同 api/apps.py)。"""
principal = current_principal()
if principal.mode == "off" and principal.username is None:
return dataclasses.replace(principal, username=actor_of(x_actor))
return principal
def _refused(e: store.FileRefused) -> CodedHTTPException:
return CodedHTTPException(e.status, e.message, e.code, field=e.field)
@router.post("", status_code=201)
async def upload(file: UploadFile | None = File(None), context: str = Form(""),
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""multipart:file + context({app_id, entry} | {task_id} | {blueprint_id}),交回引用
{file_id, name, size, sha256, type}。"""
from app.api.datasources import read_upload
from app.files.text import warm
principal = _principal(x_actor)
try:
ctx = store.parse_context(context)
setting = await store.check_context(session, principal, ctx)
except store.FileRefused as e:
raise _refused(e) from e
if file is None:
raise CodedHTTPException(422, "请选择要上传的文件", "files.missing")
raw = await read_upload(file)
name = file.filename or "附件"
try:
mime = store.type_of(name, raw)
if problem := store.accept_problem(setting, name=name, mime=mime, size=len(raw)):
raise store.FileRefused(415, "files.type_not_accepted", problem, field=ctx.get("field"))
row = await store.save(session, raw, name, uploaded_by=principal.username, context=ctx)
except store.FileRefused as e:
raise _refused(e) from e
# 先在后台把文字抽一遍:运行里 file_text 多半直接读缓存
task = asyncio.create_task(warm(row.sha256, row.name, row.mime))
_WARMING.add(task)
task.add_done_callback(_WARMING.discard)
return store.ref_of(row)
@router.get("/{file_id}")
async def download(file_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Response:
principal = _principal(x_actor)
row = await session.get(StoredFile, file_id)
if row is None:
raise CodedHTTPException(404, "文件不存在,可能没有提交、已被清理", "files.not_found")
if not await store.readable(session, principal, row):
if row.bound_run_id is None and row.bound_record is None:
# 没提交的文件只有上传的人看得到:别人不该知道它在不在
raise CodedHTTPException(404, "文件不存在,可能没有提交、已被清理", "files.not_found")
raise CodedHTTPException(403, "没有下载这个文件的权限:只有能看到它所属的那次运行或那一行记录的人才能下载",
"files.forbidden")
data = await asyncio.to_thread(store.read_blob, row.sha256)
if data is None:
raise CodedHTTPException(409, "文件内容不在了,或者和登记的不一致(可能被改动过),不能下载", "files.content_lost")
return Response(content=data, media_type=row.mime, headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(row.name)}",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, no-store",
})
@router.delete("/{file_id}", status_code=204)
async def delete(file_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> None:
try:
await store.remove(session, _principal(x_actor), file_id)
except store.FileRefused as e:
raise _refused(e) from e