Commit 156d9ab6 by luoqi

fix(ai): restore narrow navigation and route QR callbacks by origin

parent 427202a6
Pipeline #3869 failed in 0 seconds
...@@ -619,6 +619,11 @@ export class QrLoginService { ...@@ -619,6 +619,11 @@ export class QrLoginService {
if (!events.length) return { phase: 'pending', hint: '正在生成二维码…' }; if (!events.length) return { phase: 'pending', hint: '正在生成二维码…' };
// 环境冲突必须在成功绑定和账号同步之前拦下;扫码不会转移消息归属。
if (events.some((e) => e.payload.__ingressLoginConflict === true)) {
return { phase: 'failed', hint: '请不要在手机上确认登录。这个企业微信号的消息由其他环境处理,当前环境不能托管。请使用对应环境,或联系管理员确认消息归属。' };
}
// [11026] 登录成功 —— 最高优先级 // [11026] 登录成功 —— 最高优先级
const ok = events.find((e) => e.event_type === '11026'); const ok = events.find((e) => e.event_type === '11026');
if (ok) { if (ok) {
......
...@@ -80,7 +80,9 @@ export class InboundService { ...@@ -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` await tx.$executeRaw`
INSERT INTO app.outbox (tenant_id, task_type, route_key, payload, dedup_key, payload_hash) INSERT INTO app.outbox (tenant_id, task_type, route_key, payload, dedup_key, payload_hash)
......
...@@ -21,13 +21,14 @@ export function decodeIngress( ...@@ -21,13 +21,14 @@ export function decodeIngress(
} }
export function parseIngressEnvelope(body: unknown) { 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 ( if (
!value || !value ||
typeof value.deliveryId !== 'string' || 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( !/^[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.deliveryId,
) || ) ||
(value.loginConflict !== undefined && typeof value.loginConflict !== 'boolean') ||
typeof value.rawBody !== 'string' || typeof value.rawBody !== 'string' ||
Buffer.byteLength(value.rawBody) > 256 * 1024 Buffer.byteLength(value.rawBody) > 256 * 1024
) { ) {
...@@ -41,5 +42,10 @@ export function parseIngressEnvelope(body: unknown) { ...@@ -41,5 +42,10 @@ export function parseIngressEnvelope(body: unknown) {
} catch { } catch {
payload = { rawBody: value.rawBody, parseError: true }; 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 }; return { deliveryId: value.deliveryId, rawBody: value.rawBody, payload };
} }
...@@ -20,3 +20,15 @@ describe('持久中转传输契约', () => { ...@@ -20,3 +20,15 @@ describe('持久中转传输契约', () => {
expect(result.payload).toEqual({ rawBody: '{bad', parseError: true }); 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() { ...@@ -135,7 +135,7 @@ export default function NotifyPage() {
const pickable = !!data?.canPickNotifier && !busy; const pickable = !!data?.canPickNotifier && !busy;
return ( 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 <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
......
...@@ -240,7 +240,7 @@ export default function AppointmentConfirmPage() { ...@@ -240,7 +240,7 @@ export default function AppointmentConfirmPage() {
const counts = page?.counts; const counts = page?.counts;
return ( return (
<div className="flex h-screen"> <div className="flex h-screen flex-col xl:flex-row">
<Sidebar <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
...@@ -265,7 +265,7 @@ export default function AppointmentConfirmPage() { ...@@ -265,7 +265,7 @@ export default function AppointmentConfirmPage() {
onMeChange={setMe} 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"> <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> <h1 className="text-[15px] font-semibold tracking-[-.2px]">预约确认</h1>
<DateNav date={date} onChange={setDate} /> <DateNav date={date} onChange={setDate} />
......
...@@ -320,7 +320,7 @@ export default function ClinicPage() { ...@@ -320,7 +320,7 @@ export default function ClinicPage() {
}); });
return ( 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 <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
...@@ -397,7 +397,7 @@ export default function ClinicPage() { ...@@ -397,7 +397,7 @@ export default function ClinicPage() {
</aside> </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 ? ( {!view || !draft ? (
loadErr ? ( loadErr ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center">
......
...@@ -306,7 +306,7 @@ function Contacts() { ...@@ -306,7 +306,7 @@ function Contacts() {
const noAccount = !!me && !me.account; const noAccount = !!me && !me.account;
return ( 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 <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
...@@ -331,7 +331,7 @@ function Contacts() { ...@@ -331,7 +331,7 @@ function Contacts() {
onMeChange={setMe} 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 的白色顶栏,里面只写着「通讯录」—— ⛔ 原来这儿有条 56px 的白色顶栏,里面只写着「通讯录」——
而侧栏那一项正高亮着,等于把同一句话说两遍,白占一屏 5% 的高度。 而侧栏那一项正高亮着,等于把同一句话说两遍,白占一屏 5% 的高度。
......
...@@ -278,7 +278,7 @@ export default function CorpusPage() { ...@@ -278,7 +278,7 @@ export default function CorpusPage() {
const hasInherited = !!view?.zones.some((z) => z.slots.some((s) => s.inherited.length)); const hasInherited = !!view?.zones.some((z) => z.slots.some((s) => s.inherited.length));
return ( 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 <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
...@@ -362,7 +362,7 @@ export default function CorpusPage() { ...@@ -362,7 +362,7 @@ export default function CorpusPage() {
</aside> </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 ? ( {!view ? (
loadErr ? ( loadErr ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-2.5 px-6 text-center">
......
...@@ -156,7 +156,7 @@ function DebugPageInner() { ...@@ -156,7 +156,7 @@ function DebugPageInner() {
const convName = conv ? (list?.rounds.find((r) => r.conversationId === conv)?.who ?? '这条会话') : null; const convName = conv ? (list?.rounds.find((r) => r.conversationId === conv)?.who ?? '这条会话') : null;
return ( 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 <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
...@@ -180,7 +180,7 @@ function DebugPageInner() { ...@@ -180,7 +180,7 @@ function DebugPageInner() {
{me && !allowed ? ( {me && !allowed ? (
<NoAccess>调试页只有管理员能看:这里有提示词全文和患者信息。需要的话请联系管理员。</NoAccess> <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"> <header className="px-5 pb-2 pt-4">
<h1 className="text-[15px] font-semibold">调试</h1> <h1 className="text-[15px] font-semibold">调试</h1>
<p className="mt-1 max-w-[760px] text-[11.5px] leading-[1.8] text-slate-500"> <p className="mt-1 max-w-[760px] text-[11.5px] leading-[1.8] text-slate-500">
......
...@@ -549,7 +549,7 @@ function Workbench() { ...@@ -549,7 +549,7 @@ function Workbench() {
}, [filter, items]); }, [filter, items]);
return ( 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, ⛔ 别挂进全局布局:通讯录、预约确认那几页也各自取 me,
...@@ -557,8 +557,7 @@ function Workbench() { ...@@ -557,8 +557,7 @@ function Workbench() {
⚠️ me 还没到时不渲染(下面那个 if 已经拦了),所以这里 me 一定有值。 ⚠️ me 还没到时不渲染(下面那个 if 已经拦了),所以这里 me 一定有值。
*/} */}
{me ? <ProfileSetup me={me} onDone={setMe} /> : null} {me ? <ProfileSetup me={me} onDone={setMe} /> : null}
{/* ⚠️ 侧栏只在 ≥1280px 显示 —— 更窄的屏幕上它挤掉的是消息流的宽度, {/* 大屏固定侧栏;窄屏由共享导航提供菜单抽屉。 */}
而消息流才是这个页面的主体 */}
<Sidebar <Sidebar
me={me} me={me}
waitingCount={waitingBadge} waitingCount={waitingBadge}
...@@ -572,49 +571,7 @@ function Workbench() { ...@@ -572,49 +571,7 @@ function Workbench() {
className="hidden xl:flex" className="hidden xl:flex"
/> />
<main className="flex min-w-0 flex-1 flex-col overflow-hidden"> <main className="flex min-h-0 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>
{/* {/*
⚠️⚠️ 号不可用(没绑 / 掉线 / 待验证 / 封号 / 主动停)时,**整个工作台换成一张卡**。 ⚠️⚠️ 号不可用(没绑 / 掉线 / 待验证 / 封号 / 主动停)时,**整个工作台换成一张卡**。
......
...@@ -42,7 +42,7 @@ export function AdminShell({ ...@@ -42,7 +42,7 @@ export function AdminShell({
const allowed = !!me && ADMIN_PERMS.some((p) => can(me, p)); const allowed = !!me && ADMIN_PERMS.some((p) => can(me, p));
return ( 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 <Sidebar
me={me} me={me}
waitingCount={0} waitingCount={0}
...@@ -64,7 +64,7 @@ export function AdminShell({ ...@@ -64,7 +64,7 @@ export function AdminShell({
onBindChange={setBind} onBindChange={setBind}
onMeChange={setMe} 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 ? ( {!me ? (
<div className="flex flex-1 items-center justify-center text-[11.5px] text-slate-400">加载中…</div> <div className="flex flex-1 items-center justify-center text-[11.5px] text-slate-400">加载中…</div>
) : !allowed ? ( ) : !allowed ? (
......
'use client'; 'use client';
import { hostAllows } from '@/lib/api'; 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 Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { ADMIN_PERMS, can, PERM, type ClinicOption, type Me } from '@/lib/api'; import { ADMIN_PERMS, can, PERM, type ClinicOption, type Me } from '@/lib/api';
...@@ -128,7 +130,7 @@ const NAV_GROUPS: { ...@@ -128,7 +130,7 @@ const NAV_GROUPS: {
}, },
]; ];
export function Sidebar({ function SidebarContent({
me, me,
waitingCount, waitingCount,
onLogout, onLogout,
...@@ -139,6 +141,7 @@ export function Sidebar({ ...@@ -139,6 +141,7 @@ export function Sidebar({
onBind, onBind,
confirmLeave, confirmLeave,
className = '', className = '',
onNavigated,
}: { }: {
me: Me | null; me: Me | null;
waitingCount: number; waitingCount: number;
...@@ -174,6 +177,7 @@ export function Sidebar({ ...@@ -174,6 +177,7 @@ export function Sidebar({
*/ */
confirmLeave?: () => boolean; confirmLeave?: () => boolean;
className?: string; className?: string;
onNavigated?: () => void;
}) { }) {
// ⭐ 名下号的整体状态 —— ⛔ 别读 me.account(那是 accounts[0]),见 accountsSummary // ⭐ 名下号的整体状态 —— ⛔ 别读 me.account(那是 accounts[0]),见 accountsSummary
const acct = accountsSummary(me?.accounts ?? []); const acct = accountsSummary(me?.accounts ?? []);
...@@ -249,13 +253,13 @@ export function Sidebar({ ...@@ -249,13 +253,13 @@ export function Sidebar({
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={cls} className={cls}
// ⚠️ onNavigate 只在本标签页内跳转时触发 —— ⌘/Ctrl 点开新标签页不丢这一页,也就不问 // ⚠️ onNavigate 只在本标签页内跳转时触发 —— ⌘/Ctrl 点开新标签页不丢这一页,也就不问
onNavigate={ onNavigate={(e) => {
confirmLeave && !active if (confirmLeave && !active && !confirmLeave()) {
? (e) => { e.preventDefault();
if (!confirmLeave()) e.preventDefault(); return;
} }
: undefined onNavigated?.();
} }}
> >
{inner} {inner}
</Link> </Link>
...@@ -545,3 +549,44 @@ function ClinicSwitch({ ...@@ -545,3 +549,44 @@ function ClinicSwitch({
</Select> </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( ...@@ -76,6 +76,7 @@ export async function callbackRoute(
} }
let destination = let destination =
localAccounts.includes('*') || (vid && localAccounts.includes(vid)) ? 'local' : 'server'; localAccounts.includes('*') || (vid && localAccounts.includes(vid)) ? 'local' : 'server';
let loginConflict = false;
if (vid) { if (vid) {
await tx.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [ await tx.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [
'ingress-account:' + vid, 'ingress-account:' + vid,
...@@ -85,10 +86,14 @@ export async function callbackRoute( ...@@ -85,10 +86,14 @@ export async function callbackRoute(
[vid], [vid],
); );
if (known.rows[0]?.destination === 'local') destination = 'local'; 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 && round) destination = 'local';
if ( if (
!known.rowCount && !known.rowCount && !loginConflict &&
(!loginEvent || kind === 11026) && (!loginEvent || kind === 11026) &&
!(destination === 'local' && (localAccounts.includes('*') || localAccounts.includes(vid))) !(destination === 'local' && (localAccounts.includes('*') || localAccounts.includes(vid)))
) { ) {
...@@ -98,8 +103,8 @@ export async function callbackRoute( ...@@ -98,8 +103,8 @@ export async function callbackRoute(
]); ]);
} }
} }
if (round && loginEvent) destination = 'local'; if (loginEvent) destination = round ? 'local' : 'server';
return { destination, route: loginEvent && device ? 'device:' + device : vid || 'unknown' }; return { destination, route: loginEvent && device ? 'device:' + device : vid || 'unknown', loginConflict };
} }
export async function assertLocalAccount( export async function assertLocalAccount(
......
...@@ -45,7 +45,7 @@ export function createIngressServer(store: Store, config: Config) { ...@@ -45,7 +45,7 @@ export function createIngressServer(store: Store, config: Config) {
} }
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.end( 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; return;
} }
......
...@@ -15,6 +15,7 @@ export interface Delivery { ...@@ -15,6 +15,7 @@ export interface Delivery {
rawBody: string; rawBody: string;
lease: string; lease: string;
attempts: number; attempts: number;
loginConflict?: boolean;
} }
export interface QueueStats { export interface QueueStats {
pending: number; pending: number;
...@@ -52,6 +53,7 @@ CREATE INDEX IF NOT EXISTS ix_ingress_route ON ingress.deliveries (route_key, se ...@@ -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; 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' ALTER TABLE ingress.deliveries ADD COLUMN IF NOT EXISTS destination text NOT NULL DEFAULT 'server'
CHECK (destination IN ('server', 'local')); CHECK (destination IN ('server', 'local'));
ALTER TABLE ingress.deliveries ADD COLUMN IF NOT EXISTS login_conflict boolean NOT NULL DEFAULT false;
${loginRouteSchema} ${loginRouteSchema}
`; `;
...@@ -92,10 +94,10 @@ export class PgStore implements Store { ...@@ -92,10 +94,10 @@ export class PgStore implements Store {
const tx = await this.pool.connect(); const tx = await this.pool.connect();
try { try {
await tx.query('BEGIN'); 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( await tx.query(
'INSERT INTO ingress.deliveries (id,route_key,raw_body,destination) VALUES($1,$2,$3,$4)', 'INSERT INTO ingress.deliveries (id,route_key,raw_body,destination,login_conflict) VALUES($1,$2,$3,$4,$5)',
[id, route, rawBody, destination], [id, route, rawBody, destination, loginConflict],
); );
await tx.query('COMMIT'); await tx.query('COMMIT');
return id; return id;
...@@ -115,13 +117,14 @@ export class PgStore implements Store { ...@@ -115,13 +117,14 @@ export class PgStore implements Store {
SELECT d.id FROM ingress.deliveries d SELECT d.id FROM ingress.deliveries d
WHERE d.delivered_at IS NULL AND d.available_at <= clock_timestamp() AND d.destination=$2 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 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 ORDER BY d.seq LIMIT 1 FOR UPDATE SKIP LOCKED
) )
UPDATE ingress.deliveries d SET lease=$1, attempts=attempts+1, UPDATE ingress.deliveries d SET lease=$1, attempts=attempts+1,
available_at=clock_timestamp()+interval '60 seconds' available_at=clock_timestamp()+interval '60 seconds'
FROM candidate c WHERE d.id=c.id 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], [randomUUID(), destination],
); );
return (rows.rows[0] as Delivery | undefined) ?? null; return (rows.rows[0] as Delivery | undefined) ?? null;
......
...@@ -10,7 +10,7 @@ export async function deliverOne(store: Store, config: Config, send = fetch): Pr ...@@ -10,7 +10,7 @@ export async function deliverOne(store: Store, config: Config, send = fetch): Pr
redirect: 'error', redirect: 'error',
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` }, 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') { if (response.ok && (await response.text()) === 'success') {
await store.delivered(item); await store.delivered(item);
......
...@@ -227,3 +227,31 @@ test('扫码路由接口必须用本地密钥;校验入参并保留冲突状 ...@@ -227,3 +227,31 @@ test('扫码路由接口必须用本地密钥;校验入参并保留冲突状
await new Promise<void>((r) => server.close(() => r())); 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( ...@@ -95,10 +95,11 @@ test(
await new PgStore(pool).verifyRouting(); // 排空后才允许切换归属。 await new PgStore(pool).verifyRouting(); // 排空后才允许切换归属。
const all = new PgStore(pool, ['*']); const all = new PgStore(pool, ['*']);
ids.push(await all.append('{"type":11028}')); 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]); assert.equal(login?.id, ids[2]);
await all.acknowledgeLocal(login!.id, login!.lease); await all.delivered(login!);
} finally { } finally {
server.closeAllConnections(); server.closeAllConnections();
await new Promise<void>((r) => server.close(() => r())); await new Promise<void>((r) => server.close(() => r()));
......
...@@ -101,3 +101,68 @@ test( ...@@ -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`;中转 ...@@ -17,6 +17,8 @@ AI 使用 `apps/ai-service/.env` 和 `deploy/secrets/friday-ai-jwt.pem`;中转
数据库、JWT、中转令牌与服务密钥按环境独立管理。已有配置先备份、比较,不用示例覆盖,不在日常发布中重新生成密钥。生产禁止本地调试分流;测试保留已确认的账号归属和调试密钥。 数据库、JWT、中转令牌与服务密钥按环境独立管理。已有配置先备份、比较,不用示例覆盖,不在日常发布中重新生成密钥。生产禁止本地调试分流;测试保留已确认的账号归属和调试密钥。
扫码回执按发起轮次返回:已登记的本地扫码回本地,其余回服务器;`INGRESS_LOCAL_ACCOUNTS` 只决定账号消息归属,不接管服务器扫码。跨环境扫码显示冲突,不能自动转移账号或消息。本次路由版本须先发布 AI(识别冲突信封),再发布中转;旧积压不改归属、不自动重放。
PAC `PAC_READ_SECRET` 与 AI `AI_PAC_READ_SECRET` 对应;`AI_INGRESS_TOKEN` 与中转 `INGRESS_TOKEN` 对应。代理层数、供应商回调来源、模型与对象存储权限分别核实,不能用健康检查代替依赖验收。 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