880 lines
30 KiB
Python
880 lines
30 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 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],
|
|
resolve_admin_tenant: Callable[[Any, int | None], 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
|
|
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),
|
|
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 = 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 = 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 = 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),
|
|
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
|