first release
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
// WarmUp 在后台把连接先建好放进池里。
|
||||
//
|
||||
// 建连接是这套系统里最贵的一步(见上面 SetMaxIdleConns 的注释),冷启动时
|
||||
// 第一个页面会把这笔开销全吃掉;并发建 n 条(不是串行)只需约一次建连的时间。
|
||||
// 失败就放弃——真实的报错由后续请求自己暴露,这里不必打扰启动流程。
|
||||
func WarmUp(d *sql.DB, n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
conn, err := d.Conn(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close() // 归还到池里,之后就是热连接
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func Open(dsn string) (*sql.DB, error) {
|
||||
d, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空闲连接数必须与上限相同:PostgreSQL 建连接的开销远大于查询本身
|
||||
// (本机实测建连 ~300ms、查询 ~2ms,psql 也一样)。池里留不住连接时,
|
||||
// 每个并发请求都要重新建连,页面耗时反而比串行还高。
|
||||
d.SetMaxOpenConns(8)
|
||||
d.SetMaxIdleConns(8)
|
||||
d.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err = d.PingContext(ctx)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
}
|
||||
// 配置文件里写了一个还不存在的库:先用维护库把它建出来再连一次。
|
||||
// 只在「库不存在」时兜底——口令错、端口不通这些要原样报出来,
|
||||
// 否则连错服务器也会被当成「建个新的」。
|
||||
if !isDatabaseMissing(err) {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败: %w", err)
|
||||
}
|
||||
if cerr := createDatabase(ctx, dsn); cerr != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("数据库不存在,自动创建失败: %w", cerr)
|
||||
}
|
||||
if err := d.PingContext(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败(库已创建): %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// isDatabaseMissing 判断错误是不是「目标库不存在」(SQLSTATE 3D000)。
|
||||
// 驱动有时只给文本,所以再按消息兜一层。
|
||||
func isDatabaseMissing(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "3D000" {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "database") && strings.Contains(msg, "does not exist")
|
||||
}
|
||||
|
||||
// createDatabase 连到维护库把 dsn 里指定的库建出来。
|
||||
// 库名走 pgx.Identifier 转义,配置里写什么名字都不会拼坏语句。
|
||||
func createDatabase(ctx context.Context, dsn string) error {
|
||||
cc, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 dsn 失败: %w", err)
|
||||
}
|
||||
name := cc.Database
|
||||
if name == "" {
|
||||
return errors.New("dsn 里没有指定库名")
|
||||
}
|
||||
// 维护库优先 postgres,个别部署会删掉它,那就退回 template1
|
||||
var lastErr error
|
||||
for _, maint := range []string{"postgres", "template1"} {
|
||||
mc, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 dsn 失败: %w", err)
|
||||
}
|
||||
mc.Database = maint
|
||||
conn, err := pgx.ConnectConfig(ctx, mc)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize())
|
||||
conn.Close(ctx)
|
||||
if err != nil {
|
||||
// 并发启动时可能已经被另一个实例建好了,那就算成功
|
||||
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w(当前账号可能没有 CREATEDB 权限)", err)
|
||||
}
|
||||
log.Printf("数据库 %s 不存在,已自动创建", name)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("连不上维护库(postgres / template1): %w", lastErr)
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, d *sql.DB) error {
|
||||
if _, err := d.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("初始化表结构失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return contains(s, "SQLSTATE 23505") || contains(s, "duplicate key value")
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// TestOpenCreatesMissingDatabase 验证「配置里写了不存在的库,启动时自动建出来」。
|
||||
// 需要一个可连的库:用 ROND_DSN 指过来(会临时建一个库并在结束时删掉),未设置时跳过。
|
||||
func TestOpenCreatesMissingDatabase(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("设置 ROND_DSN 后运行自动建库检查")
|
||||
}
|
||||
name := fmt.Sprintf("rond_test_autocreate_%d", time.Now().UnixNano())
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 ROND_DSN: %v", err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
target := u.String()
|
||||
|
||||
d, err := Open(target)
|
||||
if err != nil {
|
||||
t.Fatalf("库不存在时应自动建出来再连,实际报错: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
d.Close()
|
||||
// DROP DATABASE 要求没有连接挂着,所以先关掉上面的句柄
|
||||
dropDatabase(t, dsn, name)
|
||||
}()
|
||||
|
||||
// 库真的建出来了
|
||||
var n int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM pg_database WHERE datname=$1`, name).Scan(&n); err != nil {
|
||||
t.Fatalf("查 pg_database: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("库 %s 未被创建(匹配到 %d 条)", name, n)
|
||||
}
|
||||
// 建表也能正常跑(否则「建了个空库」没有意义)
|
||||
if err := Migrate(context.Background(), d); err != nil {
|
||||
t.Fatalf("初始化表结构: %v", err)
|
||||
}
|
||||
var tables int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM information_schema.tables WHERE table_schema='public'`).Scan(&tables); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tables < 10 {
|
||||
t.Errorf("表结构不完整,只有 %d 张表", tables)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenExistingDatabase 守住常规路径:库已存在时不该多做事(也不该报错)。
|
||||
func TestOpenExistingDatabase(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("设置 ROND_DSN 后运行连接检查")
|
||||
}
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("连接既有库失败: %v", err)
|
||||
}
|
||||
d.Close()
|
||||
}
|
||||
|
||||
// TestDatabaseMissingDetection 确认只有「库不存在」会被当成需要建库的信号,
|
||||
// 否则连错服务器、口令错也会被当成「那就建个新的」。
|
||||
func TestDatabaseMissingDetection(t *testing.T) {
|
||||
if isDatabaseMissing(nil) {
|
||||
t.Error("nil 不该判为库不存在")
|
||||
}
|
||||
if !isDatabaseMissing(fmt.Errorf(`failed to connect: database "rond_x" does not exist (SQLSTATE 3D000)`)) {
|
||||
t.Error("库不存在的错误应被识别")
|
||||
}
|
||||
for _, msg := range []string{
|
||||
"failed SASL auth: 用户 \"postgres\" Password 认证失败 (SQLSTATE 28P01)",
|
||||
"dial tcp 127.0.0.1:5499: connectex: No connection could be made",
|
||||
} {
|
||||
if isDatabaseMissing(fmt.Errorf("%s", msg)) {
|
||||
t.Errorf("不该判为库不存在: %s", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dropDatabase(t *testing.T, dsn, name string) {
|
||||
t.Helper()
|
||||
cc, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Logf("清理时解析 dsn 失败: %v", err)
|
||||
return
|
||||
}
|
||||
cc.Database = "postgres"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
conn, err := pgx.ConnectConfig(ctx, cc)
|
||||
if err != nil {
|
||||
t.Logf("清理时连维护库失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
if _, err := conn.Exec(ctx, "DROP DATABASE IF EXISTS "+pgx.Identifier{name}.Sanitize()); err != nil {
|
||||
t.Logf("删除测试库失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
-- 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()
|
||||
);
|
||||
|
||||
-- 世界迷雾(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)
|
||||
);
|
||||
|
||||
-- 地点备注与别名。按 src_pk(rond 内部地点 ID,跨快照稳定)而不是 places.id 存,
|
||||
-- 这样重新上传备份、数据集换了新 id 之后备注依然挂着。
|
||||
-- dataset_id 是后加的:src_pk 只在单个数据集内唯一,缺了它切数据集时同编号的
|
||||
-- 地点会把别人的备注/别名认领过去。
|
||||
CREATE TABLE IF NOT EXISTS place_notes (
|
||||
user_id BIGINT NOT NULL,
|
||||
dataset_id BIGINT NOT NULL DEFAULT 0,
|
||||
src_pk INT NOT NULL,
|
||||
alias TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, dataset_id, src_pk)
|
||||
);
|
||||
|
||||
-- 站长对地点的「官方结论」:推荐(1) / 踩雷(-1),与自由文本备注分开存,同样按 src_pk。
|
||||
CREATE TABLE IF NOT EXISTS place_votes (
|
||||
user_id BIGINT NOT NULL,
|
||||
dataset_id BIGINT NOT NULL DEFAULT 0,
|
||||
src_pk INT NOT NULL,
|
||||
verdict SMALLINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, dataset_id, src_pk)
|
||||
);
|
||||
|
||||
-- 访客对地点的投票:一人一票、可改可撤。voter_key 由匿名 cookie + IP 派生,
|
||||
-- 与数据集无关(换快照不丢),故不挂 dataset_id。
|
||||
CREATE TABLE IF NOT EXISTS place_visitor_votes (
|
||||
src_pk INT NOT NULL,
|
||||
voter_key TEXT NOT NULL,
|
||||
vote SMALLINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (src_pk, 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;
|
||||
|
||||
DO $$
|
||||
DECLARE cols text;
|
||||
BEGIN
|
||||
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,
|
||||
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);
|
||||
Reference in New Issue
Block a user