Commit a7ab3e83 by luoqi

Merge FridayAI narrow navigation and QR routing into test

parents da4ccf77 156d9ab6
Pipeline #3870 failed in 0 seconds
......@@ -619,6 +619,11 @@ export class QrLoginService {
if (!events.length) return { phase: 'pending', hint: '正在生成二维码…' };
// 环境冲突必须在成功绑定和账号同步之前拦下;扫码不会转移消息归属。
if (events.some((e) => e.payload.__ingressLoginConflict === true)) {
return { phase: 'failed', hint: '请不要在手机上确认登录。这个企业微信号的消息由其他环境处理,当前环境不能托管。请使用对应环境,或联系管理员确认消息归属。' };
}
// [11026] 登录成功 —— 最高优先级
const ok = events.find((e) => e.event_type === '11026');
if (ok) {
......
......@@ -80,7 +80,9 @@ export class InboundService {
},
});
if (!account) return; // 未知账号:只留证据,不入队(下面会 warn)
if (!account) return; // 未知账号只留证据。
// 冲突回执供扫码页面解释;不能推进本环境账号状态或触发同步。
if ([11028, 15002, 11026].includes(eventType ?? -1) && prop(body, '__ingressLoginConflict') === true) return;
await tx.$executeRaw`
INSERT INTO app.outbox (tenant_id, task_type, route_key, payload, dedup_key, payload_hash)
......
......@@ -21,13 +21,14 @@ export function decodeIngress(
}
export function parseIngressEnvelope(body: unknown) {
const value = body as { deliveryId?: unknown; rawBody?: unknown } | null;
const value = body as { deliveryId?: unknown; rawBody?: unknown; loginConflict?: unknown } | null;
if (
!value ||
typeof value.deliveryId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value.deliveryId,
) ||
(value.loginConflict !== undefined && typeof value.loginConflict !== 'boolean') ||
typeof value.rawBody !== 'string' ||
Buffer.byteLength(value.rawBody) > 256 * 1024
) {
......@@ -41,5 +42,10 @@ export function parseIngressEnvelope(body: unknown) {
} catch {
payload = { rawBody: value.rawBody, parseError: true };
}
// 保留供应商原文;冲突仅接受已鉴权的中转信封,不信报文自行声明。
const record = payload as Record<string, unknown>;
delete record.__ingressLoginConflict;
if (value.loginConflict === true && [11028, 15002, 11026].includes(Number(record.type)))
record.__ingressLoginConflict = true;
return { deliveryId: value.deliveryId, rawBody: value.rawBody, payload };
}
......@@ -20,3 +20,15 @@ describe('持久中转传输契约', () => {
expect(result.payload).toEqual({ rawBody: '{bad', parseError: true });
});
});
it('登录冲突只从可信信封取得,不能由供应商正文伪造,也不影响普通消息', () => {
const decode = (type: number, loginConflict?: boolean) => decodeIngress({
deliveryId: randomUUID(), loginConflict,
rawBody: JSON.stringify({type, __ingressLoginConflict: true}),
}, headers, secret).payload;
expect(decode(15002, true)).toEqual({type: 15002, __ingressLoginConflict: true});
expect(decode(11026, true)).toEqual({type: 11026, __ingressLoginConflict: true});
expect(decode(15002)).toEqual({type: 15002});
expect(decode(11041, true)).toEqual({type: 11041});
});
import { QrLoginService } from '../src/modules/auth/qr-login.service';
import { InboundService } from '../src/modules/channel/inbound.service';
import { randomUUID } from 'node:crypto';
describe('扫码环境归属', () => {
const user = { id: 'user', tenantId: 'tenant' } as never;
it.each(['15002', '11026'])('冲突 %s 显示失败,不绑定或启动同步', async (event_type) => {
const prisma = { $queryRaw: jest.fn().mockResolvedValue([{ event_type, payload: { __ingressLoginConflict: true } }]) };
const service = new QrLoginService(prisma as never, {} as never, { assertRound: jest.fn() } as never);
const bind = jest.spyOn(service as any, 'bindAccount');
const result = await service.status(user, 'device', new Date());
expect(result.phase).toBe('failed');
expect(result.hint).toContain('其他环境');
expect(bind).not.toHaveBeenCalled();
});
it('同环境仍正常显示验证码阶段', async () => {
const prisma = { $queryRaw: jest.fn().mockResolvedValue([{ event_type: '15002', payload: { data: { state: 10 } } }]) };
const service = new QrLoginService(prisma as never, {} as never, { assertRound: jest.fn() } as never);
expect((await service.status(user, 'device', new Date())).phase).toBe('need_code');
});
it('冲突的成功事件只留档,不改变已有账号在线状态或触发通讯录同步', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([{id: 'receipt'}]), webhookDelivery: {create: jest.fn()}, $executeRaw: jest.fn() };
const prisma = {wecomAccount: {findUnique: jest.fn().mockResolvedValue({id:'account',tenantId:'tenant',vid:'123'})}, $transaction: jest.fn((fn) => fn(tx))};
const service = new InboundService(prisma as never);
await service.receiveDelivery({deliveryId:randomUUID(), rawBody:JSON.stringify({type:11026,userId:'123'}),loginConflict:true});
expect(tx.webhookDelivery.create).toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
......@@ -135,7 +135,7 @@ export default function NotifyPage() {
const pickable = !!data?.canPickNotifier && !busy;
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
<Sidebar
me={me}
waitingCount={0}
......
......@@ -240,7 +240,7 @@ export default function AppointmentConfirmPage() {
const counts = page?.counts;
return (
<div className="flex h-screen">
<div className="flex h-screen flex-col xl:flex-row">
<Sidebar
me={me}
waitingCount={0}
......@@ -265,7 +265,7 @@ export default function AppointmentConfirmPage() {
onMeChange={setMe}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<header className="flex shrink-0 flex-wrap items-center gap-x-2.5 gap-y-2 bg-white px-4 py-2.5">
<h1 className="text-[15px] font-semibold tracking-[-.2px]">预约确认</h1>
<DateNav date={date} onChange={setDate} />
......
......@@ -320,7 +320,7 @@ export default function ClinicPage() {
});
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
<Sidebar
me={me}
waitingCount={0}
......@@ -397,7 +397,7 @@ export default function ClinicPage() {
</aside>
{/* 右:表单 */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
{!view || !draft ? (
loadErr ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center">
......
......@@ -306,7 +306,7 @@ function Contacts() {
const noAccount = !!me && !me.account;
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
<Sidebar
me={me}
waitingCount={0}
......@@ -331,7 +331,7 @@ function Contacts() {
onMeChange={setMe}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
{/*
⛔ 原来这儿有条 56px 的白色顶栏,里面只写着「通讯录」——
而侧栏那一项正高亮着,等于把同一句话说两遍,白占一屏 5% 的高度。
......
......@@ -278,7 +278,7 @@ export default function CorpusPage() {
const hasInherited = !!view?.zones.some((z) => z.slots.some((s) => s.inherited.length));
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
<Sidebar
me={me}
waitingCount={0}
......@@ -362,7 +362,7 @@ export default function CorpusPage() {
</aside>
{/* 右 */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
{!view ? (
loadErr ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center">
......
......@@ -156,7 +156,7 @@ function DebugPageInner() {
const convName = conv ? (list?.rounds.find((r) => r.conversationId === conv)?.who ?? '这条会话') : null;
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
<Sidebar
me={me}
waitingCount={0}
......@@ -180,7 +180,7 @@ function DebugPageInner() {
{me && !allowed ? (
<NoAccess>调试页只有管理员能看:这里有提示词全文和患者信息。需要的话请联系管理员。</NoAccess>
) : (
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<header className="px-5 pb-2 pt-4">
<h1 className="text-[15px] font-semibold">调试</h1>
<p className="mt-1 max-w-[760px] text-[11.5px] leading-[1.8] text-slate-500">
......
......@@ -549,7 +549,7 @@ function Workbench() {
}, [filter, items]);
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
{/*
⚠️ 只挂在工作台这一页 —— 登录后落的就是这里。
⛔ 别挂进全局布局:通讯录、预约确认那几页也各自取 me,
......@@ -557,8 +557,7 @@ function Workbench() {
⚠️ me 还没到时不渲染(下面那个 if 已经拦了),所以这里 me 一定有值。
*/}
{me ? <ProfileSetup me={me} onDone={setMe} /> : null}
{/* ⚠️ 侧栏只在 ≥1280px 显示 —— 更窄的屏幕上它挤掉的是消息流的宽度,
而消息流才是这个页面的主体 */}
{/* 大屏固定侧栏;窄屏由共享导航提供菜单抽屉。 */}
<Sidebar
me={me}
waitingCount={waitingBadge}
......@@ -572,49 +571,7 @@ function Workbench() {
className="hidden xl:flex"
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* 移动端顶栏:侧栏在这个宽度下不显示,身份信息挪到这里 */}
<div className="flex h-11 shrink-0 items-center gap-2 bg-white px-4 xl:hidden">
<div className="grid size-5 place-items-center rounded-md bg-brand-600 text-[11px] font-bold text-white">
F
</div>
<span className="text-[12.5px] font-semibold">Friday</span>
<span className="ml-auto truncate text-[11px] text-slate-500">
{me?.displayName}
{/* ⚠️ 同侧栏:判据是**全部号**,⛔ 不是 me.account(accounts[0]) */}
{acct.state !== 'none' ? (
<span
className={
acct.state === 'ok'
? 'text-emerald-600'
: acct.state === 'partial'
? 'text-amber-600'
: acct.state === 'released'
? 'text-slate-500'
: 'text-rose-600'
}
>
{' · '}
{/* ⚠️ 同侧栏:按还在托管的号数;全都解除了是灰字,⛔ 不说「掉线」 */}
{acct.state === 'ok'
? '在线'
: acct.state === 'partial'
? `${acct.hosted - acct.online}/${acct.hosted} 掉线`
: acct.state === 'released'
? '已解除托管'
: '掉线'}
</span>
) : null}
</span>
<Button
variant="ghost"
size="xs"
onClick={() => void logout()}
className="shrink-0 text-slate-400"
>
⏻
</Button>
</div>
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{/*
⚠️⚠️ 号不可用(没绑 / 掉线 / 待验证 / 封号 / 主动停)时,**整个工作台换成一张卡**。
......
......@@ -42,7 +42,7 @@ export function AdminShell({
const allowed = !!me && ADMIN_PERMS.some((p) => can(me, p));
return (
<div className="flex h-screen overflow-hidden text-[12.5px] text-slate-900">
<div className="flex h-screen flex-col xl:flex-row overflow-hidden text-[12.5px] text-slate-900">
<Sidebar
me={me}
waitingCount={0}
......@@ -64,7 +64,7 @@ export function AdminShell({
onBindChange={setBind}
onMeChange={setMe}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-slate-50">
{!me ? (
<div className="flex flex-1 items-center justify-center text-[11.5px] text-slate-400">加载中…</div>
) : !allowed ? (
......
'use client';
import { hostAllows } from '@/lib/api';
import { useState } from 'react';
import { useEffect, useState, type ComponentProps } from 'react';
import { Menu } from 'lucide-react';
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetTrigger } from '@/components/ui/sheet';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ADMIN_PERMS, can, PERM, type ClinicOption, type Me } from '@/lib/api';
......@@ -128,7 +130,7 @@ const NAV_GROUPS: {
},
];
export function Sidebar({
function SidebarContent({
me,
waitingCount,
onLogout,
......@@ -139,6 +141,7 @@ export function Sidebar({
onBind,
confirmLeave,
className = '',
onNavigated,
}: {
me: Me | null;
waitingCount: number;
......@@ -174,6 +177,7 @@ export function Sidebar({
*/
confirmLeave?: () => boolean;
className?: string;
onNavigated?: () => void;
}) {
// ⭐ 名下号的整体状态 —— ⛔ 别读 me.account(那是 accounts[0]),见 accountsSummary
const acct = accountsSummary(me?.accounts ?? []);
......@@ -249,13 +253,13 @@ export function Sidebar({
aria-current={active ? 'page' : undefined}
className={cls}
// ⚠️ onNavigate 只在本标签页内跳转时触发 —— ⌘/Ctrl 点开新标签页不丢这一页,也就不问
onNavigate={
confirmLeave && !active
? (e) => {
if (!confirmLeave()) e.preventDefault();
}
: undefined
}
onNavigate={(e) => {
if (confirmLeave && !active && !confirmLeave()) {
e.preventDefault();
return;
}
onNavigated?.();
}}
>
{inner}
</Link>
......@@ -545,3 +549,44 @@ function ClinicSwitch({
</Select>
);
}
/** 窄屏复用同一份导航和权限;抽屉不另维护菜单。 */
export function Sidebar(props: ComponentProps<typeof SidebarContent>) {
const [open, setOpen] = useState(false);
const acct = accountsSummary(props.me?.accounts ?? []);
useEffect(() => {
const wide = window.matchMedia('(min-width: 1280px)');
const closeOnWide = () => { if (wide.matches) setOpen(false); };
wide.addEventListener('change', closeOnWide);
return () => wide.removeEventListener('change', closeOnWide);
}, []);
const closeThen = (action?: () => void) => action ? () => { setOpen(false); action(); } : undefined;
return <>
<SidebarContent {...props} className="hidden xl:flex" />
<header className="flex h-11 shrink-0 items-center gap-2 bg-white px-2 xl:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button type="button" aria-label="打开导航菜单" title="导航菜单"
className="grid size-10 shrink-0 place-items-center rounded-lg text-slate-600 transition hover:bg-brand-50 hover:text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Menu className="size-5" aria-hidden="true" />
</button>
</SheetTrigger>
<SheetContent className="left-0 right-auto w-[280px] max-w-[calc(100vw-32px)] bg-white shadow-xl">
<SheetTitle className="sr-only">Friday 导航菜单</SheetTitle>
<SheetDescription>选择工作台页面或管理企业微信账号</SheetDescription>
<SidebarContent {...props} className="h-full !w-full" onNavigated={() => setOpen(false)}
onOpenAccounts={closeThen(props.onOpenAccounts)} onBind={closeThen(props.onBind)}
onLogout={closeThen(props.onLogout)!} />
</SheetContent>
</Sheet>
<div className="grid size-5 place-items-center rounded-md bg-brand-600 text-[11px] font-bold text-white">F</div>
<span className="text-[12.5px] font-semibold">Friday</span>
<span className="ml-auto min-w-0 truncate pr-2 text-[11px] text-slate-500">
{props.me?.displayName}
{acct.state !== 'none' && <span className={acct.state === 'ok' ? 'text-emerald-600' : acct.state === 'released' ? 'text-slate-500' : 'text-amber-600'}>
{' · '}{acct.state === 'ok' ? '在线' : acct.state === 'released' ? '已解除托管' : acct.state === 'partial' ? `${acct.hosted - acct.online}/${acct.hosted} 掉线` : '掉线'}
</span>}
</span>
</header>
</>;
}
......@@ -76,6 +76,7 @@ export async function callbackRoute(
}
let destination =
localAccounts.includes('*') || (vid && localAccounts.includes(vid)) ? 'local' : 'server';
let loginConflict = false;
if (vid) {
await tx.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [
'ingress-account:' + vid,
......@@ -85,10 +86,14 @@ export async function callbackRoute(
[vid],
);
if (known.rows[0]?.destination === 'local') destination = 'local';
const owner = known.rows[0]?.destination === 'local' || localAccounts.includes('*') || localAccounts.includes(vid)
? 'local' : known.rows[0]?.destination;
// 登录回执跟随发起轮次,账号消息归属不随扫码改变。
loginConflict = loginEvent && !!owner && owner !== (round ? 'local' : 'server');
// 已在服务端使用的账号不被本地扫码挪走。登录事件仍回发起端用于展示状态。
if (!known.rowCount && round) destination = 'local';
if (
!known.rowCount &&
!known.rowCount && !loginConflict &&
(!loginEvent || kind === 11026) &&
!(destination === 'local' && (localAccounts.includes('*') || localAccounts.includes(vid)))
) {
......@@ -98,8 +103,8 @@ export async function callbackRoute(
]);
}
}
if (round && loginEvent) destination = 'local';
return { destination, route: loginEvent && device ? 'device:' + device : vid || 'unknown' };
if (loginEvent) destination = round ? 'local' : 'server';
return { destination, route: loginEvent && device ? 'device:' + device : vid || 'unknown', loginConflict };
}
export async function assertLocalAccount(
......
......@@ -45,7 +45,7 @@ export function createIngressServer(store: Store, config: Config) {
}
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ deliveryId: item.id, rawBody: item.rawBody, lease: item.lease }),
JSON.stringify({ deliveryId: item.id, rawBody: item.rawBody, loginConflict: item.loginConflict, lease: item.lease }),
);
return;
}
......
......@@ -15,6 +15,7 @@ export interface Delivery {
rawBody: string;
lease: string;
attempts: number;
loginConflict?: boolean;
}
export interface QueueStats {
pending: number;
......@@ -52,6 +53,7 @@ CREATE INDEX IF NOT EXISTS ix_ingress_route ON ingress.deliveries (route_key, se
CREATE INDEX IF NOT EXISTS ix_ingress_delivered ON ingress.deliveries (delivered_at) WHERE delivered_at IS NOT NULL;
ALTER TABLE ingress.deliveries ADD COLUMN IF NOT EXISTS destination text NOT NULL DEFAULT 'server'
CHECK (destination IN ('server', 'local'));
ALTER TABLE ingress.deliveries ADD COLUMN IF NOT EXISTS login_conflict boolean NOT NULL DEFAULT false;
${loginRouteSchema}
`;
......@@ -92,10 +94,10 @@ export class PgStore implements Store {
const tx = await this.pool.connect();
try {
await tx.query('BEGIN');
const { route, destination } = await callbackRoute(tx, body, this.localAccounts);
const { route, destination, loginConflict } = await callbackRoute(tx, body, this.localAccounts);
await tx.query(
'INSERT INTO ingress.deliveries (id,route_key,raw_body,destination) VALUES($1,$2,$3,$4)',
[id, route, rawBody, destination],
'INSERT INTO ingress.deliveries (id,route_key,raw_body,destination,login_conflict) VALUES($1,$2,$3,$4,$5)',
[id, route, rawBody, destination, loginConflict],
);
await tx.query('COMMIT');
return id;
......@@ -115,13 +117,14 @@ export class PgStore implements Store {
SELECT d.id FROM ingress.deliveries d
WHERE d.delivered_at IS NULL AND d.available_at <= clock_timestamp() AND d.destination=$2
AND NOT EXISTS (SELECT 1 FROM ingress.deliveries prev
WHERE prev.route_key=d.route_key AND prev.seq<d.seq AND prev.delivered_at IS NULL)
WHERE prev.route_key=d.route_key AND prev.seq<d.seq AND prev.delivered_at IS NULL
AND (prev.destination=d.destination OR d.route_key NOT LIKE 'device:%'))
ORDER BY d.seq LIMIT 1 FOR UPDATE SKIP LOCKED
)
UPDATE ingress.deliveries d SET lease=$1, attempts=attempts+1,
available_at=clock_timestamp()+interval '60 seconds'
FROM candidate c WHERE d.id=c.id
RETURNING d.id,d.raw_body AS "rawBody",d.lease,d.attempts`,
RETURNING d.id,d.raw_body AS "rawBody",d.lease,d.attempts,d.login_conflict AS "loginConflict"`,
[randomUUID(), destination],
);
return (rows.rows[0] as Delivery | undefined) ?? null;
......
......@@ -10,7 +10,7 @@ export async function deliverOne(store: Store, config: Config, send = fetch): Pr
redirect: 'error',
signal: AbortSignal.timeout(10_000),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` },
body: JSON.stringify({ deliveryId: item.id, rawBody: item.rawBody }),
body: JSON.stringify({ deliveryId: item.id, rawBody: item.rawBody, loginConflict: item.loginConflict }),
});
if (response.ok && (await response.text()) === 'success') {
await store.delivered(item);
......
......@@ -227,3 +227,31 @@ test('扫码路由接口必须用本地密钥;校验入参并保留冲突状
await new Promise<void>((r) => server.close(() => r()));
}
});
test('冲突提示随可信信封传给两端,供应商原文不变', async () => {
const store = new MemoryStore();
const rawBody = '{"type":15002,"data":{"state":10}}';
await store.append(rawBody);
store.items[0]!.loginConflict = true;
await deliverOne(store, config, async (_url, init) => {
const envelope = JSON.parse(String(init?.body));
assert.equal(envelope.loginConflict, true);
assert.equal(envelope.rawBody, rawBody);
return new Response('success');
});
await store.append(rawBody);
store.items[0]!.loginConflict = true;
const server = createIngressServer(store, { ...config, localToken: 'b'.repeat(43) });
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const response = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}/local/pull`, {
method:'POST', headers:{Authorization:`Bearer ${'b'.repeat(43)}`},
});
const envelope = await response.json() as Record<string,unknown>;
assert.equal(envelope.loginConflict, true);
assert.equal(envelope.rawBody, rawBody);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
......@@ -95,10 +95,11 @@ test(
await new PgStore(pool).verifyRouting(); // 排空后才允许切换归属。
const all = new PgStore(pool, ['*']);
ids.push(await all.append('{"type":11028}'));
assert.equal(await all.claim(), null); // 没有 userId 的扫码事件也能整条通道交给本地。
const login = await all.claim('local');
// 通配符只控制账号消息;未登记为本地发起的扫码回执仍回服务器。
assert.equal(await all.claim('local'), null);
const login = await all.claim('server');
assert.equal(login?.id, ids[2]);
await all.acknowledgeLocal(login!.id, login!.lease);
await all.delivered(login!);
} finally {
server.closeAllConnections();
await new Promise<void>((r) => server.close(() => r()));
......
......@@ -101,3 +101,68 @@ test(
}
},
);
test('测试扫码回执不被本地账号分流;消息归属不变,冲突随回执交付',
{ skip: !process.env.INGRESS_TEST_DATABASE_URL }, async () => {
const url = new URL(process.env.INGRESS_TEST_DATABASE_URL!);
assert.ok(['localhost', '127.0.0.1'].includes(url.hostname));
assert.equal(url.pathname, '/friday_ai');
const pool = createPool(url.href);
const localAccount = randomUUID(), serverAccount = randomUUID();
const localTrace = randomUUID(), localDevice = randomUUID();
const serverTrace = randomUUID(), serverDevice = randomUUID();
const ids: string[] = [];
const store = new PgStore(pool, [localAccount]);
const append = async (body: unknown) => {
const id = await store.append(JSON.stringify(body)); ids.push(id);
return (await pool.query('SELECT destination,login_conflict FROM ingress.deliveries WHERE id=$1', [id])).rows[0];
};
try {
await pool.query(schema);
assert.deepEqual(await append({ type: 11028, userId: 0, traceId: serverTrace, deviceId: serverDevice }), { destination: 'server', login_conflict: false });
for (const type of [15002, 11026]) {
assert.deepEqual(await append({ type, userId: localAccount, traceId: serverTrace, deviceId: serverDevice }), { destination: 'server', login_conflict: true });
}
// 传输信封带冲突信息,但保留供应商原始正文。
for (let i = 0; i < 3; i++) {
const item = await store.claim('server');
assert.equal(item?.id, ids[i]);
assert.equal(item?.loginConflict, i > 0);
assert.equal(JSON.parse(item!.rawBody).__ingressLoginConflict, undefined);
await store.delivered(item!);
}
assert.deepEqual(await append({ type: 11041, userId: localAccount }), { destination: 'local', login_conflict: false });
// 已持久化的本地账号同样不能被服务端扫码接管。
await pool.query("INSERT INTO ingress.account_routes(account_id,destination) VALUES($1,'local')", [localAccount]);
assert.deepEqual(await append({ type: 15002, userId: localAccount, deviceId: randomUUID() }), { destination: 'server', login_conflict: true });
await store.registerLocalLogin(localTrace);
await store.registerLocalLogin(localTrace, localDevice);
for (const type of [11028, 15002, 11026])
assert.deepEqual(await append({ type, userId: localAccount, traceId: localTrace, deviceId: localDevice }), { destination: 'local', login_conflict: false });
assert.deepEqual(await append({ type: 11041, userId: serverAccount }), { destination: 'server', login_conflict: false });
assert.deepEqual(await append({ type: 15002, userId: serverAccount, deviceId: localDevice }), { destination: 'local', login_conflict: true });
assert.deepEqual(await append({ type: 11026, userId: serverAccount, deviceId: serverDevice }), { destination: 'server', login_conflict: false });
assert.deepEqual(await append({ type: 11041, userId: localAccount }), { destination: 'local', login_conflict: false });
assert.deepEqual(await append({ type: 11041, userId: serverAccount }), { destination: 'server', login_conflict: false });
await store.verifyRouting();
// 旧本地扫码回执积压,不得阻塞同设备后来发起的服务器扫码。
await pool.query("UPDATE ingress.local_login_routes SET expires_at=now()-interval '1 second' WHERE trace_id=$1", [localTrace]);
const pending = await append({ type: 15002, userId: localAccount, deviceId: localDevice });
assert.equal(pending.destination, 'server');
const target = ids[ids.length - 1];
let delivered = false;
for (let i = 0; i < ids.length; i++) {
const item = await store.claim('server');
if (!item) break;
if (item.id === target) delivered = true;
await store.delivered(item);
}
assert.equal(delivered, true);
} finally {
await pool.query('DELETE FROM ingress.deliveries WHERE id=ANY($1::uuid[])', [ids]);
await pool.query('DELETE FROM ingress.local_login_routes WHERE trace_id=$1', [localTrace]);
await pool.query('DELETE FROM ingress.account_routes WHERE account_id=ANY($1::text[])', [[localAccount, serverAccount]]);
await pool.end();
}
});
......@@ -17,6 +17,8 @@ AI 使用 `apps/ai-service/.env` 和 `deploy/secrets/friday-ai-jwt.pem`;中转
数据库、JWT、中转令牌与服务密钥按环境独立管理。已有配置先备份、比较,不用示例覆盖,不在日常发布中重新生成密钥。生产禁止本地调试分流;测试保留已确认的账号归属和调试密钥。
扫码回执按发起轮次返回:已登记的本地扫码回本地,其余回服务器;`INGRESS_LOCAL_ACCOUNTS` 只决定账号消息归属,不接管服务器扫码。跨环境扫码显示冲突,不能自动转移账号或消息。本次路由版本须先发布 AI(识别冲突信封),再发布中转;旧积压不改归属、不自动重放。
PAC `PAC_READ_SECRET` 与 AI `AI_PAC_READ_SECRET` 对应;`AI_INGRESS_TOKEN` 与中转 `INGRESS_TOKEN` 对应。代理层数、供应商回调来源、模型与对象存储权限分别核实,不能用健康检查代替依赖验收。
## 发布前
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment