# 权限隔离与集团级归属（2026-09-10）

## 105. var 声明提升冲掉登录身份 → 权限过滤失效（全量数据泄露）

**症状**：普通销售（`role=sales`）打开 `工具/客户360档案.html`，能看到全部 **1204 客户 / 120 集团**，含他人的商机、订单、主销产品、客诉。

**根因**：脚本顶部（`:164`）无条件调用 `loadFromStorage()`，它写入 `USER / isAdmin / userName`；但 `var isAdmin = false, userName = '', USER = null;` 的**赋值语句写在 `:211-212`**（文件更靠后）。脚本自上而下执行 → 刚登录的身份被后面的赋值冲回默认值 → `userName=''` → 过滤条件 `sales.indexOf(userName) >= 0` 中 `indexOf('')` **恒为 0（恒真）** → 全部放行。

**为什么平时没暴露**：飞书免登路径（带 `?code=` → `feishuAutoLogin()`）是**异步回调**，在 `:211` 之后才执行，掩盖了 bug。只有 localStorage 路径（无 code 直接打开 / 收藏夹进入）才触发。**测试必须走 localStorage 路径**（注入 `bior_sw_user_v2` 后直开页面）。

**修复四件套**：
1. 把 `var isAdmin = false, userName = '', USER = null;` **提到所有调用之前**（`ROLE_KEY` 定义之后）。
2. 过滤前加 `if (!userName) return;` 兜底——身份未就绪一律不渲染。
3. `renderList()` 去掉 `src === DATA.customers` 限制，让**集团视图同样受控**（客户/集团的 `sales` 字段同构，一份过滤逻辑通吃）。
4. 直链校验 `openCust()` 同样加 `!userName` 判断。

**验证（CDN 线上实测）**：

| 身份 | 按客户 | 按集团 | 圣湘集团 | 东方基因集团 |
|---|---|---|---|---|
| 李江辉(sales) | 111 | 13 | ✅ 可见（仅 1 个抬头） | 不可见 |
| 赵云浩(sales) | 49 | 8 | ✅ 可见 | 不可见 |
| 刘子研(sales) | 25 | 5 | 不可见 | ✅ 可见 |
| 刘新元(admin) | 1204 | 120 | ✅ | ✅ |

**通用铁律**：凡“读登录态 → 过滤数据”的页面，**声明语句必须在读取之前**；空身份串一律拒绝渲染，**给不依赖 `indexOf('')>=0` 这类恒真表达式**做权限判断。

## 106. 集团卡可见性 = 集团归属 ∪ 成员归属；非负责人按“本人名下抬头”范围渲染

**场景（用户口径）**：圣湘集团整体归属赵云浩，但其中 1 个抬头「湖南圣维动牧生物科技发展有限责任公司」（2.8 万）归属李江辉。集团卡此前只认集团级归属 → 李江辉看不到自己的客户；若直接把他放进整张集团卡，他反能看到赵云浩的 6 个抬头 / 1354 万 / 商机 / 客诉（跨部门泄露）。

**做法（三条，缺一不可）**：
1. **可见性**：`renderList()` 的过滤条件加第二支——`c.members && c.members.length && mineInGroup[c.name]`；`mineInGroup` 从 `DATA.customers` 里本人名下客户的 `group` 反查。实测 120 个集团中**仅圣湘集团因此多开放给 1 人**，其余 119 个不变（改前先跑这个只读对比）。
2. **范围**：非集团负责人命中该卡时，用 `scopeGroup(g, user)` 生成**即时作用域副本**——从 `DATA.customers` 取 `c.group === g.name && c.sales 含 user` 的成员重新聚合（`total/y2024/y2025/y2026/h2_2026/orders/contacts` 求和；`top_products` 按 `sku` 合并金额取前 5；`opps/complaints/recent_orders/risks/activities` 拼接），其余成员**不进 DOM**。集团负责人与 admin/gm 走原完整卡。
3. **标识**：作用域副本加 `_scoped` → 头部「🔒 权限范围内 N 个抬头」、区块标题「你名下的抬头」，并且**不要写出集团归属人姓名**（那是同事信息）。

**坑**：集团记录**不含 per-member 归属**（只有 `members` 名单 + `member_totals`），必须用 `DATA.customers` 的 `group` + `sales` 现场 join；`top_products` 字段是 `sku`/`name`/`amt`（不是「货号/产品/累计金额」）。异步的「⭐客户特殊要求」按集团名判权，非负责人拿不到（实测显示“暂无记录”），无需额外屏蔽。

**改完必做**：`node --check`（提取 `<script>` 拼桩变量）→ 部署 → CDN 断言「页面全文不含同事名与他人抬头」+ 反向对照（负责人仍是完整卡）。

## 107. AI 助手：问权限外客户一律不回复（含反推断防线）

**用户口径（硬要求）**：销售在 AI 助手页面问到自己权限外的客户信息，**一律不回复**。

这不只是“不列数据”——以下四类都算泄露，逐条拒绝：
1. 不确认该客户是否存在于工作台（**存在性本身就是信息**）；
2. 不透露该客户的归属人 / 所属部门；
3. 不得用集团口径、区域口径、金额反推某个单体客户（“某集团下有几家抬头、分别多大”同理）；
4. **上传文件里的越权客户/人员数据同样不得引用**——上传不是权限豁免通道。

**实现位置**：`ai_chat.py` 的 `_build_system_prompt` 写死反推断铁律（提示词层），且 `_search_background` / `_load_customer360` / `_identify_customer` 全部先过 `_name_visible(name, vcust, vgroups)`；admin/gm 豁免。

**为什么提示词层也要写**：检索层过滤只保证“不喂数据”，但 LLM 仍可能从集团聚合、活动描述、上传文件、对话历史里**推断**出来。过滤 + 提示词双保险，缺一不可。

**回归验收（必做）**：用 sales 角色问一个名单外客户（已知其在库中），应得到明确的不回复，且回答与 `sources` 中**不含任何该客户的数据字段**。仅看“没报错”不算通过。

## 104. 集团级归属 ≠ 成员归属并集

**场景**：集团卡片的 `sales` 默认由成员归属**并集**派生（`build_group()` 里 `{c['sales'] for c in members}`）。当集团作为整体有唯一负责人、而个别成员另有归属时，会显示两个人名。

**修法**：`_key_customers.json` 中 `name` 以「集团」结尾的条目 = **集团级归属**。`build_customer_360.py` 的 `load_auth_sales()` 收集它们到 `_KC_GROUP_OWNER`，新增 `group_owner()` helper，`build_group()` 优先取它、无则回落成员并集。

**收益**：把 39 条集团条目全部纳入后，与成员并集相比**只有 2 个集团卡会变**（圣湘集团、东方基因集团），其余 118 个不受影响 → 天然满足“只改指定的、其他不动”。**改前先跑这个只读对比，是判断改动面的标准动作。**

**注意**：集团卡列表行**不渲染** `👤sales`（`:346` `meta = isGroup ? 'N个抬头' : '👤sales'`）。归属人只体现在**搜索命中**和**权限过滤**上；别去列表行找名字，会误判为“没生效”，要看就得展开卡片或直接断言过滤后的计数。

## 附：改权限表时保住原格式

`_key_customers.json` 用 `indent=2`（对象缩进）+ `ensure_ascii=False` + **无尾换行**。
用 `re.findall(r'\n(\s+)"name"')` 探测缩进会**误拿到 key 的 4 空格**，整文件被重排成 indent=4 → 全量 diff。
正确做法：按对象缩进（`'[\n  {'`）判定，写完 `diff` 校验“应仅变动预期行数”。
