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
1b133cc2
Commit
1b133cc2
authored
Oct 12, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge FridayAI onboarding and host mappings into test
parents
a66a3883
cb08cf0e
Pipeline
#3757
failed in 0 seconds
Changes
25
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
25 changed files
with
1721 additions
and
23 deletions
+1721
-23
apps/ai-service/src/common/permissions.ts
+2
-1
apps/ai-service/src/modules/integration/integration-policy.service.ts
+17
-4
apps/ai-service/src/modules/integration/integration.schema.ts
+6
-1
apps/ai-service/src/modules/platform/platform-onboarding.schema.ts
+23
-0
apps/ai-service/src/modules/platform/platform-onboarding.service.ts
+331
-0
apps/ai-service/src/modules/platform/platform.controller.ts
+56
-1
apps/ai-service/src/modules/platform/platform.module.ts
+9
-1
apps/ai-service/src/modules/platform/platform.service.ts
+34
-2
apps/ai-service/src/modules/workbench/admin.types.ts
+3
-0
apps/ai-service/src/modules/workbench/contact.controller.ts
+1
-0
apps/ai-service/src/modules/workbench/host-user-mapping.ts
+40
-0
apps/ai-service/src/modules/workbench/org-admin.service.ts
+9
-0
apps/ai-service/src/modules/workbench/provision.service.ts
+8
-2
apps/ai-service/src/modules/workbench/user-admin.service.ts
+40
-1
apps/ai-service/src/modules/workbench/user.controller.ts
+7
-0
apps/ai-service/tests/integration-boundary.spec.ts
+40
-0
apps/ai-service/tests/platform-onboarding.integration.spec.ts
+426
-0
apps/ai-web/src/app/platform/page.tsx
+209
-4
apps/ai-web/src/components/admin/org-node-detail.tsx
+2
-1
apps/ai-web/src/components/admin/person-detail.tsx
+19
-1
apps/ai-web/src/components/platform/tenant-onboarding.tsx
+343
-0
apps/ai-web/src/components/provision-drawer.tsx
+11
-3
apps/ai-web/src/lib/api.ts
+5
-0
docs/friday-ai-platform-management.md
+39
-1
docs/qa/2026-10-12-platform-onboarding-local.md
+41
-0
No files found.
apps/ai-service/src/common/permissions.ts
View file @
1b133cc2
...
...
@@ -33,6 +33,7 @@
*/
export
const
PERMISSIONS
=
{
PLATFORM_VIEW
:
'platform.view'
,
PLATFORM_TENANT_MANAGE
:
'platform.tenant.manage'
,
PLATFORM_HOSTING_MANAGE
:
'platform.hosting.manage'
,
PLATFORM_AUDIT_VIEW
:
'platform.audit.view'
,
APPOINTMENT_CREATE
:
'appointment.create'
,
...
...
@@ -149,7 +150,7 @@ export const ADMIN_PERMISSIONS: readonly Permission[] = [
* 未知角色的正确处理是**什么都不给**,见 `permissionsOf`。
*/
const
ROLE_PERMISSIONS
:
Record
<
string
,
readonly
Permission
[]
>
=
{
platform_admin
:
[
PERMISSIONS
.
PLATFORM_VIEW
,
PERMISSIONS
.
PLATFORM_HOSTING_MANAGE
,
PERMISSIONS
.
PLATFORM_AUDIT_VIEW
],
platform_admin
:
[
PERMISSIONS
.
PLATFORM_VIEW
,
PERMISSIONS
.
PLATFORM_
TENANT_MANAGE
,
PERMISSIONS
.
PLATFORM_
HOSTING_MANAGE
,
PERMISSIONS
.
PLATFORM_AUDIT_VIEW
],
platform_viewer
:
[
PERMISSIONS
.
PLATFORM_VIEW
,
PERMISSIONS
.
PLATFORM_AUDIT_VIEW
],
owner
:
[
PERMISSIONS
.
APPOINTMENT_CREATE
,
...
...
apps/ai-service/src/modules/integration/integration-policy.service.ts
View file @
1b133cc2
...
...
@@ -86,7 +86,16 @@ export class IntegrationPolicyService {
throw
new
BizError
(
12804
,
'集团未开通或已停用'
);
const
parsed
=
tenantPolicySchema
.
safeParse
(
row
.
integrationPolicy
);
if
(
!
parsed
.
success
)
throw
new
BizError
(
12804
,
'集团接入规则未就绪'
);
const
policy
=
parsed
.
data
;
// 平台新开户沿用宿主统一能力;旧客户的显式限制保持原样。
const
policy
=
parsed
.
data
.
inheritAppPolicy
?
{
...
parsed
.
data
,
allowedRoles
:
app
.
allowedRoles
,
capabilities
:
app
.
capabilities
,
modules
:
app
.
modules
,
maxSessionSeconds
:
app
.
maxSessionSeconds
,
}
:
parsed
.
data
;
const
nodes
=
await
db
.
orgUnit
.
findMany
({
where
:
{
tenantId
:
id
},
select
:
{
id
:
true
,
parentId
:
true
,
isActive
:
true
,
kind
:
true
},
...
...
@@ -260,9 +269,13 @@ export class IntegrationPolicyService {
}
async
context
(
session
:
AuthSession
,
db
:
IntegrationDb
=
this
.
prisma
):
Promise
<
HostScope
|
null
>
{
if
(
session
.
source
!==
'host'
)
return
null
;
if
(
!
session
.
tenantId
||
session
.
scope
===
'platform'
)
throw
new
BizError
(
12810
,
'宿主会话范围无效'
);
const
roleGrant
=
await
db
.
userRoleGrant
.
findUnique
({
where
:{
userId_scope
:{
userId
:
session
.
userId
,
scope
:
'tenant'
}}});
if
(
!
roleGrant
||
roleGrant
.
tenantId
!==
session
.
tenantId
)
throw
new
BizError
(
12810
,
'角色授权已撤销'
);
if
(
!
session
.
tenantId
||
session
.
scope
===
'platform'
)
throw
new
BizError
(
12810
,
'宿主会话范围无效'
);
const
roleGrant
=
await
db
.
userRoleGrant
.
findUnique
({
where
:
{
userId_scope
:
{
userId
:
session
.
userId
,
scope
:
'tenant'
}
},
});
if
(
!
roleGrant
||
roleGrant
.
tenantId
!==
session
.
tenantId
)
throw
new
BizError
(
12810
,
'角色授权已撤销'
);
const
ctx
=
session
.
integrationContext
as
unknown
as
HostSessionContext
;
if
(
ctx
?.
version
!==
2
||
!
ctx
.
appId
||
!
ctx
.
launchId
)
throw
new
BizError
(
12810
,
'旧宿主会话已结束,请重新进入'
);
...
...
apps/ai-service/src/modules/integration/integration.schema.ts
View file @
1b133cc2
...
...
@@ -17,7 +17,11 @@ export const BASIC_WRITE = [
'channel.bind'
,
'appointment.task.process'
,
]
as
const
;
export
const
CAPABILITIES
=
[...
BASIC_READ
,
...
BASIC_WRITE
,
...
Object
.
values
(
PERMISSIONS
).
filter
((
p
)
=>
!
p
.
startsWith
(
'platform.'
))]
as
const
;
export
const
CAPABILITIES
=
[
...
BASIC_READ
,
...
BASIC_WRITE
,
...
Object
.
values
(
PERMISSIONS
).
filter
((
p
)
=>
!
p
.
startsWith
(
'platform.'
)),
]
as
const
;
const
BASE_BY_ROLE
:
Record
<
string
,
readonly
string
[]
>
=
{
viewer
:
BASIC_READ
,
staff
:
[...
BASIC_READ
,
...
BASIC_WRITE
],
...
...
@@ -112,6 +116,7 @@ export const appSchema = z
.
strict
();
export
const
tenantPolicySchema
=
z
.
object
({
inheritAppPolicy
:
z
.
boolean
().
optional
(),
version
:
z
.
literal
(
2
),
rootOrgId
:
z
.
string
().
uuid
(),
modules
:
z
.
array
(
moduleName
).
min
(
1
).
max
(
2
).
default
([
'workbench'
,
'appointment-confirm'
]),
...
...
apps/ai-service/src/modules/platform/platform-onboarding.schema.ts
0 → 100644
View file @
1b133cc2
import
{
z
}
from
'zod'
;
import
{
sourceId
}
from
'../integration/integration.schema'
;
export
const
onboardingSchema
=
z
.
object
({
name
:
z
.
string
().
trim
().
min
(
2
).
max
(
64
),
partnerCode
:
z
.
string
().
regex
(
/^
[
a-z0-9
][
a-z0-9_-
]{0,63}
$/
),
partnerTenantId
:
sourceId
,
partnerOrgRootRef
:
sourceId
,
sourceUserId
:
sourceId
.
optional
(),
ownerName
:
z
.
string
().
trim
().
min
(
2
).
max
(
32
),
reason
:
z
.
string
().
trim
().
min
(
1
).
max
(
500
),
})
.
strict
();
export
type
OnboardingInput
=
z
.
infer
<
typeof
onboardingSchema
>
;
export
const
ownerMappingSchema
=
z
.
object
({
sourceUserId
:
sourceId
,
reason
:
z
.
string
().
trim
().
min
(
1
).
max
(
500
),
})
.
strict
();
export
type
OwnerMappingInput
=
z
.
infer
<
typeof
ownerMappingSchema
>
;
apps/ai-service/src/modules/platform/platform-onboarding.service.ts
0 → 100644
View file @
1b133cc2
import
{
BadRequestException
,
ConflictException
,
Injectable
}
from
'@nestjs/common'
;
import
{
createHash
,
randomBytes
}
from
'node:crypto'
;
import
{
Prisma
}
from
'../../../generated/prisma'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
type
{
PlatformUser
}
from
'../../common/guards/jwt-auth.guard'
;
import
{
setTenantRole
}
from
'../../common/role-grants'
;
import
{
identityProvider
,
tenantPolicySchema
}
from
'../integration/integration.schema'
;
import
{
PacService
}
from
'../pac/pac.service'
;
import
type
{
OnboardingInput
,
OwnerMappingInput
}
from
'./platform-onboarding.schema'
;
// 与员工开户相同:凭据仅返回一次,数据库和审计只保存摘要。
const
ACTIVATION_TTL_MS
=
30
*
60
_000
;
type
Db
=
Pick
<
Prisma
.
TransactionClient
,
'tenant'
|
'orgUnitRef'
|
'integrationApp'
>
;
@
Injectable
()
export
class
PlatformOnboardingService
{
constructor
(
private
readonly
db
:
PrismaService
,
private
readonly
pac
:
PacService
,
)
{}
async
applications
()
{
return
this
.
db
.
integrationApp
.
findMany
({
where
:
{
enabled
:
true
},
orderBy
:
{
appId
:
'asc'
},
select
:
{
appId
:
true
,
hostCode
:
true
,
allowedRoles
:
true
,
capabilities
:
true
,
modules
:
true
,
},
});
}
private
async
check
(
db
:
Db
,
input
:
OnboardingInput
)
{
const
tenants
=
await
db
.
tenant
.
findMany
({
where
:
{
partnerCode
:
input
.
partnerCode
,
partnerTenantId
:
input
.
partnerTenantId
},
select
:
{
id
:
true
,
partnerOrgRootRef
:
true
,
integrationPolicy
:
true
},
});
if
(
tenants
.
some
((
t
)
=>
t
.
partnerOrgRootRef
===
input
.
partnerOrgRootRef
))
throw
new
ConflictException
(
'这个客户已开户,请返回列表管理,不能重复开通'
);
const
refs
=
[
input
.
partnerOrgRootRef
];
const
occupied
=
await
db
.
orgUnitRef
.
findFirst
({
where
:
{
tenantId
:
{
in
:
tenants
.
map
((
t
)
=>
t
.
id
)
},
hostCode
:
input
.
partnerCode
,
externalId
:
{
in
:
refs
},
},
select
:
{
externalId
:
true
},
});
if
(
occupied
||
tenants
.
some
(
(
t
)
=>
refs
.
includes
(
t
.
partnerOrgRootRef
)
||
Object
.
keys
(
tenantPolicySchema
.
safeParse
(
t
.
integrationPolicy
).
data
?.
organizationMappings
??
{},
).
some
((
r
)
=>
refs
.
includes
(
r
)),
)
)
throw
new
ConflictException
(
'组织编号已关联其他客户,请核对品牌根和门店编号'
);
}
async
preview
(
input
:
OnboardingInput
)
{
await
this
.
check
(
this
.
db
,
input
);
const
app
=
await
this
.
hostApp
(
this
.
db
,
input
.
partnerCode
);
if
(
input
.
sourceUserId
&&
!
app
)
throw
new
BadRequestException
(
'此宿主尚未接入;可以暂不填写宿主用户 ID,开户后再补映射'
);
// 只读取组织字典;平台开户人员不读取患者。空字典不等于编号核验通过。
let
dataStatus
:
'verified'
|
'empty'
|
'unavailable'
=
'unavailable'
;
const
warnings
:
string
[]
=
[];
if
(
!
app
)
warnings
.
push
(
'宿主尚未接入,当前可独立登录;接入后可补充宿主账号映射。'
);
else
if
(
input
.
sourceUserId
&&
!
app
.
allowedRoles
.
includes
(
'admin'
))
warnings
.
push
(
'账号映射会保存;该宿主当前尚未开放管理员角色,内嵌管理员权限需在宿主接入配置中统一开放。'
,
);
try
{
const
nodes
=
await
this
.
pac
.
orgNodes
({
host
:
input
.
partnerCode
,
tenantId
:
input
.
partnerTenantId
,
rootRef
:
input
.
partnerOrgRootRef
,
});
if
(
!
nodes
.
length
)
{
dataStatus
=
'empty'
;
warnings
.
push
(
'PAC 暂无该客户的组织数据;开户后仍需核对数据接入,不能据此认为患者数据已就绪。'
,
);
}
else
{
dataStatus
=
'verified'
;
}
}
catch
(
e
)
{
if
(
e
instanceof
BadRequestException
)
throw
e
;
warnings
.
push
(
'暂时无法核验 PAC 组织数据;可以先开户,患者数据关联需后续核对。'
);
}
return
{
dataStatus
,
warnings
,
ownerRole
:
'owner'
,
ownerScope
:
'tenant'
};
}
async
create
(
actor
:
PlatformUser
,
input
:
OnboardingInput
)
{
// 提交时重新检查,不信任浏览器的预览结果。
const
preview
=
await
this
.
preview
(
input
);
try
{
const
result
=
await
this
.
db
.
$transaction
(
async
(
tx
)
=>
{
// 同一宿主数据空间串行开户,防止并发把同一编号分配到两个客户。
await
tx
.
$queryRaw
`SELECT pg_advisory_xact_lock(hashtextextended(
${
`ai-onboard:
${
input
.
partnerCode
}
:
${
input
.
partnerTenantId
}
`
}
, 0))::text`
;
await
this
.
check
(
tx
,
input
);
const
tenant
=
await
tx
.
tenant
.
create
({
data
:
{
name
:
input
.
name
,
partnerCode
:
input
.
partnerCode
,
partnerTenantId
:
input
.
partnerTenantId
,
partnerOrgRootRef
:
input
.
partnerOrgRootRef
,
},
select
:
{
id
:
true
,
name
:
true
},
});
const
root
=
await
tx
.
orgUnit
.
create
({
data
:
{
tenantId
:
tenant
.
id
,
name
:
input
.
name
,
kind
:
'brand'
},
});
const
owner
=
await
tx
.
user
.
create
({
data
:
{
tenantId
:
tenant
.
id
,
displayName
:
input
.
ownerName
,
role
:
'owner'
,
dataScope
:
'tenant'
,
},
select
:
{
id
:
true
,
displayName
:
true
},
});
await
setTenantRole
(
tx
,
tenant
.
id
,
owner
.
id
,
'owner'
,
'manual'
,
actor
.
id
);
const
app
=
await
this
.
hostApp
(
tx
,
input
.
partnerCode
);
if
(
app
)
await
this
.
attachHost
(
tx
,
tenant
.
id
,
root
.
id
,
input
.
partnerOrgRootRef
,
app
);
if
(
input
.
sourceUserId
)
{
if
(
!
app
)
throw
new
BadRequestException
(
'宿主应用已变更,请重新核对'
);
await
tx
.
userIdentity
.
create
({
data
:
{
userId
:
owner
.
id
,
provider
:
identityProvider
(
app
.
appId
,
tenant
.
id
),
externalId
:
input
.
sourceUserId
,
},
});
}
const
activation
=
await
this
.
activation
(
tx
,
tenant
.
id
,
owner
.
id
,
actor
.
id
);
await
tx
.
auditLog
.
create
({
data
:
{
scope
:
'platform'
,
tenantId
:
tenant
.
id
,
action
:
'tenant.onboard'
,
objectType
:
'tenant'
,
objectId
:
tenant
.
id
,
actorType
:
'human'
,
actorUserId
:
actor
.
id
,
result
:
'ok'
,
changes
:
{
reason
:
input
.
reason
,
name
:
input
.
name
,
partnerCode
:
input
.
partnerCode
,
partnerTenantId
:
input
.
partnerTenantId
,
partnerOrgRootRef
:
input
.
partnerOrgRootRef
,
ownerId
:
owner
.
id
,
loginMode
:
'activation'
,
rootOrgId
:
root
.
id
,
dataStatus
:
preview
.
dataStatus
,
},
},
});
return
{
tenant
,
owner
,
activation
};
},
{
timeout
:
15000
},
);
return
{
...
result
,
warnings
:
preview
.
warnings
};
}
catch
(
e
)
{
if
(
e
instanceof
Prisma
.
PrismaClientKnownRequestError
&&
e
.
code
===
'P2002'
)
throw
new
ConflictException
(
'客户或组织编号已存在,请刷新列表核对开户结果'
);
throw
e
;
}
}
private
async
hostApp
(
db
:
Pick
<
Prisma
.
TransactionClient
,
'integrationApp'
>
,
host
:
string
)
{
const
apps
=
await
db
.
integrationApp
.
findMany
({
where
:
{
hostCode
:
host
,
enabled
:
true
}
});
if
(
apps
.
length
>
1
)
throw
new
BadRequestException
(
'此宿主存在多个有效应用,需先由接入人员明确应用归属;不能猜测账号映射'
,
);
return
apps
[
0
]
??
null
;
}
private
async
attachHost
(
tx
:
Prisma
.
TransactionClient
,
tenantId
:
string
,
rootId
:
string
,
rootRef
:
string
,
app
:
NonNullable
<
Awaited
<
ReturnType
<
PlatformOnboardingService
[
'hostApp'
]
>>>
,
)
{
await
tx
.
tenant
.
update
({
where
:
{
id
:
tenantId
},
data
:
{
integrationAppId
:
app
.
appId
,
integrationEnabled
:
true
,
integrationPolicy
:
tenantPolicySchema
.
parse
({
version
:
2
,
rootOrgId
:
rootId
,
organizationMappings
:
{
[
rootRef
]:
rootId
},
inheritAppPolicy
:
true
,
allowedRoles
:
app
.
allowedRoles
,
capabilities
:
app
.
capabilities
,
modules
:
app
.
modules
,
maxSessionSeconds
:
app
.
maxSessionSeconds
,
jitEnabled
:
true
,
}),
},
});
}
async
mapOwner
(
actor
:
PlatformUser
,
tenantId
:
string
,
ownerId
:
string
,
input
:
OwnerMappingInput
)
{
return
this
.
db
.
$transaction
(
async
(
tx
)
=>
{
await
tx
.
$queryRaw
`SELECT id FROM app.tenants WHERE id=
${
tenantId
}
::uuid FOR UPDATE`
;
const
tenant
=
await
tx
.
tenant
.
findUnique
({
where
:
{
id
:
tenantId
}
});
if
(
!
tenant
||
tenant
.
status
!==
'active'
)
throw
new
BadRequestException
(
'客户不存在或已停用'
);
const
app
=
tenant
.
integrationAppId
?
await
tx
.
integrationApp
.
findUnique
({
where
:
{
appId
:
tenant
.
integrationAppId
}
})
:
await
this
.
hostApp
(
tx
,
tenant
.
partnerCode
);
if
(
!
app
?.
enabled
||
app
.
hostCode
!==
tenant
.
partnerCode
)
throw
new
BadRequestException
(
'此宿主尚未接入,请先完成宿主应用注册;不影响独立登录'
);
await
tx
.
$queryRaw
`SELECT id FROM app.users WHERE id=
${
ownerId
}
::uuid FOR UPDATE`
;
const
owner
=
await
tx
.
user
.
findFirst
({
where
:
{
id
:
ownerId
,
tenantId
,
isActive
:
true
},
include
:
{
roleGrants
:
true
,
identities
:
true
},
});
if
(
!
owner
||
owner
.
dataScope
!==
'tenant'
||
!
owner
.
roleGrants
.
some
((
g
)
=>
g
.
scope
===
'tenant'
&&
g
.
role
===
'owner'
)
||
owner
.
roleGrants
.
some
((
g
)
=>
g
.
scope
===
'platform'
)
)
throw
new
BadRequestException
(
'请选择本客户全范围的 owner'
);
const
provider
=
identityProvider
(
app
.
appId
,
tenantId
);
const
identity
=
await
tx
.
userIdentity
.
findUnique
({
where
:
{
provider_externalId
:
{
provider
,
externalId
:
input
.
sourceUserId
}
},
});
if
(
identity
||
owner
.
identities
.
some
((
i
)
=>
i
.
provider
.
startsWith
(
'host:'
)))
throw
new
ConflictException
(
'宿主用户或管理员已建立映射,不能覆盖或合并账号'
);
if
(
!
tenant
.
integrationAppId
)
{
const
roots
=
await
tx
.
orgUnit
.
findMany
({
where
:
{
tenantId
,
parentId
:
null
,
isActive
:
true
},
});
if
(
roots
.
length
!==
1
)
throw
new
BadRequestException
(
'请确认唯一的组织顶层节点'
);
await
this
.
attachHost
(
tx
,
tenantId
,
roots
[
0
]
!
.
id
,
tenant
.
partnerOrgRootRef
,
app
);
}
await
tx
.
userIdentity
.
create
({
data
:
{
userId
:
ownerId
,
provider
,
externalId
:
input
.
sourceUserId
},
});
await
tx
.
auditLog
.
create
({
data
:
{
scope
:
'platform'
,
tenantId
,
action
:
'tenant.owner.map'
,
objectType
:
'user'
,
objectId
:
ownerId
,
actorType
:
'human'
,
actorUserId
:
actor
.
id
,
result
:
'ok'
,
changes
:
{
reason
:
input
.
reason
,
appId
:
app
.
appId
},
},
});
return
{
ok
:
true
};
});
}
private
async
activation
(
tx
:
Prisma
.
TransactionClient
,
tenantId
:
string
,
userId
:
string
,
actorId
:
string
,
)
{
const
token
=
randomBytes
(
32
).
toString
(
'base64url'
);
const
expiresAt
=
new
Date
(
Date
.
now
()
+
ACTIVATION_TTL_MS
);
await
tx
.
userActivation
.
create
({
data
:
{
tenantId
,
userId
,
tokenHash
:
createHash
(
'sha256'
).
update
(
token
).
digest
(
'hex'
),
expiresAt
,
createdBy
:
actorId
,
},
});
return
{
token
,
expiresAt
:
expiresAt
.
toISOString
()
};
}
async
reissue
(
actor
:
PlatformUser
,
tenantId
:
string
,
userId
:
string
,
reason
:
string
)
{
return
this
.
db
.
$transaction
(
async
(
tx
)
=>
{
await
tx
.
$queryRaw
`SELECT id FROM app.users WHERE id=
${
userId
}
::uuid FOR UPDATE`
;
const
user
=
await
tx
.
user
.
findFirst
({
where
:
{
id
:
userId
,
tenantId
,
isActive
:
true
},
include
:
{
identities
:
true
,
tenant
:
true
,
roleGrants
:
true
,
activations
:
{
select
:
{
userId
:
true
},
take
:
1
},
},
});
if
(
!
user
||
user
.
tenant
?.
status
!==
'active'
||
user
.
passwordHash
||
(
user
.
identities
.
length
>
0
&&
!
user
.
activations
.
length
)
||
!
user
.
roleGrants
.
some
((
r
)
=>
r
.
scope
===
'tenant'
&&
r
.
role
===
'owner'
)
||
user
.
roleGrants
.
some
((
r
)
=>
r
.
scope
===
'platform'
)
)
throw
new
BadRequestException
(
'仅支持尚未激活且已发放激活票的客户管理员'
);
await
tx
.
userActivation
.
updateMany
({
where
:
{
tenantId
,
userId
,
usedAt
:
null
},
data
:
{
usedAt
:
new
Date
()
},
});
const
activation
=
await
this
.
activation
(
tx
,
tenantId
,
userId
,
actor
.
id
);
await
tx
.
auditLog
.
create
({
data
:
{
scope
:
'platform'
,
tenantId
,
action
:
'tenant.owner.activation'
,
objectType
:
'user'
,
objectId
:
userId
,
actorType
:
'human'
,
actorUserId
:
actor
.
id
,
result
:
'ok'
,
changes
:
{
reason
},
},
});
return
activation
;
});
}
}
apps/ai-service/src/modules/platform/platform.controller.ts
View file @
1b133cc2
import
{
Body
,
Controller
,
Get
,
Param
,
Post
,
Req
,
UseGuards
}
from
'@nestjs/common'
;
import
{
Body
,
Controller
,
Get
,
Header
,
Param
,
Post
,
Req
,
UseGuards
}
from
'@nestjs/common'
;
import
{
z
}
from
'zod'
;
import
{
ZodValidationException
}
from
'nestjs-zod'
;
function
input
<
T
extends
z
.
ZodType
>
(
schema
:
T
,
value
:
unknown
):
z
.
output
<
T
>
{
...
...
@@ -16,6 +16,8 @@ import { LoginThrottleGuard, ThrottleCost } from '../auth/login-throttle.guard';
import
{
PasswordLoginService
}
from
'../auth/password-login.service'
;
import
{
SessionService
}
from
'../auth/session.service'
;
import
{
PlatformService
}
from
'./platform.service'
;
import
{
PlatformOnboardingService
}
from
'./platform-onboarding.service'
;
import
{
onboardingSchema
,
ownerMappingSchema
}
from
'./platform-onboarding.schema'
;
@
Controller
(
'platform'
)
@
AuthScope
(
'platform'
)
...
...
@@ -24,6 +26,7 @@ export class PlatformController {
private
readonly
platform
:
PlatformService
,
private
readonly
passwords
:
PasswordLoginService
,
private
readonly
sessions
:
SessionService
,
private
readonly
onboarding
:
PlatformOnboardingService
,
)
{}
@
Get
(
'me'
)
@
AllowTempPassword
()
...
...
@@ -54,6 +57,58 @@ export class PlatformController {
tenants
()
{
return
this
.
platform
.
tenants
();
}
@
Get
(
'onboarding/apps'
)
@
RequirePermission
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
)
applications
()
{
return
this
.
onboarding
.
applications
();
}
@
Post
(
'onboarding/preview'
)
@
RequirePermission
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
)
preview
(@
Body
()
body
:
unknown
)
{
return
this
.
onboarding
.
preview
(
input
(
onboardingSchema
,
body
));
}
@
Post
(
'tenants'
)
@
Header
(
'Cache-Control'
,
'no-store'
)
@
RequirePermission
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
)
create
(@
Req
()
req
:
{
platformUser
:
PlatformUser
},
@
Body
()
body
:
unknown
)
{
return
this
.
onboarding
.
create
(
req
.
platformUser
,
input
(
onboardingSchema
,
body
));
}
@
Post
(
'tenants/:id/owners/:userId/mapping'
)
@
RequirePermission
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
)
mapOwner
(
@
Req
()
req
:
{
platformUser
:
PlatformUser
},
@
Param
(
'id'
)
id
:
string
,
@
Param
(
'userId'
)
userId
:
string
,
@
Body
()
body
:
unknown
,
)
{
return
this
.
onboarding
.
mapOwner
(
req
.
platformUser
,
input
(
z
.
uuid
(),
id
),
input
(
z
.
uuid
(),
userId
),
input
(
ownerMappingSchema
,
body
),
);
}
@
Post
(
'tenants/:id/owners/:userId/activation'
)
@
Header
(
'Cache-Control'
,
'no-store'
)
@
RequirePermission
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
)
activation
(
@
Req
()
req
:
{
platformUser
:
PlatformUser
},
@
Param
(
'id'
)
id
:
string
,
@
Param
(
'userId'
)
userId
:
string
,
@
Body
()
body
:
unknown
,
)
{
const
b
=
input
(
z
.
object
({
reason
:
z
.
string
().
trim
().
min
(
1
).
max
(
500
)
}).
strict
(),
body
);
return
this
.
onboarding
.
reissue
(
req
.
platformUser
,
input
(
z
.
uuid
(),
id
),
input
(
z
.
uuid
(),
userId
),
b
.
reason
,
);
}
@
Get
(
'tenants/:id'
)
@
RequirePermission
(
PERMISSIONS
.
PLATFORM_VIEW
)
detail
(@
Param
(
'id'
)
id
:
string
)
{
...
...
apps/ai-service/src/modules/platform/platform.module.ts
View file @
1b133cc2
...
...
@@ -3,9 +3,17 @@ import { Module } from '@nestjs/common';
import
{
PlatformController
}
from
'./platform.controller'
;
import
{
PlatformService
}
from
'./platform.service'
;
import
{
HostingAdminService
}
from
'../hosting/hosting-admin.service'
;
import
{
PacModule
}
from
'../pac/pac.module'
;
import
{
PlatformOnboardingService
}
from
'./platform-onboarding.service'
;
@
Module
({
imports
:
[
PacModule
],
controllers
:
[
PlatformController
],
providers
:
[
PlatformAdminService
,
PlatformService
,
HostingAdminService
],
providers
:
[
PlatformAdminService
,
PlatformService
,
HostingAdminService
,
PlatformOnboardingService
,
],
exports
:
[
HostingAdminService
],
})
export
class
PlatformModule
{}
apps/ai-service/src/modules/platform/platform.service.ts
View file @
1b133cc2
...
...
@@ -8,6 +8,7 @@ import { Prisma } from '../../../generated/prisma';
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
HostingAdminService
}
from
'../hosting/hosting-admin.service'
;
import
type
{
PlatformUser
}
from
'../../common/guards/jwt-auth.guard'
;
import
{
identityProvider
}
from
'../integration/integration.schema'
;
import
{
maskPhone
}
from
'../../common/mask'
;
@
Injectable
()
...
...
@@ -64,7 +65,18 @@ export class PlatformService {
const
users
=
await
this
.
db
.
user
.
findMany
({
where
:
{
tenantId
},
orderBy
:
{
createdAt
:
'desc'
},
select
:
{
id
:
true
,
displayName
:
true
,
phone
:
true
,
role
:
true
,
isActive
:
true
},
select
:
{
id
:
true
,
displayName
:
true
,
phone
:
true
,
role
:
true
,
isActive
:
true
,
passwordHash
:
true
,
dataScope
:
true
,
identities
:
{
select
:
{
provider
:
true
,
externalId
:
true
}
},
activations
:
{
select
:
{
userId
:
true
},
take
:
1
},
roleGrants
:
{
select
:
{
scope
:
true
}
},
},
});
const
accounts
=
await
this
.
db
.
wecomAccount
.
findMany
({
where
:
{
tenantId
},
...
...
@@ -76,7 +88,27 @@ export class PlatformService {
});
return
{
tenant
,
users
:
users
.
map
((
u
)
=>
({
...
u
,
phone
:
u
.
phone
?
maskPhone
(
u
.
phone
)
:
null
})),
users
:
users
.
map
(({
passwordHash
,
identities
,
roleGrants
,
activations
,
...
u
})
=>
({
...
u
,
phone
:
u
.
phone
?
maskPhone
(
u
.
phone
)
:
null
,
sourceUserId
:
tenant
.
integrationAppId
?
(
identities
.
find
(
(
i
)
=>
i
.
provider
===
identityProvider
(
tenant
.
integrationAppId
!
,
tenantId
),
)?.
externalId
??
null
)
:
null
,
canMapOwner
:
u
.
role
===
'owner'
&&
u
.
dataScope
===
'tenant'
&&
u
.
isActive
&&
!
identities
.
some
((
i
)
=>
i
.
provider
.
startsWith
(
'host:'
))
&&
!
roleGrants
.
some
((
g
)
=>
g
.
scope
===
'platform'
),
pendingActivation
:
u
.
role
===
'owner'
&&
u
.
isActive
&&
!
passwordHash
&&
(
!
identities
.
length
||
activations
.
length
>
0
)
&&
!
roleGrants
.
some
((
g
)
=>
g
.
scope
===
'platform'
),
})),
accounts
,
seats
,
};
...
...
apps/ai-service/src/modules/workbench/admin.types.ts
View file @
1b133cc2
...
...
@@ -20,6 +20,9 @@ export interface AdminCan {
}
export
interface
AdminUserRow
{
sourceUserId
?:
string
|
null
;
canMapHostUser
?:
boolean
;
canReissueActivation
?:
boolean
;
id
:
string
;
displayName
:
string
;
role
:
string
;
...
...
apps/ai-service/src/modules/workbench/contact.controller.ts
View file @
1b133cc2
...
...
@@ -211,6 +211,7 @@ export class ContactController {
anchors
:
readAnchors
(
b
.
anchors
),
dryRun
:
readBool
(
b
.
dryRun
,
'dryRun'
),
rev
:
b
.
rev
,
sourceUserId
:
b
.
sourceUserId
,
});
}
...
...
apps/ai-service/src/modules/workbench/host-user-mapping.ts
0 → 100644
View file @
1b133cc2
import
{
Prisma
}
from
'../../../generated/prisma'
;
import
type
{
AuthUser
}
from
'../../common/guards/jwt-auth.guard'
;
import
{
AiApiCode
}
from
'../../common/errors/api-codes'
;
import
{
BizError
}
from
'../../common/errors/biz-error'
;
import
{
hasPermission
,
PERMISSIONS
}
from
'../../common/permissions'
;
import
{
identityProvider
,
tenantPolicySchema
}
from
'../integration/integration.schema'
;
export
function
readSourceUserId
(
value
:
unknown
):
string
|
undefined
{
if
(
value
===
undefined
||
value
===
''
)
return
undefined
;
if
(
typeof
value
!==
'string'
||
!
value
.
trim
()
||
value
.
trim
().
length
>
128
||
/
[\s\x
00-
\x
1f
]
/
.
test
(
value
.
trim
()))
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'宿主用户 ID 请填写不含空格的有效编号,最多 128 个字符'
);
return
value
.
trim
();
}
export
function
canMapHostUser
(
op
:
AuthUser
):
boolean
{
return
hasPermission
(
op
,
PERMISSIONS
.
USER_PROVISION
)
&&
op
.
dataScope
===
'tenant'
&&
(
!
op
.
hostScope
||
op
.
hostScope
.
wholeTenant
);
}
/** 调用者持有租户写锁;只建立关系,不覆盖身份,也不改变角色或数据范围。 */
export
async
function
prepareHostMapping
(
db
:
Prisma
.
TransactionClient
,
op
:
AuthUser
,
sourceUserId
:
string
)
{
if
(
!
canMapHostUser
(
op
))
throw
new
BizError
(
AiApiCode
.
AUTH_PERMISSION_DENIED
,
'关联宿主账号需要本客户全范围管理员权限'
);
const
tenant
=
await
db
.
tenant
.
findUnique
({
where
:
{
id
:
op
.
tenantId
}
});
const
app
=
tenant
?.
integrationAppId
?
await
db
.
integrationApp
.
findUnique
({
where
:
{
appId
:
tenant
.
integrationAppId
}
})
:
null
;
if
(
!
tenant
||
tenant
.
status
!==
'active'
||
!
tenant
.
integrationEnabled
||
!
app
?.
enabled
||
app
.
hostCode
!==
tenant
.
partnerCode
||
!
tenantPolicySchema
.
safeParse
(
tenant
.
integrationPolicy
).
success
)
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'本客户尚未接入宿主,可先不填写用户 ID,接入后再关联'
);
const
provider
=
identityProvider
(
app
.
appId
,
tenant
.
id
);
if
(
await
db
.
userIdentity
.
findUnique
({
where
:
{
provider_externalId
:
{
provider
,
externalId
:
sourceUserId
}
}
}))
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'这个宿主用户 ID 已关联账号,请使用已有账号,不能重复开户或覆盖'
);
return
provider
;
}
export
function
mappingConflict
(
error
:
unknown
):
never
{
if
(
error
instanceof
Prisma
.
PrismaClientKnownRequestError
&&
error
.
code
===
'P2002'
)
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'账号映射已发生变化,请刷新后核对,不能重复关联'
);
throw
error
;
}
apps/ai-service/src/modules/workbench/org-admin.service.ts
View file @
1b133cc2
import
{
tenantPolicySchema
}
from
'../integration/integration.schema'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
Prisma
}
from
'../../../generated/prisma'
;
import
{
AiApiCode
}
from
'../../common/errors/api-codes'
;
...
...
@@ -314,6 +315,14 @@ export class OrgAdminService {
data
:
{
tenantId
:
op
.
tenantId
,
orgUnitId
:
id
,
hostCode
:
snap
.
hostCode
,
externalId
:
to
},
});
}
// 旧开户可能在免登策略里保存过门店映射。修改后清除该节点旧项,统一读 orgUnitRef。
// 客户根编号承担租户路由,保留根节点的开户标识。
const
tenant
=
await
tx
.
tenant
.
findUniqueOrThrow
({
where
:
{
id
:
op
.
tenantId
}
});
const
policy
=
tenantPolicySchema
.
safeParse
(
tenant
.
integrationPolicy
);
if
(
policy
.
success
&&
policy
.
data
.
rootOrgId
!==
id
)
{
const
organizationMappings
=
Object
.
fromEntries
(
Object
.
entries
(
policy
.
data
.
organizationMappings
).
filter
(([,
nodeId
])
=>
nodeId
!==
id
));
await
tx
.
tenant
.
update
({
where
:
{
id
:
op
.
tenantId
},
data
:
{
integrationPolicy
:
{
...
policy
.
data
,
organizationMappings
}
}
});
}
const
rev
=
await
bumpOrgRev
(
tx
,
op
.
tenantId
);
await
audit
(
tx
,
op
,
to
?
'org.ref.set'
:
'org.ref.clear'
,
'org_unit'
,
id
,
{
nodeId
:
id
,
...
...
apps/ai-service/src/modules/workbench/provision.service.ts
View file @
1b133cc2
import
{
prepareHostMapping
,
readSourceUserId
,
mappingConflict
}
from
'./host-user-mapping'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
createHash
,
randomBytes
}
from
'node:crypto'
;
import
type
{
Prisma
}
from
'../../../generated/prisma'
;
...
...
@@ -90,6 +91,7 @@ export class ProvisionService {
*/
input
:
{
contactId
?:
string
;
sourceUserId
?:
unknown
;
displayName
?:
string
;
role
:
string
;
/** 旧前端:勾的节点,一律含下级 */
...
...
@@ -102,6 +104,7 @@ export class ProvisionService {
rev
?:
unknown
;
},
):
Promise
<
ProvisionPreview
|
ProvisionResult
>
{
const
sourceUserId
=
readSourceUserId
(
input
.
sourceUserId
);
const
role
=
input
.
role
===
'staff'
||
input
.
role
===
'viewer'
?
input
.
role
:
null
;
// ⚠️ ⛔ 不允许开 owner:那等于把提权通道复制一份出去。要加 owner 走 CLI(人得想一下)
if
(
!
role
)
{
...
...
@@ -160,6 +163,7 @@ export class ProvisionService {
if
(
input
.
dryRun
)
{
return
runTx
(
this
.
prisma
,
async
(
tx
)
=>
{
const
rev
=
await
shareLockOrg
(
tx
,
admin
.
tenantId
);
if
(
sourceUserId
)
await
prepareHostMapping
(
tx
,
admin
,
sourceUserId
);
const
{
next
,
nodes
}
=
await
this
.
planGrants
(
tx
,
admin
,
anchors
);
const
vis
=
visibleOf
(
nodes
,
next
);
return
{
...
...
@@ -185,6 +189,7 @@ export class ProvisionService {
// ⚠️ 先锁再重算授权 —— ⛔ 信预演的结果(预演之后别人可能改过树或他的授权)
const
cur
=
await
lockOrg
(
tx
,
admin
.
tenantId
);
if
(
input
.
rev
!==
undefined
)
assertRev
(
cur
,
input
.
rev
);
const
provider
=
sourceUserId
?
await
prepareHostMapping
(
tx
,
admin
,
sourceUserId
)
:
null
;
const
{
next
}
=
await
this
.
planGrants
(
tx
,
admin
,
anchors
);
const
u
=
await
tx
.
user
.
create
({
data
:
{
...
...
@@ -197,6 +202,7 @@ export class ProvisionService {
},
select
:
{
id
:
true
},
});
if
(
provider
&&
sourceUserId
)
await
tx
.
userIdentity
.
create
({
data
:
{
provider
,
externalId
:
sourceUserId
,
userId
:
u
.
id
}
});
if
(
next
.
length
)
{
await
tx
.
userOrgScope
.
createMany
({
data
:
next
.
map
((
a
)
=>
({
...
...
@@ -242,11 +248,11 @@ export class ProvisionService {
actorType
:
'human'
,
actorUserId
:
admin
.
id
,
// ⛔ `changes` 里**不许有票的明文** —— 它是凭据
changes
:
{
displayName
,
role
,
vid
:
contact
?.
vid
??
null
,
orgUnitIds
:
next
.
map
((
a
)
=>
a
.
orgUnitId
),
anchors
:
next
.
map
((
a
)
=>
({
orgUnitId
:
a
.
orgUnitId
,
includeSubtree
:
a
.
includeSubtree
})),
rev
},
changes
:
{
hostUserMapped
:
!!
sourceUserId
,
displayName
,
role
,
vid
:
contact
?.
vid
??
null
,
orgUnitIds
:
next
.
map
((
a
)
=>
a
.
orgUnitId
),
anchors
:
next
.
map
((
a
)
=>
({
orgUnitId
:
a
.
orgUnitId
,
includeSubtree
:
a
.
includeSubtree
})),
rev
},
},
});
return
{
userId
:
u
.
id
,
rev
,
granted
:
next
.
length
};
});
})
.
catch
(
mappingConflict
)
;
this
.
logger
.
log
(
`开户
${
displayName
}
(
${
role
}
) by=
${
admin
.
displayName
}
授权=
${
granted
}
条`
);
return
{
applied
:
true
,
rev
,
userId
,
displayName
,
token
,
expiresAt
:
expiresAt
.
toISOString
()
};
...
...
apps/ai-service/src/modules/workbench/user-admin.service.ts
View file @
1b133cc2
import
{
canMapHostUser
,
prepareHostMapping
,
readSourceUserId
,
mappingConflict
}
from
'./host-user-mapping'
;
import
{
identityProvider
}
from
'../integration/integration.schema'
;
import
{
setTenantRole
}
from
'../../common/role-grants'
;
import
{
IntegrationPolicyService
}
from
'../integration/integration-policy.service'
;
import
{
Injectable
,
Logger
,
Optional
}
from
'@nestjs/common'
;
...
...
@@ -50,6 +52,7 @@ const FILTERS = ['all', 'unactivated', 'ungranted', 'tenant'] as const;
*/
const
AUDIT_ACTIONS
=
[
'user.provision'
,
'user.host.map'
,
'user.activate'
,
'user.activation.reissue'
,
'user.scope.set'
,
...
...
@@ -161,6 +164,29 @@ export class UserAdminService {
};
}
async
mapHostUser
(
op
:
AuthUser
,
id
:
string
,
raw
:
unknown
)
{
if
(
!
canMapHostUser
(
op
))
throw
new
BizError
(
AiApiCode
.
AUTH_PERMISSION_DENIED
,
'关联源系统账号需要本客户全范围管理员权限'
);
const
sourceUserId
=
readSourceUserId
(
raw
);
if
(
!
sourceUserId
)
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'请填写源系统用户 ID'
);
if
(
!
isUuid
(
id
))
throw
new
BizError
(
AiApiCode
.
USER_NOT_FOUND
,
'找不到这个员工'
);
return
runTx
(
this
.
prisma
,
async
(
tx
)
=>
{
await
lockOrg
(
tx
,
op
.
tenantId
);
const
snap
=
await
loadOrgSnapshot
(
tx
,
op
.
tenantId
);
const
{
reach
}
=
await
reachOf
(
tx
,
op
,
snap
.
nodes
);
await
tx
.
$queryRaw
`SELECT id FROM app.users WHERE id=
${
id
}
::uuid FOR UPDATE`
;
const
target
=
await
this
.
mustManage
(
tx
,
op
,
reach
,
id
,
'role'
);
if
(
!
[
'staff'
,
'viewer'
].
includes
(
target
.
role
)
||
await
tx
.
userRoleGrant
.
findUnique
({
where
:
{
userId_scope
:
{
userId
:
id
,
scope
:
'platform'
}
}
}))
throw
new
BizError
(
AiApiCode
.
AUTH_PERMISSION_DENIED
,
'这里只能关联本客户的员工或只读账号'
);
if
(
await
tx
.
userIdentity
.
findFirst
({
where
:
{
userId
:
id
,
provider
:
{
startsWith
:
'host:'
}
}
}))
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'此员工已关联源系统账号,不能覆盖或合并'
);
const
provider
=
await
prepareHostMapping
(
tx
,
op
,
sourceUserId
);
await
tx
.
userIdentity
.
create
({
data
:
{
provider
,
externalId
:
sourceUserId
,
userId
:
id
}
});
await
audit
(
tx
,
op
,
'user.host.map'
,
'user'
,
id
,
{
mapped
:
true
});
return
{
sourceUserId
};
}).
catch
(
mappingConflict
);
}
async
setScopes
(
op
:
AuthUser
,
id
:
string
,
body
:
Record
<
string
,
unknown
>
):
Promise
<
ScopeChangeResult
>
{
if
(
!
hasPermission
(
op
,
PERMISSIONS
.
USER_SCOPE_GRANT
))
{
throw
new
BizError
(
AiApiCode
.
AUTH_PERMISSION_DENIED
,
'你没有改授权的权限'
);
...
...
@@ -378,6 +404,8 @@ export class UserAdminService {
try
{
await
runTx
(
this
.
prisma
,
async
(
tx
)
=>
{
await
tx
.
$queryRaw
`SELECT id FROM app.users WHERE id=
${
id
}
::uuid FOR UPDATE`
;
const
current
=
await
this
.
mustManage
(
tx
,
op
,
reach
,
id
,
'activation'
);
if
(
current
.
activated
)
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'账号已激活,不能重发激活链接'
);
if
(
await
tx
.
userRoleGrant
.
findUnique
({
where
:{
userId_scope
:{
userId
:
id
,
scope
:
'platform'
}}}))
throw
new
BizError
(
AiApiCode
.
AUTH_PERMISSION_DENIED
,
'平台账号由平台管理'
);
// ⚠️ 先作废旧票再建新票:uq_ua_live 只许一张没用过的票
const
revoked
=
await
tx
.
userActivation
.
updateMany
({
...
...
@@ -435,7 +463,9 @@ export class UserAdminService {
if
(
what
===
'activation'
&&
await
db
.
userRoleGrant
.
findUnique
({
where
:{
userId_scope
:{
userId
:
id
,
scope
:
'platform'
}}}))
throw
new
BizError
(
AiApiCode
.
AUTH_PERMISSION_DENIED
,
'平台账号由平台管理'
);
if
(
!
u
.
isActive
)
throw
new
BizError
(
AiApiCode
.
CLIENT_VALIDATION_FAILED
,
'这个账号已停用'
);
const
{
passwordHash
,
...
rest
}
=
u
;
return
{
...
rest
,
tenantId
:
u
.
tenantId
,
activated
:
!!
passwordHash
||
!!
(
this
.
integration
&&
(
await
this
.
integration
.
hostLoginUsers
(
op
.
tenantId
,
[
u
.
id
],
db
)).
has
(
u
.
id
)),
anchors
};
const
hostLogin
=
!!
(
this
.
integration
&&
(
await
this
.
integration
.
hostLoginUsers
(
op
.
tenantId
,
[
u
.
id
],
db
)).
has
(
u
.
id
));
const
originalTicket
=
what
===
'activation'
&&
hostLogin
?
await
db
.
userActivation
.
findFirst
({
where
:
{
userId
:
id
,
tenantId
:
op
.
tenantId
},
select
:
{
userId
:
true
}
})
:
null
;
return
{
...
rest
,
tenantId
:
u
.
tenantId
,
activated
:
!!
passwordHash
||
(
hostLogin
&&
!
originalTicket
),
anchors
};
}
/**
...
...
@@ -465,6 +495,10 @@ export class UserAdminService {
where
:
{
tenantId
:
op
.
tenantId
},
select
:
{
id
:
true
,
displayName
:
true
,
role
:
true
,
dataScope
:
true
,
isActive
:
true
,
passwordHash
:
true
,
orgUnitId
:
true
,
jobTitle
:
true
,
integrationManagedScope
:
true
,
integrationManagedRole
:
true
},
});
const
tenant
=
await
this
.
prisma
.
tenant
.
findUnique
({
where
:
{
id
:
op
.
tenantId
}
});
const
hostIdentities
=
await
this
.
prisma
.
userIdentity
.
findMany
({
where
:
{
user
:
{
tenantId
:
op
.
tenantId
},
provider
:
{
startsWith
:
'host:'
}
},
select
:
{
userId
:
true
,
provider
:
true
,
externalId
:
true
}
});
const
platformGrants
=
await
this
.
prisma
.
userRoleGrant
.
findMany
({
where
:
{
userId
:
{
in
:
users
.
map
(
u
=>
u
.
id
)
},
scope
:
'platform'
},
select
:
{
userId
:
true
}
});
const
allTickets
=
await
this
.
prisma
.
userActivation
.
findMany
({
where
:
{
tenantId
:
op
.
tenantId
},
select
:
{
userId
:
true
}
});
const
externalLogins
=
this
.
integration
?
await
this
.
integration
.
hostLoginUsers
(
op
.
tenantId
,
users
.
map
((
u
)
=>
u
.
id
))
:
new
Set
<
string
>
();
const
scopes
=
await
this
.
prisma
.
userOrgScope
.
findMany
({
where
:
{
tenantId
:
op
.
tenantId
},
...
...
@@ -520,6 +554,9 @@ export class UserAdminService {
accountCount
:
accountCount
.
get
(
u
.
id
)
??
0
,
self
,
manageable
,
sourceUserId
:
hostIdentities
.
find
(
i
=>
i
.
userId
===
u
.
id
&&
tenant
?.
integrationAppId
&&
i
.
provider
===
identityProvider
(
tenant
.
integrationAppId
,
op
.
tenantId
))?.
externalId
??
null
,
canMapHostUser
:
canMapHostUser
(
op
)
&&
manageable
&&
u
.
isActive
&&
[
'staff'
,
'viewer'
].
includes
(
u
.
role
)
&&
!
platformGrants
.
some
(
g
=>
g
.
userId
===
u
.
id
)
&&
!
hostIdentities
.
some
(
i
=>
i
.
userId
===
u
.
id
),
canReissueActivation
:
!
u
.
passwordHash
&&
allTickets
.
some
(
t
=>
t
.
userId
===
u
.
id
)
&&
!
platformGrants
.
some
(
g
=>
g
.
userId
===
u
.
id
),
});
}
return
{
rows
,
snap
};
...
...
@@ -531,6 +568,8 @@ function summarize(action: string, changes: unknown): string {
const
c
=
(
changes
??
{})
as
Record
<
string
,
unknown
>
;
const
len
=
(
x
:
unknown
)
=>
(
Array
.
isArray
(
x
)
?
x
.
length
:
0
);
switch
(
action
)
{
case
'user.host.map'
:
return
'关联了宿主账号'
;
case
'user.provision'
:
return
'开户'
;
case
'user.scope.set'
:
...
...
apps/ai-service/src/modules/workbench/user.controller.ts
View file @
1b133cc2
...
...
@@ -62,9 +62,16 @@ export class UserController {
anchors
:
readAnchors
(
b
.
anchors
),
dryRun
:
readBool
(
b
.
dryRun
,
'dryRun'
),
rev
:
b
.
rev
,
sourceUserId
:
b
.
sourceUserId
,
});
}
@
RequirePermission
(
PERMISSIONS
.
USER_PROVISION
)
@
Post
(
':id/host-identity'
)
mapHost
(@
CurrentUser
()
user
:
AuthUser
,
@
Param
(
'id'
)
id
:
string
,
@
Body
()
body
:
Record
<
string
,
unknown
>
)
{
return
this
.
admin
.
mapHostUser
(
user
,
id
,
body
?.
sourceUserId
);
}
@
RequirePermission
(
PERMISSIONS
.
USER_SCOPE_GRANT
)
@
Put
(
':id/scopes'
)
setScopes
(@
CurrentUser
()
user
:
AuthUser
,
@
Param
(
'id'
)
id
:
string
,
@
Body
()
body
:
Record
<
string
,
unknown
>
)
{
...
...
apps/ai-service/tests/integration-boundary.spec.ts
View file @
1b133cc2
...
...
@@ -66,6 +66,46 @@ it('应用/集团角色准入与原有 AI 授权取交集,不接受 owner/lead
expect
(
tokenRequestSchema
.
safeParse
({...
input
(),
user
:{...
input
().
user
,
role
:
'owner'
}}).
success
).
toBe
(
false
);
expect
(
tokenRequestSchema
.
safeParse
({...
input
(),
user
:{...
input
().
user
,
role
:
'leader'
}}).
success
).
toBe
(
false
);
});
function
brandAdminFixture
(){
const
f
=
fixture
();
const
adminApp
:
IntegrationAppConfig
=
{...
app
,
allowedRoles
:[
'staff'
,
'viewer'
,
'admin'
],
capabilities
:
roleCapabilities
(
'owner'
)};
f
.
tenants
[
0
]
!
.
integrationPolicy
=
tenantPolicySchema
.
parse
({
version
:
2
,
rootOrgId
:
root
,
allowedRoles
:
adminApp
.
allowedRoles
,
capabilities
:
adminApp
.
capabilities
});
f
.
db
.
user
.
findFirst
=
async
()
=>
({
id
:
'u'
,
isActive
:
true
,
role
:
'owner'
,
dataScope
:
'scoped'
});
const
u
=
{...
input
().
user
,
role
:
'admin'
as
const
,
orgScope
:[
'g1'
],
clinicId
:
'c1'
};
const
ctx
=
{
version
:
2
as
const
,
appId
:
'a'
,
dataTenantId
:
'space'
,
sourceUserId
:
'001'
,
launchId
:
root
,
rootOrgId
:
root
,
scopeOrgIds
:[
root
],
orgScope
:[
'g1'
],
role
:
'admin'
as
const
,
capabilityCeiling
:
roleCapabilities
(
'owner'
),
readonlyCeiling
:
false
};
return
{...
f
,
adminApp
,
u
,
ctx
};
}
it
(
'品牌管理员映射 owner,以品牌根进入时可管理整棵组织树,当前诊所只作默认选择'
,
async
()
=>
{
const
f
=
brandAdminFixture
(),
graph
=
await
f
.
policy
.
resolve
(
f
.
adminApp
,
f
.
u
);
expect
(
f
.
policy
.
role
(
f
.
adminApp
,
graph
.
policy
,
f
.
u
).
role
).
toBe
(
'owner'
);
const
scope
=
await
f
.
policy
.
scope
(
f
.
adminApp
,
graph
,
'u'
,
f
.
u
,
f
.
ctx
);
expect
(
scope
.
orgUnitIds
).
toEqual
([
root
,
leaf
]);
expect
(
scope
.
clinicId
).
toBe
(
leaf
);
expect
(
scope
.
wholeTenant
).
toBe
(
true
);
expect
(
scope
.
capabilities
).
toEqual
(
roleCapabilities
(
'owner'
));
const
reach
=
hostOperatorReach
(
scope
.
orgUnitIds
,
graph
.
nodes
.
map
(
n
=>
({...
n
,
name
:
n
.
id
})));
if
(
reach
.
all
)
throw
Error
(
'宿主范围不应绕过组织授权检查'
);
expect
(
reach
.
subtree
.
has
(
root
)).
toBe
(
true
);
});
it
(
'品牌根范围不绕过 AI 常设授权,只有诊所授权时仍看不到上级'
,
async
()
=>
{
const
f
=
brandAdminFixture
();
f
.
db
.
userOrgScope
.
findMany
=
async
()
=>
[{
orgUnitId
:
leaf
,
includeSubtree
:
true
}];
const
graph
=
await
f
.
policy
.
resolve
(
f
.
adminApp
,
f
.
u
);
const
scope
=
await
f
.
policy
.
scope
(
f
.
adminApp
,
graph
,
'u'
,
f
.
u
,
f
.
ctx
);
expect
(
scope
.
orgUnitIds
).
toEqual
([
leaf
]);
expect
(
scope
.
wholeTenant
).
toBe
(
false
);
});
it
(
'品牌管理员不能携带其他品牌的当前诊所或混合组织范围'
,
async
()
=>
{
const
f
=
brandAdminFixture
(),
graph
=
await
f
.
policy
.
resolve
(
f
.
adminApp
,
f
.
u
);
await
expect
(
f
.
policy
.
scope
(
f
.
adminApp
,
graph
,
'u'
,{...
f
.
u
,
clinicId
:
'c2'
},
f
.
ctx
)).
rejects
.
toMatchObject
({
code
:
12806
});
await
expect
(
f
.
policy
.
resolve
(
f
.
adminApp
,{...
f
.
u
,
orgScope
:[
'g1'
,
'g2'
]})).
rejects
.
toMatchObject
({
code
:
12816
});
});
it
(
'SaaS 员工即使绑定 AI owner,也只能访问本次携带的诊所及员工能力'
,
async
()
=>
{
const
f
=
brandAdminFixture
(),
u
=
{...
f
.
u
,
role
:
'staff'
as
const
,
orgScope
:[
'c1'
]};
const
graph
=
await
f
.
policy
.
resolve
(
f
.
adminApp
,
u
);
const
scope
=
await
f
.
policy
.
scope
(
f
.
adminApp
,
graph
,
'u'
,
u
,{...
f
.
ctx
,
role
:
'staff'
,
scopeOrgIds
:[
leaf
],
orgScope
:[
'c1'
],
capabilityCeiling
:
roleCapabilities
(
'staff'
)});
expect
(
scope
.
orgUnitIds
).
toEqual
([
leaf
]);
expect
(
scope
.
wholeTenant
).
toBe
(
false
);
expect
(
scope
.
capabilities
).
toEqual
(
roleCapabilities
(
'staff'
));
});
it
(
'应用凭据摘要校验从 AI 表读取,Bearer 字符串不能当 JSON secret 使用'
,
async
()
=>
{
const
{
sha256
}
=
await
import
(
'../src/modules/integration/integration.schema'
);
const
registry
=
new
IntegrationRegistryService
({
integrationApp
:{
findUnique
:
async
()
=>
({...
app
,
secretHashes
:[
sha256
(
'secret'
)]})}}
as
never
);
...
...
apps/ai-service/tests/platform-onboarding.integration.spec.ts
0 → 100644
View file @
1b133cc2
import
{
ProvisionService
}
from
'../src/modules/workbench/provision.service'
;
import
{
UserAdminService
}
from
'../src/modules/workbench/user-admin.service'
;
import
{
OrgAdminService
}
from
'../src/modules/workbench/org-admin.service'
;
import
{
ScopeService
}
from
'../src/modules/scope/scope.service'
;
import
type
{
AuthUser
}
from
'../src/common/guards/jwt-auth.guard'
;
import
{
randomUUID
,
createHash
}
from
'node:crypto'
;
import
{
PrismaClient
}
from
'../generated/prisma'
;
import
{
PrismaService
}
from
'../src/prisma/prisma.service'
;
import
{
PlatformOnboardingService
}
from
'../src/modules/platform/platform-onboarding.service'
;
import
{
onboardingSchema
,
type
OnboardingInput
,
}
from
'../src/modules/platform/platform-onboarding.schema'
;
import
{
PlatformController
}
from
'../src/modules/platform/platform.controller'
;
import
{
PlatformService
}
from
'../src/modules/platform/platform.service'
;
import
{
PERMISSIONS
,
permissionsOf
}
from
'../src/common/permissions'
;
import
{
REQUIRE_PERMISSION
}
from
'../src/common/decorators/require-permission.decorator'
;
import
{
AUTH_SCOPE
}
from
'../src/common/decorators/auth-scope.decorator'
;
import
type
{
PlatformUser
}
from
'../src/common/guards/jwt-auth.guard'
;
import
{
identityProvider
,
roleCapabilities
}
from
'../src/modules/integration/integration.schema'
;
import
{
IntegrationPolicyService
}
from
'../src/modules/integration/integration-policy.service'
;
import
{
adminCan
}
from
'../src/modules/workbench/admin-common'
;
import
{
ActivationService
}
from
'../src/modules/auth/activation.service'
;
import
{
PasswordService
}
from
'../src/modules/auth/password.service'
;
it
(
'开户 API 仅平台管理员可访问'
,
()
=>
{
expect
(
Reflect
.
getMetadata
(
AUTH_SCOPE
,
PlatformController
)).
toBe
(
'platform'
);
for
(
const
method
of
[
'create'
,
'preview'
,
'applications'
,
'activation'
,
'mapOwner'
]
as
const
)
expect
(
Reflect
.
getMetadata
(
REQUIRE_PERMISSION
,
PlatformController
.
prototype
[
method
])).
toEqual
([
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
,
]);
expect
(
permissionsOf
(
'platform_admin'
)).
toContain
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
);
for
(
const
role
of
[
'owner'
,
'staff'
,
'viewer'
,
'platform_viewer'
])
expect
(
permissionsOf
(
role
)).
not
.
toContain
(
PERMISSIONS
.
PLATFORM_TENANT_MANAGE
);
});
const
sample
:
OnboardingInput
=
{
name
:
'开户测试客户'
,
partnerCode
:
'test'
,
partnerTenantId
:
'space'
,
partnerOrgRootRef
:
'brand'
,
ownerName
:
'首位管理'
,
reason
:
'本地验证'
,
};
it
(
'开户不接受越权字段、免登配置或门店结构'
,
()
=>
{
expect
(
onboardingSchema
.
safeParse
(
sample
).
success
).
toBe
(
true
);
for
(
const
extra
of
[
{
login
:
{
mode
:
'host'
,
appId
:
'a'
,
sourceUserId
:
'1'
}
},
{
clinics
:
[]
},
{
role
:
'platform_admin'
},
{
skipPacCheck
:
true
},
{
tenantId
:
randomUUID
()
},
])
expect
(
onboardingSchema
.
safeParse
({
...
sample
,
...
extra
}).
success
).
toBe
(
false
);
expect
(
onboardingSchema
.
safeParse
({
...
sample
,
clinics
:
[{
name
:
'错误门店'
,
externalId
:
'brand'
}]
})
.
success
,
).
toBe
(
false
);
expect
(
onboardingSchema
.
safeParse
({
...
sample
,
login
:
{
mode
:
'host'
,
appId
:
'a'
,
sourceUserId
:
'1'
},
clinics
:
[],
}).
success
,
).
toBe
(
false
);
});
const
run
=
process
.
env
.
AI_PLATFORM_DB_TEST
===
'1'
?
describe
:
describe
.
skip
;
run
(
'可视化开户(仅本地真实数据库)'
,
()
=>
{
const
host
=
`onboard-
${
randomUUID
()}
`
,
appId
=
`onboard-
${
randomUUID
()}
`
;
let
db
:
PrismaClient
,
svc
:
PlatformOnboardingService
,
actor
:
PlatformUser
,
sequence
=
0
;
const
pac
=
{
orgNodes
:
jest
.
fn
()
};
const
data
=
(
o
:
Partial
<
OnboardingInput
>
=
{}):
OnboardingInput
=>
({
...
sample
,
partnerCode
:
host
,
partnerTenantId
:
`space-
${
++
sequence
}
`
,
...
o
,
});
beforeAll
(
async
()
=>
{
if
(
!
[
'localhost'
,
'127.0.0.1'
].
includes
(
new
URL
(
process
.
env
.
AI_DATABASE_URL
!
).
hostname
))
throw
Error
(
'仅限本地库'
);
db
=
new
PrismaClient
();
const
u
=
await
db
.
user
.
create
({
data
:
{
displayName
:
'开户测试平台人员'
,
role
:
'viewer'
}
});
actor
=
{
id
:
u
.
id
}
as
PlatformUser
;
svc
=
new
PlatformOnboardingService
(
db
as
PrismaService
,
pac
as
never
);
await
db
.
integrationApp
.
create
({
data
:
{
appId
,
hostCode
:
host
,
enabled
:
true
,
webBaseUrl
:
'https://ai.example.invalid'
,
secretHashes
:
[
'a'
.
repeat
(
64
)],
modules
:
[
'workbench'
],
allowedRoles
:
[
'staff'
,
'viewer'
,
'admin'
],
capabilities
:
roleCapabilities
(
'owner'
),
},
});
});
beforeEach
(()
=>
pac
.
orgNodes
.
mockReset
().
mockResolvedValue
([
{
ref
:
'brand'
,
name
:
'品牌'
,
parentRef
:
null
},
{
ref
:
'clinic'
,
name
:
'门店'
,
parentRef
:
'brand'
},
]),
);
afterAll
(
async
()
=>
{
if
(
!
db
)
return
;
const
ids
=
(
await
db
.
tenant
.
findMany
({
where
:
{
partnerCode
:
host
},
select
:
{
id
:
true
}
})
).
map
((
t
)
=>
t
.
id
);
await
db
.
userIdentity
.
deleteMany
({
where
:
{
user
:
{
tenantId
:
{
in
:
ids
}
}
}
});
await
db
.
auditLog
.
deleteMany
({
where
:
{
OR
:
[{
tenantId
:
{
in
:
ids
}
},
{
actorUserId
:
actor
?.
id
}]
},
});
await
db
.
user
.
deleteMany
({
where
:
{
OR
:
[{
tenantId
:
{
in
:
ids
}
},
{
id
:
actor
?.
id
}]
}
});
await
db
.
orgUnitRef
.
deleteMany
({
where
:
{
tenantId
:
{
in
:
ids
}
}
});
await
db
.
orgUnit
.
deleteMany
({
where
:
{
tenantId
:
{
in
:
ids
},
parentId
:
{
not
:
null
}
}
});
await
db
.
orgUnit
.
deleteMany
({
where
:
{
tenantId
:
{
in
:
ids
}
}
});
await
db
.
tenant
.
deleteMany
({
where
:
{
id
:
{
in
:
ids
}
}
});
await
db
.
integrationApp
.
deleteMany
({
where
:
{
appId
}
});
await
db
.
$disconnect
();
});
it
(
'预览无写入;空字典和不可达不冒充通过'
,
async
()
=>
{
const
input
=
data
();
expect
((
await
svc
.
preview
(
input
)).
dataStatus
).
toBe
(
'verified'
);
expect
(
await
db
.
tenant
.
count
({
where
:
{
partnerCode
:
host
}
})).
toBe
(
0
);
pac
.
orgNodes
.
mockResolvedValueOnce
([]);
expect
((
await
svc
.
preview
(
input
)).
dataStatus
).
toBe
(
'empty'
);
pac
.
orgNodes
.
mockRejectedValueOnce
(
Error
(
'offline'
));
expect
((
await
svc
.
preview
(
input
)).
dataStatus
).
toBe
(
'unavailable'
);
});
it
(
'创建全客户 owner、树、激活票和审计,业务开关关闭;可重发但不能重置已激活账号'
,
async
()
=>
{
const
input
=
data
(),
r
=
await
svc
.
create
(
actor
,
input
);
const
user
=
await
db
.
user
.
findUniqueOrThrow
({
where
:
{
id
:
r
.
owner
.
id
},
include
:
{
roleGrants
:
true
},
});
expect
(
user
).
toMatchObject
({
role
:
'owner'
,
dataScope
:
'tenant'
,
phone
:
null
,
passwordHash
:
null
,
});
expect
(
user
.
roleGrants
).
toEqual
([
expect
.
objectContaining
({
scope
:
'tenant'
,
role
:
'owner'
,
source
:
'manual'
}),
]);
expect
(
adminCan
({
...
user
,
permissions
:
permissionsOf
(
'owner'
)
}
as
never
).
editTree
).
toBe
(
true
);
expect
(
await
db
.
orgUnit
.
count
({
where
:
{
tenantId
:
r
.
tenant
.
id
}
})).
toBe
(
1
);
expect
(
await
db
.
tenant
.
findUniqueOrThrow
({
where
:
{
id
:
r
.
tenant
.
id
}
})).
toMatchObject
({
aiAutoSend
:
false
,
integrationEnabled
:
true
,
});
expect
(
await
db
.
hostingSeat
.
count
({
where
:
{
tenantId
:
r
.
tenant
.
id
}
})).
toBe
(
0
);
const
ticket
=
await
db
.
userActivation
.
findFirstOrThrow
({
where
:
{
userId
:
user
.
id
}
});
expect
(
ticket
.
tokenHash
).
toBe
(
createHash
(
'sha256'
).
update
(
r
.
activation
!
.
token
).
digest
(
'hex'
));
const
logs
=
await
db
.
auditLog
.
findMany
({
where
:
{
tenantId
:
r
.
tenant
.
id
}
});
expect
(
logs
[
0
]).
toMatchObject
({
scope
:
'platform'
,
actorUserId
:
actor
.
id
,
action
:
'tenant.onboard'
,
});
expect
(
JSON
.
stringify
(
logs
)).
not
.
toContain
(
r
.
activation
!
.
token
);
const
detail
=
await
new
PlatformService
(
db
as
PrismaService
,
{}
as
never
).
detail
(
r
.
tenant
.
id
);
expect
(
detail
.
users
[
0
]).
toMatchObject
({
pendingActivation
:
true
});
expect
(
detail
.
users
[
0
]).
not
.
toHaveProperty
(
'passwordHash'
);
const
next
=
await
svc
.
reissue
(
actor
,
r
.
tenant
.
id
,
user
.
id
,
'测试重发'
);
expect
(
(
await
db
.
userActivation
.
findUniqueOrThrow
({
where
:
{
tokenHash
:
ticket
.
tokenHash
}
}))
.
usedAt
,
).
not
.
toBeNull
();
const
activation
=
new
ActivationService
(
db
as
PrismaService
,
new
PasswordService
());
await
expect
(
activation
.
peek
(
r
.
activation
!
.
token
)).
rejects
.
toThrow
();
expect
(
await
activation
.
peek
(
next
.
token
)).
toMatchObject
({
displayName
:
input
.
ownerName
});
await
activation
.
activate
(
next
.
token
,
{
phone
:
`139
${
String
(
Date
.
now
()).
slice
(
-
8
)}
`
,
password
:
'Onboard-Local-827!a'
,
});
await
expect
(
svc
.
reissue
(
actor
,
r
.
tenant
.
id
,
user
.
id
,
'禁止重置'
)).
rejects
.
toThrow
(
'仅支持'
);
await
expect
(
activation
.
peek
(
next
.
token
)).
rejects
.
toThrow
();
});
it
(
'首位 owner 可提前建立源账号关系;激活独立登录和内嵌使用同一身份'
,
async
()
=>
{
const
input
=
data
({
sourceUserId
:
'source-owner'
}),
r
=
await
svc
.
create
(
actor
,
input
);
const
next
=
await
svc
.
reissue
(
actor
,
r
.
tenant
.
id
,
r
.
owner
.
id
,
'已映射仍可激活'
);
const
activation
=
new
ActivationService
(
db
as
PrismaService
,
new
PasswordService
());
await
activation
.
activate
(
next
.
token
,
{
phone
:
`138
${
String
(
Date
.
now
()).
slice
(
-
8
)}
`
,
password
:
'Onboard-Local-827!a'
,
});
const
root
=
await
db
.
orgUnit
.
findFirstOrThrow
({
where
:
{
tenantId
:
r
.
tenant
.
id
,
parentId
:
null
},
});
const
clinic
=
await
db
.
orgUnit
.
create
({
data
:
{
tenantId
:
r
.
tenant
.
id
,
parentId
:
root
.
id
,
name
:
'后补门店'
,
kind
:
'clinic'
},
});
await
db
.
orgUnitRef
.
create
({
data
:
{
tenantId
:
r
.
tenant
.
id
,
orgUnitId
:
clinic
.
id
,
hostCode
:
host
,
externalId
:
'clinic'
},
});
expect
(
await
db
.
userIdentity
.
findUniqueOrThrow
({
where
:
{
provider_externalId
:
{
provider
:
identityProvider
(
appId
,
r
.
tenant
.
id
),
externalId
:
'source-owner'
,
},
},
}),
).
toMatchObject
({
userId
:
r
.
owner
.
id
});
const
detail
=
await
new
PlatformService
(
db
as
PrismaService
,
{}
as
never
).
detail
(
r
.
tenant
.
id
);
expect
(
detail
.
users
[
0
]).
toMatchObject
({
sourceUserId
:
'source-owner'
,
canMapOwner
:
false
,
pendingActivation
:
false
,
});
const
app
=
await
db
.
integrationApp
.
findUniqueOrThrow
({
where
:
{
appId
}
}),
policy
=
new
IntegrationPolicyService
(
db
as
PrismaService
,
{}
as
never
);
const
u
=
{
userId
:
'source-owner'
,
tenantId
:
input
.
partnerTenantId
,
role
:
'admin'
as
const
,
orgScope
:
[
'brand'
],
clinicId
:
'clinic'
,
},
graph
=
await
policy
.
resolve
(
app
as
never
,
u
);
const
scope
=
await
policy
.
scope
(
app
as
never
,
graph
,
r
.
owner
.
id
,
u
,
{
version
:
2
,
appId
,
dataTenantId
:
input
.
partnerTenantId
,
sourceUserId
:
'source-owner'
,
launchId
:
randomUUID
(),
rootOrgId
:
graph
.
policy
.
rootOrgId
,
scopeOrgIds
:
[
graph
.
policy
.
rootOrgId
],
orgScope
:
[
'brand'
],
role
:
'admin'
,
capabilityCeiling
:
roleCapabilities
(
'owner'
),
readonlyCeiling
:
false
,
});
expect
(
scope
.
wholeTenant
).
toBe
(
true
);
expect
(
scope
.
accountAll
).
toBe
(
true
);
expect
(
scope
.
orgUnitIds
).
toHaveLength
(
2
);
await
expect
(
svc
.
reissue
(
actor
,
r
.
tenant
.
id
,
r
.
owner
.
id
,
'禁止切换'
)).
rejects
.
toThrow
(
'仅支持'
);
});
it
(
'映射可后补,跨客户和重复映射不能覆盖;宿主权限统一生效'
,
async
()
=>
{
const
r
=
await
svc
.
create
(
actor
,
data
());
const
input
=
{
sourceUserId
:
'later-owner'
,
reason
:
'后补映射'
};
await
expect
(
svc
.
mapOwner
(
actor
,
r
.
tenant
.
id
,
actor
.
id
,
input
)).
rejects
.
toThrow
(
'owner'
);
await
svc
.
mapOwner
(
actor
,
r
.
tenant
.
id
,
r
.
owner
.
id
,
input
);
await
expect
(
svc
.
mapOwner
(
actor
,
r
.
tenant
.
id
,
r
.
owner
.
id
,
input
)).
rejects
.
toThrow
(
'不能覆盖'
);
const
policy
=
new
IntegrationPolicyService
(
db
as
PrismaService
,
{}
as
never
);
const
app
=
await
db
.
integrationApp
.
findUniqueOrThrow
({
where
:
{
appId
}
});
const
tenant
=
await
db
.
tenant
.
findUniqueOrThrow
({
where
:
{
id
:
r
.
tenant
.
id
}
});
const
restricted
=
{
...
app
,
allowedRoles
:
[
'staff'
],
capabilities
:
[
'workspace.read'
]
};
const
graph
=
await
policy
.
byId
(
restricted
as
never
,
tenant
.
id
,
tenant
.
partnerTenantId
);
expect
(
graph
.
policy
.
allowedRoles
).
toEqual
([
'staff'
]);
expect
(
graph
.
policy
.
capabilities
).
toEqual
([
'workspace.read'
]);
await
db
.
tenant
.
update
({
where
:
{
id
:
tenant
.
id
},
data
:
{
integrationPolicy
:
{
...(
tenant
.
integrationPolicy
as
object
),
inheritAppPolicy
:
false
},
},
});
const
explicit
=
await
policy
.
byId
(
restricted
as
never
,
tenant
.
id
,
tenant
.
partnerTenantId
);
expect
(
explicit
.
policy
.
allowedRoles
).
toContain
(
'admin'
);
});
it
(
'无宿主应用仍能开户;带映射必须有唯一宿主应用'
,
async
()
=>
{
await
db
.
integrationApp
.
update
({
where
:
{
appId
},
data
:
{
enabled
:
false
}
});
try
{
const
r
=
await
svc
.
create
(
actor
,
data
());
expect
(
r
.
activation
).
toBeTruthy
();
await
expect
(
svc
.
create
(
actor
,
data
({
sourceUserId
:
'x'
}))).
rejects
.
toThrow
(
'尚未接入'
);
}
finally
{
await
db
.
integrationApp
.
update
({
where
:
{
appId
},
data
:
{
enabled
:
true
}
});
}
});
it
(
'并发提交只开户一次,不重复创建、不接受其他客户已占用编号'
,
async
()
=>
{
const
input
=
data
(),
results
=
await
Promise
.
allSettled
([
svc
.
create
(
actor
,
input
),
svc
.
create
(
actor
,
input
)]);
expect
(
results
.
filter
((
r
)
=>
r
.
status
===
'fulfilled'
)).
toHaveLength
(
1
);
expect
(
await
db
.
tenant
.
count
({
where
:
{
partnerCode
:
host
,
partnerTenantId
:
input
.
partnerTenantId
},
}),
).
toBe
(
1
);
await
expect
(
svc
.
create
(
actor
,
{
...
input
,
name
:
'另一个客户'
})).
rejects
.
toThrow
(
'已开户'
);
const
existing
=
await
db
.
tenant
.
findFirstOrThrow
({
where
:
{
partnerCode
:
host
,
partnerTenantId
:
input
.
partnerTenantId
},
});
const
root
=
await
db
.
orgUnit
.
findFirstOrThrow
({
where
:
{
tenantId
:
existing
.
id
,
parentId
:
null
},
});
await
db
.
orgUnitRef
.
create
({
data
:
{
tenantId
:
existing
.
id
,
orgUnitId
:
root
.
id
,
hostCode
:
host
,
externalId
:
'other-brand'
,
},
});
await
expect
(
svc
.
create
(
actor
,
{
...
input
,
partnerOrgRootRef
:
'other-brand'
})).
rejects
.
toThrow
(
'其他客户'
,
);
});
async
function
ownerTools
()
{
const
input
=
data
(),
created
=
await
svc
.
create
(
actor
,
input
);
const
tenantId
=
created
.
tenant
.
id
;
const
user
=
await
db
.
user
.
findUniqueOrThrow
({
where
:
{
id
:
created
.
owner
.
id
}
});
const
op
=
{
...
user
,
tenantId
,
permissions
:
permissionsOf
(
'owner'
),
mustChangePassword
:
false
}
as
AuthUser
;
const
scope
=
new
ScopeService
(
db
as
never
);
const
policy
=
new
IntegrationPolicyService
(
db
as
never
,
{
get
:
()
=>
db
.
integrationApp
.
findUnique
({
where
:
{
appId
}
})
}
as
never
);
return
{
input
,
tenantId
,
op
,
policy
,
root
:
await
db
.
orgUnit
.
findFirstOrThrow
({
where
:
{
tenantId
,
parentId
:
null
}
}),
provision
:
new
ProvisionService
(
db
as
never
,
scope
),
users
:
new
UserAdminService
(
db
as
never
,
scope
,
{}
as
never
,
{}
as
never
,
policy
),
};
}
it
(
'owner 开员工可同时映射、预演不写库、映射后仍可重发独立激活链接'
,
async
()
=>
{
const
t
=
await
ownerTools
();
const
body
=
{
displayName
:
'映射员工'
,
role
:
'staff'
,
sourceUserId
:
'staff-123'
,
anchors
:
[{
orgUnitId
:
t
.
root
.
id
,
includeSubtree
:
true
}]
};
const
preview
=
await
t
.
provision
.
openAccount
(
t
.
op
,
{
...
body
,
dryRun
:
true
});
expect
(
preview
.
applied
).
toBe
(
false
);
expect
(
await
db
.
userIdentity
.
count
({
where
:
{
provider
:
identityProvider
(
appId
,
t
.
tenantId
)
}
})).
toBe
(
0
);
const
r
=
await
t
.
provision
.
openAccount
(
t
.
op
,
{
...
body
,
rev
:
preview
.
rev
});
if
(
!
r
.
applied
)
throw
Error
(
'未开户'
);
expect
(
await
db
.
userIdentity
.
findUniqueOrThrow
({
where
:
{
provider_externalId
:
{
provider
:
identityProvider
(
appId
,
t
.
tenantId
),
externalId
:
'staff-123'
}
}
})).
toMatchObject
({
userId
:
r
.
userId
});
expect
(
await
db
.
user
.
findUniqueOrThrow
({
where
:
{
id
:
r
.
userId
}
})).
toMatchObject
({
role
:
'staff'
,
dataScope
:
'scoped'
,
integrationManagedRole
:
false
,
integrationManagedScope
:
false
});
expect
(
await
t
.
users
.
detail
(
t
.
op
,
r
.
userId
)).
toMatchObject
({
sourceUserId
:
'staff-123'
,
canMapHostUser
:
false
,
canReissueActivation
:
true
});
const
ticket
=
await
t
.
users
.
reissueActivation
(
t
.
op
,
r
.
userId
);
expect
(
ticket
.
token
).
not
.
toBe
(
r
.
token
);
const
activation
=
new
ActivationService
(
db
as
never
,
new
PasswordService
());
await
activation
.
activate
(
ticket
.
token
,
{
phone
:
'+8613990000022'
,
password
:
'local-activation-test-42'
});
await
expect
(
t
.
users
.
reissueActivation
(
t
.
op
,
r
.
userId
)).
rejects
.
toMatchObject
({
code
:
expect
.
any
(
Number
)
});
await
expect
(
t
.
provision
.
openAccount
(
t
.
op
,
body
)).
rejects
.
toMatchObject
({
msg
:
expect
.
stringContaining
(
'已关联'
)
});
expect
(
await
db
.
user
.
count
({
where
:
{
tenantId
:
t
.
tenantId
}
})).
toBe
(
2
);
});
it
(
'后补员工映射:普通员工、局部 owner、跨租户、本人和平台账号不能绑定;已有映射不覆盖'
,
async
()
=>
{
const
t
=
await
ownerTools
(),
other
=
await
ownerTools
();
const
r
=
await
t
.
provision
.
openAccount
(
t
.
op
,
{
displayName
:
'后补员工'
,
role
:
'viewer'
});
if
(
!
r
.
applied
)
throw
Error
(
'未开户'
);
expect
(
await
t
.
users
.
detail
(
t
.
op
,
r
.
userId
)).
toMatchObject
({
canMapHostUser
:
true
,
sourceUserId
:
null
});
for
(
const
op
of
[{
...
t
.
op
,
role
:
'staff'
,
permissions
:
permissionsOf
(
'staff'
)
},
{
...
t
.
op
,
dataScope
:
'scoped'
},
other
.
op
])
await
expect
(
t
.
users
.
mapHostUser
(
op
,
r
.
userId
,
'later-staff'
)).
rejects
.
toBeDefined
();
await
expect
(
t
.
users
.
mapHostUser
(
t
.
op
,
t
.
op
.
id
,
'self'
)).
rejects
.
toBeDefined
();
await
expect
(
t
.
users
.
mapHostUser
(
t
.
op
,
r
.
userId
,
' '
)).
rejects
.
toBeDefined
();
await
t
.
users
.
mapHostUser
(
t
.
op
,
r
.
userId
,
'later-staff'
);
await
expect
(
t
.
users
.
mapHostUser
(
t
.
op
,
r
.
userId
,
'another'
)).
rejects
.
toMatchObject
({
msg
:
expect
.
stringContaining
(
'不能覆盖'
)
});
const
r2
=
await
t
.
provision
.
openAccount
(
t
.
op
,
{
displayName
:
'平台员工'
,
role
:
'viewer'
});
if
(
!
r2
.
applied
)
throw
Error
(
'未开户'
);
await
db
.
userRoleGrant
.
create
({
data
:
{
userId
:
r2
.
userId
,
scope
:
'platform'
,
role
:
'platform_viewer'
,
source
:
'manual'
}
});
await
expect
(
t
.
users
.
mapHostUser
(
t
.
op
,
r2
.
userId
,
'platform'
)).
rejects
.
toBeDefined
();
});
it
(
'并发员工映射只允许一个账号占用;未接入时可以独立开户'
,
async
()
=>
{
const
t
=
await
ownerTools
();
const
base
=
{
displayName
:
'并发员工'
,
role
:
'staff'
,
sourceUserId
:
'concurrent-staff'
};
const
results
=
await
Promise
.
allSettled
([
t
.
provision
.
openAccount
(
t
.
op
,
base
),
t
.
provision
.
openAccount
(
t
.
op
,
base
)]);
expect
(
results
.
filter
(
r
=>
r
.
status
===
'fulfilled'
)).
toHaveLength
(
1
);
expect
(
await
db
.
user
.
count
({
where
:
{
tenantId
:
t
.
tenantId
}
})).
toBe
(
2
);
await
db
.
tenant
.
update
({
where
:
{
id
:
t
.
tenantId
},
data
:
{
integrationEnabled
:
false
}
});
await
expect
(
t
.
provision
.
openAccount
(
t
.
op
,
{
...
base
,
sourceUserId
:
'new'
})).
rejects
.
toMatchObject
({
msg
:
expect
.
stringContaining
(
'尚未接入'
)
});
expect
((
await
t
.
provision
.
openAccount
(
t
.
op
,
{
displayName
:
'独立员工'
,
role
:
'viewer'
})).
applied
).
toBe
(
true
);
});
it
(
'owner 建门店并维护 PAC 坐标,免登读取新映射;旧静态映射随替换及取消清除'
,
async
()
=>
{
const
t
=
await
ownerTools
();
const
pacStub
=
{
orgNodes
:
jest
.
fn
().
mockResolvedValue
([
{
ref
:
'brand'
,
parentRef
:
null
,
kind
:
'brand'
,
name
:
'品牌'
,
aliases
:
[]
},
{
ref
:
'clinic-one'
,
parentRef
:
'brand'
,
kind
:
'clinic'
,
name
:
'第一门店'
,
aliases
:
[]
},
{
ref
:
'clinic-two'
,
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
request
=
{
parentId
:
t
.
root
.
id
,
name
:
'新建门店'
,
kind
:
'clinic'
};
const
pre
=
await
org
.
createUnit
(
t
.
op
,
{
...
request
,
dryRun
:
true
});
const
clinic
=
await
org
.
createUnit
(
t
.
op
,
{
...
request
,
rev
:
pre
.
rev
,
dryRun
:
false
});
const
id
=
clinic
.
nodeId
!
;
await
expect
(
org
.
linkPac
({
...
t
.
op
,
permissions
:
permissionsOf
(
'staff'
)
},
id
,
{
ref
:
'clinic-one'
,
dryRun
:
true
})).
rejects
.
toBeDefined
();
await
expect
(
org
.
linkPac
(
t
.
op
,
id
,
{
ref
:
'unknown'
,
dryRun
:
true
})).
rejects
.
toMatchObject
({
msg
:
expect
.
stringContaining
(
'没有这一项'
)
});
const
link
=
async
(
ref
:
string
|
null
)
=>
{
const
preview
=
await
org
.
linkPac
(
t
.
op
,
id
,
{
ref
,
dryRun
:
true
});
return
org
.
linkPac
(
t
.
op
,
id
,
{
ref
,
rev
:
preview
.
rev
,
ack
:
true
,
dryRun
:
false
});
};
await
link
(
'clinic-one'
);
const
app
=
await
db
.
integrationApp
.
findUniqueOrThrow
({
where
:
{
appId
}
});
const
graph
=
await
t
.
policy
.
byId
(
app
as
never
,
t
.
tenantId
,
t
.
input
.
partnerTenantId
);
expect
(
graph
.
mappings
.
get
(
'clinic-one'
)).
toBe
(
id
);
await
db
.
tenant
.
update
({
where
:
{
id
:
t
.
tenantId
},
data
:
{
integrationPolicy
:
{
...
graph
.
policy
,
organizationMappings
:
{
brand
:
t
.
root
.
id
,
'clinic-one'
:
id
}
}
}
});
await
link
(
'clinic-two'
);
const
replaced
=
await
t
.
policy
.
byId
(
app
as
never
,
t
.
tenantId
,
t
.
input
.
partnerTenantId
);
expect
(
replaced
.
mappings
.
has
(
'clinic-one'
)).
toBe
(
false
);
expect
(
replaced
.
mappings
.
get
(
'clinic-two'
)).
toBe
(
id
);
await
link
(
null
);
const
cleared
=
await
t
.
policy
.
byId
(
app
as
never
,
t
.
tenantId
,
t
.
input
.
partnerTenantId
);
expect
(
cleared
.
mappings
.
has
(
'clinic-two'
)).
toBe
(
false
);
expect
(
cleared
.
mappings
.
get
(
'brand'
)).
toBe
(
t
.
root
.
id
);
});
it
(
'写入中途出错事务整体回滚'
,
async
()
=>
{
const
broken
=
new
PlatformOnboardingService
(
{
tenant
:
db
.
tenant
,
orgUnitRef
:
db
.
orgUnitRef
,
integrationApp
:
db
.
integrationApp
,
$transaction
:
(
fn
:
(
tx
:
unknown
)
=>
Promise
<
unknown
>
)
=>
db
.
$transaction
((
tx
)
=>
fn
(
new
Proxy
(
tx
,
{
get
(
target
,
prop
)
{
if
(
prop
===
'auditLog'
)
return
{
create
:
()
=>
{
throw
Error
(
'模拟事务末尾失败'
);
},
};
return
Reflect
.
get
(
target
,
prop
);
},
}),
),
),
}
as
unknown
as
PrismaService
,
pac
as
never
,
);
const
input
=
data
();
await
expect
(
broken
.
create
(
actor
,
input
)).
rejects
.
toThrow
(
'模拟事务'
);
expect
(
await
db
.
tenant
.
count
({
where
:
{
partnerCode
:
host
,
partnerTenantId
:
input
.
partnerTenantId
},
}),
).
toBe
(
0
);
});
});
apps/ai-web/src/app/platform/page.tsx
View file @
1b133cc2
...
...
@@ -9,6 +9,11 @@ import { Checkbox } from '@/components/ui/checkbox';
import
{
DateTimePicker
}
from
'@/components/ui/date-time-picker'
;
import
{
Dialog
,
DialogContent
}
from
'@/components/ui/dialog'
;
import
{
TenantOnboarding
,
ActivationCard
,
type
Activation
,
}
from
'@/components/platform/tenant-onboarding'
;
import
{
Select
,
SelectContent
,
SelectItem
,
...
...
@@ -29,6 +34,9 @@ type User = {
phone
:
string
|
null
;
role
:
string
;
isActive
:
boolean
;
pendingActivation
:
boolean
;
canMapOwner
:
boolean
;
sourceUserId
:
string
|
null
;
};
type
Seat
=
{
id
:
string
;
...
...
@@ -40,7 +48,11 @@ type Seat = {
reservationId
:
string
|
null
;
};
type
Detail
=
{
tenant
:
Tenant
;
tenant
:
Tenant
&
{
partnerTenantId
:
string
;
partnerOrgRootRef
:
string
;
integrationAppId
:
string
|
null
;
};
users
:
User
[];
accounts
:
{
id
:
string
;
nickname
:
string
|
null
}[];
seats
:
Seat
[];
...
...
@@ -62,6 +74,13 @@ const verbs = { grant: '开通席位', renew: '续期', authorize: '授权扫码
type
Action
=
keyof
typeof
verbs
;
export
default
function
PlatformPage
()
{
const
router
=
useRouter
();
const
[
mappingUser
,
setMappingUser
]
=
useState
<
User
|
null
>
(
null
);
const
[
mappingId
,
setMappingId
]
=
useState
(
''
),
[
mappingReason
,
setMappingReason
]
=
useState
(
''
);
const
[
onboarding
,
setOnboarding
]
=
useState
(
false
);
const
[
activationUser
,
setActivationUser
]
=
useState
<
User
|
null
>
(
null
);
const
[
activation
,
setActivation
]
=
useState
<
Activation
|
null
>
(
null
);
const
[
activationReason
,
setActivationReason
]
=
useState
(
''
);
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
),
[
tenants
,
setTenants
]
=
useState
<
Tenant
[]
>
([]),
[
detail
,
setDetail
]
=
useState
<
Detail
|
null
>
(
null
),
...
...
@@ -144,6 +163,7 @@ export default function PlatformPage() {
}
}
;
const can = me?.permissions.includes('platform.hosting.manage');
const canOnboard = me?.permissions.includes('platform.tenant.manage');
return (
<
main
className=
"min-h-screen bg-slate-50 text-[12.5px] text-slate-700"
>
<
header
className=
"flex h-16 items-center justify-between bg-white px-6"
>
...
...
@@ -158,9 +178,7 @@ export default function PlatformPage() {
{
me
&&
<
WorkspaceSwitch
/>
}
<
Button
variant=
"ghost"
onClick=
{
()
=>
void
api
.
post
(
'/auth/logout'
,
{}).
then
(()
=>
router
.
replace
(
'/login'
))
}
onClick=
{
()
=>
void
api
.
post
(
'/auth/logout'
,
{}).
then
(()
=>
router
.
replace
(
'/login'
))
}
>
退出
</
Button
>
...
...
@@ -222,6 +240,9 @@ export default function PlatformPage() {
'hosting.revoke'
:
'停用席位'
,
'platform.user.grant'
:
'授予平台角色'
,
'platform.user.revoke'
:
'收回平台角色'
,
'tenant.onboard'
:
'开通客户'
,
'tenant.owner.activation'
:
'重发管理员激活链接'
,
'tenant.owner.map'
:
'关联宿主账号'
,
'auth.login'
:
'登录'
,
'auth.password.change'
:
'修改密码'
,
}[
l
.
action
]
??
l
.
action
}
...
...
@@ -240,12 +261,15 @@ export default function PlatformPage() {
<>
<
div
className=
"flex items-center justify-between"
>
<
h1
className=
"text-lg font-semibold text-slate-900"
>
客户与托管
</
h1
>
<
div
className=
"flex items-center gap-3"
>
<
Input
className=
"max-w-64"
placeholder=
"搜索客户名称"
value=
{
q
}
onChange=
{
(
e
)
=>
setQ
(
e
.
target
.
value
)
}
/>
{
canOnboard
&&
<
Button
onClick=
{
()
=>
setOnboarding
(
true
)
}
>
开通客户
</
Button
>
}
</
div
>
</
div
>
<
div
className=
"overflow-x-auto rounded-xl bg-white"
>
<
table
className=
"w-full text-left"
>
...
...
@@ -305,6 +329,32 @@ export default function PlatformPage() {
{
can
?
<
Button
onClick=
{
()
=>
open
(
'grant'
,
null
)
}
>
开通一个席位
</
Button
>
:
null
}
</
div
>
<
div
className=
"rounded-xl bg-white p-5"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
h2
className=
"font-semibold"
>
数据来源与接入
</
h2
>
</
div
>
<
dl
className=
"grid gap-3 sm:grid-cols-2"
>
<
div
>
<
dt
className=
"text-slate-400"
>
PAC 宿主
</
dt
>
<
dd
>
{
detail
.
tenant
.
partnerCode
}
</
dd
>
</
div
>
<
div
>
<
dt
className=
"text-slate-400"
>
数据租户 ID
</
dt
>
<
dd
className=
"break-all"
>
{
detail
.
tenant
.
partnerTenantId
}
</
dd
>
</
div
>
<
div
>
<
dt
className=
"text-slate-400"
>
客户组织根 ID
</
dt
>
<
dd
className=
"break-all"
>
{
detail
.
tenant
.
partnerOrgRootRef
||
'待配置'
}
</
dd
>
</
div
>
<
div
>
<
dt
className=
"text-slate-400"
>
宿主应用
</
dt
>
<
dd
>
{
detail
.
tenant
.
integrationAppId
||
'宿主尚未接入'
}
</
dd
>
</
div
>
</
dl
>
<
p
className=
"mt-4 text-xs text-slate-400"
>
客户管理员在「组织架构」维护门店和患者档案关联,在「员工授权」给同事开户并分配范围。
</
p
>
</
div
>
<
div
className=
"rounded-xl bg-white p-5"
>
<
h2
className=
"mb-4 font-semibold"
>
托管席位
</
h2
>
<
p
className=
"mb-4 text-slate-400"
>
每次开通新增一个席位;解除托管不会释放已占用席位。首次扫码授权默认有效 24 小时。
...
...
@@ -377,6 +427,35 @@ export default function PlatformPage() {
{
{
owner
:
'管理员'
,
staff
:
'员工'
,
viewer
:
'只读'
}[
u
.
role
]
??
u
.
role
}
</
span
>
<
span
>
{
u
.
isActive
?
'正常'
:
'已停用'
}
</
span
>
{
u
.
sourceUserId
&&
(
<
span
className=
"text-xs text-slate-500"
>
宿主用户:
{
u
.
sourceUserId
}
</
span
>
)
}
{
canOnboard
&&
u
.
canMapOwner
&&
(
<
Button
variant=
"ghost"
onClick=
{
()
=>
{
setMappingUser
(
u
);
setMappingId
(
''
);
setMappingReason
(
''
);
setError
(
''
);
}
}
>
关联宿主账号
</
Button
>
)
}
{
canOnboard
&&
u
.
pendingActivation
&&
(
<
Button
variant=
"outline"
onClick=
{
()
=>
{
setActivationUser
(
u
);
setActivation
(
null
);
setActivationReason
(
''
);
setError
(
''
);
}
}
>
生成管理员激活链接
</
Button
>
)
}
</
div
>
))
}
</
div
>
...
...
@@ -385,6 +464,132 @@ export default function PlatformPage() {
</
section
>
</
div
>
<
Dialog
open=
{
!!
mappingUser
}
onOpenChange=
{
(
v
)
=>
{
if
(
!
v
&&
!
busy
)
setMappingUser
(
null
);
}
}
>
<
DialogContent
title=
"关联宿主账号"
variant=
"form"
>
<
form
className=
"mt-4 space-y-4"
onSubmit=
{
async
(
e
)
=>
{
e
.
preventDefault
();
if
(
!
detail
||
!
mappingUser
)
return
;
setBusy
(
true
);
setError
(
''
);
try
{
await
api
.
post
(
`/platform/tenants/${detail.tenant.id}/owners/${mappingUser.id}/mapping`
,
{
sourceUserId
:
mappingId
.
trim
(),
reason
:
mappingReason
.
trim
()
},
);
await
reload
(
detail
.
tenant
.
id
);
setMappingUser
(
null
);
}
catch
(
e
)
{
setError
(
e
instanceof
Error
?
e
.
message
:
'关联失败'
);
}
finally
{
setBusy
(
false
);
}
}
}
>
<
p
>
{
mappingUser
?.
displayName
}
</
p
>
<
p
className=
"text-sm text-slate-500"
>
沿用客户所属宿主的接入规则。请确认源系统用户与该管理员是同一人,映射后内嵌与独立登录使用同一账号。
</
p
>
<
label
className=
"block space-y-2"
>
<
span
>
宿主用户 ID
</
span
>
<
Input
required
pattern=
"\S+"
maxLength=
{
128
}
value=
{
mappingId
}
onChange=
{
(
e
)
=>
setMappingId
(
e
.
target
.
value
)
}
/>
</
label
>
<
label
className=
"block space-y-2"
>
<
span
>
关联说明
</
span
>
<
Input
required
maxLength=
{
500
}
value=
{
mappingReason
}
onChange=
{
(
e
)
=>
setMappingReason
(
e
.
target
.
value
)
}
/>
</
label
>
{
error
&&
(
<
p
role=
"alert"
className=
"text-rose-600"
>
{
error
}
</
p
>
)
}
<
Button
type=
"submit"
disabled=
{
busy
}
>
{
busy
?
'处理中…'
:
'确认关联'
}
</
Button
>
</
form
>
</
DialogContent
>
</
Dialog
>
{
onboarding
&&
(
<
TenantOnboarding
onClose=
{
()
=>
setOnboarding
(
false
)
}
onCreated=
{
(
id
)
=>
{
void
reload
(
id
).
catch
((
e
)
=>
setError
(
e
.
message
));
}
}
/>
)
}
<
Dialog
open=
{
!!
activationUser
}
onOpenChange=
{
(
v
)
=>
{
if
(
!
v
&&
!
busy
)
{
setActivationUser
(
null
);
setActivation
(
null
);
}
}
}
>
<
DialogContent
title=
"管理员激活"
variant=
"form"
>
<
div
className=
"mt-4 space-y-4"
>
<
p
>
{
activationUser
?.
displayName
}
</
p
>
{
activation
?
(
<
ActivationCard
activation=
{
activation
}
/>
)
:
(
<>
<
p
className=
"text-slate-500"
>
生成后旧链接立即失效,新链接有效 30
分钟。已设置密码或通过宿主登录的账号不能在此重置。
</
p
>
<
Input
aria
-
label=
"重新生成的原因"
placeholder=
"重新生成的原因"
value=
{
activationReason
}
maxLength=
{
500
}
onChange=
{
(
e
)
=>
setActivationReason
(
e
.
target
.
value
)
}
/>
<
Button
loading=
{
busy
}
disabled=
{
!
activationReason
.
trim
()
}
onClick=
{
()
=>
{
if
(
!
detail
||
!
activationUser
)
return
;
setBusy
(
true
);
setError
(
''
);
void
api
.
post
<
Activation
>
(
`/platform/tenants/${detail.tenant.id}/owners/${activationUser.id}/activation`
,
{
reason
:
activationReason
.
trim
()
},
)
.
then
(
setActivation
)
.
catch
((
e
)
=>
setError
(
e
.
message
))
.
finally
(()
=>
setBusy
(
false
));
}
}
>
生成新链接
</
Button
>
</>
)
}
{
error
&&
(
<
p
role=
"alert"
className=
"text-rose-600"
>
{
error
}
</
p
>
)
}
</
div
>
</
DialogContent
>
</
Dialog
>
<
Dialog
open=
{
!!
action
}
onOpenChange=
{
(
v
)
=>
{
if
(
!
v
&&
!
busy
)
setAction
(
null
);
...
...
apps/ai-web/src/components/admin/org-node-detail.tsx
View file @
1b133cc2
...
...
@@ -203,10 +203,11 @@ export function OrgNodeDetail({
)
:
null
}
</
Section
>
<
Section
title=
"患者档案"
note=
"
查患者资料、自动生成预约确认,都按这里关联的去找
"
>
<
Section
title=
"患者档案"
note=
"
关联宿主中的对应组织,患者资料和免登门店识别沿用此映射
"
>
<
Field
label=
"现在"
>
<
span
className=
{
`text-[12px] ${node.pacRef || node.patientReadable ? 'text-slate-700' : 'text-amber-700'}`
}
>
{
pacNow
}
</
span
>
</
Field
>
{
node
.
pacRef
?
<
Field
label=
"宿主组织 ID"
><
span
className=
"text-[12px] break-all"
>
{
node
.
pacRef
}
</
span
></
Field
>
:
null
}
{
edit
?
(
!
node
.
isActive
?
(
<
Muted
>
已停用的组织不能改患者档案,请先恢复它
</
Muted
>
...
...
apps/ai-web/src/components/admin/person-detail.tsx
View file @
1b133cc2
...
...
@@ -11,6 +11,7 @@ import {
type
RoleChangeResult
,
type
ScopeChangeResult
,
}
from
'@/lib/api'
;
import
{
Input
}
from
'@/components/ui/input'
;
import
{
Badge
}
from
'@/components/ui/badge'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
notify
}
from
'@/components/ui/sonner'
;
...
...
@@ -67,6 +68,9 @@ export function PersonDetail({
const
[
ticket
,
setTicket
]
=
useState
<
ActivationTicket
|
null
>
(
null
);
const
[
copied
,
setCopied
]
=
useState
(
false
);
const
[
savingProfile
,
setSavingProfile
]
=
useState
(
false
);
const
[
hostUserId
,
setHostUserId
]
=
useState
(
''
);
const
hostFlow
=
useTwoStep
<
{
sourceUserId
:
string
}
>
(
'关联失败'
);
useEffect
(()
=>
{
setHostUserId
(
''
);
hostFlow
.
reset
();
},
[
detail
.
id
,
hostFlow
.
reset
]);
// ⚠️ 预演之后又改了勾选 ⇒ 那份后果已经不对了
const
{
reset
:
resetScope
}
=
scopeFlow
;
...
...
@@ -178,9 +182,23 @@ export function PersonDetail({
/>
)
:
null
}
<
Field
label=
"宿主用户 ID"
>
{
detail
.
sourceUserId
?
<
span
className=
"text-[12px]"
>
{
detail
.
sourceUserId
}
</
span
>
:
detail
.
canMapHostUser
?
(
<
div
className=
"space-y-2"
>
<
Input
aria
-
label=
"宿主用户 ID"
value=
{
hostUserId
}
maxLength=
{
128
}
onChange=
{
e
=>
{
setHostUserId
(
e
.
target
.
value
);
hostFlow
.
reset
();}
}
/>
<
Muted
>
填写此员工在宿主里的 userId,关联后两个入口使用同一账号。
</
Muted
>
{
!
hostFlow
.
plan
?
<
Button
size=
"sm"
disabled=
{
!
hostUserId
.
trim
()
||
hostFlow
.
busy
}
onClick=
{
()
=>
void
hostFlow
.
preview
(
async
()
=>
({
sourceUserId
:
hostUserId
.
trim
()}))
}
>
关联宿主账号
</
Button
>
:
null
}
<
ConfirmPanel
flow=
{
hostFlow
}
lines=
{
[
`将宿主用户 ${hostFlow.plan?.sourceUserId ?? ''} 关联到「${name}」。`
,
'请确认是同一人;已有映射不能覆盖。'
]
}
button=
"确认关联"
onConfirm=
{
async
()
=>
{
const
result
=
await
hostFlow
.
commit
(
p
=>
api
.
mapHostUser
(
detail
.
id
,
p
.
sourceUserId
));
if
(
result
)
{
notify
.
ok
(
'已关联宿主账号'
);
hostFlow
.
reset
();
onChanged
();
}
}
}
/>
</
div
>
)
:
<
Muted
>
未关联
</
Muted
>
}
</
Field
>
<
Field
label=
"激活"
>
<
div
className=
"flex items-center gap-2 text-[12px]"
>
{
detail
.
activated
?
(
{
detail
.
activated
&&
!
detail
.
canReissueActivation
?
(
'已激活'
)
:
(
<>
...
...
apps/ai-web/src/components/platform/tenant-onboarding.tsx
0 → 100644
View file @
1b133cc2
'use client'
;
import
{
useState
}
from
'react'
;
import
{
api
}
from
'@/lib/api'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Input
}
from
'@/components/ui/input'
;
import
{
Checkbox
}
from
'@/components/ui/checkbox'
;
import
{
Dialog
,
DialogContent
}
from
'@/components/ui/dialog'
;
type
Preview
=
{
dataStatus
:
string
;
warnings
:
string
[]
};
export
type
Activation
=
{
token
:
string
;
expiresAt
:
string
};
type
Created
=
{
tenant
:
{
id
:
string
;
name
:
string
};
owner
:
{
id
:
string
;
displayName
:
string
};
activation
:
Activation
|
null
;
warnings
:
string
[];
};
function
Field
({
label
,
hint
,
children
,
}:
{
label
:
string
;
hint
?:
string
;
children
:
React
.
ReactNode
;
})
{
return
(
<
label
className=
"block space-y-1.5"
>
<
span
className=
"text-xs font-medium text-slate-600"
>
{
label
}
</
span
>
{
children
}
{
hint
&&
<
span
className=
"block text-xs text-slate-400"
>
{
hint
}
</
span
>
}
</
label
>
);
}
export
function
ActivationCard
({
activation
}:
{
activation
:
Activation
})
{
const
[
copied
,
setCopied
]
=
useState
(
false
),
[
error
,
setError
]
=
useState
(
''
);
const
url
=
typeof
window
===
'undefined'
?
''
:
`
${
window
.
location
.
origin
}
/activate?t=
${
encodeURIComponent
(
activation
.
token
)}
`
;
return
(
<
div
className=
"space-y-3 rounded-xl bg-blue-50 p-4"
>
<
p
className=
"font-medium"
>
转交给管理员本人激活
</
p
>
<
p
className=
"text-xs text-slate-500"
>
本人打开链接后填写手机号并设置密码。有效至
{
' '
}
{
new
Date
(
activation
.
expiresAt
).
toLocaleString
(
'zh-CN'
)
}
,使用一次后失效。
</
p
>
<
Input
aria
-
label=
"管理员激活链接"
readOnly
value=
{
url
}
onFocus=
{
(
e
)
=>
e
.
target
.
select
()
}
/>
<
Button
variant=
"outline"
onClick=
{
()
=>
{
void
navigator
.
clipboard
.
writeText
(
url
)
.
then
(()
=>
setCopied
(
true
))
.
catch
(()
=>
setError
(
'未能复制,请选中上面的链接手动复制'
));
}
}
>
{
copied
?
'已复制'
:
'复制激活链接'
}
</
Button
>
{
error
&&
(
<
p
role=
"alert"
className=
"text-rose-600"
>
{
error
}
</
p
>
)
}
<
p
className=
"text-xs text-slate-500"
>
关闭后不再显示此链接;未激活时可在客户详情重新生成。
</
p
>
</
div
>
);
}
export
function
TenantOnboarding
({
onClose
,
onCreated
,
}:
{
onClose
:
()
=>
void
;
onCreated
:
(
id
:
string
)
=>
void
;
})
{
const
[
step
,
setStep
]
=
useState
(
0
),
[
busy
,
setBusy
]
=
useState
(
false
),
[
error
,
setError
]
=
useState
(
''
);
const
[
name
,
setName
]
=
useState
(
''
);
const
[
host
,
setHost
]
=
useState
(
'friday'
),
[
space
,
setSpace
]
=
useState
(
'friday-market'
),
[
root
,
setRoot
]
=
useState
(
''
);
const
[
sourceUserId
,
setSourceUserId
]
=
useState
(
''
);
const
[
owner
,
setOwner
]
=
useState
(
''
),
[
reason
,
setReason
]
=
useState
(
''
);
const
[
preview
,
setPreview
]
=
useState
<
Preview
|
null
>
(
null
),
[
confirmed
,
setConfirmed
]
=
useState
(
false
),
[
created
,
setCreated
]
=
useState
<
Created
|
null
>
(
null
);
const
payload
=
()
=>
({
name
:
name
.
trim
(),
partnerCode
:
host
.
trim
(),
partnerTenantId
:
space
.
trim
(),
partnerOrgRootRef
:
root
.
trim
(),
ownerName
:
owner
.
trim
(),
...(
sourceUserId
.
trim
()
?
{
sourceUserId
:
sourceUserId
.
trim
()
}
:
{}),
reason
:
reason
.
trim
(),
});
const
next
=
async
()
=>
{
setError
(
''
);
if
(
step
===
0
)
{
setStep
(
1
);
return
;
}
setBusy
(
true
);
try
{
if
(
step
===
1
)
{
setPreview
(
await
api
.
post
<
Preview
>
(
'/platform/onboarding/preview'
,
payload
()));
setConfirmed
(
false
);
setStep
(
2
);
}
else
{
const
result
=
await
api
.
post
<
Created
>
(
'/platform/tenants'
,
payload
());
setCreated
(
result
);
setStep
(
3
);
onCreated
(
result
.
tenant
.
id
);
}
}
catch
(
e
)
{
setError
(
e
instanceof
Error
?
e
.
message
:
'操作未完成,请核对后重试'
);
}
finally
{
setBusy
(
false
);
}
};
return
(
<
Dialog
open
onOpenChange=
{
(
v
)
=>
{
if
(
!
v
&&
!
busy
)
onClose
();
}
}
>
<
DialogContent
title=
{
created
?
'客户已开通'
:
'开通客户'
}
variant=
"form"
className=
"w-[min(94vw,720px)]"
>
<
form
className=
"mt-5 space-y-5"
onSubmit=
{
(
e
)
=>
{
e
.
preventDefault
();
void
next
();
}
}
>
{
!
created
&&
(
<
ol
className=
"flex gap-3 text-xs"
aria
-
label=
"开户步骤"
>
{
[
'客户与数据来源'
,
'首位管理员'
,
'核对并开通'
].
map
((
label
,
i
)
=>
(
<
li
key=
{
label
}
className=
{
`rounded-full px-3 py-2 ${step === i ? 'bg-blue-50 font-medium text-brand-600' : 'bg-slate-50 text-slate-400'}`
}
aria
-
current=
{
step
===
i
?
'step'
:
undefined
}
>
{
i
+
1
}
.
{
label
}
</
li
>
))
}
</
ol
>
)
}
{
step
===
0
&&
(
<>
<
Field
label=
"客户名称"
>
<
Input
required
minLength=
{
2
}
maxLength=
{
64
}
value=
{
name
}
onChange=
{
(
e
)
=>
setName
(
e
.
target
.
value
)
}
placeholder=
"品牌或集团名称"
/>
</
Field
>
<
p
className=
"rounded-xl bg-blue-50 p-4 text-slate-600"
>
管理员激活后可独立登录。宿主内嵌沿用已接入宿主的规则,两种入口可同时使用。
</
p
>
<
Field
label=
"PAC 宿主"
hint=
"患者数据的来源标识,如 friday 或 jvs-dw;无需先配置免登应用。"
>
<
Input
required
pattern=
"[a-z0-9][a-z0-9_-]
{
0
,
63
}
"
value=
{
host
}
onChange=
{
(
e
)
=>
setHost
(
e
.
target
.
value
)
}
/>
</
Field
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
Field
label=
"PAC 数据租户 ID"
hint=
"Friday SaaS 使用 friday-market;不是品牌 ID。"
>
<
Input
required
maxLength=
{
128
}
pattern=
"\S+"
value=
{
space
}
onChange=
{
(
e
)
=>
setSpace
(
e
.
target
.
value
)
}
/>
</
Field
>
<
Field
label=
"客户组织根 ID"
hint=
"该品牌在宿主/PAC 中的根编号;Friday SaaS 填原生品牌 tenantId。"
>
<
Input
required
maxLength=
{
128
}
pattern=
"\S+"
value=
{
root
}
onChange=
{
(
e
)
=>
setRoot
(
e
.
target
.
value
)
}
/>
</
Field
>
</
div
>
</>
)
}
{
step
===
1
&&
(
<>
<
Field
label=
"管理员姓名"
>
<
Input
required
minLength=
{
2
}
maxLength=
{
32
}
value=
{
owner
}
onChange=
{
(
e
)
=>
setOwner
(
e
.
target
.
value
)
}
/>
</
Field
>
<
p
className=
"rounded-xl bg-blue-50 p-4 text-slate-600"
>
开户后生成 30
分钟有效的激活链接。由管理员本人填写手机号、设置密码,即可独立登录搭建组织。
</
p
>
<
Field
label=
"宿主用户 ID(可选)"
hint=
"填此人在宿主中的 userId,用于关联同一个账号;不是手机号。暂不清楚可在客户员工处补充。"
>
<
Input
maxLength=
{
128
}
pattern=
"\S+"
value=
{
sourceUserId
}
onChange=
{
(
e
)
=>
setSourceUserId
(
e
.
target
.
value
)
}
/>
</
Field
>
<
div
className=
"rounded-xl bg-slate-50 p-4"
>
<
p
className=
"font-medium"
>
客户管理员 · 全客户范围
</
p
>
<
p
className=
"mt-1 text-slate-500"
>
可管理本客户的组织树、门店数据关联、员工账号及授权。平台管理和托管席位由平台人员处理。
</
p
>
</
div
>
<
Field
label=
"开户说明"
>
<
Input
required
maxLength=
{
500
}
value=
{
reason
}
onChange=
{
(
e
)
=>
setReason
(
e
.
target
.
value
)
}
placeholder=
"记录客户及管理员信息的确认依据"
/>
</
Field
>
</>
)
}
{
step
===
2
&&
preview
&&
(
<>
<
dl
className=
"grid grid-cols-[8rem_1fr] gap-3 rounded-xl bg-slate-50 p-4"
>
<
dt
>
客户
</
dt
>
<
dd
>
{
name
}
</
dd
>
<
dt
>
数据来源
</
dt
>
<
dd
className=
"break-all"
>
{
host
}
/
{
space
}
</
dd
>
<
dt
>
组织根
</
dt
>
<
dd
className=
"break-all"
>
{
root
}
</
dd
>
<
dt
>
组织搭建
</
dt
>
<
dd
>
由管理员登录后添加
</
dd
>
<
dt
>
首位管理员
</
dt
>
<
dd
>
{
owner
}
· 客户管理员
</
dd
>
<
dt
>
宿主账号映射
</
dt
>
<
dd
>
{
sourceUserId
||
'暂未填写'
}
</
dd
>
<
dt
>
首次登录
</
dt
>
<
dd
>
本人激活后登录
</
dd
>
<
dt
>
组织数据核对
</
dt
>
<
dd
>
{
preview
.
dataStatus
===
'verified'
?
'已找到客户范围内的组织编号'
:
'尚未完成'
}
</
dd
>
</
dl
>
{
preview
.
warnings
.
map
((
w
)
=>
(
<
p
key=
{
w
}
className=
"rounded-lg bg-amber-50 p-3 text-amber-800"
>
{
w
}
</
p
>
))
}
<
p
className=
"text-xs text-slate-500"
>
将同时创建客户、顶层节点和首位管理员。托管席位与自动发送后续单独开通。
</
p
>
<
label
className=
"flex items-start gap-2"
>
<
Checkbox
checked=
{
confirmed
}
onCheckedChange=
{
(
v
)
=>
setConfirmed
(
v
===
true
)
}
/>
<
span
>
已核对客户数据范围和管理员信息,并了解上面的数据接入状态。
</
span
>
</
label
>
</>
)
}
{
created
&&
(
<>
<
p
className=
"text-base font-medium text-emerald-700"
>
{
created
.
tenant
.
name
}
已开通
</
p
>
<
p
>
{
created
.
owner
.
displayName
}
已设为客户管理员,可管理本客户的组织树和员工。
</
p
>
{
created
.
activation
?
(
<
ActivationCard
activation=
{
created
.
activation
}
/>
)
:
(
<
p
className=
"rounded-xl bg-blue-50 p-4"
>
请在客户详情为首位管理员生成激活链接。
</
p
>
)
}
{
created
.
warnings
.
map
((
w
)
=>
(
<
p
key=
{
w
}
className=
"rounded-lg bg-amber-50 p-3 text-amber-800"
>
{
w
}
</
p
>
))
}
<
p
className=
"text-slate-500"
>
接下来:管理员搭建组织树、关联门店数据并给同事开户。宿主内嵌按既有接入规则识别账号和门店,托管席位另行开通。
</
p
>
</>
)
}
{
error
&&
(
<
p
role=
"alert"
className=
"rounded-lg bg-rose-50 p-3 text-rose-700"
>
{
error
}
</
p
>
)
}
<
div
className=
"flex justify-end gap-2 border-t border-slate-100 pt-4"
>
{
created
?
(
<
Button
onClick=
{
onClose
}
>
进入客户详情
</
Button
>
)
:
(
<>
<
Button
variant=
"outline"
disabled=
{
busy
}
onClick=
{
()
=>
{
if
(
step
)
{
setStep
(
step
-
1
);
setError
(
''
);
}
else
onClose
();
}
}
>
{
step
?
'上一步'
:
'取消'
}
</
Button
>
<
Button
type=
"submit"
disabled=
{
busy
||
(
step
===
2
&&
!
confirmed
)
}
>
{
busy
?
'处理中…'
:
step
===
0
?
'下一步'
:
step
===
1
?
'核对开户信息'
:
'确认开通'
}
</
Button
>
</>
)
}
</
div
>
</
form
>
</
DialogContent
>
</
Dialog
>
);
}
apps/ai-web/src/components/provision-drawer.tsx
View file @
1b133cc2
...
...
@@ -47,6 +47,7 @@ export function ProvisionDrawer({
})
{
const
[
role
,
setRole
]
=
useState
<
'staff'
|
'viewer'
>
(
contact
?
'staff'
:
'viewer'
);
const
[
name
,
setName
]
=
useState
(
''
);
const
[
sourceUserId
,
setSourceUserId
]
=
useState
(
''
);
const
[
anchors
,
setAnchors
]
=
useState
<
AnchorInput
[]
>
([]);
const
[
nodes
,
setNodes
]
=
useState
<
GrantableNode
[]
|
null
>
(
null
);
/** 开完之后拿到的一次性链接。⚠️ **只出现这一次** —— 关掉就再也看不到了 */
...
...
@@ -69,12 +70,13 @@ export function ProvisionDrawer({
// ⚠️ 预演之后又改了输入 ⇒ 那份后果已经不对了,收起来重新预演
useEffect
(()
=>
{
reset
();
},
[
role
,
name
,
anchors
,
reset
]);
},
[
role
,
name
,
sourceUserId
,
anchors
,
reset
]);
const
body
=
{
contactId
:
contact
?.
id
,
displayName
:
contact
?
undefined
:
name
.
trim
(),
role
,
sourceUserId
:
sourceUserId
.
trim
()
||
undefined
,
anchors
,
};
...
...
@@ -114,7 +116,7 @@ export function ProvisionDrawer({
必须把这句话说在人**还没关掉**的时候;也要说清它是**凭据**。
*/
}
<
div
className=
"rounded-lg bg-amber-50 px-3 py-2 text-[11.5px] leading-[1.7] text-amber-900"
>
这条链接
<
b
>
只显示这一次
</
b
>
,关闭后就看不到了(
需要的话可以重新开账号
)。
这条链接
<
b
>
只显示这一次
</
b
>
,关闭后就看不到了(
可在员工授权页重发激活链接
)。
<
br
/>
这是一次性的开户链接,只转给
<
b
>
本人
</
b
>
。
<
b
>
30 分钟
</
b
>
后失效,用过一次也会失效。
</
div
>
...
...
@@ -176,6 +178,12 @@ export function ProvisionDrawer({
)
:
null
}
<
div
>
<
label
htmlFor=
"prov-host-user"
className=
"mb-1.5 block text-[11px] font-semibold text-slate-500"
>
宿主用户 ID(可选)
</
label
>
<
Input
id=
"prov-host-user"
value=
{
sourceUserId
}
maxLength=
{
128
}
onChange=
{
e
=>
setSourceUserId
(
e
.
target
.
value
)
}
/>
<
p
className=
"mt-1 text-[11px] text-slate-500"
>
填写员工在宿主里的 userId,使独立登录和免登使用同一个账号。也可开户后补充。
</
p
>
</
div
>
<
div
>
<
div
className=
"mb-1.5 text-[11px] font-semibold text-slate-500"
>
角色
</
div
>
<
div
className=
"flex gap-2"
>
{
/* ⛔ 这里开不出管理员 —— 那等于把提权通道复制一份出去 */
}
...
...
@@ -222,7 +230,7 @@ export function ProvisionDrawer({
</
div
>
{
copy
?
(
<
ConfirmPanel
flow=
{
flow
}
lines=
{
copy
.
lines
}
button=
{
copy
.
button
}
onConfirm=
{
()
=>
void
confirm
()
}
/>
<
ConfirmPanel
flow=
{
flow
}
lines=
{
sourceUserId
.
trim
()
?
[...
copy
.
lines
,
`关联宿主用户:${sourceUserId.trim()}`
]
:
copy
.
lines
}
button=
{
copy
.
button
}
onConfirm=
{
()
=>
void
confirm
()
}
/>
)
:
(
<
div
className=
"flex gap-2"
>
{
/* ⚠️ 姓名没填就禁用 —— 后端也判(2–32 字),⛔ 前端这道只是别让人白跑一趟 */
}
...
...
apps/ai-web/src/lib/api.ts
View file @
1b133cc2
...
...
@@ -174,6 +174,7 @@ export const api = {
setUserScopes
:
(
id
:
string
,
body
:
{
anchors
:
AnchorInput
[];
rev
?:
number
;
dryRun
:
boolean
})
=>
api
.
put
<
ScopeChangeResult
>
(
`/users/
${
encodeURIComponent
(
id
)}
/scopes`
,
body
),
/** ⚠️ 缺键 = 不改,`null` = 清空 */
mapHostUser
:
(
id
:
string
,
sourceUserId
:
string
)
=>
api
.
post
<
{
sourceUserId
:
string
}
>
(
`/users/
${
encodeURIComponent
(
id
)}
/host-identity`
,
{
sourceUserId
}),
setUserProfile
:
(
id
:
string
,
body
:
{
orgUnitId
?:
string
|
null
;
jobTitle
?:
string
|
null
})
=>
api
.
patch
<
ProfileSetResult
>
(
`/users/
${
encodeURIComponent
(
id
)}
/profile`
,
body
),
setUserRole
:
(
id
:
string
,
body
:
{
role
:
'staff'
|
'viewer'
;
dryRun
:
boolean
})
=>
...
...
@@ -185,6 +186,7 @@ export const api = {
provision
:
(
body
:
{
contactId
?:
string
;
displayName
?:
string
;
sourceUserId
?:
string
;
role
:
'staff'
|
'viewer'
;
anchors
:
AnchorInput
[];
dryRun
:
boolean
;
...
...
@@ -1700,6 +1702,9 @@ export interface AdminCan {
}
export
interface
AdminUserRow
{
sourceUserId
?:
string
|
null
;
canMapHostUser
?:
boolean
;
canReissueActivation
?:
boolean
;
id
:
string
;
displayName
:
string
;
role
:
string
;
...
...
docs/friday-ai-platform-management.md
View file @
1b133cc2
...
...
@@ -7,13 +7,51 @@
平台管理首期提供:
-
客户列表、员工与企微号查询。
-
客户开户:数据来源、组织根、首位客户管理员;激活后由管理员搭建组织。
-
托管席位开通、续期、指定期限、永久、停用。
-
给员工授权首次扫码;默认 24 小时。
-
平台操作记录:操作人、目标客户、动作、原因、结果;最近 100 条。
开通一次新增一个席位。页面与
`ai:hosting`
CLI 共用
`HostingAdminService`
,不会因入口不同改变规则。解除托管、员工转交仍在原工作台操作。
客户开户、宿主接入配置和平台人员授权暂保留 CLI;没有开放平台用户自助注册或客户管理员提权入口。
客户开户支持平台页面。宿主应用注册/密钥轮换和平台人员授权保留 CLI;没有开放平台用户自助注册或客户管理员提权入口。
## 客户开户
平台管理员在「客户与托管」点击「开通客户」,按三步完成:客户与数据来源 → 首位管理员 → 核对并开通。平台只读人员和客户 owner 不能调用开户接口。
| 信息 | 填写内容 |
| --- | --- |
| 客户名称 | 品牌或集团的名称,同时用作组织树根名称 |
| 首次进入 | 统一使用一次性激活链接,管理员本人设置手机号和密码 |
| 宿主 | 填写 PAC 宿主,如
`friday`
/
`jvs-dw`
|
| PAC 数据租户 ID | 数据所在空间;Friday SaaS 是
`friday-market`
,不是原生品牌 ID |
| 客户组织根 ID | 本客户在数据空间中的根;Friday SaaS 填原生品牌
`tenantId`
|
| 组织树 | 开户只建顶层节点;管理员登录后自行添加品牌、区域、门店和关联编号 |
| 首位管理员 | 姓名;可选填写该人员的宿主 userId,关联同一个账号;不填写门店 |
| 开户说明 | 记录信息确认依据,进入平台操作记录 |
预览只读取 PAC 组织字典,不读取患者。PAC 不可达或字典为空会明确提示未完成数据核验,允许先开户,不能将此视为患者数据验收通过。
提交在同一事务中创建客户、品牌根、首位
`owner`
、激活票和正式租户角色授权。首位 owner 为全客户范围;按宿主自动关联唯一有效接入应用;填写源用户 ID 时,同时建立身份关系。不创建门店。重复开户、跨客户重用编号会被拒绝;并发提交不会重复开户;中途失败整体回滚。不创建托管席位,不设置
`aiAutoSend`
,不发消息。
首次登录不由平台设置密码:开户结果显示一次性激活链接,30 分钟有效,由管理员本人填写手机号和密码。尚未激活且不具备平台角色的 owner(已映射宿主时须已有开户激活票) 可在客户详情重新生成链接,旧链接立即作废;已激活人员不能借此重置密码。激活票只存摘要,不进入操作日志,返回凭据的接口禁止缓存。
### 同一账号的两个入口
独立登录与宿主内嵌可以同时使用,无需客户单独选择或配置免登。新开户客户按
`partnerCode`
自动关联唯一有效的宿主应用,继承宿主统一的角色、模块和能力上限;已有客户的显式接入限制保持不变。
首位 owner 的源账号映射可在开户时填写,也可在客户详情的「客户员工 → 关联宿主账号」补充。填写源系统 userId,由平台人员核对同一人和客户归属;以应用、客户、源用户三者隔离映射,不能按姓名或手机号猜测关联,不能覆盖已有映射或合并账号。不会另建一个 owner,也不自动提权宿主应用。
没有接入应用时可以先独立开户,注册宿主后补映射;同一宿主有多个有效应用时需接入人员明确归属,不能自动猜测。宿主没开放 admin 时提示管理员权限尚未开放,但不阻止开户保存身份关系。内嵌进入仍校验应用权限、客户根和门店映射;未关联门店不会自动落到组织根。
### 开户后由谁维护
-
**客户 owner**
:全客户授权下,在「组织架构」建/改组织,在「患者档案」选择 PAC 编号关联门店;在「员工授权」开户、授予范围、调整岗位。只有 owner 角色但范围不完整,并不等于可以改整棵树。宿主会话还受本次携带范围和应用能力限制。
-
**平台管理员**
:开户时确定宿主、数据空间、客户根;客户详情可查看这些信息。已有客户的数据根不提供随意改绑,变更需要另行核对历史数据与身份。平台人员仍单独负责托管席位。
-
**接入配置人员**
:注册宿主应用及密钥;不同源系统的组织编号不一致时,继续使用受控接入配置,不在本页面猜测编号。现有门店关联选择器依赖 PAC 组织字典,字典未就绪时需先完成数据接入。
本次可视化开户无需新增数据库迁移或环境变量;既有 CLI 可继续使用。
## 开通平台账号
...
...
docs/qa/2026-10-12-platform-onboarding-local.md
0 → 100644
View file @
1b133cc2
# 平台客户开户本地验证
日期:2026-10-12。分支:
`codex/friday-ai-production-fixes`
。
## 范围
平台三步开户:客户与数据来源、首位管理员、核对并开通。支持宿主免登和独立激活。首位 owner 为全客户范围;创建客户不自动开通托管席位或发送消息。本次无新增迁移和环境变量。
## 验证结果
-
后端、前端 TypeScript 检查通过,
`git diff --check`
通过。
-
五组回归共 58 项通过:
`platform-onboarding.integration`
、
`platform-rbac.integration`
、
`platform-admin`
、
`integration-boundary`
、
`permissions`
。
-
开户数据库测试使用本机 PostgreSQL,验证事务回滚、并发防重、跨客户坐标冲突、角色范围、宿主身份与组织映射、激活和重发限制。临时数据按测试专属标识清理。
-
浏览器实测三步表单、PAC 字典为空时的提示与确认、提交成功、客户详情和数据来源展示。界面使用真实前端及开户服务、本地数据库;登录与 PAC 响应由隔离的本地验收入口模拟,不能视为线上免登或 PAC 联调验收。
-
临时验收页面及服务已关闭,临时客户、管理员和应用已清理。
## 发布边界
本次 FridayAI 代码保留本地未提交,未合入 main/test,未部署测试或生产。
Friday SaaS 后端的品牌管理员组织根范围调整另已提交:功能分支
`5080b68`
,合并并推送至
`test@1f80734`
、
`master@408f19b`
;未在本次执行部署。
## 流程调整复验
-
调整后五组回归共 60 项通过,前后端类型检查通过;共享工作树依赖的 Jest 运行使用
`NODE_PATH=apps/ai-service/node_modules`
(绝对路径)。
-
开户移除门店配置和登录方式选择,组织树交给 owner;指定的顶层节点说明文案已从页面删除。
-
首位管理员可选填源系统 userId,也可在员工处补映射;独立登录与宿主内嵌使用同一身份。
-
新客户沿用所属宿主统一权限,旧客户显式策略不变。未开放 admin 的宿主不会因开户而自动提权。
-
本地真实数据库验证先映射再激活、映射后重发激活票、后补映射、跨客户与重复映射拒绝、宿主能力继承、无应用独立开户及回滚。
-
本轮浏览器连接真实本地 AI 服务,验证无需门店/应用选择即可进入管理员步骤,填写源账号映射后可进入核对页;宿主缺少 admin 权限和 PAC 暂无法核验均明确提示。浏览器未提交新客户,未修改测试或生产。
-
本地 3113 前端及 3104 AI 服务保留供用户测试;关闭的仅为首版验收临时入口。
## owner 员工与门店映射复验
-
新增员工(直接建号、通讯录开户)可选填宿主用户 ID;员工详情支持补关联。只允许本客户全范围管理员操作,不能覆盖已有身份、关联本人/平台账号或跨客户账号;不改变员工角色、组织授权及托管状态。
-
映射后仍可使用原独立激活流程;未设置密码且曾发票的账号可重发激活链接,设置密码后拒绝重发。
-
门店沿用组织架构中的患者档案关联入口,并显示宿主组织 ID。候选限定 PAC 返回的本客户组织;替换/取消时清除旧接入策略中的该节点静态映射,免登统一读取最新关联。客户根路由标识保留。
-
六组回归 85 项通过,包含真实本地数据库开户、身份冲突、并发、越权与门店关联替换/取消;前后端类型检查通过。
-
本地后端 watch 只编译而未重新加载旧进程,已单独重启 3104 使本次代码生效。未操作测试或生产。
-
浏览器以临时 owner 登录真实 3113/3104,验证新增员工字段、已有员工补关联确认页和门店坐标展示;没有通过浏览器提交身份授权。门店真实候选依赖本地 PAC 授权和组织字典,当前本地 friday 读取被 PAC 拒绝,不能将本次隔离数据库测试当作真实数据联调通过。
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