Commit ec5fb5e5 by luoqi

merge: fix/ruitai-host-domain → main(瑞泰患者跳 rytime 门户)

parents 8f79c94e eb5d8b9f
Pipeline #3593 failed in 0 seconds
......@@ -78,6 +78,8 @@ export class PlanAggregateService {
select: {
id: true,
externalId: true,
// 关系人可能跨品牌(概率低但存在)—— 判宿主域用,见 web 的 host-brand-url
sourceUnit: true,
medicalRecordNumber: true,
name: true,
phone: true,
......@@ -152,6 +154,7 @@ export class PlanAggregateService {
select: {
id: true;
externalId: true;
sourceUnit: true;
medicalRecordNumber: true;
name: true;
phone: true;
......@@ -410,6 +413,7 @@ function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null
function serializePatient(patient: {
id: string;
externalId: string;
sourceUnit: string | null;
medicalRecordNumber: string | null;
name: string | null;
gender: string | null;
......@@ -423,6 +427,15 @@ function serializePatient(patient: {
return {
id: patient.id,
externalId: patient.externalId,
/**
* 品牌名(`patients.source_unit`,如「瑞尔」/「瑞泰」)—— **前端按它判宿主域**。
*
* ⚠️ 与 `brandId` 分工:`brandId` 是品牌 GUID(经 host.orgAliases 反解),给跳转链接的
* `{brandId}` 占位用;要**判断**是哪个品牌得用这个名字 —— 拿 GUID 判等于在前端
* 写死一串 uuid,换个环境就不对了。
* ⚠️ 宿主没有品牌概念时为 null(单品牌宿主),前端据此不做任何替换。
*/
sourceUnit: patient.sourceUnit,
/// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id)
medicalRecordNumber: patient.medicalRecordNumber,
name: patient.name,
......@@ -456,6 +469,8 @@ function serializeProfile(
relatedPatient: {
id: string;
externalId: string;
/// 关系人自己的品牌 —— 判宿主域用(亲属可能跨品牌),见 web 的 host-brand-url
sourceUnit: string | null;
medicalRecordNumber: string | null;
name: string | null;
phone: string | null;
......@@ -532,6 +547,8 @@ function serializeProfile(
// 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
externalId: r.relatedPatient?.externalId ?? r.relatedExternalId,
medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null,
/// 关系人自己的品牌 —— ⛔ 别拿本人的顶替:亲属挂在别的品牌时链接会跳错门户
sourceUnit: r.relatedPatient?.sourceUnit ?? null,
planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null,
};
})
......
......@@ -40,6 +40,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
externalId: real.patient.externalId,
medicalRecordNumber: real.patient.medicalRecordNumber ?? null,
brandId: real.patient.brandId ?? null, // 品牌 GUID(EMR 跳转 {brandId} 用)
sourceUnit: real.patient.sourceUnit ?? null, // 品牌名 —— 判宿主域用(见 lib/host-brand-url)
dedicatedCs: real.patient.dedicatedCs ?? null,
name: real.patient.name ?? '(未知)',
nameMasked: real.patient.nameMasked ?? '*',
......
......@@ -33,6 +33,7 @@ export const mockPatient = {
externalId: '5i5ya_PA00284917',
medicalRecordNumber: 'SH0Q011691' as string | null,
brandId: null as string | null, // 品牌 GUID(EMR 跳转 {brandId} 用)
sourceUnit: null as string | null, // 品牌名(判宿主域用,见 lib/host-brand-url)
dedicatedCs: { id: '5499', name: '姜莹' } as { id: string; name: string } | null,
name: '张志远',
nameMasked: '张志*',
......@@ -67,6 +68,7 @@ export const mockPatient = {
planId: null,
externalId: '5i5ya_PA00999001',
medicalRecordNumber: 'SH0Q099001',
sourceUnit: null,
relationshipRaw: 'mother',
ageCorrected: false,
},
......@@ -85,6 +87,8 @@ export const mockPatient = {
/** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */
externalId: string;
medicalRecordNumber: string | null;
/** 关系人**自己**的品牌名 —— 判宿主域用(见 lib/host-brand-url) */
sourceUnit: string | null;
/** 源数据原样关系码 + 是否被年龄纠正过 */
relationshipRaw: string;
ageCorrected: boolean;
......
......@@ -486,7 +486,12 @@ export function PlanDetailApp({
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => {
if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload());
openHostAction(
'OPEN_POTENTIAL_TREATMENT',
hostActionCtx,
potentialTreatmentPayload(),
patient.sourceUnit,
);
}
: undefined;
/**
......@@ -506,7 +511,7 @@ export function PlanDetailApp({
abandonReasons: [],
});
if (!ok) return false;
openHostAction('OPEN_RETURN_VISIT', hostActionCtx);
openHostAction('OPEN_RETURN_VISIT', hostActionCtx, undefined, patient.sourceUnit);
return true;
}
: undefined;
......@@ -573,7 +578,7 @@ export function PlanDetailApp({
brandId: patient.brandId,
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
});
}, patient.sourceUnit);
if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return false;
......@@ -728,7 +733,7 @@ export function PlanDetailApp({
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
patientId: patient.externalId,
})}
}, patient.sourceUnit)}
/>
{/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && (
......@@ -1654,10 +1659,11 @@ function IdentityCard({
};
// 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行:
// PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。
const originalArchiveUrl = resolveActionUrl('VIEW_PATIENT', {
patientId: patient.externalId,
medicalRecordNumber: patient.medicalRecordNumber,
});
const originalArchiveUrl = resolveActionUrl(
'VIEW_PATIENT',
{ patientId: patient.externalId, medicalRecordNumber: patient.medicalRecordNumber },
patient.sourceUnit,
);
return (
<section className="bg-white rounded-lg border shadow-sm flex-none">
<div className="p-3 flex items-start gap-2.5">
......@@ -1919,10 +1925,12 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont
<span className="ml-auto flex-none">
{(() => {
// 宿主档案优先,PAC 工单页兜底(见组件头注释)
const hostUrl = resolveActionUrl('VIEW_PATIENT', {
patientId: c.externalId,
medicalRecordNumber: c.medicalRecordNumber,
});
const hostUrl = resolveActionUrl(
'VIEW_PATIENT',
{ patientId: c.externalId, medicalRecordNumber: c.medicalRecordNumber },
// ⛔ 用关系人自己的品牌,不是本人的:亲属挂在别的品牌时会跳错门户
c.sourceUnit,
);
const url = hostUrl ?? (c.planId ? `/plans/${c.planId}` : null);
if (!url) {
return (
......
......@@ -14,6 +14,9 @@ export type PlanDetailData = {
medicalRecordNumber: string | null;
/// 品牌 GUID(source_unit 经 host.orgAliases 反解)—— EMR 跳转链接 {brandId} 用;无别名则 null
brandId: string | null;
/// 品牌**名**(「瑞尔」/「瑞泰」)—— 判宿主域用(见 lib/host-brand-url)。⛔ 别拿 brandId 判:
/// 那是 uuid,在前端写死一串 uuid 换个环境就不对了。单品牌宿主为 null。
sourceUnit: string | null;
dedicatedCs: { id: string; name: string } | null;
name: string | null;
nameMasked: string | null;
......@@ -54,6 +57,8 @@ export type PlanDetailData = {
/// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案)
externalId: string;
medicalRecordNumber: string | null;
/// 关系人**自己**的品牌名 —— 判宿主域用。⛔ 别拿本人的顶替(亲属可能跨品牌)
sourceUnit: string | null;
/// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过
relationshipRaw: string;
/// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文
......
'use client';
import { useAuthStore } from '@/stores/auth-store';
import { applyBrandHost } from '@/lib/host-brand-url';
import type { HostActionKey } from '@pac/types';
/**
......@@ -33,9 +34,15 @@ export function actionTemplate(key: HostActionKey): string | undefined {
export function resolveActionUrl(
key: HostActionKey,
ctx: Record<string, string | null | undefined>,
/**
* 患者所属品牌(`patient.sourceUnit`)—— 用于把宿主域换成该品牌那套(见 host-brand-url)。
* ⚠️ 不传 = 不换。⛔ 别把它塞进 `ctx`:ctx 是**占位符**字典,模板里并没有 {sourceUnit}
* 这个占位,混在一起会让人以为可以在模板里写它。
*/
sourceUnit?: string | null,
): string | undefined {
const tpl = actionTemplate(key);
return tpl ? fillActionUrl(tpl, ctx) : undefined;
return tpl ? applyBrandHost(fillActionUrl(tpl, ctx), sourceUnit) : undefined;
}
/**
......
import { describe, expect, test } from 'vitest';
import { applyBrandHost } from './host-brand-url';
/**
* 品牌 → 宿主域替换。判据是**患者**的 `sourceUnit`(不是登录人 —— 见 host-brand-url 头注:
* 诊所级用户的 sourceUnits 是哨兵 `__pac_deny_no_brand__`,判不出品牌)。
*/
describe('瑞泰患者:arrail → rytime', () => {
test('⭐⭐ 普通跳转链接换域名', () => {
expect(
applyBrandHost('https://arrail.5i5ya.com/customerInfo?userId=123&fileNumber=SH01', '瑞泰'),
).toBe('https://rytime.5i5ya.com/customerInfo?userId=123&fileNumber=SH01');
});
test('⭐⭐ HOST_ORIGIN(postMessage 的 targetOrigin)一样要换', () => {
// 🔴 不换的话浏览器**静默丢弃**这条消息:按钮点了没反应,控制台连个错都没有
expect(applyBrandHost('https://arrail.5i5ya.com', '瑞泰')).toBe('https://rytime.5i5ya.com/');
});
test('瑞尔患者原样不动', () => {
const u = 'https://arrail.5i5ya.com/followup/create/1?organizationId=c1';
expect(applyBrandHost(u, '瑞尔')).toBe(u);
});
test('已经是 rytime 的再换一次也不变(幂等)', () => {
const u = 'https://rytime.5i5ya.com/customerInfo?userId=1';
expect(applyBrandHost(u, '瑞泰')).toBe(u);
});
});
describe('⛔ 不许越界', () => {
test('⭐⭐ 只换域名,查询串里的同名参数不动', () => {
expect(applyBrandHost('https://arrail.5i5ya.com/x?from=arrail', '瑞泰')).toBe(
'https://rytime.5i5ya.com/x?from=arrail',
);
});
test('⭐ 路径里的同名片段不动', () => {
expect(applyBrandHost('https://arrail.5i5ya.com/arrail/page', '瑞泰')).toBe(
'https://rytime.5i5ya.com/arrail/page',
);
});
test('哨兵值 postMessage 不是 URL —— 原样返回,⛔ 别把它弄坏', () => {
expect(applyBrandHost('postMessage', '瑞泰')).toBe('postMessage');
});
test('相对路径没有域名可换', () => {
expect(applyBrandHost('/emr/full?tenantId=x', '瑞泰')).toBe('/emr/full?tenantId=x');
});
});
describe('拿不准就别动 —— ⛔ 这条补丁不许把能用的链接变没', () => {
test('品牌为空(单品牌宿主)', () => {
const u = 'https://arrail.5i5ya.com/x';
expect(applyBrandHost(u, null)).toBe(u);
expect(applyBrandHost(u, undefined)).toBe(u);
});
test('不认得的品牌', () => {
const u = 'https://arrail.5i5ya.com/x';
expect(applyBrandHost(u, '某新品牌')).toBe(u);
});
test('域名里没有任何已知品牌片段', () => {
const u = 'https://pac.friday.tech/x';
expect(applyBrandHost(u, '瑞泰')).toBe(u);
});
test('url 本身为空', () => {
expect(applyBrandHost(undefined, '瑞泰')).toBeUndefined();
expect(applyBrandHost('', '瑞泰')).toBe('');
});
});
/**
* 🔴 **每个宿主跳转的调用点都必须带上品牌参数** —— 漏一个不会报错(参数可选),
* 只会让瑞泰患者在那个按钮上被带去瑞尔门户,查不到人。tsc 拦不住,只能在这里锁。
* ⚠️ 判据是**源码**:这条测不了运行时,它防的是"新加一个调用点时忘了传"。
*/
describe('调用点不许漏传品牌', () => {
const readSrc = (p: string) =>
// eslint-disable-next-line @typescript-eslint/no-var-requires
(require('node:fs') as typeof import('node:fs')).readFileSync(
(require('node:path') as typeof import('node:path')).join(__dirname, p),
'utf8',
);
test('⭐⭐ plan-detail-app 里每个 resolveActionUrl / openHostAction 都带 sourceUnit', () => {
const src = readSrc('../components/plan-detail/plan-detail-app.tsx');
// 每个调用的右括号之前必须出现 sourceUnit
const calls = src.split(/resolveActionUrl\(|openHostAction\(/).slice(1);
expect(calls.length).toBeGreaterThanOrEqual(6); // 现有 4 + 2;新增的也会被本条覆盖
for (const [i, c] of calls.entries()) {
// 取到该调用的结束(下一个 `);` 或 `)}`),比对是否带了品牌
const end = Math.min(...[c.indexOf(');'), c.indexOf(')}')].filter((x) => x >= 0));
expect(c.slice(0, end)).toMatch(/sourceUnit/);
expect(i).toBeGreaterThanOrEqual(0);
}
});
test('⭐ 两条入口本身接得住品牌参数', () => {
expect(readSrc('./action-url.ts')).toMatch(/sourceUnit\?: string \| null/);
expect(readSrc('./host-message.ts')).toMatch(/sourceUnit\?: string \| null/);
// postMessage 的 targetOrigin 也走了替换
expect(readSrc('./host-message.ts')).toContain('applyBrandHost(hostOrigin(), sourceUnit)');
});
});
'use client';
/**
* 品牌 → 宿主域替换(**特殊处理**,不是通用机制)。
*
* ── 为什么需要 ────────────────────────────────────────────────
* jvs-dw 这个宿主底下有两个品牌,各有一套自己的门户:
* 瑞尔 → arrail.5i5ya.com
* 瑞泰 → rytime.5i5ya.com
* 两边**只差域名这一段**,路径、参数、占位符完全一样。而 `host.actionUrls` 是
* **host 级**配置、只有一套 —— 现在配的是瑞尔那套。于是瑞泰的患者点「查看档案 / 病历 /
* 新建预约 / 回访」,会被带到瑞尔的门户,查不到这个人。
*
* ⇒ 打开的那一刻按**患者所属品牌**把域名换掉。
*
* ── 为什么按患者判、不按登录人判 ──────────────────────────────
* 登录人的品牌在前端**拿不到**:`/auth/session` 下发的 `sourceUnits` 对诊所级用户是
* 哨兵 `__pac_deny_no_brand__`(那条路按 clinicIds 过滤,不走品牌维,2026-08-23 实测
* 三家诊所的 token 全是这个值)。而患者的 `sourceUnit` 是实打实的品牌名。
* 语义上也是患者对:这些链接要打开的是**那个患者**在宿主里的页面。
*
* ── 纪律 ──────────────────────────────────────────────────────
* ⛔ **别把它做成通用的"多品牌路由"**。它是一条写死的补丁,成立的前提是"两个门户只差
* 一个域名片段"。真要支持多品牌,正解是 actionUrls 按品牌分组配置(host 管理页加一层),
* 那时删掉这个文件。
* ⚠️ 只在**域名**里换,⛔ 不碰路径和查询串:路径里出现同名片段(如 `?from=arrail`)是宿主
* 自己的业务参数,替换它属于越界。URL 解不出来(相对路径/哨兵值)则原样返回。
* ⚠️ `HOST_ORIGIN` 也要走这条替换 —— 它是 postMessage 的 `targetOrigin`。PAC 嵌在
* rytime 里而 targetOrigin 写着 arrail,浏览器会**静默丢弃**这条消息:按钮点了没反应,
* 控制台连个错都没有。
*/
/** 品牌名 → 该品牌宿主域里的那一段。⚠️ key 是 `patients.source_unit` 的原值。 */
const BRAND_HOST_SEGMENT: Record<string, string> = {
瑞尔: 'arrail',
瑞泰: 'rytime',
};
/** 本替换认得的所有品牌片段(用于识别"这个域名是别的品牌的")。 */
const KNOWN_SEGMENTS = Object.values(BRAND_HOST_SEGMENT);
/**
* 按患者品牌改写宿主 URL / origin。
*
* @param url 已填好占位符的宿主地址,或 `HOST_ORIGIN` 那样的裸 origin
* @param sourceUnit 患者所属品牌名(`patient.sourceUnit`);null / 未知品牌 → 原样返回
*
* 返回值恒非空(拿不准就返回原值)—— ⛔ 这条补丁不许把一个本来能用的链接变没。
*/
export function applyBrandHost(
url: string | undefined,
sourceUnit: string | null | undefined,
): string | undefined {
if (!url) return url;
const want = sourceUnit ? BRAND_HOST_SEGMENT[sourceUnit] : undefined;
if (!want) return url; // 没有品牌 / 不认得的品牌 → 不动
let u: URL;
try {
u = new URL(url);
} catch {
return url; // 相对路径、哨兵值('postMessage')等 —— 没有域名可换
}
// 域名里已经是目标品牌 → 不动(幂等)
const parts = u.hostname.split('.');
const idx = parts.findIndex((p) => KNOWN_SEGMENTS.includes(p));
if (idx < 0 || parts[idx] === want) return url;
parts[idx] = want;
u.hostname = parts.join('.');
return u.toString();
}
......@@ -3,6 +3,7 @@
import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store';
import { fillActionUrl, openHostUrl } from '@/lib/action-url';
import { applyBrandHost } from '@/lib/host-brand-url';
import {
HOST_ACTION_MESSAGE_SOURCE,
HOST_ACTION_MESSAGE_TYPE,
......@@ -68,6 +69,12 @@ export function openHostAction(
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
*/
extraPayload?: HostPotentialTreatmentPayload,
/**
* 患者所属品牌 —— URL 模式换域名;postMessage 模式换 `targetOrigin`。
* 🔴 targetOrigin 必须一起换:PAC 嵌在 rytime 里而 targetOrigin 写着 arrail,
* 浏览器**静默丢弃**这条消息 —— 按钮点了没反应,控制台连个错都没有。
*/
sourceUnit?: string | null,
): boolean {
const raw = rawValue(key);
const mode = hostActionMode(key);
......@@ -76,7 +83,7 @@ export function openHostAction(
return false;
}
if (mode === 'url') {
openHostUrl(fillActionUrl(raw, ctx));
openHostUrl(applyBrandHost(fillActionUrl(raw, ctx), sourceUnit)!);
return true;
}
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
......@@ -86,6 +93,6 @@ export function openHostAction(
action: key,
payload: { patientId: ctx.patientId ?? '', ...(extraPayload ?? {}) },
};
window.parent.postMessage(message, hostOrigin()!);
window.parent.postMessage(message, applyBrandHost(hostOrigin(), sourceUnit)!);
return true;
}
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