2026-09-29 10:59:34 +08:00
/ * *
* 身 份 上 下 文 ( 任 务 A4 . 1 ) : 平 台 用 户 → 各 业 务 系 统 账 号 的 绑 定 。
*
* 两 条 边 界 ( 架 构 设 计 4.1 ) :
* - 平 台 只 记 「 这 个 平 台 用 户 在 某 个 系 统 里 是 谁 」 , 不 复 制 业 务 系 统 的 角 色 与 权 限 ( D9 : 权 限 归 业 务 系 统 )
* - 每 个 系 统 一 条 绑 定 、 各 自 独 立 的 凭 据 ; 一 个 系 统 登 录 态 失 效 不 影 响 另 一 个 系 统
*
* 未 绑 定 = 平 台 不 知 道 该 以 谁 的 身 份 调 用 该 系 统 , 策 略 层 默 认 拒 绝 , 不 做 「 拿 服 务 账 号 顶 替 」 这 类 隐 式 降 级 。
* /
import type { Database } from '../storage/db.js' ;
export interface IdentitySubject {
userId : string ;
tenantId : string ;
}
export interface AccountBinding {
userId : string ;
tenantId : string ;
providerId : string ;
/** 该用户在业务系统里的账号标识(工号、登录名等),不含密码 */
accountId : string ;
displayName : string ;
2026-09-30 14:26:35 +08:00
/** active = 可用; expired = 登录态失效需重新绑定; disabled = 已停用。后两者都拒绝,但提示不同 */
status : 'active' | 'expired' | 'disabled' ;
2026-09-29 10:59:34 +08:00
createdAt : number ;
updatedAt : number ;
}
export interface BindInput {
userId : string ;
tenantId : string ;
providerId : string ;
accountId : string ;
displayName? : string ;
status? : AccountBinding [ 'status' ] ;
}
interface BindingRow {
user_id : string ;
tenant_id : string ;
provider_id : string ;
account_id : string ;
display_name : string ;
status : string ;
created_at : number ;
updated_at : number ;
}
const toBinding = ( row : BindingRow ) : AccountBinding = > ( {
userId : row.user_id ,
tenantId : row.tenant_id ,
providerId : row.provider_id ,
accountId : row.account_id ,
displayName : row.display_name ,
2026-09-30 14:26:35 +08:00
status : row.status === 'disabled' ? 'disabled' : row . status === 'expired' ? 'expired' : 'active' ,
2026-09-29 10:59:34 +08:00
createdAt : row.created_at ,
updatedAt : row.updated_at ,
} ) ;
export class IdentityContext {
/** 没有数据库时的内存实现:开发与单测用,语义与落库版一致 */
private readonly memory = new Map < string , AccountBinding > ( ) ;
constructor (
private readonly db? : Database ,
private readonly now : ( ) = > number = Date . now ,
) { }
static key ( subject : IdentitySubject , providerId : string ) : string {
return ` ${ subject . tenantId } : ${ subject . userId } : ${ providerId } ` ;
}
/** 绑定或更新一个系统账号( A5 起由 APS 侧的账号绑定流程调用) */
bind ( input : BindInput ) : AccountBinding {
const at = this . now ( ) ;
const existing = this . resolve ( input , input . providerId ) ;
const binding : AccountBinding = {
userId : input.userId ,
tenantId : input.tenantId ,
providerId : input.providerId ,
accountId : input.accountId ,
displayName : input.displayName ? ? input . accountId ,
status : input.status ? ? 'active' ,
createdAt : existing?.createdAt ? ? at ,
updatedAt : at ,
} ;
if ( this . db ) {
this . db
. prepare (
` insert into identity_bindings (user_id, tenant_id, provider_id, account_id, display_name, status, credential_ref, credential_iv, credential_ciphertext, credential_tag, created_at, updated_at)
values ( ? , ? , ? , ? , ? , ? , null , null , null , null , ? , ? )
on conflict ( user_id , tenant_id , provider_id ) do update set
account_id = excluded . account_id ,
display_name = excluded . display_name ,
status = excluded . status ,
updated_at = excluded . updated_at ` ,
)
. run ( binding . userId , binding . tenantId , binding . providerId , binding . accountId , binding . displayName , binding . status , binding . createdAt , binding . updatedAt ) ;
} else {
this . memory . set ( IdentityContext . key ( binding , binding . providerId ) , binding ) ;
}
return binding ;
}
unbind ( subject : IdentitySubject , providerId : string ) : boolean {
if ( this . db ) {
const before = this . db
. prepare ( 'select count(*) as n from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?' )
. get ( subject . userId , subject . tenantId , providerId ) as { n : number } | undefined ;
this . db
. prepare ( 'delete from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?' )
. run ( subject . userId , subject . tenantId , providerId ) ;
return ( before ? . n ? ? 0 ) > 0 ;
}
return this . memory . delete ( IdentityContext . key ( subject , providerId ) ) ;
}
/** 停用:只影响这一个系统,其他系统的登录态与绑定不动 */
disable ( subject : IdentitySubject , providerId : string ) : AccountBinding | undefined {
2026-09-30 14:26:35 +08:00
return this . setStatus ( subject , providerId , 'disabled' ) ;
}
/ * *
* 登 录 态 失 效 : 业 务 系 统 拒 绝 了 这 次 调 用 ( 401 / 403 ) , 账 号 映 射 还 在 但 凭 据 不 能 用 了 。
* 标 成 expired 之 后 策 略 层 会 直 接 拒 — — 不 再 拿 旧 凭 据 反 复 去 撞 系 统 。
* /
expire ( subject : IdentitySubject , providerId : string ) : AccountBinding | undefined {
return this . setStatus ( subject , providerId , 'expired' ) ;
}
private setStatus ( subject : IdentitySubject , providerId : string , status : AccountBinding [ 'status' ] ) : AccountBinding | undefined {
2026-09-29 10:59:34 +08:00
const existing = this . resolve ( subject , providerId ) ;
if ( ! existing ) return undefined ;
2026-09-30 14:26:35 +08:00
return this . bind ( { . . . existing , status } ) ;
2026-09-29 10:59:34 +08:00
}
resolve ( subject : IdentitySubject , providerId : string ) : AccountBinding | undefined {
if ( this . db ) {
const row = this . db
. prepare ( 'select user_id, tenant_id, provider_id, account_id, display_name, status, created_at, updated_at from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?' )
. get ( subject . userId , subject . tenantId , providerId ) as BindingRow | undefined ;
return row ? toBinding ( row ) : undefined ;
}
return this . memory . get ( IdentityContext . key ( subject , providerId ) ) ;
}
list ( subject : IdentitySubject ) : AccountBinding [ ] {
if ( this . db ) {
const rows = this . db
. prepare ( 'select user_id, tenant_id, provider_id, account_id, display_name, status, created_at, updated_at from identity_bindings where user_id = ? and tenant_id = ? order by provider_id' )
. all ( subject . userId , subject . tenantId ) as unknown as BindingRow [ ] ;
return rows . map ( toBinding ) ;
}
return [ . . . this . memory . values ( ) ]
. filter ( ( binding ) = > binding . userId === subject . userId && binding . tenantId === subject . tenantId )
. sort ( ( a , b ) = > a . providerId . localeCompare ( b . providerId ) ) ;
}
}
/** 平台用户到某个系统账号的映射结果,注入给 Provider( 不含任何凭据) */
export interface ResolvedIdentity {
accountId : string ;
displayName : string ;
}
export function resolvedIdentity ( binding : AccountBinding | undefined ) : ResolvedIdentity | undefined {
return binding ? { accountId : binding.accountId , displayName : binding.displayName } : undefined ;
}