1473 lines
56 KiB
Diff
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);
|
|
+ });
|
|
+});
|