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() {
hostId: host.id,
tenantId: args.tenant,
clinicIds: [], // W2 demo:暂不做诊所级过滤(timeline 不读 clinicIds)
sourceUnits: [], // 不限品牌(CLI 调试上下文)
userId: 'cli',
};
......
......@@ -4,6 +4,9 @@ export interface TenantScopeContext {
hostId: string;
tenantId: string;
clinicIds: string[];
/// 集团模型:用户所属源组织命名空间(品牌)。数据范围在患者粒度按它圈本品牌;
/// 空数组 = 不限品牌(集团级角色 / 单命名空间 host)。
sourceUnits: string[];
userId: string;
}
......
......@@ -23,6 +23,7 @@ export class TenantScopeInterceptor implements NestInterceptor {
hostId: req.user.hostId,
tenantId: req.user.tenantId,
clinicIds: req.user.clinicIds ?? [],
sourceUnits: req.user.sourceUnits ?? [],
userId: req.user.sub,
};
}
......
......@@ -42,6 +42,7 @@ interface RefreshPayload {
hostId: string;
tenantId: string;
clinicIds: string[];
sourceUnits?: string[];
role: UserRole;
dictionary?: TokenDictionary;
jti: string;
......@@ -165,6 +166,7 @@ export class AuthService {
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds,
sourceUnits: payload.sourceUnits,
role: payload.role,
permissions,
...(payload.dictionary ? { dictionary: payload.dictionary } : {}),
......@@ -177,6 +179,7 @@ export class AuthService {
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds,
sourceUnits: payload.sourceUnits,
role: payload.role,
dictionary: payload.dictionary,
});
......@@ -199,6 +202,7 @@ export class AuthService {
hostId: string;
tenantId: string;
clinicIds: string[];
sourceUnits?: string[];
role: UserRole;
dictionary?: TokenDictionary;
}): Promise<string> {
......@@ -227,12 +231,13 @@ export class AuthService {
// 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true);
// 生产真 host 接入后,删 endpoint 或把 env 设 false。
/// jvs-dw 两个 brand 的预制 user payload(对齐 manifest.yaml tenant_map)
/// 来源:`apps/pac-service/data/jvs-dw/manifest.yaml` §"tenant 路由"
/// jvs-dw 预制 user payload(集团模型:tenant=集团 slug,品牌=source_unit)
/// 来源:`apps/pac-service/data/jvs-dw/manifest.yaml` §"集团模型"
/// `docs/deployment-data-ingest.md` §clinic UUID 列表
private readonly MOCK_PRESETS = {
ruier: {
tenantId: 'ba67e6cf30dc4f9c9c46adef188bbd04',
tenantId: 'ruier-grp', // 集团(= 租户)
sourceUnit: '瑞尔', // 该用户所属品牌(数据范围按它圈本品牌)
tenantNameZh: '瑞尔',
clinics: {
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
......@@ -241,7 +246,8 @@ export class AuthService {
},
},
ruitai: {
tenantId: '77057aed269f4a14957ae0ad0eff359a',
tenantId: 'ruier-grp', // 同集团
sourceUnit: '瑞泰',
tenantNameZh: '瑞泰',
clinics: {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
......@@ -309,6 +315,7 @@ export class AuthService {
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
sourceUnits: [preset.sourceUnit], // 数据范围按本品牌圈(集团内品牌隔离,复刻现状)
role: req.role,
permissions,
dictionary,
......@@ -318,6 +325,7 @@ export class AuthService {
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
sourceUnits: [preset.sourceUnit],
role: req.role,
dictionary,
});
......
......@@ -37,6 +37,7 @@ export class McpAuthService {
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds ?? [],
sourceUnits: payload.sourceUnits ?? [],
userId: payload.sub,
},
permissions: payload.permissions ?? [],
......
......@@ -52,6 +52,8 @@ export class PatientService {
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
// 集团模型:数据范围在患者粒度按品牌圈(sourceUnits 空 = 不限,集团级角色)
...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q } },
......
......@@ -147,6 +147,11 @@ export class PlanService {
hostId: scope.hostId,
tenantId: scope.tenantId,
supersededAt: null,
// 集团模型:数据范围按品牌圈(plan→patient.sourceUnit);覆盖召回池 NULL-clinic 工单。
// sourceUnits 空 = 不限品牌(集团级角色)。
...(scope.sourceUnits.length
? { patient: { sourceUnit: { in: scope.sourceUnits } } }
: {}),
};
// view 基础过滤
......
......@@ -63,7 +63,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
private readonly hostName = process.env.PAC_WX_AIBOT_HOST?.trim() || 'jvs-dw';
// PoC 默认租户 = 瑞尔(ruier);可用 env 覆盖。生产从 企微userid→客服 映射来。
private readonly tenantId =
process.env.PAC_WX_AIBOT_TENANT_ID?.trim() || 'ba67e6cf30dc4f9c9c46adef188bbd04';
process.env.PAC_WX_AIBOT_TENANT_ID?.trim() || 'ruier-grp';
constructor(
private readonly auth: AuthService,
......@@ -300,7 +300,7 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
/** 默认客服 scope(PoC:统一 host/tenant;clicker 当 operatorUserId)。 */
private async defaultScope(wxUserid: string): Promise<TenantScopeContext> {
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({
hostId: z.string(),
tenantId: z.string(),
clinicIds: z.array(z.string()),
/// 集团模型:用户所属源组织命名空间(品牌)。数据范围在患者粒度按它圈本品牌;
/// 空数组 = 不限品牌(集团级角色 / 单命名空间 host)。缺省视为不限(向后兼容老 token)。
sourceUnits: z.array(z.string()).optional(),
role: UserRoleSchema,
permissions: z.array(z.string()),
/// 可选名字典(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