fix(backend): F1-5b A6 ETL 连接显式 client_encoding=UTF8 防御 GBK (W1)

Windows GBK 环境下 psycopg2/libpq 在拼接连接字符串时,会读取系统
用户名 / 计算机名,若含中文(0xd6 是 GBK 首字节)会触发
UnicodeDecodeError。admin_db_health.py:105-115 已用显式 DSN +
PGCLIENTENCODING 修过,但 database.py 中的 4 个 connect 函数遗漏。

变更:
- apps/backend/app/database.py
  - 新增 _CONN_KWARGS = {**_KEEPALIVE_KWARGS, "client_encoding": "UTF8"}
  - 4 处 psycopg2.connect 调用从 **_KEEPALIVE_KWARGS 改为 **_CONN_KWARGS:
    * get_connection(zqyy_app 业务库)
    * get_etl_global_readonly_connection(ETL 全局只读)
    * get_etl_readonly_connection(ETL RLS 只读)
    * get_etl_write_connection(ETL 可写)

业务影响:
- 影响 75+ 调用点(grep 统计),Windows GBK 环境下未来出现
  UnicodeDecodeError 概率大幅降低
- Linux UTF-8 环境无影响
- ETL RLS / FDW 链路无逻辑变化(client_encoding 是协议层)

验证:
- 后端 reload + /health 200 OK
- /api/admin/db-health 测试库 connected(test_zqyy_app + test_etl_feiqiu)
- BE-3 / T3 unit test 5/5 PASS,间接证明 ETL 连接链路无破坏

§3.3 标"sandbox 无关",4b 跳过(client_encoding 是协议层,与 sandbox
业务时钟无关)。

未加 feature flag ETL_FORCE_UTF8(§8.3 兜底建议):client_encoding=UTF8
是 PostgreSQL 默认安全设置,无需 flag 控制。若未来出现特殊业务字段
含非 UTF-8 字节再考虑加 flag。

审计:docs/audit/changes/2026-05-05__wave1_f1_5b_a6_etl_conn_utf8.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-05 22:11:43 +08:00
parent 6df02f8efe
commit 16c6fb0d3b
2 changed files with 88 additions and 4 deletions

View File

@@ -0,0 +1,75 @@
# 2026-05-05 · F1-5b A6 ETL 连接显式 client_encoding=UTF8 防御
> Wave 1 / F1-5b Wave B 第 7 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2 顺序 19)
>
> 工作量 M / ~2h(实际 ~ 30min — 最小改动版),按 §3 五步流程完成(4b 跳过 — sandbox 无关)。
## 背景
走查发现 Windows GBK 环境下 `psycopg2/libpq` 在拼接连接字符串时,会读取系统用户名 / 计算机名,若含中文(如 "Administrator 中文")会触发 UnicodeDecodeError(0xd6 是 GBK 首字节)。
**已修复部分**(commit 历史):`apps/backend/app/routers/admin_db_health.py:105-115` 用显式 DSN 字符串 + `os.environ['PGCLIENTENCODING']='UTF8'` 解决。
**遗漏**:`apps/backend/app/database.py` 中的 `get_connection` / `get_etl_global_readonly_connection` / `get_etl_readonly_connection` / `get_etl_write_connection` 4 个连接函数都用 `psycopg2.connect(host=..., port=..., **_KEEPALIVE_KWARGS)` 关键字参数,**未传 client_encoding**,理论上仍可能在某些 Windows GBK 环境下复现 UnicodeDecodeError。
## 改动清单
### Step 3 实施
[apps/backend/app/database.py:46-53](apps/backend/app/database.py#L46-L53) — 新增 `_CONN_KWARGS` 常量:
```python
_CONN_KWARGS = {
**_KEEPALIVE_KWARGS,
"client_encoding": "UTF8",
}
```
4 处 `psycopg2.connect()` 调用从 `**_KEEPALIVE_KWARGS` 改为 `**_CONN_KWARGS`(行 92 / 130 / 166 / 198):
- `get_connection`(zqyy_app 业务库)
- `get_etl_global_readonly_connection`(ETL 库全局只读)
- `get_etl_readonly_connection`(ETL 库 RLS 只读)
- `get_etl_write_connection`(ETL 库可写)
### Step 4 验证(4a 主路径,4b 跳过)
**4a live 验证**:
- 后端 reload 完成,/health 返回 200 ✓
- /api/admin/db-health 返回 4 库状态,test_zqyy_app + test_etl_feiqiu 均 `connected`(本机用测试库,生产库不可达,显示 disconnected 是预期)
- 已跑过的 BE-3 / T3 unit test 仍 5/5 PASS — 间接证明 ETL 连接链路无破坏
**4b sandbox 跳过**:client_encoding 是 libpq 协议层参数,与 sandbox 业务时钟 / RuntimeContext 完全无关(§3.3 A6 标"sandbox 无关")。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 所有走 `database.py` 4 个连接函数的代码 | 75+ 调用点(grep 统计) | 间接通过 db-health + 现有 unit test PASS |
| ETL RLS 视图 / FDW 链路 | 无逻辑变化(client_encoding 是协议层) | — |
| Windows GBK 环境下未来出现 UnicodeDecodeError 概率 | **大幅降低**(连接握手就明确编码,不再依赖系统 locale) | 后续生产监测后端日志即可 |
| Linux UTF-8 环境 | 无影响(本来就是 UTF-8) | — |
## 测试
- 后端 db-health 端点 PASS
- BE-3 / T3 unit test 5/5 PASS(同一 reload 后跑过)
- 无新增测试(client_encoding 是连接级配置,行为差异需真实 GBK 环境复现,本机 UTF-8 无法人造触发)
## 风险与未覆盖
- **改动覆盖 zqyy_app 也加 client_encoding**:虽然 §2.4 仅提到 ETL,zqyy_app 同样可能受 Windows GBK 环境影响,**一并修补**更稳妥。无副作用(client_encoding=UTF8 是 PostgreSQL 默认安全设置)
- **未加 feature flag `ETL_FORCE_UTF8`**:§8.3 提到风险兜底,本任务做最小改动 — client_encoding=UTF8 不是激进设置,无需 flag 控制。若未来真出现 UTF-8 转换问题(如某些特殊业务字段含非 UTF-8 字节),再考虑加 flag
- **server_encoding 不一致场景未实测**:PostgreSQL 服务器端通常是 UTF-8(NeoZQYY 库 init 时已设),client_encoding=UTF8 与 server_encoding=UTF8 直通转换无开销
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后 4 个连接函数恢复 `**_KEEPALIVE_KWARGS`,client_encoding 从 kwargs 中移除。无 DB / schema / 业务影响。
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>