Files
kefu/deploy/patient-session-20260918/baseline/source/archive_api.py
T
2026-09-21 10:34:06 +08:00

899 lines
31 KiB
Python

# -*- coding: utf-8 -*-
"""企业微信聊天归档 API。
路由独立注册在 ``/api/v2/archive`` 下,不改动现有模型、用户和桌面端同步接口。
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any, Callable
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from starlette.responses import FileResponse, RedirectResponse
from archive_store import ALL_TENANTS, ArchiveStore
from zyt_patient_client import ZytPatientClient, ZytPatientError
ZYT_PATIENT_LIST_PERMISSION = "tcm.diagnosis/lists"
class StorageBody(BaseModel):
bucket: str = ""
region: str = ""
custom_domain: str = ""
media_prefix: str = "archive/media"
export_prefix: str = "archive/exports"
release_prefix: str = "desktop/releases"
encryption_mode: str = "AES256"
secret_id: str = ""
secret_key: str = ""
enabled: bool = False
class MediaPrepareBody(BaseModel):
sha256: str
size_bytes: int
mime_type: str = "application/octet-stream"
original_filename: str = "file"
class MultipartPartBody(BaseModel):
part_number: int = Field(ge=1, le=10000)
etag: str = Field(min_length=1, max_length=512)
class MediaMultipartCompleteBody(BaseModel):
upload_id: str = Field(min_length=1, max_length=2048)
parts: list[MultipartPartBody] = Field(min_length=1, max_length=10000)
class ImportBody(BaseModel):
source_account: dict[str, Any]
messages: list[dict[str, Any]] = Field(min_length=1, max_length=5000)
batch_id: str = ""
source_table: str = "message_table"
checkpoint: Any = None
class MetadataBody(BaseModel):
source_account: dict[str, Any]
people: list[dict[str, Any]] = Field(default_factory=list, max_length=5000)
conversations: list[dict[str, Any]] = Field(default_factory=list, max_length=5000)
class DesktopCheckpointBody(BaseModel):
source_account: dict[str, Any]
source_table: str = "message_table"
checkpoint: dict[str, Any]
class MediaAccessBody(BaseModel):
media_ids: list[str] = Field(min_length=1, max_length=200)
expires: int = Field(default=300, ge=60, le=900)
class ExportBody(BaseModel):
formats: list[str] = Field(default_factory=lambda: ["sql", "xlsx"])
filters: dict[str, Any] = Field(default_factory=dict)
class IdentityBody(BaseModel):
identity_type: str
scope_id: str = ""
external_id: str
verified: bool = True
class PatientBindingBody(BaseModel):
patient_id: int = Field(gt=0)
diagnosis_id: int = Field(default=0, ge=0)
relation_type: str = Field(default="self", min_length=2, max_length=16)
class DesktopPatientBindingBody(PatientBindingBody):
external_account_id: str = Field(min_length=1, max_length=191)
conversation_external_id: str = Field(min_length=1, max_length=512)
person_id: str = Field(default="", max_length=64)
def _http_error(exc: Exception) -> HTTPException:
if isinstance(exc, HTTPException):
return exc
if isinstance(exc, KeyError):
return HTTPException(status_code=404, detail=str(exc).strip("'"))
if isinstance(exc, ValueError):
return HTTPException(status_code=400, detail=str(exc))
if isinstance(exc, ZytPatientError):
return HTTPException(status_code=502, detail=str(exc))
if isinstance(exc, RuntimeError):
return HTTPException(status_code=409, detail=str(exc))
return HTTPException(status_code=502, detail=f"外部存储操作失败:{exc}")
def register_archive_routes(
app: FastAPI,
database: Any,
current: Callable[..., Any],
require: Callable[..., Any],
client_ip: Callable[[Request], str],
current_desktop: Callable[..., Any],
# 返回值有两种形状:一个租户,或者一组租户(管理员选「全部」且只被授权到
# 若干客户端账号时)。两种都由 `archive_store.tenant_scope` 统一翻译。
resolve_admin_tenant: Callable[[Any, int | None], "str | list[str]"],
zyt_api_url: Callable[[], str] | None = None,
zyt_patient_searcher: Callable[[str, str, int, int], dict[str, Any]] | None = None,
) -> ArchiveStore:
"""将聊天归档路由附加到现有 FastAPI 应用。"""
store = ArchiveStore(database)
store.initialize()
app.state.archive_store = store
def single_tenant(value: Any) -> str:
"""写路径要的那**一个**租户。
看数据可以跨账号(「全部」),但绑患者、发导出任务不行:这条记录要记在
谁名下,得有个确切答案。这里把它挡在最前面并说清楚该怎么办——否则会一路
走到 `safe_scope`,报一句"租户只能包含字母、数字、下划线和连字符",
对着界面上的下拉框完全看不懂。
"""
if isinstance(value, str) and value != ALL_TENANTS:
return value
raise HTTPException(
status_code=400,
detail="这一步要先在右上角选择具体的客户端账号,「全部」下无法确定归属",
)
export_slot = asyncio.Lock()
async def run_export(job_id: str) -> None:
# Queued jobs wait asynchronously, never occupying the API worker pool.
async with export_slot:
await asyncio.to_thread(store.run_export_job, job_id)
def _desktop_patient_access(account: Any) -> dict[str, Any]:
try:
raw_permissions = account["zyt_permissions_json"]
except (KeyError, IndexError):
raw_permissions = "[]"
try:
parsed_permissions = json.loads(str(raw_permissions or "[]"))
except (TypeError, ValueError):
parsed_permissions = []
permissions = {
str(item).strip().lower()
for item in parsed_permissions
if str(item).strip()
} if isinstance(parsed_permissions, list) else set()
try:
is_root = bool(int(account["zyt_root"] or 0))
except (KeyError, IndexError, TypeError, ValueError):
is_root = False
try:
known = bool(int(account["zyt_permissions_known"] or 0))
except (KeyError, IndexError, TypeError, ValueError):
known = False
allowed = is_root or "*" in permissions or ZYT_PATIENT_LIST_PERMISSION in permissions
if allowed:
reason = ""
elif known:
reason = "当前登录的 ZYT 账号没有患者列表权限"
else:
reason = "暂未取得当前 ZYT 账号的权限信息,请退出软件后重新登录"
return {
"can_search": allowed,
"can_bind": allowed,
"can_unbind": allowed,
"is_root": is_root,
"known": known,
"required_permission": ZYT_PATIENT_LIST_PERMISSION,
"reason": reason,
}
def _require_desktop_patient_access(account: Any) -> dict[str, Any]:
access = _desktop_patient_access(account)
if not access["can_search"]:
raise HTTPException(status_code=403, detail=str(access["reason"]))
return access
def _context_with_access(
context: dict[str, Any], access: dict[str, Any]
) -> dict[str, Any]:
result = dict(context)
result["permissions"] = access
return result
async def _search_patients_for_tenant(
tenant: str, keyword: str, page_no: int, page_size: int
) -> dict[str, Any]:
token = await asyncio.to_thread(store.zyt_token, tenant)
if zyt_patient_searcher is not None:
result = await asyncio.to_thread(
zyt_patient_searcher,
token,
str(keyword or ""),
int(page_no),
int(page_size),
)
else:
if zyt_api_url is None:
raise RuntimeError("ZYT 患者接口地址未配置")
client = ZytPatientClient(zyt_api_url(), token)
result = await asyncio.to_thread(
client.search_patients,
str(keyword or ""),
page_no=int(page_no),
page_size=int(page_size),
)
if not isinstance(result, dict):
raise ZytPatientError("ZYT 患者查询结果格式不正确")
items = await asyncio.to_thread(store.cache_patients, list(result.get("items") or []), tenant)
return {
"items": items,
"total": int(result.get("total") or len(items)),
"page_no": int(result.get("page_no") or page_no),
"page_size": int(result.get("page_size") or page_size),
}
async def _patient_visible_to_tenant(
tenant: str, patient_id: int
) -> dict[str, Any] | None:
"""绑定前重新确认该患者仍在当前 ZYT 账号的数据范围内。"""
token = await asyncio.to_thread(store.zyt_token, tenant)
if zyt_patient_searcher is not None:
result = await asyncio.to_thread(
zyt_patient_searcher, token, str(int(patient_id)), 1, 50
)
items = list(result.get("items") or []) if isinstance(result, dict) else []
return next(
(
dict(item)
for item in items
if isinstance(item, dict)
and int(item.get("patient_id") or 0) == int(patient_id)
),
None,
)
if zyt_api_url is None:
raise RuntimeError("ZYT 患者接口地址未配置")
client = ZytPatientClient(zyt_api_url(), token)
return await asyncio.to_thread(client.get_patient, int(patient_id))
@app.get("/api/v2/archive/stats")
def archive_stats(
account_id: int | None = None,
principal: Any = Depends(require("im:read")),
) -> dict[str, Any]:
return store.stats(resolve_admin_tenant(principal, account_id))
@app.get("/api/v2/archive/conversations")
def archive_conversations(
limit: int = 50,
cursor: str = "",
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
) -> dict[str, Any]:
try:
return store.conversations(
limit, cursor, resolve_admin_tenant(principal, account_id)
)
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/conversations/{conversation_id}/messages")
def archive_messages(
conversation_id: str,
limit: int = 100,
cursor: str = "",
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
) -> dict[str, Any]:
try:
return store.messages(
conversation_id,
limit,
cursor,
resolve_admin_tenant(principal, account_id),
)
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/imports/messages")
def archive_import_messages(
body: ImportBody,
request: Request,
principal: Any = Depends(require("im:import")),
) -> dict[str, Any]:
try:
return store.import_messages(
body.model_dump(), principal.id, client_ip(request)
)
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/imports/metadata")
def archive_import_metadata(
body: MetadataBody,
request: Request,
principal: Any = Depends(require("im:import")),
) -> dict[str, Any]:
try:
return store.sync_metadata(
body.model_dump(), principal.id, client_ip(request)
)
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/checkpoint")
def desktop_archive_checkpoint(
external_account_id: str,
source_table: str = "message_table",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
if not str(external_account_id or "").strip():
raise HTTPException(status_code=400, detail="账号标识不能为空")
return {
"checkpoint": store.source_checkpoint(
external_account_id, source_table, str(account["tenant_id"])
)
}
@app.post("/api/v2/archive/desktop/checkpoint")
def desktop_archive_advance_checkpoint(
body: DesktopCheckpointBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
return {
"checkpoint": store.advance_source_checkpoint(
body.source_account,
body.source_table,
body.checkpoint,
str(account["tenant_id"]),
)
}
@app.get("/api/v2/archive/desktop/pending-attachments")
def desktop_pending_attachments(
external_account_id: str,
limit: int = 500,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
if not str(external_account_id or "").strip():
raise HTTPException(status_code=400, detail="账号标识不能为空")
return {
"source_message_ids": store.claim_pending_attachment_source_ids(
external_account_id, limit, str(account["tenant_id"])
)
}
@app.post("/api/v2/archive/desktop/imports/messages")
def desktop_archive_import_messages(
body: ImportBody,
request: Request,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
try:
values = body.model_dump()
values["tenant_id"] = str(account["tenant_id"])
return store.import_messages(values, None, client_ip(request))
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/imports/metadata")
def desktop_archive_import_metadata(
body: MetadataBody,
request: Request,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
try:
values = body.model_dump()
values["tenant_id"] = str(account["tenant_id"])
return store.sync_metadata(values, None, client_ip(request))
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/people")
def archive_people(
limit: int = 100,
keyword: str = "",
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
return {
"items": store.people(
limit, keyword, resolve_admin_tenant(principal, account_id)
)
}
@app.get("/api/v2/archive/people/{person_id}")
def archive_person(
person_id: str,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
try:
return {
"person": store.person_detail(
person_id, resolve_admin_tenant(principal, account_id)
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/people/{person_id}/identities")
def bind_archive_identity(
person_id: str,
body: IdentityBody,
request: Request,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
try:
return {
"person": store.bind_identity(
person_id,
body.model_dump(),
principal.id,
client_ip(request),
single_tenant(resolve_admin_tenant(principal, account_id)),
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/patients/search")
async def search_zyt_patients(
keyword: str,
page_no: int = 1,
page_size: int = 20,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
tenant = single_tenant(
await asyncio.to_thread(resolve_admin_tenant, principal, account_id)
)
try:
return await _search_patients_for_tenant(
tenant, keyword, page_no, page_size
)
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patient-context")
def desktop_patient_context(
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
access = _desktop_patient_access(account)
if not access["can_search"]:
return {
"available": False,
"reason": str(access["reason"]),
"people": [],
"patient_bindings": [],
"permissions": access,
}
try:
context = store.conversation_patient_context(
external_account_id,
conversation_external_id,
str(account["tenant_id"]),
person_id=person_id,
)
return _context_with_access(context, access)
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patients/search")
async def desktop_search_zyt_patients(
keyword: str,
page_no: int = 1,
page_size: int = 20,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
_require_desktop_patient_access(account)
try:
return await _search_patients_for_tenant(
str(account["tenant_id"]), keyword, page_no, page_size
)
except Exception as exc:
raise _http_error(exc) from exc
@app.put("/api/v2/archive/desktop/patient-binding")
async def desktop_bind_archive_patient(
body: DesktopPatientBindingBody,
request: Request,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
tenant = str(account["tenant_id"])
access = _require_desktop_patient_access(account)
try:
visible_patient = await _patient_visible_to_tenant(tenant, body.patient_id)
if visible_patient is None:
raise HTTPException(
status_code=403,
detail="当前 ZYT 账号无权访问该患者,不能建立绑定",
)
context = await asyncio.to_thread(store.conversation_patient_context,
body.external_account_id,
body.conversation_external_id,
tenant,
person_id=body.person_id,
)
person = context.get("person")
if not isinstance(person, dict) or not person.get("id"):
if context.get("requires_person_selection"):
raise ValueError("群聊中有多个联系人,请先选择需要绑定的联系人")
raise ValueError(str(context.get("reason") or "当前会话没有可绑定的联系人"))
binding = await asyncio.to_thread(store.bind_patient,
str(person["id"]),
body.model_dump(),
None,
client_ip(request),
tenant,
)
return {
"binding": binding,
"context": _context_with_access(
await asyncio.to_thread(store.conversation_patient_context,
body.external_account_id,
body.conversation_external_id,
tenant,
person_id=str(person["id"]),
),
access,
),
}
except Exception as exc:
raise _http_error(exc) from exc
@app.delete("/api/v2/archive/desktop/patient-binding")
def desktop_unbind_archive_patient(
request: Request,
external_account_id: str,
conversation_external_id: str,
binding_id: str,
person_id: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
tenant = str(account["tenant_id"])
access = _require_desktop_patient_access(account)
try:
context = store.conversation_patient_context(
external_account_id,
conversation_external_id,
tenant,
person_id=person_id,
)
person = context.get("person")
if not isinstance(person, dict) or not person.get("id"):
raise ValueError("当前会话没有选中可解除绑定的联系人")
store.unbind_patient(
str(person["id"]), binding_id, None, client_ip(request), tenant
)
return {
"context": _context_with_access(
store.conversation_patient_context(
external_account_id,
conversation_external_id,
tenant,
person_id=str(person["id"]),
),
access,
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.put("/api/v2/archive/people/{person_id}/patient-bindings")
def bind_archive_patient(
person_id: str,
body: PatientBindingBody,
request: Request,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
tenant = single_tenant(resolve_admin_tenant(principal, account_id))
try:
binding = store.bind_patient(
person_id,
body.model_dump(),
principal.id,
client_ip(request),
tenant,
)
return {
"binding": binding,
"person": store.person_detail(person_id, tenant),
}
except Exception as exc:
raise _http_error(exc) from exc
@app.delete(
"/api/v2/archive/people/{person_id}/patient-bindings/{binding_id}"
)
def unbind_archive_patient(
person_id: str,
binding_id: str,
request: Request,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
tenant = single_tenant(resolve_admin_tenant(principal, account_id))
try:
store.unbind_patient(
person_id,
binding_id,
principal.id,
client_ip(request),
tenant,
)
return {"person": store.person_detail(person_id, tenant)}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/storage")
def archive_storage(
_: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
return {"storage": store.storage_config()}
@app.put("/api/v2/archive/storage")
def save_archive_storage(
body: StorageBody,
request: Request,
principal: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
try:
return {
"storage": store.save_storage_config(
body.model_dump(), principal.id, client_ip(request)
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/storage/test")
def test_archive_storage(
request: Request,
principal: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
try:
result = store.test_storage()
database.audit(
principal.id,
"archive.storage.test",
f"ok={int(bool(result.get('ok')))} bucket={result.get('bucket', '')}",
client_ip(request),
)
return {"result": result}
except Exception as exc:
database.audit(
principal.id,
"archive.storage.test",
f"ok=0 error={str(exc)[:500]}",
client_ip(request),
)
raise _http_error(exc) from exc
@app.get("/api/v2/archive/media")
def archive_media(
limit: int = 100,
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
) -> dict[str, Any]:
return {
"items": store.media_items(
limit, resolve_admin_tenant(principal, account_id)
)
}
@app.post("/api/v2/archive/media/access-urls")
def archive_media_access_urls(
body: MediaAccessBody,
request: Request,
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
) -> dict[str, Any]:
try:
items = store.media_download_urls(
body.media_ids,
body.expires,
resolve_admin_tenant(principal, account_id),
)
database.audit(
principal.id,
"archive.media.access",
f"count={len(items)}",
client_ip(request),
)
return {"items": items}
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/prepare")
def prepare_archive_media(
body: MediaPrepareBody,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
try:
return store.prepare_media(body.model_dump())
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/prepare")
def desktop_prepare_archive_media(
body: MediaPrepareBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
try:
values = body.model_dump()
values["tenant_id"] = str(account["tenant_id"])
return store.prepare_media(values)
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/{media_id}/complete")
def complete_archive_media(
media_id: str,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
try:
return {"media": store.complete_media(media_id)}
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/{media_id}/multipart-complete")
def complete_archive_multipart_media(
media_id: str,
body: MediaMultipartCompleteBody,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
try:
return {
"media": store.complete_multipart_media(
media_id,
body.upload_id,
[part.model_dump() for part in body.parts],
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/{media_id}/complete")
def desktop_complete_archive_media(
media_id: str,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
try:
return {
"media": store.complete_media(media_id, str(account["tenant_id"]))
}
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/{media_id}/multipart-complete")
def desktop_complete_archive_multipart_media(
media_id: str,
body: MediaMultipartCompleteBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
try:
return {
"media": store.complete_multipart_media(
media_id,
body.upload_id,
[part.model_dump() for part in body.parts],
str(account["tenant_id"]),
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/media/{media_id}/view")
def view_archive_media(
media_id: str,
request: Request,
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
) -> RedirectResponse:
try:
url = store.media_download_url(
media_id, tenant_id=resolve_admin_tenant(principal, account_id)
)
database.audit(
principal.id, "archive.media.view", f"media={media_id}", client_ip(request)
)
return RedirectResponse(url=url, status_code=307)
except Exception as exc:
raise _http_error(exc) from exc
@app.post("/api/v2/archive/exports")
def create_archive_export(
body: ExportBody,
request: Request,
background: BackgroundTasks,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
) -> dict[str, Any]:
try:
job = store.create_export_job(
body.formats,
body.filters,
principal.id,
client_ip(request),
single_tenant(resolve_admin_tenant(principal, account_id)),
)
background.add_task(run_export, job["id"])
return {"job": job}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/exports")
def archive_exports(
limit: int = 100,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
) -> dict[str, Any]:
return {
"jobs": store.export_jobs(
limit, resolve_admin_tenant(principal, account_id)
)
}
@app.get("/api/v2/archive/exports/{job_id}")
def archive_export(
job_id: str,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
) -> dict[str, Any]:
try:
return {
"job": store.export_job(
job_id, resolve_admin_tenant(principal, account_id)
)
}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/export-files/{file_id}/download")
def download_archive_export(
file_id: str,
request: Request,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
):
try:
item = store.export_file(
file_id, resolve_admin_tenant(principal, account_id)
)
database.audit(
principal.id,
"archive.export.download",
f"file={file_id} job={item['job_id']}",
client_ip(request),
)
if item["storage_status"] == "cos":
return RedirectResponse(
url=store.export_download_url(file_id), status_code=307
)
path = Path(item["local_path"]).resolve()
root = store.export_root.resolve()
if not path.is_relative_to(root) or not path.is_file():
raise KeyError("导出文件已不在本机")
return FileResponse(
path, filename=item["file_name"], media_type="application/octet-stream"
)
except Exception as exc:
raise _http_error(exc) from exc
return store