Files
kefu/deploy/archive-performance-fix/changes.diff
T
2026-09-21 10:34:06 +08:00

1473 lines
56 KiB
Diff

--- a/wechat_rpa/archive_store.py
+++ b/wechat_rpa/archive_store.py
@@ -369,6 +369,17 @@
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
@@ -2659,7 +2670,7 @@
FROM archive_conversation_member cm
WHERE cm.person_id=p.id) AS conversation_count,
(SELECT COUNT(*) FROM archive_message m
- WHERE m.sender_person_id=p.id) AS message_count,
+ 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,
@@ -2847,35 +2858,34 @@
params: list[Any] = [tenant]
where = "c.tenant_id=?"
if cursor:
- where += " AND (c.last_message_at < ? OR (c.last_message_at=? AND c.id<?))"
+ 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_time, cursor_id])
+ params.extend([cursor_time, cursor_id])
params.append(limit + 1)
with self.database.connect() as db:
rows = db.execute(
- f"""SELECT c.*,a.external_account_id,
+ 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.conversation_id=c.id) AS message_count,
- (SELECT m.content FROM archive_message m
- WHERE m.conversation_id=c.id
- ORDER BY m.sent_at DESC,m.id DESC LIMIT 1) AS last_content,
- (SELECT m.message_type FROM archive_message m
- WHERE m.conversation_id=c.id
- ORDER BY m.sent_at DESC,m.id DESC LIMIT 1) AS last_message_type,
+ 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
- JOIN archive_message m ON m.id=ma.message_id
- WHERE m.conversation_id=c.id
- AND m.id=(SELECT lm.id FROM archive_message lm
- WHERE lm.conversation_id=c.id
- ORDER BY lm.sent_at DESC,lm.id DESC LIMIT 1))
- AS last_attachment_count
- FROM archive_conversation c
+ 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
- WHERE {where}
- ORDER BY c.last_message_at DESC,c.id DESC LIMIT ?""",
+ 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
@@ -2919,8 +2929,8 @@
cursor_time, cursor_id = cursor.split("|", 1)
except ValueError as exc:
raise ValueError("消息游标格式不正确") from exc
- where += " AND (m.sent_at > ? OR (m.sent_at=? AND m.id>?))"
- params.extend([cursor_time, cursor_time, cursor_id])
+ 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(
@@ -3076,6 +3086,10 @@
"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"))
@@ -3098,15 +3112,22 @@
) -> list[dict[str, Any]]:
tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户")
with self.database.connect() as db:
- ids = [
- row["id"]
- for row in db.execute(
- """SELECT id FROM archive_export_job WHERE tenant_id=?
- ORDER BY created_at DESC LIMIT ?""",
- (tenant, max(1, min(int(limit), 500))),
+ rows = db.execute(
+ """SELECT * FROM archive_export_job WHERE tenant_id=?
+ ORDER BY created_at DESC,id DESC LIMIT ?""",
+ (tenant, 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()
- ]
- return [self.export_job(job_id) for job_id in ids]
+ 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<=?"]
--- a/wechat_rpa/archive_api.py
+++ b/wechat_rpa/archive_api.py
@@ -131,6 +131,13 @@
store = ArchiveStore(database)
store.initialize()
app.state.archive_store = store
+ export_slot = asyncio.Lock()
+
+ async def run_export(job_id: str) -> None:
+ # Queued jobs wait asynchronously, never occupying the API worker pool.
+ async with export_slot:
+ await asyncio.to_thread(store.run_export_job, job_id)
+
def _desktop_patient_access(account: Any) -> dict[str, Any]:
try:
@@ -187,7 +194,7 @@
async def _search_patients_for_tenant(
tenant: str, keyword: str, page_no: int, page_size: int
) -> dict[str, Any]:
- token = store.zyt_token(tenant)
+ token = await asyncio.to_thread(store.zyt_token, tenant)
if zyt_patient_searcher is not None:
result = await asyncio.to_thread(
zyt_patient_searcher,
@@ -208,7 +215,7 @@
)
if not isinstance(result, dict):
raise ZytPatientError("ZYT 患者查询结果格式不正确")
- items = store.cache_patients(list(result.get("items") or []), tenant)
+ items = await asyncio.to_thread(store.cache_patients, list(result.get("items") or []), tenant)
return {
"items": items,
"total": int(result.get("total") or len(items)),
@@ -221,7 +228,7 @@
) -> dict[str, Any] | None:
"""绑定前重新确认该患者仍在当前 ZYT 账号的数据范围内。"""
- token = store.zyt_token(tenant)
+ token = await asyncio.to_thread(store.zyt_token, tenant)
if zyt_patient_searcher is not None:
result = await asyncio.to_thread(
zyt_patient_searcher, token, str(int(patient_id)), 1, 50
@@ -242,14 +249,14 @@
return await asyncio.to_thread(client.get_patient, int(patient_id))
@app.get("/api/v2/archive/stats")
- async def archive_stats(
+ def archive_stats(
account_id: int | None = None,
principal: Any = Depends(require("im:read")),
) -> dict[str, Any]:
return store.stats(resolve_admin_tenant(principal, account_id))
@app.get("/api/v2/archive/conversations")
- async def archive_conversations(
+ def archive_conversations(
limit: int = 50,
cursor: str = "",
account_id: int | None = None,
@@ -263,7 +270,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/conversations/{conversation_id}/messages")
- async def archive_messages(
+ def archive_messages(
conversation_id: str,
limit: int = 100,
cursor: str = "",
@@ -281,7 +288,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/imports/messages")
- async def archive_import_messages(
+ def archive_import_messages(
body: ImportBody,
request: Request,
principal: Any = Depends(require("im:import")),
@@ -294,7 +301,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/imports/metadata")
- async def archive_import_metadata(
+ def archive_import_metadata(
body: MetadataBody,
request: Request,
principal: Any = Depends(require("im:import")),
@@ -307,7 +314,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/checkpoint")
- async def desktop_archive_checkpoint(
+ def desktop_archive_checkpoint(
external_account_id: str,
source_table: str = "message_table",
account: Any = Depends(current_desktop),
@@ -321,7 +328,7 @@
}
@app.post("/api/v2/archive/desktop/checkpoint")
- async def desktop_archive_advance_checkpoint(
+ def desktop_archive_advance_checkpoint(
body: DesktopCheckpointBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
@@ -335,7 +342,7 @@
}
@app.get("/api/v2/archive/desktop/pending-attachments")
- async def desktop_pending_attachments(
+ def desktop_pending_attachments(
external_account_id: str,
limit: int = 500,
account: Any = Depends(current_desktop),
@@ -349,7 +356,7 @@
}
@app.post("/api/v2/archive/desktop/imports/messages")
- async def desktop_archive_import_messages(
+ def desktop_archive_import_messages(
body: ImportBody,
request: Request,
account: Any = Depends(current_desktop),
@@ -362,7 +369,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/imports/metadata")
- async def desktop_archive_import_metadata(
+ def desktop_archive_import_metadata(
body: MetadataBody,
request: Request,
account: Any = Depends(current_desktop),
@@ -375,7 +382,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/people")
- async def archive_people(
+ def archive_people(
limit: int = 100,
keyword: str = "",
account_id: int | None = None,
@@ -388,7 +395,7 @@
}
@app.get("/api/v2/archive/people/{person_id}")
- async def archive_person(
+ def archive_person(
person_id: str,
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
@@ -403,7 +410,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/people/{person_id}/identities")
- async def bind_archive_identity(
+ def bind_archive_identity(
person_id: str,
body: IdentityBody,
request: Request,
@@ -431,7 +438,7 @@
account_id: int | None = None,
principal: Any = Depends(require("im:identity:write")),
) -> dict[str, Any]:
- tenant = resolve_admin_tenant(principal, account_id)
+ tenant = await asyncio.to_thread(resolve_admin_tenant, principal, account_id)
try:
return await _search_patients_for_tenant(
tenant, keyword, page_no, page_size
@@ -440,7 +447,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/desktop/patient-context")
- async def desktop_patient_context(
+ def desktop_patient_context(
external_account_id: str,
conversation_external_id: str,
person_id: str = "",
@@ -496,7 +503,7 @@
status_code=403,
detail="当前 ZYT 账号无权访问该患者,不能建立绑定",
)
- context = store.conversation_patient_context(
+ context = await asyncio.to_thread(store.conversation_patient_context,
body.external_account_id,
body.conversation_external_id,
tenant,
@@ -507,7 +514,7 @@
if context.get("requires_person_selection"):
raise ValueError("群聊中有多个联系人,请先选择需要绑定的联系人")
raise ValueError(str(context.get("reason") or "当前会话没有可绑定的联系人"))
- binding = store.bind_patient(
+ binding = await asyncio.to_thread(store.bind_patient,
str(person["id"]),
body.model_dump(),
None,
@@ -517,7 +524,7 @@
return {
"binding": binding,
"context": _context_with_access(
- store.conversation_patient_context(
+ await asyncio.to_thread(store.conversation_patient_context,
body.external_account_id,
body.conversation_external_id,
tenant,
@@ -530,7 +537,7 @@
raise _http_error(exc) from exc
@app.delete("/api/v2/archive/desktop/patient-binding")
- async def desktop_unbind_archive_patient(
+ def desktop_unbind_archive_patient(
request: Request,
external_account_id: str,
conversation_external_id: str,
@@ -568,7 +575,7 @@
raise _http_error(exc) from exc
@app.put("/api/v2/archive/people/{person_id}/patient-bindings")
- async def bind_archive_patient(
+ def bind_archive_patient(
person_id: str,
body: PatientBindingBody,
request: Request,
@@ -594,7 +601,7 @@
@app.delete(
"/api/v2/archive/people/{person_id}/patient-bindings/{binding_id}"
)
- async def unbind_archive_patient(
+ def unbind_archive_patient(
person_id: str,
binding_id: str,
request: Request,
@@ -615,13 +622,13 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/storage")
- async def archive_storage(
+ def archive_storage(
_: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
return {"storage": store.storage_config()}
@app.put("/api/v2/archive/storage")
- async def save_archive_storage(
+ def save_archive_storage(
body: StorageBody,
request: Request,
principal: Any = Depends(require("im:storage:write")),
@@ -636,7 +643,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/storage/test")
- async def test_archive_storage(
+ def test_archive_storage(
request: Request,
principal: Any = Depends(require("im:storage:write")),
) -> dict[str, Any]:
@@ -659,7 +666,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/media")
- async def archive_media(
+ def archive_media(
limit: int = 100,
account_id: int | None = None,
principal: Any = Depends(require("im:content:read")),
@@ -671,7 +678,7 @@
}
@app.post("/api/v2/archive/media/access-urls")
- async def archive_media_access_urls(
+ def archive_media_access_urls(
body: MediaAccessBody,
request: Request,
account_id: int | None = None,
@@ -694,7 +701,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/prepare")
- async def prepare_archive_media(
+ def prepare_archive_media(
body: MediaPrepareBody,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
@@ -704,7 +711,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/prepare")
- async def desktop_prepare_archive_media(
+ def desktop_prepare_archive_media(
body: MediaPrepareBody,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
@@ -716,7 +723,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/{media_id}/complete")
- async def complete_archive_media(
+ def complete_archive_media(
media_id: str,
_: Any = Depends(require("im:import")),
) -> dict[str, Any]:
@@ -726,7 +733,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/media/{media_id}/multipart-complete")
- async def complete_archive_multipart_media(
+ def complete_archive_multipart_media(
media_id: str,
body: MediaMultipartCompleteBody,
_: Any = Depends(require("im:import")),
@@ -743,7 +750,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/{media_id}/complete")
- async def desktop_complete_archive_media(
+ def desktop_complete_archive_media(
media_id: str,
account: Any = Depends(current_desktop),
) -> dict[str, Any]:
@@ -755,7 +762,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/desktop/media/{media_id}/multipart-complete")
- async def desktop_complete_archive_multipart_media(
+ def desktop_complete_archive_multipart_media(
media_id: str,
body: MediaMultipartCompleteBody,
account: Any = Depends(current_desktop),
@@ -773,7 +780,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/media/{media_id}/view")
- async def view_archive_media(
+ def view_archive_media(
media_id: str,
request: Request,
account_id: int | None = None,
@@ -791,7 +798,7 @@
raise _http_error(exc) from exc
@app.post("/api/v2/archive/exports")
- async def create_archive_export(
+ def create_archive_export(
body: ExportBody,
request: Request,
background: BackgroundTasks,
@@ -806,13 +813,13 @@
client_ip(request),
resolve_admin_tenant(principal, account_id),
)
- background.add_task(store.run_export_job, job["id"])
+ background.add_task(run_export, job["id"])
return {"job": job}
except Exception as exc:
raise _http_error(exc) from exc
@app.get("/api/v2/archive/exports")
- async def archive_exports(
+ def archive_exports(
limit: int = 100,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
@@ -824,7 +831,7 @@
}
@app.get("/api/v2/archive/exports/{job_id}")
- async def archive_export(
+ def archive_export(
job_id: str,
account_id: int | None = None,
principal: Any = Depends(require("im:export")),
@@ -839,7 +846,7 @@
raise _http_error(exc) from exc
@app.get("/api/v2/archive/export-files/{file_id}/download")
- async def download_archive_export(
+ def download_archive_export(
file_id: str,
request: Request,
account_id: int | None = None,
--- a/wechat_rpa/admin_api.py
+++ b/wechat_rpa/admin_api.py
@@ -294,7 +294,7 @@
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
return forwarded or (request.client.host if request.client else "")
- async def current(request: Request) -> Principal:
+ def current(request: Request) -> Principal:
raw = request.headers.get("authorization", "")
token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
user = database.session(token) if token else None
@@ -306,7 +306,7 @@
)
return Principal(user, database.permissions_for_user(int(user["id"])))
- async def current_desktop(request: Request):
+ def current_desktop(request: Request):
raw = request.headers.get("authorization", "")
token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
account = database.desktop_session(token)
@@ -591,7 +591,8 @@
# ── 客户端账号与设备 ─────────────────────────────────────────────────
@app.get("/api/v2/desktop-accounts")
- async def list_desktop_accounts(
+ def list_desktop_accounts(
+ include_counts: bool = True,
principal: Principal = Depends(current),
) -> dict:
allowed = (
@@ -600,7 +601,7 @@
else database.desktop_account_ids_for_admin(principal.id)
)
return {
- "accounts": database.desktop_accounts(allowed),
+ "accounts": database.desktop_accounts(allowed, include_counts=include_counts),
"can_view_all": "desktop-account:all" in principal.permissions,
# 尚未分配任何账号的旧角色仍能查看升级前的未归属数据;一旦分配账号,
# 就必须明确选择授权账号,不能再借默认值绕过范围限制。
--- a/wechat_rpa/admin_backend.py
+++ b/wechat_rpa/admin_backend.py
@@ -1015,7 +1015,7 @@
db.commit()
def desktop_accounts(
- self, allowed_ids: list[int] | None = None
+ self, allowed_ids: list[int] | None = None, *, include_counts: bool = True
) -> list[dict[str, Any]]:
allowed = None if allowed_ids is None else sorted({int(item) for item in allowed_ids})
where = ""
@@ -1025,6 +1025,13 @@
return []
where = f" WHERE a.id IN ({','.join('?' for _ in allowed)})"
params = tuple(allowed)
+ archive_counts = (
+ """(SELECT COUNT(*) FROM archive_source_account sa
+ WHERE sa.tenant_id=a.tenant_id) AS source_account_count,
+ (SELECT COUNT(*) FROM archive_message m
+ WHERE m.tenant_id=a.tenant_id) AS message_count"""
+ if include_counts else "0 AS source_account_count,0 AS message_count"
+ )
now_epoch = int(time.time())
online_cutoff = (
datetime.now().astimezone() - timedelta(seconds=20)
@@ -1046,10 +1053,7 @@
(SELECT d.app_version FROM desktop_devices d
WHERE d.account_id=a.id ORDER BY d.last_seen_at DESC LIMIT 1)
AS app_version,
- (SELECT COUNT(*) FROM archive_source_account sa
- WHERE sa.tenant_id=a.tenant_id) AS source_account_count,
- (SELECT COUNT(*) FROM archive_message m
- WHERE m.tenant_id=a.tenant_id) AS message_count
+ {archive_counts}
FROM desktop_accounts a{where}
ORDER BY a.last_online_at DESC,a.id DESC""",
(now_epoch, online_cutoff, *params),
--- a/admin-web/apps/web-antd/src/api/archive.ts
+++ b/admin-web/apps/web-antd/src/api/archive.ts
@@ -185,18 +185,19 @@
return text ? `?${text}` : '';
}
-export function fetchArchiveStats(accountId = 0) {
+export function fetchArchiveStats(accountId = 0, signal?: AbortSignal) {
return requestClient.get<ArchiveStats>(
`/archive/stats${query({ account_id: accountId })}`,
- );
-}
-
-export function fetchArchiveConversations(limit = 50, cursor = '', accountId = 0) {
+ { signal },
+ );
+}
+
+export function fetchArchiveConversations(limit = 50, cursor = '', accountId = 0, signal?: AbortSignal) {
return requestClient.get<{
has_more: boolean;
items: ArchiveConversation[];
next_cursor: string;
- }>(`/archive/conversations${query({ account_id: accountId, cursor, limit })}`);
+ }>(`/archive/conversations${query({ account_id: accountId, cursor, limit })}`, { signal });
}
export function fetchArchiveMessages(
@@ -204,6 +205,7 @@
limit = 100,
cursor = '',
accountId = 0,
+ signal?: AbortSignal,
) {
return requestClient.get<{
has_more: boolean;
@@ -211,6 +213,7 @@
next_cursor: string;
}>(
`/archive/conversations/${encodeURIComponent(conversationId)}/messages${query({ account_id: accountId, cursor, limit })}`,
+ { signal },
);
}
@@ -218,15 +221,17 @@
mediaIds: string[],
expires = 900,
accountId = 0,
+ signal?: AbortSignal,
) {
return requestClient.post<{ items: ArchiveMediaAccess[] }>(
`/archive/media/access-urls${query({ account_id: accountId })}`,
{ expires, media_ids: mediaIds },
- );
-}
-
-export function fetchArchiveStorage() {
- return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage');
+ { signal },
+ );
+}
+
+export function fetchArchiveStorage(signal?: AbortSignal) {
+ return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage', { signal });
}
export function saveArchiveStorage(data: Partial<ArchiveStorage> & {
@@ -245,15 +250,17 @@
}>('/archive/storage/test');
}
-export function fetchArchiveMedia(limit = 100, accountId = 0) {
+export function fetchArchiveMedia(limit = 100, accountId = 0, signal?: AbortSignal) {
return requestClient.get<{ items: ArchiveMedia[] }>(
`/archive/media${query({ account_id: accountId, limit })}`,
- );
-}
-
-export function fetchArchivePeople(limit = 100, keyword = '', accountId = 0) {
+ { signal },
+ );
+}
+
+export function fetchArchivePeople(limit = 100, keyword = '', accountId = 0, signal?: AbortSignal) {
return requestClient.get<{ items: ArchivePerson[] }>(
`/archive/people${query({ account_id: accountId, keyword, limit })}`,
+ { signal },
);
}
@@ -333,9 +340,10 @@
);
}
-export function fetchArchiveExports(limit = 100, accountId = 0) {
+export function fetchArchiveExports(limit = 100, accountId = 0, signal?: AbortSignal) {
return requestClient.get<{ jobs: ArchiveExportJob[] }>(
`/archive/exports${query({ account_id: accountId, limit })}`,
+ { signal },
);
}
--- a/admin-web/apps/web-antd/src/api/console.ts
+++ b/admin-web/apps/web-antd/src/api/console.ts
@@ -196,13 +196,13 @@
}
// ── 客户端账号 ───────────────────────────────────────────────────────────────
-export function fetchDesktopAccounts() {
+export function fetchDesktopAccounts(includeCounts = true) {
return requestClient.get<{
accounts: DesktopAccountItem[];
can_view_all: boolean;
can_view_legacy: boolean;
}>(
- '/desktop-accounts',
+ `/desktop-accounts?include_counts=${includeCounts}`,
);
}
--- a/admin-web/apps/web-antd/src/components/archive-account-selector.vue
+++ b/admin-web/apps/web-antd/src/components/archive-account-selector.vue
@@ -25,7 +25,7 @@
onMounted(async () => {
loading.value = true;
try {
- const response = await fetchDesktopAccounts();
+ const response = await fetchDesktopAccounts(false);
accounts.value = response.accounts;
canViewLegacy.value = response.can_view_legacy;
const saved = Number(localStorage.getItem('wecom-archive-account-id') || 0);
--- a/admin-web/apps/web-antd/src/views/archive/conversations.vue
+++ b/admin-web/apps/web-antd/src/views/archive/conversations.vue
@@ -20,14 +20,20 @@
} from '#/api/archive';
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
-const loading = ref(false);
-const messageLoading = ref(false);
+import { useArchiveLoad } from './use-archive-load';
+
+const pageRequest = useArchiveLoad();
+const loading = pageRequest.loading;
+const messageRequest = useArchiveLoad();
+const messageLoading = messageRequest.loading;
const conversations = ref<ArchiveConversation[]>([]);
-const nextCursor = ref('');
+const page = ref(0);
+let cursors = [''];
const hasMore = ref(false);
const selected = ref<ArchiveConversation | null>(null);
const messages = ref<ArchiveMessage[]>([]);
-const messageCursor = ref('');
+const messagePage = ref(0);
+let messageCursors = [''];
const messageHasMore = ref(false);
const mediaUrls = ref<Record<string, string>>({});
const accountId = ref(0);
@@ -73,76 +79,74 @@
return labels[status] || status;
}
-async function loadMediaUrls(items: ArchiveMessage[]) {
- const ids = [
- ...new Set(
- items.flatMap((item) =>
- (item.attachments || [])
- .filter((attachment) => attachment.status === 'ready')
- .map((attachment) => attachment.id),
- ),
- ),
- ].filter((id) => !mediaUrls.value[id]);
- for (let index = 0; index < ids.length; index += 200) {
+async function loadMediaUrls(items: ArchiveMessage[], account: number, signal: AbortSignal) {
+ const ids = [...new Set(items.flatMap((item) => (item.attachments || [])
+ .filter((attachment) => attachment.status === 'ready')
+ .map((attachment) => attachment.id)))];
+ const urls: Record<string, string> = {};
+ for (let index = 0; index < ids.length && !signal.aborted; index += 200) {
try {
- const data = await fetchArchiveMediaAccessUrls(
- ids.slice(index, index + 200),
- 900,
- accountId.value,
- );
- const additions = Object.fromEntries(
- data.items.map((item) => [item.id, item.url]),
- );
- mediaUrls.value = { ...mediaUrls.value, ...additions };
+ const data = await fetchArchiveMediaAccessUrls(ids.slice(index, index + 200), 900, account, signal);
+ Object.assign(urls, Object.fromEntries(data.items.map((item) => [item.id, item.url])));
} catch {
- // 消息正文仍可正常查看;素材签名失败时保留状态提示,刷新后可重试。
+ // A signing failure must not hide the message text.
}
}
-}
-
-async function load(reset = true) {
- loading.value = true;
- try {
- const data = await fetchArchiveConversations(
- 50,
- reset ? '' : nextCursor.value,
- accountId.value,
- );
- conversations.value = reset
- ? data.items
- : [...conversations.value, ...data.items];
- nextCursor.value = data.next_cursor;
- hasMore.value = data.has_more;
- } finally {
- loading.value = false;
+ return urls;
+}
+
+async function load(reset = true, targetPage = 0) {
+ if (reset) {
+ cursors = [''];
+ conversations.value = [];
+ page.value = 0;
+ hasMore.value = false;
}
+ await pageRequest.run(
+ (signal) => fetchArchiveConversations(50, cursors[targetPage] || '', accountId.value, signal),
+ (data) => {
+ conversations.value = data.items;
+ page.value = targetPage;
+ cursors[targetPage + 1] = data.next_cursor;
+ hasMore.value = data.has_more;
+ },
+ );
+}
+
+function closeConversation() {
+ messageRequest.cancel();
+ selected.value = null;
+ messages.value = [];
+ mediaUrls.value = {};
+ messageHasMore.value = false;
}
async function openConversation(record: Record<string, any>) {
+ closeConversation();
selected.value = record as ArchiveConversation;
- messages.value = [];
- messageCursor.value = '';
- mediaUrls.value = {};
- await loadMessages(true);
-}
-
-async function loadMessages(reset = false) {
- if (!selected.value) return;
- messageLoading.value = true;
- try {
- const data = await fetchArchiveMessages(
- selected.value.id,
- 100,
- reset ? '' : messageCursor.value,
- accountId.value,
- );
- messages.value = reset ? data.items : [...messages.value, ...data.items];
- messageCursor.value = data.next_cursor;
- messageHasMore.value = data.has_more;
- await loadMediaUrls(data.items);
- } finally {
- messageLoading.value = false;
- }
+ messageCursors = [''];
+ messagePage.value = 0;
+ await loadMessages(0);
+}
+
+async function loadMessages(targetPage = 0) {
+ const conversation = selected.value;
+ if (!conversation) return;
+ const account = accountId.value;
+ await messageRequest.run(
+ async (signal) => {
+ const data = await fetchArchiveMessages(conversation.id, 100, messageCursors[targetPage] || '', account, signal);
+ const urls = await loadMediaUrls(data.items, account, signal);
+ return { data, urls };
+ },
+ ({ data, urls }) => {
+ messages.value = data.items;
+ messagePage.value = targetPage;
+ messageCursors[targetPage + 1] = data.next_cursor;
+ messageHasMore.value = data.has_more;
+ mediaUrls.value = urls;
+ },
+ );
}
const TYPE_LABEL: Record<string, string> = {
@@ -163,13 +167,13 @@
<div class="flex items-center gap-2">
<ArchiveAccountSelector
v-model="accountId"
- @change="() => { selected = null; load(true); }"
+ @change="() => { closeConversation(); load(true); }"
/>
<Button :loading="loading" @click="load(true)">刷新</Button>
</div>
</template>
<p class="mb-3 text-sm text-gray-500">
- 使用时间 + 唯一 ID 游标翻页,数据增长到百万级时不会因深分页越来越慢。
+ 每页显示 50 个会话;打开会话后按时间顺序分页查看消息。
</p>
<Table
:data-source="conversations"
@@ -202,8 +206,10 @@
</template>
</template>
</Table>
- <div v-if="hasMore" class="mt-4 text-center">
- <Button :loading="loading" @click="load(false)">加载更多会话</Button>
+ <div class="mt-4 flex items-center justify-center gap-3">
+ <Button :disabled="page === 0 || loading" @click="load(false, page - 1)">上一页</Button>
+ <span>第 {{ page + 1 }} 页</span>
+ <Button :disabled="!hasMore || loading" @click="load(false, page + 1)">下一页</Button>
</div>
</Card>
@@ -211,7 +217,7 @@
:open="!!selected"
:title="selected?.name"
width="860"
- @close="selected = null"
+ @close="closeConversation"
>
<Empty v-if="!messageLoading && !messages.length" description="暂无消息" />
<div v-for="item in messages" :key="item.id" class="mb-3 border-b border-gray-100 pb-3">
@@ -241,14 +247,14 @@
v-else-if="attachment.media_type === 'video' && mediaUrls[attachment.id]"
class="max-h-[420px] max-w-full rounded bg-black"
controls
- preload="metadata"
+ preload="none"
:src="mediaUrls[attachment.id]"
></video>
<audio
v-else-if="attachment.media_type === 'audio' && isBrowserAudio(attachment.mime_type) && mediaUrls[attachment.id]"
class="w-full"
controls
- preload="metadata"
+ preload="none"
:src="mediaUrls[attachment.id]"
></audio>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
@@ -272,8 +278,10 @@
</div>
</div>
</div>
- <div v-if="messageHasMore" class="mt-3 text-center">
- <Button :loading="messageLoading" @click="loadMessages(false)">加载更早消息</Button>
+ <div class="mt-3 flex items-center justify-center gap-3">
+ <Button :disabled="messagePage === 0 || messageLoading" @click="loadMessages(messagePage - 1)">上一页</Button>
+ <span>第 {{ messagePage + 1 }} 页 · 每页 100 条</span>
+ <Button :disabled="!messageHasMore || messageLoading" @click="loadMessages(messagePage + 1)">下一页</Button>
</div>
</Drawer>
</div>
--- a/admin-web/apps/web-antd/src/views/archive/exports.vue
+++ b/admin-web/apps/web-antd/src/views/archive/exports.vue
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ArchiveExportJob } from '#/api/archive';
-import { onUnmounted, reactive, ref } from 'vue';
+import { onActivated, onDeactivated, onUnmounted, reactive, ref } from 'vue';
import { downloadFileFromBlob } from '@vben/utils';
import {
@@ -24,25 +24,29 @@
} from '#/api/archive';
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
-const loading = ref(false);
+import { useArchiveLoad } from './use-archive-load';
+
+const pageRequest = useArchiveLoad();
+const loading = pageRequest.loading;
const creating = ref(false);
const downloading = ref('');
const jobs = ref<ArchiveExportJob[]>([]);
const accountId = ref(0);
const form = reactive({ date_from: '', date_to: '', formats: ['sql', 'xlsx'] });
-let timer: ReturnType<typeof setInterval> | undefined;
+let timer: ReturnType<typeof setTimeout> | undefined;
+let active = true;
function setDate(field: 'date_from' | 'date_to', value: unknown) {
form[field] = typeof value === 'string' ? value : '';
}
async function load() {
- loading.value = true;
- try {
- jobs.value = (await fetchArchiveExports(100, accountId.value)).jobs;
- } finally {
- loading.value = false;
- }
+ if (timer) clearTimeout(timer);
+ await pageRequest.run(
+ (signal) => fetchArchiveExports(100, accountId.value, signal),
+ (data) => { jobs.value = data.jobs; },
+ );
+ schedulePolling();
}
async function createJob() {
@@ -83,21 +87,29 @@
completed: '已完成', failed: '失败', queued: '等待中', running: '处理中',
};
-const startPolling = () => {
- if (timer) clearInterval(timer);
- timer = setInterval(() => {
- if (jobs.value.some((item) => ['queued', 'running'].includes(item.status))) load();
+function schedulePolling() {
+ if (timer) clearTimeout(timer);
+ if (!active || !jobs.value.some((item) => ['queued', 'running'].includes(item.status))) return;
+ timer = setTimeout(() => {
+ if (loading.value || document.visibilityState === 'hidden') schedulePolling();
+ else void load();
}, 3000);
-};
-startPolling();
-onUnmounted(() => timer && clearInterval(timer));
+}
+function stopPolling() {
+ active = false;
+ if (timer) clearTimeout(timer);
+ pageRequest.cancel();
+}
+onActivated(() => { active = true; schedulePolling(); });
+onDeactivated(stopPolling);
+onUnmounted(stopPolling);
</script>
<template>
<div class="p-5">
<Card title="新建导出" class="mb-4">
<template #extra>
- <ArchiveAccountSelector v-model="accountId" @change="load" />
+ <ArchiveAccountSelector v-model="accountId" @change="() => { jobs = []; load(); }" />
</template>
<Form layout="inline">
<Form.Item label="格式">
--- a/admin-web/apps/web-antd/src/views/archive/overview.vue
+++ b/admin-web/apps/web-antd/src/views/archive/overview.vue
@@ -8,17 +8,19 @@
import { fetchArchiveStats } from '#/api/archive';
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
-const loading = ref(false);
+import { useArchiveLoad } from './use-archive-load';
+
+const pageRequest = useArchiveLoad();
+const loading = pageRequest.loading;
const stats = ref<ArchiveStats | null>(null);
const accountId = ref(0);
async function load() {
- loading.value = true;
- try {
- stats.value = await fetchArchiveStats(accountId.value);
- } finally {
- loading.value = false;
- }
+ stats.value = null;
+ await pageRequest.run(
+ (signal) => fetchArchiveStats(accountId.value, signal),
+ (data) => { stats.value = data; },
+ );
}
</script>
--- a/admin-web/apps/web-antd/src/views/archive/people.vue
+++ b/admin-web/apps/web-antd/src/views/archive/people.vue
@@ -33,7 +33,10 @@
} from '#/api/archive';
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
-const loading = ref(false);
+import { useArchiveLoad } from './use-archive-load';
+
+const pageRequest = useArchiveLoad();
+const loading = pageRequest.loading;
const binding = ref(false);
const patientBinding = ref(false);
const patientSearching = ref(false);
@@ -62,14 +65,11 @@
});
async function load() {
- loading.value = true;
- try {
- people.value = (
- await fetchArchivePeople(200, keyword.value, accountId.value)
- ).items;
- } finally {
- loading.value = false;
- }
+ people.value = [];
+ await pageRequest.run(
+ (signal) => fetchArchivePeople(200, keyword.value, accountId.value, signal),
+ (data) => { people.value = data.items; },
+ );
}
async function openPerson(record: Record<string, any>) {
--- a/admin-web/apps/web-antd/src/views/archive/storage.vue
+++ b/admin-web/apps/web-antd/src/views/archive/storage.vue
@@ -25,7 +25,10 @@
} from '#/api/archive';
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
-const loading = ref(false);
+import { useArchiveLoad } from './use-archive-load';
+
+const pageRequest = useArchiveLoad();
+const loading = pageRequest.loading;
const saving = ref(false);
const testing = ref(false);
const mediaItems = ref<ArchiveMedia[]>([]);
@@ -46,17 +49,17 @@
});
async function load() {
- loading.value = true;
- try {
- const [storage, media] = await Promise.all([
- fetchArchiveStorage(),
- fetchArchiveMedia(100, accountId.value),
- ]);
- Object.assign(form, storage.storage, { secret_id: '', secret_key: '' });
- mediaItems.value = media.items;
- } finally {
- loading.value = false;
- }
+ mediaItems.value = [];
+ await pageRequest.run(
+ (signal) => Promise.all([
+ fetchArchiveStorage(signal),
+ fetchArchiveMedia(100, accountId.value, signal),
+ ]),
+ ([storage, media]) => {
+ Object.assign(form, storage.storage, { secret_id: '', secret_key: '' });
+ mediaItems.value = media.items;
+ },
+ );
}
async function save() {
--- /dev/null
+++ b/wechat_rpa/test_archive_performance.py
@@ -0,0 +1,219 @@
+"""Bounded archive queries and event-loop responsiveness with synthetic data."""
+from __future__ import annotations
+
+import asyncio
+import json
+import sqlite3
+import tempfile
+import threading
+import time
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest import mock
+
+import httpx
+from fastapi import FastAPI
+
+from admin_backend import Database
+from archive_api import register_archive_routes
+from archive_store import ArchiveStore
+
+
+class MeasuredDatabase(Database):
+ max_steps = 0
+ statements: list[str] | None = None
+
+ def connect(self):
+ db = super().connect()
+ if self.statements is not None:
+ db.set_trace_callback(self.statements.append)
+ if self.max_steps:
+ steps = 0
+ def progress():
+ nonlocal steps
+ steps += 1000
+ return steps > self.max_steps
+ db.set_progress_handler(progress, 1000)
+ return db
+
+
+def seed_archive(database, count=120_000):
+ now = "2026-09-14T00:00:00.000+00:00"
+ with database.connect() as db:
+ for tenant in ("default", "other"):
+ db.execute("""INSERT INTO archive_source_account
+ (id,tenant_id,external_account_id,created_at,updated_at)
+ VALUES (?,?,?,?,?)""", (tenant, tenant, tenant, now, now))
+ db.executemany("""INSERT INTO archive_person
+ (id,tenant_id,display_name,created_at,updated_at) VALUES (?,?,?,?,?)""",
+ ((f"p{i:06}", "other" if i%2 else "default", f"Person {i}", now, now)
+ for i in range(2000)))
+ db.executemany("""INSERT INTO archive_conversation
+ (id,tenant_id,source_account_id,external_id,name,last_message_at,created_at,updated_at)
+ VALUES (?,?,?,?,?,?,?,?)""",
+ ((f"c{i:06}", "other" if i%2 else "default", "other" if i%2 else "default",
+ str(i), f"Conversation {i}", now, now, now) for i in range(2000)))
+ db.executemany("""INSERT INTO archive_message
+ (id,tenant_id,conversation_id,source_account_id,sender_person_id,
+ sent_at,dedup_key,created_at,updated_at,content,message_type)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
+ ((f"m{i:09}", "other" if i%2 else "default", f"c{i%2000:06}",
+ "other" if i%2 else "default", f"p{i%2000:06}", now, str(i), now, now,
+ f"Synthetic message {i}", "text") for i in range(count)))
+ db.executemany("""INSERT INTO archive_export_job
+ (id,tenant_id,status,formats_json,filters_json,cutoff_at,created_at)
+ VALUES (?,? ,'completed','["csv"]','{}',?,?)""",
+ ((f"job{i:04}", "default", now, now) for i in range(100)))
+
+
+class ArchivePerformanceTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.temp = tempfile.TemporaryDirectory()
+ cls.database = MeasuredDatabase(Path(cls.temp.name)/"archive.db")
+ cls.database.initialize("", seed_admin=False)
+ cls.store = ArchiveStore(cls.database)
+ cls.store.initialize()
+ seed_archive(cls.database)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.temp.cleanup()
+
+ def tearDown(self):
+ self.database.max_steps = 0
+ self.database.statements = None
+
+ def test_page_queries_do_not_rescan_all_messages_per_item(self):
+ # VM work budget is deterministic across slow/fast test machines.
+ self.database.max_steps = 500_000
+ page = self.store.conversations()
+ self.assertEqual(len(page["items"]), 50)
+ self.assertTrue(page["has_more"])
+ self.assertTrue(all(x["message_count"] == 60 for x in page["items"]))
+ self.assertTrue(all(int(x["external_id"])%2 == 0 for x in page["items"]))
+ people = self.store.people(200)
+ self.assertEqual(len(people), 200)
+ self.assertTrue(all(x["message_count"] == 60 for x in people))
+
+ def test_cursor_ties_have_no_duplicates_or_missing_rows(self):
+ ids = []
+ cursor = ""
+ while True:
+ page = self.store.conversations(200, cursor)
+ ids.extend(item["id"] for item in page["items"])
+ if not page["has_more"]:
+ break
+ cursor = page["next_cursor"]
+ self.assertEqual(len(ids), 1000)
+ self.assertEqual(len(set(ids)), 1000)
+ self.assertEqual(ids, sorted(ids, reverse=True))
+ first = self.store.messages(ids[0], 30)
+ second = self.store.messages(ids[0], 30, first["next_cursor"])
+ self.assertEqual(len(first["items"])+len(second["items"]), 60)
+ self.assertFalse(second["has_more"])
+ self.assertFalse({x["id"] for x in first["items"]}&{x["id"] for x in second["items"]})
+
+ def test_stats_tenant_isolation_and_latest_timestamp(self):
+ result = self.store.stats()
+ self.assertEqual(result["messages"], 60_000)
+ self.assertEqual(result["conversations"], 1000)
+ self.assertEqual(result["people"], 1000)
+ self.assertEqual(self.store.stats("absent")["messages"], 0)
+ self.assertEqual(result["last_message_at"], "2026-09-14T00:00:00.000+00:00")
+
+ def test_export_list_uses_two_queries_for_one_hundred_jobs(self):
+ self.database.statements = []
+ self.assertEqual(len(self.store.export_jobs(100)), 100)
+ selects = [s for s in self.database.statements if s.lstrip().upper().startswith("SELECT")]
+ self.assertEqual(len(selects), 2)
+ self.assertEqual(self.store.export_jobs(100, "other"), [])
+
+ def test_export_order_uses_index_without_full_sort(self):
+ self.database.statements = []
+ iterator = self.store._message_rows({}, "9999", batch_size=20)
+ rows = [next(iterator) for _ in range(20)]
+ iterator.close()
+ query = next(s for s in self.database.statements if "AS conversation_name" in s)
+ with self.database.connect() as db:
+ plan = [row[3] for row in db.execute("EXPLAIN QUERY PLAN "+query)]
+ self.assertFalse(any("TEMP B-TREE" in detail for detail in plan), plan)
+ self.assertEqual(len(rows), 20)
+
+
+class ArchiveConcurrencyTest(unittest.TestCase):
+ def test_slow_archive_io_does_not_block_unrelated_requests(self):
+ async def scenario():
+ principal = SimpleNamespace(id=1)
+ def require(*codes):
+ return lambda: principal
+ database = mock.Mock()
+ with mock.patch("archive_api.ArchiveStore") as factory:
+ store = factory.return_value
+ store.storage_config.return_value = {"enabled": False}
+ app = FastAPI()
+ register_archive_routes(app, database, lambda: principal, require,
+ lambda request: "127.0.0.1", lambda: {"tenant_id":"default"},
+ lambda principal, account_id: "default")
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
+ cases = [
+ ("conversations", "GET", "/api/v2/archive/conversations", None, {"items":[]}),
+ ("import_messages", "POST", "/api/v2/archive/imports/messages",
+ {"source_account":{},"messages":[{}]}, {}),
+ ("test_storage", "POST", "/api/v2/archive/storage/test", None, {"ok":True}),
+ ]
+ for method, verb, path, body, result in cases:
+ entered, release = threading.Event(), threading.Event()
+ def slow(*args, **kwargs):
+ entered.set()
+ release.wait(2)
+ return result
+ getattr(store, method).side_effect = slow
+ request = asyncio.create_task(client.request(verb, path, json=body))
+ try:
+ self.assertTrue(await asyncio.to_thread(entered.wait, 3))
+ response = await asyncio.wait_for(client.get("/api/v2/archive/storage"), 1)
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(request.done(), method+" blocked the event loop")
+ finally:
+ release.set()
+ await request
+ asyncio.run(scenario())
+
+ def test_queued_exports_do_not_exhaust_api_workers(self):
+ async def scenario():
+ principal = SimpleNamespace(id=1)
+ def require(*codes):
+ return lambda: principal
+ entered, release = threading.Event(), threading.Event()
+ calls = []
+ def export(job_id):
+ calls.append(job_id)
+ entered.set()
+ release.wait(3)
+ with mock.patch("archive_api.ArchiveStore") as factory:
+ store = factory.return_value
+ store.create_export_job.return_value = {"id":"job"}
+ store.run_export_job.side_effect = export
+ store.storage_config.return_value = {"enabled":False}
+ app = FastAPI()
+ register_archive_routes(app, mock.Mock(), lambda: principal, require,
+ lambda request:"127.0.0.1", lambda: {}, lambda p,a:"default")
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url="http://test") as client:
+ requests = [asyncio.create_task(client.post("/api/v2/archive/exports",json={"formats":["csv"]})) for _ in range(45)]
+ try:
+ self.assertTrue(await asyncio.to_thread(entered.wait,3))
+ response = await asyncio.wait_for(client.get("/api/v2/archive/storage"),1)
+ self.assertEqual(response.status_code,200)
+ self.assertEqual(len(calls),1)
+ finally:
+ release.set()
+ results = await asyncio.gather(*requests)
+ self.assertTrue(all(item.status_code==200 for item in results))
+ self.assertEqual(len(calls),45)
+ asyncio.run(scenario())
+
+
+if __name__ == "__main__":
+ unittest.main()
--- /dev/null
+++ b/admin-web/apps/web-antd/src/views/archive/use-archive-load.ts
@@ -0,0 +1,32 @@
+import { onDeactivated, onScopeDispose, ref } from 'vue';
+
+/** Only the current page/account request may update the view. */
+export function useArchiveLoad() {
+ const loading = ref(false);
+ let controller: AbortController | undefined;
+
+ function cancel() {
+ controller?.abort();
+ controller = undefined;
+ loading.value = false;
+ }
+
+ async function run<T>(fetcher: (signal: AbortSignal) => Promise<T>, apply: (data: T) => void) {
+ cancel();
+ const request = new AbortController();
+ controller = request;
+ loading.value = true;
+ try {
+ const data = await fetcher(request.signal);
+ if (controller === request && !request.signal.aborted) apply(data);
+ } catch {
+ // The request interceptor reports real failures; canceled requests stay silent.
+ } finally {
+ if (controller === request) loading.value = false;
+ }
+ }
+
+ onDeactivated(cancel);
+ onScopeDispose(cancel);
+ return { cancel, loading, run };
+}
--- /dev/null
+++ b/admin-web/apps/web-antd/src/views/archive/use-archive-load.test.ts
@@ -0,0 +1,65 @@
+import { createApp, defineComponent, h, nextTick } from 'vue';
+import { describe, expect, it, vi } from 'vitest';
+
+import { useArchiveLoad } from './use-archive-load';
+
+function mountRequest() {
+ let request!: ReturnType<typeof useArchiveLoad>;
+ const app = createApp(defineComponent({
+ setup() { request = useArchiveLoad(); return () => h('div'); },
+ }));
+ app.mount(document.createElement('div'));
+ return { app, request };
+}
+
+describe('archive request lifecycle', () => {
+ it('cancels the old account and ignores responses arriving out of order', async () => {
+ const { app, request } = mountRequest();
+ let resolveOld!: (value: string) => void;
+ let oldSignal!: AbortSignal;
+ const displayed: string[] = [];
+ const old = request.run((signal) => {
+ oldSignal = signal;
+ return new Promise<string>((resolve) => { resolveOld = resolve; });
+ }, (data) => displayed.push(data));
+ await request.run(async () => 'new account', (data) => displayed.push(data));
+ expect(oldSignal.aborted).toBe(true);
+ resolveOld('old account');
+ await old;
+ expect(displayed).toEqual(['new account']);
+ expect(request.loading.value).toBe(false);
+ app.unmount();
+ });
+
+ it('keeps the spinner for the current request when a canceled request finishes', async () => {
+ const { app, request } = mountRequest();
+ let oldDone!: () => void;
+ let newDone!: () => void;
+ const apply = vi.fn();
+ const old = request.run(() => new Promise<void>((resolve) => { oldDone = resolve; }), apply);
+ const current = request.run(() => new Promise<void>((resolve) => { newDone = resolve; }), apply);
+ oldDone(); await old;
+ expect(request.loading.value).toBe(true);
+ newDone(); await current;
+ expect(request.loading.value).toBe(false);
+ expect(apply).toHaveBeenCalledTimes(1);
+ app.unmount();
+ });
+
+ it('aborts network work and blocks state updates after leaving the page', async () => {
+ const { app, request } = mountRequest();
+ let signal!: AbortSignal;
+ let done!: (value: number) => void;
+ const apply = vi.fn();
+ const pending = request.run((value) => {
+ signal = value;
+ return new Promise<number>((resolve) => { done = resolve; });
+ }, apply);
+ app.unmount();
+ await nextTick();
+ expect(signal.aborted).toBe(true);
+ done(1); await pending;
+ expect(apply).not.toHaveBeenCalled();
+ expect(request.loading.value).toBe(false);
+ });
+});