Files
rond-with-you/internal/db/schema.sql
T
tamakyi 4c3032cf48 防复发:把「删数据集带走站点自建数据」做成结构性门禁 + 常驻体检
用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:

1) 归属铁律(写进 schema.sql 抬头 + README 新章节)
   判断标准只有一句:**这份记录能不能从备份重新导入?**
   能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可挂
   `REFERENCES datasets(id) ON DELETE CASCADE`;
   不能(place_images/place_notes/place_votes/topics)⇒ 只留 dataset_id 当出处、**不许挂外键**。

2) 两道门禁(dataset_ownership_test.go),都做了变异验证:
   - 运行时:查 pg_constraint,凡挂 datasets 的表必须在白名单里;**反向也查**
     (白名单里的表不许被顺手摘掉,否则删数据集会留成片孤儿行)。
   - 静态:扫 schema.sql 的 CREATE TABLE。只看运行时有盲点——新建的库走 CREATE TABLE,
     开发库可能压根没跑过那次迁移。变异(把 topics 的 CASCADE 补回去)当场红。
   - 变异验证顺带发现:扫描必须先跳过 `--` 注释行,规矩的说明文字里就写着
     `REFERENCES datasets(id)`,不跳过会把自己写的文档当成违规。
   - 还发现运行时那道单独看**不够**:夹具每次跑迁移,迁移自己就把坏外键摘了,
     它永远不会在不健康的库上报警 ⇒ 补了「注入事故原样的外键再跑迁移,断言被摘掉」
     的自愈用例(并先确认变异真的生效,防假绿)。

3) 常驻体检:后台首页显示「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 >0 直接报警。
   事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**,等照片看不见了才追。

自愈用例又抓出一个此前漏掉的缺口:place_notes / place_votes 上的同类外键迁移并不会摘
(原先只写了 place_images 与 topics 两句 DROP)⇒ 补成五张自建数据表全覆盖。

⚠️ 排序坑(本项目第二次踩):摘外键那段最初放在 place_images 建表**之前**,
开发库毫无症状、**全新库起不来**(ALTER 报「关系不存在」),被
TestFreshDatabaseServesAppQueries 抓住。整段已挪到 place_images 之后,并把坑写进注释。

顺带订正「删除数据集」的二次确认文案(专题改完之后它还在说专题会一起删)。
全量验收 PASS=336 FAIL=0 SKIP=0。
2026-10-01 01:15:04 +08:00

664 lines
35 KiB
SQL
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
-- rond-with-you 数据结构
-- 一次上传 = 一个 dataset,所有事实表挂在其下,支持多份备份共存与切换
--
-- ⚠️️ 新增一张带 dataset_id 的表时,先答一个问题:
-- **它的记录能不能从备份重新导入?**
-- 能(到访/地点/位移/天气/活动/标签/原始行留档)⇒ 可以挂
-- `REFERENCES datasets(id) ON DELETE CASCADE`:删数据集时它们跟着走是对的。
-- 不能(图片、备注、结论、专题等**站点自建**的数据)⇒ **只留 dataset_id 当出处,不许挂外键**:
-- 删数据集会把它们一起删掉,而这些记录不在任何备份里,删了就是永久丢失
-- (2026-09-30 图片事故:行被 CASCADE、文件却留在盘上,读取按坐标键、行按数据集级联,
-- 两套口径打架)。参考 place_images / place_notes / place_votes / topics 现在的写法。
-- 这条规矩由 `TestOnlyBackupDerivedTablesCascadeFromDatasets` 与
-- `TestSchemaDeclaresNoDatasetFKForUserData`(运行时 + 静态两道)守住,改这里会当场红。
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS datasets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
sha256 CHAR(64) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source_created_at TIMESTAMPTZ,
device_name TEXT,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'done',
error TEXT,
visit_count INT NOT NULL DEFAULT 0,
place_count INT NOT NULL DEFAULT 0,
raw_visit_count INT NOT NULL DEFAULT 0,
first_visit_at TIMESTAMPTZ,
last_visit_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_datasets_user ON datasets(user_id, uploaded_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_datasets_sha ON datasets(user_id, sha256);
CREATE TABLE IF NOT EXISTS activities (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
is_home BOOLEAN NOT NULL DEFAULT FALSE,
is_work BOOLEAN NOT NULL DEFAULT FALSE,
is_excluded BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (dataset_id, src_pk)
);
CREATE TABLE IF NOT EXISTS tags (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
name TEXT NOT NULL,
color TEXT,
group_name TEXT,
UNIQUE (dataset_id, src_pk)
);
CREATE TABLE IF NOT EXISTS places (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
name TEXT,
poi_category TEXT,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
country_code TEXT,
province TEXT,
city TEXT,
district TEXT,
sublocality TEXT,
thoroughfare TEXT,
timezone TEXT,
visit_count INT NOT NULL DEFAULT 0,
dwell_minutes BIGINT NOT NULL DEFAULT 0,
first_visit_at TIMESTAMPTZ,
last_visit_at TIMESTAMPTZ,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_places_dataset ON places(dataset_id);
CREATE INDEX IF NOT EXISTS idx_places_city ON places(dataset_id, city);
CREATE INDEX IF NOT EXISTS idx_places_geo ON places(dataset_id, lat, lon);
CREATE TABLE IF NOT EXISTS visits (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
place_id BIGINT REFERENCES places(id) ON DELETE CASCADE,
activity_id BIGINT REFERENCES activities(id) ON DELETE SET NULL,
arrival TIMESTAMPTZ NOT NULL,
departure TIMESTAMPTZ,
duration_min INT,
is_home BOOLEAN NOT NULL DEFAULT FALSE,
is_work BOOLEAN NOT NULL DEFAULT FALSE,
bookmarked BOOLEAN NOT NULL DEFAULT FALSE,
is_user_added BOOLEAN NOT NULL DEFAULT FALSE,
remark TEXT,
emoji TEXT,
weather_symbol TEXT,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_visits_arrival ON visits(dataset_id, arrival DESC);
CREATE INDEX IF NOT EXISTS idx_visits_place ON visits(dataset_id, place_id);
CREATE INDEX IF NOT EXISTS idx_visits_activity ON visits(dataset_id, activity_id);
CREATE TABLE IF NOT EXISTS visit_tags (
visit_id BIGINT NOT NULL REFERENCES visits(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (visit_id, tag_id)
);
CREATE TABLE IF NOT EXISTS movements (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
user_id BIGINT,
src_pk INT NOT NULL,
transport_src INT,
transport_name TEXT,
transport_color TEXT,
transport_icon TEXT,
from_visit_src INT,
to_visit_src INT,
from_place_id BIGINT REFERENCES places(id) ON DELETE SET NULL,
to_place_id BIGINT REFERENCES places(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_min INT,
distance_km DOUBLE PRECISION,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_movements_start ON movements(dataset_id, started_at DESC);
CREATE TABLE IF NOT EXISTS weather (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
user_id BIGINT,
src_pk INT NOT NULL,
visit_src INT,
at TIMESTAMPTZ,
is_daylight BOOLEAN,
temperature_c DOUBLE PRECISION,
apparent_c DOUBLE PRECISION,
humidity DOUBLE PRECISION,
precipitation_amount DOUBLE PRECISION,
precipitation_chance DOUBLE PRECISION,
wind_speed DOUBLE PRECISION,
visibility DOUBLE PRECISION,
uv_index INT,
condition TEXT,
symbol TEXT,
uv_category TEXT,
wind_direction TEXT,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_weather_at ON weather(dataset_id, at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 类别值 → 中文显示名 的站点自定义覆盖。**只影响显示,不碰数据**:
-- 导出到 rond 用的是 places.poi_category 本身,这里改名不会写回手机。
-- 刻意不挂 dataset_id:类别值是它自己的稳定键,挂数据集会像备注/投票那样换个数据集就失联。
CREATE TABLE IF NOT EXISTS category_labels (
value TEXT PRIMARY KEY,
label TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 世界迷雾(Fog of World)已探索位图,按块粒度存储。
-- 迷雾与数据集无关(来自另一个应用),挂在用户维度下;
-- gbx/gby 为全球块坐标(0..65535),一个位对应 2^22 网格里约 9.55m 的一格。
CREATE TABLE IF NOT EXISTS fog_blocks (
user_id BIGINT NOT NULL,
gbx INT NOT NULL,
gby INT NOT NULL,
bitmap BYTEA NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, gbx, gby)
);
-- 地点备注与别名。挂**坐标键**(place_key)而不是 src_pk:
--
-- src_pk(rond 的 Z_PK)只在单个数据集内有效——rond 每次导出/同步都会重排编号
-- (实测两份备份里 822 条到访的 UUID 完全一致、Z_PK 100% 不同),所以换一份备份、
-- 换个数据集之后,同编号的完全是另一个地点,备注就再也挂不回去了。
-- 坐标是地点的地理事实,跨备份稳定:place_key = 经纬度各取 6 位小数拼起来
-- (约 0.1m 精度),与 Go 侧的 rond.PlaceCoordKey 必须逐位一致。
-- dataset_id / src_pk 保留下来只作「最后一次写入时挂在哪儿」的排查线索。
CREATE TABLE IF NOT EXISTS place_notes (
user_id BIGINT NOT NULL,
place_key TEXT NOT NULL,
dataset_id BIGINT NOT NULL DEFAULT 0,
src_pk INT NOT NULL DEFAULT 0,
alias TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
rond_note TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, place_key)
);
-- 站长对地点的「官方结论」:推荐(1) / 踩雷(-1),与备注一样按坐标键存。
CREATE TABLE IF NOT EXISTS place_votes (
user_id BIGINT NOT NULL,
place_key TEXT NOT NULL,
dataset_id BIGINT NOT NULL DEFAULT 0,
src_pk INT NOT NULL DEFAULT 0,
verdict SMALLINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, place_key)
);
-- 访客对地点的投票:一人一票、可改可撤。voter_key 由匿名 cookie + IP 派生。
--
-- 这是站点自己的数据(rondbackup 里没有这一项),但同样要认得出「是哪个地点」:
-- 挂 place_key 而不是 src_pk,理由与备注、结论一样。src_pk 只留作排查线索。
-- 迁移时这张表没有 dataset_id 可依,只能按**活跃数据集**认领那个编号属于哪个地点
-- (见文件末尾的迁移块)。
CREATE TABLE IF NOT EXISTS place_visitor_votes (
place_key TEXT NOT NULL,
voter_key TEXT NOT NULL,
vote SMALLINT NOT NULL,
src_pk INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (place_key, voter_key)
);
CREATE INDEX IF NOT EXISTS idx_pvv_src ON place_visitor_votes(src_pk);
-- GPX 轨迹:rond 只记录「地点到地点」的直线位移,导入 GPX 可以把真实路径补上。
-- 坐标统一按 WGS-84 存(GPX 标准),展示时再按底图坐标系转换。
CREATE TABLE IF NOT EXISTS gpx_tracks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL,
point_count INT NOT NULL DEFAULT 0,
length_km DOUBLE PRECISION NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
points JSONB NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gpx_user ON gpx_tracks(user_id, uploaded_at DESC);
-- ---------- 增量迁移 ----------
-- 数据集是「快照」语义:一次上传 = 一份完整数据集,页面始终只展示其中一份。
-- 因此判重不做行级去重,而是「文件摘要 + 内容指纹」两道,见 datasets.content_hash。
-- 这里只给事实表补上 user_id(分区键,也为将来多用户留位)。
ALTER TABLE visits ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE movements ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE weather ADD COLUMN IF NOT EXISTS user_id BIGINT;
-- 回填失败绝不能拖垮启动:老库若已存在重复行,唯一索引会让回填直接报错。
-- 这里逐条兜住,最坏情况只是这些历史行保持 NULL,站点照常启动。
DO $mig$
BEGIN
BEGIN
UPDATE visits SET user_id = d.user_id FROM datasets d
WHERE d.id = visits.dataset_id AND visits.user_id IS NULL;
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'visits 回填 user_id 遇到重复行,已跳过';
END;
BEGIN
UPDATE movements SET user_id = d.user_id FROM datasets d
WHERE d.id = movements.dataset_id AND movements.user_id IS NULL;
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'movements 回填 user_id 遇到重复行,已跳过';
END;
BEGIN
UPDATE weather SET user_id = d.user_id FROM datasets d
WHERE d.id = weather.dataset_id AND weather.user_id IS NULL;
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'weather 回填 user_id 遇到重复行,已跳过';
END;
END
$mig$;
-- 早期版本把 (user_id, src_pk) 做成了跨数据集全局唯一,想实现「重复导入不堆积」。
-- 但那会让更新备份里的新记录只落进未激活的数据集(页面上看不到),已废弃,这里清理掉。
-- 行级唯一性仍由各表自带的 UNIQUE (dataset_id, src_pk) 保证。
DROP INDEX IF EXISTS idx_visits_dedupe;
DROP INDEX IF EXISTS idx_movements_dedupe;
DROP INDEX IF EXISTS idx_weather_dedupe;
-- 内容指纹:识别「内容相同、但文件字节不同」的重复上传(rond 每次重新导出字节都会变)
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS content_hash TEXT;
-- 管理员 TOTP 两步验证:secret 为 base32(无填充);enabled=false 时 secret 仅作为
-- 「已生成待确认」状态存在,确认验证码通过后才允许登录启用
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_datasets_content ON datasets(user_id, content_hash);
-- rond 备份里按次到访记录的备注(ZVISIT.ZREMARK_)汇总后的缓存。
-- 单独一列存:回填只重写这一列,站长手写的 note 永远不受影响;
-- 展示时把 rond_note 拼在 note 之前(见 web.applyNotes 的 joinNote)。
ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS rond_note TEXT NOT NULL DEFAULT '';
-- 导出 rondbackup 所需的 Core Data 元信息(实体编号、ZPRIMARYKEY、建表 DDL、访问↔标签关联表),
-- 导入时抓取并缓存,导出时据此从 PostgreSQL 重建 LifeEasy.sqlite。NULL 表示旧数据集未抓到,
-- 需要把原始 .rondbackup 重新上传一次(即便内容相同也会补抓)。
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS coredata_meta JSONB;
-- 「换基线」的记档:底座库(baselines/<id>.sqlite)是哪一份包换出来的。
-- 记着它有两个用处:① 底座文件丢了可以按 baseline_sha256 去 uploads 里找回同一份包重建
-- (否则 ensureBaseline 只会退回 datasets.sha256,也就是最初那份旧包——底座会被悄悄换回去);
-- ② 页面上能说清「现在这个底座来自哪个文件」。
-- baseline_sha256 IS NULL = 从未换过底座,来源就是最初上传的原始包。
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS baseline_name TEXT;
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS baseline_sha256 CHAR(64);
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS baseline_at TIMESTAMPTZ;
-- rond 原始行留档:每条实体在 Core Data 里的**完整一行**(含站点未映射的字段)
-- 按 JSONB 存下来。有了它,导出不再依赖基线库去补那些字段——
-- 基线即便丢了也能精确还原,站点侧也具备完整数据,不会因为映射范围窄而漏字段。
-- BLOB 编码为 {"$blob": "<base64>"};时间戳保持 Core Data 的浮点秒,避免精度损失。
CREATE TABLE IF NOT EXISTS entity_raw (
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
entity TEXT NOT NULL,
src_pk INT NOT NULL,
raw JSONB NOT NULL,
-- skipped=真 表示这行因缺少必填字段没能进 PostgreSQL(例如没有到达时间的孤独到访)。
-- 导出时这些行要按留档原样写回,否则往返一趟就少数据;而用户在站点上删掉的
-- 行不在此列,不会被「复活」。
skipped BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (dataset_id, entity, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_entity_raw_lookup ON entity_raw (dataset_id, entity);
-- 旧库升级:补上后加的 skipped 列
ALTER TABLE entity_raw ADD COLUMN IF NOT EXISTS skipped BOOLEAN NOT NULL DEFAULT FALSE;
-- 旧库升级:place_notes / place_votes 补 dataset_id 并把主键扩成三列。
-- 回填归到当前激活数据集——旧数据只能这么认领,之后按数据集各管各的。
ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS dataset_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE place_votes ADD COLUMN IF NOT EXISTS dataset_id BIGINT NOT NULL DEFAULT 0;
UPDATE place_notes SET dataset_id = COALESCE((SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1), 0)
WHERE dataset_id = 0;
UPDATE place_votes SET dataset_id = COALESCE((SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1), 0)
WHERE dataset_id = 0;
-- ⚠️ 这一段只在「还没搬到坐标键」的库上跑:文件末尾那次迁移会把主键换成
-- (user_id, place_key),这里要是也跟着改回去,两张表就会在每次启动时来回翻,
-- 永远停在最后一个执行的块上。
DO $$
DECLARE cols text;
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'place_notes' AND column_name = 'place_key') THEN
RETURN;
END IF;
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_notes_pkey';
IF cols IS DISTINCT FROM 'user_id,dataset_id,src_pk' THEN
ALTER TABLE place_notes DROP CONSTRAINT IF EXISTS place_notes_pkey;
ALTER TABLE place_notes ADD CONSTRAINT place_notes_pkey PRIMARY KEY (user_id, dataset_id, src_pk);
END IF;
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_votes_pkey';
IF cols IS DISTINCT FROM 'user_id,dataset_id,src_pk' THEN
ALTER TABLE place_votes DROP CONSTRAINT IF EXISTS place_votes_pkey;
ALTER TABLE place_votes ADD CONSTRAINT place_votes_pkey PRIMARY KEY (user_id, dataset_id, src_pk);
END IF;
END $$;
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
--
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE)。
-- 专题是「一段时间 + 一块区域」的**定义**,不是备份里的数据:换数据集之后它照旧成立
-- (内容照当前生效数据集算),删掉旧数据集不该把它带走。带 CASCADE 时实测丢过一批专题,
-- 而且没有恢复来源(专题只存在这张表里)。dataset_id 退化为「当初在哪份数据上建的」。
CREATE TABLE IF NOT EXISTS topics (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dataset_id BIGINT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
center_lat DOUBLE PRECISION NOT NULL DEFAULT 0,
center_lon DOUBLE PRECISION NOT NULL DEFAULT 0,
radius_km DOUBLE PRECISION NOT NULL DEFAULT 0,
show_fog BOOLEAN NOT NULL DEFAULT TRUE,
-- enabled=假 时前台不可见(列表不出现、详情 404),站长仍可在后台预览
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id);
-- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
-- data 存的是原始(zlib 压缩后的)字节,一个字节都不动。
CREATE TABLE IF NOT EXISTS fog_raw_files (
user_id BIGINT NOT NULL,
path TEXT NOT NULL,
data BYTEA NOT NULL,
PRIMARY KEY (user_id, path)
);
-- 迷雾遮罩区:这些圆形范围内的迷雾对访客隐藏(站长自己照常看得到)。
-- 存的中心是 **GCJ-02**(与 places 同系),渲染成瓦片前会换算到迷雾网格的 WGS-84。
CREATE TABLE IF NOT EXISTS fog_masks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
radius_m DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 地点坐标的来源留档。以前没有任何记录,只能靠「留档里 ZRAWLATITUDE 与显示坐标是否相同」
-- 反推——照片导入的点没有 ZLOCATION 留档行,根本判不出来。2026-09 那次照片导入选错坐标系
-- (把 WGS-84 的 EXIF 当成已经是 GCJ-02)导致 63 个点整体偏 500~670m,事后只能逐张对原图
-- 才查出来,就是因为缺这几列。现在建点的那一刻就写下来:
-- coord_source 哪个入口建的:rond(导入备份)/ photo(照片导入)/ manual(后台新增地点)
-- coord_sys 提交上来的坐标本身属于哪个坐标系:wgs84 / gcj02
-- src_lat/lon 提交上来、**还没做任何换算**的原始坐标
-- 三者齐全时,显示坐标 = 按 coord_sys 换算 src_lat/lon 的结果,随时可复算、可核对。
-- 留空的含义是「历史行、没有留档」,不要在迁移里用启发式去猜(猜错比留空更糟)。
ALTER TABLE places ADD COLUMN IF NOT EXISTS coord_source TEXT;
ALTER TABLE places ADD COLUMN IF NOT EXISTS coord_sys TEXT;
ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lat DOUBLE PRECISION;
ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lon DOUBLE PRECISION;
-- 存量回填不在这里做:判据需要 geo 包的 GCJ-02 往返换算(见 internal/ingest/coord.go),
-- SQL 里写不出来。启动时由 ingest.BackfillAllCoordSource 统一处理,那里同时能识别出
-- 「站点导出的备份给新建地点合成的 ZLOCATION 行」,不会把它误当成真机的原始 GPS。
-- 地点图片:站长给地点配的图。字节由**浏览器**转成 WebP 后再上传(服务端只负责收下与转存),
-- 1 核机器不用扛解码。storage 记这一张实际放在哪、object_key 是本地相对路径或对象存储 key:
-- 换了存储后端之后旧图仍按各自那一列去取,不会因为改了设置就全变破图。
--
-- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE,后果见下)。
-- 图片的「认地点」靠坐标键 place_key,与数据集无关;数据集只是当初上传时的落点。
-- 带 CASCADE 时删除旧数据集会把图片行一并删掉,而**文件还留在磁盘上**——
-- 读取按坐标键、行却按数据集级联,两套口径打架,实测就丢过一批图(2026-09-30)。
-- 备注/结论早就改成坐标键了,图片这里跟上,dataset_id 退化成「出处」这一列。
CREATE TABLE IF NOT EXISTS place_images (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL,
src_pk INT NOT NULL,
-- place_key 与备注、结论共用同一套坐标键:换数据集后图片还挂得回原地点。
-- 对象键里仍然带 dataset_id(那只是存储路径,与「认不认得出是同一个地点」无关)。
place_key TEXT NOT NULL DEFAULT '',
storage TEXT NOT NULL DEFAULT 'local',
object_key TEXT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
bytes BIGINT NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (dataset_id, src_pk, object_key)
);
CREATE INDEX IF NOT EXISTS idx_place_images_place ON place_images(dataset_id, src_pk, position);
-- ---------- 站点自建数据:一律摘掉 datasets 的外键 ----------
-- ⚠️ 这一段必须排在**所有相关 CREATE TABLE 之后**(place_images 是最后一张):
-- 迁移块插在建表之前时,开发库(表已存在)毫无症状,**全新库却起不来**
-- (`ALTER TABLE place_images …` 报「关系不存在」)——`TestFreshDatabaseServesAppQueries`
-- 就是守这个的,本项目踩过。
-- 这一段是给**已经部署出去的库**自愈用的,幂等:约束不在时什么都不做。
-- 五张表全都兜住:只摘 place_images / topics 不够——place_notes / place_votes 是更早那次
-- 改坐标键时摘的,老库上未必摘干净(变异验证就是这么抓出来的:把外键补回去后发现迁移不管它们)。
-- 摘掉之后「删数据集」不再带走任何站点自建的数据。
ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey;
ALTER TABLE place_notes DROP CONSTRAINT IF EXISTS place_notes_dataset_id_fkey;
ALTER TABLE place_votes DROP CONSTRAINT IF EXISTS place_votes_dataset_id_fkey;
ALTER TABLE place_visitor_votes DROP CONSTRAINT IF EXISTS place_visitor_votes_dataset_id_fkey;
ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey;
-- ---------- 附属数据改挂坐标键(一次性迁移)----------
-- 备注 / 结论 / 图片原本挂 (dataset_id, src_pk),换个数据集就全看不见了。
-- 这里把它们搬到坐标键上;只在「还没有 place_key 列」时执行,所以新库直接走上面的
-- CREATE TABLE,老库走这一段。
--
-- 坐标键的算法必须与 Go 侧 rond.PlaceCoordKey 逐位一致:
-- fmt.Sprintf("%.6f|%.6f", lat, lon) ↔ to_char(lat,'FM999990.000000')||'|'||to_char(lon,'FM999990.000000')
-- 已在真实数据上逐条比对过(368/368 相同)。改任何一边都要重新比。
DO $$
DECLARE
cols text;
BEGIN
-- 判据是**主键**而不是「列在不在」:半迁移过的库(列补上了、主键还是老的三列)
-- 也得能修好,否则这张表会一直卡在中间状态。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'place_notes' AND column_name = 'place_key') THEN
ALTER TABLE place_notes ADD COLUMN place_key TEXT;
END IF;
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_notes_pkey';
IF cols IS DISTINCT FROM 'user_id,place_key' THEN
UPDATE place_notes n SET place_key = (
SELECT to_char(p.lat, 'FM999990.000000') || '|' || to_char(p.lon, 'FM999990.000000')
FROM places p
WHERE p.dataset_id = n.dataset_id AND p.src_pk = n.src_pk
AND p.lat IS NOT NULL AND p.lon IS NOT NULL)
WHERE n.place_key IS NULL;
-- 地点已经不在库里的(被删过),留一个不会撞的孤儿键,宁可留着也别丢
UPDATE place_notes SET place_key = 'orphan:' || dataset_id || ':' || src_pk
WHERE place_key IS NULL;
-- 同一坐标上有多条时只留最新的一条,否则加不上主键
DELETE FROM place_notes a USING place_notes b
WHERE a.user_id = b.user_id AND a.place_key = b.place_key
AND (a.updated_at, a.ctid) < (b.updated_at, b.ctid);
ALTER TABLE place_notes ALTER COLUMN place_key SET NOT NULL;
ALTER TABLE place_notes DROP CONSTRAINT IF EXISTS place_notes_pkey;
ALTER TABLE place_notes ADD PRIMARY KEY (user_id, place_key);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'place_votes' AND column_name = 'place_key') THEN
ALTER TABLE place_votes ADD COLUMN place_key TEXT;
END IF;
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_votes_pkey';
IF cols IS DISTINCT FROM 'user_id,place_key' THEN
UPDATE place_votes n SET place_key = (
SELECT to_char(p.lat, 'FM999990.000000') || '|' || to_char(p.lon, 'FM999990.000000')
FROM places p
WHERE p.dataset_id = n.dataset_id AND p.src_pk = n.src_pk
AND p.lat IS NOT NULL AND p.lon IS NOT NULL)
WHERE n.place_key IS NULL;
UPDATE place_votes SET place_key = 'orphan:' || dataset_id || ':' || src_pk
WHERE place_key IS NULL;
DELETE FROM place_votes a USING place_votes b
WHERE a.user_id = b.user_id AND a.place_key = b.place_key
AND (a.updated_at, a.ctid) < (b.updated_at, b.ctid);
ALTER TABLE place_votes ALTER COLUMN place_key SET NOT NULL;
ALTER TABLE place_votes DROP CONSTRAINT IF EXISTS place_votes_pkey;
ALTER TABLE place_votes ADD PRIMARY KEY (user_id, place_key);
END IF;
-- 图片自己有 id 主键,只需补列 + 回填;查询走文件末尾那个索引
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'place_images' AND column_name = 'place_key') THEN
ALTER TABLE place_images ADD COLUMN place_key TEXT NOT NULL DEFAULT '';
END IF;
UPDATE place_images i SET place_key = COALESCE((
SELECT to_char(p.lat, 'FM999990.000000') || '|' || to_char(p.lon, 'FM999990.000000')
FROM places p
WHERE p.dataset_id = i.dataset_id AND p.src_pk = i.src_pk
AND p.lat IS NOT NULL AND p.lon IS NOT NULL),
'orphan:' || i.dataset_id || ':' || i.src_pk)
WHERE i.place_key = '';
-- 访客票:这张表没有 dataset_id,回填时只能按**活跃数据集**认领那个编号属于哪个地点。
-- 两个数据集里都有同一编号时也只认活跃的那个——这是唯一说得通的猜法,
-- 认不出来的留孤儿键,票不丢。
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'place_visitor_votes' AND column_name = 'place_key') THEN
ALTER TABLE place_visitor_votes ADD COLUMN place_key TEXT;
END IF;
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_visitor_votes_pkey';
IF cols IS DISTINCT FROM 'place_key,voter_key' THEN
UPDATE place_visitor_votes vv SET place_key = COALESCE((
SELECT to_char(p.lat, 'FM999990.000000') || '|' || to_char(p.lon, 'FM999990.000000')
FROM places p JOIN datasets d ON d.id = p.dataset_id
WHERE d.is_active AND p.src_pk = vv.src_pk
AND p.lat IS NOT NULL AND p.lon IS NOT NULL
ORDER BY p.id LIMIT 1),
'orphan:' || vv.src_pk)
WHERE vv.place_key IS NULL;
-- 同一个访客对同一坐标只剩一条:留最新的
DELETE FROM place_visitor_votes a USING place_visitor_votes b
WHERE a.voter_key = b.voter_key AND a.place_key = b.place_key
AND (a.updated_at, a.ctid) < (b.updated_at, b.ctid);
ALTER TABLE place_visitor_votes ALTER COLUMN place_key SET NOT NULL;
ALTER TABLE place_visitor_votes DROP CONSTRAINT IF EXISTS place_visitor_votes_pkey;
ALTER TABLE place_visitor_votes ADD PRIMARY KEY (place_key, voter_key);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_place_images_key ON place_images(place_key, position);
CREATE INDEX IF NOT EXISTS idx_pvv_key ON place_visitor_votes(place_key);
-- 已并入 / 子段到访不收进地点的到访数与停留(2026-09-29)
--
-- 真机里两行到访可能「不独立成立」:ZVISIT.ZMERGEDTO 指向它被并入的那条,
-- ZPARENT 说明它只是某个父段的一部分。app 对这两种行既不单独显示也不计数,本站以前
-- 当成普通到访 ⇒ 时间线上出现「同一时刻同一地点两条」、日停留重复计入(实测数据集 9
-- 有 54 + 10 条,26 条的到达时间与原件完全相同)。
--
-- 判据与 internal/stats/stats.go 的 SoloVisitSQL 同口径,只在**读**的一侧收口:
-- 导出的包照旧原样带着这些行(rond 靠它们认这次合并发生过),所以这里只动
-- visit_count / dwell_minutes 两列,first_visit_at / last_visit_at 保持全量
-- (后者会被导出成 ZLOCATION.ZLASTARRIVALDATE_)。
--
-- 只动「名下真有这种行」的地点,且幂等:重跑到收敛,没有这种行的库一次也不改行。
UPDATE places p SET
visit_count = sub.c,
dwell_minutes = sub.d
FROM (
SELECT v.place_id,
count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM entity_raw er
WHERE er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))) AS c,
COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)) FILTER (WHERE NOT EXISTS (SELECT 1 FROM entity_raw er
WHERE er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))), 0) AS d
FROM visits v
WHERE v.place_id IS NOT NULL
GROUP BY v.place_id
) sub
WHERE p.id = sub.place_id
AND EXISTS (SELECT 1 FROM visits v JOIN entity_raw er
ON er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
WHERE v.place_id = p.id
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL));
-- 「这次到访不独立成立」(已并入别处 / 只是某个父段的一部分)做成派生列(2026-09-30)
--
-- 读侧到处要判它(时间线、统计、地点列的 5 个子查询、专题……),原来是每行去 entity_raw 里
-- 取回**整行 Core Data JSON** 再解析 raw->>'ZMERGEDTO':实体表里的 raw 是 TOAST 过的整行,
-- 一次探针要几毫秒,地点库那种「每个地点 × 5 个子查询」的量级下实测 70 秒才出页面。
-- 派生成一列之后读侧只看布尔值(实测 /places 5.3s → 0.01s 量级)。
--
-- ⚠️ 写入侧要跟着刷:导入(internal/ingest)、合并(backup_merge 的 recountPlaces)都会重算。
-- 直接改 entity_raw 的合并标记(比如 tools/ 里的脚本)之后也要重跑一次这里的 UPDATE。
ALTER TABLE visits ADD COLUMN IF NOT EXISTS is_merged BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE visits v SET is_merged = TRUE
FROM entity_raw er
WHERE er.dataset_id = v.dataset_id AND er.entity = 'ZVISIT' AND er.src_pk = v.src_pk
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL)
AND NOT v.is_merged;
-- 给「从 entity_raw 重算 is_merged」用:只索引确实带合并标记的那几十行,
-- 不用取回 raw 就能判定(上面那条回填与导入回填都走它,否则一次导入要多花几秒)。
CREATE INDEX IF NOT EXISTS idx_entity_raw_visit_flags ON entity_raw (dataset_id, src_pk)
WHERE entity = 'ZVISIT' AND (raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL);