From b327bc1230c7f38060e6a303977b845baa37baeb Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 18 Sep 2026 13:05:49 +0800 Subject: [PATCH] first release --- .gitattributes | 23 + .gitignore | 25 + README.md | 311 +++ conf/app.example.ini | 92 + deploy/rond-with-you.service | 25 + go.mod | 28 + go.sum | 77 + internal/auth/auth.go | 208 ++ internal/auth/auth_test.go | 76 + internal/auth/totp.go | 159 ++ internal/captcha/captcha.go | 265 +++ internal/captcha/captcha_test.go | 179 ++ internal/config/config.go | 157 ++ internal/config/config_test.go | 88 + internal/db/db.go | 153 ++ internal/db/db_test.go | 111 + internal/db/schema.sql | 414 ++++ internal/exif/exif.go | 254 +++ internal/exif/exif_test.go | 215 ++ internal/exif/heif.go | 222 ++ internal/fog/fog.go | 303 +++ internal/fog/fog_test.go | 185 ++ internal/fog/layers.go | 66 + internal/fog/mask.go | 188 ++ internal/fog/mask_test.go | 143 ++ internal/fog/render.go | 188 ++ internal/fog/store.go | 300 +++ internal/geo/geo.go | 93 + internal/gpx/gpx.go | 157 ++ internal/ingest/coord.go | 158 ++ internal/ingest/ingest.go | 414 ++++ internal/media/media.go | 333 +++ internal/media/media_test.go | 145 ++ internal/notes/notes.go | 129 ++ internal/rond/export.go | 1381 ++++++++++++ internal/rond/export_roundtrip_test.go | 424 ++++ internal/rond/parse.go | 675 ++++++ internal/setting/setting.go | 263 +++ internal/setting/setting_test.go | 133 ++ internal/stats/stats.go | 966 ++++++++ internal/stats/stats_test.go | 252 +++ internal/stats/weather_where_test.go | 78 + internal/votes/votes.go | 164 ++ internal/web/assets.go | 60 + internal/web/backfill.go | 151 ++ internal/web/filter_options_test.go | 49 + internal/web/fog.go | 356 +++ internal/web/fog_mask.go | 103 + internal/web/geocode.go | 411 ++++ internal/web/gzip.go | 115 + internal/web/handlers_admin.go | 1189 ++++++++++ internal/web/handlers_edit.go | 1993 +++++++++++++++++ internal/web/handlers_export.go | 208 ++ internal/web/handlers_images.go | 267 +++ internal/web/handlers_photos.go | 281 +++ internal/web/handlers_public.go | 1125 ++++++++++ internal/web/handlers_topics.go | 500 +++++ internal/web/handlers_vote.go | 234 ++ internal/web/mask_cache.go | 84 + internal/web/movement_e2e_test.go | 683 ++++++ internal/web/movement_form_test.go | 122 + internal/web/notice.go | 49 + internal/web/parallel.go | 66 + internal/web/parallel_test.go | 97 + internal/web/parse_visit_times_test.go | 46 + internal/web/place_coord_test.go | 440 ++++ internal/web/place_image_test.go | 304 +++ internal/web/seq_test.go | 94 + internal/web/server.go | 472 ++++ internal/web/static/css/app.css | 900 ++++++++ internal/web/static/favicon.svg | 5 + internal/web/static/icons/icon-192.png | Bin 0 -> 1492 bytes internal/web/static/icons/icon-512.png | Bin 0 -> 4600 bytes internal/web/static/js/photo-exif.js | 278 +++ internal/web/static/js/place-image.js | 119 + internal/web/static/js/rond-map.js | 930 ++++++++ internal/web/static/manifest.webmanifest | 15 + .../static/vendor/flatpickr/flatpickr.min.css | 13 + .../static/vendor/flatpickr/flatpickr.min.js | 2 + .../web/static/vendor/flatpickr/l10n/zh.js | 68 + .../web/static/vendor/heat/leaflet-heat.js | 11 + .../vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes .../static/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes .../vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes .../vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes .../vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes .../web/static/vendor/leaflet/leaflet.css | 661 ++++++ internal/web/static/vendor/leaflet/leaflet.js | 6 + .../markercluster/MarkerCluster.Default.css | 60 + .../vendor/markercluster/MarkerCluster.css | 14 + .../markercluster/leaflet.markercluster.js | 2 + internal/web/templates/admin/edit.html | 1081 +++++++++ internal/web/templates/admin/fog-mask.html | 173 ++ internal/web/templates/admin/index.html | 169 ++ internal/web/templates/admin/login.html | 53 + internal/web/templates/admin/notes.html | 189 ++ internal/web/templates/admin/password.html | 35 + internal/web/templates/admin/photos.html | 746 ++++++ internal/web/templates/admin/settings.html | 151 ++ internal/web/templates/admin/topics.html | 105 + internal/web/templates/admin/twofa-login.html | 39 + internal/web/templates/admin/twofa.html | 62 + internal/web/templates/home.html | 182 ++ internal/web/templates/layout.html | 295 +++ internal/web/templates/map.html | 333 +++ internal/web/templates/place.html | 454 ++++ internal/web/templates/places.html | 84 + internal/web/templates/report.html | 144 ++ internal/web/templates/stats.html | 245 ++ internal/web/templates/timeline.html | 60 + internal/web/templates/topic.html | 97 + internal/web/templates/topics.html | 33 + internal/web/templates_test.go | 19 + internal/web/tiles.go | 261 +++ internal/web/track_tag_test.go | 34 + internal/web/view.go | 1389 ++++++++++++ internal/web/visitor_pages_test.go | 106 + main.go | 88 + scripts/build-linux.sh | 7 + .../photo_coord_audit.cpython-313.pyc | Bin 0 -> 26042 bytes tools/audit_db.sql | 96 + tools/photo_coord_audit.py | 354 +++ tools/verify_rondbackup.py | 116 + 123 files changed, 30003 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 conf/app.example.ini create mode 100644 deploy/rond-with-you.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/totp.go create mode 100644 internal/captcha/captcha.go create mode 100644 internal/captcha/captcha_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/schema.sql create mode 100644 internal/exif/exif.go create mode 100644 internal/exif/exif_test.go create mode 100644 internal/exif/heif.go create mode 100644 internal/fog/fog.go create mode 100644 internal/fog/fog_test.go create mode 100644 internal/fog/layers.go create mode 100644 internal/fog/mask.go create mode 100644 internal/fog/mask_test.go create mode 100644 internal/fog/render.go create mode 100644 internal/fog/store.go create mode 100644 internal/geo/geo.go create mode 100644 internal/gpx/gpx.go create mode 100644 internal/ingest/coord.go create mode 100644 internal/ingest/ingest.go create mode 100644 internal/media/media.go create mode 100644 internal/media/media_test.go create mode 100644 internal/notes/notes.go create mode 100644 internal/rond/export.go create mode 100644 internal/rond/export_roundtrip_test.go create mode 100644 internal/rond/parse.go create mode 100644 internal/setting/setting.go create mode 100644 internal/setting/setting_test.go create mode 100644 internal/stats/stats.go create mode 100644 internal/stats/stats_test.go create mode 100644 internal/stats/weather_where_test.go create mode 100644 internal/votes/votes.go create mode 100644 internal/web/assets.go create mode 100644 internal/web/backfill.go create mode 100644 internal/web/filter_options_test.go create mode 100644 internal/web/fog.go create mode 100644 internal/web/fog_mask.go create mode 100644 internal/web/geocode.go create mode 100644 internal/web/gzip.go create mode 100644 internal/web/handlers_admin.go create mode 100644 internal/web/handlers_edit.go create mode 100644 internal/web/handlers_export.go create mode 100644 internal/web/handlers_images.go create mode 100644 internal/web/handlers_photos.go create mode 100644 internal/web/handlers_public.go create mode 100644 internal/web/handlers_topics.go create mode 100644 internal/web/handlers_vote.go create mode 100644 internal/web/mask_cache.go create mode 100644 internal/web/movement_e2e_test.go create mode 100644 internal/web/movement_form_test.go create mode 100644 internal/web/notice.go create mode 100644 internal/web/parallel.go create mode 100644 internal/web/parallel_test.go create mode 100644 internal/web/parse_visit_times_test.go create mode 100644 internal/web/place_coord_test.go create mode 100644 internal/web/place_image_test.go create mode 100644 internal/web/seq_test.go create mode 100644 internal/web/server.go create mode 100644 internal/web/static/css/app.css create mode 100644 internal/web/static/favicon.svg create mode 100644 internal/web/static/icons/icon-192.png create mode 100644 internal/web/static/icons/icon-512.png create mode 100644 internal/web/static/js/photo-exif.js create mode 100644 internal/web/static/js/place-image.js create mode 100644 internal/web/static/js/rond-map.js create mode 100644 internal/web/static/manifest.webmanifest create mode 100644 internal/web/static/vendor/flatpickr/flatpickr.min.css create mode 100644 internal/web/static/vendor/flatpickr/flatpickr.min.js create mode 100644 internal/web/static/vendor/flatpickr/l10n/zh.js create mode 100644 internal/web/static/vendor/heat/leaflet-heat.js create mode 100644 internal/web/static/vendor/leaflet/images/layers-2x.png create mode 100644 internal/web/static/vendor/leaflet/images/layers.png create mode 100644 internal/web/static/vendor/leaflet/images/marker-icon-2x.png create mode 100644 internal/web/static/vendor/leaflet/images/marker-icon.png create mode 100644 internal/web/static/vendor/leaflet/images/marker-shadow.png create mode 100644 internal/web/static/vendor/leaflet/leaflet.css create mode 100644 internal/web/static/vendor/leaflet/leaflet.js create mode 100644 internal/web/static/vendor/markercluster/MarkerCluster.Default.css create mode 100644 internal/web/static/vendor/markercluster/MarkerCluster.css create mode 100644 internal/web/static/vendor/markercluster/leaflet.markercluster.js create mode 100644 internal/web/templates/admin/edit.html create mode 100644 internal/web/templates/admin/fog-mask.html create mode 100644 internal/web/templates/admin/index.html create mode 100644 internal/web/templates/admin/login.html create mode 100644 internal/web/templates/admin/notes.html create mode 100644 internal/web/templates/admin/password.html create mode 100644 internal/web/templates/admin/photos.html create mode 100644 internal/web/templates/admin/settings.html create mode 100644 internal/web/templates/admin/topics.html create mode 100644 internal/web/templates/admin/twofa-login.html create mode 100644 internal/web/templates/admin/twofa.html create mode 100644 internal/web/templates/home.html create mode 100644 internal/web/templates/layout.html create mode 100644 internal/web/templates/map.html create mode 100644 internal/web/templates/place.html create mode 100644 internal/web/templates/places.html create mode 100644 internal/web/templates/report.html create mode 100644 internal/web/templates/stats.html create mode 100644 internal/web/templates/timeline.html create mode 100644 internal/web/templates/topic.html create mode 100644 internal/web/templates/topics.html create mode 100644 internal/web/templates_test.go create mode 100644 internal/web/tiles.go create mode 100644 internal/web/track_tag_test.go create mode 100644 internal/web/view.go create mode 100644 internal/web/visitor_pages_test.go create mode 100644 main.go create mode 100644 scripts/build-linux.sh create mode 100644 tools/__pycache__/photo_coord_audit.cpython-313.pyc create mode 100644 tools/audit_db.sql create mode 100644 tools/photo_coord_audit.py create mode 100644 tools/verify_rondbackup.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6ad68cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# 统一按 LF 存放与检出。 +# +# 仓库里的文本一直是 LF,但 Windows 默认的 core.autocrlf=true 会把**工作区**换成 CRLF, +# 于是 `gofmt -l` 会把「行尾是 CRLF」误报成「格式不对」——18 个文件里只有 12 个是真问题, +# 真实的格式问题就淹没在噪声里。这条属性让工作区也保持 LF,检查才有意义。 +* text=auto eol=lf + +# 二进制:别让 git 去做行尾转换或内容探测 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.zip binary +*.gz binary +*.pdf binary +*.sqlite binary +*.rondbackup binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4cd51f --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 构建产物 +/rond +/rond.exe +*.exe~ +/bin + +# 运行时数据 +/storage/ +/tmp/ +/shots/ +/dist/ +.tmp-test/ +.playwright-cli/ + +# 含密钥的真实配置 +/conf/app.ini +/conf/app-test.ini + +# 原始备份 +/data/ +.workbuddy/ +.tmp-test/ + +# 开发期的临时探针/抓取产物(不属于运行时) +/tmpfog/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..eec8ead --- /dev/null +++ b/README.md @@ -0,0 +1,311 @@ +# rond-with-you + +把 rond(iOS 应用 LifeEasy)导出的 `.rondbackup` 备份,变成一张可浏览、可编辑、可对外分享的个人足迹地图。 + +数据全在自己的 PostgreSQL 里,站点无外部 CDN 依赖,单文件二进制约 19MB,1 核 1G 的小机器就能跑。 + +## 前台功能 + +- **主页**:地点 / 城市 / 省份 / 记录天数 / 停留时长 / 出行里程等核心数字,足迹地图预览、类型分布、 + 城市排行、月度活跃度、活跃日历、出行方式、作息分布、标签、天气足迹、最近足迹——每个模块都能在后台开关 +- **地图**:全屏地图,支持聚合、底图切换(街道 / 纯路网 / 卫星影像 / OSM)、 + 按时间范围 / 类型 / 城市 / 标签 / 最短停留 / 关键词筛选; + 另可按需叠加**行程轨迹**、**到访热力**、**城市聚合**、**GPX 轨迹** +- **地点库**:全部地点的表格与搜索、排序、分页 +- **地点详情**:位置、到访次数、累计停留、逐次到访记录、**图片画廊**、访客投票 +- **时间线**:按天分组的到访流水 +- **统计**:月度分布、类型分布、作息分布、城市 / 省份排行、星期分布、出行方式、标签、天气, + 以及世界迷雾覆盖与各城市点亮率 +- **年度报告**:`/report` 按年汇总,含「🏅 首次到达的城市」里程碑,可一键复制链接或打印成 PDF +- **专题**:把「一次旅行」这类线索单独成页——按时间段和 / 或「中心点 + 半径」圈定范围, + 可附带迷雾层,适合分享某次出行的专属地图 +- **PWA**:带 manifest 与图标,手机浏览器可「添加到主屏幕」独立窗口打开 + +## 后台功能 + +| 入口 | 能做什么 | +| --- | --- | +| 数据总览 | 上传 `.rondbackup`、切换/删除数据集、导入导出世界迷雾、导出数据 | +| 数据编辑 | 地点 / 到访 / 行程 / 活动类型 / 天气的增删改;带筛选与分页;坐标来源标记与筛选 | +| 照片导入 | 从相册批量补录地点(前端读 EXIF,可顺手把照片存为该地点的图片) | +| 地点备注 | 别名、备注、「推荐 / 踩雷」结论,支持批量操作 | +| 专题 | 新建 / 编辑 / 启停专题 | +| 设置 | 站点标题与头像、主题色、主页模块开关、访客可见页面、访客可见区域、隐家/隐工作、图片存储、默认底图 | +| 账号 | 改密码、两步验证(TOTP) | + +## 技术栈 + +| 层 | 选型 | +| --- | --- | +| 后端 | 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. 准备数据库 + +```bash +createdb rond_with_you +# 或:psql -U postgres -c "CREATE DATABASE rond_with_you ENCODING 'UTF8'" +``` + +表结构在程序启动时自动创建(`internal/db/schema.sql`,全部 `IF NOT EXISTS`,老库会自动增量升级)。 + +### 2. 配置 + +```bash +cp conf/app.example.ini conf/app.ini +vi conf/app.ini # 至少需要填 dsn +``` + +配置项全部在 `conf/app.example.ini` 里有注释说明;只有 `dsn` 必填。 + +### 3. 运行 + +```bash +go build -o rond . +./rond -c conf/app.ini +``` + +首次启动会创建管理员账号,用户名取环境变量 `ROND_ADMIN_USER`(默认 `admin`), +密码取 `ROND_ADMIN_PASSWORD`(默认 `rond-admin`)。**登录后请立即在后台修改密码。** + +打开 ,进入 `/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。导出走「基线库 + 覆盖」,不重建库 + +## 照片导入(补录没有轨迹的时间段) + +手机里的照片带着拍摄时间和 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`,按 `src_pk` 存):详情页「我的结论」一键 👍推荐 / 👎踩雷, + 再点已选按钮取消;后台备注页也能逐行或批量打标。 + - **访客投票**(`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 小机器) + +```bash +# 交叉编译 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 在线压缩(图片本身已压缩,直接透传)。 + 实测 `/stats` 38KB → 5.4KB、`/api/track` 39KB → 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` 可以做导入→导出→逐表对账,检查改动有没有破坏备份保真度。 + +## 后续可以做的 + +- 多用户注册与权限(数据模型已按 `user_id` 分区,加个注册页即可) +- 年度报告「长图」直接导出 PNG(目前用浏览器打印生成 PDF;如需像素级 PNG 可再接 html2canvas 一类库) diff --git a/conf/app.example.ini b/conf/app.example.ini new file mode 100644 index 0000000..d190159 --- /dev/null +++ b/conf/app.example.ini @@ -0,0 +1,92 @@ +# rond-with-you 站点配置样例 +# 复制为 conf/app.ini 后按实际情况修改:cp conf/app.example.ini conf/app.ini +# 只有 dsn 是必填项,其余留空或删掉都会走默认值。 + +# 监听地址。放在反向代理后面时通常保持 :8080,由代理转发进来 +listen = :8080 + +# PostgreSQL 连接串(必填) +# 库不存在时会自动创建(要求该账号有 CREATEDB 权限),随后自动建表 +# 库名/账号里含特殊字符时记得做 URL 编码 +dsn = postgres://postgres:your-password@127.0.0.1:5432/rond_with_you?sslmode=disable + +# 会话签名密钥(64 位十六进制),留空则每次启动随机生成,重启后需要重新登录 +# 生成方式:openssl rand -hex 32 +secret_key = + +# 上传文件与解析中间文件目录(启动时自动创建) +upload_dir = storage/uploads +data_dir = storage/data + +# 登录有效期(天) +session_days = 14 + +# ---------- 底图 ---------- +# amap 高德地图,免密钥栅格瓦片,开箱可用(默认) +# tianditu 天地图,必须同时填 map_key +# custom 自备瓦片服务,必须填 map_tile_url 与 map_crs +map_provider = amap + +# 地图密钥(天地图 tk,或高德/自建服务需要的 key) +# 填了之后瓦片改由服务端转发,密钥不会出现在页面源码里;留空则由浏览器直连上游 +# 天地图密钥申请: https://console.tianditu.gov.cn/api/key +map_key = + +# 仅 map_provider = custom 时使用。支持 {s} {x} {y} {z} {key} 占位符 +map_tile_url = + +# 仅 map_provider = custom 时使用,声明该底图的坐标系:gcj02 或 wgs84 +# 站点数据本身是 GCJ-02,选 wgs84(天地图 / OSM)时前端会自动纠偏 +map_crs = + +# 说明:「默认铺哪张底图」(街道 / 纯路网 / 卫星影像 / OSM)在**后台 → 设置 → 地图底图**里选, +# 不在这里配。纯路网不带 POI 与路名标注,点位密集、放到最大时看着更清楚; +# 访客在地图上手动切换过的话,浏览器会记住他自己的选择。 + +# ---------- 后台「新增地点」按名称搜坐标 ---------- +# 高德 Web 服务类型 Key。注意:与瓦片用的 JS Key 不是同一种,需要在 +# 高德开放平台单独申请(免费):https://lbs.amap.com/api/webservice/create-project-and-key +# 只经服务端 /api/geocode 代理调用,不会出现在页面源码里; +# 留空则该功能关闭,仍可手动填经纬度或在地图上点选。 +geocode_key = + +# ---------- 地点图片的对象存储(可选) ---------- +# 「图片存本地还是存对象存储」在后台 → 设置 → 图片存储 里切换;这里只放凭据, +# 不进数据库、也不会下发到前端。全部留空 = 只能用服务器本地磁盘 +# (图片落在 data_dir 下的 media/ 目录,由站点自己直出)。 +# +# 图片由**浏览器**转成 WebP 后再上传(长边 2000px、质量 0.82),服务端只负责收下与转存, +# 不做解码,1 核机器也扛得住。对象名形如 <数据集>/<上传月份>/<地点号>-<哈希>.webp: +# 按月份分目录便于整月备份与迁移,带地点号便于在控制台上一眼认出来,带内容哈希可天然去重。 +# +# 以 Cloudflare R2 为例(S3 兼容,其他 S3 服务同理): +# s3_endpoint https://<账号ID>.r2.cloudflarestorage.com +# s3_region auto +# s3_bucket 桶名 +# s3_access_key R2 API 令牌的 Access Key ID +# s3_secret_key R2 API 令牌的 Secret Access Key +# s3_public_base 桶的公开域名(自定义域或 r2.dev 域名),拼上对象名就是 +# +# s3_public_base 写完整地址最好;只写裸域名也行(程序会自动补 https://), +# 但不填就取不到图——R2 桶默认私有,没有公开域名浏览器拿不到对象。 +s3_endpoint = +s3_region = auto +s3_bucket = +s3_access_key = +s3_secret_key = +s3_public_base = + +# ---------- 第三方统计 / 追踪脚本(可选) ---------- +# 填 URL(http/https 开头)会自动渲染成 , +# 例如 Umami 的 https://umami.example.com/script.js +# 需要附加属性或初始化代码的服务(百度统计、Plausible 等),可以把整段 贴进来。 +# 只在公开页面生效,后台与登录页不会挂这个脚本。 +track_script = + +# 打开后会打印每个请求的日志(含超过 30ms 的慢查询) +debug = false + +# ---------- 环境变量(不在本文件里) ---------- +# 首次启动会用它们创建管理员账号,之后改用户名/密码请到后台「账号设置」: +# ROND_ADMIN_USER 管理员用户名,默认 admin +# ROND_ADMIN_PASSWORD 管理员密码,默认 rond-admin(务必登录后立即修改) diff --git a/deploy/rond-with-you.service b/deploy/rond-with-you.service new file mode 100644 index 0000000..cc7b340 --- /dev/null +++ b/deploy/rond-with-you.service @@ -0,0 +1,25 @@ +[Unit] +Description=rond-with-you 足迹站点 +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +User=rond +WorkingDirectory=/opt/rond-with-you +ExecStart=/opt/rond-with-you/rond -c /opt/rond-with-you/conf/app.ini +Restart=on-failure +RestartSec=3 +# 初始管理员口令,仅首次启动时使用 +Environment=ROND_ADMIN_USER=admin +Environment=ROND_ADMIN_PASSWORD=change-me-please + +# 限制 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/rond-with-you/storage + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7bd4bb3 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module rond-with-you + +go 1.25 + +require ( + github.com/jackc/pgx/v5 v5.7.6 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.24.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70d19ff --- /dev/null +++ b/go.sum @@ -0,0 +1,77 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8cc792d --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,208 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + iterations = 210_000 + keyLen = 32 + saltLen = 16 +) + +var ErrInvalidCredentials = errors.New("用户名或密码错误") + +// HashPassword 使用 PBKDF2-SHA256 生成口令摘要。 +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, keyLen) + if err != nil { + return "", err + } + return fmt.Sprintf("pbkdf2$sha256$%d$%s$%s", iterations, b64(salt), b64(dk)), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 5 || parts[0] != "pbkdf2" { + return false + } + iter, err := strconv.Atoi(parts[2]) + if err != nil { + return false + } + salt, err := unb64(parts[3]) + if err != nil { + return false + } + want, err := unb64(parts[4]) + if err != nil { + return false + } + got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 +} + +func b64(b []byte) string { return base64.RawStdEncoding.EncodeToString(b) } +func unb64(s string) ([]byte, error) { return base64.RawStdEncoding.DecodeString(s) } + +type User struct { + ID int64 + Username string + DisplayName string + Hash string +} + +type Store struct { + DB *sql.DB + Secret []byte + Days int +} + +// Authenticate 校验用户口令。 +func (s *Store) Authenticate(ctx context.Context, username, password string) (*User, error) { + var u User + err := s.DB.QueryRowContext(ctx, `SELECT id, username, COALESCE(display_name,''), password_hash + FROM users WHERE username=$1`, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Hash) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInvalidCredentials + } + if err != nil { + return nil, err + } + if !VerifyPassword(u.Hash, password) { + return nil, ErrInvalidCredentials + } + if _, err := s.DB.ExecContext(ctx, `UPDATE users SET last_login_at=now() WHERE id=$1`, u.ID); err != nil { + return nil, err + } + return &u, nil +} + +// EnsureDefaultUser 在没有任何用户时创建初始管理员。 +func (s *Store) EnsureDefaultUser(ctx context.Context, username, password string) (bool, error) { + var n int + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil { + return false, err + } + if n > 0 { + return false, nil + } + hash, err := HashPassword(password) + if err != nil { + return false, err + } + if _, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, display_name) + VALUES ($1,$2,$3)`, username, hash, "站长"); err != nil { + return false, err + } + return true, nil +} + +func (s *Store) ChangePassword(ctx context.Context, userID int64, current, next string) error { + var hash string + if err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=$1`, userID).Scan(&hash); err != nil { + return err + } + if !VerifyPassword(hash, current) { + return ErrInvalidCredentials + } + nh, err := HashPassword(next) + if err != nil { + return err + } + _, err = s.DB.ExecContext(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, nh, userID) + return err +} + +// ErrUsernameTaken 表示新用户名已被占用。 +var ErrUsernameTaken = errors.New("用户名已被占用") + +// ChangeUsername 校验口令后改名。 +// 会话 cookie 认的是 id 与口令摘要(都不含用户名),所以改名不会把当前登录踢掉。 +func (s *Store) ChangeUsername(ctx context.Context, userID int64, current, name string) error { + var hash string + if err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=$1`, userID).Scan(&hash); err != nil { + return err + } + if !VerifyPassword(hash, current) { + return ErrInvalidCredentials + } + var taken bool + if err := s.DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM users WHERE username=$1 AND id<>$2)`, name, userID).Scan(&taken); err != nil { + return err + } + if taken { + return ErrUsernameTaken + } + _, err := s.DB.ExecContext(ctx, `UPDATE users SET username=$1 WHERE id=$2`, name, userID) + return err +} + +// UserByID 读取用户;用于每次请求还原登录态。 +func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) { + var u User + err := s.DB.QueryRowContext(ctx, `SELECT id, username, COALESCE(display_name,''), password_hash + FROM users WHERE id=$1`, id).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Hash) + if err != nil { + return nil, err + } + return &u, nil +} + +// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。 +func (s *Store) SignToken(userID int64, hash string) string { + exp := time.Now().AddDate(0, 0, s.Days).Unix() + payload := fmt.Sprintf("%d|%d", userID, exp) + return payload + "|" + s.mac(payload, hash) +} + +func (s *Store) ParseToken(token, hash string) (int64, bool) { + parts := strings.Split(token, "|") + if len(parts) != 3 { + return 0, false + } + uid, err1 := strconv.ParseInt(parts[0], 10, 64) + exp, err2 := strconv.ParseInt(parts[1], 10, 64) + if err1 != nil || err2 != nil || time.Now().Unix() > exp { + return 0, false + } + if !hmac.Equal([]byte(parts[2]), []byte(s.mac(parts[0]+"|"+parts[1], hash))) { + return 0, false + } + return uid, true +} + +func (s *Store) mac(payload, hash string) string { + m := hmac.New(sha256.New, s.Secret) + m.Write([]byte(payload)) + // 混入口令摘要,改密后旧 cookie 立即作废 + m.Write([]byte("|" + hash[:min(len(hash), 16)])) + return b64(m.Sum(nil)) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..9525c83 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,76 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// TestChangeUsername 覆盖改名的正向路径、口令校验与占用冲突。 +// 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来, +// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。 +func TestChangeUsername(t *testing.T) { + dsn := os.Getenv("ROND_DSN") + if dsn == "" { + t.Skip("设置 ROND_DSN 后运行改名检查") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("连接数据库: %v", err) + } + defer db.Close() + ctx := context.Background() + + name := fmt.Sprintf("tmp_rename_%d", time.Now().UnixNano()) + hash, err := HashPassword("tmp-pass-123456") + if err != nil { + t.Fatal(err) + } + var id int64 + if err := db.QueryRowContext(ctx, + `INSERT INTO users (username, password_hash) VALUES ($1,$2) RETURNING id`, name, hash).Scan(&id); err != nil { + t.Fatalf("建临时用户: %v", err) + } + defer db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, id) + + st := &Store{DB: db, Secret: []byte("test-secret"), Days: 7} + // 改名不该影响已登录会话:cookie 认的是 id 与口令摘要,都不含用户名 + tok := st.SignToken(id, hash) + + next := name + "_new" + if err := st.ChangeUsername(ctx, id, "wrong-password", next); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("口令错误应返回 ErrInvalidCredentials,实际 %v", err) + } + + // 换成一个已存在的用户名(库里除自己之外随便取一个) + var other string + if err := db.QueryRowContext(ctx, `SELECT username FROM users WHERE id<>$1 LIMIT 1`, id).Scan(&other); err == nil { + if err := st.ChangeUsername(ctx, id, "tmp-pass-123456", other); !errors.Is(err, ErrUsernameTaken) { + t.Errorf("占用他人用户名应返回 ErrUsernameTaken,实际 %v", err) + } + } + + if err := st.ChangeUsername(ctx, id, "tmp-pass-123456", next); err != nil { + t.Fatalf("正常改名失败: %v", err) + } + u, err := st.UserByID(ctx, id) + if err != nil { + t.Fatalf("回读用户: %v", err) + } + if u.Username != next { + t.Errorf("用户名应为 %q,实际 %q", next, u.Username) + } + if got, ok := st.ParseToken(tok, hash); !ok || got != id { + t.Errorf("改名后旧会话应仍有效,实际 ok=%v id=%d", ok, got) + } + // 密码没被动过:改完还能用原口令登录 + if _, err := st.Authenticate(ctx, next, "tmp-pass-123456"); err != nil { + t.Errorf("改名后原口令应仍可登录: %v", err) + } +} diff --git a/internal/auth/totp.go b/internal/auth/totp.go new file mode 100644 index 0000000..ddbd613 --- /dev/null +++ b/internal/auth/totp.go @@ -0,0 +1,159 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "errors" + "fmt" + "strings" + "time" +) + +// TOTP 实现(RFC 6238,30 秒步长、6 位数字、HMAC-SHA1), +// 与 Google Authenticator / 1Password / Microsoft Authenticator 兼容。 + +const ( + totpStep = 30 * time.Second + totpStepS = 30 // 步长(秒),计数器 = unix秒 / 30 + totpWindow = 1 // 允许前后偏差的步数 +) + +// counterOf 把时刻换算成 TOTP 计数器。 +func counterOf(unixSec int64) uint64 { + if unixSec < 0 { + return 0 + } + return uint64(unixSec / totpStepS) +} + +var ErrTOTPNotEnabled = errors.New("未开启两步验证") + +// GenerateTOTPSecret 生成 20 字节随机秘钥的 base32 编码(无填充)。 +func GenerateTOTPSecret() (string, error) { + raw := make([]byte, 20) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw), nil +} + +func totpCode(secret string, counter uint64) (string, error) { + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString( + strings.ToUpper(strings.ReplaceAll(secret, " ", ""))) + if err != nil { + return "", fmt.Errorf("秘钥格式无效: %w", err) + } + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], counter) + m := hmac.New(sha1.New, key) + m.Write(msg[:]) + sum := m.Sum(nil) + off := sum[len(sum)-1] & 0x0f + code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000 + return fmt.Sprintf("%06d", code), nil +} + +// TOTPCode 返回指定时刻的验证码(用于展示与测试)。 +func TOTPCode(secret string, t time.Time) (string, error) { + return totpCode(secret, counterOf(t.Unix())) +} + +// VerifyTOTP 校验验证码,允许前后各一个步长的时钟偏差。 +func VerifyTOTP(secret, code string) bool { + now := counterOf(time.Now().Unix()) + for _, c := range []uint64{now - totpWindow, now, now + totpWindow} { + want, err := totpCode(secret, c) + if err != nil { + return false + } + if subtle.ConstantTimeCompare([]byte(want), []byte(strings.TrimSpace(code))) == 1 { + return true + } + } + return false +} + +// OTPAuthURL 生成验证器 App 扫码用的 URI。 +func OTPAuthURL(username, secret string) string { + label := urlEscape("rond-with-you:" + username) + return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + urlEscape("rond-with-you") + + "&algorithm=SHA1&digits=6&period=30" +} + +func urlEscape(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '~', r == ':': + b.WriteRune(r) + default: + fmt.Fprintf(&b, "%%%02X", r) + } + } + return b.String() +} + +// ---------- 用户秘钥存取 ---------- + +// TOTPState 返回用户的两步验证状态。 +func (s *Store) TOTPState(ctx context.Context, userID int64) (secret string, enabled bool, err error) { + err = s.DB.QueryRowContext(ctx, `SELECT COALESCE(totp_secret,''), totp_enabled + FROM users WHERE id=$1`, userID).Scan(&secret, &enabled) + return +} + +// SetTOTPSecret 保存(或覆盖)秘钥;enabled 保持不变,供「先展示二维码再确认」流程。 +func (s *Store) SetTOTPSecret(ctx context.Context, userID int64, secret string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_secret=$1 WHERE id=$2`, secret, userID) + return err +} + +// EnableTOTP 确认启用两步验证。 +func (s *Store) EnableTOTP(ctx context.Context, userID int64) error { + _, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_enabled=TRUE WHERE id=$1 AND totp_secret<>''`, userID) + return err +} + +// DisableTOTP 关闭两步验证并清除秘钥。 +func (s *Store) DisableTOTP(ctx context.Context, userID int64) error { + _, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_enabled=FALSE, totp_secret='' WHERE id=$1`, userID) + return err +} + +// ---------- 两步登录的中间凭据 ---------- + +// SignPending2FA 在口令校验通过但未过两步验证时签发短时中间令牌, +// 绑定口令摘要(改密即失效),有效期 5 分钟。 +func (s *Store) SignPending2FA(userID int64, hash string) string { + exp := time.Now().Add(5 * time.Minute).Unix() + payload := fmt.Sprintf("2fa|%d|%d", userID, exp) + return payload + "|" + s.mac("p"+payload, hash) +} + +// ParsePending2FA 还原中间令牌。 +func (s *Store) ParsePending2FA(token, hash string) (int64, bool) { + parts := strings.Split(token, "|") + if len(parts) != 4 || parts[0] != "2fa" { + return 0, false + } + var uid, exp int64 + if _, err := fmt.Sscanf(parts[1], "%d", &uid); err != nil { + return 0, false + } + if _, err := fmt.Sscanf(parts[2], "%d", &exp); err != nil { + return 0, false + } + if time.Now().Unix() > exp { + return 0, false + } + payload := "2fa|" + parts[1] + "|" + parts[2] + if !hmac.Equal([]byte(parts[3]), []byte(s.mac("p"+payload, hash))) { + return 0, false + } + return uid, true +} diff --git a/internal/captcha/captcha.go b/internal/captcha/captcha.go new file mode 100644 index 0000000..20d0608 --- /dev/null +++ b/internal/captcha/captcha.go @@ -0,0 +1,265 @@ +// Package captcha 生成登录页用的图形验证码。 +// +// 刻意不引第三方库、也不依赖字体文件:字形是内置的 5×7 点阵,放大后逐个加抖动, +// 再叠干扰线与噪点。服务端只存答案(不存图片),答案一次性使用并带过期时间。 +package captcha + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "image" + "image/color" + "image/draw" + "image/png" + "math/big" + mrand "math/rand" + "strings" + "sync" + "time" +) + +const ( + // alphabet 是取码字符集:去掉 0/O、1/I/L 这些互相像的,省得用户反复认错。 + alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + // CodeLen 是验证码长度。 + CodeLen = 4 + + glyphW, glyphH = 5, 7 + scale = 4 + padX, padY = 10, 6 + // stepX 要给抖动留余量:字符宽 20,抖动 ±2,步长 26 时最小间距只剩 2px, + // 相邻字符的笔画会串在一起(人眼也一样难看)。30 保证最小间距仍有 6px。 + stepX = 30 + // Width/Height 是出图尺寸。 + Width = padX*2 + (CodeLen-1)*stepX + glyphW*scale // 130 + Height = padY*2 + glyphH*scale // 40 + + defaultTTL = 10 * time.Minute + maxItems = 4096 +) + +// Store 保存待验证的答案。站点是单进程部署,放内存即可; +// 重启会让已发出的验证码失效,用户重新获取一次就行。 +type Store struct { + mu sync.Mutex + items map[string]item + ttl time.Duration +} + +type item struct { + code string + exp time.Time +} + +// NewStore 建一个验证码池;ttl <= 0 时用 10 分钟。 +func NewStore(ttl time.Duration) *Store { + if ttl <= 0 { + ttl = defaultTTL + } + return &Store{items: map[string]item{}, ttl: ttl} +} + +// Issue 生成一张新验证码,返回用于对答的 id 与 PNG 字节。 +func (s *Store) Issue() (string, []byte, error) { + code, err := randomCode() + if err != nil { + return "", nil, err + } + id, err := randomID() + if err != nil { + return "", nil, err + } + s.mu.Lock() + s.pruneLocked() + s.items[id] = item{code: code, exp: time.Now().Add(s.ttl)} + s.mu.Unlock() + + png, err := render(code) + if err != nil { + return "", nil, err + } + return id, png, nil +} + +// Verify 校验答案。无论对错都作废该 id——一次一用,避免拿同一张图反复试。 +func (s *Store) Verify(id, answer string) bool { + s.mu.Lock() + it, ok := s.items[id] + delete(s.items, id) + s.mu.Unlock() + if !ok || time.Now().After(it.exp) { + return false + } + got := strings.ToUpper(strings.TrimSpace(answer)) + return got == it.code +} + +// Answer 返回某个 id 当前对应的答案,不作废它。 +// 只给测试与本地排查用:答案本身不会经任何接口下发到客户端。 +func (s *Store) Answer(id string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + it, ok := s.items[id] + return it.code, ok +} + +// pruneLocked 清掉过期项;仍然超过上限时随便丢一些,防止有人只取图不对答把内存撑大。 +// 调用方需持有 s.mu。 +func (s *Store) pruneLocked() { + now := time.Now() + for k, v := range s.items { + if now.After(v.exp) { + delete(s.items, k) + } + } + for k := range s.items { + if len(s.items) < maxItems { + break + } + delete(s.items, k) + } +} + +func randomCode() (string, error) { + var b strings.Builder + for i := 0; i < CodeLen; i++ { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return "", err + } + b.WriteByte(alphabet[n.Int64()]) + } + return b.String(), nil +} + +func randomID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// render 把验证码画成 PNG:底 → 干扰线 → 字符(各自抖动)→ 噪点。 +func render(code string) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, Width, Height)) + draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{248, 250, 251, 255}}, image.Point{}, draw.Src) + + for i := 0; i < 3; i++ { + drawLine(img, mrand.Intn(Width), mrand.Intn(Height), mrand.Intn(Width), mrand.Intn(Height), rgb(160, 215)) + } + for i, ch := range code { + g, ok := glyphs[ch] + if !ok { + continue + } + // 每个字符独立抖动,位置与颜色都不同——不抖的话点阵字太规整,等于白送 + ox := padX + i*stepX + mrand.Intn(5) - 2 + oy := padY + mrand.Intn(5) - 2 + col := rgb(0, 110) + for row := 0; row < glyphH; row++ { + for c := 0; c < glyphW; c++ { + if g[row]&(1<= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// glyphs 是 5×7 点阵字形:每行低 5 位有效,最高位在最左边。 +var glyphs = map[rune][glyphH]byte{ + '0': {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110}, + '1': {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110}, + '2': {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111}, + '3': {0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110}, + '4': {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010}, + '5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110}, + '6': {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110}, + '7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000}, + '8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110}, + '9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100}, + 'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001}, + 'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110}, + 'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110}, + 'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110}, + 'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111}, + 'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000}, + 'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111}, + 'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001}, + 'I': {0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110}, + 'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100}, + 'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001}, + 'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111}, + 'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001}, + 'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001}, + 'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110}, + 'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000}, + 'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101}, + 'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001}, + 'S': {0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110}, + 'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100}, + 'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110}, + 'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100}, + 'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001}, + 'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001}, + 'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100}, + 'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111}, +} diff --git a/internal/captcha/captcha_test.go b/internal/captcha/captcha_test.go new file mode 100644 index 0000000..3817efd --- /dev/null +++ b/internal/captcha/captcha_test.go @@ -0,0 +1,179 @@ +package captcha + +import ( + "bytes" + "image" + "image/png" + "strings" + "testing" + "time" +) + +func TestIssueAndVerify(t *testing.T) { + s := NewStore(time.Minute) + id, pngBytes, err := s.Issue() + if err != nil { + t.Fatalf("出图失败: %v", err) + } + if id == "" || len(pngBytes) == 0 { + t.Fatal("id 或图片为空") + } + code, ok := s.Answer(id) + if !ok || len([]rune(code)) != CodeLen { + t.Fatalf("答案不对: %q ok=%v", code, ok) + } + for _, r := range code { + if !strings.ContainsRune(alphabet, r) { + t.Errorf("答案含取码字符集之外的字符 %q", r) + } + } + + // 大小写与首尾空格都该容忍(用户手打容易带上) + if !s.Verify(id, " "+strings.ToLower(code)+" ") { + t.Error("正确验证码应通过(应容忍大小写与空格)") + } + // 一次性:同一张图不能再用 + if s.Verify(id, code) { + t.Error("同一张验证码不应能被验证两次") + } + + // 错误答案 + id2, _, err := s.Issue() + if err != nil { + t.Fatal(err) + } + if s.Verify(id2, "XXXX") { + t.Error("错误答案不应通过") + } + if s.Verify(id2, "XXXX") { + t.Error("失败后该验证码也应作废") + } + + // 未发出的 id + if s.Verify("nope", "ABCD") { + t.Error("未知 id 不应通过") + } +} + +func TestExpiry(t *testing.T) { + s := NewStore(time.Millisecond) + id, _, err := s.Issue() + if err != nil { + t.Fatal(err) + } + code, _ := s.Answer(id) + time.Sleep(5 * time.Millisecond) + if s.Verify(id, code) { + t.Error("过期后不应通过") + } +} + +func TestPrune(t *testing.T) { + s := NewStore(time.Millisecond) + for i := 0; i < 5; i++ { + if _, _, err := s.Issue(); err != nil { + t.Fatal(err) + } + } + time.Sleep(5 * time.Millisecond) + // 下一次 Issue 会顺手清掉过期的,池子不该越攒越大 + if _, _, err := s.Issue(); err != nil { + t.Fatal(err) + } + if n := len(s.items); n > 1 { + t.Errorf("过期项应被清掉,实际还剩 %d 条", n) + } +} + +// TestImageShowsAnswer 确认图里画的确实是答案:按已知点阵模板逐位回读。 +// 若哪天改了绘制偏移、抖动范围或字形表却忘了同步,用户会连自己也登不进后台, +// 这条测试会立刻失败——它同时就是「图还认得出」的最低保证。 +func TestImageShowsAnswer(t *testing.T) { + s := NewStore(time.Minute) + for n := 0; n < 8; n++ { + id, pngBytes, err := s.Issue() + if err != nil { + t.Fatal(err) + } + want, _ := s.Answer(id) + img, err := png.Decode(bytes.NewReader(pngBytes)) + if err != nil { + t.Fatalf("图片无法解码: %v", err) + } + if b := img.Bounds(); b.Dx() != Width || b.Dy() != Height { + t.Fatalf("图片尺寸应为 %dx%d,实际 %dx%d", Width, Height, b.Dx(), b.Dy()) + } + if got := readCode(t, img); got != want { + t.Fatalf("图里读出 %q,答案却是 %q", got, want) + } + } +} + +// darkGrid 把图片预先取成「哪些像素属于字符笔画」的网格: +// 字形用 0~110 的深色,干扰线与噪点都在 120 以上。逐像素调 img.At 太慢,先摊平。 +func darkGrid(img image.Image) [][]bool { + grid := make([][]bool, Height) + for y := 0; y < Height; y++ { + row := make([]bool, Width) + for x := 0; x < Width; x++ { + r, g, b, _ := img.At(x, y).RGBA() + row[x] = r>>8 < 120 && g>>8 < 120 && b>>8 < 120 + } + grid[y] = row + } + return grid +} + +// readCode 逐位回读验证码:每个字符的位置只在基准点 ±2 内抖动, +// 于是在这个小邻域里让每个字形去套,取重合度最高的那个。 +func readCode(t *testing.T, img image.Image) string { + t.Helper() + grid := darkGrid(img) + var out strings.Builder + // 基准位置与 render 保持一致 + for i := 0; i < CodeLen; i++ { + baseX := padX + i*stepX + best, bestGlyph := -1, '?' + for ch, g := range glyphs { + if !strings.ContainsRune(alphabet, ch) { + continue + } + for dx := -3; dx <= 3; dx++ { + for dy := -3; dy <= 3; dy++ { + score := 0 + for row := 0; row < glyphH; row++ { + for c := 0; c < glyphW; c++ { + on := g[row]&(1<= Width || y >= Height { + continue + } + // 命中加分、误命中扣分:只看命中数的话,笔画多的字形 + // (比如 B 比 C 多一竖)光靠瞎撞也能赢 + if grid[y][x] { + score++ + } else { + score-- + } + } + } + if score > best { + best, bestGlyph = score, ch + } + } + } + } + // 一个小字形最多 (5*7*scale*scale) 个像素,笔画中心数远小于它; + // 命中太少说明这个位置没有字符,读数就不可信了 + if best < 8 { + t.Fatalf("第 %d 个字符位置没读到笔画(最高分 %d)", i, best) + } + out.WriteRune(bestGlyph) + } + return out.String() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9afeed2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,157 @@ +package config + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type Config struct { + Listen string + DSN string + SecretKey string + UploadDir string + DataDir string + SessionDays int + Debug bool + + // 底图。amap 走免密钥瓦片;填了 map_key 或选 tianditu 时改由服务端代理, + // 密钥不出现在页面源码里。 + MapProvider string // amap | tianditu | custom + MapKey string + MapTileURL string // custom 模式下的瓦片模板,支持 {s} {x} {y} {z} {key} + MapCRS string // custom 模式下底图的坐标系:gcj02 | wgs84 + + // 地理编码(后台「按名称搜坐标」)。高德 Web 服务类型密钥,与瓦片用的 + // JS 密钥不是同一个;只经服务端代理调用,不进入前端源码。留空则功能关闭。 + GeocodeKey string + + // TrackScript 是第三方统计 / 追踪脚本。填 URL 会包成 + +{{if .OpenSrcPK}} + +{{end}} + + +{{end}} + +{{/* 交通方式的颜色下拉:rond 认的是系统色名,不是一个颜色选择器。 + 每行行内编辑都要一份,抽出来免得 40 行 × 13 个选项堆在模板里。 */}} +{{define "coloropts"}} + +{{$cur := .cur}}{{range $c := .colors}}{{end}} +{{end}} diff --git a/internal/web/templates/admin/fog-mask.html b/internal/web/templates/admin/fog-mask.html new file mode 100644 index 0000000..0bf2485 --- /dev/null +++ b/internal/web/templates/admin/fog-mask.html @@ -0,0 +1,173 @@ +{{define "content"}} +
+
+
+

迷雾遮罩区

+
这些范围内的迷雾对访客隐藏,你自己仍然看得到。在地图上点一下就是圆心,半径可调。
+
+ +
+ +
+ {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + +
+ +
+ + + + +
+ + + + +
+
在下方地图上点选圆心
+
+
+ +
+

已有遮罩区({{len .Masks}})

+ {{if .Masks}} + + + + {{range .Masks}} + + + + + + + {{end}} + +
名称圆心(纬度, 经度)半径
{{if .Name}}{{.Name}}{{else}}(未命名){{end}}{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}{{printf "%.0f" .RadiusM}} 米 + +
+ + +
+
+ {{else}} +
还没有遮罩区。在地图上点选圆心、设置半径后保存即可。
+ {{end}} +
遮罩只影响访客:站长登录状态下看到的始终是完整迷雾。
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html new file mode 100644 index 0000000..1fe9821 --- /dev/null +++ b/internal/web/templates/admin/index.html @@ -0,0 +1,169 @@ +{{define "content"}} +
+
+
+

后台管理

+
上传 rond 备份、切换展示的数据、调整站点设置
+
+ +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + {{if .DefaultPW}}
⚠️ 您仍在使用初始口令 rond-admin,站点对公网开放时任何人都可以用这组口令登录后台。请尽快修改密码,并在两步验证中绑定动态口令。
{{end}} + +
+

上传备份

+
+ 在 rond App 中导出备份,得到 .rondbackup 文件后上传。 + 单次最大 {{size .MaxSize}}。 + 上传是以备份为准重建数据:站点里删掉的地点、改过的记录,只要下次传一份 + 内容不同的备份,就会被备份里的原样覆盖回来(重复传同一份文件不会,会提示「完全一致」)。 + 在站点上新加的地点属于当时那个数据集,传新备份后要看它们,可在下面的数据集列表里切回。 +
+
+ + +
+
+ +
+

世界迷雾

+
+ 导入 Fog of World 导出的 .fwss 快照,在足迹地图上叠加已探索区域。 + 迷雾网格为 WGS-84,站点会自动对高德等 GCJ-02 底图做纠偏。 + 迷雾是站点级数据,不属于任何数据集,删除数据集不影响迷雾。 + {{if gt .FogBlocks 0}} + 当前已入库 {{num .FogBlocks}} 块 / {{num .FogCells}} 格, + 估算已探索 {{printf "%.1f" .FogArea}} km²{{if .FogUpdatedAt}},最近更新 {{dtt .FogUpdatedAt}}{{end}}。 + {{else}} + 尚未导入迷雾——把 Fog of World 的 .fwss(在 App 内导出,通常在 zip 里)上传即可点亮地图。 + {{end}} +
+
+ + + +
+ {{if gt .FogBlocks 0}} + + {{end}} +
+ +
+

数据导出

+
+ 把当前展示的数据集导出为通用格式,方便备份或迁移到其他平台。 + 坐标保持入库时的 GCJ-02,GeoJSON 里已注明坐标系。 +
+ +
+ 也可导出为 rond 原始格式的 .rondbackup(含你在数据编辑里补录的记录),可直接导回 rond App。 + 坐标为 GCJ-02,原样写回、无需纠偏。 +
+ 导出 rondbackup(可回导 rond) +
+ +
+
+

已导入的数据集

+
+ +
+
+
同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它
+ {{if .Datasets}} +
+ + + + + + {{range .Datasets}} + + + + + + + + + + {{end}} + +
文件导入时间到访地点数据跨度状态
{{trunc .Name 34}}{{dt .UploadedAt}}{{num .Visits}}{{num .Places}}{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}} + {{if and $.Dataset (eq $.Dataset.ID .ID)}} + 展示中 + {{else}} + 未启用 + {{end}} + + {{if not (and $.Dataset (eq $.Dataset.ID .ID))}} +
+ +
+ {{end}} +
+ +
+
+
+ {{else}} +
📦
还没有导入任何数据集
+ {{end}} +
+ +
+

数据说明

+
从备份中读取并转换的字段
+
+ + + + + + + + + + +
来源表内容用途
ZVISIT到访记录(到达 / 离开时间、类型、地点、备注、标签)时间线、统计、地图
ZLOCATION地点(名称、经纬度、城市、省份、POI 分类)地图打点、地点库
ZACTIVITY自定义类型(颜色、图标、是否家 / 工作)类型分布
ZMOVEMENT位移段(交通方式、起止时间)出行方式统计
ZTAG自定义标签及分组标签筛选
ZHOURLYWEATHER逐小时天气(气温、天气现象、湿度)天气足迹
+
+
+
+{{end}} diff --git a/internal/web/templates/admin/login.html b/internal/web/templates/admin/login.html new file mode 100644 index 0000000..67395af --- /dev/null +++ b/internal/web/templates/admin/login.html @@ -0,0 +1,53 @@ +{{define "login"}} + + + +{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}} + +登录后台 · {{.Settings.SiteTitle}} + + + + + +
+
+
+
{{.Settings.OwnerAvatar}}
+

{{.Settings.SiteTitle}}

+
后台管理
+
+ +
+ {{if .Err}}
{{.Err}}
{{end}} + {{if .Flash}}
{{.Flash}}
{{end}} +
+ + + + + +
+ +
+ + +
+
+ +{{end}} diff --git a/internal/web/templates/admin/notes.html b/internal/web/templates/admin/notes.html new file mode 100644 index 0000000..de0aaaa --- /dev/null +++ b/internal/web/templates/admin/notes.html @@ -0,0 +1,189 @@ +{{define "content"}} +
+
+
+

地点备注

+
列出当前数据集的全部地点,可给任意地点起别名、写备注、标「推荐 / 踩雷」;别名会替代原名显示
+
+ +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + +
+
+ + 备注对外{{if .NotesPublic}}公开(访客也能看到){{else}}不公开(只有你能看到){{end}} + · 共 {{num .Total}} 个地点 + +
+ +
+
+
+ + + + + + 重置 +
+
+ + {{if .Rows}} +
+
+ + 批量: + + + + +
+ +
+
+ {{range .Rows}} +
+
+ +
+
+
+ + + +
+ + + +
+
+
+ 地点 ID {{.SrcPK}}{{if .VisitCount}} · 到访 {{num .VisitCount}} 次{{end}} + {{if eq .Verdict 1}} · 已标推荐{{else if eq .Verdict -1}} · 已标踩雷{{end}} + {{if or .Up .Down}} · 访客 👍{{.Up}} 👎{{.Down}}{{end}} + {{if .Content}} · 更新于 {{dt .UpdatedAt}}{{end}} +
+ {{if .RondNote}}
来自 rond:{{.RondNote}}
{{end}} +
+
+ {{end}} +
+
+ + {{if gt .PageCount 1}} +
+ {{if gt .PageNo 1}}上一页{{end}} + 第 {{.PageNo}} / {{.PageCount}} 页 + {{if lt .PageNo .PageCount}}下一页{{end}} +
+ {{end}} + + + {{else}} +
📝
{{if .Query}}没有匹配的地点{{else}}当前数据集里还没有地点{{end}}
+ {{end}} + + {{if .Orphans}} +
+

已不在当前数据集的备注

+
这些备注挂在当前备份里已不存在的地点上(换了快照的旧地点),可逐条清理。
+
+
+ {{range .Orphans}} +
+
+
{{if .Alias}}{{.Alias}}{{else}}(无别名){{end}}{{if .Content}} · {{.Content}}{{end}}{{if .RondNote}} · 来自 rond:{{.RondNote}}{{end}}
+
地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}
+
+
+ + + + + +
+
+ {{end}} +
+
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/admin/password.html b/internal/web/templates/admin/password.html new file mode 100644 index 0000000..d205a81 --- /dev/null +++ b/internal/web/templates/admin/password.html @@ -0,0 +1,35 @@ +{{define "content"}} +
+
+
+

账号设置

+
修改密码后当前会话会失效,需要用新密码重新登录
+
+
+ +
+

用户名

+ {{if eq .Flash "name"}}
用户名已更新
{{end}} + {{if .Err}}
{{.Err}}
{{end}} +
+ + +
+
+
+ +
+

密码

+ {{if and .Flash (ne .Flash "name")}}
密码已更新
{{end}} +
+ + + +
+ + 返回后台 +
+
+
+
+{{end}} diff --git a/internal/web/templates/admin/photos.html b/internal/web/templates/admin/photos.html new file mode 100644 index 0000000..b8b5b46 --- /dev/null +++ b/internal/web/templates/admin/photos.html @@ -0,0 +1,746 @@ +{{define "content"}} +{{/* 解析器放最前:它必须在用户选完文件之前就绪,放页面底部会晚一步 */}} + + +{{/* 选项数据放隐藏 select 里给脚本读:内联 JSON 会被模板的 JS 上下文转义搞坏 */}} + + + + +
+
+
+

照片导入

+
从照片的 EXIF 读拍摄时间与坐标,反查地址后逐条确认,再写入数据库
+
+ +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + +
+

1. 选照片与坐标系

+
+ 支持 JPEG 与 HEIC/HEIF。EXIF 在浏览器里读取,照片不会上传到服务器—— + 一次几十张、几百 MB 都没有上传耗时或存储压力。 +
+
+ 照片里的坐标是 + + +
+ {{if not .GeocodeReady}} +
未配置高德 geocode_key,无法反查地址,只能靠坐标判断。
+ {{end}} +
+ + + 可多选,JPEG / HEIC 都行 +
+
+
+ +
+ + + +
+
+ +{{/* 单个点位的地图:总图留着看分布,这一个用来看「这个点到底在哪」 */}} + + + +{{end}} diff --git a/internal/web/templates/admin/settings.html b/internal/web/templates/admin/settings.html new file mode 100644 index 0000000..b584ee6 --- /dev/null +++ b/internal/web/templates/admin/settings.html @@ -0,0 +1,151 @@ +{{define "content"}} +
+
+
+

站点设置

+
控制主页展示的模块、隐私处理与地图底图
+
+ +
+ + {{if .Flash}}
设置已保存
{{end}} + +
+ +
+

基本信息

+
展示在主页顶部与浏览器标题
+
+ + + + +
+ +
+ +
+

主页展示模块

+
取消勾选的模块不会出现在主页上;一个都不选时按全部展示处理
+
+ {{range .Modules}} + + {{end}} +
+
+ +
+

公开页面

+
控制访客能在导航里看到并访问哪些页面;你登录后始终可以访问全部页面
+
+ {{range .Pages}} + + {{end}} +
+
+ 关闭的页面在访客导航中隐藏,直接输入网址也会得到 404。主页不受此开关影响。 +
+ +
+ 访客访问站点根路径时直接落到这里;你登录后访问根路径仍然看主页。 + 选择了一个没对访客开放的页面时不会跳转(会留在主页)。 +
+
+ +
+

访客可见区域

+
+ 一行一个,省 / 市 / 区都认(例如「广东省」「贵阳市」「南明区」)。 + 留空表示不限制,访客能看到全部数据;你自己登录后始终是全量。 +
+ + + +
+ +
+

隐私处理

+
「家」和「工作」通常是敏感位置,默认已隐藏
+ + + + +
+ 隐藏后匿名访客看不到这两类记录。你登录后可在任意公开页面用筛选器上的 + 含家/工作 开关临时显示给自己看。 +
+ +
该设置作用于全站统计与地图,可有效过滤路过点。
+
+ +
+

地图底图

+
在 conf/app.ini 中配置,改完重启生效
+
当前生效{{.MapInfo}}
+ {{if .MapLayers}} + +
放大后底图自带的 POI 与路名标注太密、看不清自己的点时,换成纯路网。 + 访客在地图上手动切换过的话,浏览器会记住他自己的选择。
+ {{end}} +
+
map_provider:amap(默认,免密钥)/ tianditu(需 map_key)/ custom(自备 map_tile_url)
+
map_key:填写后瓦片改由服务端代理,密钥不会出现在页面源码里。
+
map_crs:仅 custom 使用,填 gcj02 或 wgs84;站点数据本身是 GCJ-02,换成 WGS-84 底图(天地图 / OSM)时会自动纠偏。
+
+
+ +
+

图片存储

+
地点图片存到哪儿。图片在浏览器里转成 WebP 后才上传,服务器只负责收下与转存
+ + +
对象存储{{.S3Info}}
+
+
凭据放在 conf/app.ini,不进数据库、也不会下发到前端:
+
s3_endpoint(R2 形如 https://<账号>.r2.cloudflarestorage.com)、s3_bucket、s3_access_key、s3_secret_key、s3_region(R2 填 auto)。
+
s3_public_base:桶的公开域名(自定义域或 r2.dev),拼上对象名就是 <img src>;不填的话图片存得进去但浏览器取不到。
+
改了 conf/app.ini 需要重启;这里的「存哪边」保存后立即生效,已经存过的老图仍按各自原来的位置取,不受影响。
+
+
+ +
+ + 取消 +
+
+
+{{end}} diff --git a/internal/web/templates/admin/topics.html b/internal/web/templates/admin/topics.html new file mode 100644 index 0000000..7a0af67 --- /dev/null +++ b/internal/web/templates/admin/topics.html @@ -0,0 +1,105 @@ +{{define "content"}} +
+
+
+

专题管理

+
把某段时间、某个区域里的足迹打包成一个可对外展示的页面(地点 + 世界迷雾)
+
+ +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + +
+

{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}

+
时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。 + 区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选。
+
+ {{if not .IsNew}}{{end}} +
+ + +
+ +
+ + + +
+
区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域
+
+ + + +
+
+ + +
+ + {{if not .IsNew}}取消编辑{{end}} +
+
+ +
+

专题列表

+
共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览
+ {{if .Topics}} +
+ + + + {{range .Topics}} + + + + + + + + + + {{end}} + +
标题时间范围地点到访区域对访客
{{.Title}}{{if .Subtitle}} · {{.Subtitle}}{{end}}{{.RangeLabel}}{{num .PlaceCount}}{{num .VisitCount}}{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}} +
+ + +
+
+ 编辑 +
+ + +
+
+
+ {{else}} +
还没有专题,用上面的表单建一个吧
+ {{end}} +
+
+ + +{{end}} diff --git a/internal/web/templates/admin/twofa-login.html b/internal/web/templates/admin/twofa-login.html new file mode 100644 index 0000000..7516fe7 --- /dev/null +++ b/internal/web/templates/admin/twofa-login.html @@ -0,0 +1,39 @@ +{{define "login2"}} + + + +{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}} + +两步验证 · {{.Settings.SiteTitle}} + + + + + +
+
+
+
🔐
+

两步验证

+
输入验证器 App 中的 6 位验证码
+
+ +
+ {{if .Err}}
{{.Err}}
{{end}} +
+ + + + +
+
+ + +
+
+ +{{end}} diff --git a/internal/web/templates/admin/twofa.html b/internal/web/templates/admin/twofa.html new file mode 100644 index 0000000..44f7e06 --- /dev/null +++ b/internal/web/templates/admin/twofa.html @@ -0,0 +1,62 @@ +{{define "content"}} +
+
+
+

两步验证

+
为后台登录追加一层动态验证码(TOTP,兼容 Google Authenticator 等 App)
+
+ +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + + {{if .Enabled}} +
+

状态:已开启

+
每次登录后台,除口令外还需输入验证器 App 中的 6 位验证码
+
+ + +
+
+ {{else if .Pending}} +
+

绑定验证器

+
用验证器 App 扫描二维码,或手动输入秘钥,然后输入 App 显示的 6 位验证码确认
+
+ {{if .QR}}TOTP 二维码{{end}} +
+ +
账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒
+
+
+
+ + +
+
+ {{else}} +
+

状态:未开启

+
开启后,登录 = 口令 + 动态验证码,即使口令泄露账户也不会被直接攻破
+
+ +
+
+ 需要一个 TOTP 验证器 App(Google Authenticator、Microsoft Authenticator、1Password、Aegis 等均可)。 +
+
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html new file mode 100644 index 0000000..c70f83f --- /dev/null +++ b/internal/web/templates/home.html @@ -0,0 +1,182 @@ +{{define "content"}} +{{if not .HasData}} +
{{template "emptystate" .}}
+{{else}} +
+ +
+
+

{{.Settings.OwnerAvatar}} {{.Settings.OwnerName}}

+
{{if .Settings.SiteSubtitle}}{{.Settings.SiteSubtitle}} · {{end}}{{.RangeLabel}}
+
+ +
+ + {{if .Settings.Has "overview"}} +
+
{{num .Overview.Places}}个
去过的地点
+
{{num .Overview.Cities}}座
到过的城市
+
{{num .Overview.Provinces}}个
覆盖省份
+
{{num .Overview.Days}}天
有记录的日子
+
{{num .Overview.Visits}}次
累计到访
+
{{dur .Overview.DwellMin}}
累计停留
+
{{km .Overview.DistanceKm}}km
出行里程
+
{{if .Overview.FirstAt}}{{since .Overview.FirstAt}}{{else}}—{{end}}
开始记录于
+
+ {{end}} + + {{if and (.Settings.Has "map") .Points}} +
+

足迹地图

+
共 {{num (len .Points)}} 个地点{{if .Settings.HideHome}} · 已隐藏「家」{{end}}{{if .Settings.HideWork}} · 已隐藏「工作」{{end}}
+
+
滚轮被禁用,点击地图进入全屏模式查看细节与筛选。
+
+ {{end}} + +
+ {{if and (.Settings.Has "types") .ActivityStats}} +
+

去过的类型

+
按到访次数排序
+
+ {{range .ActivityStats}} +
+
{{with icon .Icon}}{{.}} {{end}}{{.Name}}
+
+
{{num .Count}} 次
+
+ {{end}} +
+
+ {{end}} + + {{if and (.Settings.Has "cities") .CityStats}} +
+

城市足迹

+
按到访次数排序
+
+ {{range .CityStats}} +
+ +
+
{{num .Count}} 次
+
+ {{end}} +
+
共覆盖 {{num .Overview.Cities}} 座城市、{{num .Overview.Provinces}} 个省级行政区。
+
+ {{end}} +
+ + {{if .Monthly}} +
+

月度活跃度

+
每月到访次数
+
+ {{range .Monthly}}
{{end}} +
+
{{range .Monthly}}{{.Label}}{{end}}
+
+ {{end}} + + {{if and (.Settings.Has "heatmap") .Calendar}} +
+

活跃日历

+
每一列是一周,颜色越深记录越多
+
+ {{range .Calendar}}
{{range .Days}}
{{end}}
{{end}} +
+
少 多
+
+ {{end}} + +
+ {{if and (.Settings.Has "transport") .Transports}} +
+

出行方式

+
按次数排序,里程为两点间直线距离估算
+
+ {{range .Transports}} +
+
{{with icon .Icon}}{{.}} {{end}}{{.Name}}
+
+
{{num .Count}} 次
+
+ {{end}} +
+
+ {{end}} + + {{if and (.Settings.Has "tags") .Tags}} +
+

我的标签

+
记录时打上的自定义标签
+
+ {{range .Tags}} + + {{.Name}} + {{num .Count}} + + {{end}} +
+
+ {{end}} +
+ + {{if and (.Settings.Has "weather") .Weather}} +
+

天气足迹

+
记录期间经历过的天气
+
+ + + + {{range .Weather}} + + + + + + + {{end}} + +
天气记录小时数平均气温范围
{{condition .Condition}}{{num .Count}}{{f1 .AvgTemp}} ℃{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃
+
+
+ {{end}} + + {{if and (.Settings.Has "timeline") .Recent}} +
+

最近足迹

+
最新 {{num (len .Recent)}} 条到访记录
+
+ {{range .Recent}} +
+
{{iconor .Icon "📍"}}
+
+
{{if .PlaceID}}{{.PlaceName}}{{else}}未命名地点{{end}}
+
{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}{{$t}}{{end}}{{end}}
+
+
{{dur .DurationMin}}
{{since .Arrival}}
+
+ {{end}} +
+ +
+ {{end}} + +
+ +{{if and (.Settings.Has "map") .Points}} + +{{end}} +{{end}} +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..cee9635 --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,295 @@ +{{define "nav"}} + +{{end}} + +{{define "filters"}} +
+
+ {{range .Presets}}{{.Label}}{{end}} + {{.RangeLabel}} + {{if .IsAdmin}} + 含家/工作 + {{end}} +
+ + {{/* 已选条件:以前只能靠「重置」全清,想撤掉其中一个得手动改回「全部」再点应用 */}} + {{$chips := .ActiveChips}} + {{if $chips}} +
+ 已选 + {{range $chips}}{{.Label}}×{{end}} + 全部清空 +
+ {{end}} + + {{if .Activities}} +
+ {{range $i, $a := .Activities}}{{if or (lt $i 14) ($.IsAct $a.ID)}} + + {{$a.Name}} + {{num $a.Count}} + + {{end}}{{end}} +
+ {{if gt (len .Activities) 14}} +
+ 展开其余 {{len .Activities}} 个类型 +
+ {{range $i, $a := .Activities}}{{if and (ge $i 14) (not ($.IsAct $a.ID))}} + + {{$a.Name}} + {{num $a.Count}} + + {{end}}{{end}} +
+
+ {{end}} + {{end}} + +
+ + + + + + +
+ {{/* 下拉与日期改动即生效(见下面的脚本);这个按钮是给「关键词」和 + 关了 JS 的浏览器兜底的,所以不再是主按钮样式 */}} + + 重置 +
+
+ {{range .ActivityIDs}}{{end}} + {{if .ShowHomeWork}}{{end}} + {{if .Sort}}{{end}} +
+{{end}} + +{{define "emptystate"}} +
+
+
🗺️
+
还没有导入任何数据
+
登录后台,上传 rond 导出的 .rondbackup 备份文件即可生成足迹地图。
+ +
+
+{{end}} + +{{define "pager"}} +{{if gt .PageCount 1}} +
+ {{if gt .PageNo 1}}上一页{{end}} + 第 {{.PageNo}} / {{.PageCount}} 页 + {{if lt .PageNo .PageCount}}下一页{{end}} +
+{{end}} +{{end}} + +{{define "layout"}} + + + +{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}} + +{{.Title}} · {{.Settings.SiteTitle}} + + + + + +{{if .HasMap}} + +{{if or (eq .Nav "map") (eq .Nav "topics")}} + + +{{end}} +{{end}} +{{if .Flatpickr}} + +{{end}} + +{{/* accent-base 是站长选的主色原值:暗色模式会用 color-mix 把它提亮,否则深色底上的链接会看不清(原来的深绿在深底上只有 3.3:1)。 */}} + + + +{{template "nav" .}} +{{template "content" .}} +{{if ne .Nav "map"}} +
+ {{.Settings.SiteTitle}}{{if .Settings.SiteSubtitle}} · {{.Settings.SiteSubtitle}}{{end}} + {{if .Dataset}} · 数据截至 {{d .Dataset.Last}}{{end}} +
+{{end}} +{{if .HasMap}} + +{{if or (eq .Nav "map") (eq .Nav "topics")}}{{end}} + +{{end}} +{{if .Flatpickr}} + + +{{end}} +{{/* 第三方统计脚本:只在公开页面挂,后台/登录页不挂(免得把自己的操作也统计进去) */}} +{{if and .TrackScript (ne .Nav "admin")}} +{{.TrackScript}} +{{end}} + + +{{/* 长列表(地点库 300+ 行、时间线 800+ 条)划到底后能一键回来。 + 地图页整页不滚动,所以这个按钮在那里不会出现。 */}} +{{/* 顶部细进度条:筛选是整页 GET,慢网下点了没反应会让人以为卡住 */}} + + + + +{{end}} diff --git a/internal/web/templates/map.html b/internal/web/templates/map.html new file mode 100644 index 0000000..71d61f4 --- /dev/null +++ b/internal/web/templates/map.html @@ -0,0 +1,333 @@ +{{define "content"}} +{{if not .HasData}} +
{{template "emptystate" .}}
+{{else}} +
+
+ + {{/* 时间轴回放:拖动即可按「首次到访时间」累加显示点位。 + 注意它和左侧的日期区间是两回事——区间是「只看这段时间」, + 时间轴是「一路走到这个时刻为止」,所以放在地图底部而不是筛选面板里。 */}} + + + {{/* 加载失败时的提示条:绝不重新加载页面,只在地图上说清楚并给一个重试按钮 */}} + + +
+
+

时间范围

+
+ {{range .Presets}}{{.Label}}{{end}} + {{if .IsAdmin}} + 含家/工作 + {{end}} +
+
+ + +
+ + {{if .Activities}} +

地点类型

+
+ {{range $i, $a := .Activities}}{{if or (lt $i 12) ($.IsAct $a.ID)}} + + {{$a.Name}} + + {{end}}{{end}} +
+ {{if gt (len .Activities) 12}} +
+ 展开其余 {{len .Activities}} 个类型 +
+ {{range $i, $a := .Activities}}{{if and (ge $i 12) (not ($.IsAct $a.ID))}} + {{$a.Name}} + {{end}}{{end}} +
+
+ {{end}} + {{end}} + +

其它条件

+
+ + +
+
+ + +
+ {{range .ActivityIDs}}{{end}} + {{if .ShowHomeWork}}{{end}} + +
+ + 重置 +
+
+ + + + +
+
当前显示 {{num (len .Points)}} 个地点
+
+
+ + {{if .Legend}} +
+
类型分布
+ {{range .Legend}} +
{{.Name}} {{num .Count}}
+ {{end}} +
+ {{end}} +
+ + +{{end}} +{{end}} diff --git a/internal/web/templates/place.html b/internal/web/templates/place.html new file mode 100644 index 0000000..3724728 --- /dev/null +++ b/internal/web/templates/place.html @@ -0,0 +1,454 @@ +{{define "content"}} +
+
+
+

{{if .Place.Name}}{{.Place.Name}}{{else}}未命名地点{{end}}

+
+ {{if .Place.City}}{{.Place.City}}{{end}} + {{if .Place.Province}} · {{.Place.Province}}{{end}} + {{if .Place.District}} · {{.Place.District}}{{end}} + {{if .Place.POICategory}} · {{poiname .Place.POICategory}}{{end}} + · {{f1 .Place.Lat}}, {{f1 .Place.Lon}} +
+
+
+ ← 返回地点库 + 在地图中查看 + {{if .IsAdmin}}编辑{{end}} + {{if .Images}}{{end}} +
+
+ +
+
{{num .Place.VisitCount}}次
到访次数
+
{{dur .Place.DwellMinutes}}
累计停留
+
{{if .Place.FirstAt}}{{d .Place.FirstAt}}{{else}}—{{end}}
首次到访
+
{{if .Place.LastAt}}{{since .Place.LastAt}}{{else}}—{{end}}
最近到访
+
+ + {{if .IsAdmin}} +
+

备注与别名

+
+ 别名会替代原名显示在所有页面上;备注{{if .Settings.NotesPublic}}对访客公开{{else}}只有你自己能看到{{end}}。 + 「推荐 / 踩雷」是单独的按钮,不用写进备注。按 rond 的地点 ID 保存,重新上传备份也不会丢。 +
+ {{if .Flash}}
{{.Flash}}
{{end}} + {{if .RondNote}} +
+
+ rond 备份里给这个地点写过的备注(按次到访记录,只读;展示时会拼在你的备注之前): +
+
{{.RondNote}}
+
+ {{end}} +
+ + + + + +
+
+ {{else if .Place.Note}} +

备注

{{.Place.Note}}
+ {{end}} + + {{if .IsAdmin}} +
+

地点图片

+
+ 选好图就自动上传并立即生效,不用再点保存。 + 上传前会在浏览器里转成 WebP(长边 2000px),服务器只负责收下与转存,不做转码。 + 当前存储:{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}} + ,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。 +
+
+
+ {{range .Images}} +
+ + +
+ {{end}} +
+
+ + + +
+
+
+
+ {{end}} + + {{if or .IsAdmin .Settings.MarksPublic}} +
+

推荐 / 踩雷

+ + {{if .IsAdmin}} + {{/* 站长这里只放自己的结论;访客票数只读展示,避免同一块地方出现两套投票按钮 */}} +
值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。
+
+ 我的结论 + + + + + +
+
+ 访客票数 + + 👍 {{num .Votes.Up}} + 👎 {{num .Votes.Down}} + + 只读 +
+ {{else}} +
值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。
+
+ 大家怎么说 + + + + + +
+ {{end}} +
+ + {{end}} + +
+
+

位置

+
底图:GCJ-02(与记录坐标同系,无需纠偏)
+
+
+ +
+

到访记录

+
最近 {{num (len .Visits)}} 次
+
+ {{range .Visits}} +
+
{{iconor .Icon "📍"}}
+
+
{{day .Arrival}}
+
{{hm .Arrival}} – {{if .Departure}}{{hm .Departure}}{{else}}进行中{{end}}{{if .Remark}} · {{.Remark}}{{end}}
+
+
{{dur .DurationMin}}
+
+ {{end}} +
+
+
+
+ + + +{{/* 全屏画廊:单张查看 + 左右切换 + 双击放大。 + 以前是把所有图竖着堆在一个滚动页里,手机上既不能左右滑、也不能放大。 + 容器常驻(内容由 JS 从缩略图墙同步),所以这里不用 if .Images 包着。 */}} + + + + +{{end}} diff --git a/internal/web/templates/places.html b/internal/web/templates/places.html new file mode 100644 index 0000000..b314c57 --- /dev/null +++ b/internal/web/templates/places.html @@ -0,0 +1,84 @@ +{{define "content"}} +
+
+
+

地点库

+
+ 共 {{num .Total}} 个地点 · {{.RangeLabel}}{{if .City}} · 城市:{{.City}}{{end}}{{if .Keyword}} · 关键词:{{.Keyword}}{{end}} +
+
+
+ + {{if not .HasData}} + {{template "emptystate" .}} + {{else}} + {{template "filters" .}} + +
+ + + {{if or .IsAdmin .Settings.MarksPublic}} +
+ 标记 + +
+ {{end}} + + {{if .Places}} +
+ + + + + + + + + + {{range .Places}} + + + + + + + + + + {{end}} + +
地点城市主要类型到访累计停留首次最近
+ {{if .Name}}{{.Name}}{{else}}未命名地点{{end}} + {{if .IsHome}}家{{end}} + {{if .IsWork}}工作{{end}} + {{if eq .Verdict 1}}👍 推荐{{else if eq .Verdict -1}}👎 踩雷{{end}} + {{if or .Up .Down}}👍{{.Up}} · 👎{{.Down}}{{end}} + {{if .City}}{{.City}}{{else}}—{{end}} + {{if .TopActivity}}{{.TopActivity}}{{else}}—{{end}} + {{num .VisitCount}}{{dur .DwellMinutes}}{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}{{if .LastAt}}{{d .LastAt}}{{end}}
+
+ {{template "pager" .}} + {{else}} +
+
🔍
+
没有符合条件的地点。
+ +
+ {{end}} +
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/report.html b/internal/web/templates/report.html new file mode 100644 index 0000000..e259b19 --- /dev/null +++ b/internal/web/templates/report.html @@ -0,0 +1,144 @@ +{{define "content"}} +
+
+
+

{{.Year}} 年度报告

+
这一年的足迹,一目了然
+
+
+ {{if .Years}} + + {{end}} + + +
+
+ + {{if not .HasData}} + {{template "emptystate" .}} + {{else if gt .Year 0}} +
+
{{num .Overview.Visits}}次
到访记录
+
{{num .NewPlaces}}个
新地点
+
{{num .Overview.Cities}}个
城市
+
{{num .Overview.Provinces}}个
省级行政区
+
{{num .Overview.Days}}天
活跃天数
+
{{km .Overview.DistanceKm}}km
出行里程
+
+ + {{if .Cities}} +
+

🏅 首次到达的城市

+
这一年里,你第一次踏足的这些城市(按到达日期排序)
+
+ {{range .Cities}} + + 📍 {{.City}} · {{.First}} + + {{end}} +
+
+ {{end}} + +
+
+

去得最多的城市

+
按到访次数
+ {{if .TopCities}} +
+ {{range $i, $c := .TopCities}} +
+
{{add $i 1}}. {{.City}}
+
+
{{num .Count}} 次
+
+ {{end}} +
+ {{else}}
这一年还没有到访记录
{{end}} +
+ +
+

南北两端

+
这一年到过纬度最高与最低的地点
+ {{if .North}} +
+ + + + + {{if .South}}{{end}} + +
方位地点纬度
最北{{.North.Name}} · {{.North.City}}{{f1 .North.Lat}}°N
最南{{.South.Name}} · {{.South.City}}{{f1 .South.Lat}}°N
+
+ {{else}}
这一年还没有到访记录
{{end}} +
+
+ + {{if .Transports}} +
+

在路上

+
这一年 {{num .Overview.Movements}} 段行程,共 {{km .Overview.DistanceKm}} km
+
+ + + + {{range .Transports}} + + + + + + {{end}} + +
方式次数里程
{{with icon .Icon}}{{.}} {{end}}{{.Name}}{{num .Count}}{{km .Distance}} km
+
+
+ {{end}} + + {{if .Fog}} + {{/* 这张卡原来只有一句说明、没有任何内容(本来想放地图但没接上),看着像坏了。 + 改成与统计页一致的四个数字;完整的迷雾视图在 /stats 与地图页。 */}} +
+

世界迷雾

+
迷雾是累计数据,不按年份切分。
+
+
{{f1 .Fog.AreaKm2}}km²
已探索面积
+
{{pct .Fog.LitPlaces .Fog.Places}}%
地点点亮率
+
{{num .Fog.LitPlaces}}/{{num .Fog.Places}}
点亮地点
+
{{num .Fog.Blocks}}块
迷雾快照
+
+
+ {{end}} + + + {{else}} +
还没有数据
+ {{end}} +
+ +{{end}} diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html new file mode 100644 index 0000000..fc8ca70 --- /dev/null +++ b/internal/web/templates/stats.html @@ -0,0 +1,245 @@ +{{define "content"}} +
+
+
+

数据统计

+ {{/* 备份文件名只对站长有意义,访客看到的是噪音(也是没必要暴露的信息) */}} +
{{.RangeLabel}}{{if .IsAdmin}}{{if .Dataset}} · 数据来自 {{.Dataset.Name}}{{end}}{{end}}
+
+
+ + {{if not .HasData}} + {{template "emptystate" .}} + {{else}} + {{template "filters" .}} + +
+
{{num .Overview.Visits}}次
到访记录
+
{{num .Overview.Places}}个
不同地点
+
{{dur .Overview.DwellMin}}
累计停留
+
{{km .Overview.DistanceKm}}km
出行里程
+
+ + {{if or .IsAdmin .Settings.MarksPublic}} +
+

推荐 & 踩雷

+
+ 站长结论 推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}} | 访客票数 👍 {{num .VoteUp}} · 👎 {{num .VoteDown}} +
+
+
+
👍 推荐的地点
+ {{if .Recommended}} + + {{else}}
还没有标记
{{end}} +
+
+
👎 踩雷的地点
+ {{if .Avoided}} + + {{else}}
还没有标记
{{end}} +
+
+
+ {{end}} + +
+

月度分布

+
每月到访次数,最高 {{num .MaxMonthly}} 次
+
+ {{/* 点柱条直接筛出那个月;title 是悬停读数 */}} + {{range .Monthly}}{{end}} +
+
{{range .Monthly}}{{.Label}}{{end}}
+
+ +
+
+

类型分布

+
按到访次数排序
+ +
+ +
+

一天中的分布

+
按到访开始时间统计
+
+ {{range $h, $c := .Hourly}}
{{end}} +
+
+ {{range $h, $c := .Hourly}}{{if eq $h 0}}0{{else if eq $h 6}}6{{else if eq $h 12}}12{{else if eq $h 18}}18{{else}} {{end}}{{end}} +
+
+
+ +
+
+

城市排行

+
到访次数
+ +
+ +
+

省级行政区

+
覆盖情况
+
+ + + + {{range .ProvinceStats}} + + + + + + + {{end}} + +
省份城市地点到访
{{.Province}}{{num .Cities}}{{num .Places}}{{num .Count}}
+
+
+
+ +
+
+

星期分布

+
一周中的到访规律
+
+ {{range .Weekday}} + {{/* 星期没有对应的筛选维度(筛选只有日期区间),所以只补悬停读数 */}} +
+
{{.Label}}
+
+
{{num .Count}} 次
+
+ {{end}} +
+
+ +
+

出行方式

+
次数与估算里程
+ {{if .Transports}} +
+ + + + {{range .Transports}} + + + + + + + {{end}} + +
方式次数里程在途时间
{{with icon .Icon}}{{.}} {{end}}{{.Name}}{{num .Count}}{{km .Distance}} km{{dur .DwellMin}}
+
+ {{else}}
暂无位移记录
{{end}} +
+
+ +
+ {{if .Tags}} +
+

标签统计

+
自定义标签的使用情况
+
+ {{range .Tags}} + +
{{.Name}}
+
+
{{num .Count}} 次
+
+ {{end}} +
+
+ {{end}} + + {{if .Weather}} +
+

天气与气温

+
记录期间经历的小时级天气
+
+ + + + {{range .Weather}} + + + + + + {{end}} + +
天气小时数平均气温
{{condition .Condition}}{{num .Count}}{{f1 .AvgTemp}} ℃
+
+ + {{/* 逐月平均气温:这份数据以前一直算着却没渲染(白跑一条查询),这里把它用上。 + 横向可滚动,十几根柱子挤在窄屏上也不会把月份标签压成一团。 */}} + {{if .Temps}} +
+
逐月平均气温
+
+ {{range .Temps}} +
+ {{f1 .AvgC}}° +
+ {{.Label}} +
+ {{end}} +
+
+ {{end}} +
+ {{end}} +
+ + {{if .Fog}} +
+

世界迷雾覆盖

+
+ 迷雾快照 {{num .Fog.Blocks}} 块 / {{num .Fog.Cells}} 格,估算已探索 + {{f1 .Fog.AreaKm2}} km²;地点落在已探索格内的比例即「点亮率」 +
+
+
{{f1 .Fog.AreaKm2}}km²
已探索面积
+
{{pct .Fog.LitPlaces .Fog.Places}}%
地点点亮率
+
{{num .Fog.LitPlaces}}/{{num .Fog.Places}}
点亮地点
+
+ {{if .Fog.Cities}} +
+ {{range .Fog.Cities}} + +
{{.Name}}
+
+
{{.Lit}}/{{.Total}}
+
+ {{end}} +
+ {{end}} +
+ {{end}} + {{end}} +
+{{end}} diff --git a/internal/web/templates/timeline.html b/internal/web/templates/timeline.html new file mode 100644 index 0000000..da22c18 --- /dev/null +++ b/internal/web/templates/timeline.html @@ -0,0 +1,60 @@ +{{define "content"}} +
+
+
+

时间线

+
共 {{num .Total}} 条到访记录 · {{.RangeLabel}}
+
+
+ + {{if not .HasData}} + {{template "emptystate" .}} + {{else}} + {{template "filters" .}} + + {{if .Days}} + + + {{range .Days}} +
+
+ {{.Label}} + {{num (len .Items)}} 条 · 停留 {{dur .Dwell}} +
+ {{range .Items}} +
+
{{hm .Arrival}}
+
+
+
+ {{if .PlaceID}}{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}{{else}}未命名地点{{end}} + {{if .ActivityName}}{{.ActivityName}}{{end}} + {{range .Tags}}{{.}}{{end}} +
+
+ {{if .Departure}}{{hm .Arrival}} – {{hm .Departure}} · {{end}}停留 {{dur .DurationMin}} + {{if .City}} · {{.City}}{{end}} + {{if .Remark}} · {{.Remark}}{{end}} +
+
+
+ {{end}} +
+ {{end}} + + {{template "pager" .}} + {{else}} +
+
📭
+
没有符合条件的到访记录。
+ +
+ {{end}} + {{end}} +
+{{end}} diff --git a/internal/web/templates/topic.html b/internal/web/templates/topic.html new file mode 100644 index 0000000..573877c --- /dev/null +++ b/internal/web/templates/topic.html @@ -0,0 +1,97 @@ +{{define "content"}} +{{if not .HasData}} +
{{template "emptystate" .}}
+{{else}} +
+
+
+

{{.Topic.Title}}

+ {{if .Topic.Subtitle}}
{{.Topic.Subtitle}}
{{end}} + {{/* 时间范围独立成行:塞进 KPI 网格里会被挤成好几行 */}} +
{{.Topic.RangeLabel}}{{if .Topic.DayCount}} · 共 {{num .Topic.DayCount}} 天{{end}}
+
+
+ ← 全部专题 + {{if .IsAdmin}}编辑{{end}} +
+
+ + {{if not .Topic.Enabled}}
这个专题尚未对外展示,只有你自己能看到。
{{end}} + + {{if .Topic.Description}}
{{.Topic.Description}}
{{end}} + +
+
+
{{if .Topic.HasArea}}区域内地点{{else}}地点{{end}}
{{num .Topic.PlaceCount}}
+
到访次数
{{num .Topic.VisitCount}}
+
涉及城市
{{num .Topic.CityCount}}
+
累计停留
{{dur .Topic.DwellMin}}
+
+
+ +
+ {{/* 高度写 inline:站点里 #map{height:100%} 是 ID 选择器,只靠外部 CSS 类 + 容易在别处规则变动/缓存不一致时把容器算成 0 高,地图就整块消失 */}} +
+
地图加载中……若一直不出现,请强制刷新(Ctrl+F5)或检查浏览器是否拦截了地图脚本
+
+
+ +
+

去过的地方

+
共 {{num .Topic.PlaceCount}} 个,按到访次数排序
+ {{if .Points}} +
+ + + + {{range .Points}} + + + + + + + + + {{end}} + +
地点城市类型到访停留最近一次
{{if .Name}}{{.Name}}{{else}}(未命名){{end}}{{.City}}{{if .Act}}{{.Act}}{{end}}{{num .Count}}{{dur .Dwell}}{{.LastAt}}
+
+ {{else}} +
这个时间 / 区域范围内没有记录
+ {{end}} +
+
+ + +{{end}} +{{end}} diff --git a/internal/web/templates/topics.html b/internal/web/templates/topics.html new file mode 100644 index 0000000..7ae1dbe --- /dev/null +++ b/internal/web/templates/topics.html @@ -0,0 +1,33 @@ +{{define "content"}} +
+
+
+

专题

+
把某段时间、某个区域的足迹整理成一个个小专题
+
+ {{if .IsAdmin}}{{end}} +
+ + {{if .Topics}} + + {{else}} +
还没有对外展示的专题
+ {{end}} +
+{{end}} diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go new file mode 100644 index 0000000..995d4c8 --- /dev/null +++ b/internal/web/templates_test.go @@ -0,0 +1,19 @@ +package web + +import ( + "html/template" + "path/filepath" + "testing" +) + +// TestPageTemplatesParse 把每个页面模板都解析一遍。 +// 模板是启动时编译的(见 New),语法错会让整个服务起不来; +// 这个测试把它提前到 go test 阶段,不用真起一次服务才发现。 +func TestPageTemplatesParse(t *testing.T) { + for key, def := range pageFiles { + if _, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()). + ParseFS(tmplFS, def.files...); err != nil { + t.Errorf("解析模板 %s 失败: %v", key, err) + } + } +} diff --git a/internal/web/tiles.go b/internal/web/tiles.go new file mode 100644 index 0000000..e450802 --- /dev/null +++ b/internal/web/tiles.go @@ -0,0 +1,261 @@ +package web + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "rond-with-you/internal/config" +) + +// mapLayer 描述一个可供前端使用的底图/叠加图层。 +// crs 表示该底图自身的坐标系:国内底图是 gcj02,天地图与 OSM 是 wgs84。 +type mapLayer struct { + Key string `json:"key"` + Label string `json:"label"` + CRS string `json:"crs"` + URL string `json:"url"` + MaxZoom int `json:"maxZoom"` + Attribution string `json:"attr"` +} + +// tileSet 是当前部署的底图集合。 +// upstream 记录哪些图层必须经服务端转发:命中即说明该图层带密钥, +// 密钥只在上游模板里拼接,不会出现在页面源码中。 +type tileSet struct { + base []mapLayer + overlays []mapLayer + upstream map[string]string + subStart int // 瓦片子域起始序号:高德从 1 起,天地图从 0 起 + subCount int // 瓦片子域个数:高德 4 个,天地图 8 个 +} + +// 高德免密钥瓦片(三种样式实测:同一块的「近黑像素占比」≈ 文字量) +// +// style=8 街道图,带 POI 与路名标注(长沙一块 dark≈1.5%,wprd 主机更密≈3.5%) +// style=7 纯路网,只画道路、几乎不标注(同一块 dark≈0.2%)—— 点密集时用它才看得清自己的点 +// style=6 卫星影像(走 wprd 主机) +const ( + amapStyleStreet = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}" + amapStyleRoadnet = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}" + amapStyleSatellite = "https://wprd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=6&x={x}&y={y}&z={z}" +) + +const tdtBase = "https://t{s}.tianditu.gov.cn/%s/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" + + "&LAYER=%s&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}" + +func buildTileSet(cfg *config.Config) tileSet { + ts := tileSet{upstream: map[string]string{}, overlays: []mapLayer{}, subStart: 1, subCount: 4} + switch cfg.MapProvider { + case "tianditu": + ts.subStart, ts.subCount = 0, 8 + ts.upstream["tdt-vec"] = fmt.Sprintf(tdtBase, "vec_w", "vec") + ts.upstream["tdt-img"] = fmt.Sprintf(tdtBase, "img_w", "img") + ts.upstream["tdt-cva"] = fmt.Sprintf(tdtBase, "cva_w", "cva") + ts.upstream["tdt-cia"] = fmt.Sprintf(tdtBase, "cia_w", "cia") + ts.base = []mapLayer{ + {Key: "tdt-vec", Label: "矢量", CRS: "wgs84", URL: "/tiles/tdt-vec/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 天地图"}, + {Key: "tdt-img", Label: "影像", CRS: "wgs84", URL: "/tiles/tdt-img/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 天地图"}, + } + // 天地图的注记是独立图层,缺了会看不清地名 + ts.overlays = []mapLayer{ + {Key: "tdt-cva", Label: "矢量注记", CRS: "wgs84", URL: "/tiles/tdt-cva/{z}/{x}/{y}", MaxZoom: 18}, + {Key: "tdt-cia", Label: "影像注记", CRS: "wgs84", URL: "/tiles/tdt-cia/{z}/{x}/{y}", MaxZoom: 18}, + } + case "custom": + key := cfg.MapTileURL + if cfg.MapKey != "" { + ts.upstream["custom"] = key + ts.base = []mapLayer{{Key: "custom", Label: "底图", CRS: cfg.MapCRS, URL: "/tiles/custom/{z}/{x}/{y}", MaxZoom: 19}} + } else { + ts.base = []mapLayer{{Key: "custom", Label: "底图", CRS: cfg.MapCRS, URL: key, MaxZoom: 19}} + } + default: // amap + // OSM 免密钥全球瓦片作为兜底:高德瓦片加载持续失败时前端会自动切过去 + osm := mapLayer{Key: "osm", Label: "OSM", CRS: "wgs84", + URL: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", MaxZoom: 19, Attribution: "© OpenStreetMap"} + if cfg.MapKey == "" { + // 免密钥瓦片,浏览器直连,服务端零负担 + ts.base = []mapLayer{ + {Key: "amap-street", Label: "街道", CRS: "gcj02", URL: amapStyleStreet, MaxZoom: 18, Attribution: "© 高德地图"}, + {Key: "amap-roadnet", Label: "纯路网", CRS: "gcj02", URL: amapStyleRoadnet, MaxZoom: 18, Attribution: "© 高德地图"}, + {Key: "amap-satellite", Label: "卫星影像", CRS: "gcj02", URL: amapStyleSatellite, MaxZoom: 18, Attribution: "© 高德地图"}, + osm, + } + } else { + ts.upstream["amap-street"] = amapStyleStreet + ts.upstream["amap-roadnet"] = amapStyleRoadnet + ts.upstream["amap-satellite"] = amapStyleSatellite + ts.base = []mapLayer{ + {Key: "amap-street", Label: "街道", CRS: "gcj02", URL: "/tiles/amap-street/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"}, + {Key: "amap-roadnet", Label: "纯路网", CRS: "gcj02", URL: "/tiles/amap-roadnet/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"}, + {Key: "amap-satellite", Label: "卫星影像", CRS: "gcj02", URL: "/tiles/amap-satellite/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"}, + osm, + } + } + } + return ts +} + +// ---------- 瓦片代理 ---------- + +var tilePathRe = regexp.MustCompile(`^/tiles/([a-z0-9-]+)/(\d{1,2})/(\d{1,8})/(\d{1,8})$`) + +type cachedTile struct { + body []byte + ct string + at time.Time +} + +type tileCache struct { + mu sync.Mutex + items map[string]cachedTile + limit int +} + +func newTileCache(limit int) *tileCache { + return &tileCache{items: map[string]cachedTile{}, limit: limit} +} + +func (c *tileCache) get(k string) (cachedTile, bool) { + c.mu.Lock() + defer c.mu.Unlock() + t, ok := c.items[k] + return t, ok +} + +// clear 清空缓存(数据变更后调用)。 +func (c *tileCache) clear() { + c.mu.Lock() + c.items = map[string]cachedTile{} + c.mu.Unlock() +} + +func (c *tileCache) put(k string, t cachedTile) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.items) >= c.limit { + // 简单的整表淘汰:够用且不会让内存无上限增长 + c.items = map[string]cachedTile{} + } + c.items[k] = t +} + +var tileHTTPClient = &http.Client{Timeout: 15 * time.Second} + +// handleTile 代理瓦片请求。上游地址与密钥都在服务端拼接,前端只拿到本站路径。 +func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) { + m := tilePathRe.FindStringSubmatch(r.URL.Path) + if m == nil { + http.NotFound(w, r) + return + } + layer := m[1] + tpl, ok := s.tiles.upstream[layer] + if !ok { + http.NotFound(w, r) + return + } + z, _ := strconv.Atoi(m[2]) + x, _ := strconv.Atoi(m[3]) + y, _ := strconv.Atoi(m[4]) + if z < 0 || z > 22 { + http.NotFound(w, r) + return + } + if x < 0 || y < 0 || x >= 1<= 1< 0 { + // 子域按瓦片坐标轮转。写死 0 会打到不存在的域名(高德只有 01~04) + up = strings.ReplaceAll(up, "{s}", strconv.Itoa(s.tiles.subStart+(x+y)%s.tiles.subCount)) + } + up = strings.ReplaceAll(up, "{z}", strconv.Itoa(z)) + up = strings.ReplaceAll(up, "{x}", strconv.Itoa(x)) + up = strings.ReplaceAll(up, "{y}", strconv.Itoa(y)) + up = strings.ReplaceAll(up, "{key}", s.cfg.MapKey) + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, up, nil) + if err != nil { + http.Error(w, "上游地址无效", http.StatusBadGateway) + return + } + req.Header.Set("User-Agent", "rond-with-you/1.0") + resp, err := tileHTTPClient.Do(req) + if err != nil { + http.Error(w, "拉取瓦片失败", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + http.Error(w, "上游返回 "+resp.Status, http.StatusBadGateway) + return + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + http.Error(w, "读取瓦片失败", http.StatusBadGateway) + return + } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "image/") { + ct = "image/png" + } + s.tileCache.put(cacheKey, cachedTile{body: body, ct: ct, at: time.Now()}) + writeTile(w, body, ct, false) +} + +func writeTile(w http.ResponseWriter, body []byte, ct string, cached bool) { + w.Header().Set("Content-Type", ct) + w.Header().Set("Cache-Control", "public, max-age=86400") + if cached { + w.Header().Set("X-Tile-Cache", "hit") + } + w.Write(body) +} + +// mapConfig 返回前端构建底图所需的全部信息。 +// subs 用于浏览器直连的免密钥底图:这类 URL 里带 {s} 占位符, +// 必须由前端交给 Leaflet 展开,服务端不参与,因此子域范围得下发。 +// default 是后台设的「默认铺哪张底图」(空 = 第一张); +// 访客在图上手动切换过的话,前端会优先用他自己记在 localStorage 里的选择。 +func (s *Server) mapConfig(w http.ResponseWriter, r *http.Request) { + subs := make([]string, 0, s.tiles.subCount) + for i := 0; i < s.tiles.subCount; i++ { + subs = append(subs, strconv.Itoa(s.tiles.subStart+i)) + } + def := "" + if st, err := s.sets.Load(r.Context()); err == nil { + def = st.MapDefaultBase + } + resp := map[string]any{ + "base": s.tiles.base, + "overlays": s.tiles.overlays, + "subs": subs, + "default": def, + } + if ix, err := s.fogIndex(); err == nil { + if blocks, cells := ix.Stats(); blocks > 0 { + // ver 里带上遮罩指纹:访客的瓦片允许公共缓存,遮罩改了 URL 必须跟着变, + // 否则访客会一直看到缓存里那份(最长 1 小时),像「遮罩没生效」。 + ver := strconv.FormatInt(ix.Version(), 10) + "-" + s.maskFP() + resp["fog"] = map[string]any{ + "blocks": blocks, "cells": cells, "ver": ver, + } + } + } + s.json(w, resp) +} diff --git a/internal/web/track_tag_test.go b/internal/web/track_tag_test.go new file mode 100644 index 0000000..f0e3a91 --- /dev/null +++ b/internal/web/track_tag_test.go @@ -0,0 +1,34 @@ +package web + +import ( + "strings" + "testing" +) + +func TestTrackTag(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", ""}, + {" ", ""}, + {"https://umami.example.com/script.js", + ``}, + {"http://a/b.js?x=1&y=2", ``}, + // 整段贴进来的原样输出(百度统计这类要带初始化代码) + {``, + ``}, + // 形如 URL 却夹了引号/尖括号:宁可不挂,也不拼出可注出属性的标签 + {`https://x/y.js" onload="alert(1)`, ""}, + {`https://x/.js`, ""}, + {" https://a/b.js ", ``}, + } + for _, c := range cases { + if got := string(trackTag(c.in)); got != c.want { + t.Errorf("trackTag(%q) = %q,期望 %q", c.in, got, c.want) + } + } + if !strings.Contains(string(trackTag("//cdn.example.com/a.js")), "//cdn.example.com/a.js") { + t.Error("不以 http 开头但含尖括号以外的内容应原样输出") + } +} diff --git a/internal/web/view.go b/internal/web/view.go new file mode 100644 index 0000000..0336bf8 --- /dev/null +++ b/internal/web/view.go @@ -0,0 +1,1389 @@ +package web + +import ( + "database/sql" + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "rond-with-you/internal/auth" + "rond-with-you/internal/fog" + "rond-with-you/internal/setting" + "rond-with-you/internal/stats" + "rond-with-you/internal/votes" +) + +// SetFlashFromQuery 取重定向带回来的一次性提示(?ok= / ?err=)。 +// +// 凡是「处理完跳回本页」的页面都必须调一次:`adminFlash` 之类的帮助函数把消息挂在 +// URL 上,页面不读就静默丢了——后台首页就漏过这一句,上传备份、导入迷雾、开两步验证 +// 全都没有任何提示。 +func (p *Page) SetFlashFromQuery(r *http.Request) { + q := r.URL.Query() + p.Flash, p.Err = q.Get("ok"), q.Get("err") +} + +// Page 是所有页面共享的上下文(站点设置、登录态、筛选器状态)。 +type Page struct { + Settings setting.Settings + User *auth.User + Nav string + Title string + Flash string + Err string + HasData bool + Dataset *Dataset + // AssetV 是静态资源版本号,链接带 ?v=AssetV 时才允许长期缓存 + AssetV string + // TrackScript 是站点配置里的第三方统计脚本(已包成可直接输出的 HTML) + TrackScript template.HTML + + Activities []stats.ActivityStat + TagOptions []stats.TagStat + CityList []string + From string + To string + ActivityIDs []int64 + City string + TagID int64 + Keyword string + MinDwell int + Sort string + // Verdict 是地点库的「推荐 / 踩雷」筛选(1 推荐 / -1 踩雷 / 0 不限) + Verdict int + PageNo int + PageCount int + Total int + RangeLabel string + FilterBase string + Presets []RangePreset + + // ShowHomeWork 表示本次请求允许包含「家 / 工作」。 + // 只有登录后的站长主动打开开关才为 true,匿名访客拿不到。 + ShowHomeWork bool + + // HasMap 决定是否加载 Leaflet,避免没有地图的页面白下载一份地图库。 + HasMap bool + + // Flatpickr 决定是否加载日期时间选择器(目前只有数据编辑页在用)。 + Flatpickr bool + + // HasTopics 表示该数据集有专题,导航里才露出「专题」入口。 + HasTopics bool +} + +// IsAdmin 报告当前访问者是否已登录后台。 +func (p Page) IsAdmin() bool { return p.User != nil } + +// RangePreset 是筛选器上的快捷时间范围。 +type RangePreset struct { + Label string + From string + To string + Active bool +} + +func (p Page) IsAct(id int64) bool { + for _, v := range p.ActivityIDs { + if v == id { + return true + } + } + return false +} + +func (p Page) filterPairs() []string { + var kv []string + if p.ShowHomeWork { + kv = append(kv, "hw", "1") + } + if p.From != "" { + kv = append(kv, "from", p.From) + } + if p.To != "" { + kv = append(kv, "to", p.To) + } + for _, id := range p.ActivityIDs { + kv = append(kv, "act", strconv.FormatInt(id, 10)) + } + if p.City != "" { + kv = append(kv, "city", p.City) + } + if p.TagID > 0 { + kv = append(kv, "tag", strconv.FormatInt(p.TagID, 10)) + } + if p.MinDwell > 0 { + kv = append(kv, "min", strconv.Itoa(p.MinDwell)) + } + if p.Keyword != "" { + kv = append(kv, "q", p.Keyword) + } + if p.Sort != "" { + kv = append(kv, "sort", p.Sort) + } + if p.Verdict != 0 { + kv = append(kv, "verdict", strconv.Itoa(p.Verdict)) + } + return kv +} + +// Link 在保留当前筛选条件的前提下生成链接。 +// Link 以当前筛选参数为底生成链接。extra 里的键会**覆盖**同名项,而不是追加—— +// 否则会拼出 ?sort=dwell&sort=name 这种重复参数,而取参数只认第一个, +// 表现为「点了另一个排序没反应」。extra 里值为空的键会被丢掉, +// 这样「回到默认排序」只要传 sort=""(buildURL 会跳过空值)。 +func (p Page) Link(base string, extra ...string) string { + kv := p.filterPairs() + drop := map[string]bool{} + for i := 0; i+1 < len(extra); i += 2 { + drop[extra[i]] = true + } + out := make([]string, 0, len(kv)+len(extra)) + for i := 0; i+1 < len(kv); i += 2 { + if drop[kv[i]] { + continue + } + out = append(out, kv[i], kv[i+1]) + } + return buildURL(base, append(out, extra...)) +} + +// ActLink 点击类型标签时切换该类型的选中状态。 +func (p Page) ActLink(id int64) string { + var kv []string + has := false + for _, v := range p.ActivityIDs { + if v == id { + has = true + continue + } + kv = append(kv, "act", strconv.FormatInt(v, 10)) + } + if !has { + kv = append(kv, "act", strconv.FormatInt(id, 10)) + } + rest := p.filterPairs() + for i := 0; i+1 < len(rest); i += 2 { + if rest[i] != "act" { + kv = append(kv, rest[i], rest[i+1]) + } + } + return buildURL(p.FilterBase, kv) +} + +// TagLink 切换标签筛选。 +func (p Page) TagLink(id int64) string { + kv := p.filterPairs() + out := kv[:0] + for i := 0; i+1 < len(kv); i += 2 { + if kv[i] != "tag" { + out = append(out, kv[i], kv[i+1]) + } + } + if p.TagID != id { + out = append(out, "tag", strconv.FormatInt(id, 10)) + } + return buildURL(p.FilterBase, out) +} + +// CityLink 切换城市筛选。 +func (p Page) CityLink(city string) string { + kv := p.filterPairs() + out := kv[:0] + for i := 0; i+1 < len(kv); i += 2 { + if kv[i] != "city" { + out = append(out, kv[i], kv[i+1]) + } + } + if p.City != city { + out = append(out, "city", city) + } + return buildURL(p.FilterBase, out) +} + +// VerdictLink 切换「推荐 / 踩雷」筛选(v=1 推荐 / -1 踩雷)。 +func (p Page) VerdictLink(v int) string { + kv := p.filterPairs() + out := kv[:0] + for i := 0; i+1 < len(kv); i += 2 { + if kv[i] != "verdict" { + out = append(out, kv[i], kv[i+1]) + } + } + if p.Verdict != v { + out = append(out, "verdict", strconv.Itoa(v)) + } + return buildURL(p.FilterBase, out) +} + +// RangeLink 切换日期区间。 +func (p Page) RangeLink(from, to string) string { + kv := p.filterPairs() + out := kv[:0] + for i := 0; i+1 < len(kv); i += 2 { + if kv[i] != "from" && kv[i] != "to" { + out = append(out, kv[i], kv[i+1]) + } + } + if from != "" { + out = append(out, "from", from) + } + if to != "" { + out = append(out, "to", to) + } + return buildURL(p.FilterBase, out) +} + +// MonthLink 把「2026-09」这样的月份键变成该月的日期区间链接, +// 用于点月度柱条直接把筛选切到那个月。 +func (p Page) MonthLink(key string) string { + t, err := time.Parse("2006-01", key) + if err != nil { + return p.FilterBase + } + return p.RangeLink(t.Format("2006-01-02"), t.AddDate(0, 1, 0).Format("2006-01-02")) +} + +// HWLink 切换「包含家 / 工作」。站点默认隐藏这两个常住地, +// 站长登录后在页面上打开开关即可临时显示。 +func (p Page) HWLink() string { + kv := p.filterPairs() + out := kv[:0] + for i := 0; i+1 < len(kv); i += 2 { + if kv[i] != "hw" { + out = append(out, kv[i], kv[i+1]) + } + } + if !p.ShowHomeWork { + out = append(out, "hw", "1") + } + return buildURL(p.FilterBase, out) +} + +// RemoveFilter 生成「摘掉某个筛选条件」的链接。 +// key 传 "date" 时同时摘掉 from 与 to(日期是一组); +// value 非空时只摘掉多值参数(act)里的这一项,其余保留。 +func (p Page) RemoveFilter(key, value string) string { + kv := p.filterPairs() + out := kv[:0] + for i := 0; i+1 < len(kv); i += 2 { + k, v := kv[i], kv[i+1] + match := k == key || (key == "date" && (k == "from" || k == "to")) + if !match { + out = append(out, k, v) + continue + } + if value != "" && v != value { + out = append(out, k, v) // 多值参数里只摘掉指定的那个 + } + } + return buildURL(p.FilterBase, out) +} + +// ActiveChip 是「已选条件」行里的一项。 +type ActiveChip struct { + Label string + RemoveURL string +} + +// ActiveChips 列出当前生效的筛选,让站长逐个摘掉。 +// 以前只能靠「重置」全清——想撤掉其中一个,得手动改回「全部」再点应用。 +func (p Page) ActiveChips() []ActiveChip { + var out []ActiveChip + if p.From != "" || p.To != "" { + label := "截至 " + p.To + switch { + case p.From != "" && p.To != "": + label = p.From + " ~ " + p.To + case p.From != "": + label = p.From + " 起" + } + out = append(out, ActiveChip{Label: label, RemoveURL: p.RemoveFilter("date", "")}) + } + for _, id := range p.ActivityIDs { + for _, a := range p.Activities { + if a.ID == id { + out = append(out, ActiveChip{Label: a.Name, + RemoveURL: p.RemoveFilter("act", strconv.FormatInt(id, 10))}) + break + } + } + } + if p.City != "" { + out = append(out, ActiveChip{Label: p.City, RemoveURL: p.RemoveFilter("city", "")}) + } + if p.TagID > 0 { + for _, t := range p.TagOptions { + if t.ID == p.TagID { + out = append(out, ActiveChip{Label: t.Name, RemoveURL: p.RemoveFilter("tag", "")}) + break + } + } + } + if p.MinDwell > 0 { + out = append(out, ActiveChip{Label: "≥ " + strconv.Itoa(p.MinDwell) + " 分钟", + RemoveURL: p.RemoveFilter("min", "")}) + } + if p.Keyword != "" { + out = append(out, ActiveChip{Label: "“" + p.Keyword + "”", RemoveURL: p.RemoveFilter("q", "")}) + } + if p.Verdict != 0 { + label := "👍 推荐" + if p.Verdict < 0 { + label = "👎 踩雷" + } + out = append(out, ActiveChip{Label: label, RemoveURL: p.RemoveFilter("verdict", "")}) + } + if p.ShowHomeWork { + out = append(out, ActiveChip{Label: "含家/工作", RemoveURL: p.RemoveFilter("hw", "")}) + } + return out +} + +func buildURL(base string, kv []string) string { + if len(kv) == 0 { + return base + } + v := url.Values{} + for i := 0; i+1 < len(kv); i += 2 { + if kv[i+1] == "" { + continue + } + v.Add(kv[i], kv[i+1]) + } + if len(v) == 0 { + return base + } + return base + "?" + v.Encode() +} + +// ---------- 页面数据 ---------- + +type HomeData struct { + Page + Overview stats.Overview + ActivityStats []stats.ActivityStat + CityStats []stats.CityStat + Monthly []MonthBar + Calendar []CalWeek + Transports []stats.TransportStat + Tags []stats.TagStat + Weather []stats.WeatherStat + Recent []stats.VisitRow + Places []stats.Place + Points []MapPoint + + MaxActivity int + MaxCity int + MaxTransport int + MaxTag int +} + +type MapData struct { + Page + Points []MapPoint + Legend []LegendItem +} + +// ModeCount 是轨迹图例的一项:交通方式、段数与颜色。 +type ModeCount struct { + Name string `json:"name"` + Count int `json:"count"` + Color string `json:"color"` +} + +// modeFallback 是没记录交通方式的位移段在轨迹上归到的类别(灰)。 +const modeFallback = "步行 / 其他" + +// LegendItem 是地图右下角图例的一项,按当前筛选结果统计。 +type LegendItem struct { + Name string + Color string + Icon string + Count int +} + +type PlacesData struct { + Page + Places []stats.Place +} + +type PlaceData struct { + Page + Place stats.Place + Visits []stats.VisitRow + Points []MapPoint + // Verdict 是站长官方结论(1=推荐 / -1=踩雷 / 0=未标);Votes 是访客票数; + // MyVote 是当前访客自己对这一地点的投票。 + Verdict int + Votes votes.Tally + MyVote int + // MyNote 是站长手写的备注(编辑框里编辑的就是它); + // RondNote 是 rond 备份里带出来的备注,只读,展示时拼在 MyNote 之前。 + MyNote string + RondNote string + // Images 是站长给这个地点配的图片(按 position 排序)。 + Images []PlaceImage + // ImageBackend 是当前生效的图片存储(local / s3),后台那块面板展示用; + // ImageReady 为假表示选了对象存储但凭据没配齐,此时不允许上传。 + ImageBackend string + ImageReady bool + // ImageMaxPer 是单个地点的图片张数上限,模板里用来提示 + ImageMaxPer int +} + +type TimelineData struct { + Page + Days []TimelineDay +} + +type StatsData struct { + Page + Overview stats.Overview + ActivityStats []stats.ActivityStat + CityStats []stats.CityStat + ProvinceStats []stats.ProvinceStat + Monthly []MonthBar + Hourly []int + Weekday []stats.Bucket + Transports []stats.TransportStat + Tags []stats.TagStat + Weather []stats.WeatherStat + Temps []TempPoint + Fog *FogCoverage + // 推荐 / 踩雷 汇总与榜单 + VoteRec int + VoteAvoid int + VoteUp int + VoteDown int + Recommended []stats.Place + Avoided []stats.Place + MaxMonthly int + MaxHourly int + MaxActivity int + MaxCity int + MaxWeekday int + MaxTag int +} + +// ReportData 是年度报告页的数据。 +type ReportData struct { + Page + Year int + Years []int + Overview stats.Overview + TopCities []stats.CityStat + Transports []stats.TransportStat + North *stats.Place + South *stats.Place + NewPlaces int + // Cities 是本年度「首次到达」的城市里程碑(按首次到达日期升序)。 + Cities []CityMilestone + Fog *FogCoverage +} + +// CityMilestone 是年度报告的「首次到达某城」里程碑:该城市有史以来第一次到访落在这一年。 +type CityMilestone struct { + City string + Province string + First string // 首次到达日期 YYYY-MM-DD +} + +type LoginData struct { + Settings setting.Settings + Err string + Next string + Flash string +} + +type AdminData struct { + Page + Datasets []Dataset + MaxSize int64 + // 迷雾当前状态(未导入时 Blocks 为 0,模板据此显示空态提示) + FogBlocks int + FogCells int + FogArea float64 + FogUpdatedAt *time.Time + // FogMasks 是遮罩区数量,模板据此在按钮上显示计数 + FogMasks int + // DefaultPW 表示管理员仍在使用初始口令,模板顶部给出警示条 + DefaultPW bool +} + +type NotesData struct { + Page + Rows []NoteRow + Orphans []NoteRow // 备注挂在已不在当前数据集的地点上的行 + CityOptions []string + Query string + Status string // all / has / none + City string + Sort string + NotesPublic bool + // BackURL 是带完整筛选条件的本页地址,用于行内表单保存后跳回原处 + BackURL string +} + +// PageLink 生成备注管理页的分页链接,保留当前筛选条件。 +func (d NotesData) PageLink(page int) string { + v := url.Values{} + if d.Query != "" { + v.Set("q", d.Query) + } + if d.Status != "" && d.Status != "all" { + v.Set("status", d.Status) + } + if d.City != "" { + v.Set("city", d.City) + } + if d.Sort != "" { + v.Set("sort", d.Sort) + } + v.Set("page", strconv.Itoa(page)) + return "/admin/notes?" + v.Encode() +} + +// EditData 是数据编辑页的数据:地点 / 到访 / 行程三个可搜索分页列表 + 各类新增表单。 +type EditData struct { + Page + Activities []EditOption + Tags []EditOption + Transports []EditTransport + // TransportColors 是 rond 认的那套系统色名,供交通方式选色(顺序固定,便于复用) + TransportColors []string + PlaceOptions []EditOption + VisitOptions []EditOption // 最近到访(值=src_pk),供行程/天气表单选关联 + Categories []EditOption // 已有地点的类别(去重),供新建时下拉直选 + Places []EditPlace + Visits []EditVisit + Movements []EditMovement + Query string + // VisitQuery 是到访记录列表的搜索词(与地点列表的 Query 独立) + VisitQuery string + // MovementFilter 是行程列表的筛选条件,都是选择框而不是让人手输关键词 + MovementFilter MovementFilter + PageNo int + PageCount int + Total int + VisitPageNo int + VisitPageCount int + VisitTotal int + MovementPageNo int + MovementPageCount int + MovementTotal int + // SuspectCount 是数据集里「显示坐标疑似就是 WGS-84」的地点总数 + SuspectCount int + // SuspectOnly 是地点列表「只看疑似 WGS-84」的开关 + SuspectOnly bool + // SourceFilter 是地点列表按「坐标来源」筛选的当前值(空=全部,none=没有留档) + SourceFilter string + // SourceCounts 是各来源的地点数,给筛选下拉显示数量 + SourceCounts map[string]int + CanExport bool + GeocodeReady bool + // OpenSrcPK 是「从地点详情页跳进来要定位的地点」(?open=):非 0 时自动翻到 + // 该行所在页、展开它的编辑框并高亮,省得在几百行地点里手动搜 + OpenSrcPK int +} + +// PlaceSources 供模板渲染筛选下拉,顺序固定(与 placeSourceLabels 一致)。 +func (d EditData) PlaceSources() []struct{ Value, Label string } { return placeSourceLabels } + +// SourceCount 取某个来源的点数,没有就是 0(模板里做「(N)」提示用)。 +func (d EditData) SourceCount(v string) int { return d.SourceCounts[v] } + +// CoordSysLabel 把坐标系标识写成显示用的短名。 +func CoordSysLabel(v string) string { return coordSysLabel(v) } + +// SourceLabel 把 coord_source 写成显示名;空值表示历史行没有留档。 +func SourceLabel(v string) string { + for _, s := range placeSourceLabels { + if s.Value == v { + return s.Label + } + } + return "未留档" +} + +// MovementFilter 是行程列表的筛选条件:交通方式 / 一端的地点 / 时间范围。 +// 三个都是可选项,空值表示不限。 +type MovementFilter struct { + // Transport 为空=全部、"none"=没有交通方式的那些、数字=transport_src + Transport string + // PlaceID 按起或止任一端匹配(movements 上的 from/to_place_id 是导入与新建时都写好的) + PlaceID int64 + From string // YYYY-MM-DD + To string // YYYY-MM-DD,含当天 +} + +func (f MovementFilter) set(v url.Values) { + if f.Transport != "" { + v.Set("mt", f.Transport) + } + if f.PlaceID > 0 { + v.Set("mp", strconv.FormatInt(f.PlaceID, 10)) + } + if f.From != "" { + v.Set("mfrom", f.From) + } + if f.To != "" { + v.Set("mto", f.To) + } +} + +// PageLink 是某个列表翻到第 n 页的地址,带上当前的全部筛选条件(三个列表共用, +// 翻页不会把别的列表的条件丢掉)。 +// +// 返回 template.URL 是必须的:href 里再经 html/template 的 URL 转义会把 "=" 编成 +// %3d,`mt%3d4` 在服务端解出来是「键为 mt=4」的参数,筛选条件会在翻页时静默失效。 +// & 手工写成实体,交给浏览器的解析结果仍是 &。 +func (d EditData) PageLink(param string, n int) template.URL { + v := url.Values{} + if d.Query != "" { + v.Set("q", d.Query) + } + if d.SuspectOnly { + v.Set("sus", "1") + } + if d.SourceFilter != "" { + v.Set("src", d.SourceFilter) + } + if d.VisitQuery != "" { + v.Set("vq", d.VisitQuery) + } + d.MovementFilter.set(v) + v.Set(param, strconv.Itoa(n)) + return template.URL(strings.ReplaceAll("/admin/edit?"+v.Encode(), "&", "&")) +} + +// EditOption 是表单里的一项选项(活动 / 标签 / 交通 / 地点)。 +type EditOption struct { + ID int64 + SrcPK int + Name string + Label string +} + +type EditPlace struct { + ID int64 + SrcPK int + Name string + Category string + Province string + City string + District string + Thoroughfare string + Lat float64 + Lon float64 + VisitCount int + // SuspectWGS 为真表示导入留档里的原始 GPS 与显示坐标完全相同——真机里正常会写 + // 「显示坐标 = 原始 GPS 做过 GCJ-02 偏移」,一字不差的就是当初没偏移的那批, + // 在 GCJ-02 底图上会整体偏 500 米左右。 + SuspectWGS bool + // OrigLat/OrigLon 是导入留档里的显示坐标(改过之后用来做前后对照) + OrigLat, OrigLon sql.NullFloat64 + // MovedM 是当前坐标相对导入时的位移(米),0 表示没动过 + MovedM int + // CoordSource/CoordSys 是建点时留下的来源与坐标系;SrcLat/SrcLon 是换算前的原始坐标。 + // 三者都为空表示历史行没有留档(2026-09 之前的点都是这样)。 + CoordSource string + CoordSys string + SrcLat, SrcLon sql.NullFloat64 + // CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上) + CategoryOptions []EditOption +} + +// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。 +func (p EditPlace) SourceTag() string { + if p.CoordSource == "" { + return "未留档" + } + l := SourceLabel(p.CoordSource) + if s := coordSysLabel(p.CoordSys); s != "" { + l += " · " + s + } + return l +} + +// HasSrcCoord 表示这行是否留下了「换算前的原始坐标」,模板据此决定要不要显示。 +func (p EditPlace) HasSrcCoord() bool { return p.SrcLat.Valid && p.SrcLon.Valid } + +// EditTransport 是交通方式。站点没有单独维护它——rond 的 ZTRANSPORT 是全局列表, +// 导出时由行程里用到的 transport_src 聚合而来,所以这里也从行程里读。 +type EditTransport struct { + SrcPK int + Name string + Color string + Icon string + Count int +} + +// Label 供下拉/列表显示:没有名称时退回编号,免得出现一个空选项。 +func (t EditTransport) Label() string { + if strings.TrimSpace(t.Name) == "" { + return fmt.Sprintf("方式 #%d", t.SrcPK) + } + return t.Name +} + +// EditMovement 是行程列表的一行。 +type EditMovement struct { + SrcPK int + TransportSrc int + TransportName string + TransportColor string + FromSrc int + ToSrc int + FromLabel string + ToLabel string + // 城市单独放一份:窄屏下这一列只有几十像素,城市名会把行高撑到别处的近两倍, + // 由 CSS 在窄屏隐藏(编号 + 地点名已经够认了) + FromCity string + ToCity string + // StartStr / EndStr 是表单用的 datetime-local 值;Span 是列表里显示用的短区间 + StartStr string + EndStr string + Span string + DurationMin int64 + DistanceKm float64 +} + +// movementSpan 把行程区间压成一行:同一天只写一次日期。 +// 列表在窄屏下每列只有几十像素,三行时间会把行高撑到别处的两倍。 +func movementSpan(start, end time.Time) string { + if start.Format("2006-01-02") == end.Format("2006-01-02") { + return start.Format("01-02 15:04") + " → " + end.Format("15:04") + } + return start.Format("01-02 15:04") + " → " + end.Format("01-02 15:04") +} + +type EditVisit struct { + SrcPK int + PlaceName string + City string + ActivityName string + ActivityID int64 + Arrival time.Time + ArrivalStr string + DepartureStr string + Remark string + Emoji string + Bookmarked bool + UserAdded bool +} + +type SettingsData struct { + Page + Modules []setting.Module + Pages []setting.Module + Saved bool + // MapInfo 是当前部署实际生效的底图说明,只读展示。 + MapInfo string + // MapLayers 是当前可选的底图图层,供「默认底图」下拉渲染。 + MapLayers []MapLayerOption + // S3Info 是对象存储配置状态的只读说明(配没配齐、桶名、公开域名)。 + S3Info string +} + +// MapLayerOption 是后台「默认底图」下拉的一项。 +type MapLayerOption struct { + Key string + Label string +} + +type PasswordData struct { + Page + // Username 是当前用户名,改名表单里回显用 + Username string +} + +// FogMaskData 是迷雾遮罩区管理页的数据。 +type FogMaskData struct { + Page + Masks []fog.Mask + MapInfo string +} + +// TwoFAData 是两步验证管理页的数据。 +type TwoFAData struct { + Page + // Enabled 表示两步验证已开启;Pending 表示已生成秘钥等待扫码确认 + Enabled bool + Pending bool + Secret string + // QR 是 otpauth 链接的 PNG 二维码 data URI + QR string + Err string + Flash string +} + +// TwoFALoginData 是登录第二步(输入验证码)页面的数据。 +type TwoFALoginData struct { + Settings setting.Settings + Pending string + Next string + Err string +} + +// ---------- 视图模型 ---------- + +type MapPoint struct { + ID int64 `json:"id"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Name string `json:"n"` + City string `json:"c,omitempty"` + Act string `json:"a,omitempty"` + Color string `json:"k,omitempty"` + Icon string `json:"i,omitempty"` + Count int `json:"v"` + Dwell int64 `json:"d"` + Home bool `json:"h,omitempty"` + Work bool `json:"w,omitempty"` + LastAt string `json:"t,omitempty"` + // First 是首次到访时间(Unix 秒),供地图的「时间轴回放」按时间累加显示点位; + // 没有到访记录的点为 0,时间轴一律把它们算作「一开始就在」。 + First int64 `json:"ft,omitempty"` + // SrcPK / Verdict / Up / Down 供地图气泡展示「推荐 / 踩雷」标记与票数 + SrcPK int `json:"sp,omitempty"` + Verdict int `json:"vd,omitempty"` + Up int `json:"u,omitempty"` + Down int `json:"dn,omitempty"` + // Note 是地点备注(访客是否可见由站点设置决定),气泡里直接展示 + Note string `json:"note,omitempty"` +} + +type MonthBar struct { + Key string + Label string + Count int + Pct int +} + +type CalWeek struct { + Days []CalDay + Month string +} + +type CalDay struct { + Date string + Day int + Count int + Level int + Empty bool + Title string +} + +// BuildCalendar 生成活跃日历(按周分列,周一起始)。 +func BuildCalendar(from, to time.Time, counts map[string]int) []CalWeek { + if from.IsZero() || to.IsZero() || to.Before(from) { + return nil + } + start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location()) + // 回退到周一 + off := (int(start.Weekday()) + 6) % 7 + start = start.AddDate(0, 0, -off) + end := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()) + + max := 0 + for _, c := range counts { + if c > max { + max = c + } + } + + var weeks []CalWeek + for cur := start; !cur.After(end); cur = cur.AddDate(0, 0, 7) { + w := CalWeek{} + for i := 0; i < 7; i++ { + d := cur.AddDate(0, 0, i) + key := d.Format("2006-01-02") + if d.Before(from) || d.After(end) { + w.Days = append(w.Days, CalDay{Empty: true}) + continue + } + n := counts[key] + w.Days = append(w.Days, CalDay{ + Date: key, + Day: d.Day(), + Count: n, + Level: levelOf(n, max), + Title: fmt.Sprintf("%s:%d 条记录", key, n), + }) + } + // 每列顶部标注当月,便于阅读 + mid := cur.AddDate(0, 0, 3) + w.Month = mid.Format("1月") + weeks = append(weeks, w) + } + // 若跨度超过 60 周,按月聚合展示 + if len(weeks) > 60 { + return weeks[:60] + } + return weeks +} + +func levelOf(n, max int) int { + if n <= 0 || max <= 0 { + return 0 + } + switch { + case n*4 <= max: + return 1 + case n*2 <= max: + return 2 + case n*4 <= max*3: + return 3 + default: + return 4 + } +} + +// BuildMonthly 把月度统计补齐成连续序列(无记录的月份补 0), +// 否则有空档的时间段会被压缩,看不出真实的节奏。 +func BuildMonthly(buckets []stats.Bucket) []MonthBar { + if len(buckets) == 0 { + return nil + } + byKey := make(map[string]int, len(buckets)) + for _, b := range buckets { + byKey[b.Key] = b.Count + } + start, err1 := time.Parse("2006-01", buckets[0].Key) + end, err2 := time.Parse("2006-01", buckets[len(buckets)-1].Key) + if err1 != nil || err2 != nil { + return nil + } + + // 跨度较大时按年标注,避免「8月」重复出现分不清年份 + multiYear := start.Year() != end.Year() + + max := 0 + for _, b := range buckets { + if b.Count > max { + max = b.Count + } + } + + var out []MonthBar + for cur := start; !cur.After(end); cur = cur.AddDate(0, 1, 0) { + key := cur.Format("2006-01") + n := byKey[key] + pct := 2 + if max > 0 { + pct = int(float64(n)/float64(max)*100 + 0.5) + } + if pct < 2 { + pct = 2 + } + label := fmt.Sprintf("%d月", int(cur.Month())) + if multiYear { + label = fmt.Sprintf("%d/%d", cur.Year()%100, int(cur.Month())) + } + out = append(out, MonthBar{Key: key, Label: label, Count: n, Pct: pct}) + } + return out +} + +// ---------- 模板函数 ---------- + +// maxOf 求切片中的最大计数值,用于条形图归一化。 +func maxOf[T any](in []T, get func(T) int) int { + m := 0 + for _, v := range in { + if n := get(v); n > m { + m = n + } + } + return m +} + +// appleColors 是 rond 使用的系统色名到十六进制的映射。 +var appleColors = map[string]string{ + "red": "#ff3b30", "orange": "#ff9500", "yellow": "#e8b700", "green": "#34c759", + "mint": "#00c7be", "teal": "#30b0c7", "cyan": "#32ade6", "blue": "#007aff", + "indigo": "#5856d6", "purple": "#af52de", "pink": "#ff2d55", "brown": "#a2845e", + "gray": "#8e8e93", +} + +// transportColorNames 返回 rond 的系统色名(固定顺序,不随机 map 遍历顺序)。 +func transportColorNames() []string { + out := make([]string, 0, len(appleColors)) + for n := range appleColors { + out = append(out, n) + } + sort.Strings(out) + return out +} + +func colorOf(name string) string { + if c, ok := appleColors[name]; ok { + return c + } + if len(name) == 7 && name[0] == '#' { + return name + } + return "#5b6169" +} + +var poiNames = map[string]string{ + "MKPOICategoryStore": "商店", "MKPOICategoryRestaurant": "餐厅", "MKPOICategoryCafe": "咖啡厅", + "MKPOICategoryPublicTransport": "公共交通", "MKPOICategoryHotel": "酒店", "MKPOICategoryLandmark": "地标", + "MKPOICategoryFoodMarket": "食品市场", "MKPOICategoryMuseum": "博物馆", "MKPOICategoryZoo": "动物园", + "MKPOICategoryBeach": "海滩", "MKPOICategoryCampground": "露营地", "MKPOICategoryStadium": "体育场", + "MKPOICategoryPark": "公园", "MKPOICategoryNationalPark": "国家公园", "MKPOICategorySchool": "学校", + "MKPOICategoryPharmacy": "药店", "MKPOICategoryHospital": "医院", "MKPOICategorySpa": "水疗", + "MKPOICategoryAirport": "机场", "MKPOICategoryMarina": "码头", "MKPOICategoryPostOffice": "邮局", + "MKPOICategoryMarina2": "码头", +} + +func poiName(s string) string { + if v, ok := poiNames[s]; ok { + return v + } + return s +} + +// iconOf 只保留 emoji 图标。rond 里的图标多为 SF Symbols 名称(如 house.fill), +// 在网页上无法渲染,直接返回空串让模板退回默认样式。 +func iconOf(s string) string { + for _, r := range s { + if r > 0x2000 { + return s + } + } + return "" +} + +func iconOr(s, fallback string) string { + if v := iconOf(s); v != "" { + return v + } + return fallback +} + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "num": fmtNum, + "size": fmtSize, + // dur 接受任意整数类型,模板里 int / int64 都会传入 + "dur": func(v any) string { + switch n := v.(type) { + case int64: + return fmtDur(n) + case int: + return fmtDur(int64(n)) + case int32: + return fmtDur(int64(n)) + case float64: + return fmtDur(int64(n)) + } + return "—" + }, + "km": func(f float64) string { return fmt.Sprintf("%.1f", f) }, + "f1": func(f float64) string { return fmt.Sprintf("%.1f", f) }, + "dt": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }, + // dtt 接受 *time.Time,nil 返回空串,便于可选时间字段直接渲染 + "dtt": func(t *time.Time) string { + if t == nil { + return "" + } + return t.Local().Format("2006-01-02 15:04") + }, + "d": func(t time.Time) string { return t.Local().Format("2006-01-02") }, + "hm": func(t time.Time) string { return t.Local().Format("15:04") }, + "md": func(t time.Time) string { return t.Local().Format("01-02") }, + "day": dayLabel, + "since": sinceLabel, + "trunc": func(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" + }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b float64) float64 { return a * b }, + "seqn": seqn, + "pct": func(a, b int) int { + if b == 0 { + return 0 + } + return int(float64(a)/float64(b)*100 + .5) + }, + "dict": dict, + "cond": func(ok bool, a, b string) string { + if ok { + return a + } + return b + }, + "safe": func(s string) template.HTML { return template.HTML(s) }, + "colorof": colorOf, + "poiname": poiName, + "icon": iconOf, + "iconor": iconOr, + "condition": stats.ConditionName, + "json": func(v any) template.JS { + b, err := json.Marshal(v) + if err != nil { + return template.JS("[]") + } + return template.JS(b) + }, + } +} + +func fmtSize(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1f GB", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + +func fmtNum(n int) string { + s := strconv.Itoa(n) + neg := strings.HasPrefix(s, "-") + if neg { + s = s[1:] + } + var out []string + for len(s) > 3 { + out = append([]string{s[len(s)-3:]}, out...) + s = s[:len(s)-3] + } + out = append([]string{s}, out...) + r := strings.Join(out, ",") + if neg { + return "-" + r + } + return r +} + +func fmtDur(minutes int64) string { + if minutes <= 0 { + return "—" + } + d := minutes / (60 * 24) + h := (minutes % (60 * 24)) / 60 + m := minutes % 60 + switch { + case d > 0 && h > 0: + return fmt.Sprintf("%d天%d小时", d, h) + case d > 0: + return fmt.Sprintf("%d天", d) + case h > 0 && m > 0: + return fmt.Sprintf("%d小时%d分", h, m) + case h > 0: + return fmt.Sprintf("%d小时", h) + default: + return fmt.Sprintf("%d分", m) + } +} + +var weekdayCN = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +func dayLabel(t time.Time) string { + lt := t.Local() + now := time.Now() + prefix := "" + switch lt.Format("2006-01-02") { + case now.Format("2006-01-02"): + prefix = "今天 · " + case now.AddDate(0, 0, -1).Format("2006-01-02"): + prefix = "昨天 · " + } + return fmt.Sprintf("%s%d年%d月%d日 %s", prefix, lt.Year(), lt.Month(), lt.Day(), weekdayCN[int(lt.Weekday())]) +} + +func sinceLabel(t time.Time) string { + d := time.Since(t) + if d < 0 { + d = 0 + } + days := int(d.Hours() / 24) + switch { + case days == 0: + return "今天" + case days == 1: + return "昨天" + case days < 30: + return fmt.Sprintf("%d 天前", days) + case days < 365: + return fmt.Sprintf("%d 个月前", days/30) + default: + return fmt.Sprintf("%d 年前", days/365) + } +} + +func seqn(n int) []int { + out := make([]int, 0, n) + for i := 0; i < n; i++ { + out = append(out, i) + } + return out +} + +func dict(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok { + m[k] = kv[i+1] + } + } + return m +} + +// ---------- 专题 ---------- + +// Topic 是一个行程专题:某段时间、某个区域内的足迹打包展示。 +type Topic struct { + ID int64 + Title string + Subtitle string + Description string + StartAt time.Time + EndAt time.Time + // StartDate / EndDate 是表单用的 YYYY-MM-DD 形式 + StartDate string + EndDate string + // RangeLabel 是展示用的时间范围文案 + RangeLabel string + CenterLat float64 + CenterLon float64 + RadiusKm float64 + ShowFog bool + Enabled bool + SortOrder int + // PlaceCount / VisitCount / DayCount / CityCount / DwellMin 由内容统计得出 + PlaceCount int + VisitCount int + DayCount int + CityCount int + DwellMin int64 +} + +// HasArea 报告专题是否限定了区域(中心点 + 半径)。 +func (t Topic) HasArea() bool { return t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) } + +// TopicAdminData 是后台专题管理页的数据。 +type TopicAdminData struct { + Page + Topics []Topic + Form Topic // 表单回填用 + Flash string + Err string + IsNew bool +} + +// TopicListData 是前台专题列表页的数据。 +type TopicListData struct { + Page + Topics []Topic +} + +// TopicData 是专题详情页的数据。 +type TopicData struct { + Page + Topic Topic + Points []MapPoint + IsAdmin bool + // PointsJSON / FitCircleJSON / FitBBoxJSON 内联给地图脚本 + // (template.JS 跳过转义,避免被二次转义搞坏)。两个 fit 只有一个非 null。 + PointsJSON template.JS + FitCircleJSON template.JS + FitBBoxJSON template.JS +} + +// trackTag 把配置里的追踪脚本转成可输出的 HTML。 +// +// 填 URL(http/https 开头,且不含尖括号)时包一层 `) + } + return template.HTML(v) +} + +// TempPoint 是逐月气温图上的一根柱子(含算好的百分比高度与配色), +// 具体数值与配色都在服务端算好,模板里只管摆。 +type TempPoint struct { + Key string + Label string + AvgC float64 + Hours int + Pct int + Color string +} + +// TempChart 把逐月平均气温整理成可直接渲染的柱子。 +// 高度不按 0℃ 起算——那样 -5℃ 到 35℃ 之间只有 12% 的差别,看不出季节变化; +// 这里按「本区间的最低温 ~ 最高温」铺满高度。 +func TempChart(bs []stats.TempBucket) []TempPoint { + if len(bs) == 0 { + return nil + } + lo, hi := bs[0].AvgC, bs[0].AvgC + for _, b := range bs { + if b.AvgC < lo { + lo = b.AvgC + } + if b.AvgC > hi { + hi = b.AvgC + } + } + span := hi - lo + out := make([]TempPoint, 0, len(bs)) + for _, b := range bs { + pct := 60 + if span > 0.05 { + pct = 12 + int((b.AvgC-lo)/span*88) + } + out = append(out, TempPoint{ + Key: b.Key, Label: b.Label, AvgC: b.AvgC, Hours: b.Hours, + Pct: pct, Color: tempColor(b.AvgC), + }) + } + return out +} + +// tempColor 把摄氏度映射到冷蓝(≤-5)→ 暖红(≥32)的色带。 +func tempColor(c float64) string { + switch { + case c <= -5: + return "#4b83c4" + case c <= 5: + return "#6fa8dc" + case c <= 15: + return "#69b8a8" + case c <= 22: + return "#8fbf5a" + case c <= 28: + return "#e2a33c" + default: + return "#d9614a" + } +} diff --git a/internal/web/visitor_pages_test.go b/internal/web/visitor_pages_test.go new file mode 100644 index 0000000..bbdef7a --- /dev/null +++ b/internal/web/visitor_pages_test.go @@ -0,0 +1,106 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +// withVisitorRegions 临时给「访客可见区域」配上一项,跑完还原。 +// +// 这类 bug 只在访客视角 + 配了区域时才犯(`visitorRegions` 对站长返回空), +// 站长自己点一遍是好的,所以必须专门造这个组合。 +func withVisitorRegions(t *testing.T, f *movementFixture) { + t.Helper() + var prev []byte + if err := f.pg.QueryRowContext(f.ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&prev); err != nil { + t.Fatalf("读站点设置失败: %v", err) + } + write := func(v []byte) { + t.Helper() + if _, err := f.pg.ExecContext(f.ctx, + `UPDATE settings SET value=$1::jsonb WHERE key='site'`, string(v)); err != nil { + t.Fatalf("写站点设置失败: %v", err) + } + } + write([]byte(`{"regions":["河池市"],"regions_mode":"blacklist"}`)) + t.Cleanup(func() { write(prev) }) +} + +// TestVisitorPagesWithRegionFilter 用**访客身份**把公开页面走一遍,只要求不 500。 +// +// 专为「某个查询漏了区域条件、或者参数与占位符对不上」准备:这类错误犯过两次—— +// 一次是 movements 的里程没跟着区域走,一次是专题的「天数」查询把上一条 SQL 的参数 +// 整个传进来(多了区域那几个),pgx 报 expected N arguments, got M; +// 而专题列表会把错误吞掉,表现成「专题内容全变 0、点进去才 500」。 +// 两次都只在访客视角复现,站长自己试是好的——所以这个组合必须专门测。 +func TestVisitorPagesWithRegionFilter(t *testing.T) { + f := newMovementFixture(t) + withVisitorRegions(t, f) + + var placeID, topicID int64 + if err := f.pg.QueryRowContext(f.ctx, `SELECT id FROM places WHERE dataset_id=$1 ORDER BY id LIMIT 1`, + f.dsID).Scan(&placeID); err != nil { + t.Fatalf("取地点失败: %v", err) + } + // 专题页正是这次踩坑的地方,没有现成的就建一个覆盖几天到访的 + if err := f.pg.QueryRowContext(f.ctx, `SELECT id FROM topics WHERE dataset_id=$1 ORDER BY id LIMIT 1`, + f.dsID).Scan(&topicID); err != nil { + if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO topics + (dataset_id, user_id, title, start_at, end_at, enabled) + VALUES ($1,$2,'访客冒烟','2020-01-01T00:00:00+08:00','2030-01-01T00:00:00+08:00',true) + RETURNING id`, f.dsID, userFrom(f.ctx).ID).Scan(&topicID); err != nil { + t.Fatalf("建测试专题失败: %v", err) + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM topics WHERE id=$1`, topicID) + }) + } + + pages := []struct { + name string + path string + handle http.HandlerFunc + param string // PathValue("id") 用真实 id 填充 + }{ + {"主页", "/", f.srv.home, ""}, + {"地点库", "/places", f.srv.placesPage, ""}, + {"地点详情", "/places/x", f.srv.placePage, strconv.FormatInt(placeID, 10)}, + {"统计", "/stats", f.srv.statsPage, ""}, + {"时间线", "/timeline", f.srv.timelinePage, ""}, + {"年度报告", "/report", f.srv.reportPage, ""}, + {"地图", "/map", f.srv.mapPage, ""}, + {"专题列表", "/topics", f.srv.topicsListPage, ""}, + {"专题详情", "/topics/x", f.srv.topicPage, strconv.FormatInt(topicID, 10)}, + {"点位接口", "/api/points", f.srv.apiPoints, ""}, + {"轨迹接口", "/api/track", f.srv.apiTrack, ""}, + } + for _, p := range pages { + t.Run(p.name, func(t *testing.T) { + req := httptest.NewRequest("GET", p.path, nil) + // 访客 = 上下文里没有登录态(withUser 中间件没跑) + req = req.WithContext(context.Background()) + if p.param != "" { + req.SetPathValue("id", p.param) + } + rec := httptest.NewRecorder() + p.handle(rec, req) + if rec.Code >= 500 { + t.Errorf("%s 访客访问返回 %d:%s", p.path, rec.Code, firstLine(rec.Body.String())) + } + }) + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 200 { + s = s[:200] + } + return s +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1981c02 --- /dev/null +++ b/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "rond-with-you/internal/auth" + "rond-with-you/internal/config" + "rond-with-you/internal/db" + "rond-with-you/internal/ingest" + "rond-with-you/internal/web" +) + +func main() { + confPath := flag.String("c", "conf/app.ini", "配置文件路径") + flag.Parse() + + cfg, err := config.Load(*confPath) + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + database, err := db.Open(cfg.DSN) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + defer database.Close() + + // 预建几条连接:PG 建连约 300ms,冷启动第一个页面会全吃掉 + db.WarmUp(database, 6) + if err := db.Migrate(context.Background(), database); err != nil { + log.Fatalf("初始化数据库失败: %v", err) + } + // 自愈历史留档:早期回填的判据把「站点导出时给新建地点合成的 ZLOCATION 行」 + // 当成了真机的原始 GPS,还误用了显示经度当原始经度。这里按 GCJ-02 往返关系重算。 + if err := ingest.BackfillAllCoordSource(context.Background(), database); err != nil { + log.Printf("坐标来源留档自愈失败(不影响启动): %v", err) + } + + adminUser := os.Getenv("ROND_ADMIN_USER") + if adminUser == "" { + adminUser = "admin" + } + adminPass := os.Getenv("ROND_ADMIN_PASSWORD") + if adminPass == "" { + adminPass = "rond-admin" + } + store := &auth.Store{DB: database, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays} + if created, err := store.EnsureDefaultUser(context.Background(), adminUser, adminPass); err != nil { + log.Fatalf("初始化管理员失败: %v", err) + } else if created { + log.Printf("已创建初始管理员:%s / %s(请登录后立即修改密码)", adminUser, adminPass) + } + + srv, err := web.New(cfg, database) + if err != nil { + log.Fatalf("初始化服务失败: %v", err) + } + + httpSrv := &http.Server{ + Addr: cfg.Listen, + Handler: srv.Handler(), + ReadHeaderTimeout: 15 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 90 * time.Second, + } + + go func() { + log.Printf("足迹站点已启动:http://localhost%s(后台 /admin)", cfg.Listen) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("服务异常退出: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + _ = httpSrv.Shutdown(ctx) + log.Println("已退出") +} diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100644 index 0000000..3ecc4ee --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +# 脚本位于 scripts/ 下,构建在仓库根目录进行 +cd "$(dirname "$0")/.." +mkdir -p dist +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/rond-linux-amd64 . +echo "已生成 dist/rond-linux-amd64" diff --git a/tools/__pycache__/photo_coord_audit.cpython-313.pyc b/tools/__pycache__/photo_coord_audit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d38a98f42d464f062d4ce4b414e183a733f23241 GIT binary patch literal 26042 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�!sgKh|5;yT32#^+Wum@F#C*U)#3Q zE&elX>U4ve1mYvon_=lRBj}a&#q||kPW8<%^BT#OYVYp7zN%WEvG&6Z%T0}SoL3U> z)aCsJ@7La#`JN4zy1h)5ZyUP8?A>ei)>^%L>bxt7FO%PD8a~y;cWiFo>`kBBxeb9w z>T&wg=ib!J`v=ew*C>%bPi`vG!**9oPAq{9*XJ2jT-N<580LRo`>9ITk<*^z-s4kc z!CzO9j1%0oo{fF+A1asi)6q$oN?a+rPf^NMqL1TJCnMeL*A;y;`pPfE^;TMEt}ivG zXGYIv-<&0VWqsDm>-x<%q}Ll@rkJ{w(1UgxM}=u$$SGCHeGNy1CGRKn?|g5{2Tm`u z>o<>it82U~9`UIjxhXM!tW9X6f3MI2C;Z>8RLmmCOF>B-VQGKa^aZCiz924B@Ypn(Aqi zU#H1PxJXl-CHsv`hVX+JRdtT!gQ+njT%f7WmHa1>4B>xXuu@jNKtc3iNq?UuCEs&ZyNl(-Jv3RuYih6=AbXh!0b-^mfA>ICrhFIBMYyI*rJj*+jyC1J$NR-a*_<5_yYY>mUIU33*>~L^Z(6NJJp-M!@X@5>-tC zGYN&$iw;5gUS0nJ{Qt0L0E9U&$oUlYj`%0mU-j<#s8%3LyBSoJ3HqA%zQ(V5q*Z=jCZQAEvrgrm$Qy_+^u-t6rx0;R zN5QNsuRU)-mF-hyd-nNN3kYliU2=Dxh%UOTN~L4&FG{52@8+e@o9MfRQhLGN*ep8r x?j~AIXWX5lptX0CHS`Yp?zCE(Hqv)CiDBc_cEm4E`$(dAdJRM^Njld#{|9" -f tools/audit_db.sql +-- 各项期望值均为 0;非 0 说明有漂移或悬空引用,逐项排查。 +-- 注意:Windows + Git Bash 下 psql 不能直接吃中文命令行参数(会被按 GBK 传出去), +-- 所以这个文件必须保存为 UTF-8 并用 -f 执行。 +\pset border 1 + +\echo '=== 1. 聚合统计漂移(应为 0) ===' +SELECT 'place.visit_count drift' AS chk, count(*) AS n + FROM places p + WHERE COALESCE(p.visit_count,0) <> (SELECT count(*) FROM visits v WHERE v.place_id=p.id) +UNION ALL +SELECT 'datasets.visit_count drift', count(*) + FROM datasets d + WHERE COALESCE(d.visit_count,0) <> (SELECT count(*) FROM visits v WHERE v.dataset_id=d.id) +UNION ALL +SELECT 'datasets.place_count drift', count(*) + FROM datasets d + WHERE COALESCE(d.place_count,0) <> (SELECT count(*) FROM places p WHERE p.dataset_id=d.id) +UNION ALL +SELECT 'duration_min wrong', count(*) + FROM visits WHERE departure IS NOT NULL + AND COALESCE(duration_min,-1) <> GREATEST(0, floor(extract(epoch FROM (departure-arrival))/60)::int) +UNION ALL +SELECT 'departure < arrival', count(*) + FROM visits WHERE departure IS NOT NULL AND departure < arrival; + +\echo '=== 2. 悬空引用(应为 0) ===' +SELECT 'visits -> other dataset place' AS chk, count(*) + FROM visits v JOIN places p ON p.id=v.place_id WHERE p.dataset_id<>v.dataset_id +UNION ALL +SELECT 'movements.from_place', count(*) + FROM movements m WHERE m.from_place_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM places p WHERE p.id=m.from_place_id) +UNION ALL +SELECT 'movements.to_place', count(*) + FROM movements m WHERE m.to_place_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM places p WHERE p.id=m.to_place_id) +UNION ALL +SELECT 'movements.from_visit_src', count(*) + FROM movements m WHERE m.from_visit_src IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.from_visit_src) +UNION ALL +SELECT 'movements.to_visit_src', count(*) + FROM movements m WHERE m.to_visit_src IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.to_visit_src) +UNION ALL +SELECT 'weather.visit_src', count(*) + FROM weather w WHERE w.visit_src IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src) +UNION ALL +SELECT 'visit_tags dangling', count(*) + FROM visit_tags vt + WHERE NOT EXISTS (SELECT 1 FROM visits v WHERE v.id=vt.visit_id) + OR NOT EXISTS (SELECT 1 FROM tags t WHERE t.id=vt.tag_id) +UNION ALL +SELECT 'visit_tags cross-dataset', count(*) + FROM visit_tags vt + JOIN visits v ON v.id=vt.visit_id JOIN tags t ON t.id=vt.tag_id + WHERE v.dataset_id<>t.dataset_id +UNION ALL +SELECT 'movements end0 即跨数据集错位) ===' +SELECT 'notes not in active dataset' AS chk, count(*) FROM place_notes n + WHERE NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=(SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1) AND p.src_pk=n.src_pk) +UNION ALL +SELECT 'votes not in active dataset', count(*) + FROM place_votes v + WHERE NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=(SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1) AND p.src_pk=v.src_pk); + +\echo '=== 5. 数据特征(非缺陷,仅供了解) ===' +SELECT 'places 同名同坐标重复组数' AS item, count(*)::text AS v FROM ( + SELECT 1 FROM places GROUP BY name, round(lat::numeric,5), round(lon::numeric,5) HAVING count(*)>1) t +UNION ALL +SELECT 'visits 零时长', count(*)::text FROM visits WHERE duration_min=0 +UNION ALL +SELECT 'visits 超 30 天', count(*)::text FROM visits WHERE duration_min>43200 +UNION ALL +SELECT 'visits 无活动', count(*)::text FROM visits WHERE activity_id IS NULL +UNION ALL +SELECT 'places 无类别', count(*)::text FROM places WHERE COALESCE(poi_category,'')='' +UNION ALL +SELECT 'places 无坐标', count(*)::text FROM places WHERE COALESCE(lat,0)=0 AND COALESCE(lon,0)=0; diff --git a/tools/photo_coord_audit.py b/tools/photo_coord_audit.py new file mode 100644 index 0000000..7295e24 --- /dev/null +++ b/tools/photo_coord_audit.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""照片导入的坐标体检与批量换算。 + +为什么需要它:导入页把「用户选的坐标系」交给 /api/regeo(coord 字段),服务端只对 wgs84 +做换算,坐标再 toFixed(6) 回填表单落库。选错一次,显示坐标就一字不差等于 EXIF 原值,在 +GCJ-02 底图上偏 350~680m。库里没有 created_at,也没记录导入时的坐标系选择,所以判据只有 +原图 EXIF: + + 库内坐标 == round(EXIF, 6) -> 没转换,需要修 + 库内坐标 == gcj(round(EXIF, 6)) -> 已转换,正确 + (实测 24/24 命中,0 反例) + +没有原图时退化为「sp > rond 基线上限 = 站点新建」,只能靠 --include-unverified 才动。 + +用法: + python tools/photo_coord_audit.py --site https://life.shiroko.one --cookie "uid|tok" \ + --photos D:/photos/libo --photos D:/photos/foshan --out fix.csv + # 确认清单后加 --apply +""" +import argparse +import csv +import html as htmllib +import io +import json +import math +import os +import re +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +# 生产机只监听 IPv4:默认解析出 IPv6 会先撞在超时上,而 urllib 不会自动回退 +_gai = socket.getaddrinfo + + +def _gai_prefer_v4(host, port, family=0, type=0, proto=0, flags=0): + infos = _gai(host, port, family, type, proto, flags) + v4 = [i for i in infos if i[0] == socket.AF_INET] + return v4 or infos + + +socket.getaddrinfo = _gai_prefer_v4 + +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) rond-coord-audit" +FORM_RE = re.compile(r'
', re.S) +ORIG_RE = re.compile(r'导入时的坐标:([-0-9.]+),\s*([-0-9.]+)') + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + # 保存成功是 303 + Location 里带 ok=/err=,跟过去就看不到结论了; + # 会话失效也是 303 跳登录页,跟着走会静默拿到登录页 HTML 当成空数据 + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_opener = urllib.request.build_opener(_NoRedirect) + + +def http(url, cookie="", data=None, timeout=60, tries=3): + # 走 Cloudflare 时长页面偶发读超时,重试比直接失败划算;写库的重试由调用方控 + last = None + for i in range(tries): + req = urllib.request.Request(url, data=data) + req.add_header("User-Agent", UA) + if cookie: + req.add_header("Cookie", "rond_session=" + cookie) + if data is not None: + req.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + with _opener.open(req, timeout=timeout) as r: + return r.status, dict(r.headers), r.read() + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), e.read() + except Exception as e: # 超时 / 连接被掐 + last = e + if i + 1 < tries: + time.sleep(1.5 * (i + 1)) + raise RuntimeError("请求失败 %s:%s" % (url, last)) + + +def field(block, name): + m = re.search(r'name="%s"[^>]*?value="([^"]*)"' % re.escape(name), block) + return htmllib.unescape(m.group(1)) if m else "" + + +def parse_places(page_html): + out = [] + for block in FORM_RE.findall(page_html): + src = field(block, "src_pk") + if not src.isdigit(): + continue + cat = "" + sel = re.search(r'