# BD 手册：FDW 反向映射 — ETL 库读取业务库会员生日

## 概述

在 `etl_feiqiu`（生产）/ `test_etl_feiqiu`（测试）数据库中，通过 `postgres_fdw` 创建指向 `zqyy_app` / `test_zqyy_app` 的外部表 `fdw_app.member_birthday_manual`，使 ETL DWS 任务可只读访问助教手动补录的会员生日数据。

方向：`etl_feiqiu → zqyy_app`（与现有 `setup_fdw.sql` 的 `zqyy_app → etl_feiqiu` 方向相反）。

## 变更说明

| 库 | Schema | 对象 | 变更类型 | 说明 |
|----|--------|------|---------|------|
| etl_feiqiu / test_etl_feiqiu | — | zqyy_app_server / test_zqyy_app_server | 新建外部服务器 | 指向业务库 |
| etl_feiqiu / test_etl_feiqiu | — | etl_user → app_reader 映射 | 新建用户映射 | 只读角色映射 |
| etl_feiqiu / test_etl_feiqiu | fdw_app | — | 新建 schema | 存放来自业务库的外部表 |
| etl_feiqiu / test_etl_feiqiu | fdw_app | member_birthday_manual | 新建外部表 | 映射业务库 public.member_birthday_manual |

### 外部表列定义

| 列名 | 类型 | 说明 |
|------|------|------|
| id | BIGINT | 自增主键（源表 BIGSERIAL） |
| member_id | BIGINT | 会员 ID |
| birthday_value | DATE | 补录的生日值 |
| recorded_by_assistant_id | BIGINT | 提交助教 ID |
| recorded_by_name | VARCHAR(50) | 提交助教姓名 |
| recorded_at | TIMESTAMPTZ | 提交时间 |
| source | VARCHAR(20) | 数据来源标识 |
| site_id | BIGINT | 门店 ID（多门店隔离，Requirements 13.1） |

### 角色说明

| 角色 | 所在库 | 用途 |
|------|--------|------|
| etl_user | etl_feiqiu | ETL 连接角色，通过 FDW 读取业务库数据 |
| app_reader | zqyy_app | 业务库只读角色，供 FDW 用户映射使用 |

## 兼容性

- **ETL Connector**：DWS 任务（`member_visit_task`、`member_consumption_task`）通过 `fdw_app.member_birthday_manual` 读取手动补录生日，使用 `COALESCE(手动补录值, dim_member.birthday)` 合并
- **后端 API**：无影响，后端直接写入 `zqyy_app.member_birthday_manual`
- **小程序**：无影响
- **现有 FDW**：与现有 `setup_fdw.sql`（zqyy_app → etl_feiqiu 方向）互不干扰，使用不同的 server 名和 schema 名

## 回滚策略

在 ETL 库（`etl_feiqiu` 或 `test_etl_feiqiu`）中按逆序执行：

```sql
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_app REVOKE SELECT ON TABLES FROM etl_user;
REVOKE SELECT ON ALL TABLES IN SCHEMA fdw_app FROM etl_user;
REVOKE USAGE ON SCHEMA fdw_app FROM etl_user;
DROP FOREIGN TABLE IF EXISTS fdw_app.member_birthday_manual;
DROP SCHEMA IF EXISTS fdw_app CASCADE;
DROP USER MAPPING IF EXISTS FOR etl_user SERVER zqyy_app_server;       -- 生产
-- DROP USER MAPPING IF EXISTS FOR etl_user SERVER test_zqyy_app_server; -- 测试
DROP SERVER IF EXISTS zqyy_app_server CASCADE;                          -- 生产
-- DROP SERVER IF EXISTS test_zqyy_app_server CASCADE;                   -- 测试
```

回滚后 DWS 任务的生日读取将降级为仅使用 `dim_member.birthday`（API 来源），需确保降级逻辑已实现（任务 9.3）。

## 验证步骤

```sql
-- 1. 确认外部服务器存在
SELECT srvname, srvoptions
  FROM pg_foreign_server
 WHERE srvname IN ('zqyy_app_server', 'test_zqyy_app_server');
-- 预期：1 行（生产或测试环境对应的 server）

-- 2. 确认用户映射存在
SELECT s.srvname, um.umoptions
  FROM pg_user_mapping um
  JOIN pg_foreign_server s ON um.umserver = s.oid
 WHERE s.srvname IN ('zqyy_app_server', 'test_zqyy_app_server');
-- 预期：1 行

-- 3. 确认 fdw_app schema 存在
SELECT schema_name
  FROM information_schema.schemata
 WHERE schema_name = 'fdw_app';
-- 预期：1 行

-- 4. 确认外部表列结构完整
SELECT column_name, data_type
  FROM information_schema.columns
 WHERE table_schema = 'fdw_app'
   AND table_name = 'member_birthday_manual'
 ORDER BY ordinal_position;
-- 预期：8 列

-- 5. 确认外部表可读取（需业务库侧表已存在且网络连通）
SELECT COUNT(*) FROM fdw_app.member_birthday_manual;
-- 预期：返回行数（可能为 0）
```

## 关联文件

- 生产环境脚本：`db/fdw/setup_fdw_reverse.sql`
- 测试环境脚本：`db/fdw/setup_fdw_reverse_test.sql`
- 源表迁移脚本：`db/zqyy_app/migrations/2026-02-22__C2_member_birthday_manual.sql`
- 源表文档：`docs/database/BD_Manual_member_birthday_manual.md`
- 现有正向 FDW：`db/fdw/setup_fdw.sql`（zqyy_app → etl_feiqiu 方向）
- 需求文档：`.kiro/specs/etl-aggregation-fix/requirements.md` — 需求 5.3
