Chapter 03 · Database

# 数据库设计

MySQL 8.x（InnoDB · utf8mb4 · 时区 UTC）作为主数据存储，Redis 承担验证码、限流、节点实时状态与会话。schema 自蓝本 §2 全量移植并做 MySQL 类型适配，另为弹性拓扑（04 章）扩展三张表。

## §1移植约定（Postgres 蓝本 → MySQL 8）

蓝本用法 | MySQL 8 适配 | 说明
--- | --- | ---
`jsonb` | `JSON` | 审计 meta、节点标签等
`tags[]`（数组） | `JSON`（数组） | 查询用 `JSON_CONTAINS`，量大再拆关联表
`timestamptz` | `DATETIME(6)`，应用层一律 UTC | 连接串强制 `time_zone='+00:00'`
枚举字符串 | `ENUM(...)` | 状态机字段固定且少，ENUM 自带约束
主键 | `BIGINT UNSIGNED AUTO_INCREMENT` 内部主键 + 对外 `uuid CHAR(36)` 业务标识 | 避免顺序 ID 外泄可枚举；UUID 不做主键防 InnoDB 页分裂

## §2核心表（DDL 摘要）

    -- 账户与设备
    CREATE TABLE users (
      id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      uuid        CHAR(36) NOT NULL UNIQUE,            -- 对外标识
      email       VARCHAR(255) NOT NULL UNIQUE,
      pw_hash     VARCHAR(255) NOT NULL,               -- argon2id
      dp_uuid     CHAR(36) NOT NULL,                   -- 数据面凭证 UUID（可轮换，节点侧只见它）
      status      ENUM('active','banned') NOT NULL DEFAULT 'active',
      created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    CREATE TABLE devices (
      id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      uuid        CHAR(36) NOT NULL UNIQUE,
      user_id     BIGINT UNSIGNED NOT NULL,
      name        VARCHAR(64) NOT NULL,
      platform    ENUM('ios','android','windows','macos') NOT NULL,
      last_seen   DATETIME(6) NULL,
      created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
      FOREIGN KEY (user_id) REFERENCES users(id),
      INDEX idx_user (user_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 套餐与订阅（数字口径 = design/CLAUDE.md §7）
    CREATE TABLE plans (
      id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      code          ENUM('free','pro','team') NOT NULL UNIQUE,
      max_devices   INT NOT NULL,                      -- free 1 / pro 5 / team 10
      daily_minutes INT NULL,                          -- free 10；NULL = 不限
      ad_gate       BOOLEAN NOT NULL DEFAULT FALSE     -- free 每日看广告解锁
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    CREATE TABLE subscriptions (
      id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      user_id     BIGINT UNSIGNED NOT NULL,
      plan_id     BIGINT UNSIGNED NOT NULL,
      expires_at  DATETIME(6) NOT NULL,
      source      ENUM('trial','code') NOT NULL,       -- 注册自动 7 天试用 source='trial'
      created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
      FOREIGN KEY (user_id) REFERENCES users(id),
      FOREIGN KEY (plan_id) REFERENCES plans(id),
      INDEX idx_user_exp (user_id, expires_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 激活码（App 内无支付，资金流外部化）
    CREATE TABLE code_batches (
      id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      channel     ENUM('store','tg','line','manual') NOT NULL,
      created_by  VARCHAR(64) NOT NULL,
      note        VARCHAR(255) NULL,
      created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    CREATE TABLE codes (
      id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      code_hash     CHAR(64) NOT NULL UNIQUE,          -- SHA-256；明文只出现一次
      plan_id       BIGINT UNSIGNED NOT NULL,
      duration_days INT NOT NULL,
      batch_id      BIGINT UNSIGNED NOT NULL,
      status        ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused',
      redeemed_by   BIGINT UNSIGNED NULL,
      redeemed_at   DATETIME(6) NULL,
      FOREIGN KEY (plan_id) REFERENCES plans(id),
      FOREIGN KEY (batch_id) REFERENCES code_batches(id),
      INDEX idx_status (status)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 用量（最小数据原则：仅字节/分钟，无目的地）
    CREATE TABLE usage_daily (
      user_id        BIGINT UNSIGNED NOT NULL,
      date           DATE NOT NULL,
      bytes_up       BIGINT UNSIGNED NOT NULL DEFAULT 0,
      bytes_down     BIGINT UNSIGNED NOT NULL DEFAULT 0,
      minutes_used   INT NOT NULL DEFAULT 0,
      ad_unlocked_at DATETIME(6) NULL,                 -- 免费版当日激励视频解锁时刻
      PRIMARY KEY (user_id, date)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 审计（兑换/封禁/节点操作必记）
    CREATE TABLE audit_log (
      id      BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      actor   VARCHAR(64) NOT NULL,
      action  VARCHAR(64) NOT NULL,
      target  VARCHAR(128) NOT NULL,
      meta    JSON NULL,
      at      DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
      INDEX idx_at (at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

## §3节点与弹性拓扑表（支撑 04 章）

    -- 节点：蓝本 nodes 表 + 拓扑/生命周期扩展字段
    CREATE TABLE nodes (
      id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      uuid        CHAR(36) NOT NULL UNIQUE,
      region      VARCHAR(8) NOT NULL,                 -- HK / JP / SG / US…（UI 显示 2 字母码块）
      name_zh     VARCHAR(64) NOT NULL,
      name_en     VARCHAR(64) NOT NULL,
      role        ENUM('entry','relay','exit') NOT NULL DEFAULT 'entry',
      tier        ENUM('free','pro') NOT NULL,         -- 消耗品池 / 精品池
      endpoint    VARCHAR(255) NOT NULL,               -- ip:port（REALITY）
      hy2_port    INT NULL,                            -- Hysteria2 端口跳跃区间起点
      reality_pbk VARCHAR(64) NOT NULL,
      reality_sni VARCHAR(128) NOT NULL,               -- 伪装目标 SNI
      provider_id BIGINT UNSIGNED NOT NULL,
      tags        JSON NULL,
      status      ENUM('provisioning','probing','up','draining','down','destroyed')
                  NOT NULL DEFAULT 'provisioning',     -- 生命周期状态机（04 章 §3）
      weight      INT NOT NULL DEFAULT 100,
      created_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
      FOREIGN KEY (provider_id) REFERENCES providers(id),
      INDEX idx_status_tier (status, tier)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- VPS 厂商池（多厂商、账号隔离，见 04 章 §5）
    CREATE TABLE providers (
      id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      name        VARCHAR(64) NOT NULL,
      api_kind    VARCHAR(32) NOT NULL,                -- 开通自动化所用 API 适配器
      regions     JSON NOT NULL,
      pool        ENUM('consumable','premium') NOT NULL,
      enabled     BOOLEAN NOT NULL DEFAULT TRUE,
      note        VARCHAR(255) NULL                    -- 凭证不入库，存独立 secrets 管理
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 节点事件：状态迁移与判封依据（探针明细在 Redis，结论落库）
    CREATE TABLE node_events (
      id        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      node_id   BIGINT UNSIGNED NOT NULL,
      event     ENUM('provisioned','probe_pass','probe_fail','marked_up','draining',
                     'blocked_suspect','blocked_confirmed','replaced','destroyed') NOT NULL,
      detail    JSON NULL,                             -- 探针命中率、判封依据等
      at        DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
      FOREIGN KEY (node_id) REFERENCES nodes(id),
      INDEX idx_node_at (node_id, at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    -- 目录版本：nodes 任何变更 bump，客户端 if_version 灰度
    CREATE TABLE directory_version (
      id      TINYINT PRIMARY KEY DEFAULT 1,
      version BIGINT UNSIGNED NOT NULL,
      CHECK (id = 1)
    ) ENGINE=InnoDB;

节点表里没有的东西（有意为之）节点服务器上不落任何用户身份；本表也不存节点登录凭证（SSH 密钥、厂商 API key 走独立 secrets 管理，与运营身份隔离）。

## §4Redis 键设计

键模式 | 类型 / TTL | 用途
--- | --- | ---
`auth:code:{email}` | STRING · 10min | 邮箱验证码（同邮箱 1/min 限发）
`rl:{scope}:{key}` | ZSET · 滑动窗口 | 接口限流（IP / 用户 / 邮箱多维度）
`jwt:refresh:{jti}` | STRING · 30d | refresh token 白名单（登出/封禁即删）
`node:load:{node_uuid}` | HASH · 90s | agent 每 30s 上报：在线数/带宽/CPU——目录排序与客户端"信号条"数据源
`probe:{node_uuid}:{vantage}` | STRING · 30min | 境内外探针最近结果（判封原始数据）
`dir:version` | STRING | 目录版本缓存（DB 为权威）
`redeem:fail:{user_id}` | STRING · 1h | 兑换失败计数（5 次锁 1 小时）

Redis 全部可重建（无权威数据），故不参与备份；重启后由 agent 心跳与 DB 回填。

## §5备份与恢复

层 | 策略 | 目标
--- | --- | ---
全量 | 每日 `xtrabackup`（或托管 DB 自动快照） | RPO ≤ 15min
增量 | binlog 持续归档（15min 滚动上传）
加密与异地 | 备份用 `age` 公钥加密后传对象存储（与生产、与运营身份均隔离的账号）；保留 30 天 | 单点被端不丢数据
恢复演练 | 每月在隔离环境做一次全量 + binlog 回放演练，记录耗时 | RTO ≤ 4h（控制面整体重建含 DB，见 05 章）

备份内容敏感性用户库（邮箱）是全系统最敏感资产：备份加密私钥离线保存、不进任何在线主机；对象存储账号用独立身份注册，与 VPS / 域名 / 收款不产生关联（身份隔离红线，见 06 章）。