Files
kefu/wechat_rpa/tmp/archive-server-before/archive_api.py
T
2026-09-21 10:34:06 +08:00

873 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
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 = 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 = 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 = 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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}")
async 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")
async 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 = 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")
async 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 = 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 = store.bind_patient(
str(person["id"]),
body.model_dump(),
None,
client_ip(request),
tenant,
)
return {
"binding": binding,
"context": _context_with_access(
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")
async 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")
async 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}"
)
async 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")
async def archive_storage(
_: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
return {"storage": store.storage_config()}
@app.put("/api/v2/archive/storage")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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")
async 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(store.run_export_job, job["id"])
return {"job": job}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/exports")
async 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}")
async 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")
async 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