1. 14 Jul, 2026 1 commit
    • fix(auth): exchange-code 幂等(短宽限窗),根治首开「登录已过期」 · c8592cdc
      根因:全新浏览器(无 localStorage)下,幸存 iframe 拿到的 code 已被消费(宿主预取/
      首屏双渲染中首个已消费但没落地/宿主自己先验一次)→ 第二次 exchange 得 10102 → 无票
      可退 → SessionExpired。90aefc7d 只挡了"首个成功落地"的情况,挡不住这种。
      
      改:exchangeCode 首次消费后落一份短期幂等副本(CODE_RESULT_PREFIX,EXCHANGE_GRACE_SECONDS=20s);
      同一 code 在宽限窗内重复换返回同一票,不报 10102;过窗即真失效,把可重放窗口压到最小。
      
      模拟宿主(4100)复现 + 验证:已消费 code + 清 localStorage 首开,修复前报「登录已过期」,
      修复后工作台正常加载;API 层同一 code 两次 exchange 均返回 code=0。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  2. 13 Jul, 2026 8 commits
    • feat(plans): 患者卡片手机号直接显示明文,去掉脱敏与查看图标 · c3a8f1a5
      详情页患者卡片:手机号从「脱敏 + 眼睛 toggle」改为直接显示 patient.phone 明文;
      移除 eye/eye-off 按钮及 reveal 状态。保留真/假角标 + 复制按钮。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • chore(plans): EMR 跳转按钮文案改「原始病历 →」 · 2b11ccc5
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(plans): EMR 原文跳转(治疗历史「详情」槽)+ 通用 actionUrl 占位引擎;修左栏待认领换行 · e2bd5460
      - 左栏 PatientRow:诊所名变长后「待认领」被挤换行 → StatusPill 加 whitespace-nowrap、
        诊所名 min-w-0 truncate、电话定宽,布局稳住
      - 通用占位替换 lib/action-url.ts(fillActionUrl/resolveActionUrl):扫 {key} 从 ctx 取值
        URL 编码,缺失清空;替代原来写死的 {patientId} 单占位
      - 治疗历史卡「详情」槽:配了 VIEW_MEDICAL_RECORD 就打开宿主电子病历原文(占位
        {brandId}/{clinicId}/{medicalRecordNumber}/{patientId}),隐藏原 PAC 时间轴抽屉;
        未配置回退原详情。CREATE_APPOINTMENT 也切到通用引擎(同样支持这些 id 变量)
      - 后端 plan-aggregate 下发 patient.brandId(source_unit → host.orgAliases 反解品牌 GUID);
        前端补 patient.brandId / plan.targetClinicId 贯穿 raw→adapt→mock 类型链
      - HOST_ACTION_META.VIEW_MEDICAL_RECORD 占位更新为 brandId/clinicId/medicalRecordNumber/patientId
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(auth): 诊所名走服务端派生(host.clinicNames),显示不再依赖登录传 dictionary · d929aaee
      新增 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>
      luoqi committed
    • fix(auth): 数据权限支持契约品牌 GUID —— org 树品牌节点认别名,修复品牌主管 orgScope 空数据 · 0bf6a25c
      契约约定 orgScope 传宿主组织节点 id(品牌级=品牌 GUID),但 PAC org 树品牌节点
      id 用的是名字(source_unit=瑞尔/瑞泰),宿主传品牌 GUID(ba67e6cf…=瑞尔)对不上
      → 品牌制租户 fail-closed → 品牌主管(如岳丽丽 leader)登录后一条数据看不到。
      诊所层无此问题(节点 id 本就是诊所 GUID)。
      
      改动:
      - OrgNode 加 aliases[];expandOrgScope 按 id+aliases 建索引(名字/GUID 等价命中)
      - buildOrgTree 接受 brandAliases(品牌名→GUID),挂到品牌节点 aliases
      - Host 加 org_aliases(Json):PAC 节点 id→宿主契约 id 映射;deriveTree 读它传入
      - jvs-dw-preset 补品牌 GUID;seed 回填 jvs-dw.org_aliases(瑞尔=ba67e6cf/瑞泰=77057aed)
      - 模拟登录品牌级改传品牌 GUID(真正模拟契约,旧版传品牌名自洽掩盖了此 bug)
      不动 source_unit、不影响诊所层与 friday(source_unit 本就是 GUID)。
      
      本地验证:真实 OrgTreeService.expandScope(品牌 GUID ba67e6cf) → sourceUnits=[瑞尔]
      + 瑞尔全部诊所;品牌名/诊所 GUID 照常;typecheck 干净。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(sync/friday): 接入 appointment + encounter(到诊从预约拆,同 jvs-dw) · 7235b228
      FRIDAY appointment_base → PAC appointment(全部预约)+ encounter(已到诊接诊事件),
      与第一个宿主 jvs-dw 同款设计(一个预约源喂两张 canonical)。要点:
      - status 编码两家一致(1/2/3/4/5/6/8/9);status=10=草稿(查 friday-saas
        origin/hotfix AppointmentDataCommons draft==1 实锤)→ filter 丢弃
      - scheduledAt 用 appointment_date + appointment_start 经 derive 拼(datetime_str
        是区间且 14% 空,不用)
      - 已到诊(status 4/5/6 且 in_time 非空)拆 appointment_encounter_rows → encounter,
        startedAt=in_time;externalId=预约 id,将来 EMR(med_emr_info.appointmentId)据此回挂
      本地验证:809 appointment + 640 encounter,1449 txns/1449 facts,0 失败 0 drift;
      patient timeline 正确重建「预约→到诊」就诊史。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 新增 lookup transform + FRIDAY patient 首版接入 · 4f228575
      lookup operator(Layer A.5 第 8 个):按外键 join 子表、按 order_by 挑一条、
      把字段附加到主表——补上"1:N 子表归约成标量"的空(join_arrays 只产数组,
      其余 operator 全是单表内变换)。纯增量,不改任何现有 operator,不影响 jvs-dw。
      
      FRIDAY(第二宿主)patient 首版:方案 B(tenant=friday-market + 品牌 source_unit),
      宿主纯导原始表 customer_basic_info + customer_contacts,phone 由 lookup 挑默认号
      (is_default→本人→最早)填入。本地 450 患者/2 品牌真实落库验证:source_unit、
      挑号(is_default 胜出)、gender/病历号/时区全对。CSV 含 PII 不入 git。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(auth): 公开端点(auth:false)失败不再清会话,修复嵌入首屏「登录已过期」 · 90aefc7d
      api-client 清 session 分支未按 auth 门控,导致一次性 code 被重放/过期(10102)时
      误清刚建立的会话。宿主 iframe 首屏用同一 code 换两次时,第二次 10102 把第一次
      存好的有效会话清空 → SessionExpired。改为仅「带 token 的受保护请求」失败才清;
      公开端点失败交由 bootstrap / doRefresh 各自按语义决定,兜底 fall-through 生效。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  3. 09 Jul, 2026 2 commits
    • feat(plans): 左栏患者列表加「回访结果」角标(成功/不成功/保持) · 23e18d92
      有提交过执行记录的患者,在列表行左上角按最近一次 outcome 的分组打标签,
      分组/文案/配色复用 @pac/types EXECUTION_OUTCOME_META 单一真理源。
      列表接口批量带出每 plan 最近 outcome;详情页提交回访后左栏角标即时反映(不重拉)。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(embed): 患者卡片「潜在治疗」/ 通话结果「回访」走 postMessage 通知宿主 · d98dcbd4
      - 新增两个 postMessage 动作 OPEN_POTENTIAL_TREATMENT / OPEN_RETURN_VISIT(入 HostActionKey
        + HOST_ACTION_META,admin 下拉可选);HOST_ORIGIN 作 postMessage targetOrigin,走 host 配置
        (会话下发的 actionUrls.HOST_ORIGIN),禁用 '*'
      - 患者卡片右上角「影像/档案」→「潜在治疗」;通话结果「拨打」→「回访」,固定就是这套(不判断 iframe)
      - 信封只含 { source:'pac', action, payload:{patientId} };patientId=external_id
      - postToHost 只在未配 HOST_ORIGIN 时提示;宿主侧监听校验 origin + source 后弹自己的组件
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  4. 08 Jul, 2026 1 commit
    • feat(action-urls): 会话下发 actionUrls + 「新建预约」按钮消费(配置驱动,不写死) · 1506e2fa
      - /auth/session 返回当前 host 的 actionUrls(AuthService.getActionUrls),所有角色可拿,
        不再依赖 admin 端点;SessionResponse 加 actionUrls 字段
      - 前端 loadSession 把 actionUrls 存进会话 user;「新建预约」读 actionUrls.CREATE_APPOINTMENT
        → 替换 {patientId}(=external_id)/{doctorId} → window.open(_top);未配置则提示去配
      - 宿主动作 URL 仍是宿主在 admin 配的数据,代码零硬编码
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  5. 07 Jul, 2026 3 commits
    • fix(embed): 去掉「新建预约」插槽写死的 localhost 兜底(demo 接线泄漏) · 6182b2f5
      上次把按钮从占位 toast 改成了 window.open('http://localhost:4100/appointment','_top'),
      硬编码 URL 随部署上了生产 —— 线上点击会 _top 跳到用户本机 localhost 死链。
      回退成原占位 toast;删除 action-urls.ts(带写死默认值的配置)。真实接入时 URL 由
      宿主 actionUrls / env 下发,代码不写死。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • chore: 入库会话前既有的工作区改动 · 9cdf04ef
      会话前已存在、本次一并提交:cold-import.service / sync-incremental scheduler /
      clinical-gap potential-treatment-gap SQL / cold-import manifest.schema /
      dw-lag-monitor / canonical-codes / ingestion·monitoring 文档 / jvs-dw manifest.yaml /
      package.json / pnpm-lock / .gitignore(next-env.d.ts)/ docs/friday-saas-mapping-temp。
      非本次会话产出,随此次部署一并落地。
      luoqi committed
    • feat(embed): 宿主嵌入体验 —— 隐藏 PAC 品牌/用户、会话失效兜底、动作走链接;org-tree 冷派生优化 · 1cf137ac
      - 嵌入检测(window.top):隐藏 PAC 品牌/用户/登出(IdentityCluster),会话失效显示
        「登录已过期+重试」而非 dev 快速登录(SessionExpired);模拟登录嵌入态不出现
      - 动作插槽(新建预约等):PAC 直接 window.open 宿主链接,不 iframe 嵌、不 postMessage、
        无 returnUrl;患者上下文经 query 传,回跳靠 history.back()/宿主自己的召回页
      - org-tree 派生:启动预热(OnApplicationBootstrap)+ loose index-scan 改写,
        冷派生 ~3.8s→~1ms,消除首个 scoped 用户登录尾延迟
      - docs: overview §4/§5 补 actionUrls 打开方式/回跳、sandbox 权限、嵌入隐藏、会话失效
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  6. 03 Jul, 2026 1 commit
    • feat(push): 存量/增量同一通道 —— 归一统一 / 乱序裁决 / stub 自建 / 并发闸 + 断流告警 · 33a826e7
      把 push(形态 A/C)打磨成宿主唯一接入通道,存量=按批重放的增量:
      
      摄入一致性(消除同源分叉):
      - 形态 C 复用 normalizeCanonical(金额元→分 / 无时区时间补 offset),含 envelope
        occurredAt/updatedAt —— 与形态 A/pull 同一转,宿主用自己系统的格式发,不做转换。
      - 业务事件先于患者主档到达 → dispatcher stub 自建空壳患者(对齐 form A),主档后到补全
        同一行,不双人;push 跨批顺序无关。
      
      幂等/乱序:
      - patient_facts 加 source_updated_at,fact-writer 按它裁决:更旧版本晚到 → stale_skipped
        跳过,不旧覆新。关掉"补推快照 vs 实时推送并发交错"的乱序窗口。
      
      体制/性能:
      - 限批 500(A 从 2000 对齐 C);persona 入队去抖(5min 桶去重,存量批次合并重算)。
      - 同 host 并发闸(信号量,PAC_PUSH_CONCURRENCY 默认 4;幂等+裁决保证并发语义=串行)。
      - 单飞锁索引收窄 WHERE direction='pull' —— 原索引把并发 push 批次拦成 P2002/500。
      
      异常/可观测:
      - 整批全失败 → 拒收 30802 + SyncLog FAILED;失败率/疑似改表 → 告警。
      - 未知 source → 10001(非 90000,避免宿主对永久错误无限重试)。
      - push 断流监控(超 26h 无 push 告警);form A 响应透出 mappingMisses/suspectFields 供自检。
      
      文档 channel-push 按宿主视角重写(subjectType 单一口径、金额时间统一"原样发 PAC 归一")。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  7. 02 Jul, 2026 2 commits
    • docs(integration): 登录授权补请求参数字段表 + 修 dictionary 必填/orgScope 拼写 · 984e05cc
      请求参数按 role 表那样逐字段列出(字段/类型/必填/说明),对接方更易懂。
      dictionary 标注为选填(schema 是 .optional(),不传换票仍成功,仅显示原始 id)。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(callback): 执行结果回调(PAC → 宿主)+ 修 push 密钥存储 + 修凭据页刷新 · 4c8c70a7
      执行结果回调(opt-in,宿主配 callbackUrl 才投递):
      - Host 加 callbackUrl + callbackSecrets(存原文,PAC 签名用);迁移加两列。
      - ExecutionService.submit 提交后入 execution-callback 队列(6 次退避重试,死信可见)。
      - ExecutionCallbackService:查 execution→plan→patient 拼 payload,HMAC-SHA256("{ts}.{body}")
        签名(与 push 入站同配方),fetch+8s 超时,非 2xx/超时抛错交 BullMQ 重试;jobId=executionId 幂等。
      - 凭据页新增「执行结果回执」区:callbackUrl 输入 + Callback Secrets(add/rotate/grace/删),
        suffix 用 sha256 指纹不回显原文。对接文档同步。
      
      顺手修 push 密钥存储 bug:
      - create/rotate 原本存 scrypt 哈希,但 HmacVerifier 把它当原文做 HMAC key → 宿主拿明文、
        PAC 拿哈希,两边 key 不一致,push 验签永远过不了。改为存原文(对齐 seed 与 verifier 预期)。
      - 本地实测:轮换后用返回的明文签名 → 验签通过;错误签名 → 10106 拒绝。
      
      修凭据页「刷新即整页重载」:
      - useHostAdmin.refresh 一进来就切 loading,父组件把整页换成占位、卸载 HostAdminApp,
        连带把"仅显示一次"的明文 secret 弹窗刷没(来不及复制)。改 stale-while-revalidate:
        已有数据不切 loading,旧数据留屏后台重取。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  8. 01 Jul, 2026 6 commits
    • docs(integration): 登录授权补凭证有效期表 + 链到接入总览 iframe 嵌入 · 29f27fe0
      code/accessToken/refreshToken 有效期不写清楚,接入方不知道过期后该调哪个接口;
      另外"用 code 拼 URL"具体怎么拼、嵌哪条路径此前只字未提,补链接到 §5。
      luoqi committed
    • perf(admin): GET /admin/host/self 卡 28s —— 补 2 个复合索引 · 3d02ac03
      getStats() 并发跑 10 条查询,其中两条缺覆盖索引:
      - patient_transactions.count(hostId, occurredAt>=X):(patient_id, occurred_at)
        索引带不动"只按时间不带 patient_id"的过滤,近乎全扫索引。线上冷执行 8.3s。
      - patient_facts.count(hostId, status='active'):(hostId, tenantId) 没 status、
        (patientId, status) 没 hostId,两个都覆盖不了。线上冷执行 18.9s。
        三列(非二列):tenant-guard 扩展对未带 tenantId 的查询自动注入 tenantId 过滤,
        二列索引配上这条自动追加的谓词后 planner 直接弃用索引退回全表扫,三列才稳定命中
        index-only scan。
      
      两条索引都用 CREATE INDEX CONCURRENTLY(表已 379万/371万行,避免锁表阻塞摄入写入)。
      线上手动建索引 + 验证已完成(index-only scan,查询从 8~19s 降到 <1s;端到端
      GET /admin/host/self 28s → 0.9s);IF NOT EXISTS 保证下次 `prisma migrate deploy`
      在 pac-migrate 里重跑时是空操作,只补 `_prisma_migrations` 记录。
      luoqi committed
    • perf(auth): 非 admin 首次登录仍撞 org-tree 冷派生 —— JIT 关闭 + 日志 + 同步后预热 · 5f063b0a
      上次修复(a3cbc827)只短路了空 orgScope(admin/集团级),非 admin(品牌/诊所级)
      用户第一次撞冷缓存仍全表扫。线上实测(379万行事务表)单次冷派生约 4.9s,JIT
      编译占近 20%。三处改:①派生查询关 JIT(SET LOCAL,不影响连接池其它请求)
      ②耗时打日志(>1s warn)方便下次卡顿时对时间戳 ③每日增量同步跑完主动
      forceRefresh 预热,把冷派生代价转给后台 cron,真实登录请求稳态下不再承担。
      TTL 6h→24h(配合每日预热,兜底而非决定性)。
      luoqi committed
    • perf(auth): orgScope 展开冷启动慢 —— 空 scope 短路 + 树缓存单飞/TTL · a3cbc827
      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>
      luoqi committed
    • fix(docs): auth-login 链接指向真实 endpoint 页 —— /docs/api/auth 是 tag 目录无 index(404),改… · 66876937
      fix(docs): auth-login 链接指向真实 endpoint 页 —— /docs/api/auth 是 tag 目录无 index(404),改 AuthController_exchangeToken
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • fix(docs): OpenAPI server 按环境注入 —— 本地 localhost、线上 build-arg 生产域名 · 28906322
      committed pac.json server 改回 localhost:3101(本地开箱即对);
      pac-docs Dockerfile build 期用 DOCS_API_URL 覆盖 servers;compose.prod 传生产域名。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
  9. 30 Jun, 2026 4 commits
  10. 28 Jun, 2026 2 commits
    • fix(persona): getMonetaryQuantiles 加 single-flight 防并发惊群卡死 · 64e0325e
      并发重算(--concurrency>1)时发现:缓存空/过期瞬间,N 个 recompute 同时调
      getMonetaryQuantiles → 同时发 N 份全租户聚合 $queryRaw(370万 facts 实测单跑 4.1s)
      → 打爆 4 核 DB + 连接池 → 进程卡死(stat=S,0 吞吐)。串行版无事因缓存命中只跑 1 次;
      且缓存 30min 过期,长批(~1.6h)半路会再次惊群。
      
      修:single-flight —— 缓存 miss 时把在飞 Promise 存 map,并发调用复用同一个查询,
      只跑 1 份。等待者 await JS promise(不占 DB 连接)。根治,也利好增量 cohort 路径。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • perf(persona): recompute CLI 加 --concurrency(默认1)—— I/O-bound,并发 ~4.5x · b5de1a0c
      实测推翻旧注记"persona 重算 CPU-bound、异步并发不提速":每患者 recompute 含 ~15 次
      串行 DB 往返(2 次全量 fact 读 + 多次 findFirst),实为 **I/O-bound**。单进程分块
      Promise.all 并发可重叠这些 DB 等待 —— 本地 500 患者 17s(串行)→ 4s(并发16),~4.5x,
      concurrency 8-16 后 plateau(DB 成瓶颈)。正确性验证:500/500 各 1 active persona、
      0 重复版本、0 空特征(不同患者独立写 + 同患者不并发,无竞争)。
      
      - --concurrency=N:分块并发(默认 1 向后兼容)
      - --shard=i/k:多进程分片(按患者序号取模,吃多核;DB 先饱和时用)
      - 大批(>500)只打进度行不逐条刷屏;orderBy id 稳定序供分片
      
      全量重算 131689 患者:7h(串行)→ ~1.6h(并发12)。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
  11. 27 Jun, 2026 10 commits
    • fix: agent 审查的一致性/UX 收口(by-id 守卫、死代码、陷阱注释、集团级诊所筛选) · c1b44721
      后端:
      - 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>
      luoqi committed
    • fix(auth): expandOrgScope fail-closed — 树外 ref 不再退化成"不限品牌"(跨品牌泄漏) · 42b9e8da
      agent 审查(3 个独立都挖到)的真·安全洞:expandOrgScope 对树里找不到的 ref 兜底成
      "当裸 clinicId、sourceUnits 留空"。而瑞尔/瑞泰共用 tenantId,sourceUnits=[] 被下游当
      "不限品牌" → 跨品牌看全部患者(含真号 reveal)。触发:退化树(无数据)、新诊所未进树、
      10min 缓存陈旧。
      
      - org-scope:探测 branded(树有非空 source_unit);品牌制租户 + 非空 scope 却没解析出
        任何品牌 → 塞 DENY_SOURCE_UNIT 哨兵(患者查询返回空),不退化成不限。命中集团根仍合法不限。
        单命名空间/退化树(无品牌)→ 维持 [] 不限(正确)。回传 unresolved 供刷新重试。
      - org-tree:展开有 unresolved → 强制重派生树一次再展开(避免误拒刚摄入的新诊所合法用户);
        forceRefresh 30s 防抖,挡 bogus ref 刷库。
      
      单测:品牌ref/诊所ref正常、集团根→不限、未知ref→DENY、混合(含已解析品牌)→限该品牌不DENY、
      单命名空间未知ref→不限。活体三级 + REST 无回归。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • refactor(auth): org 树从摄入数据派生(零硬编码 id,跨环境自适应) · 2a858612
      之前 org-tree 复用 jvs-dw-preset 的硬编码 clinic UUID 建树 —— 把通用隔离路径绑死到
      mock 演示数据 + 写死 id(换环境/换 DW 即废)。改为从摄入数据派生:
      
      - OrgTreeService(注入 Prisma):$queryRaw 从 patient_transactions.clinic_id JOIN
        patients.source_unit 派生「品牌→诊所」,按 (hostId,tenantId) 缓存(10min TTL);
        无数据→退化树。对任何 host/环境自适应,零硬编码 id。
      - AuthModule 导出 OrgTreeService;tenant-scope 拦截器(from+mergeMap 异步展开,ALS 不变)
        与 mcp-auth 注入它,每请求 expandScope。
      - jvs-dw-preset 退回**仅 mockLogin 用**(dev,env 门控):集团/品牌 slug 是稳定逻辑名;
        clinic UUID/中文名仅演示展示,无数据源,换环境降级成 id —— 隔离仍由 DB 派生树保证正确。
      
      验证(DB 派生树):诊所级石琳 sourceUnits=['瑞尔']/2诊所、品牌瑞尔/3、瑞泰/2、集团不限、
      REST /plans 仅命中本诊所 —— 与原硬编码结果一致。tsc 0 错。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • refactor(auth): JWT 改装通用 orgScope,过滤维每请求展开(设计 B) · 7fd4c0d8
      JWT 是 host 也能解码的凭证,原先装展开后的 sourceUnits/clinicIds(PAC 内部 schema 词汇),
      与"host 只发 org_scope"的契约不一致、泄漏内部表示。改为 JWT 只装 orgScope(与契约同词汇),
      过滤维留到每请求展开:
      - AccessTokenPayload / RefreshPayload:sourceUnits/clinicIds → orgScope;issueTokens 不再展开,直接签
      - org-tree.ts(纯函数 expandScope:按 tenantId 选 host org 树展开)+ jvs-dw-preset.ts(树数据,与 mock 共用)
      - tenant-scope.interceptor(REST)/ mcp-auth(MCP)每请求 expandScope → scope.{sourceUnits,clinicIds}
      - 前端展不开 orgScope → 新增 GET /auth/session 返回展开后的 clinicIds/sourceUnits + role/permissions/dictionary;
        auth-store 加 loadSession(登录/F5 后异步补进 user),组件读 user.clinicIds 不变
      - weixin-aibot 自签 token 同步改 orgScope;契约文档 §2/§3 同步
      
      验证:JWT 解码只含 orgScope;/auth/session 展开正确;REST /plans 诊所过滤生效(石琳仅本诊所);
      集团级 orgScope=[grp]→clinicIds=[]/sourceUnits=[] 不限。两端 tsc 0 错。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • fix(web): 模拟登录对话框分清两套权限 —— 角色=功能,选谁/哪级=数据范围 · fd57d73d
      原文案把数据权限说得像跟着角色("看自己的/看本团队所有任务"其实是 view_own/view_all
      功能权限,却像在描述数据范围)。点透两轴(正交):
      - 顶部加一句:① 角色=能做什么(功能);② 选谁/哪级=能看哪些诊所品牌(数据权限,跟角色无关)
      - ① 标题 "登录权限(角色)" → "角色 · 能做什么(功能权限)";角色 desc 改成纯功能口径
      - 集团按钮 "集团·X 身份" → "数据范围 · 集团级 …… 角色沿用上方「X」"(数据范围与角色解耦)
      - ② "选客服" → "数据范围 · 选客服 = 该客服的诊所(诊所级)+ 上方角色"
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • refactor(auth): 契约数据权限收敛为 orgScope 唯一入口(删 clinicIds/sourceUnits 入参) · aefe33ed
      第一性:换了 org-scope 数据权限后,clinicIds/sourceUnits 是 expandOrgScope 的**展开产物**
      (PAC 内部过滤维),不该出现在对外契约入参里 —— host 只该传"用户可达的 org 节点"。
      
      - TokenExchangeUser:删 clinicIds + sourceUnits,orgScope 转必填(任意层级:集团/区域/品牌/诊所)
        诊所级即传诊所 id;没品牌概念的 host 同理。clinicIds/sourceUnits 仅留在内部 JWT AccessTokenPayload。
      - issueTokens:永远走 orgScope 展开;非 jvs-dw host 用退化树(根=tenantId,无子)→
        orgScope 里诊所 id 当裸诊所透传、sourceUnits 留空、根 id=不限。删显式 fallback 分支。
      - auth-login-contract.md:org_scope 从"P2 可选增强"翻正为唯一契约入口;派生表/示例/实现现状同步
      - 活体三级复验:集团 []/0(不限);品牌 ["瑞尔"]/3;诊所 ["瑞尔"]/2
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • refactor(auth): mockLogin 走对外契约同一路径(issueTokens 单一真理源)+ 修 ['default'] bug · e7c3b3eb
      第一性:模拟登录 = 模拟 host 调换票,本应产出契约同形 user 上下文(核心 orgScope)
      并走与 exchangeToken **同一条**展开+签发。之前 mockLogin 把 expand+sign 抄了一遍,已飘:
      - 抽出 private issueTokens(host, user):orgScope→expandOrgScope→sourceUnits+clinicIds→签 access/refresh
      - exchangeToken 与 mockLogin 都委托它;mockLogin 只负责"preset → 契约 user(算 orgScope)"
      - 删 exchangeToken 的 clinicIds 空→['default'] 兜底:空=不限(下游 plan 过滤 length>0 才加),
        兜底会把集团级误锁成不存在的诊所 → 啥都看不到(mockLogin 本就没这兜底,统一后契约路径一并修)
      
      活体验证三级:集团 sourceUnits=[]/clinics=0(不限);品牌 ["瑞尔"]/3;诊所 ["瑞尔"]/2;瑞泰 ["瑞泰"]/2
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • feat(auth): org 树改递归结构 —— 层级无限(支持多级区域) · 07a32a03
      之前 OrgTree 是写死的 3 层形状 { groupId, brands:{clinics[]} },塞不进区域层。
      改成递归 OrgNode(children 无限嵌套):集团→多级区域→品牌→诊所→…均可。
      expand 逻辑不变 —— 过滤永远只落 sourceUnits/clinicIds 两维,中间层只是分组,
      展开任意节点 = 走子树收集这两类叶子属性。
      
      - OrgNode 递归类型 + buildTree(通用) ;buildOrgTree(3 层糖)签名/tree.groupId 不变
      - sourceUnit 命名空间**向下继承**:scope 到单诊所时带上祖先品牌,
        否则 sourceUnits 退化成空(不限品牌)= 越权看全品牌患者(测试抓出,已修)
      - 扁平索引按 id **聚合**(数组),同一品牌跨多个区域时全收,不被 Map 覆盖丢诊所
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • feat(auth): org 树从配置派生(去 hardcode)+ 模拟登录支持集团/品牌/诊所三级 · 44539c27
      - org-scope:删 JVS_DW_ORG_TREE hardcode,改 buildOrgTree(从配置/数据派生);
        实现对所有 host 通用,树数据 3 层本就在数据里(yaml 只为区域等数据外的层)
      - auth:jvsDwOrgTree() 从 MOCK_PRESETS 派生;exchangeToken + mockLogin 都走 expandOrgScope
      - mockLogin:数据范围统一 org-scope —— 诊所级(选客服)/品牌级(仅品牌)/集团级(orgLevel=group,
        sourceUnits=[] 看全部品牌全部诊所);MockLoginRequest 加 orgLevel
      - 前端:加"以集团身份登录"按钮(跨品牌,验证数据权限最上一层)
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed
    • feat(auth): 标准数据权限 org-scope(内存树展开,不建表) · db2c0874
      数据权限 = 用户的 org 范围(任意层级:集团/品牌/诊所)。expandOrgScope() 把
      org 节点展开成 sourceUnits + clinicIds(已有过滤层不变):
        诊所 ref → 该诊所 + 所属品牌;品牌 ref → 该品牌 + 其下全部诊所;
        集团 ref / 空 → 不限。多 ref 取并集。
      - org-scope.ts:jvs-dw 内存树(ruier-grp→瑞尔/瑞泰→诊所)+ expandOrgScope
      - 契约 exchangeToken 接 orgScope(优先于显式 sourceUnits/clinicIds)→ 展开
      - TokenExchangeUserSchema 加 orgScope
      验证:诊所/品牌/集团/跨品牌四级展开均正确。通用态树放 manifest yaml。
      
      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
      luoqi committed