Files
bid-doctor/apps/web/src/lib/events.ts
YANG JIANKUAN dd8c04001d
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:06:06 +08:00

93 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 事件流(SSE,用 fetch 读,API2):事件只是「有变化」的提示,以资源的 GET 结果为准。
* 进度事件直接写进解析任务和项目列表的缓存,让进度条即时动;其余事件让相关查询失效、重新请求。
* 断线由 api-client 自动重连;页面上的 5 秒轮询兜底。
*/
import { subscribeEvents, type BdEvent } from "@bid-doctor/api-client";
import type { QueryClient } from "@tanstack/vue-query";
import { apiOptions } from "./api";
import { qk } from "./keys";
import { TERMINAL_JOB, type CheckRun, type ParseJob, type ProjectListItem } from "./queries";
type ProjectPage = { items: ProjectListItem[]; next_cursor: string | null };
function onEvent(qc: QueryClient, e: BdEvent) {
const job = e.job;
const jobId = job?.id;
if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "parse_job") {
// 事件可能比 GET 结果旧(重连补发、乱序):已结束的任务不再被进度事件改回「进行中」,进度只增不减
qc.setQueryData<ParseJob>(qk.parseJob(jobId), (old) =>
!old || TERMINAL_JOB.has(old.status) || (job.progress ?? 0) < old.progress
? old
: {
...old,
status: job.status ?? old.status,
stage: (job.stage as ParseJob["stage"]) ?? old.stage,
progress: job.progress ?? old.progress,
},
);
qc.setQueryData<ProjectPage>(qk.projects, (old) =>
old && {
...old,
items: old.items.map((p) =>
p.active_job && p.active_job.id === jobId && (job.progress ?? 0) >= (p.active_job.progress ?? 0) ? { ...p, active_job: { ...p.active_job, progress: job.progress ?? p.active_job.progress } } : p,
),
},
);
if (e.type === "job.finished") {
// 结束后要拿完整结果(error、result),并刷新项目、额度、清单
void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checklist(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checklistItems(e.project_id) });
}
}
return;
}
if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "check_run") {
qc.setQueryData<CheckRun>(qk.checkRun(jobId), (old) =>
!old || TERMINAL_JOB.has(old.status) || (job.progress ?? 0) < old.progress
? old
: { ...old, status: job.status ?? old.status, stage: (job.stage as CheckRun["stage"]) ?? old.stage, progress: job.progress ?? old.progress },
);
if (e.type === "job.finished") {
void qc.invalidateQueries({ queryKey: qk.checkRun(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.findings(jobId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checkPreview(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checkRuns(e.project_id) });
}
}
return;
}
switch (e.type) {
case "quota.changed":
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.notifications });
break;
case "project.changed":
void qc.invalidateQueries({ queryKey: qk.projects });
if (e.project_id) void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
break;
case "resync":
void qc.invalidateQueries();
break;
default:
// notification.created、report.ready:骨架阶段还没有对应界面
break;
}
}
/** 登录后的页面框架挂载时开始订阅,返回取消函数。 */
export function startEventStream(qc: QueryClient): () => void {
return subscribeEvents(apiOptions, (e) => onEvent(qc, e));
}