Переглянути джерело

feat(订单实时): useLiveRefetch —— busy 挂起 / 静默补刷 / 500ms 合并

alphaH 1 тиждень тому
батько
коміт
047cdd1545

+ 300 - 0
frontend/scripts/verify-live-refetch.mjs

@@ -0,0 +1,300 @@
+#!/usr/bin/env node
+/**
+ * useLiveRefetch(挂起/补刷/合并)+ 应用单例(URL 拼装、登出停流)的执行级验证。
+ * 用法:cd frontend && node scripts/verify-live-refetch.mjs
+ *
+ * 量具:esbuild 把真 TS + 真 vue 打成一个临时 bundle(vue 必须**打进同一份**,
+ * 否则脚本里的 ref 与 bundle 里的 watch 分属两个 vue 实例、依赖收集是断的)。
+ */
+import { build } from "esbuild";
+import assert from "node:assert/strict";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import fs from "node:fs/promises";
+import os from "node:os";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, "..");
+const modulePath = path.join(root, "src/composables/useLiveRefetch.ts").replaceAll("\\", "/");
+// 单例(orderStream / stopOrderStream)按 Interfaces 住在 orderStreamInstance.ts —— 它不在
+// useLiveRefetch.ts 的导出里,所以要显式再导一份进 bundle。
+const instancePath = path.join(root, "src/utils/orderStreamInstance.ts").replaceAll("\\", "/");
+
+// Auth 链会读 localStorage/sessionStorage:装壳(模块 import 期不读,保险起见先装)
+const makeStore = () => {
+  const map = new Map();
+  return {
+    getItem: k => (map.has(k) ? map.get(k) : null),
+    setItem: (k, v) => map.set(k, String(v)),
+    removeItem: k => map.delete(k),
+    clear: () => map.clear()
+  };
+};
+const localStore = makeStore();
+globalThis.localStorage = localStore;
+globalThis.sessionStorage = makeStore();
+
+// 单例那条腿要用真 `new EventSource(...)`:换成假件,把 URL 抓下来
+const eventSources = [];
+globalThis.EventSource = class {
+  constructor(url) {
+    this.url = url;
+    this.closed = false;
+    this.listeners = new Map();
+    eventSources.push(this);
+  }
+  addEventListener(type, fn) {
+    if (!this.listeners.has(type)) this.listeners.set(type, []);
+    this.listeners.get(type).push(fn);
+  }
+  close() {
+    this.closed = true;
+  }
+};
+
+const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "verify-live-refetch-"));
+const bundle = path.join(tmp, "bundle.mjs");
+
+await build({
+  stdin: {
+    contents: `
+      export * from ${JSON.stringify(modulePath)};
+      export * from ${JSON.stringify(instancePath)};
+      export { ref, nextTick } from "vue";
+    `,
+    resolveDir: root, // 从 frontend/ 解析:vue 与 "@/..." 都从这里走
+    sourcefile: "verify-entry.mjs",
+    loader: "js"
+  },
+  outfile: bundle,
+  bundle: true,
+  format: "esm",
+  platform: "neutral",
+  target: "node22",
+  logLevel: "error",
+  alias: { "@": path.join(root, "src") },
+  define: {
+    "import.meta.env.VITE_APP_BASE_API": JSON.stringify("/api/v1"),
+    __VUE_OPTIONS_API__: "false",
+    __VUE_PROD_DEVTOOLS__: "false",
+    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false"
+  }
+});
+
+const { useLiveRefetch, orderStream, stopOrderStream, ref, nextTick } = await import(pathToFileURL(bundle).href);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function fakeStream() {
+  const subs = new Map();
+  const resyncs = new Set();
+  let unsubscribed = 0;
+  return {
+    subscribe(channel, fn) {
+      if (!subs.has(channel)) subs.set(channel, new Set());
+      subs.get(channel).add(fn);
+      return () => {
+        subs.get(channel)?.delete(fn);
+        unsubscribed += 1;
+      };
+    },
+    onResync(fn) {
+      resyncs.add(fn);
+      return () => resyncs.delete(fn);
+    },
+    stop() {},
+    isRunning: () => true,
+    emit(channel) {
+      for (const fn of subs.get(channel) || []) fn();
+    },
+    fireResync() {
+      for (const fn of resyncs) fn();
+    },
+    get channels() {
+      return [...subs.keys()].sort();
+    },
+    get unsubscribed() {
+      return unsubscribed;
+    }
+  };
+}
+
+let failed = 0;
+async function check(name, fn) {
+  try {
+    await fn();
+    console.log("  PASS  " + name);
+  } catch (e) {
+    failed += 1;
+    console.error("  FAIL  " + name + "\n        " + (e && e.message));
+  }
+}
+
+const OPTS = { debounceMs: 5 };
+
+await check("订阅给定的三个通道", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order", "lsy_refund", "har_order"], () => { calls += 1; }, { ...OPTS, stream });
+
+  assert.deepEqual(stream.channels, ["har_order", "lsy_order", "lsy_refund"]);
+  dispose();
+});
+
+await check("本通道的帧 → 合并窗口后刷一次;别的通道不触发", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });
+
+  stream.emit("lsy_refund");
+  await sleep(20);
+  assert.equal(calls, 0, "没订阅的通道不许刷");
+
+  stream.emit("lsy_order");
+  assert.equal(calls, 0, "合并窗口内不许同步刷(同一次写入会来好几帧)");
+  await sleep(20);
+  assert.equal(calls, 1);
+
+  dispose();
+});
+
+await check("窗口内多帧(含重连补拉)只刷一次", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream });
+
+  stream.emit("lsy_order");
+  stream.emit("lsy_order");
+  stream.fireResync();
+  await sleep(20);
+
+  assert.equal(calls, 1, "一次写入的多帧 + 一次重连补拉,合并成一次重拉");
+  dispose();
+});
+
+await check("busy 期间挂起,回安全态立刻补刷一次", async () => {
+  const stream = fakeStream();
+  const busy = ref(false);
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
+
+  busy.value = true;
+  await nextTick();
+
+  stream.emit("lsy_order");
+  stream.emit("lsy_order");
+  await sleep(20);
+  assert.equal(calls, 0, "用户正在勾选/弹窗里操作,列表不许在脚下跳");
+
+  busy.value = false;
+  await nextTick();
+  assert.equal(calls, 1, "回到安全态立刻补刷(不等下一个信号)");
+
+  await sleep(20);
+  assert.equal(calls, 1, "补刷只做一次(pending 要清掉)");
+
+  // 补刷把 pending 消费掉之后,下一轮 busy 翻转(期间**没有**新信号)不许再刷一次。
+  // 没有这一段的话,「pending 永远不清」与「pending 用完即清」两种实现读数完全相同。
+  busy.value = true;
+  await nextTick();
+  busy.value = false;
+  await nextTick();
+  await sleep(20);
+  assert.equal(calls, 1, "上一轮的 pending 已被消费:这一轮没有信号,一次都不该刷");
+  dispose();
+});
+
+await check("busy 翻转但期间没有信号 → 一次都不刷", async () => {
+  const stream = fakeStream();
+  const busy = ref(false);
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
+
+  busy.value = true;
+  await nextTick();
+  busy.value = false;
+  await nextTick();
+  await sleep(20);
+
+  assert.equal(calls, 0, "没漏东西就白拉一次 = 每次关弹窗都打一次接口");
+  dispose();
+});
+
+await check("busy 期间的重连补拉也挂起", async () => {
+  const stream = fakeStream();
+  const busy = ref(true);
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order"], () => { calls += 1; }, { ...OPTS, stream, busy });
+  await nextTick();
+
+  stream.fireResync();
+  await sleep(20);
+  assert.equal(calls, 0);
+
+  busy.value = false;
+  await nextTick();
+  assert.equal(calls, 1);
+  dispose();
+});
+
+await check("refetch 抛错 / reject 都不冒泡,后续帧仍能刷", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(
+    ["lsy_order"],
+    () => {
+      calls += 1;
+      if (calls === 1) throw new Error("同步炸");
+      if (calls === 2) return Promise.reject(new Error("异步炸"));
+      return Promise.resolve();
+    },
+    { ...OPTS, stream }
+  );
+
+  stream.emit("lsy_order");
+  await sleep(20);
+  stream.emit("lsy_order");
+  await sleep(20);
+  stream.emit("lsy_order");
+  await sleep(20);
+
+  assert.equal(calls, 3, "三次都该真的调下去(异常被吞掉,不影响下一次)");
+  dispose();
+});
+
+await check("dispose:退订全部通道、清掉未到期的合并、不再刷", async () => {
+  const stream = fakeStream();
+  let calls = 0;
+  const dispose = useLiveRefetch(["lsy_order", "lsy_refund"], () => { calls += 1; }, { ...OPTS, stream });
+
+  stream.emit("lsy_order");
+  dispose();
+  assert.equal(stream.unsubscribed, 2, "两个通道都要退订");
+
+  await sleep(20);
+  assert.equal(calls, 0, "dispose 时未到期的合并必须被清掉(页面已经卸载了)");
+
+  stream.emit("lsy_order");
+  await sleep(20);
+  assert.equal(calls, 0);
+});
+
+await check("单例:URL 现取令牌拼装 + 登出停流", async () => {
+  // Storage.get 会 JSON.parse,所以按 Storage.set 的写法存(带引号的 JSON 串)
+  localStore.setItem("remember_me", "true");
+  localStore.setItem("access_token", JSON.stringify("tok-123"));
+
+  assert.equal(orderStream(), orderStream(), "必须是同一个实例");
+
+  const off = orderStream().subscribe("lsy_order", () => {});
+  assert.equal(eventSources.length, 1);
+  assert.equal(eventSources[0].url, "/api/v1/payment/stream/orders?token=tok-123");
+
+  stopOrderStream();
+  assert.equal(eventSources[0].closed, true, "登出停流必须真的关连接(token 已清,重连只会被 403 挡回)");
+
+  off();
+});
+
+console.log(failed === 0 ? "\n全部通过" : `\n${failed} 条失败`);
+if (failed > 0) process.exit(1);

+ 4 - 0
frontend/src/composables/index.ts

@@ -2,3 +2,7 @@
 // AI 相关
 // AI 相关
 export { useAiAction } from "./ai/useAiAction";
 export { useAiAction } from "./ai/useAiAction";
 export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
 export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
+
+// 订单实时刷新
+export { useLiveRefetch } from "./useLiveRefetch";
+export type { LiveRefetchOptions } from "./useLiveRefetch";

+ 95 - 0
frontend/src/composables/useLiveRefetch.ts

@@ -0,0 +1,95 @@
+import { getCurrentInstance, onBeforeUnmount, watch, type Ref } from "vue";
+import type { OrderStream } from "@/utils/orderStream";
+import { orderStream } from "@/utils/orderStreamInstance";
+
+export interface LiveRefetchOptions {
+  /** 操作中(勾选/弹窗/提交):true 期间收到信号只记 pending,回到 false 立刻补刷一次 */
+  busy?: Ref<boolean>;
+  /** 信号合并窗口(毫秒)。同一次写入会来好几帧,默认 500ms 内只拉一次 */
+  debounceMs?: number;
+  /** 注入用(验证脚本);不传就用应用单例 */
+  stream?: OrderStream;
+}
+
+const DEFAULT_DEBOUNCE_MS = 500;
+
+/**
+ * 把「后端说有变化」变成「重拉本页列表」。用户零感知:没有状态、没有提示、没有 toast。
+ *
+ * - 后台刷新失败**静默**(调用方的请求要带 `silent: true`,见 Task 10)
+ * - `busy` 期间挂起(用户正在勾选/弹窗里操作,列表不许在脚下跳),回安全态补刷一次
+ * - 重连补拉走同一条路(断线期间漏掉的帧靠它兜)
+ * - 组件卸载自动退订;最后一个订阅者退订时连接自动关(Task 8 的引用计数)
+ */
+export function useLiveRefetch(
+  channels: string[],
+  refetch: () => unknown,
+  options: LiveRefetchOptions = {}
+): () => void {
+  const stream = options.stream ?? orderStream();
+  const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+  const busy = options.busy;
+
+  let pending = false;
+  let timer: ReturnType<typeof setTimeout> | null = null;
+  let disposed = false;
+
+  const run = (): void => {
+    if (disposed) return;
+    timer = null;
+    try {
+      const result = refetch();
+      if (result && typeof (result as Promise<unknown>).then === "function") {
+        (result as Promise<unknown>).catch(() => {
+          /* 后台刷新失败:静默。用户没主动点任何东西,弹窗只会让人困惑 */
+        });
+      }
+    } catch {
+      /* 同上 */
+    }
+  };
+
+  const schedule = (): void => {
+    if (disposed) return;
+    if (busy?.value) {
+      pending = true;
+      return;
+    }
+    if (timer !== null) return; // 合并窗口还没过:这一帧并进那一次
+    timer = setTimeout(run, debounceMs);
+  };
+
+  const offs = channels.map(channel => stream.subscribe(channel, schedule));
+  const offResync = stream.onResync(schedule);
+
+  let stopWatch = (): void => {};
+  if (busy) {
+    stopWatch = watch(busy, isBusy => {
+      if (!isBusy && pending) {
+        pending = false;
+        if (timer !== null) {
+          clearTimeout(timer);
+          timer = null;
+        }
+        run();
+      }
+    });
+  }
+
+  const dispose = (): void => {
+    if (disposed) return;
+    disposed = true;
+    if (timer !== null) {
+      clearTimeout(timer);
+      timer = null;
+    }
+    offs.forEach(off => off());
+    offResync();
+    stopWatch();
+  };
+
+  if (getCurrentInstance()) {
+    onBeforeUnmount(dispose);
+  }
+  return dispose;
+}

+ 28 - 0
frontend/src/utils/orderStreamInstance.ts

@@ -0,0 +1,28 @@
+import { Auth } from "@/utils/auth";
+import { createOrderStream, type OrderStream } from "@/utils/orderStream";
+
+/**
+ * 应用级单例。**懒创建**:导入本模块不产生任何网络行为,第一个订阅者才建连接。
+ *
+ * 令牌**每次(重)连现取** —— 它是滑动过期的,且用户可能重新登录;
+ * 只在建连时算一次,等于拿旧令牌反复撞墙(而重连永远不成功是「页面不实时」里最难查的一种)。
+ */
+let instance: OrderStream | null = null;
+
+export function orderStream(): OrderStream {
+  if (!instance) {
+    instance = createOrderStream({
+      url: () =>
+        `${import.meta.env.VITE_APP_BASE_API}/payment/stream/orders?token=${encodeURIComponent(
+          Auth.getAccessToken() || ""
+        )}`,
+      createEventSource: url => new EventSource(url)
+    });
+  }
+  return instance;
+}
+
+/** 登出时调用:关连接、清定时器,此后不再重连。 */
+export function stopOrderStream(): void {
+  instance?.stop();
+}