Files
xieyi-weixin-/archive_api.py
T
2026-09-28 10:02:07 +08:00

1517 lines
60 KiB
Python

# -*- coding: utf-8 -*-
"""企业微信聊天归档 API。
路由独立注册在 ``/api/v2/archive`` 下,不改动现有模型、用户和桌面端同步接口。
"""
from __future__ import annotations
import asyncio
import json
import re
from datetime import datetime
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, ZytPatientSessionExpired, ZytPatientPermissionDenied,
)
from zyt_order_client import (
ZytOrderClient, ZytOrderError, ZytOrderSessionExpired,
ZytOrderPermissionDenied, ZytOrderNotFound, ZytOrderTrackingUnavailable,
sanitize_order, sanitize_tracking,
)
ZYT_PATIENT_LIST_PERMISSION = "tcm.diagnosis/lists"
# 每个订单动作在 ZYT 侧真正校验的权限。桌面代理按动作要求同样的权限,避免本地
# 放行之后再被上游拒绝,也避免只有列表权限的账号看到物流轨迹。
ZYT_ORDER_LIST_PERMISSIONS = frozenset({"firstvisit.mypatient/orders"})
ZYT_ORDER_DETAIL_PERMISSIONS = frozenset({
"firstvisit.mypatient/orderdetail",
"tcm.prescriptionorder/detail",
})
ZYT_ORDER_TRACKING_PERMISSIONS = frozenset({"tcm.prescriptionorder/logisticstrace"})
ZYT_ORDER_PERMISSIONS = (
ZYT_ORDER_LIST_PERMISSIONS | ZYT_ORDER_DETAIL_PERMISSIONS | ZYT_ORDER_TRACKING_PERMISSIONS
)
ORDER_AUDIT_ACTION_PREFIX = "archive.order."
ORDER_AUDIT_LABELS = {
"archive.order.list": "查询订单列表",
"archive.order.detail": "查询订单详情",
"archive.order.tracking": "查询物流轨迹",
"archive.order.audit_status": "查询审核状态",
"archive.order.summary": "刷新订单概览",
}
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, ZytPatientSessionExpired):
return HTTPException(status_code=409, detail={
"code": "zyt_session_expired",
"message": "ZYT 患者查询登录已过期,请重新验证账号。",
})
if isinstance(exc, ZytPatientPermissionDenied):
return HTTPException(status_code=403, detail=str(exc))
if isinstance(exc, ZytOrderSessionExpired):
return HTTPException(status_code=409, detail={
"code": "zyt_session_expired",
"message": "ZYT 订单查询登录已过期,请重新验证账号。",
})
if isinstance(exc, ZytOrderPermissionDenied):
return HTTPException(status_code=403, detail=str(exc))
if isinstance(exc, ZytOrderNotFound):
return HTTPException(status_code=404, detail={
"code": "order_not_found",
"message": str(exc) or "订单不存在或不属于当前绑定患者",
})
if isinstance(exc, ZytOrderTrackingUnavailable):
# 没有单号或物流服务失败都不是权限问题;用明确的 code 让 MCP 和界面
# 告诉客服“正在核实”,而不是把它当成通用 502。
return HTTPException(
status_code=409 if exc.code == "tracking_number_missing" else 502,
detail={"code": exc.code, "message": str(exc)},
)
if isinstance(exc, ZytOrderError):
return HTTPException(status_code=502, 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))
async def _diagnosis_visible_to_patient(
tenant: str, patient_id: int, diagnosis_id: int
) -> dict[str, Any] | None:
"""Verify the selected diagnosis belongs to the selected patient.
``archive_patient_shadow`` stores only the latest diagnosis, while a
patient can have several historical diagnoses. Read raw rows with an
exact patient filter through the authenticated ZYT list endpoint; the
normal keyword search only matches name/phone and deduplicates patients.
"""
try:
patient_id = int(patient_id)
diagnosis_id = int(diagnosis_id)
except (TypeError, ValueError):
return None
if patient_id <= 0 or diagnosis_id <= 0:
return None
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(diagnosis_id), 1, 50
)
else:
if zyt_api_url is None:
raise RuntimeError("ZYT 患者接口地址未配置")
client = ZytPatientClient(zyt_api_url(), token)
return await asyncio.to_thread(
client.get_patient_diagnosis, patient_id, diagnosis_id
)
if not isinstance(result, dict):
raise ZytPatientError("ZYT 患者查询结果格式不正确")
for item in list(result.get("items") or []):
if not isinstance(item, dict):
continue
try:
item_patient_id = int(item.get("patient_id") or 0)
item_diagnosis_id = int(item.get("diagnosis_id") or 0)
except (TypeError, ValueError):
continue
if item_patient_id == patient_id and item_diagnosis_id == diagnosis_id:
return dict(item)
return None
def _desktop_order_access(
account: Any, required: frozenset[str] = ZYT_ORDER_LIST_PERMISSIONS
) -> dict[str, Any]:
"""订单查询权限只允许只读业务权限,不能由模型自行提升。
``required`` 是该动作在 ZYT 侧会检查的全部权限;缺任何一个都在本地拒绝。
"""
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
full_access = is_root or "*" in permissions
allowed = full_access or required <= permissions
reason = "" if allowed else (
"当前登录的 ZYT 账号没有患者订单查看权限"
if known else "暂未取得当前 ZYT 账号的权限信息,请退出软件后重新登录"
)
return {
"can_query": allowed,
"can_list": full_access or ZYT_ORDER_LIST_PERMISSIONS <= permissions,
"can_detail": full_access or ZYT_ORDER_DETAIL_PERMISSIONS <= permissions,
"can_track": full_access or ZYT_ORDER_TRACKING_PERMISSIONS <= permissions,
"is_root": is_root,
"known": known,
"required_permissions": sorted(required),
"reason": reason,
}
def _require_desktop_order_access(
account: Any, required: frozenset[str] = ZYT_ORDER_LIST_PERMISSIONS
) -> dict[str, Any]:
access = _desktop_order_access(account, required)
if not access["can_query"]:
raise HTTPException(status_code=403, detail=str(access["reason"]))
return access
async def _bound_patient_for_order(
account: Any, external_account_id: str, conversation_external_id: str, person_id: str = ""
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Resolve the current conversation and return its active primary binding."""
context = await asyncio.to_thread(
store.conversation_patient_context,
str(external_account_id or "").strip(),
str(conversation_external_id or "").strip(),
str(account["tenant_id"]),
person_id=str(person_id or "").strip(),
)
person = context.get("person") if isinstance(context, dict) else None
bindings = list((person or {}).get("patient_bindings") or []) if isinstance(person, dict) else []
if not bindings and isinstance(context, dict):
bindings = list(context.get("patient_bindings") or [])
if not isinstance(person, dict) or not person.get("id"):
if isinstance(context, dict) and context.get("requires_person_selection"):
raise HTTPException(status_code=409, detail={
"code": "person_selection_required",
"message": "当前会话有多个联系人,请先在桌面端选择联系人并绑定患者",
})
raise HTTPException(status_code=409, detail={
"code": "patient_binding_required",
"message": str((context or {}).get("reason") or "当前会话没有可绑定的联系人"),
})
if not bindings:
raise HTTPException(status_code=409, detail={
"code": "patient_binding_required",
"message": "当前会话尚未绑定患者,请先绑定患者",
})
binding = dict(bindings[0])
try:
patient_id = int(binding.get("patient_id") or 0)
except (TypeError, ValueError):
patient_id = 0
if patient_id <= 0:
raise HTTPException(status_code=409, detail={
"code": "patient_binding_invalid",
"message": "当前患者绑定已失效,请重新绑定",
})
# Older bindings may not have persisted diagnosis_id. Resolve it through
# the authenticated ZYT patient list before any order query, otherwise a
# broad order list could expose another patient's data.
try:
diagnosis_id = int(binding.get("diagnosis_id") or 0)
except (TypeError, ValueError):
diagnosis_id = 0
if diagnosis_id <= 0:
visible = await _patient_visible_to_tenant(str(account["tenant_id"]), patient_id)
try:
diagnosis_id = int((visible or {}).get("diagnosis_id") or 0)
except (TypeError, ValueError):
diagnosis_id = 0
if diagnosis_id <= 0:
raise HTTPException(status_code=409, detail={
"code": "patient_binding_invalid",
"message": "当前患者绑定缺少有效诊单,请重新绑定",
})
binding["diagnosis_id"] = diagnosis_id
# Do not trust a diagnosis ID persisted by an older client or supplied
# by a caller. It must still resolve to this patient in the current
# authenticated ZYT data scope before any order request is made.
verified = await _diagnosis_visible_to_patient(
str(account["tenant_id"]), patient_id, diagnosis_id
)
if verified is None:
raise HTTPException(status_code=409, detail={
"code": "patient_binding_invalid",
"message": "当前患者绑定的诊单不属于该患者,请重新绑定",
})
return context, binding
def _order_client(tenant: str) -> ZytOrderClient:
if zyt_api_url is None:
raise RuntimeError("ZYT 订单接口地址未配置")
token = store.zyt_token(str(tenant))
return ZytOrderClient(zyt_api_url(), token)
@staticmethod
def _query_time() -> str:
return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _desktop_order_diagnosis_id(row: dict[str, Any]) -> int:
try:
return int(row.get("diagnosis_id") or row.get("diagnosisId") or 0)
except (TypeError, ValueError):
return 0
async def _order_for_binding(
client: ZytOrderClient, binding: dict[str, Any], order_id: int
) -> dict[str, Any]:
order = await asyncio.to_thread(client.get_order_detail, int(order_id))
diagnosis_id = _desktop_order_diagnosis_id(order)
bound_diagnosis = int(binding.get("diagnosis_id") or 0)
if diagnosis_id <= 0 or (bound_diagnosis > 0 and diagnosis_id != bound_diagnosis):
raise ZytOrderNotFound("订单不存在或不属于当前绑定患者")
return order
def _audit_order_query(
account: Any, request: Request, action: str, binding: dict[str, Any],
order_id: int = 0, *, conversation_external_id: str = "", outcome: str = "ok",
) -> None:
try:
database.audit(
int(account["id"]),
action,
f"patient={int(binding.get('patient_id') or 0)} order={int(order_id or 0)} "
f"outcome={str(outcome or 'ok')[:40]} conv={str(conversation_external_id or '')[:200]}",
client_ip(request),
)
except Exception:
pass
def _order_query_logs(account: Any, conversation_external_id: str, limit: int = 8) -> list[dict[str, Any]]:
"""当前会话最近的订单查询留痕,给桌面端展示“MCP 查询日志”。"""
conversation = str(conversation_external_id or "").strip()
if not conversation:
return []
try:
rows = database.audit_entries_for_user(
int(account["id"]), ORDER_AUDIT_ACTION_PREFIX,
detail_contains=f"conv={conversation}", limit=limit,
)
except Exception:
return []
logs = []
for row in rows:
detail = str(row.get("detail") or "")
order_match = re.search(r"\border=(\d+)", detail)
outcome_match = re.search(r"\boutcome=(\S+)", detail)
action = str(row.get("action") or "")
logs.append({
"time": str(row.get("created_at") or ""),
"action": action,
"label": ORDER_AUDIT_LABELS.get(action, action),
"order_id": int(order_match.group(1)) if order_match else 0,
"outcome": outcome_match.group(1) if outcome_match else "ok",
})
return logs
async def _tracking_for_order(
client: ZytOrderClient, order: dict[str, Any], order_id: int, queried_at: str
) -> tuple[dict[str, Any] | None, dict[str, str]]:
"""Return (latest_tracking, error). Never invents a status when upstream fails."""
phone = str(order.get("recipient_phone") or order.get("patient_phone") or "")
try:
tracking = await asyncio.to_thread(
client.get_tracking, int(order_id), phone_tail=re.sub(r"\D", "", phone)[-4:]
)
except ZytOrderTrackingUnavailable as exc:
return None, {"code": exc.code, "message": str(exc)}
except ZytOrderError as exc:
return None, {"code": "tracking_unavailable", "message": str(exc)}
return sanitize_tracking(tracking, queried_at=queried_at), {}
def _order_list_hint(items: list[dict[str, Any]]) -> dict[str, Any]:
"""把“单笔直接查、多笔让客户选”的规则做成数据,不靠模型自觉。"""
count = len(items)
if count == 0:
return {
"order_count": 0, "selection_required": False, "single_order_id": 0,
"hint": "该患者名下没有查到订单记录:如实告诉客户系统里暂时没有查到,说明会人工核实后回复;不要向客户索要订单号。",
}
if count == 1:
return {
"order_count": 1, "selection_required": False,
"single_order_id": int(items[0].get("order_id") or 0),
"hint": "只有一笔订单,可以直接告知该订单的状态和物流。",
}
return {
"order_count": count, "selection_required": True, "single_order_id": 0,
"hint": "查到多笔订单,只能向客户说明各笔的下单时间和状态,让客户确认是哪一笔后再查物流。",
}
@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"])
),
"capabilities": {"message_origin_updates": True}
}
@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/patient-orders")
async def desktop_patient_orders(
request: Request,
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
keyword: str = "",
page_no: int = 1,
page_size: int = 20,
start_date: str = "",
end_date: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
access = _require_desktop_order_access(account, ZYT_ORDER_LIST_PERMISSIONS)
try:
context, binding = await _bound_patient_for_order(
account, external_account_id, conversation_external_id, person_id
)
page_no = max(1, int(page_no))
page_size = max(1, min(int(page_size), 50))
keyword = str(keyword or "").strip()[:80]
client = _order_client(str(account["tenant_id"]))
result = await asyncio.to_thread(
client.list_orders,
diagnosis_id=int(binding.get("diagnosis_id") or 0),
keyword=keyword, page_no=page_no, page_size=page_size,
start_date=str(start_date or "").strip(), end_date=str(end_date or "").strip(),
)
bound_diagnosis = int(binding.get("diagnosis_id") or 0)
rows = [
row for row in list(result.get("items") or [])
if isinstance(row, dict)
and (bound_diagnosis <= 0 or _desktop_order_diagnosis_id(row) == bound_diagnosis)
]
queried_at = _query_time()
items = [sanitize_order(row, queried_at=queried_at) for row in rows]
hint = _order_list_hint(items)
latest_tracking = None
tracking_error: dict[str, str] = {}
# 只有一笔订单时直接把物流带回去,模型不需要再猜要不要查、查哪一笔。
if hint["single_order_id"] and access["can_track"]:
only = items[0]
if only.get("has_tracking_number"):
latest_tracking, tracking_error = await _tracking_for_order(
client, rows[0], hint["single_order_id"], queried_at
)
else:
tracking_error = {
"code": "tracking_number_missing",
"message": "该订单还没有快递单号,请告知客户正在核实发货情况",
}
_audit_order_query(
account, request, "archive.order.list", binding,
conversation_external_id=conversation_external_id,
outcome=f"count={len(items)}",
)
return {
"ok": True,
"patient": {
"patient_id": int(binding.get("patient_id") or 0),
"diagnosis_id": bound_diagnosis,
},
"items": items,
# Never expose the upstream unfiltered count.
"total": len(items),
"page_no": page_no,
"page_size": page_size,
"queried_at": queried_at,
"binding_id": str(binding.get("id") or ""),
"context_available": bool(context.get("available")),
**hint,
"latest_tracking": latest_tracking,
"tracking_error": tracking_error,
}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patient-orders/summary")
async def desktop_patient_order_summary(
request: Request,
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
"""桌面端会话面板用的概览:能力状态、最近订单、最新物流和查询留痕。
与其它订单接口不同,这里对“未绑定 / 无权限”不抛错,而是把原因返回给界面
展示;真正的订单数据仍然只在绑定有效且有权限时才会查询。
"""
access = _desktop_order_access(account, ZYT_ORDER_LIST_PERMISSIONS)
base: dict[str, Any] = {
"ok": True,
"capability": {
"enabled": False,
"bound": False,
"reason": str(access["reason"] or ""),
"can_track": bool(access["can_track"]),
"can_detail": bool(access["can_detail"]),
},
"recent_orders": [],
"latest_tracking": None,
"tracking_error": {},
"logs": _order_query_logs(account, conversation_external_id),
"queried_at": _query_time(),
}
if not access["can_query"]:
return base
try:
_context, binding = await _bound_patient_for_order(
account, external_account_id, conversation_external_id, person_id
)
except HTTPException as exc:
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
base["capability"]["reason"] = str(detail.get("message") or "当前会话尚未绑定患者")
base["capability"]["code"] = str(detail.get("code") or "")
return base
except Exception as exc:
raise _http_error(exc) from exc
base["capability"].update({"enabled": True, "bound": True, "reason": ""})
try:
client = _order_client(str(account["tenant_id"]))
result = await asyncio.to_thread(
client.list_orders,
diagnosis_id=int(binding.get("diagnosis_id") or 0), page_no=1, page_size=5,
)
bound_diagnosis = int(binding.get("diagnosis_id") or 0)
rows = [
row for row in list(result.get("items") or [])
if isinstance(row, dict)
and (bound_diagnosis <= 0 or _desktop_order_diagnosis_id(row) == bound_diagnosis)
][:3]
queried_at = _query_time()
items = [sanitize_order(row, queried_at=queried_at) for row in rows]
base["recent_orders"] = items
base["queried_at"] = queried_at
shipped = next(
(index for index, item in enumerate(items) if item.get("has_tracking_number")),
None,
)
if shipped is not None and access["can_track"]:
base["latest_tracking"], base["tracking_error"] = await _tracking_for_order(
client, rows[shipped], int(items[shipped]["order_id"]), queried_at
)
if base["latest_tracking"] is not None:
base["latest_tracking"]["order_id"] = int(items[shipped]["order_id"])
_audit_order_query(
account, request, "archive.order.summary", binding,
conversation_external_id=conversation_external_id,
outcome=f"count={len(items)}",
)
base["logs"] = _order_query_logs(account, conversation_external_id)
except Exception as exc:
raise _http_error(exc) from exc
return base
@app.get("/api/v2/archive/desktop/patient-orders/{order_id}")
async def desktop_patient_order_detail(
order_id: int,
request: Request,
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
_require_desktop_order_access(account, ZYT_ORDER_DETAIL_PERMISSIONS)
try:
_context, binding = await _bound_patient_for_order(
account, external_account_id, conversation_external_id, person_id
)
client = _order_client(str(account["tenant_id"]))
try:
order = await _order_for_binding(client, binding, order_id)
except ZytOrderNotFound:
_audit_order_query(
account, request, "archive.order.detail", binding, order_id,
conversation_external_id=conversation_external_id, outcome="rejected",
)
raise
queried_at = _query_time()
_audit_order_query(
account, request, "archive.order.detail", binding, order_id,
conversation_external_id=conversation_external_id,
)
return {"ok": True, "order": sanitize_order(order, queried_at=queried_at)}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patient-orders/{order_id}/tracking")
async def desktop_patient_order_tracking(
order_id: int,
request: Request,
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
_require_desktop_order_access(
account, ZYT_ORDER_DETAIL_PERMISSIONS | ZYT_ORDER_TRACKING_PERMISSIONS
)
try:
_context, binding = await _bound_patient_for_order(
account, external_account_id, conversation_external_id, person_id
)
client = _order_client(str(account["tenant_id"]))
order = await _order_for_binding(client, binding, order_id)
queried_at = _query_time()
safe = sanitize_order(order, queried_at=queried_at)
if not safe.get("has_tracking_number"):
_audit_order_query(
account, request, "archive.order.tracking", binding, order_id,
conversation_external_id=conversation_external_id,
outcome="tracking_number_missing",
)
raise ZytOrderTrackingUnavailable(
"该订单还没有快递单号,请告知客户正在核实发货情况",
code="tracking_number_missing",
)
latest_tracking, tracking_error = await _tracking_for_order(
client, order, order_id, queried_at
)
if latest_tracking is None:
_audit_order_query(
account, request, "archive.order.tracking", binding, order_id,
conversation_external_id=conversation_external_id,
outcome=str(tracking_error.get("code") or "tracking_unavailable"),
)
raise ZytOrderTrackingUnavailable(
str(tracking_error.get("message") or "物流服务暂时查不到轨迹"),
code=str(tracking_error.get("code") or "tracking_unavailable"),
)
_audit_order_query(
account, request, "archive.order.tracking", binding, order_id,
conversation_external_id=conversation_external_id,
)
return {
"ok": True,
"order_id": int(order_id),
"order_no": safe.get("order_no", ""),
"express_company": safe.get("express_company", ""),
"tracking_number_masked": safe.get("tracking_number_masked", ""),
"fulfillment_status": safe.get("fulfillment_status", ""),
"latest_tracking": latest_tracking,
"queried_at": queried_at,
}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patient-orders/{order_id}/audit-status")
async def desktop_patient_order_audit_status(
order_id: int,
request: Request,
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
_require_desktop_order_access(account, ZYT_ORDER_DETAIL_PERMISSIONS)
try:
_context, binding = await _bound_patient_for_order(
account, external_account_id, conversation_external_id, person_id
)
client = _order_client(str(account["tenant_id"]))
order = await _order_for_binding(client, binding, order_id)
queried_at = _query_time()
safe = sanitize_order(order, queried_at=queried_at)
_audit_order_query(
account, request, "archive.order.audit_status", binding, order_id,
conversation_external_id=conversation_external_id,
)
return {
"ok": True,
"order_id": int(order_id),
"order_no": safe.get("order_no", ""),
"prescription_audit": safe["prescription_audit"],
"payment_audit": safe["payment_audit"],
"fulfillment_status": safe["fulfillment_status"],
"queried_at": queried_at,
}
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 账号无权访问该患者,不能建立绑定",
)
if body.diagnosis_id:
verified_diagnosis = await _diagnosis_visible_to_patient(
tenant, body.patient_id, body.diagnosis_id
)
if verified_diagnosis is None:
raise HTTPException(
status_code=403,
detail="所选诊单不属于该患者,不能建立绑定",
)
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