From c1a98d7c01cde8c26cddb5f52b43c96dcec4b43b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 13:41:08 +0800 Subject: [PATCH] =?UTF-8?q?perf(admin-web):=20=E9=95=BF=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8=20+=20=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E9=A1=B5=E5=8F=AA=E8=AF=BB=E9=85=8D=E7=BD=AE=E5=8D=A1=EF=BC=88?= =?UTF-8?q?=E8=A3=81=E5=86=B3=2016.4.3/16.4.6=EF=BC=89+=20=E4=BF=AE?= =?UTF-8?q?=E9=99=90=E6=B5=81=E8=AF=BB=E5=86=99=E6=88=90=E5=AF=B9=E7=9A=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 16.4.3(效率最高):AdminTable 超阈值走窗口化(DOM 行数与数据量解耦,上下占位行撑滚动条); 新增可复用 VirtualList;适配器消息日志换成窗口化版 - 16.4.6(操作更便利):保留列表页内联编辑,同时把「专属页只读展示该平台完整字段」真正实现 (AdapterConfigSummary,此前专属页一个配置字段都不显示) - 🔴 顺带修一条静默数据丢失:列表页限流三项原从 acct.config.* 读,而后端把它们存**账号顶层** ⇒ 表单永远回显空、保存时 undefined→0 会把已设限流清空。现改从顶层读、留空则不发该键、非法输入报错中止; 并顺手改正用户可见的错误文案(0 = 保护性默认 6条/分,负值才是关闭) - 复核:tsc 0 错 + vite build 成功;channel limits 27 / admin virtual scroll 8023 项断言;3 条变异反证 --- frontend/web/src/admin/components.tsx | 100 ++++++++- frontend/web/src/admin/hooks.ts | 174 ++++++++++++++- .../src/admin/modules/channelConfigForm.tsx | 38 +++- .../modules/channels/AdapterConfigSummary.tsx | 162 ++++++++++++++ .../modules/channels/AdapterDetailPage.tsx | 17 +- .../modules/channels/AdapterMessageLog.tsx | 93 ++++++++ .../channels/adminVirtualScroll.test.ts | 210 ++++++++++++++++++ .../modules/channels/channelUtils.test.ts | 151 +++++++++++++ .../admin/modules/channels/channelUtils.ts | 53 ++++- 9 files changed, 976 insertions(+), 22 deletions(-) create mode 100644 frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx create mode 100644 frontend/web/src/admin/modules/channels/AdapterMessageLog.tsx create mode 100644 frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts create mode 100644 frontend/web/src/admin/modules/channels/channelUtils.test.ts diff --git a/frontend/web/src/admin/components.tsx b/frontend/web/src/admin/components.tsx index 790f5a3..002057d 100644 --- a/frontend/web/src/admin/components.tsx +++ b/frontend/web/src/admin/components.tsx @@ -1,6 +1,13 @@ import { useMemo, type ReactNode } from "react"; import { EmptyState, Tag, type TagTone } from "../components/primitives"; import { Icon } from "../components/Icon"; +import { useVirtualRows, VIRTUAL_ROW_ESTIMATE } from "./hooks"; + +/** 行数超过它就启用虚拟滚动;小表保持原样全量渲染(窗口化对短表没有收益, + * 还会平白多出一个滚动条)。与 `VirtualList` 的默认阈值一致。 */ +export const VIRTUAL_THRESHOLD = 60; +/** 窗口化容器的默认最大高度(CSS 长度)。 */ +export const VIRTUAL_MAX_HEIGHT = "min(70vh, 720px)"; export interface PageHeaderProps { title: string; @@ -76,6 +83,10 @@ export interface AdminTableProps { emptyTitle?: string; rowKey: (row: T) => string; onRowClick?: (row: T) => void; + /** 行数超过它才走虚拟滚动(见 `VIRTUAL_THRESHOLD`)。 */ + virtualThreshold?: number; + /** 虚拟滚动时滚动容器的最大高度(CSS 长度)。 */ + maxHeight?: string; } /** A strict, generic data table. Loading/error/empty states are handled here so @@ -85,7 +96,11 @@ export interface AdminTableProps { * backend returns them — the same data always renders in the same order (no * jumping on refresh). Stable keys let React reuse the row DOM and only update * changed cells. An error is shown only when there is no data to keep on - * screen, so a failed silent refresh keeps the stale list visible. */ + * screen, so a failed silent refresh keeps the stale list visible. + * + * 超过 `virtualThreshold` 行时改走窗口化(裁决 16.4.3):只挂载视口内的行,其余 + * 用上下两条占位行撑住滚动条,因此 DOM 行数不再随数据量增长。阈值以下的行数、 + * 表头与单元格结构保持与全量渲染完全一致。 */ export function AdminTable({ columns, rows, @@ -94,6 +109,8 @@ export function AdminTable({ emptyTitle = "暂无数据", rowKey, onRowClick, + virtualThreshold = VIRTUAL_THRESHOLD, + maxHeight = VIRTUAL_MAX_HEIGHT, }: AdminTableProps): ReactNode { const sorted = useMemo(() => { if (!rows) return rows; @@ -104,6 +121,14 @@ export function AdminTable({ }); }, [rows, rowKey]); + const count = sorted?.length ?? 0; + const windowed = count > virtualThreshold; + const { range, containerRef, rowAttrs } = useVirtualRows({ + count, + enabled: windowed, + rowHeight: VIRTUAL_ROW_ESTIMATE, + }); + if (loading) { return
加载中…
; } @@ -114,8 +139,16 @@ export function AdminTable({ if (!hasRows) { return } title={emptyTitle} />; } + + const list = sorted ?? []; + const from = windowed ? range.start : 0; + const visible = windowed ? list.slice(from, range.end) : list; return ( -
+
@@ -130,9 +163,11 @@ export function AdminTable({ - {sorted.map((row) => ( + {windowed && range.padTop > 0 ? : null} + {visible.map((row, i) => ( onRowClick(row) : undefined} className={`border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60 ${onRowClick ? "cursor-pointer" : ""}`} > @@ -143,12 +178,71 @@ export function AdminTable({ ))} ))} + {windowed && range.padBottom > 0 ? : null}
); } +export interface VirtualListProps { + items: readonly T[]; + rowKey: (item: T) => string; + renderRow: (item: T) => ReactNode; + /** 滚动容器的最大高度(CSS 长度)。窗口化必须有确定视口。 */ + maxHeight?: string; + /** 行高估算值(px);挂载后按实测高度修正。 */ + rowHeight?: number; + /** 每行包裹元素的 class——行间距请用 padding,它会被实测进行高。 */ + rowClassName?: string; + /** 少于该行数时全量渲染(与 `AdminTable` 同口径)。 */ + threshold?: number; + className?: string; +} + +/** 窗口化的长列表:日志 / 消息流这类「条数多、还会被 WS 持续追帧」的面用它, + * DOM 行数与总条数解耦(裁决 16.4.3)。行渲染交给调用方,本组件只管挂载窗口与 + * 上下占位,因此每行内容、状态与全量渲染逐字一致。 */ +export function VirtualList({ + items, + rowKey, + renderRow, + maxHeight = VIRTUAL_MAX_HEIGHT, + rowHeight = VIRTUAL_ROW_ESTIMATE, + rowClassName = "", + threshold = VIRTUAL_THRESHOLD, + className = "", +}: VirtualListProps): ReactNode { + const count = items.length; + const windowed = count > threshold; + const { range, containerRef, rowAttrs } = useVirtualRows({ count, enabled: windowed, rowHeight }); + + if (!windowed) { + return ( +
+ {items.map((item) => ( +
+ {renderRow(item)} +
+ ))} +
+ ); + } + + const from = range.start; + return ( +
+ ); diff --git a/frontend/web/src/admin/modules/channels/AdapterMessageLog.tsx b/frontend/web/src/admin/modules/channels/AdapterMessageLog.tsx new file mode 100644 index 0000000..530442e --- /dev/null +++ b/frontend/web/src/admin/modules/channels/AdapterMessageLog.tsx @@ -0,0 +1,93 @@ +import { useMemo } from "react"; +import { adminChannelMessages } from "../../../lib/api"; +import type { AdminChannelMessage } from "../../../lib/types"; +import { useAsyncData } from "../../../lib/useAsyncData"; +import { useAppStore } from "../../../store/useAppStore"; +import { Button, Tag } from "../../../components/primitives"; +import { Notice, VirtualList, VIRTUAL_THRESHOLD } from "../../components"; +import { useAutoReload } from "../../hooks"; +import { PLATFORM_LABEL } from "../channelFields"; +import { platformOf } from "./channelUtils"; +import { ChannelLoadError } from "./channelStatusView"; +import { ChannelLogRow } from "./channelLogList"; + +/** 一行消息的估算高度(px):标签行 + 正文数行,实测后会被真实值取代。 */ +const MESSAGE_ROW_ESTIMATE = 76; + +/** 历史(后端最新在前)与本端 WS 实时尾(已按 id 去重)合并,翻成「旧 → 新」的 + * 阅读顺序。与 `ChannelLogList` 同一份口径,保证两个面看到的是同一串消息。 */ +function mergeLog( + history: readonly AdminChannelMessage[] | null, + live: readonly AdminChannelMessage[], + adapter: string, +): AdminChannelMessage[] { + const base = history ? [...history].reverse() : []; + const seen = new Set(base.map((m) => m.id)); + const tail = live.filter((m) => m.adapterId === adapter).reverse(); + return [...base, ...tail.filter((m) => !seen.has(m.id))]; +} + +/** 单适配器消息日志(design/17 §2「实时日志」)。数据面与 `ChannelLogList` 相同 + * (历史分页 + `channel_persisted` WS 追帧 + 10s 兜底轮询),区别只在渲染:消息是 + * 本模块最长的列表,也是 WS 每来一帧就要整表重渲染的面,因此走窗口化——只挂载 + * 视口内的行(裁决 16.4.3「按效率最高的做」)。加载 / 空 / 失联三种状态照旧分开。 */ +export function AdapterMessageLog({ + adapter, + limit = 200, +}: { + adapter: string; + limit?: number; +}): React.ReactNode { + const { data, loading, error, reload } = useAsyncData( + () => adminChannelMessages(adapter, limit), + [adapter, limit], + ); + useAutoReload(reload, 10000); + const channelEvents = useAppStore((s) => s.channelEvents); + const msgs = useMemo(() => mergeLog(data, channelEvents, adapter), [data, channelEvents, adapter]); + const platform = platformOf(adapter); + + return ( +
+
+ {PLATFORM_LABEL[platform] ?? platform} + {adapter} +

消息日志

+ GET /api/v1/admin/channels/messages + +
+ +
+ {loading ? ( + 加载中… + ) : error && !data && msgs.length === 0 ? ( + + ) : ( + <> + + {msgs.length === 0 ? ( + 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 + ) : ( + <> +
+ 共 {msgs.length} 条 · 上面更旧、下面更新 + {msgs.length > VIRTUAL_THRESHOLD ? 虚拟滚动 · 仅挂载视口内的行 : null} +
+ m.id} + renderRow={(m) => } + rowClassName="pb-2" + rowHeight={MESSAGE_ROW_ESTIMATE} + maxHeight="min(60vh, 640px)" + /> + + )} + + )} +
+
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts b/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts new file mode 100644 index 0000000..2d8dd2a --- /dev/null +++ b/frontend/web/src/admin/modules/channels/adminVirtualScroll.test.ts @@ -0,0 +1,210 @@ +// Logic + render test for the windowed long lists of the admin panel (裁决 16.4.3 +// 「按效率最高的做」). The bugs this guards against: +// 1. the window math and the two spacer heights drift apart → rows shift by whole +// row-heights while scrolling, or the visible band falls outside the mounted +// window and the list goes blank mid-scroll; +// 2. `AdminTable` stops trimming at the threshold → the DOM count grows linearly +// again (the thing the ruling asked to fix); +// 3. the read-only config card reads 限流 from `config` instead of the account's +// top-level fields (where the gateway actually sends them) → imposed limits +// render as「未设置」. +// The project has no frontend test runner, so this is a plain-node check driving the +// real functions/components and throwing on the first failure. Run it with the +// esbuild that already ships with vite (no new dependency, no package.json change). +// The render section pulls in `react-dom/server`, whose CJS build needs `require`, +// so this one must be bundled as CJS (the ESM output breaks on `require("util")`): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/channels/adminVirtualScroll.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-admin-virtual-scroll.test.cjs \ +// && node /tmp/cyrene-admin-virtual-scroll.test.cjs +// +// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it +// imports nothing from node:*. It lives in this directory because the module owns +// the long lists (消息日志) that consume the windowing. + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AdminTable, VIRTUAL_THRESHOLD, type AdminColumn } from "../../components"; +import { VIRTUAL_OVERSCAN, computeVirtualWindow } from "../../hooks"; +import { AdapterConfigSummary } from "./AdapterConfigSummary"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: T, want: T, what: string): void { + ok( + JSON.stringify(got) === JSON.stringify(want), + `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`, + ); +} + +function count(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +// ── 1. Deterministic window cases: exact window + exact spacer heights ───────── +const uniform = Array.from({ length: 100 }, () => 20); + +same( + computeVirtualWindow([], 0, 500, 2), + { start: 0, end: 0, padTop: 0, padBottom: 0, total: 0 }, + "empty list renders nothing and no spacers", +); +same( + computeVirtualWindow(uniform, 0, 100, 0), + { start: 0, end: 5, padTop: 0, padBottom: 1900, total: 2000 }, + "top of a uniform list mounts the first screen only", +); +same( + computeVirtualWindow(uniform, 400, 100, 0), + { start: 20, end: 25, padTop: 400, padBottom: 1500, total: 2000 }, + "mid-list padTop equals the exact accumulated height above the window", +); +same( + computeVirtualWindow(uniform, 400, 100, 2), + { start: 18, end: 27, padTop: 360, padBottom: 1460, total: 2000 }, + "overscan mounts 2 extra rows on each side", +); +same( + computeVirtualWindow(uniform, 99999, 100, 0), + { start: 95, end: 100, padTop: 1900, padBottom: 0, total: 2000 }, + "scrolling past the end clamps to the last screen (list shrank / data replaced)", +); +same( + computeVirtualWindow([10, 100, 10], 0, 50, 0), + { start: 0, end: 2, padTop: 0, padBottom: 10, total: 120 }, + "variable row heights use the real accumulated heights", +); +same( + computeVirtualWindow(uniform, 0, 0, 0), + { start: 0, end: 1, padTop: 0, padBottom: 1980, total: 2000 }, + "a viewport that is not measured yet still mounts one row", +); + +// ── 2. Randomised invariants: height conservation + visible band in the window ─ +let seed = 42; +const rnd = (): number => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; +}; + +for (let iter = 0; iter < 2000; iter += 1) { + const n = 1 + Math.floor(rnd() * 240); + const heights = Array.from({ length: n }, () => 8 + Math.floor(rnd() * 120)); + const total = heights.reduce((a, b) => a + b, 0); + const viewport = 50 + Math.floor(rnd() * 400); + const scrollTop = rnd() * (total + 400) - 200; + const overscan = Math.floor(rnd() * 5); + const w = computeVirtualWindow(heights, scrollTop, viewport, overscan); + + ok(w.end > w.start, `iter ${iter}: window must never be empty`); + ok(Math.abs(w.total - total) < 1e-9, `iter ${iter}: total height must be the sum of the rows`); + const mounted = heights.slice(w.start, w.end).reduce((a, b) => a + b, 0); + ok( + Math.abs(w.padTop + mounted + w.padBottom - w.total) < 1e-9, + `iter ${iter}: spacers + mounted rows must equal the full height`, + ); + + const clampedTop = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewport)); + let visibleStart = 0; + let acc = 0; + while (visibleStart < n - 1 && acc + heights[visibleStart] <= clampedTop) { + acc += heights[visibleStart]; + visibleStart += 1; + } + let visibleEnd = visibleStart; + let accEnd = acc; + while (visibleEnd < n && accEnd < clampedTop + viewport) { + accEnd += heights[visibleEnd]; + visibleEnd += 1; + } + ok( + visibleStart >= w.start && visibleEnd <= w.end, + `iter ${iter}: visible ${visibleStart}..${visibleEnd} must sit inside window ${w.start}..${w.end}`, + ); +} + +// ── 3. Real render of AdminTable: the threshold actually trims the DOM ───────── +interface Row { + id: string; + name: string; +} +const columns: AdminColumn[] = [ + { key: "name", title: "名称", render: (r) => r.id }, +]; + +function renderTable(count: number): string { + const rows = Array.from({ length: count }, (_, i) => ({ id: `row-${i}`, name: `行 ${i}` })); + return renderToStaticMarkup( + createElement(AdminTable, { + columns, + rows, + loading: false, + error: null, + rowKey: (r: Row) => r.id, + }), + ); +} + +// 服务器渲染时容器尚未测量(视口 0),因此只挂载「首行 + 上下 overscan」这一窗口。 +const big = renderTable(VIRTUAL_THRESHOLD + 40); +same(count(big, "data-vrow="), 1 + VIRTUAL_OVERSCAN, "long table mounts only the first window"); +ok(!big.includes("row-99"), "long table must not mount the last row (nothing outside the window)"); +ok(big.includes("data-vrow=\"0\""), "long table mounts the first row"); +ok( + count(big, ", { columns, rows: null, loading: true, error: null, rowKey: (r: Row) => r.id })).includes("加载中"), + "loading is its own state", +); +ok( + renderToStaticMarkup(createElement(AdminTable, { columns, rows: null, loading: false, error: "与后端失联", rowKey: (r: Row) => r.id })).includes("与后端失联"), + "a failed first read shows the failure, not an empty list", +); +ok( + renderToStaticMarkup(createElement(AdminTable, { columns, rows: [], loading: false, error: null, rowKey: (r: Row) => r.id })).includes("暂无数据"), + "an empty but read list shows the empty state", +); + +// ── 4. Read-only config card: 限流 comes from the account's top-level fields ──── +const account = { + id: "conf0", + config: { bot_token: "****1234", base_url: "https://kook.example", receive_mode: "ws" }, + msgPerMin: 30, + groupMsgPerMin: 5, + allowUsers: ["10001"], +}; +const card = renderToStaticMarkup( + createElement(AdapterConfigSummary, { platform: "kook", account, unknown: false, onEdit: () => {} }), +); +ok(card.includes(">30"), "msgPerMin is read from the account top-level field"); +ok(card.includes(">5"), "groupMsgPerMin is read from the account top-level field"); +ok(card.includes("未设置(不限)"), "an absent limit is shown as 未设置, not invented"); +ok(card.includes("****1234"), "the masked token the backend sent is shown verbatim"); +ok(card.includes("receive_mode"), "every other config key is still listed"); +ok( + renderToStaticMarkup( + createElement(AdapterConfigSummary, { platform: "kook", account: undefined, unknown: true, onEdit: () => {} }), + ).includes("与后端失联"), + "an unreadable channel list is reported as such, never as「没配置」", +); + +console.log( + `admin virtual scroll test: ${checks} checks passed(窗口数学 7 组确定性用例 + 2000 轮随机不变量 + AdminTable/配置卡真实渲染)`, +); diff --git a/frontend/web/src/admin/modules/channels/channelUtils.test.ts b/frontend/web/src/admin/modules/channels/channelUtils.test.ts new file mode 100644 index 0000000..520fd39 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelUtils.test.ts @@ -0,0 +1,151 @@ +// 渠道配置表单的限流读数 / 写回逻辑测试(design/12 §6 策略三/四;裁决 16.4.1 无关,纯数据安全)。 +// +// 锁两件事: +// ① **读数来源** = 账号**顶层**字段 `groupMsgPerMin` / `msgPerMin` / `maxMessages` +// (`AdminChannelAccount`,见 `internal/services/gateway/admin_write_channel_config.go` +// 的 `adminChannelAccountOf`)——**不是** `config`。读错来源 ⇒ 表单永远回显空; +// ② 由 ① 造成的连带数据丢失:回显空 ⇒ 保存时按「未设置」写 0(`omitempty`) +// ⇒ **静默清掉主人已设的限流**。所以「没动过的字段」必须原样回传。 +// +// 项目没有前端测试框架(表单是 React 组件,纯 node 下跑不了),所以这是一个零依赖的 +// plain-node 检查:它驱动真实的纯模块(channelUtils.ts 的 buildCfgTarget / +// limitFieldValue / limitRequestBody,表单 save 走的就是同一个 limitRequestBody), +// 并在第一个失败处抛出。 +// +// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelUtils.test.ts \ +// --bundle --platform=node --format=esm \ +// --outfile=/tmp/cyrene-channel-limits.test.mjs \ +// && node /tmp/cyrene-channel-limits.test.mjs +// +// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它 +// 只从项目内导入、不 import node:*。 + +import type { AdminChannelAccount, AdminChannelRecord } from "../../../lib/types"; +import { buildCfgTarget, limitFieldValue, limitRequestBody } from "./channelUtils"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: unknown, want: unknown, what: string): void { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + ok(g === w, `${what} — got ${g}, want ${w}`); +} + +const acct = (a: Partial): AdminChannelAccount => + ({ id: "conf0", config: {}, ...a }) as AdminChannelAccount; + +function records(account: AdminChannelAccount): AdminChannelRecord[] { + return [{ name: "kook:conf0", connected: true, accounts: [account] }]; +} + +/** 表单被打开时的三个限流输入值(= save 时交给 limitRequestBody 的那三个字段)。 */ +function formLimits(record: AdminChannelRecord[]): Record { + const { initial } = buildCfgTarget("kook:conf0", record); + return { + groupMsgPerMin: initial.groupMsgPerMin, + msgPerMin: initial.msgPerMin, + maxMessages: initial.maxMessages, + }; +} + +// ── ① 读数来源:账号顶层字段回显 ───────────────────────────────────────────── +const stored = records(acct({ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 })); +const target = buildCfgTarget("kook:conf0", stored); +same(target.initial.groupMsgPerMin, "20", "groupMsgPerMin(顶层)回显到表单"); +same(target.initial.msgPerMin, "30", "msgPerMin(顶层)回显到表单"); +same(target.initial.maxMessages, "40", "maxMessages(顶层)回显到表单"); +same(target.platform, "kook", "平台名仍按 adapter 名解析"); + +// `config` 里的同名键**不是**来源(后端从不把它们放进 config;读它等于永远读空)。 +// 这条断言就是这次回归的锁:把读数改回 `acct.config.*` 立刻变红。 +const nestedOnly = records( + acct({ config: { groupMsgPerMin: 99, msgPerMin: 98, maxMessages: 97 } }) +); +same( + formLimits(nestedOnly), + { groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }, + "限流不从 config 读(后端把它们放在账号顶层)" +); + +// 缺省与 0 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价;0 的真实语义是「用保护性默认」)。 +same( + formLimits(records(acct({ groupMsgPerMin: 0, msgPerMin: 0, maxMessages: 0 }))), + { groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }, + "0 与未设置都渲染成空" +); +same( + formLimits(records(acct({}))), + { groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }, + "未设置渲染成空" +); +// 负值(= 关闭限制)必须原样回显,否则一次保存就把「关闭」写回默认。 +same( + formLimits(records(acct({ groupMsgPerMin: -1 }))).groupMsgPerMin, + "-1", + "负值(关闭限制)原样回显" +); + +// ── ② 不改动就保存:已设限额原样回传,绝不被清空 ────────────────────────────── +const untouched = limitRequestBody(formLimits(stored)); +ok(untouched.ok, "没动过的表单必须能生成请求体"); +if (untouched.ok) { + same( + untouched.body, + { groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 }, + "没动过的字段原样回传(不清空已设限额)" + ); +} + +// 只改 token / 白名单(表单挂载后**没碰**限流三个框)→ 限流三键仍在请求体里。 +{ + const body: Record = { id: "conf0", token: "bt", allowUsers: ["u-1"] }; + const limits = limitRequestBody(formLimits(stored)); + ok(limits.ok, "限流部分可生成"); + if (limits.ok) Object.assign(body, limits.body); + same( + JSON.stringify(body), + '{"id":"conf0","token":"bt","allowUsers":["u-1"],"groupMsgPerMin":20,"msgPerMin":30,"maxMessages":40}', + "改 token/白名单的一单保存不会把已设限额清空" + ); +} + +// ── ③ 未设置:该键**不进请求体**(不用 0 代替未设置) ──────────────────────── +const blank = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" }); +ok(blank.ok, "留空的三个字段是合法输入(= 未设置)"); +if (blank.ok) { + same(JSON.stringify(blank.body), "{}", "未设置的字段不出现在请求体里"); +} +// 只填其中一个:另外两个不出现(显式清空与未设置都不写键 —— 后端 int 契约下 +// 「键缺席」就是 0,与留空同义;要区分「保留」与「清空」,靠的是没动过的字段原样回传)。 +const partial = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "12", maxMessages: "" }); +ok(partial.ok, "部分填写是合法输入"); +if (partial.ok) { + same(JSON.stringify(partial.body), '{"msgPerMin":12}', "只发用户真的填了的那个键"); +} + +// ── ④ 非法输入:报错中止,绝不发 NaN(NaN → JSON null → 后端 int 静默落 0 清空限额) ── +same(limitFieldValue("12"), 12, "整数原样解析"); +same(limitFieldValue(" 12 "), 12, "两侧空白被修剪"); +same(limitFieldValue("-1"), -1, "负值(关闭限制)解析为负数"); +same(limitFieldValue(""), undefined, "留空 = 未设置"); +same(limitFieldValue(" "), undefined, "全空白 = 未设置"); +same(limitFieldValue("abc"), null, "非数字 = 非法"); +same(limitFieldValue("6/min"), null, "带单位 = 非法"); +same(limitFieldValue("6.5"), null, "小数 = 非法(后端字段是 int)"); +same(limitFieldValue("1e3"), null, "科学计数法 = 非法(不给后端猜)"); + +const bad = limitRequestBody({ groupMsgPerMin: "abc", msgPerMin: "", maxMessages: "" }); +ok(!bad.ok, "非法值必须让整单失败,而不是发出去清掉已设限额"); +if (!bad.ok) { + ok(bad.error.includes("groupMsgPerMin"), `错误文案点名出错的字段 — got ${bad.error}`); +} + +console.log(`channel limits logic test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/channels/channelUtils.ts b/frontend/web/src/admin/modules/channels/channelUtils.ts index de5ce8e..d1a429e 100644 --- a/frontend/web/src/admin/modules/channels/channelUtils.ts +++ b/frontend/web/src/admin/modules/channels/channelUtils.ts @@ -47,14 +47,61 @@ export function buildCfgTarget(name: string, records: AdminChannelRecord[]): Cfg denyGroups: (acct?.denyGroups ?? []).join(","), allowUsers: (acct?.allowUsers ?? []).join(","), denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "", - groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin), - msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin), - maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages), + // 限流三个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。 + groupMsgPerMin: limitInitialOf(acct?.groupMsgPerMin), + msgPerMin: limitInitialOf(acct?.msgPerMin), + maxMessages: limitInitialOf(acct?.maxMessages), personaId: channelPersonaOf(acct), }, }; } +/** 限流 / 会话额度的三个字段名(design/12 §6 策略三/四)。回显、写回与表单共用这一份, + * 避免两处清单漂移。 + * + * ⚠️ 线上它们是**账号顶层**字段(`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/ + * MaxMessages`,见 `internal/services/gateway/admin_write_channel_config.go` 的 + * `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空 + * ⇒ 保存时按「未设置」写回 0 ⇒ **静默清掉主人已设的限流**。 */ +export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages"] as const; + +/** 限流字段名。 */ +export type LimitKey = (typeof LIMIT_KEYS)[number]; + +/** 已设限额 → 表单回显:缺省与 `0` 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价)。 + * 非零值**原样回传**,这样「没动过的字段」保存时原样送回,不会把已存限额写没。 */ +function limitInitialOf(v: number | undefined): string { + return v === undefined || v === 0 ? "" : String(v); +} + +/** 表单限流值 → 请求体取值: + * - 留空 → `undefined`:该键**不进请求体**(后端 int 契约下省略 = 0 = 未设置); + * - 整数(含负值)→ 该数值; + * - 其它(小数 / 非数字)→ `null`,调用方报错并**中止保存** —— `Number("x")` 是 NaN、 + * 序列化成 `null`,后端 int 收到 `null` 静默落 0,同样会清空已设限额。 + * + * 数值语义以 `internal/services/channelbridge/flowgate.go` 的 `flowOrDefault` 为准: + * **0 = 保护性默认**(群 6 条/分、待处理上限 50)、**负值 = 关闭限制**;`msgPerMin` 只认正值。 */ +export function limitFieldValue(raw: string): number | undefined | null { + const t = raw.trim(); + if (!t) return undefined; + return /^-?\d+$/.test(t) ? Number(t) : null; +} + +/** 表单三个限流字段 → PUT 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体, + * 也绝不用 0 代替「未设置」)。 */ +export function limitRequestBody( + fields: Record +): { ok: true; body: Partial> } | { ok: false; error: string } { + const body: Partial> = {}; + for (const key of LIMIT_KEYS) { + const value = limitFieldValue(fields[key]); + if (value === null) return { ok: false, error: `${key} 需为整数(留空 = 未设置,负值 = 关闭限制)` }; + if (value !== undefined) body[key] = value; + } + return { ok: true, body }; +} + /** The set of platforms that have at least one configured account. */ export function configuredPlatforms(records: AdminChannelRecord[]): AdminChannelRecord[] { return records.filter((c) => (c.accounts?.length ?? 0) > 0);