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
a7ab3e83
Commit
a7ab3e83
authored
Oct 23, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge FridayAI narrow navigation and QR routing into test
parents
da4ccf77
156d9ab6
Pipeline
#3870
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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
'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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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 @
a7ab3e83
...
...
@@ -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