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
85e97b11
Commit
85e97b11
authored
Oct 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge main FridayAI administrator organization setup fix into test
parents
7d1e6bad
d3f517b1
Pipeline
#3850
failed in 0 seconds
Changes
17
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
498 additions
and
43 deletions
+498
-43
apps/ai-service/src/modules/auth/auth.controller.ts
+1
-0
apps/ai-service/src/modules/integration/integration-policy.service.ts
+103
-8
apps/ai-service/src/modules/integration/integration.schema.ts
+2
-0
apps/ai-service/src/modules/integration/integration.service.ts
+4
-11
apps/ai-service/tests/integration-admin-bootstrap.spec.ts
+250
-0
apps/ai-service/tests/platform-onboarding.integration.spec.ts
+67
-1
apps/ai-service/tests/workbench-session.spec.ts
+16
-0
apps/ai-web/src/app/admin/org/page.tsx
+18
-3
apps/ai-web/src/components/platform/tenant-onboarding.tsx
+6
-6
apps/ai-web/src/components/sidebar.tsx
+8
-5
apps/ai-web/src/lib/api.ts
+1
-0
apps/ai-web/src/lib/auth-session.ts
+2
-1
apps/ai-web/src/lib/workbench-session.ts
+5
-3
apps/pac-docs/content/docs/friday-ai/getting-started.mdx
+4
-2
apps/pac-docs/content/docs/friday-ai/integration.mdx
+1
-1
apps/pac-docs/content/docs/integration/friday-ai-host.mdx
+8
-2
docs/operations/friday-ai-administration.md
+2
-0
No files found.
apps/ai-service/src/modules/auth/auth.controller.ts
View file @
85e97b11
...
@@ -417,6 +417,7 @@ export class AuthController {
...
@@ -417,6 +417,7 @@ export class AuthController {
...(
user
.
hostScope
?
{
hostContext
:
{
...(
user
.
hostScope
?
{
hostContext
:
{
appId
:
user
.
hostScope
.
appId
,
dataTenantId
:
user
.
hostScope
.
dataTenantId
,
appId
:
user
.
hostScope
.
appId
,
dataTenantId
:
user
.
hostScope
.
dataTenantId
,
orgScope
:
user
.
hostScope
.
orgScope
,
clinicId
:
user
.
hostScope
.
clinicId
,
orgScope
:
user
.
hostScope
.
orgScope
,
clinicId
:
user
.
hostScope
.
clinicId
,
unmappedClinicRef
:
user
.
hostScope
.
unmappedClinicRef
,
modules
:
user
.
hostScope
.
modules
,
capabilities
:
user
.
hostScope
.
capabilities
,
readOnly
:
user
.
hostScope
.
readOnly
,
modules
:
user
.
hostScope
.
modules
,
capabilities
:
user
.
hostScope
.
capabilities
,
readOnly
:
user
.
hostScope
.
readOnly
,
}
}
:
{}),
}
}
:
{}),
/**
/**
...
...
apps/ai-service/src/modules/integration/integration-policy.service.ts
View file @
85e97b11
...
@@ -17,6 +17,7 @@ import {
...
@@ -17,6 +17,7 @@ import {
type
TenantIntegrationPolicy
,
type
TenantIntegrationPolicy
,
}
from
'./integration.schema'
;
}
from
'./integration.schema'
;
import
{
expandSubtree
}
from
'../scope/org-tree'
;
import
{
expandSubtree
}
from
'../scope/org-tree'
;
import
{
permissionsOf
,
PERMISSIONS
}
from
'../../common/permissions'
;
export
type
IntegrationDb
=
Pick
<
export
type
IntegrationDb
=
Pick
<
Prisma
.
TransactionClient
,
Prisma
.
TransactionClient
,
|
'tenant'
|
'tenant'
...
@@ -204,6 +205,103 @@ export class IntegrationPolicyService {
...
@@ -204,6 +205,103 @@ export class IntegrationPolicyService {
...
new
Set
(
graph
.
scopeOrgIds
.
flatMap
((
id
)
=>
[...
expandSubtree
(
graph
.
nodes
,
[
id
])])),
...
new
Set
(
graph
.
scopeOrgIds
.
flatMap
((
id
)
=>
[...
expandSubtree
(
graph
.
nodes
,
[
id
])])),
].
filter
((
id
)
=>
graph
.
nodes
.
find
((
n
)
=>
n
.
id
===
id
)?.
isActive
);
].
filter
((
id
)
=>
graph
.
nodes
.
find
((
n
)
=>
n
.
id
===
id
)?.
isActive
);
}
}
/** 当前诊所是初始选择;已人工授权的全客户管理员可先配置尚未关联的门店。 */
async
clinicSelection
(
app
:
IntegrationAppConfig
,
graph
:
ResolvedTenant
,
input
:
SsoUser
,
db
:
IntegrationDb
=
this
.
prisma
,
effective
?:
{
userId
:
string
;
orgUnitIds
:
string
[];
capabilities
:
string
[]
},
):
Promise
<
{
clinicId
?:
string
;
unmappedClinicRef
?:
string
}
>
{
if
(
!
input
.
clinicId
)
return
{};
const
id
=
graph
.
mappings
.
get
(
input
.
clinicId
);
const
allowed
=
effective
?.
orgUnitIds
??
this
.
expand
(
graph
);
if
(
id
)
{
const
node
=
graph
.
nodes
.
find
((
n
)
=>
n
.
id
===
id
);
if
(
!
node
?.
isActive
||
node
.
kind
!==
'clinic'
||
!
allowed
.
includes
(
id
))
throw
new
BizError
(
12806
,
'当前诊所已停用、类型无效或不在授权范围内'
);
return
{
clinicId
:
id
};
}
const
missing
=
()
=>
new
BizError
(
12806
,
'当前诊所尚未关联,请联系客户管理员完成门店配置'
);
const
capabilities
=
effective
?.
capabilities
??
this
.
role
(
app
,
graph
.
policy
,
input
).
capabilities
;
const
management
=
[
PERMISSIONS
.
ORG_TREE_EDIT
,
PERMISSIONS
.
USER_SCOPE_GRANT
];
if
(
input
.
role
!==
'admin'
||
!
app
.
modules
.
includes
(
'workbench'
)
||
!
graph
.
policy
.
modules
.
includes
(
'workbench'
)
||
!
graph
.
scopeOrgIds
.
includes
(
graph
.
policy
.
rootOrgId
)
||
!
management
.
every
((
p
)
=>
capabilities
.
includes
(
p
))
||
!
graph
.
nodes
.
filter
((
n
)
=>
n
.
isActive
).
every
((
n
)
=>
allowed
.
includes
(
n
.
id
))
)
throw
missing
();
const
identity
=
await
db
.
userIdentity
.
findUnique
({
where
:
{
provider_externalId
:
{
provider
:
identityProvider
(
app
.
appId
,
graph
.
row
.
id
),
externalId
:
input
.
userId
,
},
},
select
:
{
user
:
{
select
:
{
id
:
true
,
tenantId
:
true
,
role
:
true
,
dataScope
:
true
,
isActive
:
true
,
mustChangePassword
:
true
,
integrationManagedRole
:
true
,
},
},
},
});
const
user
=
identity
?.
user
;
if
(
!
user
||
user
.
tenantId
!==
graph
.
row
.
id
||
(
effective
&&
user
.
id
!==
effective
.
userId
)
||
!
user
.
isActive
||
user
.
mustChangePassword
||
user
.
integrationManagedRole
||
user
.
dataScope
!==
'tenant'
||
!
management
.
every
((
p
)
=>
permissionsOf
(
user
.
role
).
includes
(
p
))
)
throw
missing
();
// ⛔ 不把已归属其他客户的诊所当作待配置;包含停用客户及规则中的映射。
const
others
=
await
db
.
tenant
.
findMany
({
where
:
{
id
:
{
not
:
graph
.
row
.
id
},
partnerCode
:
app
.
hostCode
,
partnerTenantId
:
input
.
tenantId
,
},
select
:
{
id
:
true
,
integrationPolicy
:
true
,
partnerOrgRootRef
:
true
},
});
const
refs
=
others
.
length
?
await
db
.
orgUnitRef
.
findMany
({
where
:
{
tenantId
:
{
in
:
others
.
map
((
t
)
=>
t
.
id
)
},
hostCode
:
app
.
hostCode
,
externalId
:
input
.
clinicId
,
},
select
:
{
externalId
:
true
},
})
:
[];
if
(
refs
.
length
||
others
.
some
((
t
)
=>
{
const
policy
=
tenantPolicySchema
.
safeParse
(
t
.
integrationPolicy
);
return
(
t
.
partnerOrgRootRef
===
input
.
clinicId
||
(
policy
.
success
&&
Object
.
hasOwn
(
policy
.
data
.
organizationMappings
,
input
.
clinicId
!
))
);
})
)
throw
new
BizError
(
12806
,
'当前诊所不在本客户授权范围内'
);
return
{
unmappedClinicRef
:
input
.
clinicId
};
}
async
scope
(
async
scope
(
app
:
IntegrationAppConfig
,
app
:
IntegrationAppConfig
,
graph
:
ResolvedTenant
,
graph
:
ResolvedTenant
,
...
@@ -238,13 +336,9 @@ export class IntegrationPolicyService {
...
@@ -238,13 +336,9 @@ export class IntegrationPolicyService {
const
capabilities
=
context
.
capabilityCeiling
.
filter
(
const
capabilities
=
context
.
capabilityCeiling
.
filter
(
(
p
)
=>
role
.
capabilities
.
includes
(
p
)
&&
rights
.
includes
(
p
),
(
p
)
=>
role
.
capabilities
.
includes
(
p
)
&&
rights
.
includes
(
p
),
);
);
let
clinicId
:
string
|
undefined
;
const
selection
=
await
this
.
clinicSelection
(
app
,
graph
,
input
,
db
,
{
if
(
input
.
clinicId
)
{
userId
,
orgUnitIds
,
capabilities
,
clinicId
=
graph
.
mappings
.
get
(
input
.
clinicId
);
});
const
n
=
graph
.
nodes
.
find
((
n
)
=>
n
.
id
===
clinicId
);
if
(
!
n
?.
isActive
||
n
.
kind
!==
'clinic'
||
!
orgUnitIds
.
includes
(
n
.
id
))
throw
new
BizError
(
12806
,
'当前诊所不在有效授权范围内'
);
}
const
clinicNames
:
Record
<
string
,
string
>
=
{};
const
clinicNames
:
Record
<
string
,
string
>
=
{};
for
(
const
[
ref
,
name
]
of
Object
.
entries
(
input
.
dictionary
?.
clinics
??
{}))
{
for
(
const
[
ref
,
name
]
of
Object
.
entries
(
input
.
dictionary
?.
clinics
??
{}))
{
const
id
=
graph
.
mappings
.
get
(
ref
);
const
id
=
graph
.
mappings
.
get
(
ref
);
...
@@ -257,7 +351,8 @@ export class IntegrationPolicyService {
...
@@ -257,7 +351,8 @@ export class IntegrationPolicyService {
return
{
return
{
...
context
,
...
context
,
scopeOrgIds
:
[...
graph
.
scopeOrgIds
],
scopeOrgIds
:
[...
graph
.
scopeOrgIds
],
clinicId
,
clinicId
:
selection
.
clinicId
,
unmappedClinicRef
:
selection
.
unmappedClinicRef
,
orgUnitIds
,
orgUnitIds
,
capabilities
,
capabilities
,
clinicNames
,
clinicNames
,
...
...
apps/ai-service/src/modules/integration/integration.schema.ts
View file @
85e97b11
...
@@ -156,6 +156,8 @@ export interface HostSessionContext {
...
@@ -156,6 +156,8 @@ export interface HostSessionContext {
dictionary
?:
z
.
infer
<
typeof
dictionarySchema
>
;
dictionary
?:
z
.
infer
<
typeof
dictionarySchema
>
;
}
}
export
interface
HostScope
extends
HostSessionContext
{
export
interface
HostScope
extends
HostSessionContext
{
/** 尚未关联的宿主诊所,仅供已授权管理员配置提示,不参与授权或患者查询。 */
unmappedClinicRef
?:
string
;
orgUnitIds
:
string
[];
orgUnitIds
:
string
[];
capabilities
:
string
[];
capabilities
:
string
[];
modules
:
string
[];
modules
:
string
[];
...
...
apps/ai-service/src/modules/integration/integration.service.ts
View file @
85e97b11
...
@@ -38,16 +38,7 @@ export class IntegrationService {
...
@@ -38,16 +38,7 @@ export class IntegrationService {
await
integrationLock
(
tx
,
app
.
appId
,
'space:'
+
user
.
tenantId
);
await
integrationLock
(
tx
,
app
.
appId
,
'space:'
+
user
.
tenantId
);
const
graph
=
await
this
.
policy
.
resolve
(
app
,
clean
,
tx
);
const
graph
=
await
this
.
policy
.
resolve
(
app
,
clean
,
tx
);
this
.
policy
.
role
(
app
,
graph
.
policy
,
clean
);
this
.
policy
.
role
(
app
,
graph
.
policy
,
clean
);
if
(
clean
.
clinicId
)
{
await
this
.
policy
.
clinicSelection
(
app
,
graph
,
clean
,
tx
);
const
id
=
graph
.
mappings
.
get
(
clean
.
clinicId
),
node
=
graph
.
nodes
.
find
((
n
)
=>
n
.
id
===
id
);
if
(
!
node
?.
isActive
||
node
.
kind
!==
'clinic'
||
!
this
.
policy
.
expand
(
graph
).
includes
(
node
.
id
)
)
throw
new
BizError
(
12806
,
'当前诊所不在请求组织范围内'
);
}
await
integrationLock
(
tx
,
app
.
appId
,
graph
.
row
.
id
);
await
integrationLock
(
tx
,
app
.
appId
,
graph
.
row
.
id
);
const
id
=
randomUUID
(),
const
id
=
randomUUID
(),
nonce
=
randomBytes
(
32
).
toString
(
'base64url'
),
nonce
=
randomBytes
(
32
).
toString
(
'base64url'
),
...
@@ -295,7 +286,9 @@ export class IntegrationService {
...
@@ -295,7 +286,9 @@ export class IntegrationService {
dataTenantId
:
scope
.
dataTenantId
,
dataTenantId
:
scope
.
dataTenantId
,
orgScope
:
scope
.
orgScope
,
orgScope
:
scope
.
orgScope
,
clinicId
:
scope
.
clinicId
,
clinicId
:
scope
.
clinicId
,
entryPath
:
scope
.
modules
.
includes
(
'workbench'
)
?
'/'
:
'/agents/appointment-confirm'
,
unmappedClinicRef
:
scope
.
unmappedClinicRef
,
entryPath
:
scope
.
unmappedClinicRef
?
'/admin/org'
:
scope
.
modules
.
includes
(
'workbench'
)
?
'/'
:
'/agents/appointment-confirm'
,
};
};
}
}
@
Cron
(
'0 25 3 * * *'
)
async
cleanup
():
Promise
<
void
>
{
@
Cron
(
'0 25 3 * * *'
)
async
cleanup
():
Promise
<
void
>
{
...
...
apps/ai-service/tests/integration-admin-bootstrap.spec.ts
0 → 100644
View file @
85e97b11
import
{
IntegrationPolicyService
}
from
'../src/modules/integration/integration-policy.service'
;
import
{
IntegrationService
}
from
'../src/modules/integration/integration.service'
;
import
{
identityProvider
,
roleCapabilities
,
tenantPolicySchema
,
type
HostSessionContext
,
type
IntegrationAppConfig
,
type
SsoUser
,
}
from
'../src/modules/integration/integration.schema'
;
const
root
=
'11111111-1111-4111-8111-111111111111'
;
const
clinic
=
'22222222-2222-4222-8222-222222222222'
;
function
fixture
()
{
const
app
:
IntegrationAppConfig
=
{
appId
:
'test-app'
,
hostCode
:
'test-host'
,
enabled
:
true
,
secretHashes
:
[],
webBaseUrl
:
'https://ai.example.invalid'
,
allowedRoles
:
[
'admin'
,
'staff'
,
'viewer'
],
capabilities
:
roleCapabilities
(
'owner'
),
modules
:
[
'workbench'
],
maxSessionSeconds
:
28800
,
requestsPerMinute
:
120
,
};
const
owner
=
{
id
:
'owner'
,
tenantId
:
'customer'
,
role
:
'owner'
,
dataScope
:
'tenant'
,
isActive
:
true
,
mustChangePassword
:
false
,
integrationManagedRole
:
false
,
};
const
graph
=
{
row
:
{
id
:
'customer'
},
policy
:
tenantPolicySchema
.
parse
({
version
:
2
,
rootOrgId
:
root
,
allowedRoles
:
app
.
allowedRoles
,
capabilities
:
app
.
capabilities
,
modules
:
app
.
modules
,
}),
nodes
:
[{
id
:
root
,
parentId
:
null
as
string
|
null
,
kind
:
'brand'
,
isActive
:
true
}],
mappings
:
new
Map
([[
'source-brand'
,
root
]]),
scopeOrgIds
:
[
root
],
};
const
input
:
SsoUser
=
{
userId
:
'source-owner'
,
tenantId
:
'space'
,
role
:
'admin'
,
orgScope
:
[
'source-brand'
],
clinicId
:
'source-clinic'
,
};
const
context
:
HostSessionContext
=
{
version
:
2
,
appId
:
app
.
appId
,
dataTenantId
:
'space'
,
sourceUserId
:
input
.
userId
,
launchId
:
root
,
rootOrgId
:
root
,
scopeOrgIds
:
[
root
],
orgScope
:
input
.
orgScope
,
role
:
'admin'
,
capabilityCeiling
:
app
.
capabilities
,
readonlyCeiling
:
false
,
};
const
db
=
{
user
:
{
findFirst
:
jest
.
fn
(
async
()
=>
owner
)
},
userOrgScope
:
{
findMany
:
jest
.
fn
(
async
()
=>
[{
orgUnitId
:
root
,
includeSubtree
:
true
}])
},
userIdentity
:
{
findUnique
:
jest
.
fn
(
async
():
Promise
<
{
userId
:
string
;
user
:
typeof
owner
}
|
null
>
=>
({
userId
:
owner
.
id
,
user
:
owner
,
}),
),
},
tenant
:
{
findMany
:
jest
.
fn
(
async
():
Promise
<
{
id
:
string
;
integrationPolicy
:
unknown
;
partnerOrgRootRef
?:
string
}[]
>
=>
[],
),
},
orgUnitRef
:
{
findMany
:
jest
.
fn
(
async
():
Promise
<
{
externalId
:
string
}[]
>
=>
[])
},
integrationAuthorization
:
{
create
:
jest
.
fn
(
async
()
=>
({}))
},
$queryRaw
:
jest
.
fn
(
async
()
=>
[]),
$transaction
:
jest
.
fn
(
async
(
fn
:
(
tx
:
unknown
)
=>
Promise
<
unknown
>
):
Promise
<
unknown
>
=>
fn
(
db
),
),
};
const
policy
=
new
IntegrationPolicyService
(
db
as
never
,
{}
as
never
);
const
selection
=
(
u
=
input
)
=>
policy
.
clinicSelection
(
app
,
graph
as
never
,
u
);
const
scope
=
(
u
=
input
)
=>
policy
.
scope
(
app
,
graph
as
never
,
owner
.
id
,
u
,
context
);
return
{
app
,
owner
,
graph
,
input
,
context
,
db
,
policy
,
selection
,
scope
};
}
it
(
'已映射的全客户管理员无需独立密码激活即可先配置当前门店;未知编号不进入授权集合'
,
async
()
=>
{
const
f
=
fixture
();
expect
(
await
f
.
selection
()).
toEqual
({
unmappedClinicRef
:
'source-clinic'
});
expect
(
f
.
db
.
userIdentity
.
findUnique
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
where
:
{
provider_externalId
:
{
provider
:
identityProvider
(
f
.
app
.
appId
,
'customer'
),
externalId
:
'source-owner'
,
},
},
}),
);
const
scope
=
await
f
.
scope
();
expect
(
scope
).
toMatchObject
({
wholeTenant
:
true
,
accountAll
:
true
,
unmappedClinicRef
:
'source-clinic'
,
orgUnitIds
:
[
root
],
});
expect
(
scope
.
clinicId
).
toBeUndefined
();
expect
(
scope
.
capabilities
).
toContain
(
'org.tree.edit'
);
});
it
(
'签发入口使用同一诊所校验;普通员工不能取得待配置进入码'
,
async
()
=>
{
const
f
=
fixture
();
jest
.
spyOn
(
f
.
policy
,
'resolve'
).
mockResolvedValue
(
f
.
graph
as
never
);
const
service
=
new
IntegrationService
(
f
.
db
as
never
,
{
entryCode
:
()
=>
'test-code'
}
as
never
,
{}
as
never
,
f
.
policy
,
{}
as
never
,
);
await
expect
(
service
.
token
(
f
.
app
,
f
.
input
)).
resolves
.
toMatchObject
({
code
:
'test-code'
});
expect
(
f
.
db
.
integrationAuthorization
.
create
).
toHaveBeenCalledTimes
(
1
);
f
.
db
.
integrationAuthorization
.
create
.
mockClear
();
await
expect
(
service
.
token
(
f
.
app
,
{
...
f
.
input
,
role
:
'staff'
})).
rejects
.
toMatchObject
({
code
:
12806
,
});
expect
(
f
.
db
.
integrationAuthorization
.
create
).
not
.
toHaveBeenCalled
();
});
it
.
each
([
'staff'
,
'viewer'
]
as
const
)(
'宿主 %s 即使关联 owner 也不能使用门店配置例外'
,
async
(
role
)
=>
{
const
f
=
fixture
();
await
expect
(
f
.
selection
({
...
f
.
input
,
role
})).
rejects
.
toMatchObject
({
code
:
12806
});
await
expect
(
f
.
scope
({
...
f
.
input
,
role
})).
rejects
.
toMatchObject
({
code
:
12806
});
},
);
it
.
each
([
[
'停用'
,
{
isActive
:
false
}],
[
'必须激活的旧账号'
,
{
mustChangePassword
:
true
}],
[
'宿主管理角色'
,
{
integrationManagedRole
:
true
}],
[
'局部管理员'
,
{
dataScope
:
'scoped'
}],
[
'普通员工'
,
{
role
:
'staff'
}],
[
'其他客户账号'
,
{
tenantId
:
'other-customer'
}],
]
as
const
)(
'%s不能使用门店配置例外'
,
async
(
_label
,
changes
)
=>
{
const
f
=
fixture
();
Object
.
assign
(
f
.
owner
,
changes
);
await
expect
(
f
.
selection
()).
rejects
.
toMatchObject
({
code
:
12806
});
await
expect
(
f
.
scope
()).
rejects
.
toMatchObject
({
code
:
f
.
owner
.
isActive
?
12806
:
12805
});
});
it
(
'未关联源身份时不自动创建或提升管理员'
,
async
()
=>
{
const
f
=
fixture
();
f
.
db
.
userIdentity
.
findUnique
.
mockResolvedValue
(
null
);
await
expect
(
f
.
selection
()).
rejects
.
toMatchObject
({
code
:
12806
});
});
it
(
'已有身份与当前会话用户不一致时拒绝'
,
async
()
=>
{
const
f
=
fixture
();
f
.
db
.
userIdentity
.
findUnique
.
mockResolvedValue
({
userId
:
'someone-else'
,
user
:
{
...
f
.
owner
,
id
:
'someone-else'
},
});
await
expect
(
f
.
scope
()).
rejects
.
toMatchObject
({
code
:
12806
});
});
it
(
'组织范围不含客户根时,不能借未知的当前诊所扩大范围'
,
async
()
=>
{
const
f
=
fixture
();
f
.
graph
.
nodes
.
push
({
id
:
clinic
,
parentId
:
root
,
kind
:
'clinic'
,
isActive
:
true
});
f
.
graph
.
scopeOrgIds
=
[
clinic
];
await
expect
(
f
.
selection
()).
rejects
.
toMatchObject
({
code
:
12806
});
});
it
.
each
([
'app'
,
'customer'
,
'session'
,
'module'
])(
'%s未开放配置能力时不降级放行'
,
async
(
layer
)
=>
{
const
f
=
fixture
();
if
(
layer
===
'app'
)
f
.
app
.
capabilities
=
[
'workspace.read'
];
if
(
layer
===
'customer'
)
f
.
graph
.
policy
.
capabilities
=
[
'workspace.read'
];
if
(
layer
===
'session'
)
f
.
context
.
capabilityCeiling
=
[
'workspace.read'
];
if
(
layer
===
'module'
)
f
.
app
.
modules
=
[
'appointment-confirm'
];
await
expect
(
f
.
scope
()).
rejects
.
toMatchObject
({
code
:
12806
});
});
it
.
each
([
'reference'
,
'policy'
,
'customer-root'
])(
'已有其他客户的%s映射时保持拒绝'
,
async
(
source
)
=>
{
const
f
=
fixture
();
f
.
db
.
tenant
.
findMany
.
mockResolvedValue
([
{
id
:
'other-customer'
,
partnerOrgRootRef
:
source
===
'customer-root'
?
'source-clinic'
:
'other-brand'
,
integrationPolicy
:
tenantPolicySchema
.
parse
({
version
:
2
,
rootOrgId
:
clinic
,
organizationMappings
:
source
===
'policy'
?
{
'source-clinic'
:
clinic
}
:
{},
}),
},
]);
if
(
source
===
'reference'
)
f
.
db
.
orgUnitRef
.
findMany
.
mockResolvedValue
([{
externalId
:
'source-clinic'
}]);
await
expect
(
f
.
selection
()).
rejects
.
toMatchObject
({
code
:
12806
,
msg
:
expect
.
stringContaining
(
'本客户授权范围'
),
});
await
expect
(
f
.
scope
()).
rejects
.
toMatchObject
({
code
:
12806
});
},
);
it
.
each
([
'inactive'
,
'wrong-kind'
,
'out-of-scope'
])(
'已映射诊所%s时不使用配置例外'
,
async
(
kind
)
=>
{
const
f
=
fixture
();
f
.
graph
.
nodes
.
push
({
id
:
clinic
,
parentId
:
kind
===
'out-of-scope'
?
null
:
root
,
kind
:
kind
===
'wrong-kind'
?
'brand'
:
'clinic'
,
isActive
:
kind
!==
'inactive'
,
});
f
.
graph
.
mappings
.
set
(
'source-clinic'
,
clinic
);
await
expect
(
f
.
selection
()).
rejects
.
toMatchObject
({
code
:
12806
});
},
);
it
(
'补齐映射后同一会话恢复正确诊所;配置权限撤回后原会话不能继续使用例外'
,
async
()
=>
{
const
f
=
fixture
();
expect
((
await
f
.
scope
()).
unmappedClinicRef
).
toBe
(
'source-clinic'
);
f
.
graph
.
nodes
.
push
({
id
:
clinic
,
parentId
:
root
,
kind
:
'clinic'
,
isActive
:
true
});
f
.
graph
.
mappings
.
set
(
'source-clinic'
,
clinic
);
expect
(
await
f
.
scope
()).
toMatchObject
({
clinicId
:
clinic
,
unmappedClinicRef
:
undefined
,
orgUnitIds
:
[
root
,
clinic
],
});
f
.
graph
.
mappings
.
delete
(
'source-clinic'
);
f
.
owner
.
role
=
'staff'
;
await
expect
(
f
.
scope
()).
rejects
.
toMatchObject
({
code
:
12806
});
});
apps/ai-service/tests/platform-onboarding.integration.spec.ts
View file @
85e97b11
...
@@ -3,7 +3,8 @@ import { UserAdminService } from '../src/modules/workbench/user-admin.service';
...
@@ -3,7 +3,8 @@ import { UserAdminService } from '../src/modules/workbench/user-admin.service';
import
{
OrgAdminService
}
from
'../src/modules/workbench/org-admin.service'
;
import
{
OrgAdminService
}
from
'../src/modules/workbench/org-admin.service'
;
import
{
ScopeService
}
from
'../src/modules/scope/scope.service'
;
import
{
ScopeService
}
from
'../src/modules/scope/scope.service'
;
import
type
{
AuthUser
}
from
'../src/common/guards/jwt-auth.guard'
;
import
type
{
AuthUser
}
from
'../src/common/guards/jwt-auth.guard'
;
import
{
randomUUID
,
createHash
}
from
'node:crypto'
;
import
{
randomUUID
,
createHash
,
generateKeyPairSync
}
from
'node:crypto'
;
import
type
{
ConfigService
}
from
'@nestjs/config'
;
import
{
PrismaClient
}
from
'../generated/prisma'
;
import
{
PrismaClient
}
from
'../generated/prisma'
;
import
{
PrismaService
}
from
'../src/prisma/prisma.service'
;
import
{
PrismaService
}
from
'../src/prisma/prisma.service'
;
import
{
PlatformOnboardingService
}
from
'../src/modules/platform/platform-onboarding.service'
;
import
{
PlatformOnboardingService
}
from
'../src/modules/platform/platform-onboarding.service'
;
...
@@ -22,6 +23,11 @@ import { IntegrationPolicyService } from '../src/modules/integration/integration
...
@@ -22,6 +23,11 @@ import { IntegrationPolicyService } from '../src/modules/integration/integration
import
{
adminCan
}
from
'../src/modules/workbench/admin-common'
;
import
{
adminCan
}
from
'../src/modules/workbench/admin-common'
;
import
{
ActivationService
}
from
'../src/modules/auth/activation.service'
;
import
{
ActivationService
}
from
'../src/modules/auth/activation.service'
;
import
{
PasswordService
}
from
'../src/modules/auth/password.service'
;
import
{
PasswordService
}
from
'../src/modules/auth/password.service'
;
import
{
JwtService
}
from
'../src/modules/auth/jwt.service'
;
import
{
SessionService
}
from
'../src/modules/auth/session.service'
;
import
{
IntegrationService
}
from
'../src/modules/integration/integration.service'
;
import
{
IntegrationRegistryService
}
from
'../src/modules/integration/integration-registry.service'
;
import
{
setTenantRole
}
from
'../src/common/role-grants'
;
it
(
'开户 API 仅平台管理员可访问'
,
()
=>
{
it
(
'开户 API 仅平台管理员可访问'
,
()
=>
{
expect
(
Reflect
.
getMetadata
(
AUTH_SCOPE
,
PlatformController
)).
toBe
(
'platform'
);
expect
(
Reflect
.
getMetadata
(
AUTH_SCOPE
,
PlatformController
)).
toBe
(
'platform'
);
for
(
const
method
of
[
'create'
,
'preview'
,
'applications'
,
'activation'
,
'mapOwner'
]
as
const
)
for
(
const
method
of
[
'create'
,
'preview'
,
'applications'
,
'activation'
,
'mapOwner'
]
as
const
)
...
@@ -108,6 +114,8 @@ run('可视化开户(仅本地真实数据库)', () => {
...
@@ -108,6 +114,8 @@ run('可视化开户(仅本地真实数据库)', () => {
const
ids
=
(
const
ids
=
(
await
db
.
tenant
.
findMany
({
where
:
{
partnerCode
:
host
},
select
:
{
id
:
true
}
})
await
db
.
tenant
.
findMany
({
where
:
{
partnerCode
:
host
},
select
:
{
id
:
true
}
})
).
map
((
t
)
=>
t
.
id
);
).
map
((
t
)
=>
t
.
id
);
await
db
.
integrationAuthorization
.
deleteMany
({
where
:
{
tenantId
:
{
in
:
ids
}
}
});
await
db
.
authSession
.
deleteMany
({
where
:
{
tenantId
:
{
in
:
ids
}
}
});
await
db
.
userIdentity
.
deleteMany
({
where
:
{
user
:
{
tenantId
:
{
in
:
ids
}
}
}
});
await
db
.
userIdentity
.
deleteMany
({
where
:
{
user
:
{
tenantId
:
{
in
:
ids
}
}
}
});
await
db
.
auditLog
.
deleteMany
({
await
db
.
auditLog
.
deleteMany
({
where
:
{
OR
:
[{
tenantId
:
{
in
:
ids
}
},
{
actorUserId
:
actor
?.
id
}]
},
where
:
{
OR
:
[{
tenantId
:
{
in
:
ids
}
},
{
actorUserId
:
actor
?.
id
}]
},
...
@@ -263,6 +271,64 @@ run('可视化开户(仅本地真实数据库)', () => {
...
@@ -263,6 +271,64 @@ run('可视化开户(仅本地真实数据库)', () => {
const
explicit
=
await
policy
.
byId
(
restricted
as
never
,
tenant
.
id
,
tenant
.
partnerTenantId
);
const
explicit
=
await
policy
.
byId
(
restricted
as
never
,
tenant
.
id
,
tenant
.
partnerTenantId
);
expect
(
explicit
.
policy
.
allowedRoles
).
toContain
(
'admin'
);
expect
(
explicit
.
policy
.
allowedRoles
).
toContain
(
'admin'
);
});
});
it
(
'仅客户根的首位管理员无需激活即可内嵌进入、建店关联,刷新及重入继续使用同一账号'
,
async
()
=>
{
const
input
=
data
({
sourceUserId
:
'bootstrap-owner'
});
const
r
=
await
svc
.
create
(
actor
,
input
);
const
registry
=
new
IntegrationRegistryService
(
db
as
PrismaService
);
const
policy
=
new
IntegrationPolicyService
(
db
as
PrismaService
,
registry
);
const
config
=
{
getOrThrow
:
(
key
:
string
)
=>
key
===
'jwt'
?
{
privateKeyPem
:
generateKeyPairSync
(
'ed25519'
).
privateKey
.
export
({
type
:
'pkcs8'
,
format
:
'pem'
}).
toString
(),
kid
:
'local-onboarding-test'
,
ttlSeconds
:
900
,
}
:
{
absoluteSeconds
:
28800
,
idleSeconds
:
1800
}
}
as
ConfigService
;
const
jwt
=
new
JwtService
(
config
);
const
sessions
=
new
SessionService
(
db
as
PrismaService
,
jwt
,
config
,
policy
);
const
integration
=
new
IntegrationService
(
db
as
PrismaService
,
jwt
,
sessions
,
policy
,
registry
);
const
app
=
(
await
registry
.
get
(
appId
))
!
;
const
user
=
{
userId
:
'bootstrap-owner'
,
tenantId
:
input
.
partnerTenantId
,
role
:
'admin'
as
const
,
orgScope
:
[
'brand'
],
clinicId
:
'clinic'
};
const
enter
=
async
()
=>
integration
.
exchange
((
await
integration
.
token
(
app
,
user
)).
code
,
randomUUID
());
const
first
=
await
enter
();
expect
(
first
.
userId
).
toBe
(
r
.
owner
.
id
);
expect
(
first
.
context
).
toMatchObject
({
entryPath
:
'/admin/org'
,
unmappedClinicRef
:
'clinic'
,
clinicId
:
undefined
});
const
raw
=
await
db
.
user
.
findUniqueOrThrow
({
where
:
{
id
:
r
.
owner
.
id
}
});
expect
(
raw
.
passwordHash
).
toBeNull
();
expect
(
raw
.
passwordSetAt
).
toBeNull
();
let
op
=
(
await
sessions
.
currentHostUser
(
first
.
sessionId
))
!
;
expect
(
op
).
toBeTruthy
();
expect
(
adminCan
(
op
).
editTree
).
toBe
(
true
);
const
pacStub
=
{
orgNodes
:
jest
.
fn
().
mockResolvedValue
([
{
ref
:
'brand'
,
parentRef
:
null
,
kind
:
'brand'
,
name
:
'品牌'
,
aliases
:
[]
},
{
ref
:
'clinic'
,
parentRef
:
'brand'
,
kind
:
'clinic'
,
name
:
'待配置门店'
,
aliases
:
[]
},
])
};
const
org
=
new
OrgAdminService
(
db
as
never
,
new
ScopeService
(
db
as
never
),
{
forgetClinics
:
()
=>
0
}
as
never
,
{}
as
never
,
{}
as
never
,
pacStub
as
never
);
const
tree
=
await
org
.
tree
(
op
);
expect
(
tree
.
can
.
editTree
).
toBe
(
true
);
const
request
=
{
parentId
:
tree
.
nodes
[
0
]
!
.
id
,
name
:
'待配置门店'
,
kind
:
'clinic'
};
const
pre
=
await
org
.
createUnit
(
op
,
{
...
request
,
dryRun
:
true
});
const
created
=
await
org
.
createUnit
(
op
,
{
...
request
,
rev
:
pre
.
rev
,
dryRun
:
false
});
// 新节点参与实时授权交集,不依赖旧 /auth/me 快照。
op
=
(
await
sessions
.
currentHostUser
(
first
.
sessionId
))
!
;
const
link
=
await
org
.
linkPac
(
op
,
created
.
nodeId
!
,
{
ref
:
'clinic'
,
dryRun
:
true
});
await
org
.
linkPac
(
op
,
created
.
nodeId
!
,
{
ref
:
'clinic'
,
rev
:
link
.
rev
,
ack
:
true
,
dryRun
:
false
});
op
=
(
await
sessions
.
currentHostUser
(
first
.
sessionId
))
!
;
expect
(
op
.
hostScope
.
clinicId
).
toBe
(
created
.
nodeId
);
expect
(
op
.
hostScope
.
unmappedClinicRef
).
toBeUndefined
();
await
expect
(
sessions
.
refresh
(
first
.
refreshToken
,
randomUUID
())).
resolves
.
toMatchObject
({
userId
:
r
.
owner
.
id
});
const
again
=
await
enter
();
expect
(
again
.
context
).
toMatchObject
({
entryPath
:
'/'
,
clinicId
:
created
.
nodeId
,
unmappedClinicRef
:
undefined
});
expect
(
await
db
.
user
.
count
({
where
:
{
tenantId
:
r
.
tenant
.
id
}
})).
toBe
(
1
);
expect
((
await
db
.
user
.
findUniqueOrThrow
({
where
:
{
id
:
r
.
owner
.
id
}
})).
passwordHash
).
toBeNull
();
// 没有管理员身份的员工仍报组织未关联,不签发进入码或 JIT 建人。
const
before
=
await
db
.
integrationAuthorization
.
count
({
where
:
{
tenantId
:
r
.
tenant
.
id
}
});
await
expect
(
integration
.
token
(
app
,
{
...
user
,
userId
:
'not-an-owner'
,
role
:
'staff'
,
orgScope
:
[
'not-yet-linked'
],
clinicId
:
'not-yet-linked'
})).
rejects
.
toMatchObject
({
code
:
12806
});
expect
(
await
db
.
integrationAuthorization
.
count
({
where
:
{
tenantId
:
r
.
tenant
.
id
}
})).
toBe
(
before
);
// 已有其他门店时仍能配置新店;管理员的配置权限撤回后,待配置会话立即失效。
const
pendingUser
=
{
...
user
,
clinicId
:
'another-new-clinic'
};
const
pending
=
await
integration
.
exchange
((
await
integration
.
token
(
app
,
pendingUser
)).
code
,
randomUUID
());
await
db
.
$transaction
((
tx
)
=>
setTenantRole
(
tx
,
r
.
tenant
.
id
,
r
.
owner
.
id
,
'staff'
,
'manual'
));
expect
(
await
sessions
.
currentHostUser
(
pending
.
sessionId
)).
toBeNull
();
});
it
(
'无宿主应用仍能开户;带映射必须有唯一宿主应用'
,
async
()
=>
{
it
(
'无宿主应用仍能开户;带映射必须有唯一宿主应用'
,
async
()
=>
{
await
db
.
integrationApp
.
update
({
where
:
{
appId
},
data
:
{
enabled
:
false
}
});
await
db
.
integrationApp
.
update
({
where
:
{
appId
},
data
:
{
enabled
:
false
}
});
try
{
try
{
...
...
apps/ai-service/tests/workbench-session.spec.ts
View file @
85e97b11
...
@@ -83,6 +83,22 @@ it('首屏直接使用记住的诊所;跨页面选择不触发用户重查', a
...
@@ -83,6 +83,22 @@ it('首屏直接使用记住的诊所;跨页面选择不触发用户重查', a
);
);
expect
(
load
).
toHaveBeenCalledTimes
(
1
);
expect
(
load
).
toHaveBeenCalledTimes
(
1
);
});
});
it
(
'宿主当前门店待配置时不恢复旧店或自动选第一家,管理员仍可主动切换已授权门店'
,
async
()
=>
{
const
{
store
,
load
,
readClinic
}
=
fixture
();
const
me
=
profile
();
me
.
hostContext
=
{
appId
:
'a'
,
dataTenantId
:
'space'
,
orgScope
:
[
'brand'
],
unmappedClinicRef
:
'not-yet-linked'
,
modules
:
[
'workbench'
],
capabilities
:
[
'org.tree.edit'
],
readOnly
:
false
,
};
load
.
mockResolvedValue
(
me
);
await
store
.
load
();
expect
(
store
.
getSnapshot
().
clinicId
).
toBeNull
();
expect
(
readClinic
).
not
.
toHaveBeenCalled
();
store
.
pickClinic
(
'remembered'
);
expect
(
store
.
getSnapshot
().
clinicId
).
toBe
(
'remembered'
);
store
.
setMe
(
me
,
'session-a'
);
expect
(
store
.
getSnapshot
().
clinicId
).
toBe
(
'remembered'
);
});
it
(
'过期更新期间保留机构和托管显示,更新失败也不闪为空态'
,
async
()
=>
{
it
(
'过期更新期间保留机构和托管显示,更新失败也不闪为空态'
,
async
()
=>
{
const
{
store
,
load
,
age
}
=
fixture
();
const
{
store
,
load
,
age
}
=
fixture
();
await
store
.
load
();
await
store
.
load
();
...
...
apps/ai-web/src/app/admin/org/page.tsx
View file @
85e97b11
...
@@ -8,6 +8,7 @@ import { OrgNodeDetail } from '@/components/admin/org-node-detail';
...
@@ -8,6 +8,7 @@ import { OrgNodeDetail } from '@/components/admin/org-node-detail';
import
{
OrgRootCreate
}
from
'@/components/admin/org-root-create'
;
import
{
OrgRootCreate
}
from
'@/components/admin/org-root-create'
;
import
{
Badge
}
from
'@/components/ui/badge'
;
import
{
Badge
}
from
'@/components/ui/badge'
;
import
{
kindLabel
}
from
'@/lib/labels'
;
import
{
kindLabel
}
from
'@/lib/labels'
;
import
{
useWorkbenchState
}
from
'@/components/workbench-provider'
;
/**
/**
* 组织架构(基础设置)—— 集团 / 品牌 / 区域 / 门店这棵树**本身**。
* 组织架构(基础设置)—— 集团 / 品牌 / 区域 / 门店这棵树**本身**。
...
@@ -23,14 +24,18 @@ import { kindLabel } from '@/lib/labels';
...
@@ -23,14 +24,18 @@ import { kindLabel } from '@/lib/labels';
* ⚠️ 改组织架构要求全租户档;看得见、改不了的人整页照画、控件禁用。
* ⚠️ 改组织架构要求全租户档;看得见、改不了的人整页照画、控件禁用。
*/
*/
export
default
function
AdminOrgPage
()
{
export
default
function
AdminOrgPage
()
{
return
<
AdminShell
>
{
(
me
,
guard
)
=>
<
Org
guard=
{
guard
}
tenantName=
{
me
.
tenantName
}
/>
}
</
AdminShell
>;
return
<
AdminShell
>
{
(
me
,
guard
)
=>
<
Org
guard=
{
guard
}
tenantName=
{
me
.
tenantName
}
unmappedClinicRef=
{
me
.
hostContext
?.
unmappedClinicRef
}
/>
}
</
AdminShell
>;
}
}
function
Org
({
guard
,
tenantName
}:
{
guard
:
(
e
:
unknown
)
=>
void
;
tenantName
:
string
})
{
function
Org
({
guard
,
tenantName
,
unmappedClinicRef
}:
{
guard
:
(
e
:
unknown
)
=>
void
;
tenantName
:
string
;
unmappedClinicRef
?:
string
})
{
const
{
store
}
=
useWorkbenchState
();
const
[
tree
,
setTree
]
=
useState
<
OrgTreeResp
|
null
>
(
null
);
const
[
tree
,
setTree
]
=
useState
<
OrgTreeResp
|
null
>
(
null
);
const
[
sel
,
setSel
]
=
useState
<
string
|
null
>
(
null
);
const
[
sel
,
setSel
]
=
useState
<
string
|
null
>
(
null
);
const
[
tick
,
setTick
]
=
useState
(
0
);
const
[
tick
,
setTick
]
=
useState
(
0
);
const
refresh
=
useCallback
(()
=>
setTick
((
t
)
=>
t
+
1
),
[]);
const
refresh
=
useCallback
(()
=>
{
setTick
((
t
)
=>
t
+
1
);
void
store
.
load
(
true
);
},
[
store
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
api
.
orgTree
().
then
(
setTree
).
catch
(
guard
);
api
.
orgTree
().
then
(
setTree
).
catch
(
guard
);
...
@@ -59,6 +64,9 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s
...
@@ -59,6 +64,9 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s
},
[
tree
,
sel
]);
},
[
tree
,
sel
]);
const
node
=
tree
?.
nodes
.
find
((
n
)
=>
n
.
id
===
sel
)
??
null
;
const
node
=
tree
?.
nodes
.
find
((
n
)
=>
n
.
id
===
sel
)
??
null
;
const
needsClinic
=
tree
?.
can
.
editTree
&&
unmappedClinicRef
&&
!
tree
.
nodes
.
some
(
(
n
)
=>
n
.
isActive
&&
n
.
kind
===
'clinic'
&&
n
.
pacRef
===
unmappedClinicRef
,
);
return
(
return
(
<
div
className=
"flex min-h-0 flex-1"
>
<
div
className=
"flex min-h-0 flex-1"
>
...
@@ -126,6 +134,13 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s
...
@@ -126,6 +134,13 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s
</
aside
>
</
aside
>
<
section
className=
"flex min-w-0 flex-1 flex-col"
>
<
section
className=
"flex min-w-0 flex-1 flex-col"
>
{
needsClinic
?
(
<
div
role=
"status"
className=
"border-b border-blue-100 bg-blue-50 px-4 py-3 text-[12px] leading-6 text-blue-900"
>
<
p
className=
"font-medium"
>
先关联当前门店
</
p
>
<
p
>
你已作为客户管理员进入。请添加当前门店,并在「患者档案」中关联对应诊所;已有门店可直接选择后关联。
</
p
>
<
p
className=
"text-blue-700"
>
宿主诊所编号:
<
span
className=
"break-all font-mono"
>
{
unmappedClinicRef
}
</
span
></
p
>
</
div
>
)
:
null
}
{
tree
&&
node
?
(
{
tree
&&
node
?
(
<
OrgNodeDetail
key=
{
node
.
id
}
node=
{
node
}
nodes=
{
tree
.
nodes
}
can=
{
tree
.
can
}
pac=
{
pac
}
onChanged=
{
refresh
}
onSelect=
{
setSel
}
/>
<
OrgNodeDetail
key=
{
node
.
id
}
node=
{
node
}
nodes=
{
tree
.
nodes
}
can=
{
tree
.
can
}
pac=
{
pac
}
onChanged=
{
refresh
}
onSelect=
{
setSel
}
/>
)
:
tree
&&
tree
.
nodes
.
length
===
0
&&
tree
.
can
.
editTree
?
(
)
:
tree
&&
tree
.
nodes
.
length
===
0
&&
tree
.
can
.
editTree
?
(
...
...
apps/ai-web/src/components/platform/tenant-onboarding.tsx
View file @
85e97b11
...
@@ -30,7 +30,7 @@ function Field({
...
@@ -30,7 +30,7 @@ function Field({
</
label
>
</
label
>
);
);
}
}
export
function
ActivationCard
({
activation
}:
{
activation
:
Activatio
n
})
{
export
function
ActivationCard
({
activation
,
hostMapped
=
false
}:
{
activation
:
Activation
;
hostMapped
?:
boolea
n
})
{
const
[
copied
,
setCopied
]
=
useState
(
false
),
const
[
copied
,
setCopied
]
=
useState
(
false
),
[
error
,
setError
]
=
useState
(
''
);
[
error
,
setError
]
=
useState
(
''
);
const
url
=
const
url
=
...
@@ -39,7 +39,8 @@ export function ActivationCard({ activation }: { activation: Activation }) {
...
@@ -39,7 +39,8 @@ export function ActivationCard({ activation }: { activation: Activation }) {
:
`
${
window
.
location
.
origin
}
/activate?t=
${
encodeURIComponent
(
activation
.
token
)}
`
;
:
`
${
window
.
location
.
origin
}
/activate?t=
${
encodeURIComponent
(
activation
.
token
)}
`
;
return
(
return
(
<
div
className=
"space-y-3 rounded-xl bg-blue-50 p-4"
>
<
div
className=
"space-y-3 rounded-xl bg-blue-50 p-4"
>
<
p
className=
"font-medium"
>
转交给管理员本人激活
</
p
>
<
p
className=
"font-medium"
>
{
hostMapped
?
'按需设置独立登录'
:
'转交给管理员本人激活'
}
</
p
>
{
hostMapped
?
<
p
className=
"text-xs text-slate-600"
>
已关联宿主账号,宿主授权允许时可直接进入配置组织,无需先设置独立登录。
</
p
>
:
null
}
<
p
className=
"text-xs text-slate-500"
>
<
p
className=
"text-xs text-slate-500"
>
本人打开链接后填写手机号并设置密码。有效至
{
' '
}
本人打开链接后填写手机号并设置密码。有效至
{
' '
}
{
new
Date
(
activation
.
expiresAt
).
toLocaleString
(
'zh-CN'
)
}
,使用一次后失效。
{
new
Date
(
activation
.
expiresAt
).
toLocaleString
(
'zh-CN'
)
}
,使用一次后失效。
...
@@ -214,8 +215,7 @@ export function TenantOnboarding({
...
@@ -214,8 +215,7 @@ export function TenantOnboarding({
/>
/>
</
Field
>
</
Field
>
<
p
className=
"rounded-xl bg-blue-50 p-4 text-slate-600"
>
<
p
className=
"rounded-xl bg-blue-50 p-4 text-slate-600"
>
开户后生成 30
关联宿主账号后,管理员可按宿主授权直接进入配置组织。需要独立登录时,再由本人通过 30 分钟有效的激活链接设置手机号和密码。
分钟有效的激活链接。由管理员本人填写手机号、设置密码,即可独立登录搭建组织。
</
p
>
</
p
>
<
Field
<
Field
label=
"宿主用户 ID(可选)"
label=
"宿主用户 ID(可选)"
...
@@ -263,7 +263,7 @@ export function TenantOnboarding({
...
@@ -263,7 +263,7 @@ export function TenantOnboarding({
<
dt
>
宿主账号映射
</
dt
>
<
dt
>
宿主账号映射
</
dt
>
<
dd
>
{
sourceUserId
||
'暂未填写'
}
</
dd
>
<
dd
>
{
sourceUserId
||
'暂未填写'
}
</
dd
>
<
dt
>
首次登录
</
dt
>
<
dt
>
首次登录
</
dt
>
<
dd
>
本人激活后登录
</
dd
>
<
dd
>
{
sourceUserId
.
trim
()
?
'按宿主授权直接进入;独立登录按需激活'
:
'本人激活后独立登录'
}
</
dd
>
<
dt
>
组织数据核对
</
dt
>
<
dt
>
组织数据核对
</
dt
>
<
dd
>
<
dd
>
{
preview
.
dataStatus
===
'verified'
?
'已找到客户范围内的组织编号'
:
'尚未完成'
}
{
preview
.
dataStatus
===
'verified'
?
'已找到客户范围内的组织编号'
:
'尚未完成'
}
...
@@ -288,7 +288,7 @@ export function TenantOnboarding({
...
@@ -288,7 +288,7 @@ export function TenantOnboarding({
<
p
className=
"text-base font-medium text-emerald-700"
>
{
created
.
tenant
.
name
}
已开通
</
p
>
<
p
className=
"text-base font-medium text-emerald-700"
>
{
created
.
tenant
.
name
}
已开通
</
p
>
<
p
>
{
created
.
owner
.
displayName
}
已设为客户管理员,可管理本客户的组织树和员工。
</
p
>
<
p
>
{
created
.
owner
.
displayName
}
已设为客户管理员,可管理本客户的组织树和员工。
</
p
>
{
created
.
activation
?
(
{
created
.
activation
?
(
<
ActivationCard
activation=
{
created
.
activation
}
/>
<
ActivationCard
activation=
{
created
.
activation
}
hostMapped=
{
!!
sourceUserId
.
trim
()
}
/>
)
:
(
)
:
(
<
p
className=
"rounded-xl bg-blue-50 p-4"
>
请在客户详情为首位管理员生成激活链接。
</
p
>
<
p
className=
"rounded-xl bg-blue-50 p-4"
>
请在客户详情为首位管理员生成激活链接。
</
p
>
)
}
)
}
...
...
apps/ai-web/src/components/sidebar.tsx
View file @
85e97b11
...
@@ -477,6 +477,9 @@ function ClinicSwitch({
...
@@ -477,6 +477,9 @@ function ClinicSwitch({
if
(
!
clinics
.
length
)
{
if
(
!
clinics
.
length
)
{
// ① 还没加载完 —— ⛔ 别下结论。占一行灰字,位置不跳。
// ① 还没加载完 —— ⛔ 别下结论。占一行灰字,位置不跳。
if
(
!
me
)
return
<
div
className=
"truncate text-[12px] font-semibold text-slate-300"
>
···
</
div
>;
if
(
!
me
)
return
<
div
className=
"truncate text-[12px] font-semibold text-slate-300"
>
···
</
div
>;
if
(
me
.
hostContext
?.
unmappedClinicRef
)
return
(
<
div
className=
"truncate text-[12px] font-semibold text-amber-600"
title=
"请在组织架构中添加门店并关联患者档案"
>
待配置门店
</
div
>
);
/**
/**
* ② 真的一家都没被授权 —— 这才是要去找管理员的。
* ② 真的一家都没被授权 —— 这才是要去找管理员的。
*
*
...
@@ -497,15 +500,15 @@ function ClinicSwitch({
...
@@ -497,15 +500,15 @@ function ClinicSwitch({
</
div
>
</
div
>
);
);
}
}
const
cur
=
clinics
.
find
((
c
)
=>
c
.
id
===
clinicId
)
??
clinics
[
0
]
!
;
const
cur
=
clinics
.
find
((
c
)
=>
c
.
id
===
clinicId
)
??
(
me
?.
hostContext
?.
unmappedClinicRef
?
null
:
clinics
[
0
]
!
)
;
if
(
clinics
.
length
===
1
)
{
if
(
clinics
.
length
===
1
&&
cur
)
{
return
<
div
className=
"truncate text-[12px] font-semibold"
>
{
withInactive
(
cur
)
}
</
div
>;
return
<
div
className=
"truncate text-[12px] font-semibold"
>
{
withInactive
(
cur
)
}
</
div
>;
}
}
return
(
return
(
<
Select
value=
{
cur
.
id
}
onValueChange=
{
onClinic
}
>
<
Select
value=
{
cur
?.
id
??
''
}
onValueChange=
{
onClinic
}
>
<
SelectTrigger
<
SelectTrigger
aria
-
label=
"切换诊所"
aria
-
label=
"切换诊所"
title=
{
withInactive
(
cur
)
}
title=
{
cur
?
withInactive
(
cur
)
:
'选择诊所'
}
/**
/**
* ⚠️ 必须显式压掉基类里的 `h-9` / `px-3 py-2` / `border` / `bg-white` ——
* ⚠️ 必须显式压掉基类里的 `h-9` / `px-3 py-2` / `border` / `bg-white` ——
* tailwind-merge 只合并**同一个属性**,不写就会在这行 10.5px 的小字里
* tailwind-merge 只合并**同一个属性**,不写就会在这行 10.5px 的小字里
...
@@ -521,7 +524,7 @@ function ClinicSwitch({
...
@@ -521,7 +524,7 @@ function ClinicSwitch({
现在选项里只有店名,两种写法看起来一样 —— 但哪天往选项里加一句提示,
现在选项里只有店名,两种写法看起来一样 —— 但哪天往选项里加一句提示,
无参数那种会跟着把提示也显示到这行 12px 的字里(踩过一次)。
无参数那种会跟着把提示也显示到这行 12px 的字里(踩过一次)。
*/
}
*/
}
<
SelectValue
>
{
withInactive
(
cur
)
}
</
SelectValue
>
<
SelectValue
placeholder=
"选择诊所"
>
{
cur
?
withInactive
(
cur
)
:
undefined
}
</
SelectValue
>
</
SelectTrigger
>
</
SelectTrigger
>
<
SelectContent
>
<
SelectContent
>
{
clinics
.
map
((
c
)
=>
(
{
clinics
.
map
((
c
)
=>
(
...
...
apps/ai-web/src/lib/api.ts
View file @
85e97b11
...
@@ -330,6 +330,7 @@ export function hostAllows(me: Me | null | undefined, capability: string): boole
...
@@ -330,6 +330,7 @@ export function hostAllows(me: Me | null | undefined, capability: string): boole
export
interface
Me
{
export
interface
Me
{
hostContext
?:
{
hostContext
?:
{
appId
:
string
;
dataTenantId
:
string
;
orgScope
:
string
[];
clinicId
?:
string
;
appId
:
string
;
dataTenantId
:
string
;
orgScope
:
string
[];
clinicId
?:
string
;
unmappedClinicRef
?:
string
;
modules
:
string
[];
capabilities
:
string
[];
readOnly
:
boolean
;
modules
:
string
[];
capabilities
:
string
[];
readOnly
:
boolean
;
};
};
id
:
string
;
id
:
string
;
...
...
apps/ai-web/src/lib/auth-session.ts
View file @
85e97b11
/** 所有网页登录共用:localStorage 只保存可撤销刷新凭据,访问令牌只在内存。 */
/** 所有网页登录共用:localStorage 只保存可撤销刷新凭据,访问令牌只在内存。 */
export
interface
HostEntryContext
{
export
interface
HostEntryContext
{
appId
:
string
;
dataTenantId
:
string
;
orgScope
:
string
[];
clinicId
?:
string
;
appId
:
string
;
dataTenantId
:
string
;
orgScope
:
string
[];
clinicId
?:
string
;
entryPath
:
'/'
|
'/agents/appointment-confirm'
;
unmappedClinicRef
?:
string
;
entryPath
:
'/'
|
'/agents/appointment-confirm'
|
'/admin/org'
;
}
}
export
interface
SessionTokens
{
export
interface
SessionTokens
{
context
?:
HostEntryContext
;
context
?:
HostEntryContext
;
...
...
apps/ai-web/src/lib/workbench-session.ts
View file @
85e97b11
...
@@ -67,9 +67,11 @@ export class WorkbenchSession {
...
@@ -67,9 +67,11 @@ export class WorkbenchSession {
if
(
expectedSid
!==
this
.
deps
.
sessionId
())
return
;
if
(
expectedSid
!==
this
.
deps
.
sessionId
())
return
;
const
old
=
this
.
getSnapshot
();
const
old
=
this
.
getSnapshot
();
const
sameUser
=
old
.
me
?.
id
===
me
.
id
&&
old
.
me
.
tenantId
===
me
.
tenantId
;
const
sameUser
=
old
.
me
?.
id
===
me
.
id
&&
old
.
me
.
tenantId
===
me
.
tenantId
;
const
picked
=
sameUser
?
old
.
clinicId
:
this
.
deps
.
readClinic
(
me
,
expectedSid
);
const
picked
=
sameUser
?
old
.
clinicId
const
clinicId
=
:
me
.
hostContext
?.
unmappedClinicRef
?
null
:
this
.
deps
.
readClinic
(
me
,
expectedSid
);
pickDefaultClinic
(
me
.
clinics
,
picked
??
me
.
hostContext
?.
clinicId
??
null
)?.
id
??
null
;
// 当前门店尚未关联时先留在组织配置,不用旧缓存或第一家门店冒充当前选择。
const
clinicId
=
me
.
hostContext
?.
unmappedClinicRef
&&
!
picked
?
null
:
pickDefaultClinic
(
me
.
clinics
,
picked
??
me
.
hostContext
?.
clinicId
??
null
)?.
id
??
null
;
// 内容未变时保留引用,避免业务页把后台校验误当成一次身份/范围变更。
// 内容未变时保留引用,避免业务页把后台校验误当成一次身份/范围变更。
const
stableMe
=
old
.
me
&&
JSON
.
stringify
(
old
.
me
)
===
JSON
.
stringify
(
me
)
?
old
.
me
:
me
;
const
stableMe
=
old
.
me
&&
JSON
.
stringify
(
old
.
me
)
===
JSON
.
stringify
(
me
)
?
old
.
me
:
me
;
// 显式更新优先于在途旧读取(例如绑定/转交结束后回来的新数据)。
// 显式更新优先于在途旧读取(例如绑定/转交结束后回来的新数据)。
...
...
apps/pac-docs/content/docs/friday-ai/getting-started.mdx
View file @
85e97b11
...
@@ -8,9 +8,11 @@ icon: ListChecks
...
@@ -8,9 +8,11 @@ icon: ListChecks
## 1. 确认客户与数据范围
## 1. 确认客户与数据范围
向服务方提供机构名称、业务系统、客户组织编号和首位管理员信息。服务方核对数据归属并开通客户
,管理员通过一次性激活链接设置登录信息。链接 30 分钟有效;未激活时可由平台重新生成,旧链接随即失效
。
向服务方提供机构名称、业务系统、客户组织编号和首位管理员信息。服务方核对数据归属并开通客户。
独立登录使用 FridayAI 登录页;已接入宿主的机构也可从业务系统进入。两种入口的可用功能以各自授权为准。
已接入宿主并关联账号的管理员,可按宿主授权直接进入配置组织,无需先设置手机号和密码。当前门店尚未关联时,拥有全客户配置权限的管理员会先进入「组织架构」。
需要独立登录时,由管理员通过一次性激活链接设置手机号和密码。链接 30 分钟有效;未激活时可由平台重新生成,旧链接随即失效。两种入口的可用功能以各自授权为准。
## 2. 配置组织与员工
## 2. 配置组织与员工
...
...
apps/pac-docs/content/docs/friday-ai/integration.mdx
View file @
85e97b11
...
@@ -26,7 +26,7 @@ FridayAI 通过患者数据中枢使用机构授权的患者与预约信息,
...
@@ -26,7 +26,7 @@ FridayAI 通过患者数据中枢使用机构授权的患者与预约信息,
## 从宿主进入工作台
## 从宿主进入工作台
宿主后端核验用户身份与当前权限后,申请短期进入码;FridayAI 兑换后打开已授权模块。
机构和组织映射需事先开通,用户权限按 FridayAI 与宿主授权共同限制
。
宿主后端核验用户身份与当前权限后,申请短期进入码;FridayAI 兑换后打开已授权模块。
客户与组织授权范围需事先开通,用户权限按 FridayAI 与宿主授权共同限制。已关联的全客户管理员可先进入配置尚未关联的门店;员工需等待对应门店及授权就绪后进入
。
参数、进入码、组织授权及退出处理统一见[FridayAI 宿主接入契约](/docs/integration/friday-ai-host)。
参数、进入码、组织授权及退出处理统一见[FridayAI 宿主接入契约](/docs/integration/friday-ai-host)。
...
...
apps/pac-docs/content/docs/integration/friday-ai-host.mdx
View file @
85e97b11
...
@@ -71,7 +71,7 @@ frame.src = `${aiWebBaseUrl}/embed?code=${encodeURIComponent(result.code)}`;
...
@@ -71,7 +71,7 @@ frame.src = `${aiWebBaseUrl}/embed?code=${encodeURIComponent(result.code)}`;
// 或 window.open 同一个地址。不要把用户令牌放 URL。
// 或 window.open 同一个地址。不要把用户令牌放 URL。
```
```
AI 页面自行兑换、移除 URL 中的 code、保存可撤销刷新凭据并恢复会话。无效新码不会回退到旧客户/旧账号。初始页面默认 AI 工作台;集团仅开预约确认模块时进入已开模块。
AI 页面自行兑换、移除 URL 中的 code、保存可撤销刷新凭据并恢复会话。无效新码不会回退到旧客户/旧账号。初始页面默认 AI 工作台;集团仅开预约确认模块时进入已开模块。
符合下述门店配置条件的管理员,先进入组织架构页。
```html
```html
<iframe title="FridayAI" referrerpolicy="no-referrer"
<iframe title="FridayAI" referrerpolicy="no-referrer"
...
@@ -107,7 +107,11 @@ AI 自身租户为独立集团,固定客户组织根、组织树及 PAC 引用
...
@@ -107,7 +107,11 @@ AI 自身租户为独立集团,固定客户组织根、组织树及 PAC 引用
### 当前诊所与展示名
### 当前诊所与展示名
clinicId 使用宿主诊所 ID,映射成 AI 诊所后检查是否在本次有效授权内。不是诊所、未知节点、停用或超范围时明确拒绝,不默默换到另一店。
clinicId 使用宿主诊所 ID,是初始选择,不是授权依据。已映射时必须是启用中的诊所,且在本次有效授权内;类型无效、停用或超范围返回 `12806`,不默默换到另一店。
当前诊所尚未关联时,仅以下条件同时满足才允许进入组织架构页配置门店:本次角色为 `admin`,组织范围覆盖客户根,源身份已显式关联到人工授权的全客户管理员,且应用、客户及本次会话均允许组织配置和人员授权。此时不选择其他诊所、不自动建店或绑定;管理员添加门店并关联患者档案后,原会话按新映射恢复正常诊所选择。已确认映射到其他客户的诊所仍拒绝。
普通员工或未满足上述条件的管理员,门店未关联时仍返回 `12806`。独立登录的手机号、密码激活不作为已关联宿主管理员进入的前置条件。
不传时:恢复该身份/窗口的有效选择,否则选择第一家可用诊所。新进入码提供的初始值优先旧窗口缓存;进入后可在范围内切换,刷新保留该窗口的选择。不同用户、集团和内嵌窗口不会互相改选择。
不传时:恢复该身份/窗口的有效选择,否则选择第一家可用诊所。新进入码提供的初始值优先旧窗口缓存;进入后可在范围内切换,刷新保留该窗口的选择。不同用户、集团和内嵌窗口不会互相改选择。
...
@@ -214,6 +218,8 @@ orgRoot 仅为开户时配置的固定 PAC 客户根,不是每次登录参数
...
@@ -214,6 +218,8 @@ orgRoot 仅为开户时配置的固定 PAC 客户根,不是每次登录参数
| 12815 | 网页刷新竞态,可稍后重试 |
| 12815 | 网页刷新竞态,可稍后重试 |
| 12816 | 无法唯一定位集团、跨集团范围或组织映射歧义 |
| 12816 | 无法唯一定位集团、跨集团范围或组织映射歧义 |
`12806` 是组织校验失败,不等同于“客户未开户”,也不代表必须激活独立登录。请结合 `msg` 区分未关联、停用、超范围或归属核验暂不可用;宿主可显示可操作的配置提示,不能将所有 `12806` 降级放行。管理员的待配置入口仍须通过既有身份和客户根授权检查。
每应用实例默认 token/logout 每分钟 120 次;入口另有独立的来源 IP 和全局限流,不与手机号密码登录共用桶。多实例生产网关应叠加总配额。码消费、刷新、退出的原子性由 PostgreSQL 保证,不要求 Redis。
每应用实例默认 token/logout 每分钟 120 次;入口另有独立的来源 IP 和全局限流,不与手机号密码登录共用桶。多实例生产网关应叠加总配额。码消费、刷新、退出的原子性由 PostgreSQL 保证,不要求 Redis。
宿主须提供后端身份核验、组织授权转换、前端入口及可靠退出通知。上线前完成数据库迁移、应用与客户开通,并核对真实宿主的进入、范围限制和退出流程。预约回写另行启用;未启用时在源业务系统办理。
宿主须提供后端身份核验、组织授权转换、前端入口及可靠退出通知。上线前完成数据库迁移、应用与客户开通,并核对真实宿主的进入、范围限制和退出流程。预约回写另行启用;未启用时在源业务系统办理。
docs/operations/friday-ai-administration.md
View file @
85e97b11
...
@@ -26,6 +26,8 @@ pnpm --filter @pac/ai-service ai:platform revoke \
...
@@ -26,6 +26,8 @@ pnpm --filter @pac/ai-service ai:platform revoke \
客户开户优先使用平台「客户与托管 → 开通客户」。填写宿主、数据空间、固定客户组织根和首位管理员;源用户映射使用核实的 userId,不按姓名或手机号猜测。开户不自动导入门店、增加托管席位或启用 AI 回复。
客户开户优先使用平台「客户与托管 → 开通客户」。填写宿主、数据空间、固定客户组织根和首位管理员;源用户映射使用核实的 userId,不按姓名或手机号猜测。开户不自动导入门店、增加托管席位或启用 AI 回复。
已关联的首位管理员可按宿主
`admin`
及客户根授权直接进入,无需先激活独立登录。当前门店未关联时,全客户配置管理员进入组织架构页添加并关联门店;员工、已停用门店及其他客户的门店仍被组织校验拒绝。
`12806`
应区分门店未关联与权限越界,不能统一当作未开户或一律放行。
## 托管命令
## 托管命令
`tenant`
、
`user`
、
`account`
为 FridayAI 数据库 UUID,不能用宿主 ID 或企微 vid 替代。平台页面与 CLI 共用管理服务。
`tenant`
、
`user`
、
`account`
为 FridayAI 数据库 UUID,不能用宿主 ID 或企微 vid 替代。平台页面与 CLI 共用管理服务。
...
...
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