用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:
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。
664 lines
35 KiB
SQL
664 lines
35 KiB
SQL
-- 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);
|