# Wave 1 Day 1 — D Bug 三连修

| 字段 | 值 |
|---|---|
| 日期 | 2026-05-04 |
| Wave | 1 |
| 范围 | W1-T3 + W1-T4 + W1-T5(三个 P0 D Bug) |
| 文件改动 | 5 个 backend router + 1 个 jwt 模块 |
| 配套文档 | [WAVE-1-KICKOFF.md](../../_overview/WAVE-1-KICKOFF.md) §二 / [GLOBAL-DECISION-DASHBOARD.md](../../_overview/GLOBAL-DECISION-DASHBOARD.md) |

## 一、W1-T3 — 4 处 fdw_etl 必坏残留修复(P0-5 致命 1)

**问题**:`get_etl_readonly_connection(site_id)` 已直连 ETL 库 `etl_feiqiu`,但 SQL 仍写 `FROM fdw_etl.*`(业务库 zqyy_app 的 FDW schema,在 ETL 库中不存在)。生产**必报 schema 不存在**,被 try/except 静默吞 → 接口永远返回空列表,用户看不到错误。

**修复**:把 `fdw_etl.*` 替换为 `app.*`(ETL 库的 RLS 视图层)。

| # | 文件 | 改动 |
|---|---|---|
| 1 | [tenant_users.py](../../../apps/backend/app/routers/tenant_users.py):L431 | `fdw_etl.v_dim_assistant` → `app.v_dim_assistant` |
| 2 | [tenant_users.py](../../../apps/backend/app/routers/tenant_users.py):L456-L457 | `fdw_etl.v_dim_staff` + `fdw_etl.v_dim_staff_ex` → `app.v_dim_staff` + `app.v_dim_staff_ex` |
| 3 | [tenant_excel.py](../../../apps/backend/app/routers/tenant_excel.py):L394 | `fdw_etl.v_dim_assistant` → `app.v_dim_assistant` |
| 4 | [tenant_excel.py](../../../apps/backend/app/routers/tenant_excel.py):L411 | `fdw_etl.v_dim_staff` → `app.v_dim_staff` |
| 5 | [tenant_clues.py](../../../apps/backend/app/routers/tenant_clues.py):L119 | `fdw_etl.v_dim_member` → `app.v_dim_member` |

**校验**:`grep -rn "fdw_etl" apps/backend/app/routers/` 返回 0 处。

**调研依据**:[P0-5-engineering-consistency-overview.md](../../_overview/04a-feedback/P0-5-engineering-consistency-overview.md) F-2 子代理调研。

**影响**:tenant-admin 用户审核 / Excel 上传 / 维客线索 三个功能从"接口永远返回空列表"恢复正常。

---

## 二、W1-T4 — JWT aud 缺失修复(P0-5 致命 2)

**问题**:`auth/jwt.py` 签发 admin / miniapp token **完全不带 `aud` 字段**,`decode_access_token` 也不校验 aud。仅 tenant-admin 在自己的 `tenant_admins.py` 走完整 aud 流程。**意味着 admin / 小程序 token 在 payload 层无法区分,跨端横向越权风险**。

**修复策略**(本轮最小止血):
- **Sign 端写入 aud**:admin token aud="admin",miniapp token aud="miniapp"(包括 limited 版)
- **Decode 端**:加可选 `audience` 参数,默认 `verify_aud=False` 兼容旧 token
- **暂不强制 router 切换**:18 个 router 切换到强制 aud 校验留 Wave 2(避免单次改动面过大)

| 文件 | 改动 |
|---|---|
| [jwt.py](../../../apps/backend/app/auth/jwt.py) | `create_access_token / create_refresh_token / create_token_pair / create_limited_token_pair` 全部加 `audience` 参数(可选);`decode_token / decode_access_token / decode_refresh_token` 加 `audience` 参数(传入则强制校验,不传则关闭 verify_aud) |
| [auth.py](../../../apps/backend/app/routers/auth.py):L68/L106 | admin 登录与刷新加 `audience="admin"` |
| [xcx_auth.py](../../../apps/backend/app/routers/xcx_auth.py) 共 8 处调用 | 小程序登录 / dev-switch / refresh / dev-switch-status 全部加 `audience="miniapp"` |

**校验**:`grep create_token_pair\|create_limited_token_pair apps/backend/app/routers/xcx_auth.py` 全部带 `audience="miniapp"`;auth.py 全部带 `audience="admin"`。

**调研依据**:[00-P0-round2-feedback-response-summary.md §二.2](../../_overview/04a-feedback/00-P0-round2-feedback-response-summary.md)。

**Wave 2 后续**(本轮不做):
- 拆 `dependencies.py` 为 `get_current_user_admin / get_current_user_miniapp` 两个工厂
- 18 router 切换依赖 → 强制 aud 校验生效

**影响**:新签发 token 全部带 aud claim;旧 token(无 aud)仍可使用,过期前自然淘汰。

---

## 三、W1-T5 — DBViewer 白名单 + 黑名单双保险(P0-8)

**问题**:`db_viewer.py` 仅黑名单 5 关键词(INSERT/UPDATE/DELETE/DROP/TRUNCATE),**漏拦截 ALTER/CREATE/GRANT/REVOKE/COPY/CALL/COMMENT/VACUUM/REINDEX/CLUSTER/REFRESH/LOCK** 等 12+ DDL/DCL 关键词。admin-web 可执行 DDL 改库结构(假设只读账号未限制 DDL)。

**修复策略**:
1. **白名单**:SQL 必须以 `SELECT / WITH / EXPLAIN / SHOW` 开头(去注释/空白后)
2. **黑名单**(深度防御):语句中含 17 个 DML/DDL/DCL 关键词一律拒绝
3. **注释剥离**:DENY 检查前剥离 SQL 注释,避免 `-- evil DROP\nSELECT 1` 被误判为拒绝

**关键代码**(详见 [db_viewer.py](../../../apps/backend/app/routers/db_viewer.py)):
```python
_ALLOWED_PREFIXES = ("SELECT", "WITH", "EXPLAIN", "SHOW")
_DENY_KEYWORDS = re.compile(
    r"\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|COPY|CALL|COMMENT|VACUUM|REINDEX|CLUSTER|REFRESH|LOCK)\b",
    re.IGNORECASE,
)

def _strip_comments(sql): ...  # 剥离 -- 与 /* */
def _extract_first_keyword(sql): ...  # 剥注释后取第一个 token
```

**校验**(15/15 PASS):
- ✅ SELECT / WITH / EXPLAIN / SHOW 全部通过
- ✅ ALTER / CREATE / GRANT / DELETE / DROP 全部拒绝
- ✅ INSERT 含注释前导 拒绝
- ✅ `-- evil DROP\nSELECT 1`(注释里的 DROP)通过,不误伤
- ✅ `SELECT 1; DROP TABLE x`(多语句嵌入 DROP)拒绝
- ✅ `COMMENT ON TABLE x IS "y"`(DDL COMMENT)拒绝

**调研依据**:[04a-conflicts-P0-detail.md §P0-8](../../_overview/04a-conflicts-P0-detail.md)。

**剩余防线**:`get_etl_readonly_connection` 设了 `default_transaction_read_only = on`,即使代码漏 DDL 也会被 PG 事务级别拒绝(深度防御)。

---

## 四、风险与回滚

| 项 | 风险 | 回滚方法 |
|---|---|---|
| W1-T3 | 极低 — `app.v_*` 视图早已存在,只是 SQL 引用错 schema | git revert |
| W1-T4 | 极低 — sign 端加字段不影响旧消费方;新 token 仍能 decode(verify_aud=False)| git revert |
| W1-T5 | 中 — 用户输入复杂 SQL 时白名单可能误拦截(如 `WITH RECURSIVE INSERT INTO ...` 这类罕见语句)| git revert + 添加用户实际遇到的合法 SQL 到测试用例 |

## 五、未覆盖 / 后续 Wave

- W1-T4 强制 aud 校验切换 → **Wave 2**(18 router 拆 admin / miniapp 依赖)
- 配套单测覆盖 → **Wave 2**(`tests/test_auth_jwt.py` 加 audience 用例 + `tests/test_db_viewer.py` 加白名单用例)

## 六、commit 建议消息

```
fix(backend): Wave 1 Day 1 三个 P0 D Bug 修复

- W1-T3 修 4 处 fdw_etl.* 必坏残留 → app.* (P0-5 致命 1)
  · tenant_users.py L431/L456-457: v_dim_assistant + v_dim_staff(_ex)
  · tenant_excel.py L394/L411: v_dim_assistant + v_dim_staff
  · tenant_clues.py L119: v_dim_member

- W1-T4 JWT aud sign 端写入 (P0-5 致命 2 最小止血)
  · jwt.py 全部 token 创建/解码函数加 audience 参数
  · auth.py admin 端加 audience="admin"
  · xcx_auth.py miniapp 端加 audience="miniapp" (8 处)
  · 18 router 切强制 aud 校验留 Wave 2

- W1-T5 DBViewer 白名单 + 黑名单双保险 (P0-8)
  · 白名单: SELECT/WITH/EXPLAIN/SHOW 开头
  · 黑名单: 17 关键词覆盖全 DML/DDL/DCL
  · 注释剥离避免误伤;15/15 单测 PASS

参考: docs/audit/changes/2026-05-04__wave1_day1_d_bug_triple_fix.md
```
