Commit a3cbc827 by luoqi

perf(auth): orgScope 展开冷启动慢 —— 空 scope 短路 + 树缓存单飞/TTL

tenant-group 重构后每请求都派生 org 树(全扫 patient_transactions 数十万行)解析用户 scope,
冷缓存时登录长时间卡(首次慢、之后正常)。修:
- 空 orgScope(全集团用户,最常见)直接短路返回不限,不派生树(400ms→50ms)
- 非空 scope 仍需树:getTree 加单飞(并发 miss 共享一次,防惊群)+ TTL 10min→6h(org 数据日更)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 66876937
Pipeline #3333 failed in 0 seconds
......@@ -23,10 +23,15 @@ export class OrgTreeService {
constructor(private readonly prisma: PrismaService) {}
private readonly cache = new Map<string, { tree: OrgTree; at: number }>();
private readonly ttlMs = 10 * 60 * 1000; // 10min
/** 单飞:并发 miss 共享同一次派生,防惊群 —— 冷启动 bootstrap(loadSession + plans 列表)
* 多个请求同时 miss 时,原本各自全扫一遍 patient_transactions(数十万行)导致登录长时间卡。 */
private readonly inflight = new Map<string, Promise<OrgTree>>();
// org 结构随摄入数据日更(仅新诊所/品牌才变),不必频繁重派生;10min 太紧会让"空闲后再打开"每次重扫。
// TTL 内出现的新 ref 由 expandScope 的 forceRefresh 兜(见下),故拉长到 6h 安全。
private readonly ttlMs = 6 * 60 * 60 * 1000;
private readonly refreshMinMs = 30 * 1000; // 强制重建防抖:30s 内不重复查(挡 bogus ref 刷库)
/** 取 host org 树(缓存;miss/过期或 forceRefresh 时从摄入数据派生)。 */
/** 取 host org 树(缓存;miss/过期或 forceRefresh 时从摄入数据派生;并发单飞)。 */
async getTree(hostId: string, tenantId: string, forceRefresh = false): Promise<OrgTree> {
const key = `${hostId}:${tenantId}`;
const hit = this.cache.get(key);
......@@ -34,7 +39,22 @@ export class OrgTreeService {
if (hit && !forceRefresh && age < this.ttlMs) return hit.tree;
if (hit && forceRefresh && age < this.refreshMinMs) return hit.tree; // 防抖
// 从摄入数据派生「品牌(source_unit)→ 诊所(clinic_id)」:
// 单飞:已有在途派生 → 复用,不再各自全扫(冷并发只跑 1 次)。
const inflight = this.inflight.get(key);
if (inflight) return inflight;
const p = this.deriveTree(hostId, tenantId)
.then((tree) => {
this.cache.set(key, { tree, at: Date.now() });
return tree;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, p);
return p;
}
/** 从摄入数据派生「品牌(source_unit)→ 诊所(clinic_id)」org 树(零硬编码)。 */
private async deriveTree(hostId: string, tenantId: string): Promise<OrgTree> {
// clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。
// 显式 host+tenant 过滤(此处在 ALS 之前跑,纵深防御扩展不兜,自带条件)。
const rows = await this.prisma.$queryRaw<{ source_unit: string; clinic_id: string }[]>`
......@@ -48,12 +68,9 @@ export class OrgTreeService {
for (const r of rows) {
(brandClinics[r.source_unit] ??= []).push(r.clinic_id);
}
const tree =
Object.keys(brandClinics).length > 0
return Object.keys(brandClinics).length > 0
? buildOrgTree(tenantId, brandClinics)
: buildTree({ id: tenantId }); // 无数据 → 退化树
this.cache.set(key, { tree, at: Date.now() });
return tree;
}
/** 把 orgScope(任意层级 org 节点)展开成内部过滤维 { sourceUnits, clinicIds }。 */
......@@ -62,6 +79,9 @@ export class OrgTreeService {
tenantId: string,
orgScope: string[],
): Promise<ExpandedScope> {
// 空 orgScope = 集团级不限(admin / 全集团用户,最常见)—— 结果恒为 {[],[]},
// 无需派生 org 树。短路掉,避免这类用户每次(尤其冷启动)白扫 patient_transactions。
if (orgScope.length === 0) return { sourceUnits: [], clinicIds: [] };
let r = expandOrgScope(await this.getTree(hostId, tenantId), orgScope);
// 有 ref 树里找不到 → 树缓存可能陈旧(新诊所/品牌刚摄入),强制重派生一次再展开,
// 既避免误拒合法新诊所用户,也避免陈旧退化树把品牌制用户 fail-open(见 org-scope 的 DENY)。
......
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