Files
rond-with-you/internal/db/schema.sql
T
tamakyi 82e63412c0 类别显示名可自己维护,顺手把两套对不上的中文名合成一套
你要改的那个「海滩」原来根本不在数据里——它是 Go 里硬编码的 map,所以改不动。
更糟的是这样的 map 有**两张**,而且对不上:

  MKPOICategoryFoodMarket   菜市场(编辑页)  /  食品市场(补全页、GPX)
  MKPOICategoryCafe         咖啡馆              /  咖啡厅
  MKPOICategoryFitnessCenter 健身房             /  健身中心
  外加 ATM / Atm 大小写各写一份、一条永远匹配不上的 MKPOICategoryMarina2

现在合成一张内置表(42 条,冲突处取编辑页那套),并在它上面加了一层站点覆盖:

- 新表 category_labels(value, label),站点级、不挂 dataset——值本身才是稳定键
- 端点 POST /admin/edit/category/label;传空 label = 删掉覆盖行、恢复内置默认名
- 类别台账那行改成两个带标签的输入框:
      导出值 [MKPOICategoryBeach]   ← 会跟着导出写回手机
      显示名 [               ]      ← 只影响站内显示,placeholder 写着默认值
  是 MKPOICategory 枚举但内置表里没有的,会提示「填一个中文名」,这就是你要的补全入口

**显示名绝不写进 places.poi_category**——中文跟着写进去就把导出数据污染了,
专门加了 TestCategoryLabelNeverTouchesValue 钉这条。显示名会贯穿到所有中文名的地方:
编辑页/照片页/补全页的下拉、补全页「在用」标签、访客地点页、GPX 的 desc。

连带删掉两个绑在旧表上的模板函数 catName / poiname:模板里算不出显示名(要读站点覆盖),
改成在 Go 侧算好塞进数据(FillupRow.CatLabel、PlaceData.CategoryLabel)。
2026-09-20 10:54:51 +08:00

561 lines
28 KiB
SQL
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.
-- rond-with-you 数据结构
-- 一次上传 = 一个 dataset,所有事实表挂在其下,支持多份备份共存与切换
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;
-- 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 表示不限区域(时间范围内的地点画到哪算哪)。
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 REFERENCES datasets(id) ON DELETE CASCADE,
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:
-- 换了存储后端之后旧图仍按各自那一列去取,不会因为改了设置就全变破图。
CREATE TABLE IF NOT EXISTS place_images (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
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);
-- ---------- 附属数据改挂坐标键(一次性迁移)----------
-- 备注 / 结论 / 图片原本挂 (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);