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

3700 lines
164 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""企业微信聊天归档的数据层、COS 对象层与流式导出。
这个模块刻意独立于原来的模型配置后台:它只复用 ``admin_backend.Database``
提供的连接、审计和主密钥,不修改桌面端同步、模型配置或用户管理的数据结构。
当前部署仍然使用项目已有的 SQLite;表结构和所有查询都带租户键、业务唯一键与
游标索引,后续切 MySQL 时 API 和前端无需重做。大文件本体永远不进数据库,只保存
COS 对象定位、哈希与校验状态。
"""
from __future__ import annotations
import base64
import csv
import hashlib
import hmac
import json
import os
import re
import shutil
import threading
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Iterator
from urllib.parse import quote, urlparse
from archive_content_parser import (
decode_hex_protobuf_text,
file_message_content,
mini_program_content,
parse_file_message_metadata,
parse_mini_program_metadata,
)
from archive_conversation_filter import should_exclude_conversation
DEFAULT_TENANT = "default"
RELEASE_TENANT = "system-release"
# 「全部」:不限租户。只有读路径认它——写和绑定必须落到某一个确定的租户上,
# 否则一条身份绑定会不知道该记在谁名下。
ALL_TENANTS = "*"
MAX_DIRECT_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024
MAX_RELEASE_PACKAGE_BYTES = 1024 * 1024 * 1024
MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024
MULTIPART_PART_BYTES = 2 * 1024 * 1024
EXCEL_SHEET_DATA_ROWS = 900_000
SAFE_SCOPE_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
SCHEMA = """
CREATE TABLE IF NOT EXISTS archive_storage_config (
id INTEGER PRIMARY KEY CHECK(id = 1),
provider TEXT NOT NULL DEFAULT 'cos',
bucket TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
custom_domain TEXT NOT NULL DEFAULT '',
media_prefix TEXT NOT NULL DEFAULT 'archive/media',
export_prefix TEXT NOT NULL DEFAULT 'archive/exports',
release_prefix TEXT NOT NULL DEFAULT 'desktop/releases',
encryption_mode TEXT NOT NULL DEFAULT 'AES256',
secret_id_enc TEXT NOT NULL DEFAULT '',
secret_key_enc TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS archive_source_account (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
external_account_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
corp_scope_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(tenant_id, external_account_id)
);
CREATE TABLE IF NOT EXISTS archive_import_batch (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
source_account_id TEXT NOT NULL REFERENCES archive_source_account(id),
status TEXT NOT NULL,
received_rows INTEGER NOT NULL DEFAULT 0,
inserted_rows INTEGER NOT NULL DEFAULT 0,
duplicate_rows INTEGER NOT NULL DEFAULT 0,
error_rows INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS archive_checkpoint (
source_account_id TEXT NOT NULL REFERENCES archive_source_account(id),
source_table TEXT NOT NULL,
cursor_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(source_account_id, source_table)
);
CREATE TABLE IF NOT EXISTS archive_raw_event (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
source_account_id TEXT NOT NULL REFERENCES archive_source_account(id),
batch_id TEXT NOT NULL REFERENCES archive_import_batch(id),
source_table TEXT NOT NULL DEFAULT 'message_table',
source_message_key TEXT NOT NULL,
payload_json TEXT NOT NULL,
payload_hash TEXT NOT NULL,
parser_version TEXT NOT NULL DEFAULT 'archive-v1',
parse_status TEXT NOT NULL DEFAULT 'parsed',
ingested_at TEXT NOT NULL,
UNIQUE(tenant_id, source_account_id, source_message_key)
);
CREATE TABLE IF NOT EXISTS archive_person (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
real_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_person_identity (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
person_id TEXT NOT NULL REFERENCES archive_person(id),
identity_type TEXT NOT NULL,
scope_id TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL,
external_id_hash TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'local_db',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(tenant_id, identity_type, scope_id, external_id_hash)
);
CREATE TABLE IF NOT EXISTS archive_conversation (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
source_account_id TEXT NOT NULL REFERENCES archive_source_account(id),
external_id TEXT NOT NULL,
conversation_type TEXT NOT NULL DEFAULT 'unknown',
name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_message_at TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL DEFAULT '{}',
UNIQUE(tenant_id, source_account_id, external_id)
);
CREATE TABLE IF NOT EXISTS archive_conversation_member (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
conversation_id TEXT NOT NULL REFERENCES archive_conversation(id),
person_id TEXT NOT NULL REFERENCES archive_person(id),
member_role TEXT NOT NULL DEFAULT 'member',
nickname TEXT NOT NULL DEFAULT '',
valid_from TEXT NOT NULL DEFAULT '',
valid_to TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(conversation_id, person_id, valid_from)
);
CREATE TABLE IF NOT EXISTS archive_message (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
conversation_id TEXT NOT NULL REFERENCES archive_conversation(id),
source_account_id TEXT NOT NULL REFERENCES archive_source_account(id),
sender_person_id TEXT REFERENCES archive_person(id),
raw_event_id TEXT REFERENCES archive_raw_event(id),
source_message_id TEXT NOT NULL DEFAULT '',
server_id TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
sequence_no INTEGER,
message_type TEXT NOT NULL DEFAULT 'unknown',
content TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT 'unknown',
status TEXT NOT NULL DEFAULT 'normal',
sent_at TEXT NOT NULL,
sent_at_epoch INTEGER,
dedup_key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(tenant_id, dedup_key)
);
CREATE TABLE IF NOT EXISTS archive_message_version (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES archive_message(id),
version_no INTEGER NOT NULL,
status TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
raw_event_id TEXT REFERENCES archive_raw_event(id),
created_at TEXT NOT NULL,
UNIQUE(message_id, version_no)
);
CREATE TABLE IF NOT EXISTS archive_message_relation (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES archive_message(id),
relation_type TEXT NOT NULL,
target_source_id TEXT NOT NULL DEFAULT '',
target_message_id TEXT REFERENCES archive_message(id),
created_at TEXT NOT NULL,
UNIQUE(message_id, relation_type, target_source_id)
);
CREATE TABLE IF NOT EXISTS archive_media_object (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'cos',
bucket TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
object_key TEXT NOT NULL DEFAULT '',
version_id TEXT NOT NULL DEFAULT '',
sha256 TEXT NOT NULL,
crc64 TEXT NOT NULL DEFAULT '',
etag TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
original_filename TEXT NOT NULL DEFAULT '',
media_type TEXT NOT NULL DEFAULT 'file',
storage_class TEXT NOT NULL DEFAULT 'STANDARD',
encryption_mode TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'reserved',
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
verified_at TEXT NOT NULL DEFAULT '',
UNIQUE(tenant_id, sha256)
);
CREATE TABLE IF NOT EXISTS archive_media_upload_session (
media_id TEXT PRIMARY KEY REFERENCES archive_media_object(id) ON DELETE CASCADE,
upload_id TEXT NOT NULL,
part_size INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_message_attachment (
message_id TEXT NOT NULL REFERENCES archive_message(id),
media_id TEXT NOT NULL REFERENCES archive_media_object(id),
attachment_index INTEGER NOT NULL DEFAULT 0,
attachment_role TEXT NOT NULL DEFAULT 'attachment',
match_method TEXT NOT NULL DEFAULT 'source',
match_confidence REAL NOT NULL DEFAULT 1.0,
created_at TEXT NOT NULL,
PRIMARY KEY(message_id, media_id, attachment_index)
);
CREATE TABLE IF NOT EXISTS archive_pending_attachment (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
message_id TEXT NOT NULL REFERENCES archive_message(id) ON DELETE CASCADE,
source_account_id TEXT NOT NULL REFERENCES archive_source_account(id),
source_message_id TEXT NOT NULL DEFAULT '',
original_filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
checksum TEXT NOT NULL DEFAULT '',
media_type TEXT NOT NULL DEFAULT 'file',
source_reference_sha256 TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'source_not_cached',
media_id TEXT REFERENCES archive_media_object(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(message_id, original_filename, checksum)
);
CREATE TABLE IF NOT EXISTS archive_export_job (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
status TEXT NOT NULL,
formats_json TEXT NOT NULL,
filters_json TEXT NOT NULL,
cutoff_at TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
total_rows INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT '',
completed_at TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS archive_export_file (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES archive_export_job(id) ON DELETE CASCADE,
file_format TEXT NOT NULL,
file_name TEXT NOT NULL,
local_path TEXT NOT NULL DEFAULT '',
object_key TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
storage_status TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_maintenance_state (
maintenance_key TEXT PRIMARY KEY,
maintenance_value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_zyt_session (
tenant_id TEXT PRIMARY KEY,
zyt_user_id TEXT NOT NULL DEFAULT '',
token_enc TEXT NOT NULL,
refreshed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_patient_shadow (
tenant_id TEXT NOT NULL,
patient_id INTEGER NOT NULL,
latest_diagnosis_id INTEGER NOT NULL DEFAULT 0,
patient_name TEXT NOT NULL DEFAULT '',
phone_masked TEXT NOT NULL DEFAULT '',
gender TEXT NOT NULL DEFAULT '',
age INTEGER,
source_update_time TEXT NOT NULL DEFAULT '',
synced_at TEXT NOT NULL,
PRIMARY KEY(tenant_id, patient_id)
);
CREATE TABLE IF NOT EXISTS archive_patient_binding (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
person_id TEXT NOT NULL REFERENCES archive_person(id),
patient_id INTEGER NOT NULL,
diagnosis_id INTEGER NOT NULL DEFAULT 0,
patient_name TEXT NOT NULL DEFAULT '',
phone_masked TEXT NOT NULL DEFAULT '',
relation_type TEXT NOT NULL DEFAULT 'self',
bind_method TEXT NOT NULL DEFAULT 'manual',
confidence INTEGER NOT NULL DEFAULT 100,
is_primary INTEGER NOT NULL DEFAULT 1,
source_external_userid TEXT NOT NULL DEFAULT '',
source_unionid_hash TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','revoked')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
bound_by INTEGER REFERENCES users(id),
revoked_at TEXT NOT NULL DEFAULT '',
revoked_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS archive_patient_binding_audit (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
person_id TEXT NOT NULL,
patient_id INTEGER NOT NULL,
action TEXT NOT NULL,
snapshot_json TEXT NOT NULL DEFAULT '{}',
actor_id INTEGER REFERENCES users(id),
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_raw_batch
ON archive_raw_event(batch_id, ingested_at);
CREATE INDEX IF NOT EXISTS idx_archive_raw_source
ON archive_raw_event(tenant_id, source_account_id, source_message_key, ingested_at);
CREATE INDEX IF NOT EXISTS idx_archive_identity_person
ON archive_person_identity(person_id, identity_type);
CREATE INDEX IF NOT EXISTS idx_archive_member_person
ON archive_conversation_member(person_id, conversation_id);
CREATE INDEX IF NOT EXISTS idx_archive_conv_recent
ON archive_conversation(tenant_id, last_message_at DESC, id);
-- New name also upgrades existing databases with the old mixed-direction index.
CREATE INDEX IF NOT EXISTS idx_archive_conv_page
ON archive_conversation(tenant_id, last_message_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_person_recent
ON archive_person(tenant_id, updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_msg_recent
ON archive_message(tenant_id, sent_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_media_recent
ON archive_media_object(tenant_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_export_files
ON archive_export_file(job_id, file_name);
CREATE INDEX IF NOT EXISTS idx_archive_msg_timeline
ON archive_message(tenant_id, conversation_id, sent_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_msg_sender
ON archive_message(tenant_id, sender_person_id, sent_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_archive_msg_source
ON archive_message(source_account_id, source_message_id);
CREATE INDEX IF NOT EXISTS idx_archive_media_status
ON archive_media_object(tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_archive_pending_source
ON archive_pending_attachment(source_account_id, status, source_message_id);
CREATE INDEX IF NOT EXISTS idx_archive_pending_retry
ON archive_pending_attachment(source_account_id, status, updated_at, source_message_id);
CREATE INDEX IF NOT EXISTS idx_archive_export_created
ON archive_export_job(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_archive_patient_person
ON archive_patient_binding(tenant_id, person_id, status, is_primary);
CREATE INDEX IF NOT EXISTS idx_archive_patient_id
ON archive_patient_binding(tenant_id, patient_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS uk_archive_patient_active_pair
ON archive_patient_binding(tenant_id, person_id, patient_id)
WHERE status='active';
CREATE INDEX IF NOT EXISTS idx_archive_patient_audit
ON archive_patient_binding_audit(tenant_id, binding_id, created_at DESC);
"""
MYSQL_EXPORT_SCHEMA = """SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS archive_people (
person_id VARCHAR(40) NOT NULL,
display_name VARCHAR(255) NOT NULL DEFAULT '',
real_name VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (person_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS archive_conversations (
conversation_id VARCHAR(40) NOT NULL,
source_account VARCHAR(191) NOT NULL,
external_id VARCHAR(255) NOT NULL,
conversation_type VARCHAR(32) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
last_message_at DATETIME(3) NULL,
PRIMARY KEY (conversation_id),
KEY idx_archive_conversation_source (source_account, external_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS archive_messages (
message_id VARCHAR(40) NOT NULL,
dedup_key CHAR(64) NOT NULL,
conversation_id VARCHAR(40) NOT NULL,
sender_person_id VARCHAR(40) NULL,
source_message_id VARCHAR(255) NOT NULL DEFAULT '',
server_id VARCHAR(255) NOT NULL DEFAULT '',
client_id VARCHAR(255) NOT NULL DEFAULT '',
sequence_no BIGINT NULL,
message_type VARCHAR(64) NOT NULL,
direction VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
sent_at DATETIME(3) NOT NULL,
content LONGTEXT NOT NULL,
PRIMARY KEY (message_id),
UNIQUE KEY uk_archive_message_dedup (dedup_key),
KEY idx_archive_message_timeline (conversation_id, sent_at, message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS archive_media (
media_id VARCHAR(40) NOT NULL,
bucket VARCHAR(191) NOT NULL DEFAULT '',
region VARCHAR(64) NOT NULL DEFAULT '',
object_key VARCHAR(1024) NOT NULL DEFAULT '',
version_id VARCHAR(255) NOT NULL DEFAULT '',
sha256 CHAR(64) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
mime_type VARCHAR(191) NOT NULL,
original_filename VARCHAR(512) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
PRIMARY KEY (media_id),
KEY idx_archive_media_sha256 (sha256)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS archive_message_media (
message_id VARCHAR(40) NOT NULL,
media_id VARCHAR(40) NOT NULL,
attachment_index INT NOT NULL DEFAULT 0,
attachment_role VARCHAR(32) NOT NULL DEFAULT 'attachment',
match_method VARCHAR(32) NOT NULL DEFAULT 'source',
match_confidence DECIMAL(6,5) NOT NULL DEFAULT 1,
PRIMARY KEY (message_id, media_id, attachment_index),
KEY idx_archive_message_media_media (media_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS archive_pending_attachments (
pending_id VARCHAR(40) NOT NULL,
message_id VARCHAR(40) NOT NULL,
source_message_id VARCHAR(255) NOT NULL DEFAULT '',
original_filename VARCHAR(512) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
checksum VARCHAR(128) NOT NULL DEFAULT '',
media_type VARCHAR(32) NOT NULL DEFAULT 'file',
status VARCHAR(32) NOT NULL,
media_id VARCHAR(40) NULL,
PRIMARY KEY (pending_id),
KEY idx_archive_pending_message (message_id),
KEY idx_archive_pending_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"""
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
def new_id() -> str:
return uuid.uuid4().hex
def json_text(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def safe_scope(value: str, *, label: str = "标识") -> str:
text = str(value or "").strip()
if not SAFE_SCOPE_RE.fullmatch(text):
raise ValueError(f"{label}只能包含字母、数字、下划线和连字符,长度 1–64")
return text
class TenantScope:
"""一次查询要看哪些租户。
管理端的账号下拉框有三档,这个类就是它们在 SQL 里的样子:
某个账号 → `tenant_id=?`,和以前完全一样
全部(超管)→ 不加条件。有 `desktop-account:all` 的人本来就能看全部,
加一串 IN 反而要先把所有租户查出来
全部(受限)→ `tenant_id IN (?,?)`,只覆盖他被授权的那几个账号
做成一个对象而不是各处手写 if,是因为这段条件要拼进十来条 SQL,每条的表
别名还不一样(`c.tenant_id`、`m.tenant_id`)。散着写,漏掉一处就是越权看到
别人的聊天记录——这种错不会报错,只会安静地多给几行数据。
"""
__slots__ = ("everything", "tenants")
def __init__(self, tenants: tuple[str, ...], everything: bool) -> None:
self.tenants = tenants
self.everything = everything
def clause(self, alias: str = "") -> str:
"""返回可直接拼进 WHERE 的条件;参数见 `params`,顺序一致。"""
column = f"{alias}.tenant_id" if alias else "tenant_id"
if self.everything:
# 不是"没有条件"而是"恒真":调用方都在 f-string 里拼 `WHERE {clause}`,
# 给空串会拼出 `WHERE AND ...` 这种语法错误。
return "1=1"
if len(self.tenants) == 1:
return f"{column}=?"
placeholders = ",".join("?" for _ in self.tenants)
return f"{column} IN ({placeholders})"
@property
def params(self) -> list[str]:
return [] if self.everything else list(self.tenants)
@property
def single(self) -> str:
"""只有一个租户时返回它,否则报错。写路径用这个。"""
if self.everything or len(self.tenants) != 1:
raise ValueError("这一步要先选择具体的客户端账号,「全部」下无法确定归属")
return self.tenants[0]
def tenant_scope(value: Any = DEFAULT_TENANT) -> TenantScope:
"""把调用方给的租户参数归一成 `TenantScope`。
接受三种形状,因为调用链上游(`resolve_admin_tenant`)本来就会给出三种:
一个租户字符串、`"*"`、或者一组租户。
"""
if isinstance(value, TenantScope):
return value
if value is None or value == "":
value = DEFAULT_TENANT
if isinstance(value, str):
if value == ALL_TENANTS:
return TenantScope((), True)
return TenantScope((safe_scope(value, label="租户"),), False)
tenants = tuple(
dict.fromkeys(safe_scope(item, label="租户") for item in value if item)
)
if not tenants:
raise ValueError("没有可查看的客户端账号")
return TenantScope(tenants, False)
def safe_prefix(value: str, default: str) -> str:
text = str(value or default).strip().strip("/")
if not text or ".." in text or "\\" in text:
raise ValueError("COS 路径前缀不合法")
if any(part in {"", ".", ".."} for part in text.split("/")):
raise ValueError("COS 路径前缀不合法")
return text
def normalize_sent_at(value: Any, epoch: Any = None) -> tuple[str, int | None]:
"""把秒/毫秒时间戳或 ISO 文本统一成 UTC ISO;同时保留原始秒级 epoch。"""
raw = epoch if epoch not in (None, "") else value
if isinstance(raw, (int, float)) or str(raw or "").strip().isdigit():
number = int(float(raw))
if number > 100_000_000_000: # 毫秒
number //= 1000
if number > 0:
return datetime.fromtimestamp(number, tz=timezone.utc).isoformat(
timespec="milliseconds"
), number
text = str(value or "").strip()
if not text:
raise ValueError("消息时间不能为空")
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"无法识别消息时间:{text}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
parsed = parsed.astimezone(timezone.utc)
return parsed.isoformat(timespec="milliseconds"), int(parsed.timestamp())
def conversation_type(external_id: str, explicit: str = "") -> str:
if explicit:
return explicit
prefix = str(external_id or "").split(":", 1)[0].upper()
return {
"M": "direct_wechat",
"S": "direct_wecom",
"R": "group",
"Y": "application",
"O": "service",
}.get(prefix, "unknown")
def media_type(mime_type: str) -> str:
value = str(mime_type or "").lower()
if value.startswith("image/"):
return "image"
if value.startswith("audio/"):
return "audio"
if value.startswith("video/"):
return "video"
return "file"
def _is_placeholder_name(value: Any, external_id: Any) -> bool:
"""判断名称是否只是源库里的技术 ID,占位值不能覆盖真实昵称。"""
text = str(value or "").strip()
external = str(external_id or "").strip()
if not text or text == external:
return True
if text.startswith(("S:", "M:", "R:", "Y:", "O:")):
return True
return text.isdigit()
def _display_message_content(
content: Any, message_type_name: Any, *, has_attachment: bool = False
) -> str:
"""隐藏媒体 protobuf 的十六进制残留,保留真正可读的文本。"""
text = str(content or "").strip()
type_name = str(message_type_name or "未知消息").strip() or "未知消息"
decoded = decode_hex_protobuf_text(text, type_name)
if decoded:
return decoded
compact = re.sub(r"[\s|]+", "", text)
looks_binary = bool(
len(compact) >= 80
and len(compact) % 2 == 0
and re.fullmatch(r"[0-9a-fA-F]+", compact)
)
if text and not looks_binary:
return text
if has_attachment or type_name in {
"图片", "截图", "语音", "视频", "文件", "文件回复", "文件预览",
"文件分享", "群文件", "表情",
}:
return f"[{type_name}]"
return text
def excel_safe(value: Any) -> Any:
if not isinstance(value, str):
return value
cleaned = "".join(ch for ch in value if ch in "\t\n\r" or ord(ch) >= 32)
if cleaned.startswith(("=", "+", "-", "@")):
cleaned = "'" + cleaned
if len(cleaned) > 32_767:
cleaned = cleaned[:32_730] + "…[完整内容见 SQL/CSV]"
return cleaned
def mysql_literal(value: Any) -> str:
if value is None:
return "NULL"
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return str(value)
text = str(value).replace("\\", "\\\\").replace("'", "''")
text = text.replace("\x00", "").replace("\r", "\\r").replace("\n", "\\n")
return f"'{text}'"
def mysql_datetime(value: Any) -> str | None:
"""将库内 UTC ISO 时间转成 MySQL DATETIME(3) 可直接导入的文本。"""
text = str(value or "").strip()
if not text:
return None
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
parsed = parsed.astimezone(timezone.utc)
return parsed.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
class ArchiveStore:
def __init__(self, database: Any):
self.database = database
self.export_root = Path(database.path).parent / "archive_exports"
self._export_lock = threading.Lock()
self._media_upload_lock = threading.Lock()
def initialize(self) -> None:
now = utc_now()
with self.database.connect() as db:
db.executescript(SCHEMA)
storage_columns = {
row[1] for row in db.execute("PRAGMA table_info(archive_storage_config)")
}
if "release_prefix" not in storage_columns:
db.execute(
"ALTER TABLE archive_storage_config ADD COLUMN "
"release_prefix TEXT NOT NULL DEFAULT 'desktop/releases'"
)
# 已存在的 admin 角色不会被 rbac.seed 重新灌新权限,这里补齐它的
# 全管理能力。operator/viewer 不自动获得敏感聊天权限,由角色页显式授予。
grants = {
"admin": (
"im:read", "im:content:read", "im:import", "im:export",
"im:storage:write", "im:identity:write",
),
}
for role, codes in grants.items():
if db.execute("SELECT 1 FROM roles WHERE code=?", (role,)).fetchone():
db.executemany(
"INSERT OR IGNORE INTO role_permissions(role_code,permission_code) "
"VALUES (?,?)",
[(role, code) for code in codes],
)
db.execute(
"""INSERT OR IGNORE INTO archive_storage_config
(id,updated_at) VALUES (1,?)""",
(now,),
)
self._repair_hex_protobuf_text(db, now)
self._repair_file_message_metadata(db, now)
self._repair_mini_program_metadata(db, now)
db.commit()
@staticmethod
def _repair_hex_protobuf_text(db: Any, now: str) -> int:
"""一次性回填旧版漏掉的单字符/短文本,原始事件仍完整保留。"""
repair_key = "hex-protobuf-text-v1"
if db.execute(
"SELECT 1 FROM archive_maintenance_state WHERE maintenance_key=?",
(repair_key,),
).fetchone():
return 0
rows = db.execute(
"""SELECT id,message_type,content FROM archive_message
WHERE message_type IN ('文本','text','Text')
AND length(content) BETWEEN 2 AND 131072"""
).fetchall()
repaired = 0
for row in rows:
original = str(row["content"] or "")
decoded = decode_hex_protobuf_text(original, row["message_type"])
if not decoded or decoded == original:
continue
db.execute(
"UPDATE archive_message SET content=?,updated_at=? WHERE id=?",
(decoded, now, row["id"]),
)
db.execute(
"""UPDATE archive_message_version SET content=?
WHERE message_id=? AND content=?""",
(decoded, row["id"], original),
)
repaired += 1
db.execute(
"""INSERT INTO archive_maintenance_state
(maintenance_key,maintenance_value,updated_at) VALUES (?,?,?)""",
(repair_key, str(repaired), now),
)
return repaired
@staticmethod
def _repair_file_message_metadata(db: Any, now: str) -> int:
"""从原始事件回填文件名/大小;未下载到本机的文件进入待补传表。"""
repair_key = "file-message-metadata-v1"
if db.execute(
"SELECT 1 FROM archive_maintenance_state WHERE maintenance_key=?",
(repair_key,),
).fetchone():
return 0
rows = db.execute(
"""SELECT m.id,m.source_account_id,m.source_message_id,m.content,
m.message_type,r.payload_json
FROM archive_message m
JOIN archive_raw_event r ON r.id=m.raw_event_id
WHERE m.message_type IN ('合并转发','文件')"""
).fetchall()
repaired = 0
for row in rows:
try:
payload = json.loads(row["payload_json"])
raw_fields = payload.get("raw_fields") or {}
encoded = raw_fields.get("content") or {}
if encoded.get("encoding") != "base64":
continue
raw_content = base64.b64decode(encoded.get("data") or "", validate=True)
metadata = parse_file_message_metadata(
raw_content, raw_fields.get("content_type")
)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if not metadata:
continue
linked = db.execute(
"""SELECT mo.id FROM archive_message_attachment ma
JOIN archive_media_object mo ON mo.id=ma.media_id
WHERE ma.message_id=? AND mo.original_filename=?
AND mo.size_bytes=? AND mo.status='ready' LIMIT 1""",
(
row["id"],
metadata["original_filename"],
metadata["size_bytes"],
),
).fetchone()
cached = linked is not None
normalized = file_message_content(metadata, cached=cached)
db.execute(
"""UPDATE archive_message SET message_type='文件',content=?,updated_at=?
WHERE id=?""",
(normalized, now, row["id"]),
)
db.execute(
"""UPDATE archive_message_version SET content=?
WHERE message_id=? AND content=?""",
(normalized, row["id"], row["content"]),
)
if not cached:
db.execute(
"""INSERT INTO archive_pending_attachment
(id,tenant_id,message_id,source_account_id,source_message_id,
original_filename,size_bytes,checksum,media_type,
source_reference_sha256,status,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,'source_not_cached',?,?)
ON CONFLICT(message_id,original_filename,checksum) DO UPDATE SET
size_bytes=excluded.size_bytes,
source_reference_sha256=excluded.source_reference_sha256,
updated_at=excluded.updated_at""",
(
new_id(), DEFAULT_TENANT, row["id"], row["source_account_id"],
row["source_message_id"], metadata["original_filename"],
metadata["size_bytes"], metadata["checksum"], "file",
metadata["source_reference_sha256"], now, now,
),
)
repaired += 1
db.execute(
"""INSERT INTO archive_maintenance_state
(maintenance_key,maintenance_value,updated_at) VALUES (?,?,?)""",
(repair_key, str(repaired), now),
)
return repaired
@staticmethod
def _repair_mini_program_metadata(db: Any, now: str) -> int:
"""回填旧版误标为“修改群名”的 content_type=78 小程序卡片。"""
repair_key = "mini-program-metadata-v1"
if db.execute(
"SELECT 1 FROM archive_maintenance_state WHERE maintenance_key=?",
(repair_key,),
).fetchone():
return 0
rows = db.execute(
"""SELECT m.id,m.content,r.payload_json
FROM archive_message m
JOIN archive_raw_event r ON r.id=m.raw_event_id
WHERE m.message_type IN ('修改群名','小程序')"""
).fetchall()
repaired = 0
for row in rows:
try:
payload = json.loads(row["payload_json"])
raw_fields = payload.get("raw_fields") or {}
encoded = raw_fields.get("content") or {}
if encoded.get("encoding") != "base64":
continue
raw_content = base64.b64decode(encoded.get("data") or "", validate=True)
metadata = parse_mini_program_metadata(
raw_content, raw_fields.get("content_type")
)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if not metadata:
continue
normalized = mini_program_content(metadata)
db.execute(
"""UPDATE archive_message SET message_type='小程序',content=?,updated_at=?
WHERE id=?""",
(normalized, now, row["id"]),
)
db.execute(
"""UPDATE archive_message_version SET content=?
WHERE message_id=? AND content=?""",
(normalized, row["id"], row["content"]),
)
repaired += 1
db.execute(
"""INSERT INTO archive_maintenance_state
(maintenance_key,maintenance_value,updated_at) VALUES (?,?,?)""",
(repair_key, str(repaired), now),
)
return repaired
# ── COS 配置 ─────────────────────────────────────────────────────────
def _decrypt_storage_row(self, row: Any) -> dict[str, Any]:
import secret_box
item = {key: row[key] for key in row.keys()}
secret_id = ""
secret_key = ""
try:
if item.get("secret_id_enc"):
secret_id = secret_box.decrypt(
item["secret_id_enc"], self.database._secret_key()
)
if item.get("secret_key_enc"):
secret_key = secret_box.decrypt(
item["secret_key_enc"], self.database._secret_key()
)
except Exception as exc:
raise RuntimeError("COS 凭证无法解密,请重新填写") from exc
item["secret_id"] = secret_id
item["secret_key"] = secret_key
return item
def storage_config(self, *, include_secrets: bool = False) -> dict[str, Any]:
import secret_box
with self.database.connect() as db:
row = db.execute("SELECT * FROM archive_storage_config WHERE id=1").fetchone()
if row is None:
self.initialize()
return self.storage_config(include_secrets=include_secrets)
item = self._decrypt_storage_row(row)
item.pop("secret_id_enc", None)
item.pop("secret_key_enc", None)
item["enabled"] = bool(item.get("enabled"))
item["secret_id_present"] = bool(item["secret_id"])
item["secret_key_present"] = bool(item["secret_key"])
if not include_secrets:
item["secret_id_masked"] = secret_box.masked(item.pop("secret_id", ""))
item["secret_key_masked"] = secret_box.masked(item.pop("secret_key", ""))
return item
def save_storage_config(self, values: dict[str, Any], user_id: int, ip: str) -> dict:
import secret_box
bucket = str(values.get("bucket") or "").strip()
region = str(values.get("region") or "").strip()
if bucket and not re.fullmatch(r"[a-z0-9][a-z0-9.-]{0,62}-\d+", bucket):
raise ValueError("Bucket 格式不正确,应包含 APPID 后缀")
if region and not re.fullmatch(r"[a-z0-9-]{2,64}", region):
raise ValueError("Region 格式不正确")
custom_domain = str(values.get("custom_domain") or "").strip().rstrip("/")
if custom_domain:
parsed = urlparse(custom_domain)
if parsed.scheme != "https" or not parsed.netloc or parsed.username:
raise ValueError("自定义域名必须是无账号信息的完整 HTTPS 地址")
if parsed.path not in ("", "/") or parsed.query or parsed.fragment:
raise ValueError("自定义域名不能包含路径、查询参数或片段")
encryption = str(values.get("encryption_mode") or "AES256").strip()
if encryption not in {"", "AES256", "cos/kms"}:
raise ValueError("加密模式只支持 AES256、cos/kms 或留空")
media_prefix = safe_prefix(values.get("media_prefix", ""), "archive/media")
export_prefix = safe_prefix(values.get("export_prefix", ""), "archive/exports")
release_prefix = safe_prefix(
values.get("release_prefix", ""), "desktop/releases"
)
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
current = db.execute(
"SELECT secret_id_enc,secret_key_enc FROM archive_storage_config WHERE id=1"
).fetchone()
raw_id = str(values.get("secret_id") or "").strip()
raw_key = str(values.get("secret_key") or "").strip()
secret_id_enc = (
secret_box.encrypt(raw_id, self.database._secret_key())
if raw_id
else (current["secret_id_enc"] if current else "")
)
secret_key_enc = (
secret_box.encrypt(raw_key, self.database._secret_key())
if raw_key
else (current["secret_key_enc"] if current else "")
)
enabled = bool(values.get("enabled"))
if enabled and not (bucket and region and secret_id_enc and secret_key_enc):
raise ValueError("开启 COS 前必须填写 Bucket、Region、SecretId 和 SecretKey")
db.execute(
"""INSERT INTO archive_storage_config
(id,provider,bucket,region,custom_domain,media_prefix,
export_prefix,release_prefix,encryption_mode,
secret_id_enc,secret_key_enc,
enabled,updated_at,updated_by)
VALUES (1,'cos',?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
bucket=excluded.bucket,region=excluded.region,
custom_domain=excluded.custom_domain,
media_prefix=excluded.media_prefix,
export_prefix=excluded.export_prefix,
release_prefix=excluded.release_prefix,
encryption_mode=excluded.encryption_mode,
secret_id_enc=excluded.secret_id_enc,
secret_key_enc=excluded.secret_key_enc,
enabled=excluded.enabled,updated_at=excluded.updated_at,
updated_by=excluded.updated_by""",
(
bucket, region, custom_domain, media_prefix, export_prefix,
release_prefix, encryption, secret_id_enc, secret_key_enc,
1 if enabled else 0, now, user_id,
),
)
self.database._audit(
db, user_id, "archive.storage.save",
f"bucket={bucket} region={region} enabled={enabled}", ip,
)
db.commit()
return self.storage_config()
def _cos_client(self):
config = self.storage_config(include_secrets=True)
if not config.get("enabled"):
raise RuntimeError("COS 尚未启用")
if not all(config.get(key) for key in ("bucket", "region", "secret_id", "secret_key")):
raise RuntimeError("COS 配置不完整")
from qcloud_cos import CosConfig, CosS3Client
sdk_config = CosConfig(
Region=config["region"],
SecretId=config["secret_id"],
SecretKey=config["secret_key"],
Timeout=60,
)
return config, CosS3Client(sdk_config)
def test_storage(self) -> dict[str, Any]:
config, client = self._cos_client()
response = client.head_bucket(Bucket=config["bucket"])
return {
"ok": True,
"bucket": config["bucket"],
"region": config["region"],
"request_id": str(response.get("x-cos-request-id") or ""),
}
# ── COS 素材 ─────────────────────────────────────────────────────────
def prepare_media(
self,
values: dict[str, Any],
*,
prefix_config_key: str = "media_prefix",
) -> dict[str, Any]:
tenant = safe_scope(values.get("tenant_id") or DEFAULT_TENANT, label="租户")
digest = str(values.get("sha256") or "").strip().lower()
if not SHA256_RE.fullmatch(digest):
raise ValueError("sha256 必须是 64 位小写十六进制")
size = int(values.get("size_bytes") or 0)
if size <= 0 or size > MAX_DIRECT_UPLOAD_BYTES:
raise ValueError("文件大小必须大于 0 且不超过 5GB")
mime = str(values.get("mime_type") or "application/octet-stream").strip()[:191]
filename = Path(str(values.get("original_filename") or "file")).name[:512]
config, client = self._cos_client()
now = utc_now()
with self.database.connect() as db:
row = db.execute(
"SELECT * FROM archive_media_object WHERE tenant_id=? AND sha256=?",
(tenant, digest),
).fetchone()
if row is not None and row["status"] == "ready":
return {
"media": self._public_media(row),
"reused": True,
"upload_mode": "reused",
"upload_url": "",
"required_headers": {},
}
# 上次可能已经把完整对象传到 COS,只在最终校验/回写时中断。先尝试
# 修复并复用,避免大视频因一次接口中断从头上传。
if row is not None:
try:
repaired = self._verify_media(row["id"], config, client)
except Exception:
pass
else:
return {
"media": repaired,
"reused": True,
"upload_mode": "reused",
"upload_url": "",
"required_headers": {},
}
with self.database.connect() as db:
row = db.execute(
"SELECT * FROM archive_media_object WHERE tenant_id=? AND sha256=?",
(tenant, digest),
).fetchone()
media_id = row["id"] if row is not None else new_id()
default_prefix = (
"desktop/releases"
if prefix_config_key == "release_prefix"
else "archive/media"
)
object_key = (
f"{safe_prefix(config.get(prefix_config_key, ''), default_prefix)}/"
f"{tenant}/original/{digest[:2]}/{digest}"
)
if row is None:
db.execute(
"""INSERT INTO archive_media_object
(id,tenant_id,bucket,region,object_key,sha256,size_bytes,
mime_type,original_filename,media_type,encryption_mode,
status,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,'reserved',?)""",
(
media_id, tenant, config["bucket"], config["region"], object_key,
digest, size, mime, filename, media_type(mime),
config.get("encryption_mode") or "", now,
),
)
else:
db.execute(
"""UPDATE archive_media_object SET bucket=?,region=?,object_key=?,
size_bytes=?,mime_type=?,original_filename=?,media_type=?,
encryption_mode=?,status='reserved',last_error='' WHERE id=?""",
(
config["bucket"], config["region"], object_key, size, mime,
filename, media_type(mime), config.get("encryption_mode") or "",
media_id,
),
)
db.commit()
row = db.execute(
"SELECT * FROM archive_media_object WHERE id=?", (media_id,)
).fetchone()
encryption = config.get("encryption_mode") or ""
if size >= MULTIPART_THRESHOLD_BYTES:
return self._prepare_multipart_media(
config, client, row, mime=mime, encryption=encryption
)
headers = {"Content-Type": mime, "x-cos-meta-sha256": digest}
if encryption:
headers["x-cos-server-side-encryption"] = encryption
upload_url = client.get_presigned_url(
Bucket=config["bucket"], Key=object_key, Method="PUT", Expired=900,
Headers=headers,
)
return {
"media": self._public_media(row),
"reused": False,
"upload_mode": "single",
"upload_url": upload_url,
"expires_in": 900,
"required_headers": headers,
}
@staticmethod
def _release_package_payload(payload: dict[str, Any]) -> dict[str, Any]:
media = payload.get("media") if isinstance(payload.get("media"), dict) else {}
media_id = str(media.get("id") or "")
filename = Path(str(media.get("original_filename") or "update.exe")).name
return {
**payload,
"file_name": filename,
"download_url": (
f"/api/v2/desktop/update/cos-packages/{media_id}/{quote(filename)}"
),
"package_sha256": str(media.get("sha256") or ""),
"package_size": int(media.get("size_bytes") or 0),
}
def prepare_release_package(self, values: dict[str, Any]) -> dict[str, Any]:
"""为管理端签发安装包直传 COS 的地址,安装包字节不经过 API。"""
filename = Path(str(values.get("original_filename") or "update.exe")).name
if not filename.lower().endswith(".exe"):
raise ValueError("升级包必须是 Windows EXE 安装包")
size = int(values.get("size_bytes") or 0)
if size <= 0 or size > MAX_RELEASE_PACKAGE_BYTES:
raise ValueError("安装包大小必须大于 0 且不能超过 1GB")
prepared = self.prepare_media(
{
"tenant_id": RELEASE_TENANT,
"sha256": values.get("sha256"),
"size_bytes": size,
"mime_type": "application/vnd.microsoft.portable-executable",
"original_filename": filename,
},
prefix_config_key="release_prefix",
)
return self._release_package_payload(prepared)
def complete_release_package(self, media_id: str) -> dict[str, Any]:
media = self.complete_media(media_id, RELEASE_TENANT)
return self._release_package_payload({"media": media})
def complete_multipart_release_package(
self, media_id: str, upload_id: str, parts: Iterable[dict[str, Any]]
) -> dict[str, Any]:
media = self.complete_multipart_media(
media_id, upload_id, parts, RELEASE_TENANT
)
return self._release_package_payload({"media": media})
def release_package_download_url(
self, media_id: str, filename: str, expires: int = 900
) -> str:
safe_name = Path(str(filename or "")).name
with self.database.connect() as db:
row = db.execute(
"""SELECT original_filename FROM archive_media_object
WHERE id=? AND tenant_id=? AND status='ready'""",
(media_id, RELEASE_TENANT),
).fetchone()
if row is None or safe_name != str(row["original_filename"]):
raise KeyError("升级包不存在或尚未上传完成")
return self.media_download_url(media_id, expires, RELEASE_TENANT)
def _prepare_multipart_media(
self,
config: dict[str, Any],
client: Any,
row: Any,
*,
mime: str,
encryption: str,
) -> dict[str, Any]:
size = int(row["size_bytes"])
part_size = max(
MULTIPART_PART_BYTES,
((size + 9999) // 10000 + 1024 * 1024 - 1) // (1024 * 1024)
* (1024 * 1024),
)
with self._media_upload_lock:
with self.database.connect() as db:
session = db.execute(
"SELECT * FROM archive_media_upload_session WHERE media_id=?",
(row["id"],),
).fetchone()
if session is not None and int(session["part_size"]) != part_size:
try:
client.abort_multipart_upload(
Bucket=row["bucket"],
Key=row["object_key"],
UploadId=str(session["upload_id"]),
)
finally:
with self.database.connect() as db:
db.execute(
"DELETE FROM archive_media_upload_session WHERE media_id=?",
(row["id"],),
)
db.commit()
session = None
if session is None:
options: dict[str, Any] = {
"ContentType": mime,
"Metadata": {"x-cos-meta-sha256": row["sha256"]},
}
if encryption:
options["ServerSideEncryption"] = encryption
created = client.create_multipart_upload(
Bucket=row["bucket"], Key=row["object_key"], **options
)
upload_id = str(created.get("UploadId") or "")
if not upload_id:
raise RuntimeError("COS 没有返回分块上传 ID")
with self.database.connect() as db:
db.execute(
"""INSERT OR REPLACE INTO archive_media_upload_session
(media_id,upload_id,part_size,created_at) VALUES (?,?,?,?)""",
(row["id"], upload_id, part_size, utc_now()),
)
db.commit()
else:
upload_id = str(session["upload_id"])
part_size = int(session["part_size"])
part_count = (size + part_size - 1) // part_size
completed_parts: list[dict[str, Any]] = []
marker = 0
while True:
listed = client.list_parts(
Bucket=row["bucket"],
Key=row["object_key"],
UploadId=upload_id,
MaxParts=1000,
PartNumberMarker=marker,
)
listed_parts = listed.get("Part") or []
if isinstance(listed_parts, dict):
listed_parts = [listed_parts]
for part in listed_parts:
number = int(part.get("PartNumber") or 0)
etag = str(part.get("ETag") or "").strip().strip('"')
part_bytes = int(part.get("Size") or 0)
if number > 0 and etag and part_bytes > 0:
completed_parts.append(
{
"part_number": number,
"etag": etag,
"size_bytes": part_bytes,
}
)
truncated = str(listed.get("IsTruncated") or "").lower() == "true"
next_marker = int(listed.get("NextPartNumberMarker") or 0)
if not truncated or next_marker <= marker:
break
marker = next_marker
parts = []
for number in range(1, part_count + 1):
parts.append(
{
"part_number": number,
"size_bytes": min(part_size, size - (number - 1) * part_size),
"upload_url": client.get_presigned_url(
Bucket=row["bucket"],
Key=row["object_key"],
Method="PUT",
Expired=1800,
Params={"partNumber": number, "uploadId": upload_id},
),
}
)
return {
"media": self._public_media(row),
"reused": False,
"upload_mode": "multipart",
"upload_url": "",
"expires_in": 1800,
"required_headers": {},
"multipart": {
"upload_id": upload_id,
"part_size": part_size,
"parts": parts,
"completed_parts": completed_parts,
},
}
def complete_media(
self, media_id: str, tenant_id: str = DEFAULT_TENANT
) -> dict[str, Any]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
with self.database.connect() as db:
exists = db.execute(
"SELECT 1 FROM archive_media_object WHERE id=? AND tenant_id=?",
(media_id, tenant),
).fetchone()
if exists is None:
raise KeyError("素材不存在")
config, client = self._cos_client()
return self._verify_media(media_id, config, client)
def complete_multipart_media(
self,
media_id: str,
upload_id: str,
parts: Iterable[dict[str, Any]],
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
config, client = self._cos_client()
with self.database.connect() as db:
row = db.execute(
"SELECT * FROM archive_media_object WHERE id=? AND tenant_id=?",
(media_id, tenant),
).fetchone()
session = db.execute(
"SELECT * FROM archive_media_upload_session WHERE media_id=?",
(media_id,),
).fetchone()
if row is None or session is None:
raise KeyError("分块上传任务不存在")
if not upload_id or not hmac.compare_digest(str(session["upload_id"]), upload_id):
raise ValueError("分块上传 ID 不匹配")
expected = (int(row["size_bytes"]) + int(session["part_size"]) - 1) // int(
session["part_size"]
)
normalized: list[dict[str, Any]] = []
seen: set[int] = set()
for item in parts:
number = int(item.get("part_number") or 0)
etag = str(item.get("etag") or "").strip().strip('"')
if not 1 <= number <= expected or number in seen or not etag:
raise ValueError("分块编号或 ETag 不正确")
seen.add(number)
normalized.append({"PartNumber": number, "ETag": etag})
if seen != set(range(1, expected + 1)):
raise ValueError("分块数量不完整")
normalized.sort(key=lambda item: item["PartNumber"])
client.complete_multipart_upload(
Bucket=row["bucket"],
Key=row["object_key"],
UploadId=upload_id,
MultipartUpload={"Part": normalized},
)
with self.database.connect() as db:
db.execute(
"DELETE FROM archive_media_upload_session WHERE media_id=?", (media_id,)
)
db.commit()
return self._verify_media(media_id, config, client)
def _verify_media(
self, media_id: str, config: dict[str, Any], client: Any
) -> dict[str, Any]:
with self.database.connect() as db:
row = db.execute(
"SELECT * FROM archive_media_object WHERE id=?", (media_id,)
).fetchone()
if row is None:
raise KeyError("素材不存在")
if row["bucket"] != config["bucket"] or row["region"] != config["region"]:
raise RuntimeError("素材所属 COS 配置已变化,请重新准备上传")
try:
head = client.head_object(Bucket=row["bucket"], Key=row["object_key"])
remote_size = int(head.get("Content-Length") or head.get("content-length") or 0)
remote_hash = str(
head.get("x-cos-meta-sha256")
or head.get("X-Cos-Meta-Sha256")
or ""
).lower()
if remote_size != int(row["size_bytes"]):
raise RuntimeError(
f"COS 文件大小校验失败:本地 {row['size_bytes']},远端 {remote_size}"
)
if not remote_hash:
options: dict[str, Any] = {
"ContentType": row["mime_type"],
"Metadata": {"x-cos-meta-sha256": row["sha256"]},
}
if row["encryption_mode"]:
options["ServerSideEncryption"] = row["encryption_mode"]
client.copy_object(
Bucket=row["bucket"],
Key=row["object_key"],
CopySource={
"Bucket": row["bucket"],
"Region": row["region"],
"Key": row["object_key"],
},
CopyStatus="Replaced",
**options,
)
head = client.head_object(Bucket=row["bucket"], Key=row["object_key"])
remote_hash = str(
head.get("x-cos-meta-sha256")
or head.get("X-Cos-Meta-Sha256")
or ""
).lower()
if not remote_hash:
raise RuntimeError("COS 对象缺少 SHA-256 元数据,服务端修复失败")
if remote_hash != row["sha256"]:
raise RuntimeError("COS SHA-256 元数据校验失败")
except Exception as exc:
with self.database.connect() as db:
db.execute(
"UPDATE archive_media_object SET status='failed',last_error=? WHERE id=?",
(str(exc)[:1000], media_id),
)
db.commit()
raise
now = utc_now()
with self.database.connect() as db:
db.execute(
"""UPDATE archive_media_object SET status='ready',last_error='',
crc64=?,etag=?,version_id=?,verified_at=? WHERE id=?""",
(
str(head.get("x-cos-hash-crc64ecma") or ""),
str(head.get("ETag") or head.get("etag") or "").strip('"'),
str(head.get("x-cos-version-id") or ""), now, media_id,
),
)
db.commit()
ready = db.execute(
"SELECT * FROM archive_media_object WHERE id=?", (media_id,)
).fetchone()
return self._public_media(ready)
@staticmethod
def _public_media(row: Any) -> dict[str, Any]:
keys = (
"id", "tenant_id", "provider", "bucket", "region", "object_key",
"version_id", "sha256", "crc64", "etag", "size_bytes", "mime_type",
"original_filename", "media_type", "storage_class", "encryption_mode",
"status", "last_error", "created_at", "verified_at",
)
return {key: row[key] for key in keys}
def media_items(
self, limit: int = 100, tenant_id: str = DEFAULT_TENANT
) -> list[dict[str, Any]]:
scope = tenant_scope(tenant_id)
limit = max(1, min(int(limit), 500))
with self.database.connect() as db:
rows = db.execute(
f"""SELECT * FROM archive_media_object WHERE {scope.clause()}
ORDER BY created_at DESC LIMIT ?""",
(*scope.params, limit),
).fetchall()
return [self._public_media(row) for row in rows]
def media_download_url(
self,
media_id: str,
expires: int = 300,
tenant_id: str = DEFAULT_TENANT,
) -> str:
scope = tenant_scope(tenant_id)
config, client = self._cos_client()
with self.database.connect() as db:
row = db.execute(
f"""SELECT * FROM archive_media_object
WHERE id=? AND {scope.clause()} AND status='ready'""",
(media_id, *scope.params),
).fetchone()
if row is None:
raise KeyError("素材不存在或尚未上传完成")
return client.get_presigned_download_url(
Bucket=row["bucket"], Key=row["object_key"], Expired=max(60, min(expires, 900))
)
def media_download_urls(
self,
media_ids: Iterable[str],
expires: int = 300,
tenant_id: str = DEFAULT_TENANT,
) -> list[dict[str, Any]]:
"""批量签发素材访问地址,避免消息页为每个附件重复初始化 COS。"""
scope = tenant_scope(tenant_id)
unique_ids = list(dict.fromkeys(str(item).strip() for item in media_ids if item))
if not unique_ids:
return []
if len(unique_ids) > 200:
raise ValueError("单次最多获取 200 个素材访问地址")
valid_expires = max(60, min(int(expires), 900))
placeholders = ",".join("?" for _ in unique_ids)
with self.database.connect() as db:
rows = db.execute(
f"""SELECT id,bucket,object_key FROM archive_media_object
WHERE {scope.clause()} AND status='ready'
AND id IN ({placeholders})""",
(*scope.params, *unique_ids),
).fetchall()
if not rows:
return []
_, client = self._cos_client()
by_id = {str(row["id"]): row for row in rows}
result: list[dict[str, Any]] = []
for media_id in unique_ids:
row = by_id.get(media_id)
if row is None:
continue
result.append(
{
"id": media_id,
"url": client.get_presigned_download_url(
Bucket=row["bucket"],
Key=row["object_key"],
Expired=valid_expires,
),
"expires_in": valid_expires,
}
)
return result
# ── 导入与 IM 结构 ───────────────────────────────────────────────────
def source_checkpoint(
self,
external_account_id: str,
source_table: str = "message_table",
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
with self.database.connect() as db:
row = db.execute(
"""SELECT c.cursor_json FROM archive_checkpoint c
JOIN archive_source_account a ON a.id=c.source_account_id
WHERE a.tenant_id=? AND a.external_account_id=?
AND c.source_table=?""",
(tenant, str(external_account_id), str(source_table)),
).fetchone()
if row is None:
return {}
try:
value = json.loads(row["cursor_json"])
return value if isinstance(value, dict) else {}
except (TypeError, ValueError):
return {}
def advance_source_checkpoint(
self,
source_account: dict[str, Any],
source_table: str,
checkpoint: dict[str, Any],
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
"""只推进桌面导入游标,用于整批消息均被规则过滤的情况。"""
if not isinstance(checkpoint, dict) or not checkpoint:
raise ValueError("checkpoint 不能为空")
table = str(source_table or "message_table")[:128]
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
account = self._source_account(db, tenant, source_account)
db.execute(
"""INSERT INTO archive_checkpoint
(source_account_id,source_table,cursor_json,updated_at)
VALUES (?,?,?,?) ON CONFLICT(source_account_id,source_table)
DO UPDATE SET cursor_json=excluded.cursor_json,
updated_at=excluded.updated_at""",
(account["id"], table, json_text(checkpoint), now),
)
db.commit()
return checkpoint
def pending_attachment_source_ids(
self,
external_account_id: str,
limit: int = 500,
tenant_id: str = DEFAULT_TENANT,
) -> list[str]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
limit = max(1, min(int(limit), 1000))
with self.database.connect() as db:
rows = db.execute(
"""SELECT DISTINCT pa.source_message_id
FROM archive_pending_attachment pa
JOIN archive_source_account sa ON sa.id=pa.source_account_id
JOIN archive_message m ON m.id=pa.message_id
JOIN archive_conversation c ON c.id=m.conversation_id
WHERE sa.tenant_id=? AND sa.external_account_id=?
AND c.conversation_type<>'application'
AND pa.status='source_not_cached' AND pa.source_message_id<>''
ORDER BY pa.updated_at,pa.source_message_id LIMIT ?""",
(tenant, str(external_account_id), limit),
).fetchall()
return [str(row["source_message_id"]) for row in rows]
def claim_pending_attachment_source_ids(
self,
external_account_id: str,
limit: int = 500,
tenant_id: str = DEFAULT_TENANT,
) -> list[str]:
"""轮转领取待补传附件,避免海量队列中靠后的记录长期得不到检查。"""
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
limit = max(1, min(int(limit), 1000))
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
rows = db.execute(
"""SELECT pa.id,pa.source_message_id
FROM archive_pending_attachment pa
JOIN archive_source_account sa ON sa.id=pa.source_account_id
JOIN archive_message m ON m.id=pa.message_id
JOIN archive_conversation c ON c.id=m.conversation_id
WHERE sa.tenant_id=? AND sa.external_account_id=?
AND c.conversation_type<>'application'
AND pa.status='source_not_cached' AND pa.source_message_id<>''
ORDER BY pa.updated_at,pa.source_message_id LIMIT ?""",
(tenant, str(external_account_id), limit),
).fetchall()
if rows:
db.executemany(
"UPDATE archive_pending_attachment SET updated_at=? WHERE id=?",
[(now, row["id"]) for row in rows],
)
db.commit()
return [str(row["source_message_id"]) for row in rows]
def _source_account(self, db: Any, tenant: str, values: dict[str, Any]) -> Any:
external = str(values.get("external_account_id") or "").strip()
if not external:
raise ValueError("source_account.external_account_id 不能为空")
now = utc_now()
row = db.execute(
"""SELECT * FROM archive_source_account
WHERE tenant_id=? AND external_account_id=?""",
(tenant, external),
).fetchone()
if row is None:
account_id = new_id()
db.execute(
"""INSERT INTO archive_source_account
(id,tenant_id,external_account_id,display_name,corp_scope_id,
created_at,updated_at) VALUES (?,?,?,?,?,?,?)""",
(
account_id, tenant, external,
str(values.get("display_name") or ""),
str(values.get("corp_scope_id") or ""), now, now,
),
)
row = db.execute(
"SELECT * FROM archive_source_account WHERE id=?", (account_id,)
).fetchone()
else:
db.execute(
"""UPDATE archive_source_account SET display_name=?,corp_scope_id=?,
updated_at=? WHERE id=?""",
(
str(values.get("display_name") or row["display_name"]),
str(values.get("corp_scope_id") or row["corp_scope_id"]),
now, row["id"],
),
)
return row
def _person_for_sender(
self, db: Any, tenant: str, account: Any, sender: dict[str, Any]
) -> str | None:
external_id = str(sender.get("external_id") or sender.get("id") or "").strip()
if not external_id:
return None
identity_type = str(sender.get("identity_type") or "wecom_local_uid").strip()
scope_id = str(
sender.get("scope_id") or account["corp_scope_id"] or account["id"]
)
hashed = sha256_text(f"{identity_type}\x1f{scope_id}\x1f{external_id}")
row = db.execute(
"""SELECT i.person_id FROM archive_person_identity i
WHERE i.tenant_id=? AND i.identity_type=? AND i.scope_id=?
AND i.external_id_hash=?""",
(tenant, identity_type, scope_id, hashed),
).fetchone()
now = utc_now()
display = str(sender.get("display_name") or sender.get("name") or external_id)
real_name = str(sender.get("real_name") or "")
if row is not None:
current = db.execute(
"SELECT display_name,real_name FROM archive_person WHERE id=?",
(row["person_id"],),
).fetchone()
if (
current is not None
and _is_placeholder_name(display, external_id)
and not _is_placeholder_name(current["display_name"], external_id)
):
display = str(current["display_name"])
if current is not None and not real_name:
real_name = str(current["real_name"] or "")
db.execute(
"""UPDATE archive_person SET display_name=?,real_name=?,updated_at=?
WHERE id=?""",
(display, real_name, now, row["person_id"]),
)
return str(row["person_id"])
person_id = new_id()
db.execute(
"""INSERT INTO archive_person
(id,tenant_id,display_name,real_name,created_at,updated_at)
VALUES (?,?,?,?,?,?)""",
(person_id, tenant, display, real_name, now, now),
)
db.execute(
"""INSERT INTO archive_person_identity
(id,tenant_id,person_id,identity_type,scope_id,external_id,
external_id_hash,verified,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
new_id(), tenant, person_id, identity_type, scope_id, external_id,
hashed, 1 if sender.get("verified") else 0,
str(sender.get("source") or "local_db"), now, now,
),
)
return person_id
@staticmethod
def _add_person_identity(
db: Any,
tenant: str,
person_id: str,
identity: dict[str, Any],
default_scope: str,
) -> bool:
"""给已识别人员补充 unionid 等身份;冲突时保留原绑定并跳过。"""
identity_type = str(identity.get("identity_type") or "").strip()
scope_id = str(identity.get("scope_id") or default_scope).strip()
external_id = str(identity.get("external_id") or "").strip()
if not re.fullmatch(r"[A-Za-z0-9_.:-]{2,64}", identity_type):
return False
if not external_id or len(external_id) > 512:
return False
hashed = sha256_text(f"{identity_type}\x1f{scope_id}\x1f{external_id}")
existing = db.execute(
"""SELECT person_id FROM archive_person_identity
WHERE tenant_id=? AND identity_type=? AND scope_id=?
AND external_id_hash=?""",
(tenant, identity_type, scope_id, hashed),
).fetchone()
if existing is not None and str(existing["person_id"]) != person_id:
return False
now = utc_now()
db.execute(
"""INSERT INTO archive_person_identity
(id,tenant_id,person_id,identity_type,scope_id,external_id,
external_id_hash,verified,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(tenant_id,identity_type,scope_id,external_id_hash)
DO UPDATE SET verified=excluded.verified,source=excluded.source,
updated_at=excluded.updated_at""",
(
new_id(), tenant, person_id, identity_type, scope_id, external_id,
hashed, 1 if identity.get("verified", True) else 0,
str(identity.get("source") or "wxwork_metadata"), now, now,
),
)
return True
def _conversation(
self, db: Any, tenant: str, account: Any, values: dict[str, Any]
) -> Any:
external = str(values.get("external_id") or values.get("conversation_id") or "").strip()
if not external:
raise ValueError("conversation.external_id 不能为空")
now = utc_now()
row = db.execute(
"""SELECT * FROM archive_conversation
WHERE tenant_id=? AND source_account_id=? AND external_id=?""",
(tenant, account["id"], external),
).fetchone()
name = str(values.get("name") or external)
kind = conversation_type(external, str(values.get("conversation_type") or ""))
if row is None:
conv_id = new_id()
db.execute(
"""INSERT INTO archive_conversation
(id,tenant_id,source_account_id,external_id,conversation_type,
name,created_at,updated_at,raw_json)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
conv_id, tenant, account["id"], external, kind, name, now, now,
json_text(values),
),
)
row = db.execute(
"SELECT * FROM archive_conversation WHERE id=?", (conv_id,)
).fetchone()
else:
if (
_is_placeholder_name(name, external)
and not _is_placeholder_name(row["name"], external)
):
name = str(row["name"])
db.execute(
"""UPDATE archive_conversation SET name=?,conversation_type=?,
updated_at=?,raw_json=? WHERE id=?""",
(name, kind, now, json_text(values), row["id"]),
)
return row
def sync_metadata(
self, values: dict[str, Any], user_id: int | None, ip: str
) -> dict[str, int]:
"""独立同步通讯录昵称和会话名;没有新消息时也会执行。"""
tenant = safe_scope(values.get("tenant_id") or DEFAULT_TENANT, label="租户")
people = list(values.get("people") or [])
conversations = list(values.get("conversations") or [])
if len(people) > 5000 or len(conversations) > 5000:
raise ValueError("单次元数据同步每类最多 5000 条")
now = utc_now()
people_synced = conversations_updated = 0
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
account = self._source_account(
db, tenant, dict(values.get("source_account") or {})
)
for raw_person in people:
person = dict(raw_person or {})
person.setdefault("identity_type", "wecom_userid")
person.setdefault("scope_id", account["corp_scope_id"] or account["id"])
person.setdefault("source", "wxwork_metadata")
person_id = self._person_for_sender(db, tenant, account, person)
if person_id:
people_synced += 1
extra_identities = person.get("identities") or []
if isinstance(extra_identities, list):
for raw_identity in extra_identities[:20]:
if not isinstance(raw_identity, dict):
continue
self._add_person_identity(
db,
tenant,
person_id,
raw_identity,
str(account["corp_scope_id"] or account["id"]),
)
for raw_conversation in conversations:
conversation = dict(raw_conversation or {})
external = str(
conversation.get("external_id")
or conversation.get("conversation_id")
or ""
).strip()
if not external:
continue
row = db.execute(
"""SELECT id,name FROM archive_conversation
WHERE tenant_id=? AND source_account_id=? AND external_id=?""",
(tenant, account["id"], external),
).fetchone()
if row is None:
continue
name = str(conversation.get("name") or external)
if (
_is_placeholder_name(name, external)
and not _is_placeholder_name(row["name"], external)
):
name = str(row["name"])
db.execute(
"""UPDATE archive_conversation SET name=?,conversation_type=?,
raw_json=?,updated_at=? WHERE id=?""",
(
name,
conversation_type(
external, str(conversation.get("conversation_type") or "")
),
json_text(conversation),
now,
row["id"],
),
)
conversations_updated += 1
self.database._audit(
db,
user_id,
"archive.metadata.sync",
f"people={people_synced} conversations={conversations_updated}",
ip,
)
db.commit()
return {
"people_synced": people_synced,
"conversations_updated": conversations_updated,
}
def _sync_conversation_members(
self,
db: Any,
tenant: str,
account: Any,
conversation: Any,
members: Iterable[dict[str, Any]],
) -> None:
now = utc_now()
for raw_member in members:
member = dict(raw_member or {})
person_id = self._person_for_sender(db, tenant, account, member)
if not person_id:
continue
valid_from = str(member.get("valid_from") or "")
db.execute(
"""INSERT INTO archive_conversation_member
(id,tenant_id,conversation_id,person_id,member_role,nickname,
valid_from,valid_to,created_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(conversation_id,person_id,valid_from) DO UPDATE SET
member_role=excluded.member_role,nickname=excluded.nickname,
valid_to=excluded.valid_to""",
(
new_id(), tenant, conversation["id"], person_id,
str(member.get("member_role") or "member"),
str(member.get("nickname") or member.get("display_name") or ""),
valid_from, str(member.get("valid_to") or ""), now,
),
)
@staticmethod
def _source_message_key(message: dict[str, Any]) -> str:
for key in ("source_message_id", "server_id", "client_id"):
value = str(message.get(key) or "").strip()
if value:
return f"{key}:{value}"
payload = {
"conversation": message.get("conversation") or message.get("conversation_id"),
"sender": message.get("sender"),
"sent_at": message.get("sent_at") or message.get("send_time"),
"type": message.get("message_type") or message.get("msg_type"),
"content": message.get("content"),
}
return "fingerprint:" + sha256_text(json_text(payload))
def import_messages(
self, values: dict[str, Any], user_id: int | None, ip: str
) -> dict[str, Any]:
tenant = safe_scope(values.get("tenant_id") or DEFAULT_TENANT, label="租户")
rows = list(values.get("messages") or [])
if not rows:
raise ValueError("消息批次不能为空")
if len(rows) > 5000:
raise ValueError("单批最多 5000 条消息")
batch_id = str(values.get("batch_id") or new_id()).strip()
inserted = duplicates = errors = skipped = 0
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
account = self._source_account(
db, tenant, dict(values.get("source_account") or {})
)
existing_batch = db.execute(
"SELECT id FROM archive_import_batch WHERE id=?", (batch_id,)
).fetchone()
if existing_batch is None:
db.execute(
"""INSERT INTO archive_import_batch
(id,tenant_id,source_account_id,status,received_rows,created_at)
VALUES (?,?,?,?,?,?)""",
(batch_id, tenant, account["id"], "processing", len(rows), now),
)
for message in rows:
try:
raw = dict(message)
conv_values = dict(raw.get("conversation") or {})
if not conv_values:
conv_values = {
"external_id": raw.get("conversation_id"),
"name": raw.get("conversation_name"),
}
if should_exclude_conversation(
conv_values.get("external_id")
or conv_values.get("conversation_id"),
conv_values.get("name"),
):
skipped += 1
continue
source_key = self._source_message_key(raw)
raw_json = json_text(raw)
raw_hash = sha256_text(raw_json)
# 同一源消息撤回、编辑后 payload 会变;原始事件必须保留每个不同版本。
# 归一消息的去重仍只用 source_key,不会因版本变化复制出新消息。
raw_event_key = f"{source_key}:payload:{raw_hash}"
raw_event = db.execute(
"""SELECT id FROM archive_raw_event WHERE tenant_id=?
AND source_account_id=? AND source_message_key=?""",
(tenant, account["id"], raw_event_key),
).fetchone()
if raw_event is None:
raw_id = new_id()
db.execute(
"""INSERT INTO archive_raw_event
(id,tenant_id,source_account_id,batch_id,source_table,
source_message_key,payload_json,payload_hash,ingested_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
raw_id, tenant, account["id"], batch_id,
str(raw.get("source_table") or "message_table"),
raw_event_key, raw_json, raw_hash, now,
),
)
else:
raw_id = raw_event["id"]
conv = self._conversation(db, tenant, account, conv_values)
sender = dict(raw.get("sender") or {})
if not sender and raw.get("sender_id") not in (None, ""):
sender = {
"external_id": raw.get("sender_id"),
"display_name": raw.get("sender_name"),
"identity_type": raw.get("sender_identity_type")
or "wecom_local_uid",
"scope_id": raw.get("sender_scope_id") or "",
}
sender_id = self._person_for_sender(db, tenant, account, sender)
members = list(
conv_values.get("members")
or raw.get("conversation_members")
or []
)
if sender:
members.append(sender)
self._sync_conversation_members(
db, tenant, account, conv, members
)
sent_at, sent_epoch = normalize_sent_at(
raw.get("sent_at") or raw.get("send_time"),
raw.get("sent_at_epoch"),
)
dedup = sha256_text(
f"{tenant}\x1f{account['id']}\x1f{source_key}"
)
existing = db.execute(
"SELECT id,status,content FROM archive_message WHERE tenant_id=? "
"AND dedup_key=?",
(tenant, dedup),
).fetchone()
content = str(raw.get("content") or "")
content = (
decode_hex_protobuf_text(
content,
raw.get("message_type")
or raw.get("msg_type_name")
or raw.get("msg_type"),
)
or content
)
status = str(raw.get("status") or "normal")
if existing is not None:
duplicates += 1
if existing["status"] != status or existing["content"] != content:
version = db.execute(
"""SELECT COALESCE(MAX(version_no),0)+1 AS n
FROM archive_message_version WHERE message_id=?""",
(existing["id"],),
).fetchone()["n"]
db.execute(
"""INSERT INTO archive_message_version
(id,message_id,version_no,status,content,raw_event_id,created_at)
VALUES (?,?,?,?,?,?,?)""",
(
new_id(), existing["id"], version, status, content,
raw_id, now,
),
)
db.execute(
"""UPDATE archive_message SET status=?,content=?,raw_event_id=?,
updated_at=? WHERE id=?""",
(status, content, raw_id, now, existing["id"]),
)
message_id = existing["id"]
else:
message_id = new_id()
db.execute(
"""INSERT INTO archive_message
(id,tenant_id,conversation_id,source_account_id,
sender_person_id,raw_event_id,source_message_id,server_id,
client_id,sequence_no,message_type,content,direction,status,
sent_at,sent_at_epoch,dedup_key,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
message_id, tenant, conv["id"], account["id"], sender_id,
raw_id, str(raw.get("source_message_id") or ""),
str(raw.get("server_id") or ""),
str(raw.get("client_id") or ""), raw.get("sequence_no")
if raw.get("sequence_no") is not None
else raw.get("message_seq"),
str(raw.get("message_type") or raw.get("msg_type_name")
or raw.get("msg_type") or "unknown"),
content, str(raw.get("direction") or "unknown"), status,
sent_at, sent_epoch, dedup, now, now,
),
)
db.execute(
"""INSERT INTO archive_message_version
(id,message_id,version_no,status,content,raw_event_id,created_at)
VALUES (?,?,1,?,?,?,?)""",
(new_id(), message_id, status, content, raw_id, now),
)
inserted += 1
parent = str(
raw.get("parent_source_message_id")
or raw.get("parent_message_id") or ""
).strip()
if parent:
db.execute(
"""INSERT OR IGNORE INTO archive_message_relation
(id,message_id,relation_type,target_source_id,created_at)
VALUES (?,?,?,?,?)""",
(new_id(), message_id, "reply", parent, now),
)
linked_media: list[Any] = []
for index, media_id in enumerate(raw.get("media_ids") or []):
media = db.execute(
"""SELECT id,original_filename,size_bytes,status
FROM archive_media_object WHERE id=? AND tenant_id=?""",
(str(media_id), tenant),
).fetchone()
if media is not None:
linked_media.append(media)
db.execute(
"""INSERT OR IGNORE INTO archive_message_attachment
(message_id,media_id,attachment_index,created_at)
VALUES (?,?,?,?)""",
(message_id, media["id"], index, now),
)
for raw_attachment in raw.get("attachment_metadata") or []:
attachment = dict(raw_attachment or {})
filename = Path(
str(attachment.get("original_filename") or "")
).name[:512]
if not filename:
continue
size_bytes = max(0, int(attachment.get("size_bytes") or 0))
checksum = str(attachment.get("checksum") or "")[:128]
reference_hash = str(
attachment.get("source_reference_sha256") or ""
)[:64]
matched = next(
(
item for item in linked_media
if str(item["original_filename"]) == filename
and int(item["size_bytes"]) == size_bytes
and str(item["status"]) == "ready"
),
None,
)
pending_status = "ready" if matched is not None else str(
attachment.get("status") or "source_not_cached"
)
db.execute(
"""INSERT INTO archive_pending_attachment
(id,tenant_id,message_id,source_account_id,
source_message_id,original_filename,size_bytes,checksum,
media_type,source_reference_sha256,status,media_id,
created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(message_id,original_filename,checksum) DO UPDATE SET
size_bytes=excluded.size_bytes,
source_reference_sha256=excluded.source_reference_sha256,
status=excluded.status,media_id=excluded.media_id,
updated_at=excluded.updated_at""",
(
new_id(), tenant, message_id, account["id"],
str(raw.get("source_message_id") or ""), filename,
size_bytes, checksum,
str(attachment.get("media_type") or "file"),
reference_hash, pending_status,
matched["id"] if matched is not None else None,
now, now,
),
)
db.execute(
"""UPDATE archive_conversation SET
last_message_at=CASE WHEN last_message_at='' OR last_message_at<?
THEN ? ELSE last_message_at END,
updated_at=? WHERE id=?""",
(sent_at, sent_at, now, conv["id"]),
)
except Exception:
errors += 1
raise
checkpoint = values.get("checkpoint")
if checkpoint is not None:
db.execute(
"""INSERT INTO archive_checkpoint
(source_account_id,source_table,cursor_json,updated_at)
VALUES (?,?,?,?) ON CONFLICT(source_account_id,source_table)
DO UPDATE SET cursor_json=excluded.cursor_json,
updated_at=excluded.updated_at""",
(
account["id"], str(values.get("source_table") or "message_table"),
json_text(checkpoint), now,
),
)
db.execute(
"""UPDATE archive_import_batch SET status='completed',inserted_rows=?,
duplicate_rows=?,error_rows=?,completed_at=? WHERE id=?""",
(inserted, duplicates, errors, utc_now(), batch_id),
)
self.database._audit(
db, user_id, "archive.import",
f"batch={batch_id} inserted={inserted} duplicate={duplicates} skipped={skipped}",
ip,
)
db.commit()
return {
"batch_id": batch_id,
"received": len(rows),
"inserted": inserted,
"duplicates": duplicates,
"skipped": skipped,
"errors": errors,
}
# ── ZYT 患者只读会话与本地绑定 ─────────────────────────────────────
def save_zyt_session(
self, tenant_id: str, token: str, zyt_user_id: str = ""
) -> None:
import secret_box
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
token = str(token or "").strip()
if not token:
raise ValueError("ZYT token 不能为空")
encrypted = secret_box.encrypt(token, self.database._secret_key())
now = utc_now()
with self.database.connect() as db:
db.execute(
"""INSERT INTO archive_zyt_session
(tenant_id,zyt_user_id,token_enc,refreshed_at) VALUES (?,?,?,?)
ON CONFLICT(tenant_id) DO UPDATE SET
zyt_user_id=excluded.zyt_user_id,
token_enc=excluded.token_enc,
refreshed_at=excluded.refreshed_at""",
(tenant, str(zyt_user_id or ""), encrypted, now),
)
db.commit()
def clear_zyt_session(self, tenant_id: str) -> None:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
with self.database.connect() as db:
db.execute("DELETE FROM archive_zyt_session WHERE tenant_id=?", (tenant,))
db.commit()
def zyt_token(self, tenant_id: str) -> str:
import secret_box
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
with self.database.connect() as db:
row = db.execute(
"SELECT token_enc FROM archive_zyt_session WHERE tenant_id=?",
(tenant,),
).fetchone()
if row is None or not str(row["token_enc"] or ""):
raise RuntimeError("该客户端账号尚未提供 ZYT 查询会话,请重新打开软件登录")
try:
return secret_box.decrypt(str(row["token_enc"]), self.database._secret_key())
except Exception as exc:
raise RuntimeError("ZYT 查询会话无法解密,请重新打开软件登录") from exc
def cache_patients(
self, items: list[dict[str, Any]], tenant_id: str = DEFAULT_TENANT
) -> list[dict[str, Any]]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
now = utc_now()
normalized: list[dict[str, Any]] = []
with self.database.connect() as db:
for raw in items[:100]:
item = dict(raw or {})
try:
patient_id = int(item.get("patient_id") or 0)
diagnosis_id = int(item.get("diagnosis_id") or 0)
except (TypeError, ValueError):
continue
if patient_id <= 0:
continue
row = {
"patient_id": patient_id,
"diagnosis_id": max(0, diagnosis_id),
"patient_name": str(item.get("patient_name") or "").strip()[:191],
"phone_masked": str(item.get("phone_masked") or "").strip()[:32],
"gender": str(item.get("gender") if item.get("gender") is not None else "")[:16],
"age": item.get("age"),
"source_update_time": str(item.get("source_update_time") or "")[:64],
}
try:
age = int(row["age"]) if row["age"] not in (None, "") else None
except (TypeError, ValueError):
age = None
row["age"] = age
db.execute(
"""INSERT INTO archive_patient_shadow
(tenant_id,patient_id,latest_diagnosis_id,patient_name,
phone_masked,gender,age,source_update_time,synced_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(tenant_id,patient_id) DO UPDATE SET
latest_diagnosis_id=excluded.latest_diagnosis_id,
patient_name=excluded.patient_name,
phone_masked=excluded.phone_masked,
gender=excluded.gender,age=excluded.age,
source_update_time=excluded.source_update_time,
synced_at=excluded.synced_at""",
(
tenant, patient_id, row["diagnosis_id"], row["patient_name"],
row["phone_masked"], row["gender"], age,
row["source_update_time"], now,
),
)
normalized.append(row)
db.commit()
return normalized
@staticmethod
def _binding_row(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"patient_id": int(row["patient_id"]),
"diagnosis_id": int(row["diagnosis_id"]),
"patient_name": str(row["patient_name"] or ""),
"phone_masked": str(row["phone_masked"] or ""),
"relation_type": str(row["relation_type"]),
"bind_method": str(row["bind_method"]),
"confidence": int(row["confidence"]),
"is_primary": bool(row["is_primary"]),
"status": str(row["status"]),
"created_at": str(row["created_at"]),
"updated_at": str(row["updated_at"]),
}
def conversation_patient_context(
self,
external_account_id: str,
conversation_external_id: str,
tenant_id: str = DEFAULT_TENANT,
*,
person_id: str = "",
) -> dict[str, Any]:
"""Resolve a local WeCom conversation to bindable archive people.
The desktop browser identifies rows with the original account and
conversation IDs. Patient links, however, intentionally belong to a
person so they survive conversation renames and future chats. This
method performs that tenant-scoped translation and excludes the local
WeCom account from one-to-one candidates.
"""
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
account_external = str(external_account_id or "").strip()
conversation_external = str(conversation_external_id or "").strip()
selected_person = str(person_id or "").strip()
if not account_external:
raise ValueError("企业微信账号标识不能为空")
if not conversation_external:
raise ValueError("会话标识不能为空")
with self.database.connect() as db:
conversation = db.execute(
"""SELECT c.id,c.external_id,c.name,c.conversation_type,
c.last_message_at,a.external_account_id
FROM archive_conversation c
JOIN archive_source_account a ON a.id=c.source_account_id
WHERE c.tenant_id=? AND a.tenant_id=?
AND a.external_account_id=? AND c.external_id=?""",
(tenant, tenant, account_external, conversation_external),
).fetchone()
if conversation is None:
return {
"available": False,
"reason": "该会话尚未上传到后台,请先执行一次数据备份",
"conversation": None,
"person": None,
"people": [],
"patient_bindings": [],
}
member_rows = db.execute(
"""SELECT DISTINCT p.id,p.display_name,p.real_name
FROM archive_conversation_member cm
JOIN archive_person p ON p.id=cm.person_id
WHERE cm.tenant_id=? AND cm.conversation_id=?
UNION
SELECT DISTINCT p.id,p.display_name,p.real_name
FROM archive_message m
JOIN archive_person p ON p.id=m.sender_person_id
WHERE m.tenant_id=? AND m.conversation_id=?""",
(tenant, conversation["id"], tenant, conversation["id"]),
).fetchall()
people: list[dict[str, Any]] = []
for member in member_rows:
identities = db.execute(
"""SELECT identity_type,external_id FROM archive_person_identity
WHERE tenant_id=? AND person_id=?
ORDER BY verified DESC,identity_type""",
(tenant, member["id"]),
).fetchall()
external_ids = {
str(identity["external_id"] or "").strip()
for identity in identities
}
if account_external in external_ids:
continue
bindings = db.execute(
"""SELECT * FROM archive_patient_binding
WHERE tenant_id=? AND person_id=? AND status='active'
ORDER BY is_primary DESC,updated_at DESC,id""",
(tenant, member["id"]),
).fetchall()
people.append(
{
"id": str(member["id"]),
"display_name": str(
member["real_name"] or member["display_name"] or "未知联系人"
),
"identities": [
{
"identity_type": str(identity["identity_type"]),
"external_id": str(identity["external_id"] or ""),
}
for identity in identities
],
"patient_bindings": [
self._binding_row(binding) for binding in bindings
],
}
)
people.sort(key=lambda item: (str(item["display_name"]), str(item["id"])))
chosen = next(
(item for item in people if str(item["id"]) == selected_person),
None,
)
if selected_person and chosen is None:
raise ValueError("所选联系人不属于当前会话")
if chosen is None and len(people) == 1:
chosen = people[0]
return {
"available": bool(people),
"reason": "" if people else "当前会话没有可绑定的客户联系人",
"conversation": {
"id": str(conversation["id"]),
"external_id": str(conversation["external_id"]),
"name": str(conversation["name"] or conversation["external_id"]),
"conversation_type": str(conversation["conversation_type"]),
"source_account": str(conversation["external_account_id"]),
"last_message_at": str(conversation["last_message_at"] or ""),
},
"person": chosen,
"people": people,
"requires_person_selection": len(people) > 1 and chosen is None,
"patient_bindings": list(chosen.get("patient_bindings") or []) if chosen else [],
}
def patient_bindings(
self, person_id: str, tenant_id: str = DEFAULT_TENANT
) -> list[dict[str, Any]]:
scope = tenant_scope(tenant_id)
with self.database.connect() as db:
if db.execute(
f"SELECT 1 FROM archive_person WHERE id=? AND {scope.clause()}",
(person_id, *scope.params),
).fetchone() is None:
raise KeyError("人员不存在")
rows = db.execute(
f"""SELECT * FROM archive_patient_binding
WHERE {scope.clause()} AND person_id=? AND status='active'
ORDER BY is_primary DESC,updated_at DESC,id""",
(*scope.params, person_id),
).fetchall()
return [self._binding_row(row) for row in rows]
def bind_patient(
self,
person_id: str,
values: dict[str, Any],
user_id: int | None,
ip: str,
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
try:
patient_id = int(values.get("patient_id") or 0)
requested_diagnosis_id = int(values.get("diagnosis_id") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("患者编号格式不正确") from exc
if patient_id <= 0:
raise ValueError("患者编号必须大于 0")
relation_type = str(values.get("relation_type") or "self").strip()
if relation_type not in {"self", "family", "guardian", "proxy", "other"}:
raise ValueError("患者关系类型不正确")
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
if db.execute(
"SELECT 1 FROM archive_person WHERE id=? AND tenant_id=?",
(person_id, tenant),
).fetchone() is None:
raise KeyError("人员不存在")
patient = db.execute(
"""SELECT * FROM archive_patient_shadow
WHERE tenant_id=? AND patient_id=?""",
(tenant, patient_id),
).fetchone()
if patient is None:
raise ValueError("请先从 ZYT 搜索并选择患者,不能直接填写患者编号")
diagnosis_id = requested_diagnosis_id or int(patient["latest_diagnosis_id"])
identities = db.execute(
"""SELECT identity_type,external_id FROM archive_person_identity
WHERE tenant_id=? AND person_id=? AND verified=1""",
(tenant, person_id),
).fetchall()
external_userid = ""
unionid_hash = ""
for identity in identities:
kind = str(identity["identity_type"])
external = str(identity["external_id"] or "")
if kind in {"wecom_external_userid", "wechat_external_userid"}:
external_userid = external
if kind in {"wechat_unionid", "wecom_unionid"} and external:
unionid_hash = sha256_text(external)
# 一个人可以替家属咨询多个患者,但每次新绑定设为默认患者。
db.execute(
"""UPDATE archive_patient_binding SET is_primary=0,updated_at=?
WHERE tenant_id=? AND person_id=? AND status='active'""",
(now, tenant, person_id),
)
existing = db.execute(
"""SELECT id FROM archive_patient_binding
WHERE tenant_id=? AND person_id=? AND patient_id=?
ORDER BY updated_at DESC LIMIT 1""",
(tenant, person_id, patient_id),
).fetchone()
binding_id = str(existing["id"]) if existing is not None else new_id()
if existing is None:
db.execute(
"""INSERT INTO archive_patient_binding
(id,tenant_id,person_id,patient_id,diagnosis_id,patient_name,
phone_masked,relation_type,bind_method,confidence,is_primary,
source_external_userid,source_unionid_hash,status,created_at,
updated_at,bound_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'active',?,?,?)""",
(
binding_id, tenant, person_id, patient_id, diagnosis_id,
str(patient["patient_name"]), str(patient["phone_masked"]),
relation_type, "manual", 100, 1, external_userid,
unionid_hash, now, now, user_id,
),
)
else:
db.execute(
"""UPDATE archive_patient_binding SET diagnosis_id=?,patient_name=?,
phone_masked=?,relation_type=?,bind_method='manual',confidence=100,
is_primary=1,source_external_userid=?,source_unionid_hash=?,
status='active',updated_at=?,bound_by=?,revoked_at='',revoked_by=NULL
WHERE id=?""",
(
diagnosis_id, str(patient["patient_name"]),
str(patient["phone_masked"]), relation_type, external_userid,
unionid_hash, now, user_id, binding_id,
),
)
row = db.execute(
"SELECT * FROM archive_patient_binding WHERE id=?", (binding_id,)
).fetchone()
snapshot = self._binding_row(row)
db.execute(
"""INSERT INTO archive_patient_binding_audit
(id,tenant_id,binding_id,person_id,patient_id,action,snapshot_json,
actor_id,ip,created_at) VALUES (?,?,?,?,?,'bind',?,?,?,?)""",
(
new_id(), tenant, binding_id, person_id, patient_id,
json_text(snapshot), user_id, str(ip or ""), now,
),
)
self.database._audit(
db, user_id, "archive.patient.bind",
f"person={person_id} patient={patient_id} relation={relation_type}", ip,
)
db.commit()
return snapshot
def unbind_patient(
self,
person_id: str,
binding_id: str,
user_id: int | None,
ip: str,
tenant_id: str = DEFAULT_TENANT,
) -> None:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"""SELECT * FROM archive_patient_binding
WHERE id=? AND tenant_id=? AND person_id=? AND status='active'""",
(binding_id, tenant, person_id),
).fetchone()
if row is None:
raise KeyError("患者绑定不存在或已解除")
snapshot = self._binding_row(row)
db.execute(
"""UPDATE archive_patient_binding SET status='revoked',is_primary=0,
revoked_at=?,revoked_by=?,updated_at=? WHERE id=?""",
(now, user_id, now, binding_id),
)
replacement = db.execute(
"""SELECT id FROM archive_patient_binding
WHERE tenant_id=? AND person_id=? AND status='active'
ORDER BY updated_at DESC,id LIMIT 1""",
(tenant, person_id),
).fetchone()
if replacement is not None:
db.execute(
"UPDATE archive_patient_binding SET is_primary=1 WHERE id=?",
(replacement["id"],),
)
db.execute(
"""INSERT INTO archive_patient_binding_audit
(id,tenant_id,binding_id,person_id,patient_id,action,snapshot_json,
actor_id,ip,created_at) VALUES (?,?,?,?,?,'unbind',?,?,?,?)""",
(
new_id(), tenant, binding_id, person_id, int(row["patient_id"]),
json_text(snapshot), user_id, str(ip or ""), now,
),
)
self.database._audit(
db, user_id, "archive.patient.unbind",
f"person={person_id} patient={int(row['patient_id'])}", ip,
)
db.commit()
# ── 人员唯一标识 ──────────────────────────────────────────────────────────────────────
def people(
self,
limit: int = 100,
keyword: str = "",
tenant_id: str = DEFAULT_TENANT,
) -> list[dict[str, Any]]:
scope = tenant_scope(tenant_id)
limit = max(1, min(int(limit), 500))
search = str(keyword or "").strip()
params: list[Any] = list(scope.params)
where = scope.clause("p")
if search:
where += " AND (p.display_name LIKE ? OR p.real_name LIKE ?)"
params.extend([f"%{search}%", f"%{search}%"])
params.append(limit)
with self.database.connect() as db:
rows = db.execute(
f"""SELECT p.id,p.display_name,p.real_name,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM archive_person_identity i
WHERE i.person_id=p.id) AS identity_count,
(SELECT COUNT(DISTINCT cm.conversation_id)
FROM archive_conversation_member cm
WHERE cm.person_id=p.id) AS conversation_count,
(SELECT COUNT(*) FROM archive_message m
WHERE m.tenant_id=p.tenant_id AND m.sender_person_id=p.id) AS message_count,
(SELECT COUNT(*) FROM archive_patient_binding pb
WHERE pb.tenant_id=p.tenant_id AND pb.person_id=p.id
AND pb.status='active') AS patient_binding_count,
(SELECT pb.patient_id FROM archive_patient_binding pb
WHERE pb.tenant_id=p.tenant_id AND pb.person_id=p.id
AND pb.status='active'
ORDER BY pb.is_primary DESC,pb.updated_at DESC LIMIT 1)
AS primary_patient_id,
(SELECT pb.patient_name FROM archive_patient_binding pb
WHERE pb.tenant_id=p.tenant_id AND pb.person_id=p.id
AND pb.status='active'
ORDER BY pb.is_primary DESC,pb.updated_at DESC LIMIT 1)
AS primary_patient_name
FROM archive_person p WHERE {where}
ORDER BY p.updated_at DESC,p.id DESC LIMIT ?""",
tuple(params),
).fetchall()
return [
{
"id": row["id"],
"display_name": row["display_name"],
"real_name": row["real_name"],
"identity_count": int(row["identity_count"]),
"conversation_count": int(row["conversation_count"]),
"message_count": int(row["message_count"]),
"patient_binding_count": int(row["patient_binding_count"]),
"primary_patient_id": (
int(row["primary_patient_id"])
if row["primary_patient_id"] is not None else None
),
"primary_patient_name": str(row["primary_patient_name"] or ""),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
for row in rows
]
def person_detail(
self, person_id: str, tenant_id: str = DEFAULT_TENANT
) -> dict[str, Any]:
scope = tenant_scope(tenant_id)
with self.database.connect() as db:
person = db.execute(
f"SELECT * FROM archive_person WHERE id=? AND {scope.clause()}",
(person_id, *scope.params),
).fetchone()
if person is None:
raise KeyError("人员不存在")
identities = db.execute(
"""SELECT id,identity_type,scope_id,external_id,verified,source,
created_at,updated_at
FROM archive_person_identity WHERE person_id=?
ORDER BY verified DESC,identity_type,created_at""",
(person_id,),
).fetchall()
bindings = db.execute(
f"""SELECT * FROM archive_patient_binding
WHERE {scope.clause()} AND person_id=? AND status='active'
ORDER BY is_primary DESC,updated_at DESC,id""",
(*scope.params, person_id),
).fetchall()
return {
"id": person["id"],
"display_name": person["display_name"],
"real_name": person["real_name"],
"identities": [
{
"id": row["id"],
"identity_type": row["identity_type"],
"scope_id": row["scope_id"],
"external_id": row["external_id"],
"verified": bool(row["verified"]),
"source": row["source"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
for row in identities
],
"patient_bindings": [self._binding_row(row) for row in bindings],
}
def bind_identity(
self,
person_id: str,
values: dict[str, Any],
user_id: int,
ip: str,
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
identity_type = str(values.get("identity_type") or "").strip()
scope_id = str(values.get("scope_id") or "").strip()
external_id = str(values.get("external_id") or "").strip()
if not re.fullmatch(r"[A-Za-z0-9_.:-]{2,64}", identity_type):
raise ValueError("标识类型格式不正确")
if not external_id or len(external_id) > 512:
raise ValueError("外部人员 ID 长度必须在 1–512 之间")
hashed = sha256_text(f"{identity_type}\x1f{scope_id}\x1f{external_id}")
now = utc_now()
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
if db.execute(
"SELECT 1 FROM archive_person WHERE id=? AND tenant_id=?",
(person_id, tenant),
).fetchone() is None:
raise KeyError("人员不存在")
existing = db.execute(
"""SELECT person_id FROM archive_person_identity
WHERE tenant_id=? AND identity_type=? AND scope_id=?
AND external_id_hash=?""",
(tenant, identity_type, scope_id, hashed),
).fetchone()
if existing is not None and existing["person_id"] != person_id:
raise ValueError("该企微人员 ID 已绑定到另一个人员")
db.execute(
"""INSERT INTO archive_person_identity
(id,tenant_id,person_id,identity_type,scope_id,external_id,
external_id_hash,verified,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(tenant_id,identity_type,scope_id,external_id_hash)
DO UPDATE SET verified=excluded.verified,source=excluded.source,
updated_at=excluded.updated_at""",
(
new_id(), tenant, person_id, identity_type, scope_id,
external_id, hashed, 1 if values.get("verified", True) else 0,
"manual", now, now,
),
)
self.database._audit(
db, user_id, "archive.identity.bind",
f"person={person_id} type={identity_type} scope={scope_id}", ip,
)
db.commit()
return self.person_detail(person_id, tenant)
# ── 查询 ─────────────────────────────────────────────────────────────
def stats(self, tenant_id: str = DEFAULT_TENANT) -> dict[str, Any]:
scope = tenant_scope(tenant_id)
with self.database.connect() as db:
result = {}
for key, table in (
("messages", "archive_message"),
("conversations", "archive_conversation"),
("people", "archive_person"),
("media", "archive_media_object"),
("imports", "archive_import_batch"),
("exports", "archive_export_job"),
):
result[key] = int(
db.execute(
f"SELECT COUNT(*) AS n FROM {table} "
f"WHERE {scope.clause()}",
tuple(scope.params),
).fetchone()["n"]
)
result["media_ready"] = int(
db.execute(
f"""SELECT COUNT(*) AS n FROM archive_media_object
WHERE {scope.clause()} AND status='ready'""",
tuple(scope.params),
).fetchone()["n"]
)
result["media_failed"] = int(
db.execute(
f"""SELECT COUNT(*) AS n FROM archive_media_object
WHERE {scope.clause()} AND status='failed'""",
tuple(scope.params),
).fetchone()["n"]
)
result["last_message_at"] = str(
db.execute(
"SELECT COALESCE(MAX(sent_at),'') AS v FROM archive_message "
f"WHERE {scope.clause()}",
tuple(scope.params),
).fetchone()["v"]
)
return result
def conversations(
self,
limit: int = 50,
cursor: str = "",
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
scope = tenant_scope(tenant_id)
limit = max(1, min(int(limit), 200))
params: list[Any] = list(scope.params)
where = scope.clause("c")
if cursor:
where += " AND (c.last_message_at,c.id) < (?,?)"
try:
cursor_time, cursor_id = cursor.split("|", 1)
except ValueError as exc:
raise ValueError("会话游标格式不正确") from exc
params.extend([cursor_time, cursor_id])
params.append(limit + 1)
with self.database.connect() as db:
rows = db.execute(
f"""WITH page AS (
SELECT c.* FROM archive_conversation c WHERE {where}
ORDER BY c.last_message_at DESC,c.id DESC LIMIT ?
)
SELECT c.*,a.external_account_id,
(SELECT COUNT(*) FROM archive_message m
WHERE m.tenant_id=c.tenant_id AND m.conversation_id=c.id)
AS message_count,
last.content AS last_content,last.message_type AS last_message_type,
(SELECT COUNT(*) FROM archive_message_attachment ma
WHERE ma.message_id=last.id) AS last_attachment_count
FROM page c
JOIN archive_source_account a ON a.id=c.source_account_id
LEFT JOIN archive_message last ON last.id=(
SELECT lm.id FROM archive_message lm
WHERE lm.tenant_id=c.tenant_id AND lm.conversation_id=c.id
ORDER BY lm.sent_at DESC,lm.id DESC LIMIT 1
)
ORDER BY c.last_message_at DESC,c.id DESC""",
tuple(params),
).fetchall()
has_more = len(rows) > limit
rows = rows[:limit]
items = [
{
"id": row["id"],
"external_id": row["external_id"],
"name": row["name"],
"conversation_type": row["conversation_type"],
"source_account": row["external_account_id"],
"last_message_at": row["last_message_at"],
"last_content": _display_message_content(
row["last_content"],
row["last_message_type"],
has_attachment=bool(row["last_attachment_count"]),
),
"message_count": int(row["message_count"]),
"status": row["status"],
}
for row in rows
]
next_cursor = ""
if has_more and rows:
next_cursor = f"{rows[-1]['last_message_at']}|{rows[-1]['id']}"
return {"items": items, "next_cursor": next_cursor, "has_more": has_more}
def messages(
self,
conversation_id: str,
limit: int = 100,
cursor: str = "",
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
scope = tenant_scope(tenant_id)
limit = max(1, min(int(limit), 500))
params: list[Any] = [*scope.params, conversation_id]
where = f"{scope.clause('m')} AND m.conversation_id=?"
if cursor:
try:
cursor_time, cursor_id = cursor.split("|", 1)
except ValueError as exc:
raise ValueError("消息游标格式不正确") from exc
where += " AND (m.sent_at,m.id) > (?,?)"
params.extend([cursor_time, cursor_id])
params.append(limit + 1)
with self.database.connect() as db:
rows = db.execute(
f"""SELECT m.*,p.display_name AS sender_name,
(SELECT COUNT(*) FROM archive_message_attachment ma
WHERE ma.message_id=m.id) AS attachment_count
FROM archive_message m
LEFT JOIN archive_person p ON p.id=m.sender_person_id
WHERE {where}
ORDER BY m.sent_at ASC,m.id ASC LIMIT ?""",
tuple(params),
).fetchall()
has_more = len(rows) > limit
rows = rows[:limit]
attachments_by_message: dict[str, list[dict[str, Any]]] = {
str(row["id"]): [] for row in rows
}
if rows:
message_ids = [str(row["id"]) for row in rows]
placeholders = ",".join("?" for _ in message_ids)
attachment_rows = db.execute(
f"""SELECT ma.message_id,ma.attachment_index,ma.attachment_role,
ma.match_method,ma.match_confidence,
mo.id,mo.media_type,mo.mime_type,mo.original_filename,
mo.size_bytes,mo.status,mo.sha256
FROM archive_message_attachment ma
JOIN archive_media_object mo ON mo.id=ma.media_id
WHERE ma.message_id IN ({placeholders})
ORDER BY ma.message_id,ma.attachment_index,mo.id""",
tuple(message_ids),
).fetchall()
for attachment in attachment_rows:
attachments_by_message[str(attachment["message_id"])].append(
{
"id": attachment["id"],
"media_type": attachment["media_type"],
"mime_type": attachment["mime_type"],
"original_filename": attachment["original_filename"],
"size_bytes": int(attachment["size_bytes"]),
"status": attachment["status"],
"sha256": attachment["sha256"],
"attachment_index": int(attachment["attachment_index"]),
"attachment_role": attachment["attachment_role"],
"match_method": attachment["match_method"],
"match_confidence": float(attachment["match_confidence"]),
}
)
pending_rows = db.execute(
f"""SELECT id,message_id,original_filename,size_bytes,checksum,
media_type,status
FROM archive_pending_attachment
WHERE status<>'ready' AND message_id IN ({placeholders})
ORDER BY message_id,created_at,id""",
tuple(message_ids),
).fetchall()
for pending in pending_rows:
items_for_message = attachments_by_message[str(pending["message_id"])]
items_for_message.append(
{
"id": f"pending:{pending['id']}",
"media_type": pending["media_type"],
"mime_type": "application/octet-stream",
"original_filename": pending["original_filename"],
"size_bytes": int(pending["size_bytes"]),
"status": pending["status"],
"sha256": "",
"checksum": pending["checksum"],
"attachment_index": len(items_for_message),
"attachment_role": "attachment",
"match_method": "source_metadata",
"match_confidence": 1.0,
}
)
items = [
{
"id": row["id"],
"source_message_id": row["source_message_id"],
"sender_person_id": row["sender_person_id"],
"sender_name": row["sender_name"] or "",
"message_type": row["message_type"],
"content": _display_message_content(
row["content"],
row["message_type"],
has_attachment=bool(attachments_by_message[str(row["id"])]),
),
"direction": row["direction"],
"status": row["status"],
"sent_at": row["sent_at"],
"sequence_no": row["sequence_no"],
"attachment_count": len(attachments_by_message[str(row["id"])]),
"attachments": attachments_by_message[str(row["id"])],
}
for row in rows
]
next_cursor = ""
if has_more and rows:
next_cursor = f"{rows[-1]['sent_at']}|{rows[-1]['id']}"
return {"items": items, "next_cursor": next_cursor, "has_more": has_more}
# ── 导出 ─────────────────────────────────────────────────────────────
def create_export_job(
self,
formats: Iterable[str],
filters: dict[str, Any],
user_id: int,
ip: str,
tenant_id: str = DEFAULT_TENANT,
) -> dict[str, Any]:
# 「全部」下不知道该导谁的数据,也没法把任务记在某个账号名下。这里明确
# 报错,而不是让 safe_scope 抛一句"租户只能包含字母数字"——那句话对着
# 界面上的下拉框根本看不懂。
tenant = tenant_scope(tenant_id).single
normalized = sorted({str(item).lower() for item in formats})
unsupported = set(normalized) - {"sql", "csv", "xlsx"}
if not normalized or unsupported:
raise ValueError("导出格式只能选择 sql、csv、xlsx")
job_id = new_id()
now = utc_now()
stored_filters = dict(filters or {})
stored_filters["_tenant_id"] = tenant
with self.database.connect() as db:
db.execute(
"""INSERT INTO archive_export_job
(id,tenant_id,status,formats_json,filters_json,cutoff_at,
created_at,created_by)
VALUES (?,?,'queued',?,?,?,?,?)""",
(
job_id, tenant, json_text(normalized), json_text(stored_filters),
now, now, user_id,
),
)
self.database._audit(
db, user_id, "archive.export.create",
f"job={job_id} formats={','.join(normalized)}", ip,
)
db.commit()
return self.export_job(job_id)
def export_job(
self, job_id: str, tenant_id: str | None = None
) -> dict[str, Any]:
with self.database.connect() as db:
if tenant_id is None:
row = db.execute(
"SELECT * FROM archive_export_job WHERE id=?", (job_id,)
).fetchone()
else:
scope = tenant_scope(tenant_id)
row = db.execute(
"SELECT * FROM archive_export_job "
f"WHERE id=? AND {scope.clause()}",
(job_id, *scope.params),
).fetchone()
if row is None:
raise KeyError("导出任务不存在")
files = db.execute(
"SELECT * FROM archive_export_file WHERE job_id=? ORDER BY file_name",
(job_id,),
).fetchall()
return self._public_export_job(row, files)
@staticmethod
def _public_export_job(row: Any, files: Iterable[Any]) -> dict[str, Any]:
item = {key: row[key] for key in row.keys()}
item["formats"] = json.loads(item.pop("formats_json"))
item["filters"] = json.loads(item.pop("filters_json"))
item["filters"].pop("_tenant_id", None)
item["files"] = [
{
"id": file["id"],
"format": file["file_format"],
"file_name": file["file_name"],
"size_bytes": file["size_bytes"],
"sha256": file["sha256"],
"storage_status": file["storage_status"],
}
for file in files
]
return item
def export_jobs(
self, limit: int = 100, tenant_id: str = DEFAULT_TENANT
) -> list[dict[str, Any]]:
scope = tenant_scope(tenant_id)
with self.database.connect() as db:
rows = db.execute(
f"""SELECT * FROM archive_export_job WHERE {scope.clause()}
ORDER BY created_at DESC,id DESC LIMIT ?""",
(*scope.params, max(1, min(int(limit), 500))),
).fetchall()
files_by_job: dict[str, list[Any]] = {row["id"]: [] for row in rows}
if rows:
placeholders = ",".join("?" for _ in rows)
files = db.execute(
f"""SELECT * FROM archive_export_file
WHERE job_id IN ({placeholders}) ORDER BY file_name""",
tuple(files_by_job),
).fetchall()
for file in files:
files_by_job[file["job_id"]].append(file)
return [self._public_export_job(row, files_by_job[row["id"]]) for row in rows]
def _export_where(self, filters: dict[str, Any], cutoff: str) -> tuple[str, list[Any]]:
where = ["m.tenant_id=?", "m.created_at<=?"]
tenant = safe_scope(filters.get("_tenant_id") or DEFAULT_TENANT, label="租户")
params: list[Any] = [tenant, cutoff]
if filters.get("conversation_id"):
where.append("m.conversation_id=?")
params.append(str(filters["conversation_id"]))
if filters.get("date_from"):
where.append("m.sent_at>=?")
params.append(str(filters["date_from"]) + "T00:00:00.000+00:00")
if filters.get("date_to"):
where.append("m.sent_at<=?")
params.append(str(filters["date_to"]) + "T23:59:59.999+00:00")
return " AND ".join(where), params
def _message_rows(
self, filters: dict[str, Any], cutoff: str, *, batch_size: int = 2000
) -> Iterator[dict[str, Any]]:
where, params = self._export_where(filters, cutoff)
conn = self.database.connect()
try:
cursor = conn.execute(
f"""SELECT m.id,m.dedup_key,m.conversation_id,m.sender_person_id,
m.source_message_id,m.server_id,m.client_id,m.sequence_no,
m.message_type,m.direction,m.status,m.sent_at,m.content,
p.display_name AS sender_name,c.name AS conversation_name,
a.external_account_id AS source_account
FROM archive_message m
LEFT JOIN archive_person p ON p.id=m.sender_person_id
JOIN archive_conversation c ON c.id=m.conversation_id
JOIN archive_source_account a ON a.id=m.source_account_id
WHERE {where} ORDER BY m.sent_at,m.id""",
tuple(params),
)
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
yield {key: row[key] for key in row.keys()}
finally:
conn.close()
def _count_export_rows(self, filters: dict[str, Any], cutoff: str) -> int:
where, params = self._export_where(filters, cutoff)
with self.database.connect() as db:
return int(
db.execute(
f"SELECT COUNT(*) AS n FROM archive_message m WHERE {where}",
tuple(params),
).fetchone()["n"]
)
def _write_csv(self, path: Path, filters: dict[str, Any], cutoff: str) -> None:
columns = [
"message_id", "dedup_key", "source_account", "conversation_id",
"conversation_name", "sender_person_id", "sender_name", "source_message_id",
"server_id", "client_id", "sequence_no", "message_type", "direction",
"status", "sent_at", "content",
]
mapping = {"message_id": "id"}
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns)
writer.writeheader()
for row in self._message_rows(filters, cutoff):
writer.writerow({key: row.get(mapping.get(key, key), "") for key in columns})
def _write_sql(self, path: Path, filters: dict[str, Any], cutoff: str) -> None:
where, params = self._export_where(filters, cutoff)
def query_rows(sql: str, values: Iterable[Any]) -> Iterator[dict[str, Any]]:
connection = self.database.connect()
try:
cursor = connection.execute(sql, tuple(values))
while True:
rows = cursor.fetchmany(2000)
if not rows:
break
for row in rows:
yield {key: row[key] for key in row.keys()}
finally:
connection.close()
with path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write("-- 企业微信聊天归档 MySQL 8 导出\n")
handle.write(f"-- 截止水位: {cutoff}\n\n")
handle.write(MYSQL_EXPORT_SCHEMA)
handle.write("\nSTART TRANSACTION;\n")
def write_insert(
table: str,
columns: list[str],
rows: Iterable[dict[str, Any]],
updates: list[str],
) -> None:
batch: list[str] = []
batch_bytes = 0
def flush() -> None:
nonlocal batch, batch_bytes
if not batch:
return
suffix = ""
if updates:
suffix = "\nON DUPLICATE KEY UPDATE " + ",".join(
f"`{column}`=VALUES(`{column}`)" for column in updates
)
handle.write(
f"INSERT INTO `{table}` (`"
+ "`,`".join(columns)
+ "`) VALUES\n"
+ ",\n".join(batch)
+ suffix
+ ";\n"
)
batch = []
batch_bytes = 0
for row in rows:
literal = "(" + ",".join(
mysql_literal(row.get(column)) for column in columns
) + ")"
size = len(literal.encode("utf-8"))
if batch and (len(batch) >= 500 or batch_bytes + size > 4_000_000):
flush()
batch.append(literal)
batch_bytes += size
flush()
people = query_rows(
f"""SELECT DISTINCT p.id AS person_id,p.display_name,p.real_name
FROM archive_person p JOIN archive_message m
ON m.sender_person_id=p.id WHERE {where}
ORDER BY p.id""",
params,
)
write_insert(
"archive_people",
["person_id", "display_name", "real_name"],
people,
["display_name", "real_name"],
)
conversations = query_rows(
f"""SELECT DISTINCT c.id AS conversation_id,
a.external_account_id AS source_account,c.external_id,
c.conversation_type,c.name,c.last_message_at
FROM archive_conversation c
JOIN archive_source_account a ON a.id=c.source_account_id
JOIN archive_message m ON m.conversation_id=c.id
WHERE {where} ORDER BY c.id""",
params,
)
write_insert(
"archive_conversations",
[
"conversation_id", "source_account", "external_id",
"conversation_type", "name", "last_message_at",
],
(
{**row, "last_message_at": mysql_datetime(row["last_message_at"])}
for row in conversations
),
["conversation_type", "name", "last_message_at"],
)
media = query_rows(
f"""SELECT DISTINCT mo.id AS media_id,mo.bucket,mo.region,
mo.object_key,mo.version_id,mo.sha256,mo.size_bytes,
mo.mime_type,mo.original_filename,mo.status
FROM archive_media_object mo
JOIN archive_message_attachment ma ON ma.media_id=mo.id
JOIN archive_message m ON m.id=ma.message_id
WHERE {where} ORDER BY mo.id""",
params,
)
write_insert(
"archive_media",
[
"media_id", "bucket", "region", "object_key", "version_id",
"sha256", "size_bytes", "mime_type", "original_filename", "status",
],
media,
["version_id", "status"],
)
message_columns = [
"message_id", "dedup_key", "conversation_id", "sender_person_id",
"source_message_id", "server_id", "client_id", "sequence_no",
"message_type", "direction", "status", "sent_at", "content",
]
message_rows = (
{
**row,
"message_id": row["id"],
"sent_at": mysql_datetime(row["sent_at"]),
}
for row in self._message_rows(filters, cutoff)
)
write_insert(
"archive_messages", message_columns, message_rows, ["status", "content"]
)
links = query_rows(
f"""SELECT ma.message_id,ma.media_id,ma.attachment_index,
ma.attachment_role,ma.match_method,ma.match_confidence
FROM archive_message_attachment ma
JOIN archive_message m ON m.id=ma.message_id
WHERE {where} ORDER BY ma.message_id,ma.attachment_index""",
params,
)
write_insert(
"archive_message_media",
[
"message_id", "media_id", "attachment_index", "attachment_role",
"match_method", "match_confidence",
],
links,
["attachment_role", "match_method", "match_confidence"],
)
pending_attachments = query_rows(
f"""SELECT pa.id AS pending_id,pa.message_id,
pa.source_message_id,pa.original_filename,pa.size_bytes,
pa.checksum,pa.media_type,pa.status,pa.media_id
FROM archive_pending_attachment pa
JOIN archive_message m ON m.id=pa.message_id
WHERE {where} ORDER BY pa.message_id,pa.created_at,pa.id""",
params,
)
write_insert(
"archive_pending_attachments",
[
"pending_id", "message_id", "source_message_id",
"original_filename", "size_bytes", "checksum", "media_type",
"status", "media_id",
],
pending_attachments,
["status", "media_id"],
)
handle.write("COMMIT;\n")
def _write_xlsx(self, path: Path, filters: dict[str, Any], cutoff: str) -> None:
from openpyxl import Workbook
workbook = Workbook(write_only=True)
def append_sheets(
base_name: str, headers: list[str], rows: Iterable[Iterable[Any]]
) -> None:
index = 1
sheet = workbook.create_sheet(f"{base_name}_{index:03d}")
sheet.append(headers)
count = 0
for row in rows:
if count and count % EXCEL_SHEET_DATA_ROWS == 0:
index += 1
sheet = workbook.create_sheet(f"{base_name}_{index:03d}")
sheet.append(headers)
sheet.append([excel_safe(value) for value in row])
count += 1
append_sheets(
"消息",
[
"消息ID", "去重键", "账号", "会话ID", "会话名称", "发送者ID",
"发送者", "源消息ID", "服务器ID", "客户端ID", "序号", "类型",
"方向", "状态", "发送时间(UTC)", "内容",
],
(
(
row["id"], row["dedup_key"], row["source_account"],
row["conversation_id"], row["conversation_name"],
row["sender_person_id"] or "", row["sender_name"] or "",
row["source_message_id"], row["server_id"], row["client_id"],
row["sequence_no"], row["message_type"], row["direction"],
row["status"], row["sent_at"], row["content"],
)
for row in self._message_rows(filters, cutoff)
),
)
where, params = self._export_where(filters, cutoff)
def query_rows(sql: str) -> Iterator[tuple[Any, ...]]:
connection = self.database.connect()
try:
cursor = connection.execute(sql, tuple(params))
while True:
batch = cursor.fetchmany(2000)
if not batch:
break
for row in batch:
yield tuple(row)
finally:
connection.close()
append_sheets(
"会话",
["会话ID", "账号", "源会话ID", "类型", "名称", "最后消息时间"],
query_rows(
f"""SELECT DISTINCT c.id,a.external_account_id,c.external_id,
c.conversation_type,c.name,c.last_message_at
FROM archive_conversation c
JOIN archive_source_account a ON a.id=c.source_account_id
JOIN archive_message m ON m.conversation_id=c.id
WHERE {where} ORDER BY c.id"""
),
)
append_sheets(
"联系人",
["联系人ID", "显示名称", "真实姓名", "创建时间"],
query_rows(
f"""SELECT DISTINCT p.id,p.display_name,p.real_name,p.created_at
FROM archive_person p JOIN archive_message m
ON m.sender_person_id=p.id
WHERE {where} ORDER BY p.id"""
),
)
append_sheets(
"素材",
[
"素材ID", "消息ID", "Bucket", "Region", "ObjectKey", "VersionId",
"SHA256", "大小", "MIME", "原文件名", "状态",
],
query_rows(
f"""SELECT mo.id,ma.message_id,mo.bucket,mo.region,mo.object_key,
mo.version_id,mo.sha256,mo.size_bytes,mo.mime_type,
mo.original_filename,mo.status
FROM archive_media_object mo
JOIN archive_message_attachment ma ON ma.media_id=mo.id
JOIN archive_message m ON m.id=ma.message_id
WHERE {where} ORDER BY ma.message_id,ma.attachment_index"""
),
)
append_sheets(
"待补传附件",
[
"待补传ID", "消息ID", "源消息ID", "原文件名", "大小",
"校验值", "素材类型", "状态", "已关联素材ID",
],
query_rows(
f"""SELECT pa.id,pa.message_id,pa.source_message_id,
pa.original_filename,pa.size_bytes,pa.checksum,
pa.media_type,pa.status,COALESCE(pa.media_id,'')
FROM archive_pending_attachment pa
JOIN archive_message m ON m.id=pa.message_id
WHERE {where} ORDER BY pa.message_id,pa.created_at,pa.id"""
),
)
workbook.save(path)
def _upload_export_file(self, path: Path, job: Any) -> tuple[str, str]:
if not self.storage_config(include_secrets=True).get("enabled"):
return "", "local"
config, client = self._cos_client()
created = datetime.fromisoformat(job["created_at"])
object_key = (
f"{safe_prefix(config['export_prefix'], 'archive/exports')}/"
f"{job['tenant_id']}/{created:%Y/%m}/{job['id']}/{path.name}"
)
kwargs: dict[str, Any] = {}
if config.get("encryption_mode"):
kwargs["ServerSideEncryption"] = config["encryption_mode"]
client.upload_file(
Bucket=config["bucket"], Key=object_key, LocalFilePath=str(path),
PartSize=16, MAXThread=3, EnableMD5=True, **kwargs,
)
head = client.head_object(Bucket=config["bucket"], Key=object_key)
remote_size = int(head.get("Content-Length") or head.get("content-length") or 0)
if remote_size != path.stat().st_size:
raise RuntimeError(f"导出文件上传后大小不一致:{path.name}")
return object_key, "cos"
def run_export_job(self, job_id: str) -> None:
# 单进程开发模式串行导出,避免两个大 XLSX 同时抢满内存和磁盘。生产环境可把
# 这个同名任务交给独立 Worker,数据库状态机保持不变。
with self._export_lock:
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
claimed = db.execute(
"""UPDATE archive_export_job SET status='running',started_at=?,
progress=1,error_message='' WHERE id=?
AND status IN ('queued','failed')""",
(utc_now(), job_id),
)
if claimed.rowcount != 1:
db.rollback()
return
db.commit()
job = db.execute(
"SELECT * FROM archive_export_job WHERE id=?", (job_id,)
).fetchone()
try:
formats = json.loads(job["formats_json"])
filters = json.loads(job["filters_json"])
total = self._count_export_rows(filters, job["cutoff_at"])
directory = self.export_root / job_id
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True, exist_ok=True)
created_files: list[tuple[str, Path]] = []
if "sql" in formats:
path = directory / "archive.sql"
self._write_sql(path, filters, job["cutoff_at"])
created_files.append(("sql", path))
if "csv" in formats:
path = directory / "messages.csv"
self._write_csv(path, filters, job["cutoff_at"])
created_files.append(("csv", path))
if "xlsx" in formats:
path = directory / "archive.xlsx"
self._write_xlsx(path, filters, job["cutoff_at"])
created_files.append(("xlsx", path))
manifest = {
"job_id": job_id,
"cutoff_at": job["cutoff_at"],
"filters": filters,
"message_count": total,
"files": [
{"name": path.name, "sha256": sha256_file(path), "size": path.stat().st_size}
for _fmt, path in created_files
],
}
manifest_path = directory / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
created_files.append(("manifest", manifest_path))
zip_path = directory / "archive_bundle.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
for _fmt, path in created_files:
bundle.write(path, path.name)
created_files.append(("zip", zip_path))
with self.database.connect() as db:
db.execute("DELETE FROM archive_export_file WHERE job_id=?", (job_id,))
for file_format, path in created_files:
object_key, storage_status = self._upload_export_file(path, job)
db.execute(
"""INSERT INTO archive_export_file
(id,job_id,file_format,file_name,local_path,object_key,
size_bytes,sha256,storage_status,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
new_id(), job_id, file_format, path.name, str(path),
object_key, path.stat().st_size, sha256_file(path),
storage_status, utc_now(),
),
)
db.execute(
"""UPDATE archive_export_job SET status='completed',progress=100,
total_rows=?,completed_at=? WHERE id=?""",
(total, utc_now(), job_id),
)
db.commit()
except Exception as exc:
with self.database.connect() as db:
db.execute(
"""UPDATE archive_export_job SET status='failed',
error_message=?,completed_at=? WHERE id=?""",
(str(exc)[:2000], utc_now(), job_id),
)
db.commit()
def export_file(
self, file_id: str, tenant_id: str | None = None
) -> dict[str, Any]:
with self.database.connect() as db:
params: tuple[Any, ...] = (file_id,)
tenant_clause = ""
if tenant_id is not None:
scope = tenant_scope(tenant_id)
tenant_clause = f" AND {scope.clause('j')}"
params = (file_id, *scope.params)
row = db.execute(
f"""SELECT f.*,j.tenant_id FROM archive_export_file f
JOIN archive_export_job j ON j.id=f.job_id
WHERE f.id=?{tenant_clause}""",
params,
).fetchone()
if row is None:
raise KeyError("导出文件不存在")
return {key: row[key] for key in row.keys()}
def export_download_url(self, file_id: str) -> str:
row = self.export_file(file_id)
if row["storage_status"] != "cos" or not row["object_key"]:
return ""
config, client = self._cos_client()
return client.get_presigned_download_url(
Bucket=config["bucket"], Key=row["object_key"], Expired=300
)