# baoyu-workbench-frontend-pitfalls — auth permission

## 110. 带鉴权的数据 Tab（上传/台账/文件下载）三个坑（2026-09-19）

以报销 Tab 为例，新增一个「需要后端鉴权 + 文件下载 + 上传」的 Tab 时必踩：

1. **下载不能写 `<a href="...">`** —— 链接请求带不上 `Authorization` 头，接口必 401。统一写 `apiFetch(url) → r.blob() → URL.createObjectURL → 临时 <a download>` 触发保存，用完 `revokeObjectURL`（同理：`<img src>`、`window.open` 都不行）。
2. **并行 fetch 渲染同一区域会竞态**：两个接口分别渲染同一块 DOM 时，先返回的那个可能不带后者的数据（如 list 接口不带 summary）→ 筛选条/图表渲染成空，看起来像「接口坏了」。规矩：**每个响应必须带上该区域需要的全部数据**（或渲染函数自身幂等且从一份共享 state 读），别让两个响应各管一半。
3. **Tab 权限用服务端能力位，不用前端隐藏**：登录/会话里不携带权限时，Tab 渲染后先调一次状态接口（如 `/api/expense/status`）读 `allow`，未开通就渲染「未开通」占位并隐藏上传区——前端隐藏拦不住直接调接口的人。

**上传接口的边上功夫**：多文件 multipart 字段名固定（如 `files`），前端先按扩展名过滤再提示「跳过 N 个」；服务端要回 `saved`/`skipped` 两个数组，用户才知道哪几个没进去。



## 110b. user_roles 锁库 + 用户管理服务端化（2026-09-20）

**问题**：CloudBase `user_roles` 集合 ACL 为 `read:true` → 任何登录用户可读**全公司 47 人的明文密码**；`admin.html` 整个用户管理（登录/列表/改密/新增）直连集合；`/api/auth/token` 还把 `pwd` 回传给客户端。

**修法（顺序很重要）**
1. 先加服务端接口：`/api/users/list`（含 `has_pwd`，不含 pwd）、`/api/users/roster`（任意登录用户）、`/api/users/save`、`/api/users/reset_pwd`（均 admin/gm）。
2. 登录响应**去掉 `'pwd': user.get('pwd','')`**（token 与飞书免登两处）。
3. 客户端 `loadUserList()` 改走 `/api/users/roster`，CloudBase 读作垂底（锁库后会静默失败，不能当主路径）。
4. `admin.html` 改服务端版（登录走 `/api/auth/token`，CRUD 走 `/api/users/*`），彻底移除集合直连。
5. **最后**才锁库：`tcb api tcb ModifySafeRule --body '{"EnvId":"...","CollectionName":"user_roles","AclTag":"CUSTOM","Rule":"{\"read\": false, \"write\": false}"}'` → 锁完必须三个账号回归登录 + 管理页列表。

**要点**：飞书免登走 `/api/feishu/login`（后端换身份），**不读 CloudBase** —— 所以锁库不会打断免登；登录只认本地快照 `数据/user_roles.json`，admin.html 写的就是它。备份页 `init_users.html`/`add_hechaojin.html` 会被锁库打断，已由 admin.html 取代。



## 105/104. 档案页权限泄露 & 集团级归属（2026-09-10）

`var` 赋值语句写在 `loadFromStorage()` 之后 → 登录身份被冲回默认值 → `indexOf('')>=0` 恒真 → 普通销售看到全部 1204 客户。集团卡归属默认取成员并集，`_key_customers.json` 里 `XX集团` 条目才是集团级权威。**详见 `references/permission-isolation.md`**。



## 101. 待办跨设备不同步：todos 集合 ACL PRIVATE + 匿名登录 open_id 隔离（2026-09-05）

**症状**：手机端设置的待办，电脑端看不到（反之亦然）。

**根因**：待办同步靠 CloudBase `todos` 集合，权限是 PRIVATE（仅创建者可读写）。工作台用**匿名登录**，每台设备的匿名 open_id 不同，导致每个设备只能读写自己 open_id 的文档，互相隔离。结果同一 user 在集合里分裂成多个文档（实测 `刘新元` 分裂成 4 个：电脑端29条、手机端3条各自独立）。

**修复**：
1. 改 ACL：`tcb api tcb ModifySafeRule --body '{"EnvId":"bier-sales-d0gatbvlx288724e9","CollectionName":"todos","AclTag":"CUSTOM","Rule":"{\"read\": true, \"write\": \"auth != null\"}"}'`。
   ⚠️ 必须用 `ModifySafeRule`（CUSTOM 自定义规则），**不是** `ModifyDatabaseACL`（后者只支持 PRIVATE/READONLY/ADMINWRITE/ADMINONLY 四个简易值，传 Rule 会报 UnknownParameter）。
2. 合并重复文档：`tcb db nosql execute` 的 DELETE/UPDATE。
   ⚠️ **坑**：MgoCommand 里用 `_id: {"$oid":"..."}` 会报 `InvalidParameter`；改用业务字段定位（如 `user + updated`）才成功。

**预防**：涉及 CloudBase 集合 + 匿名登录的跨设备同步，ACL 一律不能 PRIVATE。参考 `user_roles` 集合的 CUSTOM 规则（`read:true, write:"auth.openid == doc._openid"`）——它是「读放开+写仅创建者」，而 todos 需要「读写都放开」（`read:true, write:"auth != null"`）才能跨设备写同一个文档。



## 37. 非 Admin 用户数据隔离——完整实现（2026-07-27 已修复）

### 症状
赵云浩（大客户部销售员）登录后，客户 Tab 1680 条全量、订单管理 134 条全量、客诉 313 条全量。

### 实现架构
**用户部门 → MCP 部门名映射**（因登记部门名与 MCP 中不一致）：

```javascript
var USER_DEPT_MAP = {
  '大客户部': '诊断原料大客户销售部',
  '销售拓展部': '诊断原料销售拓展部',
  '浙江': '浙江', '广东': '广东',
  '诊断原料销售部': '诊断原料销售拓展部',
};
var USER_AREA_MAP = {
  '大客户部': '大客户', '销售拓展部': '销售拓展部',
  '浙江': '浙江', '广东': '广东',
  '诊断原料销售部': '销售拓展部',
};
```

**隔离辅助函数**：
```javascript
function getUserMcpDepts() {
  var u = state.user || {};
  if (u.role === 'admin') return null;  // admin 看全部
  var dept = u.dept || '';
  var mcp = USER_DEPT_MAP[dept] || dept;
  return [mcp];
}
function getUserAreas() {
  // 同上，用于客诉 area 过滤
}
```

**各 Tab 过滤点**：

| Tab | 过滤方式（2026-07-27 终版） | 过滤字段 |
|-----|---------|---------|
| 客户 | **current_status_operator 包含用户** | `c.operators.indexOf(userName) >= 0` |
| 订单 | **创建者** | `o.creator === userName` |
| 客诉 | **创建者** | `ci.creator === userName` |
| 商机沙盘 | **销售员名** | `o.sales === userName` |
| 访前准备 | 同客户（operators 匹配） | 同客户 |
| 我的活动 | `activities.filter(a => a.creator === user)` | `a.creator` |
| 看板 KPI | `getFilteredCustomers().reduce(sales_2026)` | ERP 汇总（已按 salesperson 过滤） |

### 订单 dept 字段
`pull_orders.py` 必须拉取 `field_5ed7ab`（销售部门），`shipment_pipeline.py` 透传为 `dept`。否则 `getShipmentOrders` 的 `USER_DEPTS` 过滤匹配不到任何订单。

### 验证结果（赵云浩视角，2026-07-27 终版）

| Tab | 修复前 | 修复后 | 隔离核心 |
|------|:--:|:--:|------|
| 客户 | 1680 | **63** | current_status_operator 匹配 |
| 订单 | 525 | **109** | 创建者 |
| 客诉 | 313 | **35** | 创建者 |
| 商机沙盘 | 339 | **29** | salesperson |
| 看板 KPI | 293.9万 | 不变 | ERP 已按 salesperson |

### loadData() 集成

```javascript
// Promise.all 完成后，render() 之前：
state.data = data;
if (data.mcpCustomers) {
  data.mcpCustomers.customers = adaptMcpFields(data.mcpCustomers);
}
```

### 字段不匹配症状速查

| 症状 | 代码用 | MCP 实际字段 | 
|------|--------|-------------|
| 集团列全部"—" | `c.group` | `c.field_c3224b` |
| 部门筛选全部失效 | `c.dept` | `c.field_5ed7ab` |
| 客户级别透视失效 | `c.level` | `c.field_c8e80d` |
| 客户类型/等级透视失效 | `c.ctype` | `c.field_17186c` |
| 联系人全部显示0 | `c.contacts` | `c.field_8c870a` |
| 商机数全部显示0 | `c.opps` | `(c.field_79aa0b \|\| []).length` |
| 所在地显示空 | `c.location` | `c.field_6415cf` |
| 飞书链接跳错空间 | `c.fid` + 旧URL | `c.work_item_id` + `FEISHU_URL` |
| 金额全部"—" | `c.amount` | ERP交叉匹配或销售订单聚合 |

### 新增字段时的检查清单

1. `list_workitem_field_config` 查询 `project_key=6593cd71471290e3cc6be6e6`, `work_item_type=65ae1e403c87b152f3365ca6`
2. 确认字段存在 → 在 `adaptMcpFields()` 中添加映射行
3. `select`/`multi-select` 直接取值，`workitem_related_multi_select` 取数组 `.length`，`number` 直接使用
4. 改完 Python 括号验证 + `tcb hosting deploy` 两处路径



## 78. CloudBase SDK 加载失败 → 登录按钮静默失效（2026-07-31 诊断）

**症状**：登录页正常显示（Logo + 下拉框 + 密码框 + 登录按钮），但点击「登 录」**完全没有反应**——无错误提示、无加载动画、无网络请求。

**根因链**：
1. `static.cloudbase.net/cloudbase-js-sdk/latest/cloudbase.full.js` 加载失败（飞书内置浏览器网络限制/防火墙/代理）
2. `init()` 第 1 行 `cbApp = cloudbase.init({ env: CB_ENV })` 抛出 `ReferenceError: cloudbase is not defined`
3. **`init()` 没有任何 try/catch**，整段 JS 脚本执行中断
4. `doLogin()` 函数永远不会被定义
5. 登录按钮的 `onclick="doLogin()"` 变成一个空调用 → 点击无任何反应

**代码位置**：`工具/index.html`
```javascript
// L518-557: init() 无 try/catch
function init() {
  cbApp = cloudbase.init({ env: CB_ENV });  // ← 这里崩溃，下面全部不执行
  cbDb = cbApp.database();
  // ...
}
// L2889
init();  // ← 页面加载时直接调用，无错误处理
```

**验证方法**：
```javascript
// 浏览器控制台
typeof cloudbase  // → undefined = SDK 未加载
typeof doLogin    // → undefined = 函数未定义
```

**修复方案**：
```javascript
function init() {
  // 1. 检测 SDK 是否加载
  if (typeof cloudbase === 'undefined') {
    document.getElementById('loginSub').textContent = '⚠️ 网络异常，请刷新重试';
    document.getElementById('loginBtn').disabled = true;
    return;
  }
  try {
    cbApp = cloudbase.init({ env: CB_ENV });
    cbDb = cbApp.database();
    // ... 其余 init 逻辑 ...
  } catch(e) {
    document.getElementById('loginErr').textContent = '初始化失败：' + e.message;
    document.getElementById('loginErr').style.display = 'block';
    document.getElementById('loginBtn').disabled = true;
  }
}
```

**触发场景**：
- 飞书内置浏览器网络策略限制 `static.cloudbase.net`
- 公司网络/防火墙拦截腾讯云 CDN
- iOS/Android WebView 的 ATS 或混合内容策略
- 用户切换网络后 CDN 缓存失效

**与外部浏览器的差异**：外部浏览器（Safari/Chrome）通常能正常加载 CloudBase SDK，因此同 URL 在飞书外正常、飞书内无反应是典型特征。



## 22. 离线登录保障——API 全部失败时的硬编码回退（2026-07-26）

**背景**：CloudBase HTTPS 页面登录依赖 Tunnel API 或 localhost API。但 Tunnel 不稳定（cloudflared 进程在但连接失败），localhost HTTP 被浏览器 mixed content 策略拦截。两个 API 全挂时用户无法登录。

**修复**：在 `doLogin()` 的 API 降级链末尾增加离线凭据检查：

```javascript
function doLogin() {
  // ... 输入校验 ...
  
  // 离线用户表（当 API 不可用时使用）
  var OFFLINE_USERS = {
    'admin':                {name:'超级管理员', role:'admin', dept:'诊断原料销售部'},
    'liuxinyuan@biori.com': {name:'刘新元', role:'admin', dept:'诊断原料销售部'},
  };
  var OFFLINE_PWD = 'biori2026';

  function tryLogin(i) {
    if (i >= apiUrls.length) {
      // API 全部失败 → 离线登录
      var u = OFFLINE_USERS[email.toLowerCase()];
      if (u && pwd === OFFLINE_PWD) {
        finishLogin(u);
      } else {
        errEl.textContent = '认证服务不可用，且离线凭据不匹配';
        // ...
      }
      return;
    }
    // ... 正常 API 登录 ...
  }
}
```

**关键点**：
- 离线表只包含核心管理员——不暴露完整用户列表
- `apiUrls` 先试 Tunnel(HTTPS) 再试 localhost(HTTP)，都失败才离线
- 离线凭据与前端的 `finishLogin()` 共享同一数据流，无需额外适配



## 27. CloudBase DB 字段名与代码不一致 → 全员无法登录（2026-07-26）

**症状**：输入正确密码报"密码错误"，控制台无任何 JS 报错。

**根因**：`user_roles` 集合密码字段是 **`pwd`**，代码校验 `user.password`（undefined）→ 恒不等。user_roles 实际字段：`name/dept/role/tabs/email/status/pwd`。

**调试手法**（极强）：在已加载 cloudbase SDK 的页面里直接用 browser_console 查库验证字段名与数据：
```javascript
cbDb.collection('user_roles').where({name:'刘新元'}).get().then(r => JSON.stringify(r.data))
```
⚠️ 安全后果：匿名登录后任何人都能拖全表（含明文 pwd）——长期应哈希或迁后端 DEFAULT_USERS 体系。



## 28. CloudBase DB 查询必须在匿名登录 signIn() 完成之后（2026-07-26）

**症状**：用户下拉框永远走 fallback 硬编码名单（无徽章、无部门）；飞书免登查 user_roles 也失败。

**根因**：`loadUserList()` 在 `auth.anonymousAuthProvider().signIn()` resolve 之前调用，未认证查询必败进 catch。

**模式**：init() 里先 `signIn().then(...)`，所有 cbDb 查询（loadUserList / feishuAutoLogin / 免登身份兑换）放进 then 里；signIn 的 catch 里也要 showLoginForm() 保证用户能到登录界面。



## 74. 统一硬编码登录——所有用户 biori2026 + personnel.json（2026-07-28）

CloudBase DB 认证已废弃。所有销售员统一验证链：密码 `biori2026` → `_personnel.json` 查角色/部门。

```javascript
function doLogin() {
  if (pwd !== 'biori2026') { errEl.textContent = '密码错误'; return; }
  fetch('../数据/pricing/_personnel.json?v=' + Date.now(), { cache: 'no-store' })
    .then(function(r) { return r.json(); })
    .then(function(data) {
      var found = (data.personnel || []).filter(function(p) { return p.name === name; })[0];
      if (found) {
        var role = found.role;
        if (name === '刘新元' || role === '销售总监') role = 'admin';
        else if (role === '销售经理' || role === '大客户经理') role = 'manager';
        else role = 'sales';
        finishLogin({name: found.name, role: role, dept: found.dept || '', email: found.email || '', tabs: {dashboard: true}});
      } else {
        finishLogin({name: name, role: 'sales', dept: '', email: '', tabs: {dashboard: true}});
      }
    });
}
```

**关键**：`_personnel.json` 中 role 是中文职衔 → 必须映射到系统角色。刘新元（销售总监）→ `admin`。不在人员表中的人仍可登录（默认 sales，无部门信息）。



## 84. 看板筛选器权限模型（2026-08-01）

### 权限矩阵

| 角色 | 筛选器可见 | 可选部门 | 可选销售员 | 默认 |
|------|:--:|------|------|------|
| admin / gm | ✅ | 全部3部门 | 全部在职 | 全公司 |
| manager（刘欣） | ✅ | 仅 `销售拓展部` | 本部门全部 | 销售拓展部 |
| manager（吴云） | ✅ | 仅 `生命科学部` | 浙江区域 | **浙江区域** |
| manager（韩远怀） | ✅ | 仅 `生命科学部` | 广东区域 | **广东区域** |
| sales | ❌ | — | — | 仅自己 |

### Manager 默认锁定

Manager 登录后自动应用管辖范围筛选。区域经理（吴云/韩远怀）额外按 `_personnel.json.region` 匹配区域。

```javascript
// Manager 默认筛选
if (dashboardDeptFilter.length === 0 && dashboardSalesFilter.length === 0) {
  dashboardDeptFilter = [mgrDeptName];
  if (mgrRegion.indexOf('浙江') >= 0 || mgrRegion.indexOf('广东') >= 0) {
    dashboardSalesFilter = availableSales.filter(function(s) { return s !== userName; });
  }
}
```

### 筛选联动（全部模块）

筛选后 **所有模块** 联动：
- KPI 统计卡（客户数/价税合计/本月价税/联系人）
- 年度达成率 donut（目标 + 实际均按筛选）
- H1/H2 月度趋势图
- 客户 Top10 + 三年逐月对比
- 数据源：`dash.people[].target/h1_total` + `prog.by_salesperson` + `mcpCustomers.owner/operators` + `custs.salesperson`

### 双选取交集

同时选部门+销售员时，仅显示**同时满足两个条件**的数据。

详见 `references/kpi-tax-fix-all-modules.md`。



## 93. 工作台新增 Tab 的完整改动清单 + 内嵌独立页面免登录（2026-08-30）

新增一个 tab（如「岗前培训」）需改 **4 处**，且 app.html 与 index.html **双文件都要同步**（见 #86）：

1. `TABS` 数组加 `{ id: 'training', label: '岗前培训', icon: '🎓', lines: ['da','ls'] }`
2. `render()` 的 `switch(state.tab)` 加 `case 'training': renderTraining(main); break;`
3. 新增渲染函数 `function renderTraining(main) { main.innerHTML = '<iframe src="/training/index.html" ...>'; }`
4. 双文件都要 `tcb hosting deploy`：`工具/app.html`→`/工具/app.html`、`工具/index.html`→`/workbench/index.html`

**内嵌独立页面的两个关键**：
- **iframe `src` 用绝对根路径** `/training/index.html`（从 CloudBase 根算，不受父页面所在目录影响）。
- **同 origin 免二次登录**：独立页面与工作台同 domain（tcloudbaseapp.com），localStorage 共享。独立页面 `init()` 先读自己的 ROLE_KEY，没有则读工作台的 `localStorage.getItem('bior_test_user_v1')`（app.html 的 key），取到 `name` 就 `cbDb.collection('user_roles').where({name}).get()` 回查拿完整信息（含 email/role）自动登录。⚠️ app.html 的 `finishLogin` 存的是 `{name, role, dept, line}` **无 email**，自动继承时必须回查 user_roles 补 email，不能直接 trust 工作台缓存。

改完照 #25 验语法（`node --check` 提取的内联 JS + 括号平衡），部署后照 #19 `curl -H "Cache-Control: no-cache"` grep 新 tab 标记确认生效。

多用户应用（复用 user_roles 登录 + 新集合 + ACL + 英文路径部署）完整搭建模板见 `baoyu-cloudbase-deployment` 技能的 `references/multi-user-app-pattern.md`。



## 96. CloudBase 集合权限设置：ModifyDatabaseACL 不支持 Rule，自定义规则用 ModifySafeRule（2026-08-30）

**场景**：新建 CloudBase 集合（如培训系统的 training_assignments/training_progress/training_scores），要设成「所有人可读可写」让前端匿名登录能读写。

**根因**：`tcb api tcb ModifyDatabaseACL` 只支持预设 `AclTag`（PRIVATE/READONLY/ADMINWRITE/ADMINONLY），**传 `Rule` 参数报 `The parameter Rule is not recognized`**。自定义规则必须走另一个 API。

**修复**：用 `ModifySafeRule`，`AclTag=CUSTOM` + `Rule`：
```bash
tcb api tcb ModifySafeRule --api-version 2018-06-08 \
  --body '{"EnvId":"bier-sales-d0gatbvlx288724e9","CollectionName":"<集合>","AclTag":"CUSTOM","Rule":"{\"read\":true,\"write\":true}"}' --json
```

**关键事实**：
- 新集合默认 `AclTag=PRIVATE`（仅创建者可读写）→ 管理员读不到员工数据、员工写不了自己的记录。所以建多用户集合后**必须**改权限。
- `user_roles` 实际权限是 `read:true`（所有人可读，匿名登录才能登录）+ `write:auth.openid==doc._openid`（仅创建者写），不是 skill 早先记的 `read:auth!=null/write:auth!=null`。
- 验证：`tcb api tcb DescribeDatabaseACL --api-version 2018-06-08 --body '{"EnvId":"...","CollectionName":"..."}' --json`
