# 设计文档：小程序数据库基础设施层（miniapp-db-foundation）

## 概述

本设计实现 P1 基础设施层的三大核心能力：

1. **业务库 Schema 划分**：在 `test_zqyy_app` 中创建 `auth`（认证）和 `biz`（业务）两个 Schema，配合权限管理
2. **ETL 库 RLS 视图层**：在 `test_etl_feiqiu.app` Schema 中为 35 张 DWD/DWS 表创建行级安全视图，通过 `site_id` 隔离多门店数据
3. **FDW 跨库映射**：通过 `postgres_fdw` 将 ETL 库的 RLS 视图映射为业务库的只读外部表

**环境变量驱动**：所有数据库名称通过 `.env` 环境变量引用，不硬编码。迁移脚本中使用占位符，验证脚本从 `PG_DSN` / `APP_DB_DSN` 解析连接信息。

| 环境变量 | 用途 | 示例值 |
|---------|------|--------|
| `PG_DSN` | ETL 库连接字符串 | `postgresql://user:pass@host:5432/test_etl_feiqiu` |
| `APP_DB_DSN` | 业务库连接字符串 | `postgresql://user:pass@host:5432/test_zqyy_app` |

整体数据流向：

```
ETL 库（PG_DSN）                         业务库（APP_DB_DSN）
┌─────────────────────┐                  ┌─────────────────────┐
│ dwd.dim_member      │                  │ auth (用户认证)      │
│ dwd.dim_assistant   │                  │ biz  (业务数据)      │
│ dws.dws_*           │                  │ public (系统管理)    │
│ dws.cfg_*           │                  │                     │
│         │           │                  │ fdw_etl             │
│         ▼           │   postgres_fdw   │   ├ v_dim_member    │
│ app.v_dim_member    │ ◄──────────────► │   ├ v_dim_assistant │
│ app.v_dws_*         │  IMPORT SCHEMA   │   └ v_dws_*        │
│ (RLS: site_id 过滤)  │                  │   (外部表，只读)     │
└─────────────────────┘                  └─────────────────────┘
```

## 架构

### 分层架构

```mermaid
graph TB
    subgraph "业务库（APP_DB_DSN）"
        AUTH["auth Schema<br/>用户认证、权限、映射"]
        BIZ["biz Schema<br/>业务数据"]
        PUBLIC["public Schema<br/>系统管理表（保留）"]
        FDW["fdw_etl Schema<br/>FDW 外部表（只读）"]
    end

    subgraph "ETL 库（PG_DSN）"
        APP["app Schema<br/>RLS 视图层"]
        DWD["dwd Schema<br/>明细层（11 张表）"]
        DWS["dws Schema<br/>汇总层（24 张表）"]
    end

    FDW -->|"postgres_fdw<br/>IMPORT FOREIGN SCHEMA"| APP
    APP -->|"WHERE site_id = current_setting(...)"| DWD
    APP -->|"WHERE site_id = current_setting(...)"| DWS
```

### 执行顺序

迁移脚本必须按以下顺序执行：

1. **ETL 库**（通过 `PG_DSN` 连接）：创建 `app` Schema → 创建 `app_reader` 角色 → 创建 RLS 视图 → 授权
2. **业务库**（通过 `APP_DB_DSN` 连接）：创建 `auth`/`biz` Schema → 安装 `postgres_fdw` → 创建外部服务器 → 用户映射 → 导入外部表 → 授权

## 组件与接口

### 组件 1：Schema 管理（业务库）

**职责**：在业务库（`APP_DB_DSN`）中创建 `auth` 和 `biz` Schema，配置权限。

**SQL 接口**：
```sql
-- 创建 Schema
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS biz;

-- 授权 app_user
GRANT USAGE ON SCHEMA auth TO app_user;
GRANT USAGE ON SCHEMA biz TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA biz TO app_user;

-- 未来新表自动授权
ALTER DEFAULT PRIVILEGES IN SCHEMA auth
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA biz
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
```

### 组件 2：RLS 视图层（ETL 库）

**职责**：在 ETL 库（`PG_DSN`）的 `app` Schema 中为每张源表创建带 `site_id` 过滤的视图。

**视图命名规则**：`app.v_<源表名>`，例如 `app.v_dim_member`、`app.v_dws_member_consumption_summary`。

**视图模板**：
```sql
CREATE OR REPLACE VIEW app.v_<源表名> AS
SELECT * FROM <schema>.<源表名>
WHERE site_id = current_setting('app.current_site_id')::bigint;
```

**DWD 层视图清单（11 张）**：

| 视图名 | 源表 |
|--------|------|
| `app.v_dim_member` | `dwd.dim_member` |
| `app.v_dim_assistant` | `dwd.dim_assistant` |
| `app.v_dim_member_card_account` | `dwd.dim_member_card_account` |
| `app.v_dim_table` | `dwd.dim_table` |
| `app.v_dwd_settlement_head` | `dwd.dwd_settlement_head` |
| `app.v_dwd_table_fee_log` | `dwd.dwd_table_fee_log` |
| `app.v_dwd_assistant_service_log` | `dwd.dwd_assistant_service_log` |
| `app.v_dwd_recharge_order` | `dwd.dwd_recharge_order` |
| `app.v_dwd_store_goods_sale` | `dwd.dwd_store_goods_sale` |
| `app.v_dim_staff` | `dwd.dim_staff` |
| `app.v_dim_staff_ex` | `dwd.dim_staff_ex` |

**DWS 层视图清单（24 张）**：

| 视图名 | 源表 |
|--------|------|
| `app.v_dws_member_consumption_summary` | `dws.dws_member_consumption_summary` |
| `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` |
| `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` |
| `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` |
| `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` |
| `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` |
| `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` |
| `app.v_dws_assistant_monthly_summary` | `dws.dws_assistant_monthly_summary` |
| `app.v_dws_assistant_salary_calc` | `dws.dws_assistant_salary_calc` |
| `app.v_dws_assistant_customer_stats` | `dws.dws_assistant_customer_stats` |
| `app.v_dws_assistant_finance_analysis` | `dws.dws_assistant_finance_analysis` |
| `app.v_dws_finance_daily_summary` | `dws.dws_finance_daily_summary` |
| `app.v_dws_finance_income_structure` | `dws.dws_finance_income_structure` |
| `app.v_dws_finance_recharge_summary` | `dws.dws_finance_recharge_summary` |
| `app.v_dws_finance_discount_detail` | `dws.dws_finance_discount_detail` |
| `app.v_dws_finance_expense_summary` | `dws.dws_finance_expense_summary` |
| `app.v_dws_platform_settlement` | `dws.dws_platform_settlement` |
| `app.v_dws_assistant_recharge_commission` | `dws.dws_assistant_recharge_commission` |
| `app.v_cfg_performance_tier` | `dws.cfg_performance_tier` |
| `app.v_cfg_assistant_level_price` | `dws.cfg_assistant_level_price` |
| `app.v_cfg_bonus_rules` | `dws.cfg_bonus_rules` |
| `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` |
| `app.v_dws_order_summary` | `dws.dws_order_summary` |

**P2 预留（注释标记，暂不创建）**：
- `dws.dws_member_spending_power_index` → 待 P2 完成后补充
- `dws.dws_assistant_order_contribution` → 待 P2 完成后补充

**`cfg_*` 表特殊处理**：配置表（`cfg_performance_tier`、`cfg_assistant_level_price`、`cfg_bonus_rules`、`cfg_index_parameters`）可能不含 `site_id` 列。对于不含 `site_id` 的配置表，视图直接 `SELECT *` 不加过滤条件。

**权限配置**：
```sql
-- 创建只读角色（如不存在）
DO $$ BEGIN
    IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_reader') THEN
        CREATE ROLE app_reader LOGIN;
    END IF;
END $$;

GRANT USAGE ON SCHEMA app TO app_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO app_reader;
```

### 组件 3：FDW 跨库映射（业务库）

**职责**：通过 `postgres_fdw` 将 ETL 库 `app` Schema 的视图映射为业务库 `fdw_etl` Schema 的外部表。

**实现方式**：使用 `IMPORT FOREIGN SCHEMA` 批量导入，而非逐表定义外部表。这与现有 `db/fdw/setup_fdw_test.sql` 的模式一致。

**环境感知**：迁移脚本中的 `host`、`dbname`、`port`、`password` 等连接参数使用占位符 `'***'`，部署时根据环境替换。项目已有 `db/fdw/setup_fdw_test.sql`（测试环境）和 `db/fdw/setup_fdw.sql`（生产环境）的分环境模式，本次迁移脚本遵循相同模式——提供测试环境和生产环境两个版本。

```sql
-- 安装扩展
CREATE EXTENSION IF NOT EXISTS postgres_fdw;

-- 创建外部服务器
-- host / dbname / port 按环境替换，从 PG_DSN 解析 ETL 库连接信息
CREATE SERVER IF NOT EXISTS etl_feiqiu_server
    FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host '***', dbname '***', port '***');

-- 用户映射（密码按环境替换）
CREATE USER MAPPING IF NOT EXISTS FOR app_user
    SERVER etl_feiqiu_server
    OPTIONS (user 'app_reader', password '***');

-- 创建目标 Schema
CREATE SCHEMA IF NOT EXISTS fdw_etl;

-- 批量导入
IMPORT FOREIGN SCHEMA app
    FROM SERVER etl_feiqiu_server
    INTO fdw_etl;

-- 授权
GRANT USAGE ON SCHEMA fdw_etl TO app_user;
GRANT SELECT ON ALL TABLES IN SCHEMA fdw_etl TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_etl GRANT SELECT ON TABLES TO app_user;
```

**设计决策**：
1. 使用 `IMPORT FOREIGN SCHEMA` 而非逐表 `CREATE FOREIGN TABLE`——自动匹配列定义，避免手动维护列类型不一致的风险
2. 新增 RLS 视图后只需重新执行 `IMPORT` 即可同步
3. 与现有 `db/fdw/setup_fdw_test.sql` 保持一致
4. 服务器名使用通用名 `etl_feiqiu_server`（不含环境前缀），通过连接参数区分环境

### 组件 4：验证脚本

**职责**：自动化检查所有数据库对象是否正确创建。

**文件位置**：`scripts/ops/validate_p1_db_foundation.py`

**接口**：
```python
def validate_p1_db_foundation() -> dict:
    """
    返回验证结果字典：
    {
        "schemas": {"auth": bool, "biz": bool, "app": bool, "fdw_etl": bool},
        "rls_views": {"app.v_dim_member": bool, ...},
        "fdw_tables": {"fdw_etl.v_dim_member": bool, ...},
        "rls_filtering": bool,
        "permissions": {"app_user": bool, "app_reader": bool},
        "errors": [str, ...]
    }
    """
```

**环境变量依赖**（强制从 `.env` 加载，缺失时 `RuntimeError` 终止）：
- `PG_DSN`：ETL 库连接字符串（从中解析 host、port、dbname）
- `APP_DB_DSN`：业务库连接字符串（从中解析 host、port、dbname）
- 脚本通过 `load_dotenv()` 加载根 `.env`，禁止硬编码任何数据库名称或连接参数

## 数据模型

### Schema 拓扑

```mermaid
erDiagram
    test_zqyy_app_auth {
        string schema_name "auth"
        string purpose "用户认证、权限、映射"
    }
    test_zqyy_app_biz {
        string schema_name "biz"
        string purpose "业务数据（任务、备注、AI、Excel）"
    }
    test_zqyy_app_fdw_etl {
        string schema_name "fdw_etl"
        string purpose "FDW 外部表（只读）"
    }
    test_zqyy_app_public {
        string schema_name "public"
        string purpose "系统管理表（保留）"
    }
    test_etl_feiqiu_app {
        string schema_name "app"
        string purpose "RLS 视图层"
    }
    test_etl_feiqiu_dwd {
        string schema_name "dwd"
        string purpose "明细层（11 张表）"
    }
    test_etl_feiqiu_dws {
        string schema_name "dws"
        string purpose "汇总层（24 张表）"
    }

    test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dwd : "视图引用"
    test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dws : "视图引用"
    test_zqyy_app_fdw_etl ||--|| test_etl_feiqiu_app : "postgres_fdw 映射"
```

### RLS 视图数据流

对于含 `site_id` 的表：
```
源表数据（全量）→ RLS 视图（site_id 过滤）→ FDW 外部表（只读访问）
```

对于不含 `site_id` 的配置表（`cfg_*`）：
```
源表数据（全量）→ 直通视图（无过滤）→ FDW 外部表（只读访问）
```

### 迁移脚本清单

| 序号 | 目标库 | 文件名 | 内容 |
|------|--------|--------|------|
| 1 | ETL 库（`PG_DSN`） | `YYYY-MM-DD__p1_create_app_schema_rls_views.sql` | 创建 app Schema + 全部 RLS 视图 + app_reader 权限 |
| 2 | 业务库（`APP_DB_DSN`） | `YYYY-MM-DD__p1_create_auth_biz_schemas.sql` | 创建 auth/biz Schema + app_user 权限 |
| 3 | 业务库（`APP_DB_DSN`） | `YYYY-MM-DD__p1_setup_fdw_etl.sql` | FDW 扩展 + 外部服务器 + 用户映射 + 导入外部表 |


## 正确性属性（Correctness Properties）

*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*

### Property 1：默认权限自动授予

*For any* 在 `auth` 或 `biz` Schema 中新创建的表，`app_user` 角色都应自动获得 SELECT、INSERT、UPDATE、DELETE 权限，无需额外手动授权。

**Validates: Requirements 1.5**

### Property 2：public Schema 不变量

*For any* 迁移脚本执行前后，`test_zqyy_app.public` Schema 中的表集合应保持不变——迁移不应删除、重命名或修改 `public` 中的现有表。

**Validates: Requirements 1.6**

### Property 3：RLS 视图定义包含 site_id 过滤

*For any* `test_etl_feiqiu.app` Schema 中的 RLS 视图（含 `site_id` 列的源表对应的视图），其视图定义 SQL 中都应包含 `current_setting('app.current_site_id')` 过滤条件。

**Validates: Requirements 2.4**

### Property 4：RLS 过滤正确性

*For any* 含 `site_id` 列的 RLS 视图和任意有效的 `site_id` 值，设置 `app.current_site_id` 后查询该视图，返回结果中所有行的 `site_id` 都应等于设置的值。

**Validates: Requirements 2.5**

### Property 5：未设置 site_id 时 RLS 视图拒绝访问

*For any* 含 `site_id` 过滤的 RLS 视图，在未设置 `app.current_site_id` 的会话中执行查询，应抛出错误而非返回数据。

**Validates: Requirements 2.6**

### Property 6：FDW 外部表完整性与数据一致性

*For any* `test_etl_feiqiu.app` Schema 中的视图，`test_zqyy_app.fdw_etl` Schema 中都应存在对应的可查询外部表，且在相同 `site_id` 条件下，外部表返回的数据与 RLS 视图返回的数据一致。

**Validates: Requirements 3.4, 3.5, 3.6**

### Property 7：迁移脚本结构合规性

*For any* `db/etl_feiqiu/migrations/` 或 `db/zqyy_app/migrations/` 中本次新增的迁移脚本文件，文件名应匹配 `YYYY-MM-DD__*.sql` 模式，且文件内容中应包含回滚语句（以注释形式）。

**Validates: Requirements 4.3, 4.4**

### Property 8：迁移脚本幂等性

*For any* 本次新增的迁移脚本，连续执行两次的结果应与执行一次相同——第二次执行不应产生错误。

**Validates: Requirements 4.5**

### Property 9：环境变量缺失时验证脚本报错

*For any* 必需环境变量（`PG_DSN`、`APP_DB_DSN`）的缺失组合，验证脚本应立即抛出错误终止，而非静默使用默认值或空字符串。

**Validates: Requirements 5.8**

## 错误处理

### 迁移脚本错误处理

| 场景 | 处理方式 |
|------|---------|
| Schema 已存在 | `CREATE SCHEMA IF NOT EXISTS` 幂等跳过 |
| 视图已存在 | `CREATE OR REPLACE VIEW` 覆盖更新 |
| 角色不存在 | `DO $$ ... IF NOT EXISTS ... END $$` 条件创建 |
| 源表不存在（P2 待建表） | 以注释形式预留，不创建视图 |
| FDW 服务器已存在 | `CREATE SERVER IF NOT EXISTS` 幂等跳过 |
| 用户映射已存在 | `CREATE USER MAPPING IF NOT EXISTS` 幂等跳过 |
| `IMPORT FOREIGN SCHEMA` 表已存在 | 先 `DROP SCHEMA fdw_etl CASCADE` 再重新导入（脚本中提供选项） |

### 验证脚本错误处理

| 场景 | 处理方式 |
|------|---------|
| 环境变量缺失 | `RuntimeError` 立即终止，输出缺失变量名 |
| 数据库连接失败 | 捕获 `psycopg2.OperationalError`，输出连接参数（脱敏）和错误信息 |
| Schema/视图/外部表不存在 | 记录为失败项，继续检查其余项目 |
| RLS 过滤验证无数据 | 标记为 SKIP（无法验证），不标记为失败 |
| 权限查询失败 | 记录具体错误，继续检查 |

### `current_setting` 未设置时的行为

PostgreSQL 中 `current_setting('app.current_site_id')` 在未设置时会抛出 `ERROR: unrecognized configuration parameter "app.current_site_id"`。这是期望行为（需求 2.6），确保不会意外返回全量数据。

如果需要更友好的错误信息，可以使用 `current_setting('app.current_site_id', true)` 返回 NULL，然后在视图中用 `CASE` 处理。但当前设计选择让 PostgreSQL 原生报错，因为：
1. 更安全——不可能绕过
2. 后端代码必须显式设置 `SET app.current_site_id = ...`，这是一个强制约束

## 测试策略

### 属性测试（Property-Based Testing）

使用 Python `hypothesis` 框架，测试目录：`tests/`（Monorepo 级属性测试目录）。

每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。

标注格式：`# Feature: miniapp-db-foundation, Property N: <属性标题>`

**属性测试清单**：

| 属性 | 测试方法 | 生成器 |
|------|---------|--------|
| P1 默认权限 | 生成随机表名，在 auth/biz 中创建表，验证 app_user 权限 | `hypothesis.strategies.text` 生成合法 SQL 标识符 |
| P3 视图定义过滤 | 遍历所有 app schema 视图，检查定义 SQL | 无需生成器，遍历所有视图 |
| P4 RLS 过滤正确性 | 生成随机 site_id，设置后查询视图，验证结果 | `hypothesis.strategies.integers` 生成 site_id |
| P5 未设置 site_id 报错 | 遍历所有 RLS 视图，在新会话中查询 | 无需生成器，遍历所有视图 |
| P7 脚本结构合规 | 遍历所有新增迁移脚本，验证命名和内容 | 无需生成器，遍历文件 |
| P8 幂等性 | 对每个迁移脚本执行两次，验证无错误 | 无需生成器 |
| P9 环境变量缺失 | 生成环境变量缺失组合，验证报错 | `hypothesis.strategies.sampled_from` 生成缺失组合 |

**注意**：P2（public schema 不变量）和 P6（FDW 数据一致性）需要真实数据库环境，作为集成测试在验证脚本中实现，而非 hypothesis 属性测试。

### 单元测试

单元测试聚焦于验证脚本的逻辑正确性：

- 验证脚本在 Schema 缺失时正确报告失败
- 验证脚本在权限不足时正确报告
- 验证脚本的输出格式正确（JSON 结构）
- 环境变量缺失时的错误消息包含变量名

### 集成测试

集成测试通过验证脚本 `scripts/ops/validate_p1_db_foundation.py` 实现，覆盖：

- 全部 Schema 存在性检查
- 全部 RLS 视图存在性和过滤正确性
- 全部 FDW 外部表存在性和可查询性
- 权限配置完整性
- FDW 数据与 RLS 视图数据一致性
