# 数据库表结构（完整）

源：`src/xiangqin/db.py` 的 `SCHEMA` 常量 + 迁移函数。

## 基础配置

- SQLite WAL 模式
- 外键启用（`PRAGMA foreign_keys = ON`）
- 文件路径：`/opt/xiangqin/data/xiangqin.db`（生产）/ `XIANGQIN_DB_PATH` env 覆盖

## 身份层

### `users`

```sql
CREATE TABLE users (
    id          TEXT PRIMARY KEY,       -- ULID
    phone_hash  TEXT UNIQUE NOT NULL,   -- HMAC-SHA256(phone, SALT)
    created_at  INTEGER NOT NULL,
    deleted_at  INTEGER                  -- 软删
);
```

### `auth_requests`

```sql
CREATE TABLE auth_requests (
    request_id      TEXT PRIMARY KEY,   -- ULID，作为用户下一步 verify 的引用
    phone_hash      TEXT NOT NULL,
    code_hash       TEXT NOT NULL,      -- SHA256(验证码)
    expires_at      INTEGER NOT NULL,
    used            INTEGER NOT NULL DEFAULT 0 CHECK(used IN (0,1))
);
CREATE INDEX idx_auth_expires ON auth_requests(expires_at);
```

### `sessions`

```sql
CREATE TABLE sessions (
    token_hash      TEXT PRIMARY KEY,   -- SHA256(session_token)
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at      INTEGER NOT NULL,
    last_seen_at    INTEGER NOT NULL
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
```

## Profile 层

```sql
CREATE TABLE profiles (
    user_id             TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    gender              TEXT CHECK(gender IN ('f','m','nb')),
    age                 INTEGER CHECK(age IS NULL OR (age >= 18 AND age <= 99)),
    city                TEXT,
    tags                TEXT,            -- pipe 包裹 '|a|b|c|'
    bio                 TEXT,
    height              INTEGER CHECK(height IS NULL OR (height >= 140 AND height <= 220)),
    education           TEXT CHECK(education IS NULL OR education IN
                            ('highschool','associate','bachelor','master','phd','other')),
    agent_gateway_url   TEXT,            -- 可选，开通知需要
    agent_hooks_token   TEXT,            -- 可选；CLI 展示时 mask (前4后4)
    notify_on_new_mail  INTEGER NOT NULL DEFAULT 0 CHECK(notify_on_new_mail IN (0,1)),
    updated_at          INTEGER NOT NULL
);
CREATE INDEX idx_profiles_gender_city_age ON profiles(gender, city, age);
CREATE INDEX idx_profiles_height ON profiles(height) WHERE height IS NOT NULL;
CREATE INDEX idx_profiles_education ON profiles(education) WHERE education IS NOT NULL;
```

## 信封 / 邮件层

### `messages`

虚拟信封主表。5 维状态（投递 / 通知 / 阅读 / 回应 / 处置）。

```sql
CREATE TABLE messages (
    id                          TEXT PRIMARY KEY,       -- ULID
    from_user_id                TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    to_user_id                  TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    body                        TEXT NOT NULL,          -- ≤ 2000 chars
    sent_at                     INTEGER NOT NULL,
    delivery_status             TEXT NOT NULL CHECK(delivery_status IN
                                    ('delivered','blocked_by_recipient',
                                     'recipient_quota_full','recipient_not_found')),
    delivery_failure_reason     TEXT,
    notify_attempted_at         INTEGER,
    notify_status               TEXT CHECK(notify_status IS NULL OR notify_status IN
                                    ('not_enabled','pushed','push_failed','suppressed_dup')),
    notify_error                TEXT,
    read_at                     INTEGER,
    replied_at                  INTEGER,
    reply_to_message_id         TEXT,                    -- 链到回信 message_id
    deleted_by_recipient_at     INTEGER,                 -- 软删
    envelope_source             TEXT NOT NULL CHECK(envelope_source IN ('free_daily','paid')),
    expires_at                  INTEGER NOT NULL         -- sent_at + 30 天
);
CREATE INDEX idx_messages_to_unread ON messages(to_user_id, read_at, sent_at DESC)
    WHERE deleted_by_recipient_at IS NULL AND delivery_status = 'delivered';
CREATE INDEX idx_messages_from ON messages(from_user_id, sent_at DESC);
CREATE INDEX idx_messages_expires ON messages(expires_at);
```

### `envelope_usage`

每用户每日信封消耗（免费 + 付费）。`day_key` = UTC 日期整数 `YYYYMMDD`。

```sql
CREATE TABLE envelope_usage (
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    day_key         INTEGER NOT NULL,        -- 20260423
    free_used       INTEGER NOT NULL DEFAULT 0 CHECK(free_used >= 0),
    paid_used       INTEGER NOT NULL DEFAULT 0 CHECK(paid_used >= 0),
    PRIMARY KEY (user_id, day_key)
);
```

### `envelope_balance`

付费信封累计余额（跨天不重置）。

```sql
CREATE TABLE envelope_balance (
    user_id         TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    paid_remaining  INTEGER NOT NULL DEFAULT 0 CHECK(paid_remaining >= 0),
    total_purchased INTEGER NOT NULL DEFAULT 0
);
```

### `envelope_orders`

买信封订单流水。

```sql
CREATE TABLE envelope_orders (
    id              TEXT PRIMARY KEY,
    user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    count           INTEGER NOT NULL CHECK(count > 0),
    amount_cents    INTEGER NOT NULL CHECK(amount_cents >= 0),
    mock            INTEGER NOT NULL CHECK(mock IN (0,1)),
    paid            INTEGER NOT NULL CHECK(paid IN (0,1)),
    created_at      INTEGER NOT NULL,
    provider        TEXT,
    biz_order_id    TEXT,
    status          TEXT NOT NULL DEFAULT 'paid'
                        CHECK(status IN ('pending','paid','failed','cancelled'))
);
CREATE INDEX idx_envelope_orders_user ON envelope_orders(user_id, created_at DESC);
```

## 举报 / 拉黑 / 通知层

### `reports`

举报留档，永久保留（不走 30 天清理）。

```sql
CREATE TABLE reports (
    id              TEXT PRIMARY KEY,
    message_id      TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    reporter_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    reported_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    reason          TEXT,
    created_at      INTEGER NOT NULL
);
CREATE INDEX idx_reports_reported ON reports(reported_id, created_at DESC);
```

### `blocks`

拉黑关系（单向）。`blocker_id` 拉黑了 `blocked_id` → 后者发给前者的信被拦截。

```sql
CREATE TABLE blocks (
    blocker_id      TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    blocked_id      TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at      INTEGER NOT NULL,
    PRIMARY KEY (blocker_id, blocked_id)
);
CREATE INDEX idx_blocks_blocked ON blocks(blocked_id);
```

### `notify_state`

每用户的通知去重状态，支持 12h 去重规则。

```sql
CREATE TABLE notify_state (
    user_id             TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    last_notified_at    INTEGER,     -- 上次成功推 hook 的时间
    last_read_inbox_at  INTEGER      -- 上次 xq inbox 的时间（去重重置信号）
);
```

## 迁移顺序

`src/xiangqin/db.py` 的 `init_db()` 调用：

1. `executescript(SCHEMA)` —— 幂等创建所有表
2. `_migrate_profile_columns(conn)` —— 老 `profiles` 表加新字段（`height` / `education` / `agent_gateway_url` / `agent_hooks_token` / `notify_on_new_mail`）+ 对应索引
3. `_drop_exposure_tables(conn)` —— 2026-04-23 砍曝光后清理旧表 `exposure_orders` / `exposure_usage`（`DROP TABLE IF EXISTS`，幂等）

跑 pytest 时 `test/conftest.py` 的 `tmp_db` autouse fixture 对每条测试重新 `init_db()`，幂等保证。

## 常量与单价

- **信封单价**：¥1.00 / 个 = 100 cents（`mail.ENVELOPE_PRICE_CENTS`）
- **每日免费额度**：3 封（`mail.FREE_ENVELOPES_PER_DAY`）
- **通知去重窗口**：12 小时（`mail.NOTIFY_DEDUP_HOURS`）
- **message body 上限**：2000 字符（`mail.BODY_MAX_LEN`）
- **message TTL**：30 天（`mail.MESSAGE_TTL_DAYS`）
