feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
This commit is contained in:
1 parent
ba17820040
commit
a057a0f61f
18 files changed
+339
-70
No files matched your search
@@ -17,13 +17,21 @@ function commonConnectionKeys(platform: string): string[] {
|
||||
}
|
||||
|
||||
/** 限流 / 会话额度**不在 config 里**:后端把它们作为账号顶层字段下发
|
||||
* (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages`,见
|
||||
* (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages/DirectMsgPerMin`,见
|
||||
* `internal/services/gateway/admin_write_channel_config.go`),且 `omitempty`
|
||||
* ⇒ 缺省 / 0 就是「不限」。读错来源会让面板把已设的限额显示成未设置。 */
|
||||
function fmtLimit(value?: number): string {
|
||||
return value === undefined || value === null || value === 0 ? "未设置(不限)" : String(value);
|
||||
}
|
||||
|
||||
/** `directMsgPerMin` 的展示:它的 0 **不是**「不限」而是「保护性默认 6 条/分」
|
||||
* (缺省绝不等于关掉陌生人私聊流控),负值才是主人显式关闭 —— 与其它三项的文案不同,
|
||||
* 所以单独一个格式化器,免得把「未设置」误读成「敞口」。 */
|
||||
function fmtDirectLimit(value?: number): string {
|
||||
if (value === undefined || value === null || value === 0) return "未设置(保护性默认 6 条/分)";
|
||||
return value < 0 ? `${value}(已关闭流控)` : String(value);
|
||||
}
|
||||
|
||||
/** 只读展示用的值格式化:空值明确写成「未设置」,对象/数组走 JSON 串,其余原样。
|
||||
* 绝不在这里造值——后端给什么显示什么(后端已对 token 类键做掩码)。 */
|
||||
function fmtValue(value: unknown): string {
|
||||
@@ -68,8 +76,11 @@ function connectionFields(platform: string, account: AdminChannelAccount): Field
|
||||
function businessFields(account: AdminChannelAccount): Field[] {
|
||||
return [
|
||||
{ label: "personaId(绑定角色)", value: account.personaId?.trim() ? account.personaId : "跟随默认角色" },
|
||||
{ label: "msgPerMin(私聊每分钟上限)", value: fmtLimit(account.msgPerMin) },
|
||||
// msgPerMin 是**账号级非主人消息总量上限**,不是私聊上限 —— 旧文案写成「私聊每分钟
|
||||
// 上限」会让主人以为它管的是陌生人私聊(真正管私聊的是下面那行 directMsgPerMin)。
|
||||
{ label: "msgPerMin(非主人消息每分钟总上限)", value: fmtLimit(account.msgPerMin) },
|
||||
{ label: "groupMsgPerMin(群每分钟上限)", value: fmtLimit(account.groupMsgPerMin) },
|
||||
{ label: "directMsgPerMin(陌生人对私聊每分钟上限)", value: fmtDirectLimit(account.directMsgPerMin) },
|
||||
{ label: "maxMessages(会话消息窗口)", value: fmtLimit(account.maxMessages) },
|
||||
{ label: "allowGroups(群白名单)", value: fmtList(account.allowGroups) },
|
||||
{ label: "denyGroups(群黑名单)", value: fmtList(account.denyGroups) },
|
||||
|
||||
@@ -195,6 +195,22 @@ const card = renderToStaticMarkup(
|
||||
);
|
||||
ok(card.includes(">30</dd>"), "msgPerMin is read from the account top-level field");
|
||||
ok(card.includes(">5</dd>"), "groupMsgPerMin is read from the account top-level field");
|
||||
// 私聊限流两行(裁决 13):`msgPerMin` 是**账号级非主人消息总量上限**,旧文案把它写成
|
||||
// 「私聊每分钟上限」是误导;真正管陌生人私聊的是 `directMsgPerMin`,而它的缺省不是
|
||||
// 「不限」而是保护性默认 6 条/分 —— 展示层若说成「不限」,主人会以为陌生人敞口。
|
||||
ok(!card.includes("msgPerMin(私聊每分钟上限)"), "msgPerMin 不再被说成「私聊每分钟上限」");
|
||||
ok(card.includes("msgPerMin(非主人消息每分钟总上限)"), "msgPerMin 说清是账号级总量上限");
|
||||
ok(card.includes("directMsgPerMin(陌生人对私聊每分钟上限)"), "并列补上 directMsgPerMin 行");
|
||||
ok(card.includes("未设置(保护性默认 6 条/分)"), "directMsgPerMin 的缺省说成保护性默认,不是「不限」");
|
||||
const directOff = renderToStaticMarkup(
|
||||
createElement(AdapterConfigSummary, {
|
||||
platform: "kook",
|
||||
account: { ...account, directMsgPerMin: -1 },
|
||||
unknown: false,
|
||||
onEdit: () => {},
|
||||
}),
|
||||
);
|
||||
ok(directOff.includes("-1(已关闭流控)"), "负值(主人显式关闭)显示成「已关闭流控」");
|
||||
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");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 渠道配置表单的限流读数 / 写回逻辑测试(design/12 §6 策略三/四;裁决 16.4.1 无关,纯数据安全)。
|
||||
// 渠道配置表单的限流读数 / 写回逻辑测试(design/12 §6 策略三/四 + 裁决 13 流控可配;裁决 16.4.1 无关,纯数据安全)。
|
||||
//
|
||||
// 锁两件事:
|
||||
// ① **读数来源** = 账号**顶层**字段 `groupMsgPerMin` / `msgPerMin` / `maxMessages`
|
||||
// (`AdminChannelAccount`,见 `internal/services/gateway/admin_write_channel_config.go`
|
||||
// ① **读数来源** = 账号**顶层**字段 `groupMsgPerMin` / `msgPerMin` / `maxMessages` /
|
||||
// `directMsgPerMin`(`AdminChannelAccount`,见 `internal/services/gateway/admin_write_channel_config.go`
|
||||
// 的 `adminChannelAccountOf`)——**不是** `config`。读错来源 ⇒ 表单永远回显空;
|
||||
// ② 由 ① 造成的连带数据丢失:回显空 ⇒ 保存时按「未设置」写 0(`omitempty`)
|
||||
// ⇒ **静默清掉主人已设的限流**。所以「没动过的字段」必须原样回传。
|
||||
@@ -47,44 +47,46 @@ function records(account: AdminChannelAccount): AdminChannelRecord[] {
|
||||
return [{ name: "kook:conf0", connected: true, accounts: [account] }];
|
||||
}
|
||||
|
||||
/** 表单被打开时的三个限流输入值(= save 时交给 limitRequestBody 的那三个字段)。 */
|
||||
/** 表单被打开时的四个限流输入值(= save 时交给 limitRequestBody 的那四个字段)。 */
|
||||
function formLimits(record: AdminChannelRecord[]): Record<string, string> {
|
||||
const { initial } = buildCfgTarget("kook:conf0", record);
|
||||
return {
|
||||
groupMsgPerMin: initial.groupMsgPerMin,
|
||||
msgPerMin: initial.msgPerMin,
|
||||
maxMessages: initial.maxMessages,
|
||||
directMsgPerMin: initial.directMsgPerMin,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ① 读数来源:账号顶层字段回显 ─────────────────────────────────────────────
|
||||
const stored = records(acct({ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 }));
|
||||
const stored = records(acct({ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40, directMsgPerMin: 8 }));
|
||||
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.initial.directMsgPerMin, "8", "directMsgPerMin(顶层)回显到表单");
|
||||
same(target.platform, "kook", "平台名仍按 adapter 名解析");
|
||||
|
||||
// `config` 里的同名键**不是**来源(后端从不把它们放进 config;读它等于永远读空)。
|
||||
// 这条断言就是这次回归的锁:把读数改回 `acct.config.*` 立刻变红。
|
||||
const nestedOnly = records(
|
||||
acct({ config: { groupMsgPerMin: 99, msgPerMin: 98, maxMessages: 97 } })
|
||||
acct({ config: { groupMsgPerMin: 99, msgPerMin: 98, maxMessages: 97, directMsgPerMin: 96 } })
|
||||
);
|
||||
same(
|
||||
formLimits(nestedOnly),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" },
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" },
|
||||
"限流不从 config 读(后端把它们放在账号顶层)"
|
||||
);
|
||||
|
||||
// 缺省与 0 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价;0 的真实语义是「用保护性默认」)。
|
||||
same(
|
||||
formLimits(records(acct({ groupMsgPerMin: 0, msgPerMin: 0, maxMessages: 0 }))),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" },
|
||||
formLimits(records(acct({ groupMsgPerMin: 0, msgPerMin: 0, maxMessages: 0, directMsgPerMin: 0 }))),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" },
|
||||
"0 与未设置都渲染成空"
|
||||
);
|
||||
same(
|
||||
formLimits(records(acct({}))),
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" },
|
||||
{ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" },
|
||||
"未设置渲染成空"
|
||||
);
|
||||
// 负值(= 关闭限制)必须原样回显,否则一次保存就把「关闭」写回默认。
|
||||
@@ -93,6 +95,13 @@ same(
|
||||
"-1",
|
||||
"负值(关闭限制)原样回显"
|
||||
);
|
||||
// directMsgPerMin 的负值同理:它被吞成 0 就等于把主人的「已关闭」改写回「保护性默认 6」
|
||||
// (反向静默变更),因此这一条与请求体断言(②)一起把负值两端都钉住。
|
||||
same(
|
||||
formLimits(records(acct({ directMsgPerMin: -1 }))).directMsgPerMin,
|
||||
"-1",
|
||||
"directMsgPerMin 的负值(关闭限制)原样回显,不被吞"
|
||||
);
|
||||
|
||||
// ── ② 不改动就保存:已设限额原样回传,绝不被清空 ──────────────────────────────
|
||||
const untouched = limitRequestBody(formLimits(stored));
|
||||
@@ -100,12 +109,12 @@ ok(untouched.ok, "没动过的表单必须能生成请求体");
|
||||
if (untouched.ok) {
|
||||
same(
|
||||
untouched.body,
|
||||
{ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40 },
|
||||
{ groupMsgPerMin: 20, msgPerMin: 30, maxMessages: 40, directMsgPerMin: 8 },
|
||||
"没动过的字段原样回传(不清空已设限额)"
|
||||
);
|
||||
}
|
||||
|
||||
// 只改 token / 白名单(表单挂载后**没碰**限流三个框)→ 限流三键仍在请求体里。
|
||||
// 只改 token / 白名单(表单挂载后**没碰**限流四个框)→ 限流四键仍在请求体里。
|
||||
{
|
||||
const body: Record<string, unknown> = { id: "conf0", token: "bt", allowUsers: ["u-1"] };
|
||||
const limits = limitRequestBody(formLimits(stored));
|
||||
@@ -113,24 +122,36 @@ if (untouched.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}',
|
||||
'{"id":"conf0","token":"bt","allowUsers":["u-1"],"groupMsgPerMin":20,"msgPerMin":30,"maxMessages":40,"directMsgPerMin":8}',
|
||||
"改 token/白名单的一单保存不会把已设限额清空"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ③ 未设置:该键**不进请求体**(不用 0 代替未设置) ────────────────────────
|
||||
const blank = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "" });
|
||||
ok(blank.ok, "留空的三个字段是合法输入(= 未设置)");
|
||||
const blank = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "" });
|
||||
ok(blank.ok, "留空的四个字段是合法输入(= 未设置)");
|
||||
if (blank.ok) {
|
||||
same(JSON.stringify(blank.body), "{}", "未设置的字段不出现在请求体里");
|
||||
}
|
||||
// 只填其中一个:另外两个不出现(显式清空与未设置都不写键 —— 后端 int 契约下
|
||||
// 只填其中一个:其余不出现(显式清空与未设置都不写键 —— 后端 int 契约下
|
||||
// 「键缺席」就是 0,与留空同义;要区分「保留」与「清空」,靠的是没动过的字段原样回传)。
|
||||
const partial = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "12", maxMessages: "" });
|
||||
const partial = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "12", maxMessages: "", directMsgPerMin: "" });
|
||||
ok(partial.ok, "部分填写是合法输入");
|
||||
if (partial.ok) {
|
||||
same(JSON.stringify(partial.body), '{"msgPerMin":12}', "只发用户真的填了的那个键");
|
||||
}
|
||||
// 只填 directMsgPerMin:请求体只带这一个键(它就是表单新加的那个字段的写回路径)。
|
||||
const directOnly = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "3" });
|
||||
ok(directOnly.ok, "只填 directMsgPerMin 是合法输入");
|
||||
if (directOnly.ok) {
|
||||
same(JSON.stringify(directOnly.body), '{"directMsgPerMin":3}', "只填 directMsgPerMin 时请求体只带该键");
|
||||
}
|
||||
// 负值直达请求体(不被吞成 0;0 的语义是「保护性默认 6」,与「关闭」是两件事)。
|
||||
const directOff = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "-1" });
|
||||
ok(directOff.ok, "directMsgPerMin = -1 是合法输入(关闭流控)");
|
||||
if (directOff.ok) {
|
||||
same(JSON.stringify(directOff.body), '{"directMsgPerMin":-1}', "负值原样进请求体,不被当成 0");
|
||||
}
|
||||
|
||||
// ── ④ 非法输入:报错中止,绝不发 NaN(NaN → JSON null → 后端 int 静默落 0 清空限额) ──
|
||||
same(limitFieldValue("12"), 12, "整数原样解析");
|
||||
@@ -143,11 +164,16 @@ same(limitFieldValue("6/min"), null, "带单位 = 非法");
|
||||
same(limitFieldValue("6.5"), null, "小数 = 非法(后端字段是 int)");
|
||||
same(limitFieldValue("1e3"), null, "科学计数法 = 非法(不给后端猜)");
|
||||
|
||||
const bad = limitRequestBody({ groupMsgPerMin: "abc", msgPerMin: "", maxMessages: "" });
|
||||
const bad = limitRequestBody({ groupMsgPerMin: "abc", msgPerMin: "", maxMessages: "", directMsgPerMin: "" });
|
||||
ok(!bad.ok, "非法值必须让整单失败,而不是发出去清掉已设限额");
|
||||
if (!bad.ok) {
|
||||
ok(bad.error.includes("groupMsgPerMin"), `错误文案点名出错的字段 — got ${bad.error}`);
|
||||
}
|
||||
const badDirect = limitRequestBody({ groupMsgPerMin: "", msgPerMin: "", maxMessages: "", directMsgPerMin: "abc" });
|
||||
ok(!badDirect.ok, "directMsgPerMin 非法值同样让整单失败(不发 NaN → null 清掉已设限额)");
|
||||
if (!badDirect.ok) {
|
||||
ok(badDirect.error.includes("directMsgPerMin"), `错误文案点名出错的字段 — got ${badDirect.error}`);
|
||||
}
|
||||
|
||||
// ── ⑤ 平台字段预填:掩码秘密绝不回填(回填 = 一次「打开就保存」把 ****abcd 写成真 token) ──
|
||||
// 后端 `runtimeconfig.Masked` 把 token/secret/key 类键脱敏成「****」+ 末 4 位后下发,
|
||||
|
||||
@@ -86,23 +86,24 @@ export function buildCfgTarget(name: string, records: AdminChannelRecord[], acco
|
||||
denyGroups: (acct?.denyGroups ?? []).join(","),
|
||||
allowUsers: (acct?.allowUsers ?? []).join(","),
|
||||
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
|
||||
// 限流三个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。
|
||||
// 限流四个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。
|
||||
groupMsgPerMin: limitInitialOf(acct?.groupMsgPerMin),
|
||||
msgPerMin: limitInitialOf(acct?.msgPerMin),
|
||||
maxMessages: limitInitialOf(acct?.maxMessages),
|
||||
directMsgPerMin: limitInitialOf(acct?.directMsgPerMin),
|
||||
personaId: channelPersonaOf(acct),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 限流 / 会话额度的三个字段名(design/12 §6 策略三/四)。回显、写回与表单共用这一份,
|
||||
* 避免两处清单漂移。
|
||||
/** 限流 / 会话额度的四个字段名(design/12 §6 策略三/四 + 裁决 13)。回显、写回与表单共用
|
||||
* 这一份,避免两处清单漂移。
|
||||
*
|
||||
* ⚠️ 线上它们是**账号顶层**字段(`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/
|
||||
* MaxMessages`,见 `internal/services/gateway/admin_write_channel_config.go` 的
|
||||
* `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空
|
||||
* MaxMessages/DirectMsgPerMin`,见 `internal/services/gateway/admin_write_channel_config.go`
|
||||
* 的 `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空
|
||||
* ⇒ 保存时按「未设置」写回 0 ⇒ **静默清掉主人已设的限流**。 */
|
||||
export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages"] as const;
|
||||
export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages", "directMsgPerMin"] as const;
|
||||
|
||||
/** 限流字段名。 */
|
||||
export type LimitKey = (typeof LIMIT_KEYS)[number];
|
||||
@@ -120,14 +121,15 @@ function limitInitialOf(v: number | undefined): string {
|
||||
* 序列化成 `null`,后端 int 收到 `null` 静默落 0,同样会清空已设限额。
|
||||
*
|
||||
* 数值语义以 `internal/services/channelbridge/flowgate.go` 的 `flowOrDefault` 为准:
|
||||
* **0 = 保护性默认**(群 6 条/分、待处理上限 50)、**负值 = 关闭限制**;`msgPerMin` 只认正值。 */
|
||||
* **0 = 保护性默认**(群 6 条/分、非主人私聊 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 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体,
|
||||
/** 表单四个限流字段 → PUT 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体,
|
||||
* 也绝不用 0 代替「未设置」)。 */
|
||||
export function limitRequestBody(
|
||||
fields: Record<LimitKey, string>
|
||||
|
||||
Reference in new issue
Block a user