排查:/admin/settings 渲染到一半就断
- 现象:页面在「统计脚本」那张卡片处截断,没有保存按钮、没有 </form></html>,而 HTTP 仍是 200。
- 根因:跑的是旧二进制(改动前编译),磁盘上的模板却是新的。debug=true 下 pageSet()
每次渲染都从磁盘重解析模板,于是新 settings.html 引用了旧 setting.Settings 里没有的
TrackEnabled —— 执行期报错,而 render 只把错误写进日志,客户端只看到半张页面。
- 判据:strings <二进制> | grep -c cleanTrackScript(旧 0 / 新 2)。别用 strings | grep
TrackScript —— config 里本来就有这个名字,旧二进制也命中,是个假信号。
启动自检(防复发)
- template_smoke.go:pageProbe 给 24 个页面各配一份最小可用数据,New() 里全部渲染一遍,
任一页失败就拒绝启动。pageProbe 与 pageFiles 双向校验,新加页面必须补探针,
漏配按失败处理(不是省事)。
- smokeOne 用 recover 兜住方法里的 panic,报「哪一页坏了」而不是崩栈。
- 实测:往 settings.html 插一个不存在的字段 → 启动即报
「模板自检未通过…admin/settings: can't evaluate field …」,退出码 1。
- 门禁 template_smoke_test.go:一条跑全量自检,一条变异验证(探针换成字段更少的类型
必须报错且点名该页)。
站长导航被页面开关误伤
- guardPage 只拦访客(站长始终能访问全部公开页),但导航写的是 {{if .Settings.HasPage "x"}},
没有 IsAdmin 分支 ⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
- 门禁 nav_visibility_test.go:三向不变量(站长仍有全部入口 / 访客一个都没有 /
全开放时访客也看得到),做过变异验证。
- 访客侧「只有主页」是按后台配置应有的状态,没有动数据。
统计脚本关口收紧(cleanTrackScript)
- src= 只在 <script …> 开标签内部找。原来对整段做 strings.Index,脚本体里一句
`img.src=u` 就被命中、整段被判危险而清空 —— 页面照样提示「设置已保存」,站长看不出没挂上。
- 开闭标签从「只拒 shut > open」改成严格配平:末尾未闭合的 <script> 会把页面收尾整段吞进脚本。
- 不再静默:签名改成 (script, warn),说明码经 ?warn= 回显成人话;判为危险时整段不采纳、
库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
- 门禁 TestCleanTrackScript 重写:逐条边界 + 定义性不变量(采纳了非空结果时,
里面开闭标签数必定相等)+ TestTrackWarnMessage(未识别码不得回显)。变异验证做过。
- 文案订正:settings.html 的「conf/app.ini 的 track_script 会作为这里的默认值」有歧义 ——
它不回填文本框,而是「这个框留空时、注入那一刻才回落到 ini」。
其余
- bin/ 目录删除(15 个 exe / 412MB),二进制只留仓库根 rond.exe;
技能 rond-web-verify 与 memory details 十八里 bin/rond.exe 的写法同步改掉。
- 验收:./scripts/verify.sh 全绿且 0 skip(PASS=346 FAIL=0 SKIP=0)。
rond-with-you
把 rond(iOS 应用 LifeEasy)导出的 .rondbackup 备份,变成一张可浏览、可编辑、可对外分享的个人足迹地图。
数据全在自己的 PostgreSQL 里,站点无外部 CDN 依赖,单文件二进制约 19MB,1 核 1G 的小机器就能跑。
前台功能
- 主页:地点 / 城市 / 省份 / 记录天数 / 停留时长 / 出行里程等核心数字,足迹地图预览、类型分布、 城市排行、月度活跃度、活跃日历、出行方式、作息分布、标签、天气足迹、最近足迹——每个模块都能在后台开关
- 地图:全屏地图,支持聚合、底图切换(街道 / 纯路网 / 卫星影像 / OSM)、 按时间范围 / 类型 / 城市 / 标签 / 最短停留 / 关键词筛选; 另可按需叠加行程轨迹、到访热力、城市聚合、GPX 轨迹
- 地点库:全部地点的表格与搜索、排序、分页
- 地点详情:位置、到访次数、累计停留、逐次到访记录、图片画廊、访客投票
- 时间线:按天分组的到访流水
- 统计:月度分布、类型分布、作息分布、城市 / 省份排行、星期分布、出行方式、标签、天气, 以及世界迷雾覆盖与各城市点亮率
- 年度报告:
/report按年汇总,含「🏅 首次到达的城市」里程碑,可一键复制链接或打印成 PDF - 专题:把「一次旅行」这类线索单独成页——按时间段和 / 或「中心点 + 半径」圈定范围, 可附带迷雾层,适合分享某次出行的专属地图
- PWA:带 manifest 与图标,手机浏览器可「添加到主屏幕」独立窗口打开
后台功能
| 入口 | 能做什么 |
|---|---|
| 数据总览 | 上传 .rondbackup、切换/删除数据集、导入导出世界迷雾、导出数据 |
| 数据编辑 | 地点 / 到访 / 行程 / 活动类型 / 天气的增删改;带筛选与分页;坐标来源标记与筛选 |
| 照片导入 | 从相册批量补录地点(前端读 EXIF,可顺手把照片存为该地点的图片) |
| 地点备注 | 别名、备注、「推荐 / 踩雷」结论,支持批量操作 |
| 专题 | 新建 / 编辑 / 启停专题 |
| 设置 | 站点标题与头像、主题色、主页模块开关、访客可见页面、访客可见区域、隐家/隐工作、图片存储、默认底图 |
| 账号 | 改密码、两步验证(TOTP) |
数据编辑页上的「重复到访」
站点里可能多出一条一模一样的到访,成因有两种,卡片上会分别标出来:
- 站点侧副本:同一次到访在站点存了两份,差别只在 UUID(站点侧生成的那份在手机包里找不到)。 合并与差异页都是按 UUID 认人的,于是它被当成「备份新增」。
- 补录行被合并回来:站点补录的到访没有留档、也就没有 UUID;导出时会被补上一个确定性的
派生 UUID(
v5("ZVISIT/<编号>"))写进包里,手机于是把它当成一条真记录。这份包再合并回站点时, 站点这侧没有 UUID 可对齐,又插了一条。
判定以这份数据集的基线(导入时留下的手机包)为参照(第二类不用基线)。卡片只在检出时出现在 数据编辑页,默认全选 + 二次确认;清理会连它的位移 / 天气 / 留档一起清——「补录行被合并回来」那类 会把位移 / 天气改挂到保留项(补录行自己名下没有位移,跟着删就白丢里程),并把指向它的合并 标记改指到保留项。基线不可用或与本站一条都对不上时,只判第二类,另给一行说明。
命令行有一条等价的工具 只覆盖第一类:tools/dedup_site_visits.py(默认只预演,加 --apply 才动手)。
为什么有些到访在页面上看不到
手机包里有两种「不独立成立」的到访:被别的到访并入了的(ZVISIT.ZMERGEDTO),
以及某个长驻记录下的子段(ZVISIT.ZPARENT)。rond 自己既不显示、也不把它们算进次数与停留,
本站因此也不算——时间线、统计、地点计数、地图城市圈都不含它们。
⚠️ 这些行不删:导出包必须原样带着它们,rond 要靠它们认这次合并发生过。 数据编辑页会照常列出,并打上「已并入 #562 / 子段 #374」,方便核对。
技术栈
| 层 | 选型 |
|---|---|
| 后端 | Go 1.25(标准库 net/http + html/template,无 Web 框架) |
| 数据库 | PostgreSQL(pgx/v5) |
| 备份解析 | 纯 Go SQLite 驱动 modernc.org/sqlite,零 cgo |
| 前端 | 手写 CSS + Leaflet(本地内置),零外部 CDN 依赖 |
| 地图底图 | 高德(AutoNavi)栅格瓦片,可切换为天地图 / 自备瓦片 |
| 图片存储 | 服务器本地磁盘,或任意 S3 兼容对象存储(Cloudflare R2 等) |
| 图片转码 | 浏览器 canvas 转 WebP,服务端只收成品、不解码 |
| 口令 | PBKDF2-SHA256(21 万次迭代,标准库实现) |
| 会话 | HMAC-SHA256 签名 Cookie,改密后旧会话自动失效 |
快速开始
1. 准备数据库
createdb rond_with_you
# 或:psql -U postgres -c "CREATE DATABASE rond_with_you ENCODING 'UTF8'"
表结构在程序启动时自动创建(internal/db/schema.sql,全部 IF NOT EXISTS,老库会自动增量升级)。
2. 配置
cp conf/app.example.ini conf/app.ini
vi conf/app.ini # 至少需要填 dsn
配置项全部在 conf/app.example.ini 里有注释说明;只有 dsn 必填。
3. 运行
go build -o rond .
./rond -c conf/app.ini
首次启动会创建管理员账号,用户名取环境变量 ROND_ADMIN_USER(默认 admin),
密码取 ROND_ADMIN_PASSWORD(默认 rond-admin)。登录后请立即在后台修改密码。
打开 http://localhost:8080,进入 /admin 上传 .rondbackup 文件即可。
/healthz 是给探活用的轻量健康检查。
备份解析说明
.rondbackup 本质是一个 zip,内含 Core Data 的 SQLite 库 LifeEasy.sqlite 以及同名 -wal / -shm。
数据绝大部分写在前端 WAL 里,因此解析时三个文件必须一起解出再打开,否则会丢掉几乎全部记录。
导入时会做这些转换:
| 来源表 | 内容 | 落到 |
|---|---|---|
ZVISIT |
到访(到达 / 离开、类型、地点、备注、是否收藏) | visits |
ZLOCATION |
地点(名称、经纬度、城市、省份、POI 分类) | places |
ZACTIVITY |
自定义类型(颜色、图标、是否「家 / 工作」) | activities |
ZTAG / Z_11VISITS_ |
标签与访问标签关联 | tags / visit_tags |
ZMOVEMENT |
位移段(交通方式、起止时间) | movements,里程按 haversine 估算 |
ZHOURLYWEATHER |
逐小时天气 | weather |
其他处理:
- Core Data 时间戳以 2001-01-01 UTC 为基准,解析时统一转为标准时间
- 到达 / 离开时间恰好缺失的空记录会被跳过
- 一次上传 = 一份完整快照(一个数据集),页面始终只展示其中一份
- 两道判重:同一个文件按 SHA-256 直接复用已有数据集;内容相同但文件字节不同的 (rond 每次重新导出,WAL 与时间戳都会变)按内容指纹识别,同样不会新建快照
- 新导入的快照自动接管展示,旧快照留在后台可以随时切回或删除;删除数据集会连同其下 全部记录一并移除
- 每一行的原始记录都留着(
entity_raw表)。这是导出能还原的前提:站点的管理列由站点 覆盖,其余列一律按导入时那一行的原值写回,不会因为中间改过备注就丢掉 rond 自己写的字段 - 回导:后台可导出修改过的
.rondbackup(含补录的记录,坐标为 GCJ-02 原样写回), 可直接导回 rond App。导出走「基线库 + 覆盖」,不重建库
换基线(让导出底稿跟着手机换代)
导出不是从零重建数据库,而是以一份「底座」(storage/baselines/<数据集>.sqlite)为底,
只覆盖站点托管的那几张实体表。Core Data 元数据、索引、持久化历史、ZRAWVISIT 原始采样点、
ZTRIP* 等一律原样带出来——所以底座必须是手机导出的包,拿本站导出的包当底座会越导越失真。
底座一旦定下就跟着数据集走。手机那边重新导出后想换代,以前只能整包重新导入成新数据集 (站点侧补录的行、主题、备注都得重新收拾)。后台「换基线」就是给同一份数据的底座换代用的:
- 站点数据一行不动,只换底稿;换完导出立刻以新底座为准。
- 两道判据:① 这份包与当前数据集得是同一批数据(按到访 UUID 认,低于一半要显式确认, 一条都对不上直接拒绝);② 这份底座得真的能导出(先用它干跑一次导出,失败就放弃替换)。
- 替换前旧底座自动留档(最近 3 份),换错了在页面上一键回滚;回滚本身也留档,可再滚回来。
- 换过的底座记档在
datasets.baseline_name/baseline_sha256:底座文件丢了会按记档重建, 而不是退回最初那份旧包(否则上一次换基线会静默失效)。
数据归属(删掉一份数据集会带走什么)
一条铁律:从备份导入的数据随数据集删除;站点自建的数据不随数据集删除。
| 会随数据集删掉 | 不随数据集删掉 |
|---|---|
| 到访、地点、位移、天气、活动、标签、原始行留档 (重新上传同一份备份就能再导回来) |
备注、结论、地点图片、专题 |
判断标准很简单:这份记录能不能从备份重新导入? 能 ⇒ 挂 dataset_id … ON DELETE CASCADE;
不能 ⇒ 只留 dataset_id 当出处、不许挂外键。图片那次事故就是反过来的:行被级联删了、
文件却留在盘上,而读取是按坐标键跨数据集的——两套口径打架,删数据集就等于永久丢失。
这条规矩有两道门禁守着,改坏当场红(不用靠记性):
- 运行时:查
pg_constraint,凡是挂datasets的表必须在白名单里(TestOnlyBackupDerivedTablesCascadeFromDatasets) - 静态:直接扫
schema.sql的CREATE TABLE(TestSchemaDeclaresNoDatasetFKForUserData)—— 只看运行时有个盲点:新建的库走的是 CREATE TABLE,开发库可能压根没跑过那次迁移。
另外图片挂载体检常驻后台首页(库里 N 张 / 本地盘上 M 个 / 落单 K 个): 行与对象对不上时会直接报警,不必等到照片看不见了才追。
地点图片
- 字节在浏览器里转成 WebP 再上传,服务端只做形状校验、不重新编码(1 核机器扛不住解码大图)。
- 存哪边由后台设置决定:服务器本地磁盘(
<data_dir>/media/)或对象存储(S3 / R2)。 对象键是<数据集>/<上传月份>/<地点号>-<内容哈希>.webp,按月份分目录、带内容哈希天然去重。 - 图片认地点靠坐标键,跟备注、结论同一套:换数据集之后同一个地点照样看到同一张图。
- ⚠️ 早期版本图片记录挂在数据集上(
place_images.dataset_id带ON DELETE CASCADE), 于是「删掉旧数据集」会把图片记录一起删掉,而文件还留在磁盘上——读取按坐标键、行却按数据集级联, 两套口径打架,实测丢过一批图。现在外键已摘掉,删数据集不再带走图片;已经丢掉的那批可以恢复: 后台首页「图片挂载恢复 → 扫描并恢复」,它按坐标把还在的对象挂回当前数据集的地点上 (只插记录、不动对象、可反复执行)。本地磁盘与对象存储都扫: 本地走目录遍历,对象存储走 ListObjectsV2 列桶——行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。 ⚠️ 列桶需要凭据上有s3:ListBucket权限(R2 的 API Token 默认只给对象读写), 没权限时本地那半照救,页面会把该开哪个权限说明白。 认地点要用旧数据集留下的底座库(<data_dir>/baselines/<id>.sqlite)来还原「地点号 → 坐标」, 所以别删那个文件,换基线留下的.bak-*留档也别清——换过的底座未必还是上传那会儿的编号空间, 旧编号往往只在留档里(来源会全部读进来合并,逐个试到对上为止)。都没有才认不出,报告里会列出来。 - 删除数据集只清掉从备份导来的记录与原始行留档;备注、结论、图片按坐标键保留。
- 专题归人不归数据集:专题是「一段时间 + 一块区域」的定义,换数据集之后照旧成立 (内容照当前生效数据集算),删掉旧数据集不会再把它带走。
照片导入(补录没有轨迹的时间段)
手机里的照片带着拍摄时间和 GPS,可以批量补成「地点 + 到访」。
- 照片不用上传:EXIF 在浏览器里读(
static/js/photo-exif.js),只把坐标与时间提交上来。 一次几十张、几百 MB 的相册全传一遍既慢又占磁盘——早期版本真这么干过,51 张 334MB 直接把请求撑爆。 - 坐标系:EXIF 的 GPS 按标准是 WGS-84,导入页默认勾「WGS-84」,服务端转成 GCJ-02 再落库。 如果照片里的坐标已经是纠偏过的(某些安卓相册导出),要改选「已经是 GCJ-02」,否则整批会偏 500 米左右。
- 存图(可选):每行可勾「存图」,把这张照片转成 WebP 存为该地点的图片(见下节)。 默认不勾——一次几十张全存会占不少空间,也容易把相册里的截图一起带进来。
地图与坐标
- rond 存入库的经纬度已经是 GCJ-02(火星坐标)。 iPhone 在国内走的是高德数据源,
ZLOCATION/ZRAWVISIT.ZLATITUDE都是纠偏后的坐标(实测 2246 个采样点与 GCJ-02 理论偏移的 残差为 0 米),原始 WGS-84 GPS 另存于ZRAWVISIT.ZRAWLATITUDE。 - 因此底图只要是 GCJ-02(高德 / 天地图),直接绘制即可,不要再做任何纠偏;只有当底图是
WGS-84 时,才需要 GCJ-02 → WGS-84 逆变换。
static/js/rond-map.js会读/api/map-config返回的crs自动决定是否变换,切换底图时会重新投影已有标记。 - 底图在
conf/app.ini里配:map_provider = amap(默认,免密钥)/tianditu(需map_key)/custom(自备map_tile_url与map_crs)。填了map_key后瓦片改由服务端代理 (/tiles/{layer}/{z}/{x}/{y}),密钥不会出现在页面源码里。 - 内置底图:高德街道(免密钥)、高德纯路网(只画道路,不带 POI 与路名标注)、高德卫星影像、 OpenStreetMap(免密钥全球兜底)。高德瓦片连续加载失败时前端会自动切到 OSM 并弹提示, 出境数据也能正常显示;自定义瓦片源请自行确认符合国家地图数据规定,以保证国界、 台湾省、南海诸岛等要素的绘制符合国家标准。
- 默认铺哪张底图在后台「设置 → 地图底图」里选,不在
app.ini里。 点位密集时换成纯路网最清爽(实测同一块瓦片的文字像素占比从 1.5% 降到 0.2%); 访客在地图上手动切换过的话,浏览器会记住他自己的选择,优先于后台默认值。 - 个人位置数据保存在自己部署的数据库里,站点也提供了「隐藏家 / 隐藏工作 / 过滤短停留」的隐私开关; 默认隐藏「家 / 工作」,仅站长登录后可通过「含家/工作」开关临时显示。
- 坐标来源留档:每个地点都记着它是从哪来的——
coord_source(rond导入 /photo照片导入 /manual后台新增)、coord_sys(提交时是哪个坐标系)、以及换算前的原始坐标。 不变式是「显示坐标 = 按coord_sys换算原始坐标」,随时可复算、可核对; 后台地点列表带来源标记与筛选,行内有「按坐标刷新地址」按钮。 这一栏是踩过坑才加的:2026-09 有一次照片导入把坐标系选错,63 个点整体偏 500~670 米, 当时库里没有任何线索,只能逐张翻原图才查出来。
地点图片
给地点配图,访客在地点详情页点「图片」即可全屏浏览。
- 转码在浏览器里做:选中的原图先经 canvas 转成 WebP(长边 2000px、质量 0.82), 再把成品传给服务端。1 核的服务器解一张 4000px 的原图要好几秒,几张就能卡住整站; 用户自己的手机转一张只是几十毫秒的事。服务端只做文件头校验后原样写出去,不做解码。
- 存哪儿可选:后台「设置 → 图片存储」切换
- 服务器本地磁盘(默认):落在
data_dir下的media/,由站点自己的/media/直出 - 对象存储:S3 兼容协议(Cloudflare R2、MinIO、AWS S3 等),凭据写在
conf/app.ini的s3_*,不进数据库、不下发前端(服务端签名后直接 PUT) - 每张图各自记着自己实际存在哪,换存储后端不影响已有图片(旧的仍按原位置取,不会变破图)
- 服务器本地磁盘(默认):落在
- 对象命名:
<数据集>/<上传月份>/<地点号>-<内容哈希>.webp- 按月份分目录——一次出行传几十张,按月归堆最好找、也最好整月备份或迁移; 若按地点分目录,几百个地点会摊出几百个大多只有零张图的空夹
- 文件名带地点号,在对象存储控制台里一眼看出这张是谁的
- 带内容哈希,同一张图重复上传落到同一个 key,天然去重
- 本地存储删掉最后一张时会顺手收掉空的月份目录
- 上限:单张 6MB、单个地点 12 张;照片导入整包请求上限 60MB(勾超过 60 张会先提示分两次)
- 上传即生效,没有「保存」这一步:面板里会实时显示转码 / 上传进度、当前张数, 传完缩略图和页头的「图片 N」按钮会立刻出现。页头那个按钮是访客入口,站长自己也能点开预览
备注、别名与表态
- 地点别名与备注:地点详情页可填「别名」和「备注」。别名会替代原名显示在所有页面上
(把「未命名地点」改成「外婆家」);备注默认只有站长自己能看到,可在「站点设置 →
地点备注对访客公开」打开对外展示。
两者按 rond 的地点 ID(
src_pk)单独存表,重新上传备份、地点换了数据库 id 也不会丢。 - 推荐 / 踩雷:与自由文本备注分开的两类表态,用按钮交互。站长的结论与访客的票是两码事,
各存各的表、各看各的榜,互不影响。
- 站长结论(
place_votes,按坐标键存):详情页「我的结论」一键 👍推荐 / 👎踩雷, 再点已选按钮取消;后台备注页也能逐行或批量打标。 - 访客投票(
place_visitor_votes,同样按坐标键):详情页「大家怎么说」可点赞 / 点踩, 一人一票、可改可撤;匿名身份由 cookie + IP 派生并哈希,清 cookie 也会被识别为同一来源, 起轻度防刷作用。 - 展示:地点详情、地点库(行内徽章 + 「推荐 / 踩雷」筛选,都是站长结论)、地图气泡; 统计页分成两块——「推荐 & 踩雷(我的结论)」只算站长标注的, 「访客点赞」单独一块,按 👍/👎 票数列出被顶得最高的地点并带票数。 是否对访客公开由「站点设置 → 推荐 / 踩雷标记对访客公开」控制(默认公开),两块同一开关。
- 站长结论(
- 备注批量管理:后台「地点备注」页列出当前数据集的全部地点(不只已写备注的), 支持关键词 / 城市 / 状态(全部 / 有备注 / 无备注)/ 排序筛选与分页;每行可就地编辑别名、 备注并快速打标。顶部批量条可对勾选项批量设置结论 / 清空备注(保留别名)/ 删除备注; 给原本没有备注的地点直接填写保存即等于新增。挂在当前数据集已不存在地点上的「孤儿备注」 单独列在底部便于清理。
轨迹、热力与年度报告
- 行程轨迹:地图页勾选「行程轨迹」,按时间顺序把位移段画成连线,颜色跟随 rond 的
交通方式配色(没记录交通方式的段归「步行 / 其他」灰色);右下角自动出现图例,
数据来自
/api/track,随筛选条件联动。 - 城市级聚合:地图页勾选「城市聚合」,按到访量加权画出城市圈,圈越大表示到访越多,
点开 popup 可见该城市的首次 / 末次到访日期;数据来自
/api/cities(已自动剔除「家 / 工作」)。 - 到访热力:地图页勾选「到访热力」,按到访频次渲染热力图(内置 leaflet-heat)。
- 年度报告:
/report按年展示到访、新地点、城市、活跃天数、里程、南北两端与交通方式, 迷雾面积为累计口径;并单列「🏅 首次到达的城市」里程碑——该城市有史以来第一次到访落在本年的, 按到达日期排序。报告页右上角有「复制链接」与「保存为 PDF」(调用浏览器打印,已配@media print样式,隐藏导航与按钮、卡片避免跨页,直接生成清爽的长图 / PDF 便于分享)。 - GPX 补充轨迹:后台上传
.gpx(或.xml)文件,解析其中的trk(轨迹段)与rte(路线点), 按 haversine 估算里程后存入gpx_tracks,地图上勾选「GPX 轨迹」即用橙色叠加真实路径。 库里存的是 WGS-84(GPX 标准),高德 / 天地图等 GCJ-02 底图会按点做 WGS-84 → GCJ-02 正变换, 和足迹点对齐;可在后台删除已导入轨迹。
世界迷雾(Fog of World)
后台支持导入 Fog of World 导出的 .fwss 快照,把「已探索区域」作为半透明图层叠加在足迹地图上。
- 格式:
.fwss是 zip,Model/*/下每个文件是一块瓦片(全球 512×512 块瓦片 × 128×128 块 × 64×64 格位图),内容 zlib 压缩;文件名由瓦片编号经 md5 前缀与两张置换表编码而成。 - 坐标系:迷雾网格是 Web Mercator 投影的 WGS-84(实测确认:与 rond 地点做 GCJ→WGS 转换后
90% 落差在 50m 内、中位 7m)。因此画在 GCJ-02 底图(高德)上时,服务端按块中心做 WGS→GCJ
正变换再渲染(
/tiles/fog/{z}/{x}/{y}?crs=gcj02);WGS-84 底图则原样绘制。前端切换底图坐标系时 会自动换 URL 重新拉取迷雾瓦片。 - 导入模式:叠加(默认)把新快照按位或合并进现有迷雾,适合逐步追加新路线;覆盖先清空 再整体替换,适合迁移或纠错。同一块内的位图按格合并,不会互相抹掉。后台卡片会显示当前 已入库的块/格数、估算面积与最近更新时间,并提供导出与「清空迷雾」(需二次确认)操作。
- 导出:后台「导出当前迷雾」把库里的迷雾还原成标准
.fwss(仅Model/*/),可直接导回 Fog of World 或迁移到其他部署。已验证导出→再导入格数无损。 - 迷雾遮罩区:可以按「圆心 + 半径」定义若干对访客隐藏的区域,比如把常住地那片抹掉。 遮罩只对访客生效,站长永远看到完整迷雾;被遮的格子不画填充、也不当成「未探索」的邻居, 免得遮罩边缘描出轮廓反而暴露范围。
- 迷雾浓度:地图右下(窄屏左下)的图层控件旁有「迷雾浓度」滑杆,可按底图明暗调节透明度, 选择会记在本地浏览器。
- 统计页有「世界迷雾覆盖」区块:估算已探索面积(格边长 9.55m × cos(纬度) 修正)与 各城市「点亮率」(地点落在已探索格内的比例)。
账号安全与公开页面
- 两步验证(TOTP):后台「两步验证」页扫码或手输密钥绑定 Google/Microsoft Authenticator、 1Password、Aegis 等验证器(RFC 6238,±1 窗口容错)。开启后登录分两步:口令通过后先签发 5 分钟有效的中间令牌(绑定口令摘要,改密即失效),再输入 6 位动态码才发正式会话。 关闭两步验证同样要求新鲜的动态码,防止会话被劫持后直接拆掉 2FA。
- 登录验证码:登录页带图形验证码,防脚本撞库。
- 公开页面开关:后台「站点设置」可勾选对外展示的页面(地图 / 足迹 / 时间线 / 统计 / 报告)。 关闭的页面对未登录访客不开放,导航栏同步隐藏;管理员始终可见。
- 访客可见区域:后台「访客可见区域」一行填一个,省 / 市 / 区任意层级都认
(如「广东省」「贵阳市」「南明区」),并可选择白名单(只放行这些区域)或
黑名单(藏起这些区域)。地图、地点库、时间线、统计、报告、地点详情全套生效,
管理员登录后仍是全量。
访客点到被隐藏的地点详情时不再直接甩 404,而是给一页说明「这个地点没有对访客开放」
并引导回首页(4 秒后自动跳转,也可手动点按钮)——404 会让人以为站点坏了。
代价是能反推出该 ID 确实存在;如果更在意这一点,把
internal/web/notice.go里的placeHiddenPage改回http.NotFound即可。
目录结构
main.go 启动入口
conf/app.example.ini 配置样例(真实的 app.ini 请勿提交)
internal/
config/ 配置解析
db/ 连接、迁移、schema.sql
rond/ .rondbackup 解压与 Core Data 解析
ingest/ 解析结果写入 PostgreSQL;坐标来源留档
stats/ 全部页面查询(统一筛选条件)
auth/ 口令、签名会话与 TOTP
setting/ 站点设置(JSONB)
notes/ 地点别名 / 备注
votes/ 地点「推荐 / 踩雷」结论与访客投票
media/ 地点图片存储(本地磁盘 / S3 兼容对象存储 + SigV4 签名)
gpx/ GPX 轨迹解析
geo/ haversine 与坐标系纠偏(全站唯一的坐标换算实现)
fog/ 世界迷雾 .fwss 解码 / 编码 / 入库 / 瓦片渲染
web/ 路由、处理器、模板与静态资源
static/js/photo-exif.js 浏览器端读 EXIF
static/js/place-image.js 浏览器端转 WebP 并上传
storage/ 上传文件、解析中间目录与本地图片(运行时生成)
tools/ 维护脚本(备份往返校验、坐标审计等)
部署建议(1C1G 小机器)
# 交叉编译 Linux 单文件
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o rond .
配合 deploy/rond-with-you.service 用 systemd 托管即可,程序本身无其他运行时依赖,
也不需要 libwebp 之类的原生库(图片转码在浏览器端完成,见上文)。
传输层与运行时优化(已内置)
- gzip:HTML / CSS / JS / JSON 在线压缩(图片本身已压缩,直接透传)。
实测
/stats38KB → 5.4KB、/api/track39KB → 6.7KB。 - 静态资源强缓存:页面链接都带
?v=<静态文件内容哈希>,版本一致才返回max-age=31536000, immutable;重新编译后哈希变化,旧缓存自动失效。 - 地图数据按需加载:
/map首屏只出页面骨架(12.5KB,gzip 后 3.1KB),地点数据走/api/points并行拉取;轨迹与热力图都是勾选后才请求,热力图库也是首次勾选时才下载。 - 连接池:PostgreSQL 建连接比查询贵两个数量级,因此
MaxIdleConns与MaxOpenConns相等(8/8)且启动时预热;页面内的独立查询并发跑(上限 6)。 debug = true时会打印超过 30ms 的慢查询,便于定位。
若前面还挂了 Nginx,注意不要重复 gzip,且需把 Accept-Encoding 透传给后端。
开发提示
- 模板走
//go:embed,改了.html必须重新编译;debug = true时 CSS / JS 直读磁盘,改样式免重编。 - 时间、坐标这类「原样回贴」的字段有讲究:表单精度(分钟 / 6 位小数)往往低于库里真值, 保存时若与库里已有值落在同一精度上要沿用库里那份,否则只改个备注也会把时间或坐标悄悄改掉。
tools/verify_rondbackup.py可以做导入→导出→逐表对账,检查改动有没有破坏备份保真度。- 全量验收
scripts/verify.sh(要 0 FAIL / 0 SKIP)。每个夹具都会导入一整包再删掉, 一轮下来测试库能留几百 MB 死元组,级联删除随之变慢(实测 16MB 时约 6 分钟跑完、 几百 MB 时半小时都跑不完,看着像卡死)。脚本会在库超过VACUUM_MIN_MB(默认 200MB) 时先清一次死元组;手动来一次VACUUM (FULL, ANALYZE)还能把文件也缩回去。 另外要确认库里有生效数据集(datasets.is_active),否则internal/stats的对照用例会假 skip。