realtime-probe.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. export class ProbeError extends Error {
  2. constructor(message, status = 0) { super(message); this.status = status; }
  3. }
  4. export function apiAddress(value) {
  5. let url;
  6. try { url = new URL(value.trim()); } catch { throw new ProbeError('请填写完整 API 地址,例如 https://api.example.com'); }
  7. if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
  8. throw new ProbeError('API 地址只允许 http/https 域名和路径,不要包含账号、查询参数或 #。');
  9. }
  10. return url.href.replace(/\/+$/, '');
  11. }
  12. export function apiEndpoint(base, path) {
  13. if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
  14. throw new ProbeError('接口路径无效,请检查后端配置。');
  15. }
  16. const url = new URL(`${base}${path}`);
  17. if (url.origin !== new URL(base).origin) throw new ProbeError('授权接口必须与填写的 API 地址同源。');
  18. return url.href;
  19. }
  20. export function redact(value, token = '', depth = 0) {
  21. if (depth > 8) return '[内容层级过深]';
  22. if (typeof value === 'string') {
  23. return (token ? value.split(token).join('[已隐藏 token]') : value).replace(/Bearer\s+[^\s"<>]+/gi, 'Bearer [已隐藏]');
  24. }
  25. if (Array.isArray(value)) return value.slice(0, 100).map((v) => redact(v, token, depth + 1));
  26. if (value && typeof value === 'object') {
  27. return Object.fromEntries(Object.entries(value).map(([key, v]) => [key,
  28. /authorization|password|secret|token|ticket/i.test(key) ? '[已隐藏]' : redact(v, token, depth + 1)]));
  29. }
  30. return value;
  31. }
  32. export async function requestJSON(url, { method = 'GET', token, body, signal } = {}) {
  33. let response;
  34. try {
  35. response = await fetch(url, {
  36. method, signal, credentials: 'omit', cache: 'no-store', redirect: 'error',
  37. headers: { Accept: 'application/json', Authorization: `Bearer ${token}`,
  38. ...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}) },
  39. ...(body ? { body: new URLSearchParams(body) } : {}),
  40. });
  41. } catch (error) {
  42. if (error.name === 'AbortError') throw error;
  43. throw new ProbeError('请求未能完成:请检查 API 地址、HTTPS 证书和跨域配置。');
  44. }
  45. let data;
  46. try { data = await response.json(); } catch {
  47. throw new ProbeError(`接口未返回 JSON(HTTP ${response.status}),请检查 API 地址和站点转发配置。`, response.status);
  48. }
  49. if (!response.ok) throw new ProbeError(data.msg || data.message || `HTTP ${response.status}`, response.status);
  50. return data;
  51. }
  52. const EVENTS = ['order.created', 'order.updated', 'order.deleted', 'user.created', 'user.online', 'user.offline', 'stats.updated'];
  53. export class RealtimeProbe {
  54. constructor({ Echo, Pusher, request = requestJSON, onChange = () => {}, onLog = () => {}, onEvent = () => {} }) {
  55. Object.assign(this, { Echo, Pusher, request, onChange, onLog, onEvent });
  56. this.epoch = 0;
  57. this.session = 0;
  58. this.controllers = new Set();
  59. this.channels = new Map();
  60. this.seen = new Set();
  61. this.state = this.emptyState();
  62. this.token = '';
  63. }
  64. emptyState() {
  65. return { connection: 'disconnected', api: 'idle', config: null, socketId: '',
  66. liveCount: 0, replayCount: 0, duplicateCount: 0, lastSeq: 0, expired: false, channels: [] };
  67. }
  68. emit() {
  69. this.state.channels = [...this.channels.values()].map(({ name, status, checkpoint, liveSeq, pendingAfter, replaying }) =>
  70. ({ name, status, checkpoint, liveSeq, pendingAfter, replaying }));
  71. this.onChange(this.state);
  72. }
  73. log(level, message, details) {
  74. this.onLog({ level, message: redact(message, this.token), details: redact(details, this.token), time: Date.now() });
  75. }
  76. invalidate() {
  77. this.disconnect({ quiet: true });
  78. this.session++;
  79. this.token = '';
  80. this.channels.clear();
  81. this.seen.clear();
  82. this.state = this.emptyState();
  83. this.emit();
  84. }
  85. clearCounts() {
  86. this.state.liveCount = this.state.replayCount = this.state.duplicateCount = 0;
  87. this.emit();
  88. }
  89. async get(path, options = {}) {
  90. const controller = new AbortController();
  91. this.controllers.add(controller);
  92. const timeout = setTimeout(() => controller.abort(), 15000);
  93. try {
  94. return await this.request(apiEndpoint(this.base, path), { token: this.token, signal: controller.signal, ...options });
  95. } finally {
  96. clearTimeout(timeout);
  97. this.controllers.delete(controller);
  98. }
  99. }
  100. disconnect({ quiet = false } = {}) {
  101. this.epoch++;
  102. for (const channel of this.channels.values()) {
  103. channel.pendingAfter = Math.min(channel.pendingAfter ?? channel.checkpoint, channel.checkpoint);
  104. channel.request = null;
  105. channel.replaying = false;
  106. channel.status = 'idle';
  107. }
  108. for (const controller of this.controllers) controller.abort();
  109. this.controllers.clear();
  110. const echo = this.echo;
  111. this.echo = null;
  112. echo?.disconnect();
  113. this.state.connection = 'disconnected';
  114. this.state.socketId = '';
  115. if (!quiet) this.log('info', '已断开连接;续传点已保留,可再次连接验证断线补发。');
  116. this.emit();
  117. }
  118. async configure(base, token) {
  119. this.disconnect({ quiet: true });
  120. this.session++;
  121. const session = this.session;
  122. this.channels.clear();
  123. this.seen.clear();
  124. this.token = '';
  125. this.state = this.emptyState();
  126. this.state.api = 'loading';
  127. this.emit();
  128. this.log('info', '正在读取后台实时推送配置。');
  129. try {
  130. this.base = apiAddress(base);
  131. this.token = token.trim().replace(/^Bearer\s+/i, '').trim();
  132. if (!this.token || /\s/.test(this.token)) throw new ProbeError('请填写有效的后台管理员 token,可带 Bearer 前缀。');
  133. const response = await this.get('/admin/realtime/config');
  134. if (session !== this.session) return null;
  135. if (response.code !== 0) throw new ProbeError(response.msg || '后台未返回成功状态,请检查 token 或推送开关。');
  136. const config = response.data;
  137. if (!config || typeof config.key !== 'string' || !config.key || typeof config.ws_host !== 'string' || !config.ws_host
  138. || !Array.isArray(config.channels) || !config.channels.every((c) => typeof c === 'string')
  139. || !Number.isInteger(config.ws_port) || config.ws_port < 1 || config.ws_port > 65535
  140. || typeof config.force_tls !== 'boolean' || !Number.isSafeInteger(config.last_seq) || config.last_seq < 0) {
  141. throw new ProbeError('连接参数不完整,请检查 REVERB_APP_KEY、ADMIN_WS_PUBLIC_HOST / PORT / SCHEME。');
  142. }
  143. const wsURL = new URL(`${config.force_tls ? 'wss' : 'ws'}://${config.ws_host}:${config.ws_port}`);
  144. if (wsURL.username || wsURL.password || wsURL.pathname !== '/' || wsURL.search || wsURL.hash) {
  145. throw new ProbeError('WebSocket host 应只填写域名,不带协议或路径。');
  146. }
  147. apiEndpoint(this.base, config.auth_endpoint);
  148. this.state.config = {
  149. key: config.key, ws_host: config.ws_host, ws_port: config.ws_port, force_tls: config.force_tls,
  150. auth_endpoint: config.auth_endpoint, last_seq: config.last_seq,
  151. session_expires_at: config.session_expires_at || null,
  152. channels: [...new Set(config.channels)],
  153. };
  154. this.state.lastSeq = config.last_seq;
  155. this.state.api = 'ok';
  156. this.log('success', `配置读取成功,当前账号可订阅 ${this.state.config.channels.length} 个频道。`);
  157. this.emit();
  158. return this.state.config;
  159. } catch (error) {
  160. if (session !== this.session) return null;
  161. this.state.api = 'error';
  162. this.log('error', error.name === 'AbortError' ? '配置请求已取消或超时,请重试。' : error.message);
  163. this.emit();
  164. throw error;
  165. }
  166. }
  167. connect(names) {
  168. if (!this.state.config || !this.token) throw new ProbeError('请先获取连接配置。');
  169. if (this.state.expired) throw new ProbeError('登录已过期,请更新 token 后重新获取配置。');
  170. if (typeof this.Echo !== 'function' || typeof this.Pusher !== 'function') throw new ProbeError('本地客户端文件加载失败,请检查 static/vendor/realtime 文件是否已上传。');
  171. if (globalThis.location?.protocol === 'https:' && !this.state.config.force_tls) {
  172. throw new ProbeError('当前页面是 HTTPS,但后端配置了 ws。请将 ADMIN_WS_PUBLIC_SCHEME 配置为 https。');
  173. }
  174. names = [...new Set(names)].filter((name) => this.state.config.channels.includes(name));
  175. if (!names.length) throw new ProbeError('请至少选择一个有权限的频道。');
  176. this.disconnect({ quiet: true });
  177. const epoch = this.epoch;
  178. const c = this.state.config;
  179. this.state.connection = 'connecting';
  180. this.echo = new this.Echo({
  181. broadcaster: 'reverb', Pusher: this.Pusher, key: c.key,
  182. wsHost: c.ws_host, wsPort: c.ws_port, wssPort: c.ws_port,
  183. forceTLS: c.force_tls, enabledTransports: ['ws', 'wss'], enableStats: false, disableStats: true,
  184. authorizer: (channel) => ({ authorize: (socketId, callback) => this.authorize(channel.name, socketId, callback, epoch) }),
  185. });
  186. const echo = this.echo;
  187. echo.connector.pusher.connection.bind('state_change', ({ current }) => {
  188. if (this.echo !== echo || epoch !== this.epoch) return;
  189. this.state.connection = current;
  190. if (current === 'connected') {
  191. this.state.socketId = echo.socketId() || '';
  192. this.log('success', 'WebSocket 握手成功。接下来检查频道授权。');
  193. } else {
  194. for (const channel of this.channels.values()) {
  195. channel.pendingAfter = Math.min(channel.pendingAfter ?? channel.checkpoint, channel.checkpoint);
  196. channel.request = null;
  197. channel.replaying = false;
  198. channel.status = 'idle';
  199. }
  200. this.log(current === 'failed' || current === 'unavailable' ? 'error' : 'info', `连接状态:${current}`);
  201. }
  202. this.emit();
  203. });
  204. echo.connector.pusher.connection.bind('error', (error) => {
  205. if (this.echo === echo) this.log('error', 'WebSocket 连接错误,请检查域名、Nginx、证书和 Reverb 进程。', error);
  206. });
  207. this.subscribe(names);
  208. this.emit();
  209. }
  210. async authorize(channelName, socketId, callback, epoch) {
  211. const name = channelName.replace(/^private-/, '');
  212. const channel = this.channels.get(name);
  213. if (channel) channel.status = 'authorizing';
  214. this.emit();
  215. try {
  216. const result = await this.get(this.state.config.auth_endpoint, { method: 'POST', body: { socket_id: socketId, channel_name: channelName } });
  217. if (epoch !== this.epoch || this.echo?.socketId() !== socketId) return;
  218. if (typeof result.auth !== 'string' || !result.auth) throw new ProbeError('授权接口缺少 auth 字段,请检查请求是否到达正确的 API。');
  219. callback(null, result);
  220. } catch (error) {
  221. if (epoch !== this.epoch || this.echo?.socketId() !== socketId) return;
  222. if (channel) channel.status = error.status === 403 ? 'denied' : 'error';
  223. const message = error.status === 401 ? '登录已过期或 token 无效,请重新登录并更新 token。'
  224. : error.status === 403 ? '频道授权被拒绝,请检查账号权限或后台推送开关。' : error.message;
  225. this.log('error', `${name}:${message}`);
  226. callback(error, null);
  227. if (error.status === 401) { this.state.expired = true; this.disconnect({ quiet: true }); }
  228. this.emit();
  229. }
  230. }
  231. subscribe(names) {
  232. if (!this.echo) throw new ProbeError('请先连接 WebSocket。');
  233. const allowed = this.state.config.channels;
  234. const wanted = new Set(names.filter((name) => allowed.includes(name)));
  235. for (const [name, channel] of this.channels) {
  236. if (!wanted.has(name)) {
  237. this.echo.leave(name);
  238. channel.active = false;
  239. channel.binding = null;
  240. channel.status = 'idle'; channel.request = null; channel.replaying = false;
  241. }
  242. }
  243. const echo = this.echo;
  244. const epoch = this.epoch;
  245. for (const name of wanted) {
  246. let channel = this.channels.get(name);
  247. if (channel?.binding?.echo === echo && channel.active) continue;
  248. if (!channel) {
  249. channel = { name, checkpoint: this.state.config.last_seq, liveSeq: 0, pendingAfter: this.state.config.last_seq };
  250. this.channels.set(name, channel);
  251. }
  252. channel.status = 'subscribing';
  253. channel.active = true;
  254. const sub = echo.private(name);
  255. const binding = { echo, sub };
  256. channel.binding = binding;
  257. EVENTS.forEach((event) => sub.listen(`.${event}`, (message) => {
  258. if (this.echo !== echo || epoch !== this.epoch || !channel.active || channel.binding !== binding) return;
  259. if (message.channel !== name) { this.log('warning', '消息频道与订阅频道不一致,未计入检测结果。'); return; }
  260. channel.liveSeq = Math.max(channel.liveSeq, message.seq || 0);
  261. if (channel.pendingAfter === null) channel.checkpoint = Math.max(channel.checkpoint, channel.liveSeq);
  262. this.receive(message, 'live');
  263. }));
  264. sub.subscribed(() => {
  265. if (this.echo !== echo || epoch !== this.epoch || !channel.active || channel.binding !== binding) return;
  266. channel.status = 'subscribed';
  267. this.log('success', `${name} 订阅成功。`);
  268. this.emit();
  269. this.replay([name]);
  270. });
  271. sub.error((error) => {
  272. if (this.echo !== echo || epoch !== this.epoch || !channel.active || channel.binding !== binding) return;
  273. channel.status = error.status === 403 ? 'denied' : 'error';
  274. this.emit();
  275. });
  276. }
  277. this.emit();
  278. }
  279. async replay(names, after = null) {
  280. if (!this.state.config) throw new ProbeError('请先获取配置。');
  281. if (after !== null && (!Number.isSafeInteger(after) || after < 0)) throw new ProbeError('补发起点必须是大于等于 0 的整数。');
  282. const jobs = names.map(async (name) => {
  283. const channel = this.channels.get(name);
  284. if (!channel || channel.replaying) return;
  285. const request = {};
  286. channel.request = request;
  287. channel.replaying = true;
  288. const session = this.session;
  289. const epoch = this.epoch;
  290. const current = () => this.session === session && this.epoch === epoch && channel.request === request;
  291. const from = after ?? channel.pendingAfter ?? channel.checkpoint;
  292. this.emit();
  293. try {
  294. const query = new URLSearchParams({ after_seq: String(from), channels: name });
  295. const response = await this.get(`/admin/realtime/replay?${query}`);
  296. if (!current()) return;
  297. if (response.code !== 0) throw new ProbeError(response.msg || '补发请求失败');
  298. const data = response.data;
  299. if (!data || !Number.isSafeInteger(data.last_seq) || !Array.isArray(data.events)) throw new ProbeError('补发响应格式不正确。');
  300. if (data.resync) {
  301. this.log('warning', `${name} 需要重新加载业务列表(${data.reason || 'history_unavailable'})。本页继续检查实时消息。`);
  302. } else {
  303. for (const event of data.events) if (event.channel === name) this.receive(event, 'replay');
  304. this.log('success', `${name} 补发检查完成:起点 ${from},返回 ${data.events.length} 条。`);
  305. }
  306. channel.checkpoint = Math.max(data.last_seq, channel.liveSeq);
  307. channel.pendingAfter = null;
  308. } catch (error) {
  309. if (!current()) return;
  310. this.log('error', `${name} 补发失败:${error.name === 'AbortError' ? '请求超时或已取消' : error.message}`);
  311. } finally {
  312. if (current()) { channel.replaying = false; channel.request = null; this.emit(); }
  313. }
  314. });
  315. await Promise.all(jobs);
  316. }
  317. receive(event, source) {
  318. if (!event || !Number.isSafeInteger(event.seq) || event.seq <= 0 || !this.state.config.channels.includes(event.channel)) {
  319. this.log('warning', '收到格式不符合预期的事件,未计入检测结果。');
  320. return;
  321. }
  322. const key = `${event.seq}:${event.channel}`;
  323. const duplicate = this.seen.has(key);
  324. if (duplicate) this.state.duplicateCount++;
  325. else {
  326. this.seen.add(key);
  327. if (this.seen.size > 5000) this.seen.delete(this.seen.values().next().value);
  328. }
  329. if (source === 'live') this.state.liveCount++;
  330. else this.state.replayCount++;
  331. this.state.lastSeq = Math.max(this.state.lastSeq, event.seq);
  332. this.onEvent({ source, duplicate, time: Date.now(), event: redact(event, this.token) });
  333. this.emit();
  334. }
  335. }