Files
inkwatch/deploy/init.sql
T
PMon Dev b3db58df06 rename: 项目对外名定为「墨观 InkWatch」
<user> 2026-09-16 定名。本次只改**用户看得见的那一半**,
内部标识(包名 / FFI 符号 / applicationId / compose project name / 卷名 / 数据库名)
一律保持 `pmon` 不动 —— 理由与"若要彻底改名该怎么做"见 docs/rename-inkwatch.md。

已改:
  · 界面标题:采集页「墨观 采集端」、查看页「墨观 查看端」、MaterialApp.title「墨观」
  · 桌面图标名 android:label="墨观"(刻意短,避免被系统截断)
  · 前台服务通知「墨观 正在采集」
  · 日志前缀 [PMon] → [inkwatch](**刻意用 ASCII**:中文在 Dart AOT 里按 UTF-16LE
    存,拿 grep -F 核验恒为 0 —— 这个坑踩过,日志前缀别用中文)
  · pubspec description 换成正式描述(顺手清掉 Flutter 模板残留 "A new Flutter project.")
  · README / docs 抬头与标题;方案文档改名
    docs/pmon-v4-architecture.md → docs/inkwatch-v4-architecture.md(引用同步)
  · 命名约束落地:对外文案不得出现"监视/监听/追踪/窥"类词
    (方案 2.1:产品不提供任何绕过对方操作的隐蔽监控能力,这是过审与杀软信誉的前提)

刻意没改(改了会出事):
  · Dart 包名 pmon_client —— 全部 import 失效,纯风险无收益
  · applicationId com.pmon.pmon_client —— 已装机变成"另一个 App",无法覆盖升级、
    令牌与设备归属断链。越接近上架越不该改
  · Rust 39 个 _pmon_* FFI 符号 —— 要与 .so 严格配对
  · ★ docker compose 的 name: pmon —— **它是卷名锚点**(pmon_pg_data),
    改成 inkwatch 会让 compose 去找一套新空卷 = 生产清库。
    故在其上方加了防呆注释,防止后来者顺手改掉

新增 docs/rename-inkwatch.md:完整记录改了什么、没改什么、为什么,
以及"若要连内部标识一起改"的正确顺序与风险评估(结论:不建议)。

验证:客户端 282/282、dart analyze 干净、codec/protocol 离线契约通过。
2026-09-16 12:09:53 +08:00

240 lines
12 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 墨观 元数据 + 时序初始化(PostgreSQL,TimescaleDB 为可选加速)
-- 见方案 V4.0 第 10 章:元数据层 + 时序历史层同库
--
-- ⚠️ 本脚本必须同时能在"有 TimescaleDB"与"普通 PostgreSQL"上跑完
-- - 生产:`timescale/timescaledb` 镜像 → 启用超表分区 + 自动保留 + 连续聚合
-- - 本地/CI:`postgres:15-alpine`(compose 用 POSTGRES_IMAGE 切换)→ 上述三项跳过,
-- 表结构与指标列**完全一致**,仅存储优化缺失,功能测试与开发不受影响
-- TimescaleDB 专有能力全部收敛在下方两个 DO 块内,缺扩展时只打印 NOTICE 不报错。
-- (不这么做的话,缺扩展会让 docker-entrypoint 的 ON_ERROR_STOP 直接中断建库。)
-- ==================== 元数据层 ====================
-- 账号(方案 6.4)。口令只存 scrypt 哈希 + 盐,绝不存明文;
-- 口令参数、令牌格式与 TTL 全部在 shared/accounts.js 定义,本文件不重复声明。
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
plan TEXT NOT NULL DEFAULT 'free', -- free | pro | team
secret_hash TEXT, -- scrypt 哈希(hex)
secret_salt TEXT, -- 每账号独立盐
secret_updated_at TIMESTAMPTZ,
disabled_at TIMESTAMPTZ, -- 封禁账号(保留数据,登录直接拒绝)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 会话令牌(查看端 / 界面登录态)。**只存 sha256 哈希**:脱库也拿不到可用令牌。
-- 采用 opaque 令牌而非 JWT 的理由见 shared/accounts.js 文件头(可吊销优先)。
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, -- 滑动过期:每次使用顺延
revoked_at TIMESTAMPTZ, -- 登出/踢下线即标记,无需等 TTL
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
-- ⚠️ 建表顺序即执行顺序:**被引用的表必须先建**。
-- 真实事故(2026-09-15 首次部署到腾讯云时暴露):device_tokens 原写在 devices 之前,
-- 它带 `REFERENCES devices(device_id)` → 全新库上 psql 直接报
-- `ERROR: relation "devices" does not exist` → entrypoint 因 ON_ERROR_STOP 中止 →
-- 容器重启后看到数据目录非空就**跳过初始化** → 结果库里只建了 users/sessions,
-- 其余表全缺,而容器健康检查是绿的(pg 能连上)——"部署成功但功能全废"。
-- 本地一直没暴露,是因为本地数据卷是很早就建好的(新表靠文件末尾的增量迁移段补),
-- 全新库这条路径从来没跑过。现由 tests/schema-consistency-test.js
-- 「建表顺序」段锁定:任何 CREATE TABLE 的 REFERENCES 都必须指向已定义的表。
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
device_name TEXT,
platform TEXT,
owner_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
capabilities JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
-- 稳定硬件指纹(2026-09-15 新增,见 shared/device-identity.js 文件头):
-- 客户端上报(Android = ANDROID_ID 完整值,桌面端 = 主机名)。
-- 它**与 device_id 的派生规则无关** —— 正因如此,即使 device_id 变了
-- (真事故:修机型显示名把 device_id 也改了),服务端仍能认出是同一台机。
hardware_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_devices_owner ON devices(owner_id);
-- 归并判据①的查询路径:同 owner 名下按 hardware_id 找兄弟行
CREATE INDEX IF NOT EXISTS idx_devices_hardware ON devices(owner_id, hardware_id);
-- 设备令牌(采集端专用,方案 6.4「与查看会话权限分离,云端可吊销或轮换」)。
-- 采集端长期运行,故不设过期,只靠 revoked_at 吊销;轮换 = 发新的 + 吊销旧的。
-- 注意:必须排在 devices **之后**(见上方顺序说明)。
CREATE TABLE IF NOT EXISTS device_tokens (
token_hash TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_device_tokens_device ON device_tokens(device_id);
-- 授权关系:被查看设备主动授予查看账号
-- account_id 用 TEXT:业务层账号 ID 可能是邮箱/第三方 openid,不保证数字
CREATE TABLE IF NOT EXISTS device_grants (
device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_grants_account ON device_grants(account_id);
CREATE TABLE IF NOT EXISTS subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
-- 审计日志(配对尝试、令牌吊销、授权变更)
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
event TEXT NOT NULL,
account_id TEXT,
device_id TEXT,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
-- ==================== 时序历史层 ====================
-- 指标列与 shared/metrics-schema.js 的权威清单严格对齐
-- (metrics-schema 的 dbColumn 非空项必须在本表中存在同名列,
-- 一致性由 tests/schema-consistency-test.js 强制校验)
-- 另存 payload 全量原文,后续新增指标无需改表(查询可走 JSONB)。
CREATE TABLE IF NOT EXISTS device_metrics (
ts TIMESTAMPTZ NOT NULL,
device_id TEXT NOT NULL,
seq BIGINT,
-- CPU
cpu_usage DOUBLE PRECISION, -- CPU 使用率 %
cpu_temp DOUBLE PRECISION, -- CPU 温度 ℃(平台受限,多数为 NULL)
-- 内存
mem_usage DOUBLE PRECISION, -- 内存使用率 %
mem_used_mb DOUBLE PRECISION, -- 已用内存 MB
mem_total_mb DOUBLE PRECISION, -- 物理内存总量 MB
-- GPU
gpu_usage DOUBLE PRECISION, -- GPU 使用率 %(V1.0 未接入,恒 NULL)
-- 磁盘
disk_free_gb DOUBLE PRECISION, -- 可用空间合计 GB
disk_total_gb DOUBLE PRECISION, -- 总容量合计 GB
disk_read_mbps DOUBLE PRECISION, -- 磁盘读速率 MB/s(V1.0 未接入)
disk_write_mbps DOUBLE PRECISION, -- 磁盘写速率 MB/s(V1.0 未接入)
-- 网络(口径 Mbps,Rust 侧由字节/秒换算后写入)
net_rx_mbps DOUBLE PRECISION, -- 下行速率 Mbps
net_tx_mbps DOUBLE PRECISION, -- 上行速率 Mbps
-- 温度(增强项)
battery_temp DOUBLE PRECISION, -- 电池温度 ℃(仅移动端可得)
payload JSONB -- 全量原文(含 cpu_cores 数组等无列指标)
);
CREATE INDEX IF NOT EXISTS idx_metrics_device_ts ON device_metrics(device_id, ts DESC);
-- 断线补传幂等由网关层实现(按 device_id+seq 在入队前去重,见 gateway._isDuplicateSeq)。
-- 此处仅建普通查询索引;不加唯一约束,因为 ts 由服务端改写、
-- 同一 seq 补传时 ts 不同,DB 层唯一索引无法表达该语义。
CREATE INDEX IF NOT EXISTS idx_metrics_device_seq ON device_metrics(device_id, seq);
-- ==================== TimescaleDB 可选加速(缺失即优雅降级)====================
-- 超表:按时间自动分区,支撑按保留期 drop chunk
-- 保留策略:free 24h / pro 7d / team 30d(生产按账号档位分别配置,此处取上限 30 天)
DO $pmon$
DECLARE
has_ts boolean;
BEGIN
SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'timescaledb')
INTO has_ts;
IF has_ts THEN
CREATE EXTENSION IF NOT EXISTS timescaledb;
PERFORM create_hypertable('device_metrics', 'ts', if_not_exists => TRUE);
PERFORM add_retention_policy('device_metrics', INTERVAL '30 days', if_not_exists => TRUE);
RAISE NOTICE '墨观: TimescaleDB 已启用(超表 + 30 天自动保留)';
ELSE
RAISE NOTICE '墨观: 未检测到 TimescaleDB,跳过超表与自动保留策略(表结构与指标列已建全,功能不受影响)';
END IF;
END
$pmon$;
-- 降采样:超过 1 天的数据按 10 秒粒度聚合(控制存储成本,见方案 10.3)
--
-- ⚠️ 两个分支的**指标列清单必须完全一致**,只允许分桶表达式不同:
-- TimescaleDB 用 time_bucket(),普通 PG 用 to_timestamp(floor(epoch/10)*10)
-- 新增/改名指标时两处都要改,一致性由 tests/schema-consistency-test.js 校验(否则红灯)。
DO $pmon$
BEGIN
IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'timescaledb') THEN
EXECUTE $sql$
CREATE MATERIALIZED VIEW IF NOT EXISTS device_metrics_10s
WITH (timescaledb.continuous) AS
SELECT
time_bucket('10 seconds', ts) AS bucket,
device_id,
avg(cpu_usage) AS cpu_usage,
avg(cpu_temp) AS cpu_temp,
avg(mem_usage) AS mem_usage,
avg(mem_used_mb) AS mem_used_mb,
avg(mem_total_mb) AS mem_total_mb,
avg(gpu_usage) AS gpu_usage,
avg(disk_free_gb) AS disk_free_gb,
avg(disk_total_gb) AS disk_total_gb,
avg(net_rx_mbps) AS net_rx_mbps,
avg(net_tx_mbps) AS net_tx_mbps
FROM device_metrics
GROUP BY bucket, device_id
WITH NO DATA
$sql$;
PERFORM add_continuous_aggregate_policy('device_metrics_10s',
start_offset => INTERVAL '2 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '30 minutes',
if_not_exists => TRUE);
RAISE NOTICE '墨观: device_metrics_10s 已建为连续聚合(自动刷新)';
ELSE
EXECUTE $sql$
CREATE MATERIALIZED VIEW IF NOT EXISTS device_metrics_10s AS
SELECT
to_timestamp(floor(extract(epoch FROM ts) / 10) * 10) AS bucket,
device_id,
avg(cpu_usage) AS cpu_usage,
avg(cpu_temp) AS cpu_temp,
avg(mem_usage) AS mem_usage,
avg(mem_used_mb) AS mem_used_mb,
avg(mem_total_mb) AS mem_total_mb,
avg(gpu_usage) AS gpu_usage,
avg(disk_free_gb) AS disk_free_gb,
avg(disk_total_gb) AS disk_total_gb,
avg(net_rx_mbps) AS net_rx_mbps,
avg(net_tx_mbps) AS net_tx_mbps
FROM device_metrics
GROUP BY 1, device_id
WITH NO DATA
$sql$;
RAISE NOTICE '墨观: device_metrics_10s 已建为普通物化视图(无自动刷新,需手动 REFRESH)';
END IF;
END
$pmon$;
-- ==================== 增量迁移(已有库升级用)====================
-- ⚠️ 迁移语句**不写在这里**,全部集中在 `migrate.sql`(单一真相源)。
-- 原因:本文件挂在 /docker-entrypoint-initdb.d/,**只在数据目录为空时执行** ——
-- 写在这里的迁移在已有库上永远不会跑(2026-09-15 的运维坑,详见 migrate.sql 头注释)。
-- 下面这一行让**全新库**也走同一份迁移定义,使两条路径完全一致。
\i /migrate.sql
-- 连续聚合视图定义变更后需重建才生效(IF NOT EXISTS 不会更新既有定义)
-- 注意:删除连续聚合会一并丢弃其历史聚合数据,重跑后按 policy 重新物化。
-- DROP MATERIALIZED VIEW IF EXISTS device_metrics_10s;