Commit d929aaee by luoqi

feat(auth): 诊所名走服务端派生(host.clinicNames),显示不再依赖登录传 dictionary

新增 Host.clinicNames(诊所 id→名),从摄入源派生;/auth/session 合并进
dictionary.clinics 下发(宿主换票传的 dictionary.clinics 优先覆盖,服务端补齐其余)。
前端 loadSession 合并 session.dictionary → visibleClinics 直接显示中文名,GUID 不再裸露。

- manifest 加 clinic_directory: { table, id_field, name_field } 声明诊所名来源
- refresh-clinic-names CLI:按 clinic_directory 从 DW(sql_source)或 CSV(文件源)
  SELECT DISTINCT 出 id→名,幂等 upsert host.clinicNames;可接 cron 定期刷
- jvs-dw 源 = DW fact_emr_treatment_out.organization_name

顺带发现 preset 把 dad2f04a 硬编码成"杭州高德",DW 真名是"欧美中心诊所"——
再证手维护/登录传的名字会过时,派生才是单一真理源。

本地验证:CLI 派生 5 家写入;合并逻辑(登录覆盖+服务端补齐)已测;typecheck 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 0bf6a25c
Pipeline #3347 failed in 0 seconds
......@@ -55,6 +55,14 @@ monitoring:
tenant_id: ruier-grp # 集团 = 租户(静态 slug)
identity_namespace_field: brand # source_unit ← 源 brand 字段(防撞号)
# 诊所名字典来源:DW fact_emr_treatment_out.organization_id → organization_name。
# `pnpm refresh-clinic-names -- --dir=./data/jvs-dw` 据此刷 host.clinicNames(服务端诊所名,
# 不依赖登录传 dictionary)。
clinic_directory:
table: fact_emr_treatment_out
id_field: organization_id
name_field: organization_name
amount_unit: yuan
timezone: Asia/Shanghai
......
......@@ -39,7 +39,8 @@
"sync:test": "ts-node --transpile-only src/cli/sync-test.cli.ts",
"stale-scan": "ts-node --transpile-only src/cli/stale-scan.cli.ts",
"stale-scan:prod": "node dist/cli/stale-scan.cli.js",
"openapi:dump": "ts-node --transpile-only src/cli/dump-openapi.cli.ts"
"openapi:dump": "ts-node --transpile-only src/cli/dump-openapi.cli.ts",
"refresh-clinic-names": "ts-node --transpile-only src/cli/refresh-clinic-names.cli.ts"
},
"prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts"
......
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "clinic_names" JSONB NOT NULL DEFAULT '{}';
......@@ -122,6 +122,12 @@ model Host {
/// 形状: { "瑞尔": "ba67e6cf30dc4f9c9c46adef188bbd04", "瑞泰": "77057aed269f4a14957ae0ad0eff359a" }
orgAliases Json @default("{}") @map("org_aliases")
/// 诊所名字典:诊所 id 中文名。**从摄入数据派生**(DW organization_name ),服务端维护,
/// /auth/session 合并进 dictionary.clinics 下发 —— 前端显示名字不再依赖宿主换票时传 dictionary
/// 宿主换票仍可传 dictionary.clinics 作覆盖(登录传的优先,服务端补齐其余)
/// 形状: { "66701845dd2342e19f9e9f576c4ffe9c": "北京朝阳公园诊所", ... }
clinicNames Json @default("{}") @map("clinic_names")
/// 执行结果回执地址(宿主接收 POST HTTPS endpoint);null = 不启用回执(opt-in)
callbackUrl String? @map("callback_url")
/// 回执验签用的 HMAC 共享密钥;PAC **签名**,故存**原文**(不同于 appSecret 存哈希)
......
/**
* refresh-clinic-names — 从摄入源派生「诊所 id → 名字」写入 host.clinicNames。
*
* /auth/session 会把 host.clinicNames 合并进 dictionary.clinics 下发,
* 前端显示诊所名不再依赖宿主换票时传 dictionary(见 Host.clinicNames 注释)。
*
* 源在 manifest 的 `clinic_directory: { table, id_field, name_field }` 声明:
* - sql_source 模式:SELECT DISTINCT id,name FROM <table>(连 DW,env 覆盖连接)
* - 文件模式:读 tables[] 里对应 <table> 的 CSV/JSON,去重
*
* 用法:
* pnpm refresh-clinic-names -- --dir=./data/jvs-dw [--host=<name 覆盖>]
*
* 幂等:整表 upsert host.clinicNames(全量覆盖为最新派生结果)。可接 cron 定期刷。
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { parse as parseCsv } from 'csv-parse/sync';
import { createClient } from '@clickhouse/client';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ColdImportManifestSchema } from '../modules/sync/cold-import/manifest.schema';
// CLI 是短命进程,不需要 org-tree 启动预热(且会在 app.close() 时跟后台 warmAll 抢连接报噪音)。
process.env.PAC_ORGTREE_WARM_ON_BOOT = 'false';
function flag(name: string): string | undefined {
const hit = process.argv.slice(2).find((a) => a.startsWith(`--${name}=`));
return hit?.slice(name.length + 3);
}
async function deriveFromClickhouse(
source: NonNullable<ReturnType<typeof ColdImportManifestSchema.parse>['sql_source']>,
dir: { table: string; id_field: string; name_field: string },
logger: Logger,
): Promise<Record<string, string>> {
const url = process.env.DW_CLICKHOUSE_URL?.trim() || source.connection.url;
const database = process.env.DW_CLICKHOUSE_DATABASE?.trim() || source.connection.database;
const username = process.env.DW_CLICKHOUSE_USERNAME?.trim() || source.connection.username;
const password = process.env[source.connection.password_env];
if (!password) throw new Error(`ClickHouse 密码缺失:环境变量 ${source.connection.password_env} 未设`);
const client = createClient({ url, database, username, password, request_timeout: 60_000 });
try {
// 表名可能是 queries 的 key(逻辑名)→ 取其真实 FROM 表;否则当真实表名
const realTable = (() => {
const sql = source.queries[dir.table];
const m = sql?.match(/FROM\s+([\w.]+)/i);
return m ? m[1] : dir.table;
})();
const q =
`SELECT ${dir.id_field} AS id, any(${dir.name_field}) AS name ` +
`FROM ${realTable} WHERE notEmpty(${dir.id_field}) AND notEmpty(${dir.name_field}) ` +
`GROUP BY ${dir.id_field}`;
logger.log(`CH 派生: ${q}`);
const rs = await client.query({ query: q, format: 'JSONEachRow' });
const rows = (await rs.json()) as Array<{ id: string; name: string }>;
const map: Record<string, string> = {};
for (const r of rows) if (r.id && r.name) map[String(r.id)] = String(r.name);
return map;
} finally {
await client.close();
}
}
function deriveFromFile(
manifestDir: string,
tables: Array<{ table: string; file: string; format?: 'csv' | 'json' }>,
dir: { table: string; id_field: string; name_field: string },
): Record<string, string> {
const t = tables.find((x) => x.table === dir.table);
if (!t) throw new Error(`clinic_directory.table=${dir.table} 不在 tables[] 里`);
const p = path.resolve(manifestDir, t.file);
const raw = fs.readFileSync(p, 'utf-8');
const rows: Array<Record<string, unknown>> = t.file.toLowerCase().endsWith('.json')
? JSON.parse(raw)
: parseCsv(raw, { columns: true, skip_empty_lines: true, trim: true });
const map: Record<string, string> = {};
for (const r of rows) {
const id = r[dir.id_field];
const name = r[dir.name_field];
if (id != null && String(id) !== '' && name != null && String(name) !== '') {
map[String(id)] = String(name);
}
}
return map;
}
async function main(): Promise<void> {
const logger = new Logger('refresh-clinic-names');
const dirArg = flag('dir');
if (!dirArg) {
logger.error('用法: pnpm refresh-clinic-names -- --dir=./data/<host> [--host=<name>]');
process.exit(1);
}
const manifestDir = path.resolve(process.cwd(), dirArg);
const manifest = ColdImportManifestSchema.parse(
yaml.load(fs.readFileSync(path.join(manifestDir, 'manifest.yaml'), 'utf-8')),
);
const hostName = flag('host') || manifest.host_name;
const cd = manifest.clinic_directory;
if (!cd) {
logger.error(`manifest 未声明 clinic_directory —— 无法派生诊所名`);
process.exit(1);
}
const map = manifest.sql_source
? await deriveFromClickhouse(manifest.sql_source, cd, logger)
: deriveFromFile(manifestDir, manifest.tables ?? [], cd);
const n = Object.keys(map).length;
logger.log(`派生诊所名 ${n} 家`);
if (n === 0) {
logger.warn('派生结果为空,跳过写入(不清空既有 clinicNames)');
process.exit(0);
}
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
try {
const prisma = app.get(PrismaService);
const host = await prisma.host.findFirst({ where: { name: hostName } });
if (!host) throw new Error(`host "${hostName}" 不存在`);
await prisma.host.update({ where: { id: host.id }, data: { clinicNames: map } });
logger.log(`✓ 已写入 host="${hostName}" clinicNames ${n} 家(样例: ${Object.values(map).slice(0, 3).join(' / ')})`);
} finally {
await app.close();
}
}
void main();
......@@ -91,6 +91,18 @@ export class AuthController {
@CurrentUser() user: AccessTokenPayload,
@TenantScope() scope: TenantScopeContext,
) {
// 诊所名字典:服务端派生(host.clinicNames)打底,宿主换票传的 dictionary.clinics 覆盖优先。
// 这样前端显示诊所名不再依赖每次登录都传全 —— 服务端补齐 GUID→中文名,登录传的仅作覆盖。
const serverClinicNames = await this.auth.getClinicNames(user.hostId);
const loginDict = user.dictionary;
const mergedClinics = { ...serverClinicNames, ...(loginDict?.clinics ?? {}) };
const dictionary =
Object.keys(mergedClinics).length > 0 || loginDict?.users
? {
...(Object.keys(mergedClinics).length > 0 ? { clinics: mergedClinics } : {}),
...(loginDict?.users ? { users: loginDict.users } : {}),
}
: undefined;
return {
sub: user.sub,
hostId: user.hostId,
......@@ -100,7 +112,7 @@ export class AuthController {
orgScope: user.orgScope,
clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开
sourceUnits: scope.sourceUnits,
...(user.dictionary ? { dictionary: user.dictionary } : {}),
...(dictionary ? { dictionary } : {}),
// 宿主动作跳转模板 —— 前端"新建预约"等按钮据此打开宿主页(缺失=不渲染/不跳)
actionUrls: await this.auth.getActionUrls(user.hostId),
};
......
......@@ -76,6 +76,15 @@ export class AuthService {
return (host?.actionUrls as Record<string, string | null>) ?? {};
}
/// 服务端诊所名字典(从摄入数据派生,存 host.clinicNames)。session 合并进 dictionary.clinics。
async getClinicNames(hostId: string): Promise<Record<string, string>> {
const host = await this.prisma.host.findUnique({
where: { id: hostId },
select: { clinicNames: true },
});
return (host?.clinicNames as Record<string, string>) ?? {};
}
async exchangeToken(req: TokenExchangeRequest): Promise<TokenExchangeResponse> {
const host = await this.prisma.host.findUnique({ where: { appId: req.appId } });
if (!host || !host.active) {
......
......@@ -167,6 +167,18 @@ export const ColdImportManifestSchema = z
/// ClickHouse SQL 数据源(跟 tables 二选一)
sql_source: ClickHouseSourceSchema.optional(),
/// 诊所名字典来源(可选)—— 声明哪张表的哪两列 = 诊所 id → 名字。
/// `refresh-clinic-names` CLI 据此 SELECT DISTINCT 出 id→名,写 host.clinicNames;
/// /auth/session 再合并进 dictionary.clinics 下发,前端显示名字不依赖登录传。
/// sql_source 模式:table 是 CH 表名(或 queries 的 key);文件模式:table 对应 tables[].table。
clinic_directory: z
.object({
table: z.string().min(1),
id_field: z.string().min(1),
name_field: z.string().min(1),
})
.optional(),
/// **Layer A.5 transforms**(可选)— 在 sources 之后、AssemblerEngine 之前跑。
/// 6 operator 白名单:project / split_json_array / derive / route_by_pattern / pick_first_nonzero / filter。
/// 详见 apps/pac-docs/content/docs/architecture/data-ingestion.mdx §五。
......
......@@ -110,6 +110,8 @@ export const useAuthStore = create<AuthState>()(
clinicIds: s.clinicIds,
sourceUnits: s.sourceUnits,
actionUrls: s.actionUrls,
// session 已把服务端诊所名合并进 dictionary(登录传的优先);无则保留 JWT 里的
dictionary: s.dictionary ?? st.user.dictionary,
},
}
: {},
......
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