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
fd0a3336
Commit
fd0a3336
authored
Oct 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(friday-ai): allow mapped customer admins to configure unlinked clinics
parent
b7901c93
Pipeline
#3851
failed in 0 seconds
Changes
17
Pipelines
1
Hide 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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
/** 所有网页登录共用: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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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 @
fd0a3336
...
@@ -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