Commit c1b44721 by luoqi

fix: agent 审查的一致性/UX 收口(by-id 守卫、死代码、陷阱注释、集团级诊所筛选)

后端:
- plan.recomputeByPatient:patient 守卫补 source_unit(与其它 by-id 守卫对齐;防跨品牌触发重算 + 存在性探测)
- persona.getByVersion:删(无 scope 的 findUnique by-id,死代码,属之前修过的泄漏模式)
- cold-import:改"sourceUnit:''(过渡)"陷阱注释为真实语义(从 manifest 解析,勿退回 '')
- auth.allClinics:去"恰好两品牌"硬编码,改 Object.values(MOCK_PRESETS) 遍历;refresh/mcp 陈旧注释(clinicIds→orgScope)

前端:
- auth-store:setTokens 同 sub 沿用上次 clinicIds 占位,消除 silent refresh 时诊所筛选闪空(M2 竞态)
- visibleClinics(user) helper:clinicIds 空(集团级=不限)→ 回退字典全部诊所,修集团登录后诊所筛选空白(M1);
  4 处(plans-list/patient-picker 筛选 + plans/plan-detail 计数)统一用它

两端 tsc 0 错。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 42b9e8da
......@@ -29,7 +29,7 @@ const REFRESH_PREFIX = 'pac:refresh:';
/**
* Refresh token payload(W3 末 A 方案降级版)
*
* 容纳重签 access token 所需的全部 user 上下文(role/clinicIds/dictionary),
* 容纳重签 access token 所需的全部 user 上下文(role/orgScope/dictionary),
* **无需** refresh 时回调 host SSO 拉权限即可重发 access。
*
* 安全考量:
......@@ -116,7 +116,7 @@ export class AuthService {
* 流程:
* 1. 验证 refresh token 签名(独立 secret JWT_REFRESH_SECRET)
* 2. Redis 校验 jti 是否还在白名单(未被 rotation 作废、未被人工撤销)
* 3. 用 refresh payload 里的 sub/role/clinicIds/dictionary 重签新 access token
* 3. 用 refresh payload 里的 sub/role/orgScope/dictionary 重签新 access token
* 4. **Rotation**:作废老 jti,签发新 refresh token(新 jti 入 Redis),
* 防 refresh token 被偷后无限刷;被偷者一旦用了,真主人下次刷新就因老 jti 被撤销而失败 → 早发现
*
......@@ -279,7 +279,10 @@ export class AuthService {
let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role);
let orgScope: string[];
// 全集团诊所(集团级登录 / 字典用)
const allClinics = { ...this.MOCK_PRESETS.ruier.clinics, ...this.MOCK_PRESETS.ruitai.clinics };
const allClinics = Object.values(this.MOCK_PRESETS).reduce(
(acc, p) => Object.assign(acc, p.clinics),
{} as Record<string, string>,
);
if (req.orgLevel === 'group') {
// ── 集团级:orgScope=[集团根 id] → 展开后 sourceUnits=[]/clinicIds=[] 不限 ──
......
......@@ -12,7 +12,7 @@ export interface McpAuthContext {
/**
* McpAuthService — MCP 端点的 Bearer 鉴权。
*
* 复用平台 JWT(AuthModule 配置的 jwt.secret)。token payload 带 hostId/tenantId/clinicIds/permissions,
* 复用平台 JWT(AuthModule 配置的 jwt.secret)。token payload 带 hostId/tenantId/orgScope/permissions,
* → 派生 TenantScopeContext,每个工具调用据此**强制租户隔离**(与 REST 同一套边界)。
*/
@Injectable()
......
......@@ -375,14 +375,6 @@ export class PersonaService {
return p ? this.serialize(p) : null;
}
async getByVersion(patientId: string, version: number) {
const p = await this.prisma.persona.findUnique({
where: { patientId_version: { patientId, version } },
include: { features: true },
});
return p ? this.serialize(p) : null;
}
private serialize(p: {
id: string;
hostId: string;
......
......@@ -495,7 +495,13 @@ export class PlanService {
engine: PlanEngineService,
): Promise<{ plansCreated: number }> {
const patient = await this.prisma.patient.findFirst({
where: { hostId: scope.hostId, tenantId: scope.tenantId, id: patientId },
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
id: patientId,
// 品牌隔离:与其它 by-id 守卫对齐,防跨品牌触发重算 + 借计数差做存在性探测
...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
},
select: { id: true },
});
if (!patient) throw new NotFoundException(`Patient ${patientId} not found in tenant`);
......
......@@ -1332,7 +1332,8 @@ export class ColdImportService {
() =>
this.prisma.patient.upsert({
where: {
// sourceUnit:''(过渡)— 真填值随 tenant→集团 remap 一起做
// sourceUnit 从 manifest.identity_namespace_field 解析(jvs-dw='brand'→'瑞尔'/'瑞泰');
// 进唯一键消歧集团内 external_id 跨品牌撞号。**勿退回 ''**,否则同号不同品牌会 upsert 成同一行。
hostId_tenantId_sourceUnit_externalId: { hostId, tenantId, sourceUnit, externalId },
},
create: { hostId, tenantId, sourceUnit, externalId, ...patientData },
......
......@@ -7,7 +7,7 @@ import { Permission } from '@pac/types';
import { Can } from '@/components/can';
import { plansApi } from '@/components/plans/plans-api';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
import { useAuthStore } from '@/stores/auth-store';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { userDisplayName, roleNameZh } from '@/lib/utils';
/**
......@@ -82,7 +82,7 @@ function EntryResolver() {
<div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block">
<div className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">
{user?.clinicIds?.length ?? 0} 个诊所 · {roleNameZh(user?.role)}
{visibleClinics(user).length} 个诊所 · {roleNameZh(user?.role)}
</div>
</div>
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
......
......@@ -21,7 +21,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import {
cn,
formatGender,
......@@ -839,7 +839,7 @@ function TopBar({
{/* 用户名块 — md+ 才显,移动端只剩头像 */}
<div className="hidden md:block text-right text-[11.5px] leading-tight text-slate-500">
<div className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {roleNameZh(user?.role)}</div>
<div className="nums">{visibleClinics(user).length} 个诊所 · {roleNameZh(user?.role)}</div>
</div>
<span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
{userDisplayName(user).charAt(0).toUpperCase()}
......
......@@ -8,7 +8,7 @@ import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react
import { PERSONA_TAG_FILTER_DIMS, Permission, type PlanListItem } from '@pac/types';
import { cn, formatGender } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission';
import { useAuthStore } from '@/stores/auth-store';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
......@@ -50,11 +50,8 @@ export function PatientPickerRail({
const [tags, setTags] = useState<Set<string>>(new Set()); // "key:value"
const user = useAuthStore((st) => st.user);
const clinicDict = user?.dictionary?.clinics;
const clinicOptions = useMemo(
() => (user?.clinicIds ?? []).map((id) => ({ id, name: String(clinicDict?.[id] ?? id) })),
[user?.clinicIds, clinicDict],
);
const clinicDict = user?.dictionary?.clinics; // 显示 plan 诊所名用(字典查表,非 scope)
const clinicOptions = useMemo(() => visibleClinics(user), [user]);
useEffect(() => {
const t = setTimeout(() => setKeyword(search.trim()), 300);
......
......@@ -29,7 +29,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAuthStore } from '@/stores/auth-store';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { useHasPermission } from '@/hooks/use-permission';
import { ApiError } from '@/lib/api-client';
import { cn, formatGender, userDisplayName, roleNameZh } from '@/lib/utils';
......@@ -218,12 +218,8 @@ export function PlansListApp() {
setQuery({ status: s, page: 1 });
clearSelected();
};
// 诊所多选筛选 — 选项 = 当前用户**可见诊所**(token.clinicIds = RBAC 范围),名取自 dictionary.clinics
// (dictionary 只是 id→名 查表,可能含范围外诊所;真正的可见范围以 clinicIds 为准)。状态存 query.targetClinicIds
const clinicOptions = useMemo(() => {
const dict = user?.dictionary?.clinics ?? {};
return (user?.clinicIds ?? []).map((id) => ({ id, name: String(dict[id] ?? id) }));
}, [user?.clinicIds, user?.dictionary?.clinics]);
// 诊所多选筛选 — 选项 = 当前用户可见诊所(clinicIds 受限范围;集团级空→回退字典全部)。状态存 query.targetClinicIds
const clinicOptions = useMemo(() => visibleClinics(user), [user]);
const selectedClinics = query.targetClinicIds ?? [];
const setClinics = (ids: string[]) => {
setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
......
......@@ -14,6 +14,17 @@ export type SessionUser = AccessTokenPayload & {
sourceUnits?: string[];
};
/**
* 用户可见诊所选项(诊所筛选/计数用)。
* clinicIds 非空 = 受限范围,以它为准;空 = 不限(集团级)→ 回退到字典里全部诊所
* (否则集团级用户诊所筛选会空白)。名字取 dictionary,缺则回退 id。
*/
export function visibleClinics(user: SessionUser | null): { id: string; name: string }[] {
const dict = user?.dictionary?.clinics ?? {};
const ids = user?.clinicIds?.length ? user.clinicIds : Object.keys(dict);
return ids.map((id) => ({ id, name: String(dict[id] ?? id) }));
}
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
......@@ -67,13 +78,21 @@ export const useAuthStore = create<AuthState>()(
expiresAt: null,
user: null,
setTokens: ({ accessToken, refreshToken, expiresIn }) => {
const decoded = decodeJwt(accessToken);
const prev = get().user;
// 防闪空:silent refresh 重签 token 时,同一 sub 沿用上次已展开的 clinicIds/sourceUnits
// 作占位,避免 user 被 decodeJwt 重置后诊所筛选短暂清空(loadSession 随后会刷新)。
const user =
decoded && prev && prev.sub === decoded.sub
? { ...decoded, clinicIds: prev.clinicIds, sourceUnits: prev.sourceUnits }
: decoded;
set({
accessToken,
refreshToken,
expiresAt: Date.now() + expiresIn * 1000,
user: decodeJwt(accessToken),
user,
});
// 异步补齐展开后的数据范围(不阻塞登录;拿到前诊所筛选暂为空)
// 异步补齐/刷新展开后的数据范围(不阻塞登录)
void get().loadSession();
},
loadSession: async () => {
......
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