Commit eb5d8b9f by luoqi

fix(宿主跳转): 瑞泰患者跳瑞尔门户 —— 打开时把 arrail 换成 rytime

jvs-dw 底下两个品牌各有一套门户:瑞尔 arrail.5i5ya.com / 瑞泰 rytime.5i5ya.com,
两边**只差域名这一段**,路径参数完全一样。而 host.actionUrls 是 host 级配置、只有一套
(现在配的是瑞尔那套)。于是瑞泰患者点「查看档案 / 病历 / 新建预约 / 回访」,
会被带到瑞尔门户 —— 那边查不到这个人。

⇒ 打开的那一刻按**患者所属品牌**把域名换掉(lib/host-brand-url.ts)。

【为什么按患者判、不按登录人判】
登录人的品牌前端**拿不到**:/auth/session 下发的 sourceUnits 对诊所级用户是哨兵
`__pac_deny_no_brand__`(那条路按 clinicIds 过滤,不走品牌维)。2026-08-23 拿生产
三家诊所的 token 实测,全是这个值 —— 判不出品牌。而患者的 sourceUnit 是实打实的品牌名。
语义上也是患者对:这些链接要打开的就是**那个患者**在宿主里的页面。

【改了什么】
- 后端 plan-aggregate:患者 + **关系人**都下发 sourceUnit
  (关系人用他自己的, 不拿本人的顶替 —— 亲属跨品牌时会跳错门户)
- 新增 lib/host-brand-url.ts:applyBrandHost(url, sourceUnit)
- resolveActionUrl / openHostAction 加可选的品牌参数;plan-detail-app 六个调用点全传上

【两个容易漏的点】
- 🔴 **HOST_ORIGIN 也必须换** —— 它是 postMessage 的 targetOrigin。PAC 嵌在 rytime 里
  而 targetOrigin 写着 arrail,浏览器**静默丢弃**这条消息:按钮点了没反应,控制台连个错都没有。
- ️ **只换域名,不碰路径和查询串**:`?from=arrail` 是宿主自己的业务参数,替换它属于越界。
  URL 解不出来(相对路径 / 哨兵值 'postMessage')原样返回 —— 这条补丁不许把能用的链接变没。

【纪律】 别把它做成通用的"多品牌路由"。它成立的前提是"两个门户只差一个域名片段"。
真要支持多品牌,正解是 actionUrls 按品牌分组配置(host 管理页加一层),那时删掉这个文件。

【验证】
- 新增 host-brand-url.test.ts 14 条:替换 / 幂等 / 只换域名不碰路径查询串 / 哨兵值与
  相对路径原样 / 未知品牌与空值不动 / **每个调用点都带上了品牌参数**(源码断言,
  防的是"新加调用点时忘了传" —— 参数可选,漏了 tsc 不报)
- 本地实测 /plans/:id/full:瑞泰患者 sourceUnit='瑞泰'、瑞尔患者 ='瑞尔'
- 两端 tsc + 后端 1443 用例 + 前端 46 用例全绿

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 8f79c94e
...@@ -78,6 +78,8 @@ export class PlanAggregateService { ...@@ -78,6 +78,8 @@ export class PlanAggregateService {
select: { select: {
id: true, id: true,
externalId: true, externalId: true,
// 关系人可能跨品牌(概率低但存在)—— 判宿主域用,见 web 的 host-brand-url
sourceUnit: true,
medicalRecordNumber: true, medicalRecordNumber: true,
name: true, name: true,
phone: true, phone: true,
...@@ -152,6 +154,7 @@ export class PlanAggregateService { ...@@ -152,6 +154,7 @@ export class PlanAggregateService {
select: { select: {
id: true; id: true;
externalId: true; externalId: true;
sourceUnit: true;
medicalRecordNumber: true; medicalRecordNumber: true;
name: true; name: true;
phone: true; phone: true;
...@@ -410,6 +413,7 @@ function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null ...@@ -410,6 +413,7 @@ function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null
function serializePatient(patient: { function serializePatient(patient: {
id: string; id: string;
externalId: string; externalId: string;
sourceUnit: string | null;
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
name: string | null; name: string | null;
gender: string | null; gender: string | null;
...@@ -423,6 +427,15 @@ function serializePatient(patient: { ...@@ -423,6 +427,15 @@ function serializePatient(patient: {
return { return {
id: patient.id, id: patient.id,
externalId: patient.externalId, externalId: patient.externalId,
/**
* 品牌名(`patients.source_unit`,如「瑞尔」/「瑞泰」)—— **前端按它判宿主域**。
*
* ⚠️ 与 `brandId` 分工:`brandId` 是品牌 GUID(经 host.orgAliases 反解),给跳转链接的
* `{brandId}` 占位用;要**判断**是哪个品牌得用这个名字 —— 拿 GUID 判等于在前端
* 写死一串 uuid,换个环境就不对了。
* ⚠️ 宿主没有品牌概念时为 null(单品牌宿主),前端据此不做任何替换。
*/
sourceUnit: patient.sourceUnit,
/// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id) /// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id)
medicalRecordNumber: patient.medicalRecordNumber, medicalRecordNumber: patient.medicalRecordNumber,
name: patient.name, name: patient.name,
...@@ -456,6 +469,8 @@ function serializeProfile( ...@@ -456,6 +469,8 @@ function serializeProfile(
relatedPatient: { relatedPatient: {
id: string; id: string;
externalId: string; externalId: string;
/// 关系人自己的品牌 —— 判宿主域用(亲属可能跨品牌),见 web 的 host-brand-url
sourceUnit: string | null;
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
name: string | null; name: string | null;
phone: string | null; phone: string | null;
...@@ -532,6 +547,8 @@ function serializeProfile( ...@@ -532,6 +547,8 @@ function serializeProfile(
// 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页 // 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
externalId: r.relatedPatient?.externalId ?? r.relatedExternalId, externalId: r.relatedPatient?.externalId ?? r.relatedExternalId,
medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null, medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null,
/// 关系人自己的品牌 —— ⛔ 别拿本人的顶替:亲属挂在别的品牌时链接会跳错门户
sourceUnit: r.relatedPatient?.sourceUnit ?? null,
planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null, planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null,
}; };
}) })
......
...@@ -40,6 +40,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -40,6 +40,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
externalId: real.patient.externalId, externalId: real.patient.externalId,
medicalRecordNumber: real.patient.medicalRecordNumber ?? null, medicalRecordNumber: real.patient.medicalRecordNumber ?? null,
brandId: real.patient.brandId ?? null, // 品牌 GUID(EMR 跳转 {brandId} 用) brandId: real.patient.brandId ?? null, // 品牌 GUID(EMR 跳转 {brandId} 用)
sourceUnit: real.patient.sourceUnit ?? null, // 品牌名 —— 判宿主域用(见 lib/host-brand-url)
dedicatedCs: real.patient.dedicatedCs ?? null, dedicatedCs: real.patient.dedicatedCs ?? null,
name: real.patient.name ?? '(未知)', name: real.patient.name ?? '(未知)',
nameMasked: real.patient.nameMasked ?? '*', nameMasked: real.patient.nameMasked ?? '*',
......
...@@ -33,6 +33,7 @@ export const mockPatient = { ...@@ -33,6 +33,7 @@ export const mockPatient = {
externalId: '5i5ya_PA00284917', externalId: '5i5ya_PA00284917',
medicalRecordNumber: 'SH0Q011691' as string | null, medicalRecordNumber: 'SH0Q011691' as string | null,
brandId: null as string | null, // 品牌 GUID(EMR 跳转 {brandId} 用) 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, dedicatedCs: { id: '5499', name: '姜莹' } as { id: string; name: string } | null,
name: '张志远', name: '张志远',
nameMasked: '张志*', nameMasked: '张志*',
...@@ -67,6 +68,7 @@ export const mockPatient = { ...@@ -67,6 +68,7 @@ export const mockPatient = {
planId: null, planId: null,
externalId: '5i5ya_PA00999001', externalId: '5i5ya_PA00999001',
medicalRecordNumber: 'SH0Q099001', medicalRecordNumber: 'SH0Q099001',
sourceUnit: null,
relationshipRaw: 'mother', relationshipRaw: 'mother',
ageCorrected: false, ageCorrected: false,
}, },
...@@ -85,6 +87,8 @@ export const mockPatient = { ...@@ -85,6 +87,8 @@ export const mockPatient = {
/** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */ /** 关系人宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位 */
externalId: string; externalId: string;
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
/** 关系人**自己**的品牌名 —— 判宿主域用(见 lib/host-brand-url) */
sourceUnit: string | null;
/** 源数据原样关系码 + 是否被年龄纠正过 */ /** 源数据原样关系码 + 是否被年龄纠正过 */
relationshipRaw: string; relationshipRaw: string;
ageCorrected: boolean; ageCorrected: boolean;
......
...@@ -486,7 +486,12 @@ export function PlanDetailApp({ ...@@ -486,7 +486,12 @@ export function PlanDetailApp({
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT') const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => { ? () => {
if (!gateCheck()) return; if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload()); openHostAction(
'OPEN_POTENTIAL_TREATMENT',
hostActionCtx,
potentialTreatmentPayload(),
patient.sourceUnit,
);
} }
: undefined; : undefined;
/** /**
...@@ -506,7 +511,7 @@ export function PlanDetailApp({ ...@@ -506,7 +511,7 @@ export function PlanDetailApp({
abandonReasons: [], abandonReasons: [],
}); });
if (!ok) return false; if (!ok) return false;
openHostAction('OPEN_RETURN_VISIT', hostActionCtx); openHostAction('OPEN_RETURN_VISIT', hostActionCtx, undefined, patient.sourceUnit);
return true; return true;
} }
: undefined; : undefined;
...@@ -573,7 +578,7 @@ export function PlanDetailApp({ ...@@ -573,7 +578,7 @@ export function PlanDetailApp({
brandId: patient.brandId, brandId: patient.brandId,
clinicId: plan?.targetClinicId, clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber, medicalRecordNumber: patient.medicalRecordNumber,
}); }, patient.sourceUnit);
if (!url) { if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT'); showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return false; return false;
...@@ -728,7 +733,7 @@ export function PlanDetailApp({ ...@@ -728,7 +733,7 @@ export function PlanDetailApp({
clinicId: plan?.targetClinicId, clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber, medicalRecordNumber: patient.medicalRecordNumber,
patientId: patient.externalId, patientId: patient.externalId,
})} }, patient.sourceUnit)}
/> />
{/* 召回建议 — 暂时隐藏(SuggestionCard) */} {/* 召回建议 — 暂时隐藏(SuggestionCard) */}
{recallHistory.length > 0 && ( {recallHistory.length > 0 && (
...@@ -1654,10 +1659,11 @@ function IdentityCard({ ...@@ -1654,10 +1659,11 @@ function IdentityCard({
}; };
// 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行: // 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行:
// PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。 // PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。
const originalArchiveUrl = resolveActionUrl('VIEW_PATIENT', { const originalArchiveUrl = resolveActionUrl(
patientId: patient.externalId, 'VIEW_PATIENT',
medicalRecordNumber: patient.medicalRecordNumber, { patientId: patient.externalId, medicalRecordNumber: patient.medicalRecordNumber },
}); patient.sourceUnit,
);
return ( return (
<section className="bg-white rounded-lg border shadow-sm flex-none"> <section className="bg-white rounded-lg border shadow-sm flex-none">
<div className="p-3 flex items-start gap-2.5"> <div className="p-3 flex items-start gap-2.5">
...@@ -1919,10 +1925,12 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont ...@@ -1919,10 +1925,12 @@ function RelatedKinRow({ contacts }: { contacts: typeof mockPatient.profile.cont
<span className="ml-auto flex-none"> <span className="ml-auto flex-none">
{(() => { {(() => {
// 宿主档案优先,PAC 工单页兜底(见组件头注释) // 宿主档案优先,PAC 工单页兜底(见组件头注释)
const hostUrl = resolveActionUrl('VIEW_PATIENT', { const hostUrl = resolveActionUrl(
patientId: c.externalId, 'VIEW_PATIENT',
medicalRecordNumber: c.medicalRecordNumber, { patientId: c.externalId, medicalRecordNumber: c.medicalRecordNumber },
}); // ⛔ 用关系人自己的品牌,不是本人的:亲属挂在别的品牌时会跳错门户
c.sourceUnit,
);
const url = hostUrl ?? (c.planId ? `/plans/${c.planId}` : null); const url = hostUrl ?? (c.planId ? `/plans/${c.planId}` : null);
if (!url) { if (!url) {
return ( return (
......
...@@ -14,6 +14,9 @@ export type PlanDetailData = { ...@@ -14,6 +14,9 @@ export type PlanDetailData = {
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
/// 品牌 GUID(source_unit 经 host.orgAliases 反解)—— EMR 跳转链接 {brandId} 用;无别名则 null /// 品牌 GUID(source_unit 经 host.orgAliases 反解)—— EMR 跳转链接 {brandId} 用;无别名则 null
brandId: string | null; brandId: string | null;
/// 品牌**名**(「瑞尔」/「瑞泰」)—— 判宿主域用(见 lib/host-brand-url)。⛔ 别拿 brandId 判:
/// 那是 uuid,在前端写死一串 uuid 换个环境就不对了。单品牌宿主为 null。
sourceUnit: string | null;
dedicatedCs: { id: string; name: string } | null; dedicatedCs: { id: string; name: string } | null;
name: string | null; name: string | null;
nameMasked: string | null; nameMasked: string | null;
...@@ -54,6 +57,8 @@ export type PlanDetailData = { ...@@ -54,6 +57,8 @@ export type PlanDetailData = {
/// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案) /// 关系人的宿主侧 id / 病历号 —— 填 VIEW_PATIENT 槽位占位用(配了就跳宿主档案)
externalId: string; externalId: string;
medicalRecordNumber: string | null; medicalRecordNumber: string | null;
/// 关系人**自己**的品牌名 —— 判宿主域用。⛔ 别拿本人的顶替(亲属可能跨品牌)
sourceUnit: string | null;
/// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过 /// 源数据原样的关系码;与 relationship 不同 = 被年龄纠正过
relationshipRaw: string; relationshipRaw: string;
/// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文 /// 关系方向被年龄纠正过 —— UI 给一句 hover 说明,让客服知道这不是源数据原文
......
'use client'; 'use client';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { applyBrandHost } from '@/lib/host-brand-url';
import type { HostActionKey } from '@pac/types'; import type { HostActionKey } from '@pac/types';
/** /**
...@@ -33,9 +34,15 @@ export function actionTemplate(key: HostActionKey): string | undefined { ...@@ -33,9 +34,15 @@ export function actionTemplate(key: HostActionKey): string | undefined {
export function resolveActionUrl( export function resolveActionUrl(
key: HostActionKey, key: HostActionKey,
ctx: Record<string, string | null | undefined>, ctx: Record<string, string | null | undefined>,
/**
* 患者所属品牌(`patient.sourceUnit`)—— 用于把宿主域换成该品牌那套(见 host-brand-url)。
* ⚠️ 不传 = 不换。⛔ 别把它塞进 `ctx`:ctx 是**占位符**字典,模板里并没有 {sourceUnit}
* 这个占位,混在一起会让人以为可以在模板里写它。
*/
sourceUnit?: string | null,
): string | undefined { ): string | undefined {
const tpl = actionTemplate(key); 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 @@ ...@@ -3,6 +3,7 @@
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { fillActionUrl, openHostUrl } from '@/lib/action-url'; import { fillActionUrl, openHostUrl } from '@/lib/action-url';
import { applyBrandHost } from '@/lib/host-brand-url';
import { import {
HOST_ACTION_MESSAGE_SOURCE, HOST_ACTION_MESSAGE_SOURCE,
HOST_ACTION_MESSAGE_TYPE, HOST_ACTION_MESSAGE_TYPE,
...@@ -68,6 +69,12 @@ export function openHostAction( ...@@ -68,6 +69,12 @@ export function openHostAction(
* 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。 * 还会把病情描述写进浏览器历史和宿主的 access log。要 URL 模式也带,得宿主改成 POST。
*/ */
extraPayload?: HostPotentialTreatmentPayload, extraPayload?: HostPotentialTreatmentPayload,
/**
* 患者所属品牌 —— URL 模式换域名;postMessage 模式换 `targetOrigin`。
* 🔴 targetOrigin 必须一起换:PAC 嵌在 rytime 里而 targetOrigin 写着 arrail,
* 浏览器**静默丢弃**这条消息 —— 按钮点了没反应,控制台连个错都没有。
*/
sourceUnit?: string | null,
): boolean { ): boolean {
const raw = rawValue(key); const raw = rawValue(key);
const mode = hostActionMode(key); const mode = hostActionMode(key);
...@@ -76,7 +83,7 @@ export function openHostAction( ...@@ -76,7 +83,7 @@ export function openHostAction(
return false; return false;
} }
if (mode === 'url') { if (mode === 'url') {
openHostUrl(fillActionUrl(raw, ctx)); openHostUrl(applyBrandHost(fillActionUrl(raw, ctx), sourceUnit)!);
return true; return true;
} }
// 信封:source + type + action + payload(契约约定;无 version / doctorId / meta) // 信封:source + type + action + payload(契约约定;无 version / doctorId / meta)
...@@ -86,6 +93,6 @@ export function openHostAction( ...@@ -86,6 +93,6 @@ export function openHostAction(
action: key, action: key,
payload: { patientId: ctx.patientId ?? '', ...(extraPayload ?? {}) }, payload: { patientId: ctx.patientId ?? '', ...(extraPayload ?? {}) },
}; };
window.parent.postMessage(message, hostOrigin()!); window.parent.postMessage(message, applyBrandHost(hostOrigin(), sourceUnit)!);
return true; 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