feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 design/22 §4.4/§4.6 + §7-4/7-7(契约先行,后端由并行代理实现)。
- 人格页:展示每个角色绑定的会话数——面板侧用 GET /admin/sessions 的 personaId 聚合(personaId 为空 = 继承默认角色,已计入默认角色并在 UI 写明口径),随既有 useAutoReload 刷新;**未新增后端字段**
- 渠道级绑定 UI:每个渠道账号加「角色」下拉(选项取 GET /admin/personas),保存走 PUT /admin/channels/{platform}/config 的 personaId;已绑角色回填、未绑定显示「跟随默认角色」(不冒充某个角色);提示语写明**群聊/陌生人会话始终用默认角色**,避免暗示可覆盖隔离会话
- 验收:npm run build 绿 + 逻辑测试(personaSession/messageSync)通过
This commit is contained in:
AskaEth committed 2026-10-03 20:50:07 +08:00
1 parent 2aa3233f84
commit 3732c538cc
9 files changed
+553 -26

No files matched your search

@@ -0,0 +1,136 @@
// Logic test for the channel-level role binding (design/22 §4.4, 批次 2): the
// account-record → form prefill read, and the form value → PUT body mapping.
//
// The mapping rule that matters is omission vs explicit clear. A plain string
// field cannot tell `personaId: ""` from an absent key on the wire, so the panel
// must only send the key when it means something: an untouched select on an
// account that already reads as unbound sends NOTHING (both backend readings of an
// omitted field — "keep" or "clear" — then leave the stored binding where it is),
// while picking「跟随默认角色」on a bound account sends `""` (a real clear).
// The project has no frontend test runner, so this is a dependency-free
// plain-node check driving the real pure module and throwing on the first
// failure. Run it with the esbuild that already ships with vite:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelPersona.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-channel-persona.test.mjs \
// && node /tmp/cyrene-channel-persona.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import type { AdminChannelAccount } from "../../../lib/types";
import {
channelPersonaOf,
channelPersonaRequest,
withChannelPersona,
} from "./channelPersona";
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>): AdminChannelAccount =>
({ id: "conf0", ...a }) as AdminChannelAccount;
// ── Read side: the account record backfills the select ────────────────────────
same(channelPersonaOf(acct({ personaId: "shiro" })), "shiro", "top-level personaId backfills");
same(channelPersonaOf(acct({ personaId: " shiro " })), "shiro", "the backfilled value is trimmed");
// `omitempty` means "unbound" arrives as an absent key, never as "".
same(channelPersonaOf(acct({})), "", "an absent personaId reads as 未绑定");
same(channelPersonaOf(acct({ personaId: "" })), "", "an explicit empty string reads as 未绑定");
same(channelPersonaOf(undefined), "", "no account record reads as 未绑定, never a guess");
// Fallback for a backend that nests the value inside `config` — without it a real
// binding would display as「未绑定」and a save could write over it.
same(
channelPersonaOf(acct({ config: { personaId: "shiro", bot_token: "x" } })),
"shiro",
"a config-nested personaId is read as a fallback",
);
// A present top-level field is authoritative, even when empty.
same(
channelPersonaOf(acct({ personaId: "", config: { personaId: "shiro" } })),
"",
"a present top-level field wins over the nested fallback",
);
same(
channelPersonaOf(acct({ config: { personaId: 42 } })),
"",
"a non-string nested value is not read as a role id",
);
// ── Write side: the request body ──────────────────────────────────────────────
// Bound → send the id (a re-bind, and idempotent).
same(channelPersonaRequest({ id: "shiro", dirty: false }), "shiro", "a bound select sends its id");
same(channelPersonaRequest({ id: " shiro ", dirty: true }), "shiro", "a chosen id is trimmed");
// Never touched and reads unbound → omit the key entirely.
same(
channelPersonaRequest({ id: "", dirty: false }),
undefined,
"an untouched unbound select omits personaId",
);
// Owner explicitly picked「跟随默认角色」→ send "" so the stored binding is cleared.
same(channelPersonaRequest({ id: "", dirty: true }), "", "an explicit clear sends an empty string");
// The body-level mapping, exercised as it is on the wire: the key must be absent
// from the JSON when omitted (that is what JSON.stringify does to undefined).
same(
withChannelPersona({ id: "conf0", token: undefined }, { id: "", dirty: false }),
{ id: "conf0", token: undefined },
"an untouched unbound select leaves the body untouched",
);
same(
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: "", dirty: false })),
'{"id":"conf0"}',
"the omitted personaId is absent from the serialized body",
);
same(
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: "shiro", dirty: true })),
'{"id":"conf0","personaId":"shiro"}',
"a chosen role reaches the body",
);
same(
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: "", dirty: true })),
'{"id":"conf0","personaId":""}',
"an explicit clear reaches the body as an empty string",
);
// The original body is never mutated (the form's other fields stay as they are).
{
const body = { id: "conf0" };
const out = withChannelPersona(body, { id: "shiro", dirty: true });
same(body, { id: "conf0" }, "the input body is not mutated");
same(out, { id: "conf0", personaId: "shiro" }, "the returned body carries the personaId");
}
// ── Round-trip: what the read side produced must not be re-written as a clear ──
// Opening a bound account and saving without touching the select re-sends the same
// id (idempotent under either omission semantics).
{
const value = channelPersonaOf(acct({ personaId: "shiro" }));
same(
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: value, dirty: false })),
'{"id":"conf0","personaId":"shiro"}',
"save-without-edit re-sends the stored binding",
);
}
// … and opening an unbound account and saving without touching it sends nothing.
{
const value = channelPersonaOf(acct({}));
same(
JSON.stringify(withChannelPersona({ id: "conf0" }, { id: value, dirty: false })),
'{"id":"conf0"}',
"save-without-edit on an unbound account sends no personaId",
);
}
console.log(`channel persona logic test: ${checks} checks passed`);
@@ -0,0 +1,51 @@
import type { AdminChannelAccount } from "../../../lib/types";
/** Reading the channel account's bound role (design/22 §4.4, 批次 2).
*
* The contract puts `personaId` at the top level of the account record. The
* `config` map is accepted as a fallback so a backend that nests the value there
* still backfills the form instead of showing a real binding as「未绑定」.
* A present top-level field is authoritative even when it is `""` (explicitly
* unbound); only an absent one falls through. */
export function channelPersonaOf(account: AdminChannelAccount | undefined): string {
if (!account) return "";
const top = account.personaId;
if (typeof top === "string") return top.trim();
const nested = account.config?.personaId;
return typeof nested === "string" ? nested.trim() : "";
}
/** The role select's state: the id it shows, plus whether the owner touched it.
* The dirty flag is what lets an explicit clear differ from "so far unbound". */
export interface ChannelPersonaValue {
id: string;
dirty: boolean;
}
/** Map the select onto the `personaId` field of the channel-config PUT body.
*
* - a non-empty id → send it (bind / re-bind);
* - an empty id the owner *changed* to empty → send `""`, an explicit clear;
* - an empty id the owner never touched → omit the key. The account already
* reads as unbound, so both readings of an omitted field by the backend
* ("keep the stored value" / "clear it") leave the stored binding where it is
* — which is exactly the read-modify-write semantics of this endpoint.
*
* `undefined` means "leave the key out"; `""` means "clear it". They are never
* conflated, because a plain string field cannot distinguish them on the wire. */
export function channelPersonaRequest(value: ChannelPersonaValue): string | undefined {
const id = value.id.trim();
if (id !== "") return id;
return value.dirty ? "" : undefined;
}
/** The PUT body with `personaId` attached only when the mapping produced one, so
* an untouched unbound select never writes an empty string over the stored
* binding. */
export function withChannelPersona<T extends Record<string, unknown>>(
body: T,
value: ChannelPersonaValue,
): T {
const personaId = channelPersonaRequest(value);
return personaId === undefined ? body : { ...body, personaId };
}
@@ -1,5 +1,6 @@
import type { AdminChannelRecord } from "../../../lib/types";
import { PLATFORM_FIELDS } from "../channelFields";
import { channelPersonaOf } from "./channelPersona";
import type { ChannelConfigInitial } from "../channelConfigForm";
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
@@ -49,6 +50,7 @@ export function buildCfgTarget(name: string, records: AdminChannelRecord[]): Cfg
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
personaId: channelPersonaOf(acct),
},
};
}