Commit 62168f4f by luoqi

feat(tenant): P0-4 查询层品牌隔离(scope.sourceUnits)+ 去写死 UUID

- token/scope 加 sourceUnits(用户所属品牌;空=不限,集团级角色)
- 数据范围在患者粒度按品牌圈:患者搜索 + plan 列表(patient.sourceUnit,覆盖召回池)
- auth mock 预设 tenantId→ruier-grp + sourceUnit;weixin fallback→ruier-grp
- mcp/timeline/weixin 手动 scope 补 sourceUnits

至此 P0 代码完整:3 表 source_unit + 摄入 + 集团 slug + 品牌隔离查询。
本地重摄已验证:external_id 261067 → 王辰(瑞尔)/王文彦(瑞泰)两条独立行,
facts source_unit 全填,tenant=ruier-grp。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 7325beed
...@@ -123,6 +123,7 @@ async function bootstrap() { ...@@ -123,6 +123,7 @@ async function bootstrap() {
hostId: host.id, hostId: host.id,
tenantId: args.tenant, tenantId: args.tenant,
clinicIds: [], // W2 demo:暂不做诊所级过滤(timeline 不读 clinicIds) clinicIds: [], // W2 demo:暂不做诊所级过滤(timeline 不读 clinicIds)
sourceUnits: [], // 不限品牌(CLI 调试上下文)
userId: 'cli', userId: 'cli',
}; };
......
...@@ -4,6 +4,9 @@ export interface TenantScopeContext { ...@@ -4,6 +4,9 @@ export interface TenantScopeContext {
hostId: string; hostId: string;
tenantId: string; tenantId: string;
clinicIds: string[]; clinicIds: string[];
/// 集团模型:用户所属源组织命名空间(品牌)。数据范围在患者粒度按它圈本品牌;
/// 空数组 = 不限品牌(集团级角色 / 单命名空间 host)。
sourceUnits: string[];
userId: string; userId: string;
} }
......
...@@ -23,6 +23,7 @@ export class TenantScopeInterceptor implements NestInterceptor { ...@@ -23,6 +23,7 @@ export class TenantScopeInterceptor implements NestInterceptor {
hostId: req.user.hostId, hostId: req.user.hostId,
tenantId: req.user.tenantId, tenantId: req.user.tenantId,
clinicIds: req.user.clinicIds ?? [], clinicIds: req.user.clinicIds ?? [],
sourceUnits: req.user.sourceUnits ?? [],
userId: req.user.sub, userId: req.user.sub,
}; };
} }
......
...@@ -42,6 +42,7 @@ interface RefreshPayload { ...@@ -42,6 +42,7 @@ interface RefreshPayload {
hostId: string; hostId: string;
tenantId: string; tenantId: string;
clinicIds: string[]; clinicIds: string[];
sourceUnits?: string[];
role: UserRole; role: UserRole;
dictionary?: TokenDictionary; dictionary?: TokenDictionary;
jti: string; jti: string;
...@@ -165,6 +166,7 @@ export class AuthService { ...@@ -165,6 +166,7 @@ export class AuthService {
hostId: payload.hostId, hostId: payload.hostId,
tenantId: payload.tenantId, tenantId: payload.tenantId,
clinicIds: payload.clinicIds, clinicIds: payload.clinicIds,
sourceUnits: payload.sourceUnits,
role: payload.role, role: payload.role,
permissions, permissions,
...(payload.dictionary ? { dictionary: payload.dictionary } : {}), ...(payload.dictionary ? { dictionary: payload.dictionary } : {}),
...@@ -177,6 +179,7 @@ export class AuthService { ...@@ -177,6 +179,7 @@ export class AuthService {
hostId: payload.hostId, hostId: payload.hostId,
tenantId: payload.tenantId, tenantId: payload.tenantId,
clinicIds: payload.clinicIds, clinicIds: payload.clinicIds,
sourceUnits: payload.sourceUnits,
role: payload.role, role: payload.role,
dictionary: payload.dictionary, dictionary: payload.dictionary,
}); });
...@@ -199,6 +202,7 @@ export class AuthService { ...@@ -199,6 +202,7 @@ export class AuthService {
hostId: string; hostId: string;
tenantId: string; tenantId: string;
clinicIds: string[]; clinicIds: string[];
sourceUnits?: string[];
role: UserRole; role: UserRole;
dictionary?: TokenDictionary; dictionary?: TokenDictionary;
}): Promise<string> { }): Promise<string> {
...@@ -227,12 +231,13 @@ export class AuthService { ...@@ -227,12 +231,13 @@ export class AuthService {
// 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true); // 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true);
// 生产真 host 接入后,删 endpoint 或把 env 设 false。 // 生产真 host 接入后,删 endpoint 或把 env 设 false。
/// jvs-dw 两个 brand 的预制 user payload(对齐 manifest.yaml tenant_map) /// jvs-dw 预制 user payload(集团模型:tenant=集团 slug,品牌=source_unit)
/// 来源:`apps/pac-service/data/jvs-dw/manifest.yaml` §"tenant 路由" /// 来源:`apps/pac-service/data/jvs-dw/manifest.yaml` §"集团模型"
/// `docs/deployment-data-ingest.md` §clinic UUID 列表 /// `docs/deployment-data-ingest.md` §clinic UUID 列表
private readonly MOCK_PRESETS = { private readonly MOCK_PRESETS = {
ruier: { ruier: {
tenantId: 'ba67e6cf30dc4f9c9c46adef188bbd04', tenantId: 'ruier-grp', // 集团(= 租户)
sourceUnit: '瑞尔', // 该用户所属品牌(数据范围按它圈本品牌)
tenantNameZh: '瑞尔', tenantNameZh: '瑞尔',
clinics: { clinics: {
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所', '7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
...@@ -241,7 +246,8 @@ export class AuthService { ...@@ -241,7 +246,8 @@ export class AuthService {
}, },
}, },
ruitai: { ruitai: {
tenantId: '77057aed269f4a14957ae0ad0eff359a', tenantId: 'ruier-grp', // 同集团
sourceUnit: '瑞泰',
tenantNameZh: '瑞泰', tenantNameZh: '瑞泰',
clinics: { clinics: {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院', c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
...@@ -309,6 +315,7 @@ export class AuthService { ...@@ -309,6 +315,7 @@ export class AuthService {
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
sourceUnits: [preset.sourceUnit], // 数据范围按本品牌圈(集团内品牌隔离,复刻现状)
role: req.role, role: req.role,
permissions, permissions,
dictionary, dictionary,
...@@ -318,6 +325,7 @@ export class AuthService { ...@@ -318,6 +325,7 @@ export class AuthService {
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
sourceUnits: [preset.sourceUnit],
role: req.role, role: req.role,
dictionary, dictionary,
}); });
......
...@@ -37,6 +37,7 @@ export class McpAuthService { ...@@ -37,6 +37,7 @@ export class McpAuthService {
hostId: payload.hostId, hostId: payload.hostId,
tenantId: payload.tenantId, tenantId: payload.tenantId,
clinicIds: payload.clinicIds ?? [], clinicIds: payload.clinicIds ?? [],
sourceUnits: payload.sourceUnits ?? [],
userId: payload.sub, userId: payload.sub,
}, },
permissions: payload.permissions ?? [], permissions: payload.permissions ?? [],
......
...@@ -52,6 +52,8 @@ export class PatientService { ...@@ -52,6 +52,8 @@ export class PatientService {
where: { where: {
hostId: scope.hostId, hostId: scope.hostId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
// 集团模型:数据范围在患者粒度按品牌圈(sourceUnits 空 = 不限,集团级角色)
...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
OR: [ OR: [
{ name: { contains: q, mode: 'insensitive' } }, { name: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q } }, { phone: { contains: q } },
......
...@@ -147,6 +147,11 @@ export class PlanService { ...@@ -147,6 +147,11 @@ export class PlanService {
hostId: scope.hostId, hostId: scope.hostId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
supersededAt: null, supersededAt: null,
// 集团模型:数据范围按品牌圈(plan→patient.sourceUnit);覆盖召回池 NULL-clinic 工单。
// sourceUnits 空 = 不限品牌(集团级角色)。
...(scope.sourceUnits.length
? { patient: { sourceUnit: { in: scope.sourceUnits } } }
: {}),
}; };
// view 基础过滤 // view 基础过滤
......
...@@ -63,7 +63,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy { ...@@ -63,7 +63,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
private readonly hostName = process.env.PAC_WX_AIBOT_HOST?.trim() || 'jvs-dw'; private readonly hostName = process.env.PAC_WX_AIBOT_HOST?.trim() || 'jvs-dw';
// PoC 默认租户 = 瑞尔(ruier);可用 env 覆盖。生产从 企微userid→客服 映射来。 // PoC 默认租户 = 瑞尔(ruier);可用 env 覆盖。生产从 企微userid→客服 映射来。
private readonly tenantId = private readonly tenantId =
process.env.PAC_WX_AIBOT_TENANT_ID?.trim() || 'ba67e6cf30dc4f9c9c46adef188bbd04'; process.env.PAC_WX_AIBOT_TENANT_ID?.trim() || 'ruier-grp';
constructor( constructor(
private readonly auth: AuthService, private readonly auth: AuthService,
...@@ -300,7 +300,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy { ...@@ -300,7 +300,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
/** 默认客服 scope(PoC:统一 host/tenant;clicker 当 operatorUserId)。 */ /** 默认客服 scope(PoC:统一 host/tenant;clicker 当 operatorUserId)。 */
private async defaultScope(wxUserid: string): Promise<TenantScopeContext> { private async defaultScope(wxUserid: string): Promise<TenantScopeContext> {
const hostId = await this.resolveHostId(); const hostId = await this.resolveHostId();
return { hostId, tenantId: this.tenantId, clinicIds: [], userId: `wx:${wxUserid}` }; return { hostId, tenantId: this.tenantId, clinicIds: [], sourceUnits: [], userId: `wx:${wxUserid}` };
} }
/** 取会话历史副本(过期 / 不存在 → 空)。返回副本,避免本轮失败污染已存状态。 */ /** 取会话历史副本(过期 / 不存在 → 空)。返回副本,避免本轮失败污染已存状态。 */
......
...@@ -154,6 +154,9 @@ export const AccessTokenPayloadSchema = z.object({ ...@@ -154,6 +154,9 @@ export const AccessTokenPayloadSchema = z.object({
hostId: z.string(), hostId: z.string(),
tenantId: z.string(), tenantId: z.string(),
clinicIds: z.array(z.string()), clinicIds: z.array(z.string()),
/// 集团模型:用户所属源组织命名空间(品牌)。数据范围在患者粒度按它圈本品牌;
/// 空数组 = 不限品牌(集团级角色 / 单命名空间 host)。缺省视为不限(向后兼容老 token)。
sourceUnits: z.array(z.string()).optional(),
role: UserRoleSchema, role: UserRoleSchema,
permissions: z.array(z.string()), permissions: z.array(z.string()),
/// 可选名字典(clinics/users id→名),host 换票时带,前端 UI 用于把 UUID 显示成名字。 /// 可选名字典(clinics/users id→名),host 换票时带,前端 UI 用于把 UUID 显示成名字。
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment