feat(admin): 人格页「绑定会话数」+ 渠道账号绑定角色(B 批次 2 前端)
按 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:
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user