Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
156d9ab6
Commit
156d9ab6
authored
Oct 23, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(ai): restore narrow navigation and route QR callbacks by origin
parent
427202a6
Pipeline
#3869
failed in 0 seconds
Changes
22
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
243 additions
and
83 deletions
+243
-83
apps/ai-service/src/modules/auth/qr-login.service.ts
+5
-0
apps/ai-service/src/modules/channel/inbound.service.ts
+3
-1
apps/ai-service/src/modules/channel/ingress-envelope.ts
+7
-1
apps/ai-service/tests/ingress-envelope.spec.ts
+12
-0
apps/ai-service/tests/qr-login-routing.spec.ts
+29
-0
apps/ai-web/src/app/admin/notify/page.tsx
+1
-1
apps/ai-web/src/app/agents/appointment-confirm/page.tsx
+2
-2
apps/ai-web/src/app/clinic/page.tsx
+2
-2
apps/ai-web/src/app/contacts/page.tsx
+2
-2
apps/ai-web/src/app/corpus/page.tsx
+2
-2
apps/ai-web/src/app/debug/page.tsx
+2
-2
apps/ai-web/src/app/page.tsx
+3
-46
apps/ai-web/src/components/admin/admin-shell.tsx
+2
-2
apps/ai-web/src/components/sidebar.tsx
+54
-9
apps/channel-ingress/src/login-routes.ts
+8
-3
apps/channel-ingress/src/server.ts
+1
-1
apps/channel-ingress/src/store.ts
+8
-5
apps/channel-ingress/src/worker.ts
+1
-1
apps/channel-ingress/tests/ingress.test.ts
+28
-0
apps/channel-ingress/tests/local-pull.test.ts
+4
-3
apps/channel-ingress/tests/login-routes.test.ts
+65
-0
docs/operations/friday-ai-deployment.md
+2
-0
No files found.
apps/ai-service/src/modules/auth/qr-login.service.ts
View file @
156d9ab6
...
...
@@ -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
)
{
...
...
apps/ai-service/src/modules/channel/inbound.service.ts
View file @
156d9ab6
...
...
@@ -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)
...
...
apps/ai-service/src/modules/channel/ingress-envelope.ts
View file @
156d9ab6
...
...
@@ -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 };
}
apps/ai-service/tests/ingress-envelope.spec.ts
View file @
156d9ab6
...
...
@@ -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
});
});
apps/ai-service/tests/qr-login-routing.spec.ts
0 → 100644
View file @
156d9ab6
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
();
});
});
apps/ai-web/src/app/admin/notify/page.tsx
View file @
156d9ab6
...
...
@@ -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
}
...
...
apps/ai-web/src/app/agents/appointment-confirm/page.tsx
View file @
156d9ab6
...
...
@@ -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
}
/>
...
...
apps/ai-web/src/app/clinic/page.tsx
View file @
156d9ab6
...
...
@@ -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"
>
...
...
apps/ai-web/src/app/contacts/page.tsx
View file @
156d9ab6
...
...
@@ -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% 的高度。
...
...
apps/ai-web/src/app/corpus/page.tsx
View file @
156d9ab6
...
...
@@ -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"
>
...
...
apps/ai-web/src/app/debug/page.tsx
View file @
156d9ab6
...
...
@@ -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"
>
...
...
apps/ai-web/src/app/page.tsx
View file @
156d9ab6
...
...
@@ -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"
>
{
/*
⚠️⚠️ 号不可用(没绑 / 掉线 / 待验证 / 封号 / 主动停)时,**整个工作台换成一张卡**。
...
...
apps/ai-web/src/components/admin/admin-shell.tsx
View file @
156d9ab6
...
...
@@ -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
?
(
...
...
apps/ai-web/src/components/sidebar.tsx
View file @
156d9ab6
'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
>
</>;
}
apps/channel-ingress/src/login-routes.ts
View file @
156d9ab6
...
...
@@ -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
(
...
...
apps/channel-ingress/src/server.ts
View file @
156d9ab6
...
...
@@ -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
,
l
oginConflict
:
item
.
loginConflict
,
l
ease
:
item
.
lease
}),
);
return
;
}
...
...
apps/channel-ingress/src/store.ts
View file @
156d9ab6
...
...
@@ -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
;
...
...
apps/channel-ingress/src/worker.ts
View file @
156d9ab6
...
...
@@ -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
);
...
...
apps/channel-ingress/tests/ingress.test.ts
View file @
156d9ab6
...
...
@@ -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
()));
}
});
apps/channel-ingress/tests/local-pull.test.ts
View file @
156d9ab6
...
...
@@ -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
()));
...
...
apps/channel-ingress/tests/login-routes.test.ts
View file @
156d9ab6
...
...
@@ -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
();
}
});
docs/operations/friday-ai-deployment.md
View file @
156d9ab6
...
...
@@ -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`
对应。代理层数、供应商回调来源、模型与对象存储权限分别核实,不能用健康检查代替依赖验收。
## 发布前
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment