first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
+23
View File
@@ -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
+25
View File
@@ -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/
+311
View File
@@ -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`)。**登录后请立即在后台修改密码。**
打开 <http://localhost:8080>,进入 `/admin` 上传 `.rondbackup` 文件即可。
`/healthz` 是给探活用的轻量健康检查。
## 备份解析说明
`.rondbackup` 本质是一个 zip,内含 Core Data 的 SQLite 库 `LifeEasy.sqlite` 以及同名 `-wal` / `-shm`。
**数据绝大部分写在前端 WAL 里**,因此解析时三个文件必须一起解出再打开,否则会丢掉几乎全部记录。
导入时会做这些转换:
| 来源表 | 内容 | 落到 |
| --- | --- | --- |
| `ZVISIT` | 到访(到达 / 离开、类型、地点、备注、是否收藏) | `visits` |
| `ZLOCATION` | 地点(名称、经纬度、城市、省份、POI 分类) | `places` |
| `ZACTIVITY` | 自定义类型(颜色、图标、是否「家 / 工作」) | `activities` |
| `ZTAG` / `Z_11VISITS_` | 标签与访问标签关联 | `tags` / `visit_tags` |
| `ZMOVEMENT` | 位移段(交通方式、起止时间) | `movements`,里程按 haversine 估算 |
| `ZHOURLYWEATHER` | 逐小时天气 | `weather` |
其他处理:
- Core Data 时间戳以 2001-01-01 UTC 为基准,解析时统一转为标准时间
- 到达 / 离开时间恰好缺失的空记录会被跳过
- 一次上传 = 一份**完整快照**(一个数据集),页面始终只展示其中一份
- 两道判重:同一个文件按 SHA-256 直接复用已有数据集;内容相同但文件字节不同的
(rond 每次重新导出,WAL 与时间戳都会变)按**内容指纹**识别,同样不会新建快照
- 新导入的快照自动接管展示,旧快照留在后台可以随时切回或删除;删除数据集会连同其下
全部记录一并移除
- **每一行的原始记录都留着**(`entity_raw` 表)。这是导出能还原的前提:站点的管理列由站点
覆盖,其余列一律按导入时那一行的原值写回,不会因为中间改过备注就丢掉 rond 自己写的字段
- **回导**:后台可导出修改过的 `.rondbackup`(含补录的记录,坐标为 GCJ-02 原样写回),
可直接导回 rond App。导出走「基线库 + 覆盖」,不重建库
## 照片导入(补录没有轨迹的时间段)
手机里的照片带着拍摄时间和 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 一类库)
+92
View File
@@ -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:[email protected]: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 域名),拼上对象名就是 <img src>
#
# s3_public_base 写完整地址最好;只写裸域名也行(程序会自动补 https://),
# 但不填就取不到图——R2 桶默认私有,没有公开域名浏览器拿不到对象。
s3_endpoint =
s3_region = auto
s3_bucket =
s3_access_key =
s3_secret_key =
s3_public_base =
# ---------- 第三方统计 / 追踪脚本(可选) ----------
# 填 URL(http/https 开头)会自动渲染成 <script defer src="..."></script>,
# 例如 Umami 的 https://umami.example.com/script.js
# 需要附加属性或初始化代码的服务(百度统计、Plausible 等),可以把整段 <script>…</script> 贴进来。
# 只在公开页面生效,后台与登录页不会挂这个脚本。
track_script =
# 打开后会打印每个请求的日志(含超过 30ms 的慢查询)
debug = false
# ---------- 环境变量(不在本文件里) ----------
# 首次启动会用它们创建管理员账号,之后改用户名/密码请到后台「账号设置」:
# ROND_ADMIN_USER 管理员用户名,默认 admin
# ROND_ADMIN_PASSWORD 管理员密码,默认 rond-admin(务必登录后立即修改)
+25
View File
@@ -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
+28
View File
@@ -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
)
+77
View File
@@ -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=
+208
View File
@@ -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
}
+76
View File
@@ -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)
}
}
+159
View File
@@ -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
}
+265
View File
@@ -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<<uint(glyphW-1-c)) == 0 {
continue
}
for dy := 0; dy < scale; dy++ {
for dx := 0; dx < scale; dx++ {
img.Set(ox+c*scale+dx, oy+row*scale+dy, col)
}
}
}
}
}
// 压在最上面的噪点少一些,别把字盖住
for i := 0; i < 60; i++ {
img.Set(mrand.Intn(Width), mrand.Intn(Height), rgb(120, 200))
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// rgb 返回每个通道取 [lo, hi) 的随机颜色。
func rgb(lo, hi int) color.RGBA {
f := func() uint8 { return uint8(lo + mrand.Intn(hi-lo)) }
return color.RGBA{f(), f(), f(), 255}
}
// drawLine 是 Bresenham 画线(只用标准库,不引画图包)。
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
dx, dy := abs(x1-x0), -abs(y1-y0)
sx, sy := -1, -1
if x0 < x1 {
sx = 1
}
if y0 < y1 {
sy = 1
}
err := dx + dy
for {
img.Set(x0, y0, c)
if x0 == x1 && y0 == y1 {
return
}
e2 := 2 * err
if e2 >= 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},
}
+179
View File
@@ -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<<uint(glyphW-1-c)) != 0
if !on {
continue
}
// 只看笔画中心,避开边缘被抖动与噪点糊掉的部分
x := baseX + dx + c*scale + scale/2
y := padY + dy + row*scale + scale/2
if x < 0 || y < 0 || x >= 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()
}
+157
View File
@@ -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 会包成 <script src>;
// 需要附加属性或初始化代码的服务(百度统计、Umami 等)可以把整段 <script> 贴进来。
TrackScript string
// 地点图片的对象存储(S3 兼容,Cloudflare R2 等)。存哪边由后台「图片存储」
// 设置决定,凭据只留在服务端、不进数据库也不下发前端。全部留空 = 只能用本地磁盘。
S3Endpoint string // R2 形如 https://<account>.r2.cloudflarestorage.com
S3Region string // R2 填 auto
S3Bucket string
S3AccessKey string
S3SecretKey string
S3PublicBase string // 公开访问前缀(自定义域名或 r2.dev),拼对象 key 即为 <img src>
}
// S3Ready 判断对象存储是否配置齐全(缺一不可,否则只能在本地存)。
func (c *Config) S3Ready() bool {
return c.S3Endpoint != "" && c.S3Bucket != "" && c.S3AccessKey != "" && c.S3SecretKey != ""
}
// MediaDir 是本地图片的落盘目录。
func (c *Config) MediaDir() string { return filepath.Join(c.DataDir, "media") }
func Load(path string) (*Config, error) {
kv := map[string]string{}
f, err := os.Open(path)
if err == nil {
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
kv[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("读取配置 %s: %w", path, err)
}
cfg := &Config{
Listen: get(kv, "listen", ":8080"),
DSN: get(kv, "dsn", ""),
SecretKey: get(kv, "secret_key", ""),
UploadDir: get(kv, "upload_dir", "storage/uploads"),
DataDir: get(kv, "data_dir", "storage/data"),
SessionDays: getInt(kv, "session_days", 14),
Debug: get(kv, "debug", "false") == "true",
MapProvider: strings.ToLower(get(kv, "map_provider", "amap")),
MapKey: get(kv, "map_key", ""),
MapTileURL: get(kv, "map_tile_url", ""),
MapCRS: strings.ToLower(get(kv, "map_crs", "gcj02")),
GeocodeKey: get(kv, "geocode_key", ""),
TrackScript: strings.TrimSpace(get(kv, "track_script", "")),
S3Endpoint: strings.TrimRight(get(kv, "s3_endpoint", ""), "/"),
S3Region: get(kv, "s3_region", "auto"),
S3Bucket: get(kv, "s3_bucket", ""),
S3AccessKey: get(kv, "s3_access_key", ""),
S3SecretKey: get(kv, "s3_secret_key", ""),
S3PublicBase: normalizeBaseURL(get(kv, "s3_public_base", "")),
}
if cfg.DSN == "" {
return nil, fmt.Errorf("配置缺少 dsn(PostgreSQL 连接串)")
}
if cfg.MapProvider != "amap" && cfg.MapProvider != "tianditu" && cfg.MapProvider != "custom" {
return nil, fmt.Errorf("map_provider 只支持 amap / tianditu / custom,当前为 %q", cfg.MapProvider)
}
if cfg.MapProvider == "tianditu" && cfg.MapKey == "" {
return nil, fmt.Errorf("map_provider = tianditu 时必须填写 map_key(天地图密钥)")
}
if cfg.MapProvider == "custom" && cfg.MapTileURL == "" {
return nil, fmt.Errorf("map_provider = custom 时必须填写 map_tile_url")
}
if cfg.MapCRS != "gcj02" && cfg.MapCRS != "wgs84" {
return nil, fmt.Errorf("map_crs 只支持 gcj02 / wgs84,当前为 %q", cfg.MapCRS)
}
if cfg.SecretKey == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
cfg.SecretKey = hex.EncodeToString(b)
}
for _, d := range []string{cfg.UploadDir, cfg.DataDir, cfg.MediaDir()} {
if err := os.MkdirAll(filepath.Clean(d), 0o755); err != nil {
return nil, fmt.Errorf("创建目录 %s: %w", d, err)
}
}
return cfg, nil
}
// normalizeBaseURL 把「域名」补成完整 URL。
// s3_public_base 是直接拼进 <img src> 的,写成裸域名(r2.example.com)会得到相对地址,
// 浏览器把当前页面的路径拼上去,图全变破图——这种写法太容易发生了,这里统一补 https。
func normalizeBaseURL(v string) string {
v = strings.TrimRight(strings.TrimSpace(v), "/")
if v == "" || strings.Contains(v, "://") {
return v
}
return "https://" + v
}
func get(kv map[string]string, key, def string) string {
if v, ok := kv[key]; ok && v != "" {
return v
}
return def
}
func getInt(kv map[string]string, key string, def int) int {
if v, ok := kv[key]; ok && v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"os"
"regexp"
"strings"
"testing"
)
// TestExampleConfigDocumentsEveryKey 守住「样例配置与代码不脱节」。
//
// app.example.ini 是用户唯一能看到的配置说明:里面没有的键,等于不存在。
// 反过来说,样例里写了代码不读的键(曾经有个 base_url)同样是坑——填了没反应,
// 用户只会怀疑自己写错了。两个方向都检查。
func TestExampleConfigDocumentsEveryKey(t *testing.T) {
src, err := os.ReadFile("config.go")
if err != nil {
t.Fatalf("读 config.go: %v", err)
}
// 以真正的读取点为准:get(kv, "...") / getInt(kv, "...")
// 注意键名要允许数字(s3_endpoint 这类),写成 [a-z_]+ 会把它们整批漏掉,
// 于是「样例里写了但代码不读」这种坑就查不出来了。
re := regexp.MustCompile(`get(?:Int)?\(kv,\s*"([a-z0-9_]+)"`)
used := map[string]bool{}
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
used[m[1]] = true
}
if len(used) < 10 {
t.Fatalf("只从 config.go 扫到 %d 个配置键,抽取规则可能已失效", len(used))
}
ex, err := os.ReadFile("../../conf/app.example.ini")
if err != nil {
t.Fatalf("读 app.example.ini: %v", err)
}
documented := map[string]bool{}
for _, line := range strings.Split(string(ex), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
k, _, ok := strings.Cut(line, "=")
if !ok {
continue
}
documented[strings.TrimSpace(k)] = true
}
for k := range used {
if !documented[k] {
t.Errorf("app.example.ini 缺少配置项 %s(代码会读它)", k)
}
}
for k := range documented {
if !used[k] {
t.Errorf("app.example.ini 里的 %s 代码并不读取,要么补上实现要么删掉", k)
}
}
}
// TestExampleConfigLoads 用示例配置真跑一遍解析,确认它开箱可用。
// dsn 是样例里的占位串,解析阶段不会去连库,所以这里能过。
func TestExampleConfigLoads(t *testing.T) {
cfg, err := Load("../../conf/app.example.ini")
if err != nil {
t.Fatalf("示例配置解析失败: %v", err)
}
// 挑几个「文档里承诺过」的默认值:改了代码或改了文档,这里都会响
if cfg.Listen != ":8080" {
t.Errorf("listen 默认值变了: %q", cfg.Listen)
}
if cfg.SessionDays != 14 {
t.Errorf("session_days 默认值变了: %d", cfg.SessionDays)
}
if cfg.MapProvider != "amap" || cfg.MapCRS != "gcj02" {
t.Errorf("底图默认值变了: provider=%q crs=%q", cfg.MapProvider, cfg.MapCRS)
}
if cfg.S3Region != "auto" || cfg.S3Ready() {
t.Errorf("对象存储默认应当是「未配置」且 region=auto,实际 ready=%v region=%q",
cfg.S3Ready(), cfg.S3Region)
}
if cfg.Debug {
t.Error("示例配置里 debug 应当是关的")
}
if cfg.SecretKey == "" {
t.Error("secret_key 留空时应自动生成,不该是空串")
}
}
+153
View File
@@ -0,0 +1,153 @@
package db
import (
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed schema.sql
var schemaSQL string
// WarmUp 在后台把连接先建好放进池里。
//
// 建连接是这套系统里最贵的一步(见上面 SetMaxIdleConns 的注释),冷启动时
// 第一个页面会把这笔开销全吃掉;并发建 n 条(不是串行)只需约一次建连的时间。
// 失败就放弃——真实的报错由后续请求自己暴露,这里不必打扰启动流程。
func WarmUp(d *sql.DB, n int) {
for i := 0; i < n; i++ {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
conn, err := d.Conn(ctx)
if err != nil {
return
}
conn.Close() // 归还到池里,之后就是热连接
}()
}
}
func Open(dsn string) (*sql.DB, error) {
d, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
// 空闲连接数必须与上限相同:PostgreSQL 建连接的开销远大于查询本身
// (本机实测建连 ~300ms、查询 ~2ms,psql 也一样)。池里留不住连接时,
// 每个并发请求都要重新建连,页面耗时反而比串行还高。
d.SetMaxOpenConns(8)
d.SetMaxIdleConns(8)
d.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = d.PingContext(ctx)
if err == nil {
return d, nil
}
// 配置文件里写了一个还不存在的库:先用维护库把它建出来再连一次。
// 只在「库不存在」时兜底——口令错、端口不通这些要原样报出来,
// 否则连错服务器也会被当成「建个新的」。
if !isDatabaseMissing(err) {
d.Close()
return nil, fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
if cerr := createDatabase(ctx, dsn); cerr != nil {
d.Close()
return nil, fmt.Errorf("数据库不存在,自动创建失败: %w", cerr)
}
if err := d.PingContext(ctx); err != nil {
d.Close()
return nil, fmt.Errorf("连接 PostgreSQL 失败(库已创建): %w", err)
}
return d, nil
}
// isDatabaseMissing 判断错误是不是「目标库不存在」(SQLSTATE 3D000)。
// 驱动有时只给文本,所以再按消息兜一层。
func isDatabaseMissing(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "3D000" {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "database") && strings.Contains(msg, "does not exist")
}
// createDatabase 连到维护库把 dsn 里指定的库建出来。
// 库名走 pgx.Identifier 转义,配置里写什么名字都不会拼坏语句。
func createDatabase(ctx context.Context, dsn string) error {
cc, err := pgx.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("解析 dsn 失败: %w", err)
}
name := cc.Database
if name == "" {
return errors.New("dsn 里没有指定库名")
}
// 维护库优先 postgres,个别部署会删掉它,那就退回 template1
var lastErr error
for _, maint := range []string{"postgres", "template1"} {
mc, err := pgx.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("解析 dsn 失败: %w", err)
}
mc.Database = maint
conn, err := pgx.ConnectConfig(ctx, mc)
if err != nil {
lastErr = err
continue
}
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize())
conn.Close(ctx)
if err != nil {
// 并发启动时可能已经被另一个实例建好了,那就算成功
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return fmt.Errorf("%w(当前账号可能没有 CREATEDB 权限)", err)
}
log.Printf("数据库 %s 不存在,已自动创建", name)
return nil
}
return fmt.Errorf("连不上维护库(postgres / template1): %w", lastErr)
}
func Migrate(ctx context.Context, d *sql.DB) error {
if _, err := d.ExecContext(ctx, schemaSQL); err != nil {
return fmt.Errorf("初始化表结构失败: %w", err)
}
return nil
}
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
s := err.Error()
return contains(s, "SQLSTATE 23505") || contains(s, "duplicate key value")
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
+111
View File
@@ -0,0 +1,111 @@
package db
import (
"context"
"fmt"
"net/url"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
// TestOpenCreatesMissingDatabase 验证「配置里写了不存在的库,启动时自动建出来」。
// 需要一个可连的库:用 ROND_DSN 指过来(会临时建一个库并在结束时删掉),未设置时跳过。
func TestOpenCreatesMissingDatabase(t *testing.T) {
dsn := os.Getenv("ROND_DSN")
if dsn == "" {
t.Skip("设置 ROND_DSN 后运行自动建库检查")
}
name := fmt.Sprintf("rond_test_autocreate_%d", time.Now().UnixNano())
u, err := url.Parse(dsn)
if err != nil {
t.Fatalf("解析 ROND_DSN: %v", err)
}
u.Path = "/" + name
target := u.String()
d, err := Open(target)
if err != nil {
t.Fatalf("库不存在时应自动建出来再连,实际报错: %v", err)
}
defer func() {
d.Close()
// DROP DATABASE 要求没有连接挂着,所以先关掉上面的句柄
dropDatabase(t, dsn, name)
}()
// 库真的建出来了
var n int
if err := d.QueryRow(`SELECT count(*) FROM pg_database WHERE datname=$1`, name).Scan(&n); err != nil {
t.Fatalf("查 pg_database: %v", err)
}
if n != 1 {
t.Fatalf("库 %s 未被创建(匹配到 %d 条)", name, n)
}
// 建表也能正常跑(否则「建了个空库」没有意义)
if err := Migrate(context.Background(), d); err != nil {
t.Fatalf("初始化表结构: %v", err)
}
var tables int
if err := d.QueryRow(`SELECT count(*) FROM information_schema.tables WHERE table_schema='public'`).Scan(&tables); err != nil {
t.Fatal(err)
}
if tables < 10 {
t.Errorf("表结构不完整,只有 %d 张表", tables)
}
}
// TestOpenExistingDatabase 守住常规路径:库已存在时不该多做事(也不该报错)。
func TestOpenExistingDatabase(t *testing.T) {
dsn := os.Getenv("ROND_DSN")
if dsn == "" {
t.Skip("设置 ROND_DSN 后运行连接检查")
}
d, err := Open(dsn)
if err != nil {
t.Fatalf("连接既有库失败: %v", err)
}
d.Close()
}
// TestDatabaseMissingDetection 确认只有「库不存在」会被当成需要建库的信号,
// 否则连错服务器、口令错也会被当成「那就建个新的」。
func TestDatabaseMissingDetection(t *testing.T) {
if isDatabaseMissing(nil) {
t.Error("nil 不该判为库不存在")
}
if !isDatabaseMissing(fmt.Errorf(`failed to connect: database "rond_x" does not exist (SQLSTATE 3D000)`)) {
t.Error("库不存在的错误应被识别")
}
for _, msg := range []string{
"failed SASL auth: 用户 \"postgres\" Password 认证失败 (SQLSTATE 28P01)",
"dial tcp 127.0.0.1:5499: connectex: No connection could be made",
} {
if isDatabaseMissing(fmt.Errorf("%s", msg)) {
t.Errorf("不该判为库不存在: %s", msg)
}
}
}
func dropDatabase(t *testing.T, dsn, name string) {
t.Helper()
cc, err := pgx.ParseConfig(dsn)
if err != nil {
t.Logf("清理时解析 dsn 失败: %v", err)
return
}
cc.Database = "postgres"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := pgx.ConnectConfig(ctx, cc)
if err != nil {
t.Logf("清理时连维护库失败: %v", err)
return
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, "DROP DATABASE IF EXISTS "+pgx.Identifier{name}.Sanitize()); err != nil {
t.Logf("删除测试库失败: %v", err)
}
}
+414
View File
@@ -0,0 +1,414 @@
-- rond-with-you 数据结构
-- 一次上传 = 一个 dataset,所有事实表挂在其下,支持多份备份共存与切换
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS datasets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
sha256 CHAR(64) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source_created_at TIMESTAMPTZ,
device_name TEXT,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'done',
error TEXT,
visit_count INT NOT NULL DEFAULT 0,
place_count INT NOT NULL DEFAULT 0,
raw_visit_count INT NOT NULL DEFAULT 0,
first_visit_at TIMESTAMPTZ,
last_visit_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_datasets_user ON datasets(user_id, uploaded_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_datasets_sha ON datasets(user_id, sha256);
CREATE TABLE IF NOT EXISTS activities (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
is_home BOOLEAN NOT NULL DEFAULT FALSE,
is_work BOOLEAN NOT NULL DEFAULT FALSE,
is_excluded BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (dataset_id, src_pk)
);
CREATE TABLE IF NOT EXISTS tags (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
name TEXT NOT NULL,
color TEXT,
group_name TEXT,
UNIQUE (dataset_id, src_pk)
);
CREATE TABLE IF NOT EXISTS places (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
name TEXT,
poi_category TEXT,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
country_code TEXT,
province TEXT,
city TEXT,
district TEXT,
sublocality TEXT,
thoroughfare TEXT,
timezone TEXT,
visit_count INT NOT NULL DEFAULT 0,
dwell_minutes BIGINT NOT NULL DEFAULT 0,
first_visit_at TIMESTAMPTZ,
last_visit_at TIMESTAMPTZ,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_places_dataset ON places(dataset_id);
CREATE INDEX IF NOT EXISTS idx_places_city ON places(dataset_id, city);
CREATE INDEX IF NOT EXISTS idx_places_geo ON places(dataset_id, lat, lon);
CREATE TABLE IF NOT EXISTS visits (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
place_id BIGINT REFERENCES places(id) ON DELETE CASCADE,
activity_id BIGINT REFERENCES activities(id) ON DELETE SET NULL,
arrival TIMESTAMPTZ NOT NULL,
departure TIMESTAMPTZ,
duration_min INT,
is_home BOOLEAN NOT NULL DEFAULT FALSE,
is_work BOOLEAN NOT NULL DEFAULT FALSE,
bookmarked BOOLEAN NOT NULL DEFAULT FALSE,
is_user_added BOOLEAN NOT NULL DEFAULT FALSE,
remark TEXT,
emoji TEXT,
weather_symbol TEXT,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_visits_arrival ON visits(dataset_id, arrival DESC);
CREATE INDEX IF NOT EXISTS idx_visits_place ON visits(dataset_id, place_id);
CREATE INDEX IF NOT EXISTS idx_visits_activity ON visits(dataset_id, activity_id);
CREATE TABLE IF NOT EXISTS visit_tags (
visit_id BIGINT NOT NULL REFERENCES visits(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (visit_id, tag_id)
);
CREATE TABLE IF NOT EXISTS movements (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
user_id BIGINT,
src_pk INT NOT NULL,
transport_src INT,
transport_name TEXT,
transport_color TEXT,
transport_icon TEXT,
from_visit_src INT,
to_visit_src INT,
from_place_id BIGINT REFERENCES places(id) ON DELETE SET NULL,
to_place_id BIGINT REFERENCES places(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_min INT,
distance_km DOUBLE PRECISION,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_movements_start ON movements(dataset_id, started_at DESC);
CREATE TABLE IF NOT EXISTS weather (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
user_id BIGINT,
src_pk INT NOT NULL,
visit_src INT,
at TIMESTAMPTZ,
is_daylight BOOLEAN,
temperature_c DOUBLE PRECISION,
apparent_c DOUBLE PRECISION,
humidity DOUBLE PRECISION,
precipitation_amount DOUBLE PRECISION,
precipitation_chance DOUBLE PRECISION,
wind_speed DOUBLE PRECISION,
visibility DOUBLE PRECISION,
uv_index INT,
condition TEXT,
symbol TEXT,
uv_category TEXT,
wind_direction TEXT,
UNIQUE (dataset_id, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_weather_at ON weather(dataset_id, at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 世界迷雾(Fog of World)已探索位图,按块粒度存储。
-- 迷雾与数据集无关(来自另一个应用),挂在用户维度下;
-- gbx/gby 为全球块坐标(0..65535),一个位对应 2^22 网格里约 9.55m 的一格。
CREATE TABLE IF NOT EXISTS fog_blocks (
user_id BIGINT NOT NULL,
gbx INT NOT NULL,
gby INT NOT NULL,
bitmap BYTEA NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, gbx, gby)
);
-- 地点备注与别名。按 src_pk(rond 内部地点 ID,跨快照稳定)而不是 places.id 存,
-- 这样重新上传备份、数据集换了新 id 之后备注依然挂着。
-- dataset_id 是后加的:src_pk 只在单个数据集内唯一,缺了它切数据集时同编号的
-- 地点会把别人的备注/别名认领过去。
CREATE TABLE IF NOT EXISTS place_notes (
user_id BIGINT NOT NULL,
dataset_id BIGINT NOT NULL DEFAULT 0,
src_pk INT NOT NULL,
alias TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, dataset_id, src_pk)
);
-- 站长对地点的「官方结论」:推荐(1) / 踩雷(-1),与自由文本备注分开存,同样按 src_pk。
CREATE TABLE IF NOT EXISTS place_votes (
user_id BIGINT NOT NULL,
dataset_id BIGINT NOT NULL DEFAULT 0,
src_pk INT NOT NULL,
verdict SMALLINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, dataset_id, src_pk)
);
-- 访客对地点的投票:一人一票、可改可撤。voter_key 由匿名 cookie + IP 派生,
-- 与数据集无关(换快照不丢),故不挂 dataset_id。
CREATE TABLE IF NOT EXISTS place_visitor_votes (
src_pk INT NOT NULL,
voter_key TEXT NOT NULL,
vote SMALLINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (src_pk, voter_key)
);
CREATE INDEX IF NOT EXISTS idx_pvv_src ON place_visitor_votes(src_pk);
-- GPX 轨迹:rond 只记录「地点到地点」的直线位移,导入 GPX 可以把真实路径补上。
-- 坐标统一按 WGS-84 存(GPX 标准),展示时再按底图坐标系转换。
CREATE TABLE IF NOT EXISTS gpx_tracks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL,
point_count INT NOT NULL DEFAULT 0,
length_km DOUBLE PRECISION NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
points JSONB NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_gpx_user ON gpx_tracks(user_id, uploaded_at DESC);
-- ---------- 增量迁移 ----------
-- 数据集是「快照」语义:一次上传 = 一份完整数据集,页面始终只展示其中一份。
-- 因此判重不做行级去重,而是「文件摘要 + 内容指纹」两道,见 datasets.content_hash。
-- 这里只给事实表补上 user_id(分区键,也为将来多用户留位)。
ALTER TABLE visits ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE movements ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE weather ADD COLUMN IF NOT EXISTS user_id BIGINT;
-- 回填失败绝不能拖垮启动:老库若已存在重复行,唯一索引会让回填直接报错。
-- 这里逐条兜住,最坏情况只是这些历史行保持 NULL,站点照常启动。
DO $mig$
BEGIN
BEGIN
UPDATE visits SET user_id = d.user_id FROM datasets d
WHERE d.id = visits.dataset_id AND visits.user_id IS NULL;
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'visits 回填 user_id 遇到重复行,已跳过';
END;
BEGIN
UPDATE movements SET user_id = d.user_id FROM datasets d
WHERE d.id = movements.dataset_id AND movements.user_id IS NULL;
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'movements 回填 user_id 遇到重复行,已跳过';
END;
BEGIN
UPDATE weather SET user_id = d.user_id FROM datasets d
WHERE d.id = weather.dataset_id AND weather.user_id IS NULL;
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'weather 回填 user_id 遇到重复行,已跳过';
END;
END
$mig$;
-- 早期版本把 (user_id, src_pk) 做成了跨数据集全局唯一,想实现「重复导入不堆积」。
-- 但那会让更新备份里的新记录只落进未激活的数据集(页面上看不到),已废弃,这里清理掉。
-- 行级唯一性仍由各表自带的 UNIQUE (dataset_id, src_pk) 保证。
DROP INDEX IF EXISTS idx_visits_dedupe;
DROP INDEX IF EXISTS idx_movements_dedupe;
DROP INDEX IF EXISTS idx_weather_dedupe;
-- 内容指纹:识别「内容相同、但文件字节不同」的重复上传(rond 每次重新导出字节都会变)
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS content_hash TEXT;
-- 管理员 TOTP 两步验证:secret 为 base32(无填充);enabled=false 时 secret 仅作为
-- 「已生成待确认」状态存在,确认验证码通过后才允许登录启用
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_datasets_content ON datasets(user_id, content_hash);
-- rond 备份里按次到访记录的备注(ZVISIT.ZREMARK_)汇总后的缓存。
-- 单独一列存:回填只重写这一列,站长手写的 note 永远不受影响;
-- 展示时把 rond_note 拼在 note 之前(见 web.applyNotes 的 joinNote)。
ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS rond_note TEXT NOT NULL DEFAULT '';
-- 导出 rondbackup 所需的 Core Data 元信息(实体编号、ZPRIMARYKEY、建表 DDL、访问↔标签关联表),
-- 导入时抓取并缓存,导出时据此从 PostgreSQL 重建 LifeEasy.sqlite。NULL 表示旧数据集未抓到,
-- 需要把原始 .rondbackup 重新上传一次(即便内容相同也会补抓)。
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS coredata_meta JSONB;
-- rond 原始行留档:每条实体在 Core Data 里的**完整一行**(含站点未映射的字段)
-- 按 JSONB 存下来。有了它,导出不再依赖基线库去补那些字段——
-- 基线即便丢了也能精确还原,站点侧也具备完整数据,不会因为映射范围窄而漏字段。
-- BLOB 编码为 {"$blob": "<base64>"};时间戳保持 Core Data 的浮点秒,避免精度损失。
CREATE TABLE IF NOT EXISTS entity_raw (
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
entity TEXT NOT NULL,
src_pk INT NOT NULL,
raw JSONB NOT NULL,
-- skipped=真 表示这行因缺少必填字段没能进 PostgreSQL(例如没有到达时间的孤独到访)。
-- 导出时这些行要按留档原样写回,否则往返一趟就少数据;而用户在站点上删掉的
-- 行不在此列,不会被「复活」。
skipped BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (dataset_id, entity, src_pk)
);
CREATE INDEX IF NOT EXISTS idx_entity_raw_lookup ON entity_raw (dataset_id, entity);
-- 旧库升级:补上后加的 skipped 列
ALTER TABLE entity_raw ADD COLUMN IF NOT EXISTS skipped BOOLEAN NOT NULL DEFAULT FALSE;
-- 旧库升级:place_notes / place_votes 补 dataset_id 并把主键扩成三列。
-- 回填归到当前激活数据集——旧数据只能这么认领,之后按数据集各管各的。
ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS dataset_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE place_votes ADD COLUMN IF NOT EXISTS dataset_id BIGINT NOT NULL DEFAULT 0;
UPDATE place_notes SET dataset_id = COALESCE((SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1), 0)
WHERE dataset_id = 0;
UPDATE place_votes SET dataset_id = COALESCE((SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1), 0)
WHERE dataset_id = 0;
DO $$
DECLARE cols text;
BEGIN
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_notes_pkey';
IF cols IS DISTINCT FROM 'user_id,dataset_id,src_pk' THEN
ALTER TABLE place_notes DROP CONSTRAINT IF EXISTS place_notes_pkey;
ALTER TABLE place_notes ADD CONSTRAINT place_notes_pkey PRIMARY KEY (user_id, dataset_id, src_pk);
END IF;
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
INTO cols
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conname = 'place_votes_pkey';
IF cols IS DISTINCT FROM 'user_id,dataset_id,src_pk' THEN
ALTER TABLE place_votes DROP CONSTRAINT IF EXISTS place_votes_pkey;
ALTER TABLE place_votes ADD CONSTRAINT place_votes_pkey PRIMARY KEY (user_id, dataset_id, src_pk);
END IF;
END $$;
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
CREATE TABLE IF NOT EXISTS topics (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
center_lat DOUBLE PRECISION NOT NULL DEFAULT 0,
center_lon DOUBLE PRECISION NOT NULL DEFAULT 0,
radius_km DOUBLE PRECISION NOT NULL DEFAULT 0,
show_fog BOOLEAN NOT NULL DEFAULT TRUE,
-- enabled=假 时前台不可见(列表不出现、详情 404),站长仍可在后台预览
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id);
-- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
-- data 存的是原始(zlib 压缩后的)字节,一个字节都不动。
CREATE TABLE IF NOT EXISTS fog_raw_files (
user_id BIGINT NOT NULL,
path TEXT NOT NULL,
data BYTEA NOT NULL,
PRIMARY KEY (user_id, path)
);
-- 迷雾遮罩区:这些圆形范围内的迷雾对访客隐藏(站长自己照常看得到)。
-- 存的中心是 **GCJ-02**(与 places 同系),渲染成瓦片前会换算到迷雾网格的 WGS-84。
CREATE TABLE IF NOT EXISTS fog_masks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
radius_m DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 地点坐标的来源留档。以前没有任何记录,只能靠「留档里 ZRAWLATITUDE 与显示坐标是否相同」
-- 反推——照片导入的点没有 ZLOCATION 留档行,根本判不出来。2026-09 那次照片导入选错坐标系
-- (把 WGS-84 的 EXIF 当成已经是 GCJ-02)导致 63 个点整体偏 500~670m,事后只能逐张对原图
-- 才查出来,就是因为缺这几列。现在建点的那一刻就写下来:
-- coord_source 哪个入口建的:rond(导入备份)/ photo(照片导入)/ manual(后台新增地点)
-- coord_sys 提交上来的坐标本身属于哪个坐标系:wgs84 / gcj02
-- src_lat/lon 提交上来、**还没做任何换算**的原始坐标
-- 三者齐全时,显示坐标 = 按 coord_sys 换算 src_lat/lon 的结果,随时可复算、可核对。
-- 留空的含义是「历史行、没有留档」,不要在迁移里用启发式去猜(猜错比留空更糟)。
ALTER TABLE places ADD COLUMN IF NOT EXISTS coord_source TEXT;
ALTER TABLE places ADD COLUMN IF NOT EXISTS coord_sys TEXT;
ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lat DOUBLE PRECISION;
ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lon DOUBLE PRECISION;
-- 存量回填不在这里做:判据需要 geo 包的 GCJ-02 往返换算(见 internal/ingest/coord.go),
-- SQL 里写不出来。启动时由 ingest.BackfillAllCoordSource 统一处理,那里同时能识别出
-- 「站点导出的备份给新建地点合成的 ZLOCATION 行」,不会把它误当成真机的原始 GPS。
-- 地点图片:站长给地点配的图。字节由**浏览器**转成 WebP 后再上传(服务端只负责收下与转存),
-- 1 核机器不用扛解码。storage 记这一张实际放在哪、object_key 是本地相对路径或对象存储 key:
-- 换了存储后端之后旧图仍按各自那一列去取,不会因为改了设置就全变破图。
CREATE TABLE IF NOT EXISTS place_images (
id BIGSERIAL PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
src_pk INT NOT NULL,
storage TEXT NOT NULL DEFAULT 'local',
object_key TEXT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
bytes BIGINT NOT NULL DEFAULT 0,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (dataset_id, src_pk, object_key)
);
CREATE INDEX IF NOT EXISTS idx_place_images_place ON place_images(dataset_id, src_pk, position);
+254
View File
@@ -0,0 +1,254 @@
// Package exif 从 JPEG 里读出拍摄时间与 GPS 坐标(照片导入用)。
//
// 只做两件事:EXIF 的 DateTimeOriginal 和 GPS IFD,其余一概不碰。
// 之所以自己解析而不引第三方库:站点是内网/离线构建,少一个依赖少一份麻烦;
// JPEG 的 EXIF 就在紧跟在 SOI 之后的 APP1 段里,格式固定,值得手写。
//
// 注意 EXIF 里的 GPS 是 WGS-84,而 rond / 站点内部是 GCJ-02,
// 调用方拿到坐标后要自己转(见 internal/geo.WGS84ToGCJ02)。
package exif
import (
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"time"
)
// Info 是从一张照片里读到的有用信息。
type Info struct {
Lat, Lon float64
HasGPS bool
ShotAt time.Time
HasTime bool
}
var (
errNotImage = errors.New("不认识的图片格式(只支持 JPEG 与 HEIC/HEIF)")
errNoEXIF = errors.New("这张图片没有 EXIF 信息")
errNeedField = errors.New("EXIF 里没有拍摄时间或 GPS")
// exif 时间没有时区,按本地理解(手机拍的就是当地时间)
timeLayout = "2006:01:02 15:04:05"
)
// Parse 读出照片的拍摄时间与 GPS,自动识别 JPEG 与 HEIC/HEIF。
// 缺 GPS 或时间都能容忍,用 Has* 判断。
func Parse(r io.Reader) (Info, error) {
// HEIC 的 Exif item 位置由 iloc 指到绝对偏移,可能在文件靠后,所以要读全;
// 上限 32MB 兜底(调用方另有更严的大小限制)。
buf, err := io.ReadAll(io.LimitReader(r, 32<<20))
if err != nil {
return Info{}, err
}
if len(buf) >= 2 && buf[0] == 0xFF && buf[1] == 0xD8 {
return parseJPEG(buf)
}
// ISO BMFF:第 5~8 字节是 ftyp
if len(buf) >= 12 && string(buf[4:8]) == "ftyp" {
return parseHEIF(buf)
}
return Info{}, errNotImage
}
// parseJPEG 扫 JPEG 的段,找到 APP1 里的 EXIF。
func parseJPEG(buf []byte) (Info, error) {
i := 2
for i+4 <= len(buf) {
if buf[i] != 0xFF { // 段间的填充字节
i++
continue
}
marker := buf[i+1]
switch {
case marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7):
i += 2 // 这些标记后面没有长度字段
continue
case marker == 0xDA || marker == 0xD9: // 图像数据开始 / 文件结束
return Info{}, errNoEXIF
}
size := int(buf[i+2])<<8 | int(buf[i+3])
if size < 2 || i+2+size > len(buf) {
break
}
seg := buf[i+4 : i+2+size]
if marker == 0xE1 && len(seg) > 6 && string(seg[:6]) == "Exif\x00\x00" {
return parseTIFF(seg[6:])
}
i += 2 + size
}
return Info{}, errNoEXIF
}
// ParseAll 是 Parse 的便利版:把「有照片但没 GPS」也算成软失败,
// 返回尽量多的信息,同时给出缺什么。
func ParseAll(r io.Reader) (Info, error) {
info, err := Parse(r)
if err != nil {
return info, err
}
if !info.HasGPS && !info.HasTime {
return info, errNeedField
}
return info, nil
}
type entry struct {
typ uint16
count uint32
valOff [4]byte
}
// readIFD 读出某个 IFD 的所有条目(tag -> entry)。
func readIFD(t []byte, off int, bo binary.ByteOrder) map[uint16]entry {
out := map[uint16]entry{}
if off < 0 || off+2 > len(t) {
return out
}
n := int(bo.Uint16(t[off : off+2]))
base := off + 2
if base+n*12 > len(t) {
n = (len(t) - base) / 12
}
for i := 0; i < n; i++ {
p := base + i*12
e := entry{
typ: bo.Uint16(t[p+2 : p+4]),
count: bo.Uint32(t[p+4 : p+8]),
}
copy(e.valOff[:], t[p+8:p+12])
out[bo.Uint16(t[p:p+2])] = e
}
return out
}
// typeSize 是 EXIF 里每种类型的字节数。
func typeSize(typ uint16) int {
switch typ {
case 1, 2, 6, 7: // BYTE / ASCII / SBYTE / UNDEFINED
return 1
case 3, 8: // SHORT / SSHORT
return 2
case 4, 9, 11: // LONG / SLONG / FLOAT
return 4
case 5, 10, 12: // RATIONAL / SRATIONAL / DOUBLE
return 8
}
return 1
}
// valueOffset 返回值所在位置的偏移。总长 > 4 字节时 valOff 存的是偏移,
// 否则值直接内联在 valOff 里(左对齐)。
func (e entry) valueOffset(t []byte, bo binary.ByteOrder) int {
if int(e.count)*typeSize(e.typ) > 4 {
return int(bo.Uint32(e.valOff[:]))
}
// 内联值:算出它在整个 t 里的位置
return -1
}
// inline 返回内联值(4 字节以内)的字节。
func (e entry) inline() []byte { return e.valOff[:] }
// ascii 读 ASCII 类型的值(EXIF 里以 NUL 结尾)。
func (e entry) ascii(t []byte, bo binary.ByteOrder) string {
total := int(e.count)
if total <= 0 {
return ""
}
var raw []byte
if total <= 4 {
raw = e.inline()[:total]
} else {
off := int(bo.Uint32(e.valOff[:]))
if off < 0 || off+total > len(t) {
return ""
}
raw = t[off : off+total]
}
return strings.TrimRight(string(raw), "\x00")
}
// rationals 读一组 RATIONAL(用 uint32 对表示),n 是想要的个数。
func (e entry) rationals(t []byte, bo binary.ByteOrder, n int) ([]float64, bool) {
off := int(bo.Uint32(e.valOff[:]))
if off < 0 || off+n*8 > len(t) {
return nil, false
}
out := make([]float64, n)
for i := 0; i < n; i++ {
num := bo.Uint32(t[off+i*8 : off+i*8+4])
den := bo.Uint32(t[off+i*8+4 : off+i*8+8])
if den == 0 {
return nil, false
}
out[i] = float64(num) / float64(den)
}
return out, true
}
func parseTIFF(t []byte) (Info, error) {
var info Info
if len(t) < 8 {
return info, errNoEXIF
}
var bo binary.ByteOrder
switch string(t[:2]) {
case "II":
bo = binary.LittleEndian
case "MM":
bo = binary.BigEndian
default:
return info, errNoEXIF
}
if bo.Uint16(t[2:4]) != 0x002A {
return info, errNoEXIF
}
ifd0 := readIFD(t, int(bo.Uint32(t[4:8])), bo)
// 拍摄时间在 Exif 子 IFD(IFD0 里的 0x8769 指向它)
if e, ok := ifd0[0x8769]; ok {
sub := readIFD(t, int(bo.Uint32(e.valOff[:])), bo)
if d, ok := sub[0x9003]; ok {
if s := strings.TrimSpace(d.ascii(t, bo)); s != "" {
if tm, err := time.ParseInLocation(timeLayout, s, time.Local); err == nil {
info.ShotAt, info.HasTime = tm, true
}
}
}
}
// GPS 在另一个子 IFD(IFD0 里的 0x8825)
if e, ok := ifd0[0x8825]; ok {
gps := readIFD(t, int(bo.Uint32(e.valOff[:])), bo)
lat, okLat := gpsCoord(gps, t, bo, 1, 2)
lon, okLon := gpsCoord(gps, t, bo, 3, 4)
if okLat && okLon {
info.Lat, info.Lon, info.HasGPS = lat, lon, true
}
}
if !info.HasGPS && !info.HasTime {
return info, fmt.Errorf("%w", errNeedField)
}
return info, nil
}
// gpsCoord 把「度分秒 + 方位」拼成十进制度。refTag 是 1/3(N-S、E-W),valTag 是 2/4。
func gpsCoord(gps map[uint16]entry, t []byte, bo binary.ByteOrder, refTag, valTag uint16) (float64, bool) {
refE, ok1 := gps[refTag]
valE, ok2 := gps[valTag]
if !ok1 || !ok2 {
return 0, false
}
parts, ok := valE.rationals(t, bo, 3)
if !ok {
return 0, false
}
v := parts[0] + parts[1]/60 + parts[2]/3600
switch strings.ToUpper(strings.TrimSpace(refE.ascii(t, bo))) {
case "S", "W":
v = -v
}
return v, true
}
+215
View File
@@ -0,0 +1,215 @@
package exif
import (
"bytes"
"encoding/binary"
"math"
"testing"
"time"
)
// buildTIFF 拼出小端 TIFF(IFD0 + ExifIFD + GPS IFD),JPEG 与 HEIC 共用。
func buildTIFF(lat, lon float64, shot string) []byte {
tiff := &bytes.Buffer{}
tiff.WriteString("II")
binary.Write(tiff, binary.LittleEndian, uint16(0x002A))
binary.Write(tiff, binary.LittleEndian, uint32(8)) // IFD0 偏移
const (
ifd0Off = 8
exifOff = ifd0Off + 30 // IFD0: 2 条目 = 2 + 24 + 4
shotOff = exifOff + 18 // ExifIFD: 1 条目 = 2 + 12 + 4
shotLen = len("2006:01:02 15:04:05") + 1
gpsOff = shotOff + shotLen
ratOff = gpsOff + 54 // GPS IFD: 4 条目 = 2 + 48 + 4
)
// IFD0
binary.Write(tiff, binary.LittleEndian, uint16(2))
tiff.Write(entryBytes(0x8769, 4, 1, uint32(exifOff)))
tiff.Write(entryBytes(0x8825, 4, 1, uint32(gpsOff)))
binary.Write(tiff, binary.LittleEndian, uint32(0))
// ExifIFD:DateTimeOriginal
binary.Write(tiff, binary.LittleEndian, uint16(1))
tiff.Write(entryBytes(0x9003, 2, uint32(shotLen), uint32(shotOff)))
binary.Write(tiff, binary.LittleEndian, uint32(0))
tiff.WriteString(shot + "\x00")
// GPS IFD
binary.Write(tiff, binary.LittleEndian, uint16(4))
latRef, lonRef := byte('N'), byte('E')
alat, alon := math.Abs(lat), math.Abs(lon)
if lat < 0 {
latRef = 'S'
}
if lon < 0 {
lonRef = 'W'
}
tiff.Write(entryBytes(1, 2, 2, uint32(latRef)))
tiff.Write(entryBytes(2, 5, 3, uint32(ratOff)))
tiff.Write(entryBytes(3, 2, 2, uint32(lonRef)))
tiff.Write(entryBytes(4, 5, 3, uint32(ratOff+24)))
binary.Write(tiff, binary.LittleEndian, uint32(0))
writeDMS(tiff, alat)
writeDMS(tiff, alon)
return tiff.Bytes()
}
// buildJPEG 拼一张最小 JPEG:SOI + APP1(EXIF) + EOI。
// 只有 EXIF 段是真的,后面不需要有效图像数据——解析器读到 APP1 就返回。
func buildJPEG(t *testing.T, lat, lon float64, shot string) []byte {
t.Helper()
body := &bytes.Buffer{}
body.Write([]byte("Exif\x00\x00"))
body.Write(buildTIFF(lat, lon, shot))
out := &bytes.Buffer{}
out.Write([]byte{0xFF, 0xD8, 0xFF, 0xE1})
binary.Write(out, binary.BigEndian, uint16(body.Len()+2))
out.Write(body.Bytes())
out.Write([]byte{0xFF, 0xD9})
return out.Bytes()
}
// mkBox 拼一个 ISO BMFF 盒子(32 位长度)。
func mkBox(typ string, payload []byte) []byte {
b := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(b[0:4], uint32(8+len(payload)))
copy(b[4:8], typ)
copy(b[8:], payload)
return b
}
// buildHEIC 拼一个最小 HEIC:ftyp + meta(iinf + iloc) + Exif item。
// Exif item 放在文件末尾,iloc 里填它的绝对偏移——真实文件也是这样定位的。
func buildHEIC(t *testing.T, lat, lon float64, shot string) []byte {
t.Helper()
tiff := buildTIFF(lat, lon, shot)
// HEIF 的 Exif item = 4 字节 TIFF 偏移 + TIFF 本体
item := append([]byte{0, 0, 0, 4}, tiff...)
ftyp := mkBox("ftyp", append([]byte("heic"), []byte{0, 0, 0, 0, 'h', 'e', 'i', 'c'}...))
// infe v2:version+flags + item_ID + protection + item_type + 空名字
infe := mkBox("infe", append([]byte{2, 0, 0, 0, 0, 1, 0, 0}, append([]byte("Exif"), 0)...))
iinf := mkBox("iinf", append([]byte{0, 0, 0, 0, 0, 1}, infe...))
// iloc v0:offsetSize=4 lengthSize=4 baseSize=0,一条数据段
ilocPayload := []byte{0, 0, 0, 0, 0x44, 0x00, 0, 1, 0, 1, 0, 0, 0, 1}
offPos := len(ilocPayload)
ilocPayload = append(ilocPayload, 0, 0, 0, 0) // extent_offset 待回填
ilocPayload = append(ilocPayload,
byte(len(item)>>24), byte(len(item)>>16), byte(len(item)>>8), byte(len(item)))
iloc := mkBox("iloc", ilocPayload)
meta := mkBox("meta", append([]byte{0, 0, 0, 0}, append(iinf, iloc...)...))
// extent_offset 的绝对位置:meta 头(8) + fullbox(4) + iinf + iloc 头(8) + 段内偏移
pos := 8 + 4 + len(iinf) + 8 + offPos
binary.BigEndian.PutUint32(meta[pos:pos+4], uint32(len(ftyp)+len(meta)))
out := &bytes.Buffer{}
out.Write(ftyp)
out.Write(meta)
out.Write(item)
return out.Bytes()
}
func entryBytes(tag, typ uint16, count uint32, val uint32) []byte {
b := make([]byte, 12)
binary.LittleEndian.PutUint16(b[0:2], tag)
binary.LittleEndian.PutUint16(b[2:4], typ)
binary.LittleEndian.PutUint32(b[4:8], count)
binary.LittleEndian.PutUint32(b[8:12], val)
return b
}
// writeDMS 把十进制度拆成 度/分/秒 三个 rational。
func writeDMS(w *bytes.Buffer, v float64) {
d := math.Floor(v)
m := math.Floor((v - d) * 60)
s := ((v-d)*60 - m) * 60
for _, p := range [][2]uint32{{uint32(d), 1}, {uint32(m), 1}, {uint32(math.Round(s * 10000)), 10000}} {
binary.Write(w, binary.LittleEndian, p[0])
binary.Write(w, binary.LittleEndian, p[1])
}
}
func TestParseGPSAndTime(t *testing.T) {
// 大理古城附近,EXIF 存的是 WGS-84
jpg := buildJPEG(t, 25.6890, 100.1560, "2026:08:14 12:40:33")
info, err := Parse(bytes.NewReader(jpg))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if !info.HasGPS {
t.Fatal("没读到 GPS")
}
if math.Abs(info.Lat-25.6890) > 1e-4 || math.Abs(info.Lon-100.1560) > 1e-4 {
t.Fatalf("坐标不对: %v, %v", info.Lat, info.Lon)
}
if !info.HasTime {
t.Fatal("没读到拍摄时间")
}
want := time.Date(2026, 8, 14, 12, 40, 33, 0, time.Local)
if !info.ShotAt.Equal(want) {
t.Fatalf("时间不对: %v 期望 %v", info.ShotAt, want)
}
}
func TestParseSouthWest(t *testing.T) {
jpg := buildJPEG(t, -33.8688, -70.6693, "2025:01:02 03:04:05")
info, err := Parse(bytes.NewReader(jpg))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if info.Lat > 0 || info.Lon > 0 {
t.Fatalf("南纬/西经应该是负数: %v, %v", info.Lat, info.Lon)
}
}
func TestParseRejectsNonImage(t *testing.T) {
if _, err := Parse(bytes.NewReader([]byte("PNG\x89 not a jpeg"))); err == nil {
t.Fatal("非图片应该报错")
}
bare := []byte{0xFF, 0xD8, 0xFF, 0xD9} // 合法 JPEG 但没有 EXIF 段
if _, err := Parse(bytes.NewReader(bare)); err == nil {
t.Fatal("没有 EXIF 应该报错")
}
}
func TestParseHEIC(t *testing.T) {
// 喜洲古镇附近,EXIF 存 WGS-84
heic := buildHEIC(t, 25.8560, 100.1310, "2026:08:15 15:43:00")
info, err := Parse(bytes.NewReader(heic))
if err != nil {
t.Fatalf("HEIC 解析失败: %v", err)
}
if !info.HasGPS || math.Abs(info.Lat-25.8560) > 1e-4 || math.Abs(info.Lon-100.1310) > 1e-4 {
t.Fatalf("坐标不对: %v, %v", info.Lat, info.Lon)
}
if !info.HasTime || info.ShotAt.Format("2006-01-02 15:04:05") != "2026-08-15 15:43:00" {
t.Fatalf("时间不对: %v", info.ShotAt)
}
}
func TestParseHEICNoExifItem(t *testing.T) {
// 有 meta/iinf/iloc 但没有 Exif item:应报「没有 EXIF」而不是崩
infe := mkBox("infe", append([]byte{2, 0, 0, 0, 0, 1, 0, 0}, append([]byte("hvc1"), 0)...))
iinf := mkBox("iinf", append([]byte{0, 0, 0, 0, 0, 1}, infe...))
meta := mkBox("meta", append([]byte{0, 0, 0, 0}, iinf...))
ftyp := mkBox("ftyp", append([]byte("heic"), []byte{0, 0, 0, 0, 'h', 'e', 'i', 'c'}...))
buf := append(ftyp, meta...)
if _, err := Parse(bytes.NewReader(buf)); err == nil {
t.Fatal("没有 Exif item 应该报错")
}
}
func TestParseTruncatedDoesNotPanic(t *testing.T) {
// 截断到各种长度都不该 panic(上传中断、文件损坏在真实世界里都会出现)
full := buildHEIC(t, 25.8560, 100.1310, "2026:08:15 15:43:00")
for _, n := range []int{1, 5, 12, 20, len(full) / 2, len(full) - 1} {
if n <= len(full) {
_, _ = Parse(bytes.NewReader(full[:n]))
}
}
jpg := buildJPEG(t, 25.6890, 100.1560, "2026:08:14 12:40:33")
for _, n := range []int{1, 4, 10, len(jpg) / 2, len(jpg) - 1} {
_, _ = Parse(bytes.NewReader(jpg[:n]))
}
}
+222
View File
@@ -0,0 +1,222 @@
// HEIF/HEIC 容器里的 EXIF 提取。
//
// HEIC 是 ISO BMFF(和 MP4 同一套盒子结构):文件头是 ftyp,后面跟一个 meta box,
// 里面 iinf 描述每个 item(其中有一个 item_type == "Exif"),iloc 给出它的位置。
// 取出来的 Exif item 是「4 字节 TIFF 偏移 + 标准 TIFF」,后半段与 JPEG 的 APP1 完全一样,
// 所以直接交给 parseTIFF 复用。
//
// 纯 Go 实现,不引 libheif:内网/离线构建环境下 CGO 依赖太麻烦,而我们只要 EXIF 这一小块。
package exif
import (
"encoding/binary"
"errors"
)
var errNotHEIF = errors.New("不是 HEIF/HEIC 容器")
// boxHeader 读一个 box 的头,返回类型、负载起点与下一个 box 的起点。
func boxHeader(b []byte, off int) (typ string, payload, next int, ok bool) {
if off < 0 || off+8 > len(b) {
return "", 0, 0, false
}
size := int(binary.BigEndian.Uint32(b[off : off+4]))
hdr := 8
switch size {
case 1: // largesize
if off+16 > len(b) {
return "", 0, 0, false
}
size = int(binary.BigEndian.Uint64(b[off+8 : off+16]))
hdr = 16
case 0: // 一直到文件末尾
size = len(b) - off
}
if size < hdr || off+size > len(b) {
return "", 0, 0, false
}
return string(b[off+4 : off+8]), off + hdr, off + size, true
}
// parseHEIF 从 HEIC/HEIF 里取 EXIF。
func parseHEIF(b []byte) (Info, error) {
for off := 0; off+8 <= len(b); {
typ, payload, next, ok := boxHeader(b, off)
if !ok {
break
}
if typ == "meta" {
// meta 是 FullBox:负载前面还有 4 字节 version+flags
if payload+4 > next {
return Info{}, errNotHEIF
}
return parseMeta(b, payload+4, next)
}
if next <= off {
break
}
off = next
}
return Info{}, errNoEXIF
}
func parseMeta(b []byte, start, end int) (Info, error) {
iinfStart, iinfEnd := -1, -1
ilocStart, ilocEnd := -1, -1
for off := start; off+8 <= end; {
typ, payload, next, ok := boxHeader(b, off)
if !ok || next > end {
break
}
switch typ {
case "iinf":
iinfStart, iinfEnd = payload, next
case "iloc":
ilocStart, ilocEnd = payload, next
}
off = next
}
if iinfStart < 0 || ilocStart < 0 {
return Info{}, errNoEXIF
}
itemID, ok := findExifItem(b, iinfStart, iinfEnd)
if !ok {
return Info{}, errNoEXIF
}
off, length, ok := findItemExtent(b, ilocStart, ilocEnd, itemID)
if !ok || off < 0 || length <= 0 || off+length > len(b) {
return Info{}, errNoEXIF
}
item := b[off : off+length]
if len(item) < 4 {
return Info{}, errNoEXIF
}
// Exif item 开头 4 字节是 TIFF 头相对本 item 起点的偏移
tiff := int(binary.BigEndian.Uint32(item[:4]))
if tiff < 0 || tiff+8 > len(item) {
tiff = 4 // 少数写入方这里填得不准,退化成「紧跟这 4 字节」
}
return parseTIFF(item[tiff:])
}
// findExifItem 在 iinf 里找 item_type 为 "Exif" 的 item。
func findExifItem(b []byte, start, end int) (uint32, bool) {
if start+4 > end {
return 0, false
}
ver := b[start]
p := start + 4
var count int
if ver == 0 {
if p+2 > end {
return 0, false
}
count = int(binary.BigEndian.Uint16(b[p : p+2]))
p += 2
} else {
if p+4 > end {
return 0, false
}
count = int(binary.BigEndian.Uint32(b[p : p+4]))
p += 4
}
for i := 0; i < count; i++ {
typ, payload, next, ok := boxHeader(b, p)
if !ok || next > end {
break
}
// infe v2 起:version+flags(4) + item_ID(2) + protection(2) + item_type(4)
if typ == "infe" && payload < next && b[payload] >= 2 && payload+12 <= next {
if string(b[payload+8:payload+12]) == "Exif" {
return uint32(binary.BigEndian.Uint16(b[payload+4 : payload+6])), true
}
}
p = next
}
return 0, false
}
// findItemExtent 在 iloc 里找指定 item 的第一段数据(偏移 + 长度)。
func findItemExtent(b []byte, start, end int, wantID uint32) (int, int, bool) {
if start+8 > end {
return 0, 0, false
}
ver := b[start]
p := start + 4
offsetSize := int(b[p] >> 4)
lengthSize := int(b[p] & 0x0F)
p++
baseSize := int(b[p] >> 4)
indexSize := 0
if ver >= 1 {
indexSize = int(b[p] & 0x0F)
}
p++
if offsetSize == 0 || lengthSize == 0 || p+2 > end {
return 0, 0, false
}
count := int(binary.BigEndian.Uint16(b[p : p+2]))
p += 2
// 越过越界就读不动了,返回 false 让上层当作没找到
readN := func(n int) (uint64, bool) {
if n < 0 || p+n > end {
return 0, false
}
var v uint64
for i := 0; i < n; i++ {
v = v<<8 | uint64(b[p+i])
}
p += n
return v, true
}
for i := 0; i < count; i++ {
var id uint32
if ver == 0 {
v, ok := readN(2)
if !ok {
return 0, 0, false
}
id = uint32(v)
} else {
v, ok := readN(4)
if !ok {
return 0, 0, false
}
id = uint32(v)
}
if ver >= 1 {
if _, ok := readN(2); !ok { // construction_method + reserved
return 0, 0, false
}
}
if _, ok := readN(2); !ok { // data_reference_index
return 0, 0, false
}
base, ok := readN(baseSize)
if !ok {
return 0, 0, false
}
extentCountV, ok := readN(2)
if !ok {
return 0, 0, false
}
for e := 0; e < int(extentCountV); e++ {
if ver >= 1 && indexSize > 0 {
if _, ok := readN(indexSize); !ok {
return 0, 0, false
}
}
off, ok1 := readN(offsetSize)
length, ok2 := readN(lengthSize)
if !ok1 || !ok2 {
return 0, 0, false
}
if id == wantID {
return int(base + off), int(length), true
}
}
}
return 0, 0, false
}
+303
View File
@@ -0,0 +1,303 @@
// Package fog 解析与生成世界迷雾(Fog of World)的快照数据。
//
// 快照是一个 zip,其中 Model/*/ 下的每个文件是一块「瓦片」:全球 512x512 块瓦片,
// 每块瓦片 128x128 个块,每块是 64x64 的位图,一个位对应全球 2^22 x 2^22 网格里
// 约 9.55m*cos(纬度) 宽的一格。网格走网页墨卡托,经纬度即 WGS-84(已实测确认)。
// 文件内容 zlib 压缩:头 32768 字节是 16384 个 uint16(LE),值 = 该块在块体里的
// 序号(1 基,0 表示无数据);块体每块 512 字节位图 + 3 字节附加(地区码与置位校验)。
package fog
import (
"archive/zip"
"bytes"
"compress/zlib"
"crypto/md5"
"encoding/binary"
"fmt"
"io"
"math"
"path"
"strconv"
"strings"
)
const (
// MapTiles 全球瓦片数(每边)。
MapTiles = 512
// TileBlocks 每块瓦片的块数(每边)。
TileBlocks = 128
// BlockCells 每块的格子数(每边)。
BlockCells = 64
// WorldCells 全球格子数(每边),2^22。
WorldCells = MapTiles * TileBlocks * BlockCells
bitmapSize = BlockCells * BlockCells / 8 // 512
extraSize = 3
blockSize = bitmapSize + extraSize // 515
headerLen = TileBlocks * TileBlocks
headerSize = headerLen * 2 // 32768
mask1 = "olhwjsktri"
mask2 = "eizxdwknmo"
)
// Block 是一片 64x64 的已探索位图。GX/GY 为全局块坐标(0..65535),
// Bitmap 固定 512 字节,行优先、每行 8 字节、字节内高位在前。
type Block struct {
GX, GY int
Bitmap []byte
}
// CellCount 返回位图里的置位数(已探索格子数)。
func (b *Block) CellCount() int {
n := 0
for _, v := range b.Bitmap {
n += int(popcount[v])
}
return n
}
var popcount = func() [256]byte {
var t [256]byte
for i := range t {
t[i] = byte(strings.Count(strconv.FormatUint(uint64(i), 2), "1"))
}
return t
}()
// tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
func tileIDFromName(name string) (int, bool) {
if len(name) < 7 || len(name[4:]) < 3 {
return 0, false
}
digits := make([]byte, 0, len(name))
for _, c := range name[4 : len(name)-2] {
v := strings.IndexRune(mask1, c)
if v < 0 {
return 0, false
}
digits = append(digits, byte('0'+v))
}
id, err := strconv.Atoi(string(digits))
if err != nil {
return 0, false
}
s := strconv.Itoa(id)
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
return 0, false
}
tail := name[len(name)-2:]
for i := 0; i < 2; i++ {
if int(s[len(s)-2+i]-'0') != strings.IndexByte(mask2, tail[i]) {
return 0, false
}
}
return id, true
}
// tileName 把瓦片编号编码回文件名。
func tileName(id int) string {
s := strconv.Itoa(id)
sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
var b strings.Builder
b.WriteString(sum[:4])
for i := 0; i < len(s); i++ {
b.WriteByte(mask1[s[i]-'0'])
}
for i := len(s) - 2; i < len(s); i++ {
b.WriteByte(mask2[s[i]-'0'])
}
return b.String()
}
// Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
func Decode(data []byte) ([]Block, int, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, 0, fmt.Errorf("打开迷雾快照: %w", err)
}
merged := map[[2]int][]byte{}
cells := 0
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
id, ok := tileIDFromName(path.Base(f.Name))
if !ok {
continue
}
rc, err := f.Open()
if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
}
raw, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
}
zdata, err := zlibDecompress(raw)
if err != nil {
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
}
if len(zdata) < headerSize {
continue
}
tx, ty := id%MapTiles, id/MapTiles
for i := 0; i < headerLen; i++ {
idx := int(binary.LittleEndian.Uint16(zdata[2*i:]))
if idx == 0 {
continue
}
off := headerSize + (idx-1)*blockSize
if off+blockSize > len(zdata) {
return nil, 0, fmt.Errorf("%s: 块体越界", f.Name)
}
bm := make([]byte, bitmapSize)
copy(bm, zdata[off:off+bitmapSize])
key := [2]int{tx*TileBlocks + i%TileBlocks, ty*TileBlocks + i/TileBlocks}
if old, dup := merged[key]; dup {
for j := range bm {
old[j] |= bm[j]
}
continue
}
merged[key] = bm
cells += popcountSum(bm)
}
}
blocks := make([]Block, 0, len(merged))
for k, bm := range merged {
blocks = append(blocks, Block{GX: k[0], GY: k[1], Bitmap: bm})
}
sortBlocks(blocks)
return blocks, cells, nil
}
func popcountSum(bm []byte) int {
n := 0
for _, v := range bm {
n += int(popcount[v])
}
return n
}
func sortBlocks(bs []Block) {
for i := 1; i < len(bs); i++ {
for j := i; j > 0 && (bs[j-1].GY > bs[j].GY || (bs[j-1].GY == bs[j].GY && bs[j-1].GX > bs[j].GX)); j-- {
bs[j-1], bs[j] = bs[j], bs[j-1]
}
}
}
func zlibDecompress(raw []byte) ([]byte, error) {
zr, err := zlib.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, err
}
defer zr.Close()
return io.ReadAll(zr)
}
// Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
// layers 是留存的非已探索层原始文件(Model/~ 与 Model/#),原样写回——
// 它们不参与渲染,但少了它们 fog of world 那边看到的就是残缺快照。
func Encode(blocks []Block, layers map[string][]byte) ([]byte, error) {
byTile := map[[2]int][]Block{}
for _, b := range blocks {
tx, ty := b.GX/TileBlocks, b.GY/TileBlocks
byTile[[2]int{tx, ty}] = append(byTile[[2]int{tx, ty}], b)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for t, list := range byTile {
sortByLinear(list)
header := make([]byte, headerSize)
body := make([]byte, 0, len(list)*blockSize)
for k, b := range list {
i := (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks
binary.LittleEndian.PutUint16(header[2*i:], uint16(k+1))
body = append(body, b.Bitmap...)
body = append(body, extraBytes(b)...)
}
var raw bytes.Buffer
zc := zlib.NewWriter(&raw)
if _, err := zc.Write(header); err != nil {
return nil, err
}
if _, err := zc.Write(body); err != nil {
return nil, err
}
if err := zc.Close(); err != nil {
return nil, err
}
id := t[0] + t[1]*MapTiles
name := "Model/*/" + tileName(id)
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write(raw.Bytes()); err != nil {
return nil, err
}
}
// 其它层直接写原始字节,不做二次压缩,免得多一层格式风险
for p, data := range layers {
w, err := zw.Create(p)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sortByLinear(list []Block) {
lin := func(b Block) int { return (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks }
for i := 1; i < len(list); i++ {
for j := i; j > 0 && lin(list[j-1]) > lin(list[j]); j-- {
list[j-1], list[j] = list[j], list[j-1]
}
}
}
// extraBytes 生成块尾那 3 字节。真机存的是一个**大端 24 位数**,值为 置位数*2+1:
// 实测真机包 8701/8701 个块精确满足(如 118 格 -> 0x0000ED = 237 = 118*2+1)。
// 一个 64x64 块最多 4096 格,该值 <= 0x2001,所以最高字节在数学上恒为 0。
// (旧实现写的是 0x10 / 0x80|…,纯属臆造,会让导出包里每个块的元信息都与真机对不上。)
func extraBytes(b Block) []byte {
sum := b.CellCount()*2 + 1
return []byte{0x00, byte(sum >> 8), byte(sum & 0xFF)}
}
// CellSideM 是格子在赤道的边长(米)。宽随纬度 cos 收缩,高恒定(网页墨卡托)。
const CellSideM = 9.55462861
// CellOf 把 WGS-84 经纬度投到全球格坐标(网页墨卡托,2^22 网格)。
func CellOf(lat, lon float64) (cx, cy int) {
cx = int((lon + 180.0) / 360.0 * WorldCells)
rad := lat * math.Pi / 180
cy = int((math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells)
return
}
// AreaKm2 估算块集覆盖的实地面积:每格面积按块中心纬度做 cos 修正。
func AreaKm2(blocks []Block) float64 {
var m2 float64
for _, b := range blocks {
m2 += float64(b.CellCount()) * cellAreaM2(b.GX, b.GY)
}
return m2 / 1e6
}
// cellAreaM2 是某块内单格的面积(m²):一格宽 CellSideM、随纬度乘 cos 收缩,高恒定。
func cellAreaM2(gx, gy int) float64 {
_, lat := cellLonLat(gx*BlockCells+BlockCells/2, gy*BlockCells+BlockCells/2)
return CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
}
+185
View File
@@ -0,0 +1,185 @@
package fog
import (
"archive/zip"
"bytes"
"encoding/binary"
"io"
"os"
"path"
"strings"
"testing"
)
// TestNativeSnapshotRoundtrip 守住「真机快照解码再编码后内容不变」。
// 需要环境变量 FOG_SRC 指向真机导出的 .fwss;未设置时跳过。
//
// 重点守三件事:
// 1. Model/* 的每块逐字节一致(512 字节位图 + 尾 3 字节元信息)。尾 3 字节曾按臆造的
// 0x10/0x80 生成,实测 8701/8701 个块与真机全不符——它其实是一个大端 24 位数,
// 值为置位数*2+1;
// 2. Model/* 的条目集合与文件名不变(名字是 md5 前缀 + 掩码数字,重建时必须能原样还原);
// 3. Model/~ 与 Model/# 两层原样带出,条目名与内容一个不差——少了它们快照就残缺 55%。
//
// 比对按「格」而不是按块体槽位:槽位序号只是头里的指针,顺序允许不同,指向同一张位图即可。
func TestNativeSnapshotRoundtrip(t *testing.T) {
src := os.Getenv("FOG_SRC")
if src == "" {
t.Skip("设置 FOG_SRC 后运行迷雾快照往返检查")
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读取 %s: %v", src, err)
}
blocks, cells, err := Decode(data)
if err != nil {
t.Fatalf("解码: %v", err)
}
layers, err := RawLayers(data)
if err != nil {
t.Fatalf("读取其它层: %v", err)
}
out, err := Encode(blocks, layers)
if err != nil {
t.Fatalf("编码: %v", err)
}
orig, err := unzipAll(data)
if err != nil {
t.Fatalf("解开原始包: %v", err)
}
got, err := unzipAll(out)
if err != nil {
t.Fatalf("解开导出包: %v", err)
}
if len(orig) != len(got) {
t.Errorf("条目数不一致: 原始 %d vs 导出 %d", len(orig), len(got))
}
for name := range orig {
if _, ok := got[name]; !ok {
t.Errorf("导出包缺少条目 %s", name)
}
}
for name := range got {
if _, ok := orig[name]; !ok {
t.Errorf("导出包多出条目 %s", name)
}
}
checked := 0
for name, a := range orig {
if !isModelStar(name) {
continue
}
b, ok := got[name]
if !ok {
continue
}
pa, err := zlibDecompress(a)
if err != nil {
t.Fatalf("%s 原始载荷解压失败: %v", name, err)
}
pb, err := zlibDecompress(b)
if err != nil {
t.Fatalf("%s 导出载荷解压失败: %v", name, err)
}
if len(pa) != len(pb) {
t.Errorf("%s: 载荷长度 %d vs %d", name, len(pa), len(pb))
continue
}
for cell := 0; cell < headerLen; cell++ {
ba, bb := slotAt(pa, cell), slotAt(pb, cell)
if (ba == nil) != (bb == nil) {
t.Errorf("%s: 格 %d 一边有块一边没有", name, cell)
continue
}
if ba == nil {
continue
}
if !bytes.Equal(ba[:bitmapSize], bb[:bitmapSize]) {
t.Errorf("%s: 格 %d 的块位图不一致", name, cell)
}
if !bytes.Equal(ba[bitmapSize:], bb[bitmapSize:]) {
t.Errorf("%s: 格 %d 的块元信息不一致: 原始 %x vs 导出 %x(置位数 %d)",
name, cell, ba[bitmapSize:], bb[bitmapSize:], popcountSum(ba[:bitmapSize]))
}
checked++
}
}
if checked == 0 {
t.Error("没有比对到任何块,样本可能不对")
}
// 其它层:条目内容(zlib 流本身)逐字节一致
for name, v := range layers {
if !bytes.Equal(got[name], v) {
t.Errorf("其它层 %s 未原样带出", name)
}
}
t.Logf("已核对 %d 个块、%d 个其它层条目(%d 格)", checked, len(layers), cells)
}
// TestExtraBytesEncoding 钉住块尾 3 字节的编码:大端 24 位数,值 = 置位数*2+1,最高字节恒为 0。
func TestExtraBytesEncoding(t *testing.T) {
bm := make([]byte, bitmapSize)
for _, c := range []struct{ bit, cells int }{{0, 1}, {7, 2}, {8, 3}, {511, 4}} {
bm[c.bit/8] |= 0x80 >> (c.bit % 8)
if got := extraBytes(Block{Bitmap: bm}); got[0] != 0 || int(got[1])<<8|int(got[2]) != c.cells*2+1 {
t.Errorf("置位数 %d 时得到 %x", c.cells, got)
}
}
// 满块 4096 格:值 8193 = 0x2001,最高字节仍应为 0
full := make([]byte, bitmapSize)
for i := range full {
full[i] = 0xFF
}
if got := extraBytes(Block{Bitmap: full}); !bytes.Equal(got, []byte{0x00, 0x20, 0x01}) {
t.Errorf("满块应为 002001,实际 %x", got)
}
}
func isModelStar(name string) bool {
name = strings.ReplaceAll(name, "\\", "/")
rest := strings.TrimPrefix(name, "Model/")
return rest != name && strings.HasPrefix(rest, "*/")
}
// unzipAll 读出包内全部条目的内容——那是 zlib 流本身,要 zlibDecompress 才是载荷。
func unzipAll(data []byte) (map[string][]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
out := map[string][]byte{}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
b, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, err
}
out[path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))] = b
}
return out, nil
}
// slotAt 取出某个格子所指向的块(头里存的是 1 基槽位序号,0 表示该格没有数据)。
func slotAt(payload []byte, cell int) []byte {
idx := int(binary.LittleEndian.Uint16(payload[2*cell:]))
if idx == 0 {
return nil
}
off := headerSize + (idx-1)*blockSize
if off+blockSize > len(payload) {
return nil
}
return payload[off : off+blockSize]
}
+66
View File
@@ -0,0 +1,66 @@
package fog
import (
"archive/zip"
"bytes"
"fmt"
"io"
"path"
"strings"
)
// layerMaxBytes 是单个留存条目的解压上限(实测最大约 250KB)。
const layerMaxBytes = 8 << 20
// RawLayers 取出快照里「不属于已探索层」的条目(内容原样,条目名也原样)。
//
// fog of world 的快照里有三类瓦片:
//
// Model/* 已探索层:128×128 块,每块 512 字节位图 + 3 字节元信息(本包解析的就是它)
// Model/~ 另一层位图:每块 512 字节,没有那 3 字节
// Model/# 只有元信息:每块 3 字节
//
// 后两类我们不解析(站点只用已探索层来渲染),但**必须原样留住**——
// 否则重新导出的快照会丢掉这 55% 的内容,fog of world 那边看到的就是残缺数据。
//
// 留存范围是「快照里的全部条目,除去已探索层与被 Decode 解析掉的那些瓦片」:
// Decode 认的是「文件名能通过瓦片校验」,不认目录,所以判断也要按文件名来,
// 否则同一个文件会被解析一遍、又原样写一遍,包里出现两份。
func RawLayers(data []byte) (map[string][]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
out := map[string][]byte{}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
p := path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))
// Model/* 之下就是已探索层,由 fog_blocks 重建
if rest := strings.TrimPrefix(p, "Model/"); rest != p {
if i := strings.Index(rest, "/"); i > 0 && rest[:i] == "*" {
continue
}
}
// 文件名能通过瓦片校验的会被 Decode 解析成块,写回时也走 Model/*,这里不重复留存
if _, ok := tileIDFromName(path.Base(p)); ok {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
// 留存的条目不解析、也不该被截断:超限就报错,别把半份数据悄悄带进导出包
raw, err := io.ReadAll(io.LimitReader(rc, layerMaxBytes+1))
rc.Close()
if err != nil {
return nil, err
}
if int64(len(raw)) > layerMaxBytes {
return nil, fmt.Errorf("条目 %s 超过 %d 字节,疑似异常快照", p, layerMaxBytes)
}
out[p] = raw
}
return out, nil
}
+188
View File
@@ -0,0 +1,188 @@
package fog
import (
"fmt"
"hash/fnv"
"math"
"strconv"
"rond-with-you/internal/geo"
)
// Mask 是一块「对访客隐藏」的圆形区域:中心(**GCJ-02**,与 places 同系)+ 半径(米)。
//
// 迷雾网格是 WGS-84、底图可能是 GCJ-02,所以判定前必须把中心换算到网格坐标,
// 与渲染瓦片时对 GCJ 底图做的纠偏是同一套变换——不然遮罩会整体偏出几百米。
type Mask struct {
ID int64
Name string
Lat float64
Lon float64
RadiusM float64
}
// maskGrid 是遮罩在基网格里的预计算形式。
//
// 网格的纵向边长恒为 CellSideM、横向边长随纬度乘 cos(φ) 收缩,
// 所以「实地上的圆」在网格里是**椭圆**(横向更宽)。半径换算错这一步,
// 高纬度地区会明显偏窄。
type maskGrid struct {
m Mask
gx, gy float64 // 圆心(格坐标,浮点)
rx, ry float64 // 横向 / 纵向半径(格)
x0, x1 int // 包围盒(格),用于快速排除
y0, y1 int
}
func newMaskGrid(m Mask) maskGrid {
wlat, wlon := geo.GCJ02ToWGS84(m.Lat, m.Lon)
gx, gy := mercX(wlon), mercY(wlat)
cos := math.Cos(wlat * math.Pi / 180)
if cos < 1e-6 {
cos = 1e-6
}
ry := m.RadiusM / CellSideM
rx := ry / cos
g := maskGrid{m: m, gx: gx, gy: gy, rx: rx, ry: ry}
g.x0 = int(math.Floor(gx-rx)) - 1
g.x1 = int(math.Ceil(gx+rx)) + 1
g.y0 = int(math.Floor(gy-ry)) - 1
g.y1 = int(math.Ceil(gy+ry)) + 1
return g
}
func (g maskGrid) hitCell(cx, cy int) bool {
if cx < g.x0 || cx > g.x1 || cy < g.y0 || cy > g.y1 {
return false
}
dx := (float64(cx) + 0.5 - g.gx) / g.rx
dy := (float64(cy) + 0.5 - g.gy) / g.ry
return dx*dx+dy*dy <= 1
}
// MaskSet 是一组遮罩的判定集合。零值/nil 表示不遮任何东西。
type MaskSet struct {
grids []maskGrid
}
// NewMaskSet 预计算一组遮罩;传空切片会返回 nil。
func NewMaskSet(masks []Mask) *MaskSet {
if len(masks) == 0 {
return nil
}
s := &MaskSet{grids: make([]maskGrid, 0, len(masks))}
for _, m := range masks {
if m.RadiusM <= 0 {
continue
}
s.grids = append(s.grids, newMaskGrid(m))
}
if len(s.grids) == 0 {
return nil
}
return s
}
// Empty 报告集合里有没有遮罩(nil 安全)。
func (s *MaskSet) Empty() bool { return s == nil || len(s.grids) == 0 }
// Hit 报告某个全球格是否被遮罩盖住。nil 安全,未命中时只花一次包围盒比较。
func (s *MaskSet) Hit(cx, cy int) bool {
if s == nil {
return false
}
for _, g := range s.grids {
if g.hitCell(cx, cy) {
return true
}
}
return false
}
// HitLatLon 按经纬度判定(同样传 GCJ-02),用于地点级的过滤。
func (s *MaskSet) HitLatLon(lat, lon float64) bool {
if s == nil {
return false
}
wlat, wlon := geo.GCJ02ToWGS84(lat, lon)
return s.Hit(CellOf(wlat, wlon))
}
// maskedStats 统计被遮罩盖住的置位格数与面积(m²),用于从「已探索」的总量里扣掉。
//
// 三个要点:
// - 逐格判一次 masks.Hit,而不是按遮罩逐个累加:遮罩可以互相重叠,按遮罩累加会把
// 重叠区域的格子算好几遍(格数扣多,和面积也会对不上);
// - 只遍历遮罩包围盒覆盖到的块(块坐标去重),不扫全库;
// - 面积用 cellAreaM2,与 AreaKm2 同一套算法。
func maskedStats(blocks map[[2]int][]byte, masks *MaskSet) (cells int, areaM2 float64) {
if masks.Empty() {
return 0, 0
}
seen := make(map[[2]int]bool)
for _, g := range masks.grids {
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
key := [2]int{bx, by}
if seen[key] {
continue // 两块遮罩可能盖到同一块,不能算两遍
}
seen[key] = true
bm, ok := blocks[key]
if !ok || len(bm) != bitmapSize {
continue
}
n := 0
for row := 0; row < BlockCells; row++ {
line := bm[row*8 : row*8+8]
for colB, v := range line {
if v == 0 {
continue
}
for bit := 0; bit < 8; bit++ {
if v&(0x80>>bit) == 0 {
continue
}
if masks.Hit(bx*BlockCells+colB*8+bit, by*BlockCells+row) {
n++
}
}
}
}
if n > 0 {
cells += n
areaM2 += float64(n) * cellAreaM2(bx, by)
}
}
}
}
return cells, areaM2
}
// MaskedStats 在读锁内统计被遮罩盖住的部分。调用方本来拿不到块表(它不导出),
// 而走 All() 会为一次统计复制并排序整张块表(上万块),所以这里直接在索引上算。
func (ix *Index) MaskedStats(masks *MaskSet) (cells int, areaM2 float64) {
if masks.Empty() {
return 0, 0
}
ix.mu.RLock()
defer ix.mu.RUnlock()
return maskedStats(ix.blocks, masks)
}
// MaskFingerprint 返回一组遮罩的短指纹,供前端把它拼进迷雾瓦片的 URL。
//
// 遮罩不在 fogIndex 里(每次请求都实时读库),所以服务端改完立刻生效;
// 但访客的瓦片是允许公共缓存的,URL 不变的话浏览器 / CDN 会继续拿缓存里那份
// (最长 1 小时)——设完遮罩看不到变化,很容易被当成「遮罩不生效」。
// 指纹变了 URL 就变,缓存自然失效。
func MaskFingerprint(masks []Mask) string {
if len(masks) == 0 {
return "0"
}
h := fnv.New64a()
for _, m := range masks {
fmt.Fprintf(h, "%d|%.6f|%.6f|%.1f|", m.ID, m.Lat, m.Lon, m.RadiusM)
}
return strconv.FormatUint(h.Sum64(), 16)
}
+143
View File
@@ -0,0 +1,143 @@
package fog
import (
"math"
"testing"
)
// allOnesBlock 造一块位图全置位的块(每格都已探索)。
func allOnesBlock(gx, gy int) Block {
bm := make([]byte, BlockCells*BlockCells/8)
for i := range bm {
bm[i] = 0xFF
}
return Block{GX: gx, GY: gy, Bitmap: bm}
}
// blocksAround 造出覆盖这些遮罩包围盒的全部块。
func blocksAround(masks ...Mask) []Block {
var out []Block
seen := map[[2]int]bool{}
for _, m := range masks {
g := newMaskGrid(m)
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
if seen[[2]int{bx, by}] {
continue
}
seen[[2]int{bx, by}] = true
out = append(out, allOnesBlock(bx, by))
}
}
}
return out
}
// TestMaskedCellsUnion 遮罩重叠时,被遮住的格子只能算一次。
// 曾经的实现是「按遮罩逐个累加命中格数」,重叠区域会被统计两三遍,
// 于是覆盖率里扣掉的格数偏大、与面积(另一套逐格算法)对不上。
func TestMaskedCellsUnion(t *testing.T) {
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
onlyA, onlyB := NewMaskSet([]Mask{a}), NewMaskSet([]Mask{b})
both := NewMaskSet([]Mask{a, b})
if onlyA == nil || onlyB == nil || both == nil {
t.Fatal("遮罩集合不应为空")
}
blocks := blocksAround(a, b)
var want int
for _, blk := range blocks {
for row := 0; row < BlockCells; row++ {
for col := 0; col < BlockCells; col++ {
if both.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
want++
}
}
}
}
// 用例前提:两块遮罩真的有重叠,否则测不出重复计数
if sum := countMasked(blocks, onlyA) + countMasked(blocks, onlyB); sum <= want {
t.Fatalf("两块遮罩没有重叠(单独 %d + %d,并集 %d),用例无效", sum, want, want)
}
if got := countMasked(blocks, both); got != want {
t.Errorf("重叠遮罩下 MaskedCells = %d,应为并集 %d(偏大即重复计数)", got, want)
}
}
// TestMaskedAreaMatchesCells 面积与格数必须出自同一个集合:
// 扣掉的面积换算成格数后要与统计出的格数相等。
func TestMaskedAreaMatchesCells(t *testing.T) {
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
set := NewMaskSet([]Mask{a, b})
blocks := blocksAround(a, b)
// 逐块按块中心纬度算格子面积(与 AreaKm2Excluding 内同一套算法)
var lostM2 float64
for _, blk := range blocks {
_, lat := cellLonLat(blk.GX*BlockCells+BlockCells/2, blk.GY*BlockCells+BlockCells/2)
cell := CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
n := 0
for row := 0; row < BlockCells; row++ {
for col := 0; col < BlockCells; col++ {
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
n++
}
}
}
lostM2 += float64(n) * cell
}
byKey := map[[2]int][]byte{}
for _, blk := range blocks {
byKey[[2]int{blk.GX, blk.GY}] = blk.Bitmap
}
gotCells, gotM2 := maskedStats(byKey, set)
if gotCells != countMasked(blocks, set) {
t.Errorf("遮罩格数 %d,暴力统计 %d", gotCells, countMasked(blocks, set))
}
if diff := math.Abs(gotM2 - lostM2); diff > 1e-9 {
t.Errorf("遮罩面积 %.6f m²,按格数算应为 %.6f m²(差 %.3f)", gotM2, lostM2, diff)
}
}
// countMasked 暴力数一遍被遮住的格子(测试基准)。
func countMasked(blocks []Block, set *MaskSet) int {
n := 0
for _, blk := range blocks {
for row := 0; row < BlockCells; row++ {
for col := 0; col < BlockCells; col++ {
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
n++
}
}
}
}
return n
}
// TestMaskFingerprint 指纹要能反映遮罩的任何改动,否则访客的瓦片缓存不会失效。
func TestMaskFingerprint(t *testing.T) {
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 1500}
if MaskFingerprint(nil) != "0" {
t.Error("空遮罩的指纹应为 0")
}
base := MaskFingerprint([]Mask{a})
cases := map[string][]Mask{
"多了一块": {a, b},
"半径改了": {{ID: 1, Lat: a.Lat, Lon: a.Lon, RadiusM: 3000}},
"位置挪了": {{ID: 1, Lat: a.Lat + 0.001, Lon: a.Lon, RadiusM: a.RadiusM}},
"换成另一块": {b},
}
for name, list := range cases {
if MaskFingerprint(list) == base {
t.Errorf("%s 之后指纹没变,访客缓存不会失效", name)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package fog
import (
"image"
"math"
"rond-with-you/internal/geo"
)
// 迷雾覆盖层配色:填充用偏亮的蓝,边界再描一圈更深的轮廓,
// 这样在浅色底图上也能一眼看出「已揭开」的范围。
var (
FogColor = [4]byte{48, 122, 224, 132}
FogEdge = [4]byte{16, 52, 132, 232}
)
// RenderTile 渲染一张 256x256 的迷雾透明瓦片。
//
// z/x/y 为 slippy 瓦片坐标。crs 指明底图坐标系:
// 迷雾网格是 WGS-84,而高德这类底图把 GCJ-02 坐标画在自己的名义位置上,
// 所以 gcj02 底图下每个块要先做 WGS84→GCJ02 正变换再投影;
// 块只有 600m 见方,偏移场在块内变化不足 1px,按块中心算一次即可。
// wgs84 底图(天地图/自备)则不偏移。
// masks 是被遮罩盖住、对访客不显示的区域(nil = 全部显示)。被遮住的格子既不画填充,
// 也不算作「未探索」的邻居——否则遮罩边缘会被描出一圈轮廓,等于把隐藏范围本身暴露了。
func RenderTile(blocks map[[2]int][]byte, z, x, y int, crs string, masks *MaskSet) *image.NRGBA {
if z < 0 || z > 22 {
return nil
}
step := WorldCells >> z // 该瓦片每边覆盖的格子数
scale := 256.0 / float64(step)
ox, oy := float64(x*step), float64(y*step)
// 第一遍只画掩码,第二遍再按邻域决定填充色还是轮廓色:
// 只有紧邻「未探索」的像素才描边,已探索区域内部保持平涂。
var mask [256 * 256]bool
// hole 记录「因为遮罩而不画」的像素,第二遍描边时把它当作正常像素跳过
var hole []bool
if !masks.Empty() {
hole = make([]bool, 256*256)
}
mark := func(px, py int) {
if px < 0 || px > 255 || py < 0 || py > 255 {
return
}
mask[py*256+px] = true
}
fill := func(px, py, w, h int) {
for r := 0; r < h; r++ {
for c := 0; c < w; c++ {
mark(px+c, py+r)
}
}
}
// markHole 把一段像素标成「被遮罩隐去」
markHole := func(px, py, w, h int) {
if hole == nil {
return
}
for r := 0; r < h; r++ {
for c := 0; c < w; c++ {
x, y := px+c, py+r
if x >= 0 && x <= 255 && y >= 0 && y <= 255 {
hole[y*256+x] = true
}
}
}
}
// 直接遍历块表而不是全球块范围:低缩放级下范围循环会膨胀到数十亿次空查询。
// GCJ 偏移最多约 700m(74 格),粗裁剪时两侧各放宽两块保证不漏。
const margin = 2 * BlockCells
lo, hi := float64(x*step-margin), float64((x+1)*step+margin)
rlo, rhi := float64(y*step-margin), float64((y+1)*step+margin)
shift := crs == "gcj02"
for key, bm := range blocks {
gbx, gby := key[0], key[1]
bx, by := float64(gbx*BlockCells), float64(gby*BlockCells)
if bx+BlockCells <= lo || bx >= hi || by+BlockCells <= rlo || by >= rhi {
continue
}
ddx, ddy := 0.0, 0.0
if shift {
ddx, ddy = blockShift(gbx, gby)
}
u0 := (bx + ddx - ox) * scale
v0 := (by + ddy - oy) * scale
if scale >= 1 {
w := int(math.Ceil(scale))
for row := 0; row < BlockCells; row++ {
line := bm[row*8 : row*8+8]
if line[0]|line[1]|line[2]|line[3]|line[4]|line[5]|line[6]|line[7] == 0 {
continue
}
py := int(math.Floor(v0 + float64(row)*scale))
for colB, byteVal := range line {
if byteVal == 0 {
continue
}
for bit := 0; bit < 8; bit++ {
if byteVal&(0x80>>bit) == 0 {
continue
}
px := int(math.Floor(u0 + float64(colB*8+bit)*scale))
if masks.Hit(gbx*BlockCells+colB*8+bit, gby*BlockCells+row) {
markHole(px, py, w, w)
continue
}
fill(px, py, w, w)
}
}
}
continue
}
// 格子比像素还小(低缩放级):整块只要有探索就整块涂上。
// 这时按块中心判遮罩——缩到这种级别,块本身还不到一个像素。
if popcountSum(bm) == 0 {
continue
}
if masks.Hit(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2) {
continue
}
u1 := (bx + BlockCells + ddx - ox) * scale
v1 := (by + BlockCells + ddy - oy) * scale
px0, py0 := int(math.Floor(u0)), int(math.Floor(v0))
px1, py1 := int(math.Ceil(u1)), int(math.Ceil(v1))
if px1-px0 < 1 {
px1 = px0 + 1
}
if py1-py0 < 1 {
py1 = py0 + 1
}
fill(px0, py0, px1-px0, py1-py0)
}
// 第二遍着色。瓦片外的邻格按「已探索」处理,避免在瓦片接缝处
// 凭空描出一条网格线(真实边界由邻瓦片自己画)。
img := image.NewNRGBA(image.Rect(0, 0, 256, 256))
for py := 0; py < 256; py++ {
for px := 0; px < 256; px++ {
if !mask[py*256+px] {
continue
}
// 已探索的邻居:被遮罩隐去的也算「不算未探索」,免得把隐藏范围描出轮廓
lit := func(i int) bool { return mask[i] || (hole != nil && hole[i]) }
edge := false
if px > 0 && !lit(py*256+px-1) {
edge = true
} else if px < 255 && !lit(py*256+px+1) {
edge = true
} else if py > 0 && !lit((py-1)*256+px) {
edge = true
} else if py < 255 && !lit((py+1)*256+px) {
edge = true
}
c := FogColor
if edge {
c = FogEdge
}
i := (py*256 + px) * 4
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c[0], c[1], c[2], c[3]
}
}
return img
}
// blockShift 返回该块中心在 GCJ-02 底图下的像素偏移(单位:基网格格子)。
func blockShift(gbx, gby int) (float64, float64) {
lon, lat := cellLonLat(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2)
glat, glon := geo.WGS84ToGCJ02(lat, lon)
return mercX(glon) - mercX(lon), mercY(glat) - mercY(lat)
}
func mercX(lon float64) float64 { return (lon + 180.0) / 360.0 * WorldCells }
func mercY(lat float64) float64 {
rad := lat * math.Pi / 180
return (math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells
}
func cellLonLat(cx, cy int) (float64, float64) {
lon := (float64(cx)+0.5)/WorldCells*360.0 - 180.0
n := 2 * math.Pi * (float64(cy) + 0.5) / WorldCells
lat := math.Atan(math.Sinh(math.Pi-n)) * 180.0 / math.Pi
return lon, lat
}
+300
View File
@@ -0,0 +1,300 @@
package fog
import (
"database/sql"
"errors"
"fmt"
"sync"
"time"
)
// Index 是某个用户迷雾位图的内存索引,按块粒度持有,
// 供瓦片渲染做范围查询;写路径先落库再刷新内存。
type Index struct {
mu sync.RWMutex
blocks map[[2]int][]byte
cells int
// areaM2 是全部置位格的实地面积(m²),写入索引时一并算好。
// 覆盖率统计每次都要这个数,现算得把上万块的位图全扫一遍。
areaM2 float64
ver int64 // 最近一次更新的 unix 秒,用作瓦片缓存版本号
}
// NewIndex 建一个空索引。
func NewIndex() *Index {
return &Index{blocks: map[[2]int][]byte{}}
}
// Load 从库里加载某用户的全部迷雾块。
func (ix *Index) Load(db *sql.DB, userID int64) error {
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks WHERE user_id=$1`, userID)
if err != nil {
return err
}
defer rows.Close()
blocks := map[[2]int][]byte{}
cells, areaM2 := 0, 0.0
for rows.Next() {
var gx, gy int
var bm []byte
if err := rows.Scan(&gx, &gy, &bm); err != nil {
return err
}
blocks[[2]int{gx, gy}] = bm
n := popcountSum(bm)
cells += n
areaM2 += float64(n) * cellAreaM2(gx, gy)
}
if err := rows.Err(); err != nil {
return err
}
ix.mu.Lock()
ix.blocks = blocks
ix.cells = cells
ix.areaM2 = areaM2
ix.ver = time.Now().Unix()
ix.mu.Unlock()
return nil
}
// LoadAll 从库里加载全部迷雾块(站点为单用户展示,公开读路径不区分归属)。
func (ix *Index) LoadAll(db *sql.DB) error {
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks`)
if err != nil {
return err
}
defer rows.Close()
blocks := map[[2]int][]byte{}
cells, areaM2 := 0, 0.0
for rows.Next() {
var gx, gy int
var bm []byte
if err := rows.Scan(&gx, &gy, &bm); err != nil {
return err
}
blocks[[2]int{gx, gy}] = bm
n := popcountSum(bm)
cells += n
areaM2 += float64(n) * cellAreaM2(gx, gy)
}
if err := rows.Err(); err != nil {
return err
}
ix.mu.Lock()
ix.blocks = blocks
ix.cells = cells
ix.areaM2 = areaM2
ix.ver = time.Now().Unix()
ix.mu.Unlock()
return nil
}
// Stats 返回块数与格子数。
func (ix *Index) Stats() (blocks, cells int) {
ix.mu.RLock()
defer ix.mu.RUnlock()
return len(ix.blocks), ix.cells
}
// AreaKm2 返回已探索面积(km²,按块中心纬度做 cos 修正)。写入索引时算好,这里只读。
func (ix *Index) AreaKm2() float64 {
ix.mu.RLock()
defer ix.mu.RUnlock()
return ix.areaM2 / 1e6
}
// Version 返回数据版本号,随每次写入变化。
func (ix *Index) Version() int64 {
ix.mu.RLock()
defer ix.mu.RUnlock()
return ix.ver
}
// View 在读锁内执行渲染查询,避免渲染中途被写换代。
func (ix *Index) View(fn func(blocks map[[2]int][]byte)) {
ix.mu.RLock()
defer ix.mu.RUnlock()
fn(ix.blocks)
}
// Import 把解析出的块写入库:merge 模式按位或合并进已有数据,
// replace 模式先清空该用户全部迷雾再写入。完成后刷新内存索引。
func (ix *Index) Import(db *sql.DB, userID int64, blocks []Block, merge bool) (applied int, err error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if !merge {
if _, err := tx.Exec(`DELETE FROM fog_blocks WHERE user_id=$1`, userID); err != nil {
return 0, err
}
}
var upsert *sql.Stmt
if upsert, err = tx.Prepare(`INSERT INTO fog_blocks (user_id, gbx, gby, bitmap, updated_at)
VALUES ($1,$2,$3,$4,now())
ON CONFLICT (user_id, gbx, gby) DO UPDATE SET bitmap=$4, updated_at=now()`); err != nil {
return 0, err
}
defer upsert.Close()
for _, b := range blocks {
if merge {
var old []byte
err := tx.QueryRow(`SELECT bitmap FROM fog_blocks WHERE user_id=$1 AND gbx=$2 AND gby=$3`,
userID, b.GX, b.GY).Scan(&old)
switch {
case err == nil && len(old) == len(b.Bitmap):
for i := range b.Bitmap {
b.Bitmap[i] |= old[i]
}
case err != nil && !errors.Is(err, sql.ErrNoRows):
return 0, err
}
}
if _, err := upsert.Exec(userID, b.GX, b.GY, b.Bitmap); err != nil {
return 0, err
}
applied++
}
if err := tx.Commit(); err != nil {
return 0, err
}
if err := ix.Load(db, userID); err != nil {
return applied, err
}
return applied, nil
}
// CellLit 报告某个全局格是否已探索。
func (ix *Index) CellLit(cx, cy int) bool {
ix.mu.RLock()
defer ix.mu.RUnlock()
bm, ok := ix.blocks[[2]int{cx / BlockCells, cy / BlockCells}]
if !ok {
return false
}
col, row := cx%BlockCells, cy%BlockCells
return bm[row*8+col/8]&(0x80>>(col%8)) != 0
}
// All 返回全部块(导出用)。
func (ix *Index) All() []Block {
ix.mu.RLock()
defer ix.mu.RUnlock()
out := make([]Block, 0, len(ix.blocks))
for k, bm := range ix.blocks {
out = append(out, Block{GX: k[0], GY: k[1], Bitmap: bm})
}
sortBlocks(out)
return out
}
// LoadOrNewIndex 返回某用户的迷雾索引;首次访问时从库里加载。
func LoadOrNewIndex(db *sql.DB, userID int64, existing *Index) (*Index, error) {
if existing != nil {
return existing, nil
}
ix := NewIndex()
if err := ix.Load(db, userID); err != nil {
return nil, fmt.Errorf("加载迷雾数据: %w", err)
}
return ix, nil
}
// SaveRawLayers 保存快照里其它图层的原始条目(path -> zlib 流本身,一个字节都不动)。
// replace 为真时先清空(「整体替换」与「清空迷雾」都该把旧的一并带走),
// 否则同名覆盖——后传的快照更完整。
func (ix *Index) SaveRawLayers(db *sql.DB, userID int64, layers map[string][]byte, replace bool) error {
if !replace && len(layers) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if replace {
if _, err := tx.Exec(`DELETE FROM fog_raw_files WHERE user_id=$1`, userID); err != nil {
return err
}
}
for p, data := range layers {
if _, err := tx.Exec(`INSERT INTO fog_raw_files (user_id, path, data) VALUES ($1,$2,$3)
ON CONFLICT (user_id, path) DO UPDATE SET data=EXCLUDED.data`, userID, p, data); err != nil {
return err
}
}
return tx.Commit()
}
// DropRawLayers 丢弃留存的其它图层。清空迷雾时调用(与 fog_blocks 一样是站点级,
// 不按用户区分),否则导出的快照里会留着已经没有对应 Model/* 瓦片的图层条目。
func DropRawLayers(db *sql.DB) error {
_, err := db.Exec(`DELETE FROM fog_raw_files`)
return err
}
// RawLayers 读出留存的非已探索层文件(path -> 原始字节)。
func LoadRawLayers(db *sql.DB, userID int64) (map[string][]byte, error) {
rows, err := db.Query(`SELECT path, data FROM fog_raw_files WHERE user_id=$1`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]byte{}
for rows.Next() {
var p string
var data []byte
if err := rows.Scan(&p, &data); err != nil {
return nil, err
}
out[p] = data
}
return out, rows.Err()
}
// ---------- 迷雾遮罩区(对访客隐藏的范围) ----------
// SaveMask 新增或更新一块遮罩(ID 为 0 时新增),返回其 ID。
func SaveMask(db *sql.DB, userID int64, m Mask) (int64, error) {
if m.ID > 0 {
_, err := db.Exec(`UPDATE fog_masks SET name=$1, lat=$2, lon=$3, radius_m=$4 WHERE id=$5 AND user_id=$6`,
m.Name, m.Lat, m.Lon, m.RadiusM, m.ID, userID)
if err != nil {
return 0, err
}
return m.ID, nil
}
var id int64
err := db.QueryRow(`INSERT INTO fog_masks (user_id, name, lat, lon, radius_m) VALUES ($1,$2,$3,$4,$5)
RETURNING id`, userID, m.Name, m.Lat, m.Lon, m.RadiusM).Scan(&id)
return id, err
}
// DeleteMask 删除一块遮罩。
func DeleteMask(db *sql.DB, userID, id int64) error {
_, err := db.Exec(`DELETE FROM fog_masks WHERE id=$1 AND user_id=$2`, id, userID)
return err
}
// LoadMasks 读出全部遮罩。遮罩与迷雾一样是站点级数据(单站长的站点),
// 展示与渲染时不分归属,与 fog_blocks 的 LoadAll 保持一致。
func LoadMasks(db *sql.DB) ([]Mask, error) {
rows, err := db.Query(`SELECT id, COALESCE(name,''), lat, lon, radius_m FROM fog_masks
WHERE radius_m > 0 ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Mask
for rows.Next() {
var m Mask
if err := rows.Scan(&m.ID, &m.Name, &m.Lat, &m.Lon, &m.RadiusM); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
+93
View File
@@ -0,0 +1,93 @@
package geo
import "math"
const earthRadiusKm = 6371.0088
// Haversine 返回两点间大圆距离(公里)。输入为 WGS-84 经纬度。
func Haversine(lat1, lon1, lat2, lon2 float64) float64 {
r1, r2 := rad(lat1), rad(lat2)
dlat := rad(lat2 - lat1)
dlon := rad(lon2 - lon1)
a := math.Sin(dlat/2)*math.Sin(dlat/2) +
math.Cos(r1)*math.Cos(r2)*math.Sin(dlon/2)*math.Sin(dlon/2)
return 2 * earthRadiusKm * math.Asin(math.Min(1, math.Sqrt(a)))
}
func rad(d float64) float64 { return d * math.Pi / 180 }
// Bearing 返回从点 1 指向点 2 的方位角(度,正北为 0,顺时针;范围 -180~180)。
// 用来把「坐标移动了多远」说成「往东南 520 米」这种能对上的话。
func Bearing(lat1, lon1, lat2, lon2 float64) float64 {
dLon := rad(lon2 - lon1)
y := math.Sin(dLon) * math.Cos(rad(lat2))
x := math.Cos(rad(lat1))*math.Sin(rad(lat2)) -
math.Sin(rad(lat1))*math.Cos(rad(lat2))*math.Cos(dLon)
return math.Atan2(y, x) * 180 / math.Pi
}
// 中国境内判定所用的粗略外接矩形
func inChina(lat, lon float64) bool {
return lon >= 73.66 && lon <= 135.05 && lat >= 3.86 && lat <= 53.55
}
const (
axxisPi = math.Pi * 3000.0 / 180.0
ee = 0.00669342162296594323
semiMajor = 6378245.0
)
// WGS84ToGCJ02 将 GPS 原始坐标转为火星坐标(GCJ-02)。
// 用于把原始定位、世界迷雾等 WGS-84 数据叠加到国内底图上。
func WGS84ToGCJ02(lat, lon float64) (float64, float64) {
if !inChina(lat, lon) {
return lat, lon
}
dLat := transformLat(lon-105.0, lat-35.0)
dLon := transformLon(lon-105.0, lat-35.0)
radLat := lat / 180.0 * math.Pi
magic := math.Sin(radLat)
magic = 1 - ee*magic*magic
sqrtMagic := math.Sqrt(magic)
dLat = (dLat * 180.0) / ((semiMajor * (1 - ee)) / (magic * sqrtMagic) * math.Pi)
dLon = (dLon * 180.0) / (semiMajor / sqrtMagic * math.Cos(radLat) * math.Pi)
return lat + dLat, lon + dLon
}
func transformLat(x, y float64) float64 {
ret := -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.Sqrt(math.Abs(x))
ret += (20.0*math.Sin(6.0*x*math.Pi) + 20.0*math.Sin(2.0*x*math.Pi)) * 2.0 / 3.0
ret += (20.0*math.Sin(y*math.Pi) + 40.0*math.Sin(y/3.0*math.Pi)) * 2.0 / 3.0
ret += (160.0*math.Sin(y/12.0*math.Pi) + 320*math.Sin(y*math.Pi/30.0)) * 2.0 / 3.0
return ret
}
func transformLon(x, y float64) float64 {
ret := 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.Sqrt(math.Abs(x))
ret += (20.0*math.Sin(6.0*x*math.Pi) + 20.0*math.Sin(2.0*x*math.Pi)) * 2.0 / 3.0
ret += (20.0*math.Sin(x*math.Pi) + 40.0*math.Sin(x/3.0*math.Pi)) * 2.0 / 3.0
ret += (150.0*math.Sin(x/12.0*math.Pi) + 300.0*math.Sin(x/30.0*math.Pi)) * 2.0 / 3.0
return ret
}
// GCJ02ToWGS84 是 WGS84ToGCJ02 的逆运算。
// 正变换没有解析反函数,用不动点迭代逼近;偏移量随经纬度缓变,5 次迭代已收敛到厘米级。
func GCJ02ToWGS84(lat, lon float64) (float64, float64) {
if !inChina(lat, lon) {
return lat, lon
}
wLat, wLon := lat, lon
for i := 0; i < 5; i++ {
gLat, gLon := WGS84ToGCJ02(wLat, wLon)
wLat += lat - gLat
wLon += lon - gLon
}
return wLat, wLon
}
// GCJ02ToWGS84InPlace 与 GCJ02ToWGS84 相同,供批量换算时复用调用点。
func GCJ02ToWGS84InPlace(points [][2]float64) {
for i := range points {
points[i][0], points[i][1] = GCJ02ToWGS84(points[i][0], points[i][1])
}
}
+157
View File
@@ -0,0 +1,157 @@
// Package gpx 解析 GPX 轨迹文件(标准 WGS-84 坐标),
// 用来给 rond 的「地点到地点」直线位移补上真实路径。
package gpx
import (
"encoding/xml"
"errors"
"fmt"
"math"
"strings"
"time"
)
// Track 是一条轨迹(GPX 的 trk / rte),坐标是 WGS-84。
type Track struct {
Name string
Points []Point
Time time.Time
}
type Point struct {
Lat float64
Lon float64
Ele float64
T time.Time
}
type gpxDoc struct {
XMLName xml.Name `xml:"gpx"`
Name string `xml:"metadata>name"`
Trks []trk `xml:"trk"`
Rtes []rte `xml:"rte"`
}
type trk struct {
Name string `xml:"name"`
Trkseg []trkseg `xml:"trkseg"`
}
type trkseg struct {
Pts []wpt `xml:"trkpt"`
}
type rte struct {
Name string `xml:"name"`
Pts []wpt `xml:"rtept"`
}
type wpt struct {
Lat float64 `xml:"lat,attr"`
Lon float64 `xml:"lon,attr"`
Ele float64 `xml:"ele"`
T string `xml:"time"`
}
// Parse 解析 GPX 文本,返回其中的全部轨迹。trk 优先,没有则退回 rte。
func Parse(data []byte) ([]Track, error) {
var doc gpxDoc
if err := xml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("GPX 解析失败: %w", err)
}
var out []Track
base := strings.TrimSpace(doc.Name)
for _, t := range doc.Trks {
tr := Track{Name: firstNonEmpty(strings.TrimSpace(t.Name), base)}
for _, seg := range t.Trkseg {
for _, p := range seg.Pts {
if p.Lat == 0 && p.Lon == 0 {
continue
}
pt := Point{Lat: p.Lat, Lon: p.Lon, Ele: p.Ele}
if p.T != "" {
pt.T, _ = time.Parse(time.RFC3339, strings.TrimSpace(p.T))
}
tr.Points = append(tr.Points, pt)
}
}
if len(tr.Points) > 1 {
if tr.Time.IsZero() {
tr.Time = tr.Points[0].T
}
out = append(out, tr)
}
}
for _, r := range doc.Rtes {
tr := Track{Name: firstNonEmpty(strings.TrimSpace(r.Name), base)}
for _, p := range r.Pts {
if p.Lat == 0 && p.Lon == 0 {
continue
}
pt := Point{Lat: p.Lat, Lon: p.Lon}
if p.T != "" {
pt.T, _ = time.Parse(time.RFC3339, strings.TrimSpace(p.T))
}
tr.Points = append(tr.Points, pt)
}
if len(tr.Points) > 1 {
if tr.Time.IsZero() {
tr.Time = tr.Points[0].T
}
out = append(out, tr)
}
}
if len(out) == 0 {
return nil, errors.New("文件里没有找到轨迹点(需要 trk/trkpt 或 rte/rtept)")
}
return out, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// LengthKm 计算轨迹里程(Haversine)。
func (t Track) LengthKm() float64 {
sum := 0.0
for i := 1; i < len(t.Points); i++ {
sum += haversine(t.Points[i-1], t.Points[i])
}
return sum
}
// BBox 返回 [minLon, minLat, maxLon, maxLat]。
func (t Track) BBox() [4]float64 {
b := [4]float64{180, 90, -180, -90}
for _, p := range t.Points {
if p.Lon < b[0] {
b[0] = p.Lon
}
if p.Lat < b[1] {
b[1] = p.Lat
}
if p.Lon > b[2] {
b[2] = p.Lon
}
if p.Lat > b[3] {
b[3] = p.Lat
}
}
return b
}
func haversine(a, b Point) float64 {
const r = 6371.0
dLat := (b.Lat - a.Lat) * math.Pi / 180
dLon := (b.Lon - a.Lon) * math.Pi / 180
la1 := a.Lat * math.Pi / 180
la2 := b.Lat * math.Pi / 180
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(la1)*math.Cos(la2)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * r * math.Asin(math.Sqrt(h))
}
+158
View File
@@ -0,0 +1,158 @@
package ingest
import (
"context"
"database/sql"
"fmt"
"rond-with-you/internal/geo"
)
// querier 让回填既能跑在导入事务里,也能跑在启动时的连接池上。
type querier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// BackfillCoordSource 给「有 ZLOCATION 留档」的地点补坐标来源,并自愈两类历史错误。
//
// 判据不能是「有留档行」——站点导出的备份会给新建地点合成 ZLOCATION 行,把
// ZRAWLATITUDE 写成 GCJ02ToWGS84(导出那一刻的显示坐标)。这份包被回导之后,合成行
// 会被误当成真机的原始 GPS,于是照片导入的点被标成「rond 导入」。区分办法就是这条
// 往返关系本身:
//
// ZRAWLATITUDE == GCJ02ToWGS84(ZLATITUDE) (位级相等,因为值就是我们自己算的)
//
// 真机行的 ZRAW 是独立采样值,从不满足(实测基线 321 行零命中)。判据必须拿留档行
// **自己的** ZLATITUDE 去比,而不是 places.lat——后者会被后来的坐标编辑改掉。
//
// 顺带修一处历史笔误:早期回填把 src_lon 写成了显示经度 ZLONGITUDE,应为原始经度
// ZRAWLONGITUDE。按「src_lon 恰好等于显示经度」精确识别后改写,不碰用户手工订正过的值。
func BackfillCoordSource(ctx context.Context, q querier, datasetID int64) (int, error) {
rows, err := q.QueryContext(ctx, `SELECT p.src_pk, COALESCE(p.coord_source,''), p.src_lat, p.src_lon,
(r.raw->>'ZLATITUDE')::float8, (r.raw->>'ZLONGITUDE')::float8,
(r.raw->>'ZRAWLATITUDE')::float8, (r.raw->>'ZRAWLONGITUDE')::float8
FROM places p JOIN entity_raw r
ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
WHERE p.dataset_id=$1
AND (r.raw->>'ZLATITUDE') ~ '^-?[0-9.]+$'
AND (r.raw->>'ZLONGITUDE') ~ '^-?[0-9.]+$'
AND (r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'`, datasetID)
if err != nil {
return 0, fmt.Errorf("读留档行: %w", err)
}
defer rows.Close()
var fillPK []int64
var fillLat, fillLon []float64
var clearPK, fixPK []int64
var fixLat, fixLon []float64
for rows.Next() {
var pk int64
var source string
var srcLat, srcLon sql.NullFloat64
var zlat, zlon, rawLat, rawLon float64
if err := rows.Scan(&pk, &source, &srcLat, &srcLon, &zlat, &zlon, &rawLat, &rawLon); err != nil {
return 0, err
}
gLat, gLon := geo.GCJ02ToWGS84(zlat, zlon)
if gLat == zlat && gLon == zlon {
// 境外点不做 GCJ 偏移,ZRAW 与显示坐标本来就该相等。可合成行写的是
// GCJ02ToWGS84(显示坐标),在境外同样是恒等——两边长得一模一样,判不出来,
// 干脆不碰:宁可留空,也别把真机的境外点清掉。
continue
}
if gLat == rawLat && gLon == rawLon {
// 合成行:早期回填按「有留档」误判成了 rond,撤回它写下的那份
if source == "rond" && srcLat.Valid && srcLat.Float64 == rawLat {
clearPK = append(clearPK, pk)
}
continue
}
switch {
case source == "":
fillPK = append(fillPK, pk)
fillLat = append(fillLat, rawLat)
fillLon = append(fillLon, rawLon)
case source == "rond" && srcLon.Valid && srcLon.Float64 == zlon && srcLon.Float64 != rawLon:
fixPK = append(fixPK, pk)
fixLat = append(fixLat, rawLat)
fixLon = append(fixLon, rawLon)
}
}
if err := rows.Err(); err != nil {
return 0, err
}
changed := 0
if len(fillPK) > 0 {
res, err := q.ExecContext(ctx, `UPDATE places p SET coord_source='rond', coord_sys='wgs84',
src_lat=v.rlat, src_lon=v.rlon
FROM (SELECT unnest($2::bigint[]) AS pk, unnest($3::float8[]) AS rlat,
unnest($4::float8[]) AS rlon) v
WHERE p.dataset_id=$1 AND p.src_pk=v.pk AND COALESCE(p.coord_source,'')=''`,
datasetID, fillPK, fillLat, fillLon)
if err != nil {
return changed, fmt.Errorf("补坐标来源: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
changed += int(n)
}
}
if len(fixPK) > 0 {
res, err := q.ExecContext(ctx, `UPDATE places p SET src_lat=v.rlat, src_lon=v.rlon
FROM (SELECT unnest($2::bigint[]) AS pk, unnest($3::float8[]) AS rlat,
unnest($4::float8[]) AS rlon) v
WHERE p.dataset_id=$1 AND p.src_pk=v.pk`,
datasetID, fixPK, fixLat, fixLon)
if err != nil {
return changed, fmt.Errorf("修正原始坐标: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
changed += int(n)
}
}
if len(clearPK) > 0 {
res, err := q.ExecContext(ctx, `UPDATE places SET coord_source=NULL, coord_sys=NULL,
src_lat=NULL, src_lon=NULL
WHERE dataset_id=$1 AND src_pk=ANY($2::bigint[]) AND coord_source='rond'`,
datasetID, clearPK)
if err != nil {
return changed, fmt.Errorf("撤回误判的来源: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
changed += int(n)
}
}
return changed, nil
}
// BackfillAllCoordSource 对所有数据集跑一遍回填,供启动时自愈历史数据用。
// 单个数据集失败不阻断启动——留档是增强,不该挡住服务。
func BackfillAllCoordSource(ctx context.Context, d *sql.DB) error {
rows, err := d.QueryContext(ctx, `SELECT id FROM datasets ORDER BY id`)
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, id := range ids {
if _, err := BackfillCoordSource(ctx, d, id); err != nil {
return fmt.Errorf("数据集 %d: %w", id, err)
}
}
return nil
}
+414
View File
@@ -0,0 +1,414 @@
package ingest
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"time"
"rond-with-you/internal/geo"
"rond-with-you/internal/rond"
)
type Stats struct {
Visits int
Places int
RawVisits int
Skipped int
Identical bool // 内容与已有数据集完全一致,本次没有新建快照
FirstVisit *time.Time
LastVisit *time.Time
}
// File 计算文件摘要,用于去重。
func File(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// insertEntityRaw 把「实体 -> src_pk -> 完整原始行」写进 entity_raw。
// 单条失败不阻断导入(留档只影响导出保真度),但会记日志提示。
func insertEntityRaw(ctx context.Context, tx *sql.Tx, datasetID int64, rows map[string]map[int]map[string]any, skipped map[string]map[int]bool) error {
for entity, byPK := range rows {
for pk, raw := range byPK {
blob, err := json.Marshal(raw)
if err != nil {
log.Printf("留档 %s#%d 序列化失败: %v", entity, pk, err)
continue
}
// 传 string 而不是 []byte:pgx 会把 []byte 当 bytea,jsonb 列要文本
if _, err := tx.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (dataset_id, entity, src_pk) DO UPDATE SET raw=EXCLUDED.raw, skipped=EXCLUDED.skipped`,
datasetID, entity, pk, string(blob), skipped[entity][pk]); err != nil {
return fmt.Errorf("留档 %s 原始行: %w", entity, err)
}
}
}
return nil
}
// contentHash 给备份内容算指纹,用来识别「内容没变、只是重新导出了一次」的上传。
// rond 每次导出连 WAL 一起打包,字节必然不同,文件摘要挡不住这种重复;
// 但同一批到访的 Z_PK 是稳定的,所以按到访自然键排序后取摘要即可。
func contentHash(b *rond.Backup) string {
keys := make([]string, 0, len(b.Visits))
for _, v := range b.Visits {
if v.Arrival == nil {
continue
}
keys = append(keys, fmt.Sprintf("%d|%d", v.SrcPK, v.Arrival.Unix()))
}
sort.Strings(keys)
h := sha256.New()
for _, k := range keys {
h.Write([]byte(k))
h.Write([]byte{'\n'})
}
return hex.EncodeToString(h.Sum(nil))
}
// Import 把解析后的备份写入 PostgreSQL,整个过程在一个事务内完成。
func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha string, size int64, b *rond.Backup) (int64, *Stats, error) {
hash := contentHash(b)
// 留档 Core Data 结构快照(实体编号 / DDL / 关联表)。失败不致命,留 NULL。
metaJSON, _ := json.Marshal(b.Meta)
hasMeta := len(metaJSON) > 0 && string(metaJSON) != "null"
// 内容与已有数据集完全一致时直接复用,不新建快照,也不改动当前展示的那一份。
var prevID int64
var prev Stats
err := db.QueryRowContext(ctx, `SELECT id, visit_count, place_count, raw_visit_count FROM datasets
WHERE user_id=$1 AND content_hash=$2 AND status='done'
ORDER BY uploaded_at DESC LIMIT 1`, userID, hash).
Scan(&prevID, &prev.Visits, &prev.Places, &prev.RawVisits)
switch {
case err == nil:
if hasMeta {
if _, e := db.ExecContext(ctx, `UPDATE datasets SET coredata_meta=$1 WHERE id=$2`, metaJSON, prevID); e != nil {
log.Printf("补抓 Core Data 元信息失败: %v", e)
}
}
prev.Identical = true
return prevID, &prev, nil
case !errors.Is(err, sql.ErrNoRows):
return 0, nil, err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, nil, err
}
defer tx.Rollback()
var datasetID int64
err = tx.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, size_bytes, status)
VALUES ($1,$2,$3,$4,'importing') RETURNING id`, userID, originalName, sha, size).Scan(&datasetID)
if err != nil {
return 0, nil, fmt.Errorf("创建数据集: %w", err)
}
// 缓存 Core Data 元信息,导出 rondbackup 时据此重建库结构。
if hasMeta {
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET coredata_meta=$1 WHERE id=$2`, metaJSON, datasetID); err != nil {
return 0, nil, fmt.Errorf("写入 Core Data 元信息: %w", err)
}
}
// 站点模型装不下的行(缺必填字段)记在这里,导出时按留档原样写回。
rawSkipped := map[string]map[int]bool{}
skip := func(entity string, pk int) {
if rawSkipped[entity] == nil {
rawSkipped[entity] = map[int]bool{}
}
rawSkipped[entity][pk] = true
}
// 活动类型
actID := map[int]int64{}
homeAct, workAct := map[int]bool{}, map[int]bool{}
for _, a := range b.Activities {
var id int64
if err := tx.QueryRowContext(ctx, `INSERT INTO activities
(dataset_id, src_pk, name, color, icon, is_home, is_work, is_excluded, is_archived)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
datasetID, a.SrcPK, a.Name, a.Color, a.Icon, a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived).Scan(&id); err != nil {
return 0, nil, fmt.Errorf("写入活动类型: %w", err)
}
actID[a.SrcPK] = id
homeAct[a.SrcPK] = a.IsHome
workAct[a.SrcPK] = a.IsWork
}
// 标签
tagID := map[int]int64{}
for _, t := range b.Tags {
var id int64
if err := tx.QueryRowContext(ctx, `INSERT INTO tags (dataset_id, src_pk, name, color, group_name)
VALUES ($1,$2,$3,$4,NULLIF($5,'')) RETURNING id`,
datasetID, t.SrcPK, t.Name, t.Color, t.GroupName).Scan(&id); err != nil {
return 0, nil, fmt.Errorf("写入标签: %w", err)
}
tagID[t.SrcPK] = id
}
// 交通方式
transName, transColor, transIcon := map[int]string{}, map[int]string{}, map[int]string{}
for _, t := range b.Transports {
transName[t.SrcPK], transColor[t.SrcPK], transIcon[t.SrcPK] = t.Name, t.Color, t.Icon
}
// 地点
placeID := map[int]int64{}
type locAgg struct {
count int
dwell int64
first *time.Time
last *time.Time
}
agg := map[int]*locAgg{}
locCoord := map[int][2]float64{}
for _, l := range b.Locations {
var id int64
if err := tx.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, poi_category, lat, lon, country_code, province, city,
district, sublocality, thoroughfare, timezone)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
datasetID, l.SrcPK, l.Name, nullStr(l.POICategory), l.Lat, l.Lon,
nullStr(l.CountryCode), nullStr(l.Province), nullStr(l.City), nullStr(l.District),
nullStr(l.Sublocality), nullStr(l.Thoroughfare), nullStr(l.Timezone)).Scan(&id); err != nil {
return 0, nil, fmt.Errorf("写入地点: %w", err)
}
placeID[l.SrcPK] = id
agg[l.SrcPK] = &locAgg{}
locCoord[l.SrcPK] = [2]float64{l.Lat, l.Lon}
}
// 访问记录
visitID := map[int]int64{}
var first, last *time.Time
var vcount, skipped int
visitPlace := map[int]int{}
for _, v := range b.Visits {
if v.Arrival == nil {
skip("ZVISIT", v.SrcPK)
continue
}
var pid any
if v.LocationSrcPK != nil {
if id, ok := placeID[*v.LocationSrcPK]; ok {
pid = id
}
}
var aid any
isHome, isWork := false, false
if v.ActivitySrcPK != nil {
if id, ok := actID[*v.ActivitySrcPK]; ok {
aid = id
}
isHome, isWork = homeAct[*v.ActivitySrcPK], workAct[*v.ActivitySrcPK]
}
var dur any
if v.Departure != nil {
m := int(v.Departure.Sub(*v.Arrival).Minutes())
if m < 0 {
m = 0
}
dur = m
}
var id int64
err := tx.QueryRowContext(ctx, `INSERT INTO visits
(dataset_id, user_id, src_pk, place_id, activity_id, arrival, departure, duration_min,
is_home, is_work, bookmarked, is_user_added, remark, emoji, weather_symbol)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULLIF($13,''),NULLIF($14,''),NULLIF($15,''))
ON CONFLICT DO NOTHING
RETURNING id`,
datasetID, userID, v.SrcPK, pid, aid, v.Arrival, v.Departure, dur,
isHome, isWork, v.Bookmarked, v.UserAdded, v.Remark, v.Emoji, v.WeatherSymbol).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
// 该到访已随更早的备份导入过,跳过即可,连同它的标签一起跳过
skipped++
continue
}
if err != nil {
return 0, nil, fmt.Errorf("写入访问记录: %w", err)
}
visitID[v.SrcPK] = id
vcount++
if v.LocationSrcPK != nil {
if a, ok := agg[*v.LocationSrcPK]; ok {
a.count++
if dur != nil {
a.dwell += int64(dur.(int))
}
if a.first == nil || v.Arrival.Before(*a.first) {
a.first = v.Arrival
}
if a.last == nil || v.Arrival.After(*a.last) {
a.last = v.Arrival
}
}
visitPlace[v.SrcPK] = *v.LocationSrcPK
}
if first == nil || v.Arrival.Before(*first) {
first = v.Arrival
}
if last == nil || v.Arrival.After(*last) {
last = v.Arrival
}
for _, tpk := range v.TagSrcPKs {
if tid, ok := tagID[tpk]; ok {
if _, err := tx.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`, id, tid); err != nil {
return 0, nil, fmt.Errorf("写入访问标签: %w", err)
}
}
}
}
// 位移
for _, m := range b.Movements {
var dist any
var fromPID, toPID any
if m.FromVisitSrc != nil && m.ToVisitSrc != nil {
fp, okf := visitPlace[*m.FromVisitSrc]
tp, okt := visitPlace[*m.ToVisitSrc]
if okf && okt {
if id, ok := placeID[fp]; ok {
fromPID = id
}
if id, ok := placeID[tp]; ok {
toPID = id
}
c1, c2 := locCoord[fp], locCoord[tp]
if c1[0] != 0 && c2[0] != 0 {
d := geo.Haversine(c1[0], c1[1], c2[0], c2[1])
dist = d
}
}
}
var tname, tcolor, ticon any
if m.TransportSrc != nil {
tname, tcolor, ticon = nullStr(transName[*m.TransportSrc]), nullStr(transColor[*m.TransportSrc]), nullStr(transIcon[*m.TransportSrc])
}
var dur any
if m.Start != nil && m.End != nil {
if d := int(m.End.Sub(*m.Start).Minutes()); d >= 0 {
dur = d
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO movements
(dataset_id, user_id, src_pk, transport_src, transport_name, transport_color, transport_icon,
from_visit_src, to_visit_src, from_place_id, to_place_id, started_at, ended_at, duration_min, distance_km)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT DO NOTHING`,
datasetID, userID, m.SrcPK, m.TransportSrc, tname, tcolor, ticon,
m.FromVisitSrc, m.ToVisitSrc, fromPID, toPID, m.Start, m.End, dur, dist); err != nil {
return 0, nil, fmt.Errorf("写入位移记录: %w", err)
}
}
// 小时级天气(去重,仅保留整点)
for _, w := range b.Weather {
if w.At == nil {
skip("ZHOURLYWEATHER", w.SrcPK)
continue
}
if _, err := tx.ExecContext(ctx, `INSERT INTO weather
(dataset_id, user_id, src_pk, visit_src, at, is_daylight, temperature_c, apparent_c, humidity,
precipitation_amount, precipitation_chance, wind_speed, visibility, uv_index,
condition, symbol, uv_category, wind_direction)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NULLIF($15,''),NULLIF($16,''),NULLIF($17,''),NULLIF($18,''))
ON CONFLICT DO NOTHING`,
datasetID, userID, w.SrcPK, w.VisitSrc, w.At, w.IsDaylight, w.TempC, w.ApparentC, w.Humidity,
w.PrecipAmt, w.PrecipPct, w.WindSpeed, w.Visibility, w.UVIndex,
w.Condition, w.Symbol, w.UVCategory, w.WindDir); err != nil {
return 0, nil, fmt.Errorf("写入天气记录: %w", err)
}
}
// 回填地点聚合
for src, a := range agg {
if _, err := tx.ExecContext(ctx, `UPDATE places SET visit_count=$1, dwell_minutes=$2,
first_visit_at=$3, last_visit_at=$4 WHERE id=$5`, a.count, a.dwell, a.first, a.last, placeID[src]); err != nil {
return 0, nil, fmt.Errorf("回填地点统计: %w", err)
}
}
// 汇总
st := &Stats{Visits: vcount, Skipped: skipped, Places: len(b.Locations), RawVisits: b.RawVisitNum, FirstVisit: first, LastVisit: last}
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET status='done', visit_count=$1, place_count=$2,
raw_visit_count=$3, first_visit_at=$4, last_visit_at=$5,
source_created_at=$6, content_hash=$7 WHERE id=$8`,
st.Visits, st.Places, st.RawVisits, first, last, time.Now(), hash, datasetID); err != nil {
return 0, nil, fmt.Errorf("更新数据集汇总: %w", err)
}
// 快照式语义:最新导入的那一份接管展示,其余自动退居后台(后台仍可手动切回)
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET is_active = (id=$1) WHERE user_id=$2`,
datasetID, userID); err != nil {
return 0, nil, fmt.Errorf("切换生效数据集: %w", err)
}
// 原始行留档:站点只映射了一部分字段,把 Core Data 里的整行存下来,
// 导出时逐列还原——这样既不受映射范围限制,也不依赖基线库去补剩下的列。
if err := insertEntityRaw(ctx, tx, datasetID, b.RawRows, rawSkipped); err != nil {
return 0, nil, err
}
// 坐标来源留档要等 entity_raw 写完才能做(原始 GPS 只在留档行里)。
if _, err := BackfillCoordSource(ctx, tx, datasetID); err != nil {
return 0, nil, err
}
if err := tx.Commit(); err != nil {
return 0, nil, err
}
return datasetID, st, nil
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// Cached 记录已有上传的摘要,供重复上传时直接复用。
type Cached struct {
DatasetID int64
Stats Stats
}
func SortLocations(list []rond.Location) {
sort.Slice(list, func(i, j int) bool { return list[i].SrcPK < list[j].SrcPK })
}
func TempDir(base, name string) (string, error) {
d := filepath.Join(base, fmt.Sprintf("import-%d", time.Now().UnixNano()))
if err := os.MkdirAll(d, 0o700); err != nil {
return "", err
}
return d, nil
}
+333
View File
@@ -0,0 +1,333 @@
// Package media 负责地点图片的落盘 / 对象存储与取址。
//
// 存哪边由后台设置决定(local | s3),S3 凭据只在 conf/app.ini 里、不进数据库。
// 上传的字节由浏览器转成 WebP 之后才发过来,服务端只做「接收 → 原样写出去」,
// 不碰解码与转码,1 核机器也扛得住。
package media
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"rond-with-you/internal/config"
)
// BackendLocal / BackendS3 是两种存储后端的标识(与后台设置里的取值一致)。
const (
BackendLocal = "local"
BackendS3 = "s3"
)
// Store 把「写到哪」收敛到一处:本地磁盘或 S3 兼容对象存储。
type Store struct {
cfg *config.Config
client *http.Client
}
func New(cfg *config.Config) *Store {
return &Store{cfg: cfg, client: &http.Client{Timeout: 60 * time.Second}}
}
// LocalDir 返回本地图片目录,供静态路由挂载。
func (s *Store) LocalDir() string { return s.cfg.MediaDir() }
// S3Ready 表示对象存储是否配置齐全(后台选了 s3 但没配齐时要能给出明确提示)。
func (s *Store) S3Ready() bool { return s.cfg.S3Ready() }
// LocalPath 把对象 key 映射成本地磁盘路径,并保证不越出图片目录。
func (s *Store) LocalPath(key string) (string, error) {
clean := path.Clean("/" + key)
if clean == "/" || strings.Contains(clean, "..") {
return "", fmt.Errorf("非法对象名 %q", key)
}
return filepath.Join(s.cfg.MediaDir(), filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
}
// PublicURL 返回浏览器可直接放进 <img src> 的地址。
// 本地存储走站内 /media/;对象存储走 s3_public_base(没配就返回空串,
// 调用方要当成「配错了」处理——R2 桶默认私有,不给公开域名浏览器取不到)。
func (s *Store) PublicURL(backend, key string) string {
if backend == BackendS3 {
return s.S3URL(key)
}
return "/media/" + strings.TrimPrefix(key, "/")
}
// S3URL 返回对象存储上的公开地址;未配 s3_public_base 时返回空串。
func (s *Store) S3URL(key string) string {
if s.cfg.S3PublicBase == "" {
return ""
}
return s.cfg.S3PublicBase + "/" + strings.TrimPrefix(key, "/")
}
// Save 写入一个对象。backend 传 BackendLocal 或 BackendS3。
func (s *Store) Save(ctx context.Context, backend, key string, data []byte, contentType string) error {
switch backend {
case BackendLocal:
full, err := s.LocalPath(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return err
}
// 先写临时文件再改名:中途失败不会留下半张图
tmp := full + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, full)
case BackendS3:
if !s.cfg.S3Ready() {
return errors.New("对象存储未配置齐全(需要 s3_endpoint / s3_bucket / s3_access_key / s3_secret_key)")
}
return s.putS3(ctx, key, data, contentType)
default:
return fmt.Errorf("未知的存储后端 %q", backend)
}
}
// Delete 删掉一个对象;对象本来就不在不算错误(本地文件不存在 / S3 返回 404)。
func (s *Store) Delete(ctx context.Context, backend, key string) error {
switch backend {
case BackendLocal:
full, err := s.LocalPath(key)
if err != nil {
return err
}
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
return err
}
s.pruneEmptyDirs(filepath.Dir(full))
return nil
case BackendS3:
if !s.cfg.S3Ready() {
return nil // 配置被撤掉了,本地已经无从删除,交给调用方记日志
}
return s.deleteS3(ctx, key)
default:
return fmt.Errorf("未知的存储后端 %q", backend)
}
}
// pruneEmptyDirs 从里往外收掉空目录。按月份分目录后,删掉某个月最后一张图就会留下空夹;
// 只删"空"的(os.Remove 对非空目录会报错,就此打住),且绝不越出图片根目录。
func (s *Store) pruneEmptyDirs(dir string) {
root := filepath.Clean(s.cfg.MediaDir())
for filepath.Clean(dir) != root && strings.HasPrefix(filepath.Clean(dir), root+string(filepath.Separator)) {
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
/* ---------- S3 兼容对象存储:只实现 PUT / DELETE + SigV4 签名 ---------- */
func (s *Store) putS3(ctx context.Context, key string, data []byte, contentType string) error {
if contentType == "" {
contentType = "application/octet-stream"
}
req, err := s.s3Request(ctx, http.MethodPut, key, data)
if err != nil {
return err
}
req.Header.Set("Content-Type", contentType)
// 图片内容寻址、key 里带哈希,可以长期缓存
req.Header.Set("Cache-Control", "public, max-age=31536000, immutable")
s.sign(req, data)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("上传对象存储失败:HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (s *Store) deleteS3(ctx context.Context, key string) error {
req, err := s.s3Request(ctx, http.MethodDelete, key, nil)
if err != nil {
return err
}
s.sign(req, nil)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("删除对象失败:HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (s *Store) s3Request(ctx context.Context, method, key string, body []byte) (*http.Request, error) {
// 路径式访问:R2 / MinIO / AWS 都支持,省掉虚拟主机名的判断
u := s.cfg.S3Endpoint + "/" + s.cfg.S3Bucket + "/" + escapePath(key)
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.ContentLength = int64(len(body))
}
req.Header.Set("x-amz-content-sha256", sha256Hex(body))
return req, nil
}
// sign 给请求补上 SigV4 签名(用当前时间)。
func (s *Store) sign(req *http.Request, body []byte) {
s.signAt(req, body, time.Now().UTC())
}
// unsignedHeaders 是不参与签名的头:Authorization 自己就是签名结果,
// 其余几个由 transport 在发送时补/改,签了反而对不上。
var unsignedHeaders = map[string]struct{}{
"authorization": {}, "content-length": {}, "user-agent": {},
"accept-encoding": {}, "connection": {},
}
// signAt 与 sign 相同,但时间可注入——SigV4 的签名依赖时刻,测试要能锁死它。
// 签进去的是「请求上现有的全部头(去掉上面那几个)+ host」,签全量比挑几个更稳:
// 不会因为漏签某个自定义头而被服务端拒绝。
func (s *Store) signAt(req *http.Request, body []byte, now time.Time) {
const (
algo = "AWS4-HMAC-SHA256"
service = "s3"
)
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256Hex(body)
req.Header.Set("x-amz-content-sha256", payloadHash)
req.Header.Set("x-amz-date", amzDate)
headers := map[string]string{"host": req.URL.Host}
for k, v := range req.Header {
name := strings.ToLower(k)
if _, skip := unsignedHeaders[name]; skip {
continue
}
headers[name] = strings.Join(v, ",")
}
// 参与签名的头必须按名字排序,且值要去掉首尾空白、内部连续空白压成一个空格
names := make([]string, 0, len(headers))
for k := range headers {
names = append(names, k)
}
sort.Strings(names)
var canonHeaders strings.Builder
for _, n := range names {
canonHeaders.WriteString(n)
canonHeaders.WriteByte(':')
canonHeaders.WriteString(strings.Join(strings.Fields(headers[n]), " "))
canonHeaders.WriteByte('\n')
}
signedHeaders := strings.Join(names, ";")
canonicalRequest := strings.Join([]string{
req.Method,
canonicalURI(req.URL.Path),
canonicalQuery(req.URL.RawQuery),
canonHeaders.String(),
signedHeaders,
payloadHash,
}, "\n")
scope := strings.Join([]string{dateStamp, s.cfg.S3Region, service, "aws4_request"}, "/")
stringToSign := strings.Join([]string{
algo,
amzDate,
scope,
sha256Hex([]byte(canonicalRequest)),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+s.cfg.S3SecretKey), dateStamp)
kRegion := hmacSHA256(kDate, s.cfg.S3Region)
kService := hmacSHA256(kRegion, service)
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req.Header.Set("Authorization", fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
algo, s.cfg.S3AccessKey, scope, signedHeaders, signature))
}
// canonicalURI 逐段编码路径;"/" 保留作分隔符,其余按 RFC 3986 转义。
func canonicalURI(p string) string {
if p == "" {
return "/"
}
segs := strings.Split(p, "/")
for i, s := range segs {
segs[i] = escape(s)
}
return strings.Join(segs, "/")
}
// canonicalQuery 按 key 排序并转义查询串(当前用不到,留着保证签名公式完整)。
func canonicalQuery(raw string) string {
if raw == "" {
return ""
}
parts := strings.Split(raw, "&")
sort.Strings(parts)
return strings.Join(parts, "&")
}
func escapePath(p string) string {
segs := strings.Split(strings.TrimPrefix(p, "/"), "/")
for i, s := range segs {
segs[i] = escape(s)
}
return strings.Join(segs, "/")
}
// escape 实现 RFC 3986 的 unreserved 之外全部百分号转义(空格要转成 %20,不能是 +)。
func escape(s string) string {
const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if strings.IndexByte(unreserved, c) >= 0 {
b.WriteByte(c)
continue
}
fmt.Fprintf(&b, "%%%02X", c)
}
return b.String()
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}
+145
View File
@@ -0,0 +1,145 @@
package media
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/config"
)
// TestSignV4MatchesAWSExample 用 AWS 官方文档里的示例向量锁死 SigV4。
// 手写签名最容易在「规范化请求 / 签名串 / 派生密钥」这三步之一算错,
// 而那类错误只有真去 PUT 才会暴露;有了官方向量,离线就能验。
//
// 向量来自 AWS《Examples of the complete Signature Version 4 signing process》,
// 场景是 GET 一个带 Range 的对象(服务 s3、区域 us-east-1)。
func TestSignV4MatchesAWSExample(t *testing.T) {
s := New(&config.Config{
S3Endpoint: "https://examplebucket.s3.amazonaws.com",
S3Region: "us-east-1",
S3Bucket: "examplebucket",
S3AccessKey: "AKIAIOSFODNN7EXAMPLE",
S3SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
})
req, err := http.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/test.txt", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=0-9")
s.signAt(req, nil, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
const want = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
auth := req.Header.Get("Authorization")
if !strings.Contains(auth, "Signature="+want) {
t.Errorf("SigV4 与官方向量不符\n得到: %s\n期望签名: %s", auth, want)
}
if !strings.Contains(auth, "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date") {
t.Errorf("参与签名的头不对: %s", auth)
}
if !strings.Contains(auth, "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request") {
t.Errorf("Credential 作用域不对: %s", auth)
}
}
// TestEscapeRFC3986 复核百分号转义:对象名里出现空格、加号、中文时不能被签错。
func TestEscapeRFC3986(t *testing.T) {
cases := map[string]string{
"abc-_.~": "abc-_.~",
"a b": "a%20b",
"a+b": "a%2Bb",
"中文": "%E4%B8%AD%E6%96%87",
"a/b": "a%2Fb",
"2026/01/1": "2026%2F01%2F1",
}
for in, want := range cases {
if got := escape(in); got != want {
t.Errorf("escape(%q) = %q,期望 %q", in, got, want)
}
}
// 路径按段编码:分隔符要保留
if got := canonicalURI("/a b/c+d/x.webp"); got != "/a%20b/c%2Bd/x.webp" {
t.Errorf("canonicalURI 分段编码不对: %q", got)
}
if got := escapePath("2026/01/a b.webp"); got != "2026/01/a%20b.webp" {
t.Errorf("escapePath 不对: %q", got)
}
}
// TestLocalKeyRoundTrip 本地后端:key → 磁盘路径 → 读回,并挡住目录穿越。
func TestLocalKeyRoundTrip(t *testing.T) {
dir := t.TempDir()
s := New(&config.Config{DataDir: dir})
if err := s.Save(t.Context(), BackendLocal, "9/12/abc.webp", []byte("RIFFwebp"), "image/webp"); err != nil {
t.Fatalf("写入失败: %v", err)
}
got, err := s.LocalPath("9/12/abc.webp")
if err != nil {
t.Fatalf("取路径失败: %v", err)
}
if b, err := os.ReadFile(got); err != nil || string(b) != "RIFFwebp" {
t.Fatalf("读回失败: %q %v", b, err)
}
if err := s.Delete(t.Context(), BackendLocal, "9/12/abc.webp"); err != nil {
t.Fatalf("删除失败: %v", err)
}
if err := s.Delete(t.Context(), BackendLocal, "9/12/abc.webp"); err != nil {
t.Fatalf("重复删除不该报错: %v", err)
}
// key 一律当成「相对图片目录」的路径:带 .. 的会被规整,规整后必须仍落在目录内
for _, tricky := range []string{"../conf/app.ini", "a/../../etc/passwd", "/abs/x.webp"} {
p, err := s.LocalPath(tricky)
if err != nil {
continue // 直接拒绝也可以
}
rel, err := filepath.Rel(dir, p)
if err != nil || strings.HasPrefix(rel, "..") {
t.Errorf("路径逃出了图片目录: %q -> %q", tricky, p)
}
}
}
// TestPublicURL 复核取址:本地走站内,对象存储走公开域名;没配公开域名要返回空串。
func TestPublicURL(t *testing.T) {
local := New(&config.Config{DataDir: t.TempDir()})
if got := local.PublicURL(BackendLocal, "9/a.webp"); got != "/media/9/a.webp" {
t.Errorf("本地取址不对: %q", got)
}
noBase := New(&config.Config{DataDir: t.TempDir(), S3Endpoint: "https://x.r2.cloudflarestorage.com",
S3Bucket: "b", S3AccessKey: "k", S3SecretKey: "s"})
if got := noBase.PublicURL(BackendS3, "9/a.webp"); got != "" {
t.Errorf("没配公开域名时应当返回空串(提示配错),实际 %q", got)
}
withBase := New(&config.Config{DataDir: t.TempDir(), S3PublicBase: "https://img.example.com"})
if got := withBase.PublicURL(BackendS3, "9/a.webp"); got != "https://img.example.com/9/a.webp" {
t.Errorf("对象存储取址不对: %q", got)
}
}
// TestS3RequestUsesPathStyle 复核请求形态:路径式、带上内容哈希。
func TestS3RequestUsesPathStyle(t *testing.T) {
s := New(&config.Config{S3Endpoint: "https://acct.r2.cloudflarestorage.com",
S3Region: "auto", S3Bucket: "pics", S3AccessKey: "k", S3SecretKey: "s"})
req, err := s.s3Request(t.Context(), http.MethodPut, "9/12/a b.webp", []byte("x"))
if err != nil {
t.Fatal(err)
}
if got, want := req.URL.Path, "/pics/9/12/a b.webp"; got != want {
t.Errorf("路径不对: %q(期望 %q)", got, want)
}
if got := req.Header.Get("x-amz-content-sha256"); got != sha256Hex([]byte("x")) {
t.Errorf("内容哈希不对: %q", got)
}
if req.URL.Host != "acct.r2.cloudflarestorage.com" {
t.Errorf("主机不对: %q", req.URL.Host)
}
// 签名后不应报错,且路径仍是「已解码」的原样路径(签名时再按段转义)
s.sign(req, []byte("x"))
if req.Header.Get("Authorization") == "" {
t.Error("签名后没有 Authorization 头")
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package notes 存放站长为地点加的别名与备注。
// 备注按 rond 的地点 ID(src_pk)而非数据库自增 id 存储,
// 因此重新上传备份、地点换了新 id 之后内容依然挂在原地。
package notes
import (
"context"
"database/sql"
"strings"
"time"
)
type Note struct {
SrcPK int
Alias string
Content string
RondNote string
UpdatedAt time.Time
}
type Store struct{ DB *sql.DB }
// All 返回某个数据集的全站备注(站点只有一个站长账号,读的时候不按 user_id 过滤,
// 这样匿名访客也能看到别名;写的时候才需要登录用户的 id)。
// 后写覆盖先写,保证同一地点只保留一条。必须按 dataset_id 过滤:
// src_pk 只在单个数据集内唯一,跨数据集同编号是完全不同的地点。
func (s *Store) All(ctx context.Context, datasetID int64) (map[int]Note, error) {
out := map[int]Note{}
rows, err := s.DB.QueryContext(ctx,
`SELECT src_pk, alias, note, rond_note, updated_at FROM place_notes
WHERE dataset_id=$1 ORDER BY updated_at DESC`, datasetID)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var n Note
if err := rows.Scan(&n.SrcPK, &n.Alias, &n.Content, &n.RondNote, &n.UpdatedAt); err != nil {
return out, err
}
out[n.SrcPK] = n
}
return out, rows.Err()
}
// SetNote 只改备注、保留别名(批量「清空备注」用);两者都为空则删除记录。
func (s *Store) SetNote(ctx context.Context, userID, datasetID int64, srcPK int, content string) error {
var alias string
err := s.DB.QueryRowContext(ctx,
`SELECT alias FROM place_notes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`,
userID, datasetID, srcPK).Scan(&alias)
if err != nil && err != sql.ErrNoRows {
return err
}
return s.Save(ctx, userID, datasetID, srcPK, alias, content)
}
// Delete 删除某地点的整条备注与别名。
func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) error {
_, err := s.DB.ExecContext(ctx,
`DELETE FROM place_notes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`, userID, datasetID, srcPK)
return err
}
// Save 写入别名与备注;两者都为空时清空手写内容(rond 回填的那一块要留着)。
func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error {
alias = strings.TrimSpace(alias)
content = strings.TrimSpace(content)
if alias == "" && content == "" {
// 没有 rond 备注时直接删行;有则只清空手写部分,别把回填结果一起删掉
_, err := s.DB.ExecContext(ctx,
`DELETE FROM place_notes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3 AND rond_note=''`,
userID, datasetID, srcPK)
if err != nil {
return err
}
_, err = s.DB.ExecContext(ctx,
`UPDATE place_notes SET alias='', note='', updated_at=now()
WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`, userID, datasetID, srcPK)
return err
}
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_notes (user_id, dataset_id, src_pk, alias, note, updated_at)
VALUES ($1,$2,$3,$4,$5,now())
ON CONFLICT (user_id, dataset_id, src_pk)
DO UPDATE SET alias=EXCLUDED.alias, note=EXCLUDED.note, updated_at=now()`,
userID, datasetID, srcPK, alias, content)
return err
}
// BackfillRond 把 rond 备份里按次到访记录的备注(ZVISIT.ZREMARK_)汇总进 rond_note:
// 同一地点多条备注全部保留,去重后按时间升序、每条一行并带上日期。
// 只重写 rond_note 一列,站长手写的 note 一概不动。返回写了多少行。
func (s *Store) BackfillRond(ctx context.Context, userID, datasetID int64) (int, error) {
// DISTINCT 在「日期 + 内容」这一层做,同一天重复写的同一句只留一条
const agg = `
WITH raw AS (
SELECT p.src_pk, to_char(v.arrival, 'YYYY-MM-DD') || ' ' || btrim(v.remark) AS line
FROM visits v JOIN places p ON p.id = v.place_id
WHERE v.dataset_id = $1 AND btrim(COALESCE(v.remark, '')) <> ''
), uniq AS (
SELECT DISTINCT src_pk, line, left(line, 10) AS day FROM raw
), agg AS (
SELECT src_pk, string_agg(line, E'\n' ORDER BY day, line) AS txt FROM uniq GROUP BY src_pk
)`
res, err := s.DB.ExecContext(ctx, agg+`
INSERT INTO place_notes (user_id, dataset_id, src_pk, alias, note, rond_note, updated_at)
SELECT $2, $1, src_pk, '', '', txt, now() FROM agg
ON CONFLICT (user_id, dataset_id, src_pk) DO UPDATE SET rond_note = EXCLUDED.rond_note`,
datasetID, userID)
if err != nil {
return 0, err
}
affected, _ := res.RowsAffected()
// 备注被删掉的地点要清掉旧值,否则会一直留着过期的 rond 内容。
// 限定在本数据集的地点里:datasetID 不合法时子查询为空,一行都不会动,
// 不会把别的数据集的回填结果误清掉。
if _, err := s.DB.ExecContext(ctx, `
UPDATE place_notes n SET rond_note = ''
WHERE n.user_id = $2 AND n.dataset_id = $1 AND n.rond_note <> ''
AND n.src_pk IN (SELECT src_pk FROM places WHERE dataset_id = $1)
AND NOT EXISTS (
SELECT 1 FROM visits v JOIN places p ON p.id = v.place_id
WHERE v.dataset_id = $1 AND p.src_pk = n.src_pk AND btrim(COALESCE(v.remark, '')) <> ''
)`, datasetID, userID); err != nil {
return 0, err
}
return int(affected), nil
}
File diff suppressed because it is too large Load Diff
+424
View File
@@ -0,0 +1,424 @@
package rond
import (
"bytes"
"database/sql"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
// TestBaselineKeepsCoreData 守住导出的地基:基线库必须完整保留 Core Data 的
// 元数据表与索引、以及我们不落库的实体(ZRAWVISIT 等)。一旦有人重新改成
// 「纯建表重建」,这里会立刻失败——那正是当初导出包 85KB、rond 提示恢复失败的原因。
// 需要环境变量 ROND_SRC(原始 .rondbackup);未设置时跳过。
func TestBaselineKeepsCoreData(t *testing.T) {
src := os.Getenv("ROND_SRC")
if src == "" {
t.Skip("设置 ROND_SRC 后运行基线保真度检查")
}
base := filepath.Join(t.TempDir(), "baseline.sqlite")
if err := WriteBaseline(src, base); err != nil {
t.Fatalf("生成基线库失败: %v", err)
}
before := shapeOf(t, src)
after := shapeOfFile(t, base)
if after.meta == 0 || after.metaPlist == 0 {
t.Errorf("基线缺少 Z_METADATA / Z_PLIST,Core Data 会拒绝打开")
}
if after.modelCache == 0 {
t.Errorf("基线缺少 Z_MODELCACHE")
}
if !reflect.DeepEqual(before.tables, after.tables) {
t.Errorf("表集合不一致\n原: %v\n基: %v", before.tables, after.tables)
}
if !reflect.DeepEqual(before.indexes, after.indexes) {
t.Errorf("索引集合不一致:缺 %v", diffList(before.indexes, after.indexes))
}
for _, kv := range [][3]any{
{"ZRAWVISIT", before.rawVisit, after.rawVisit},
{"ZKEYWORD", before.keyword, after.keyword},
{"ZVISIT", before.visit, after.visit},
{"ZLOCATION", before.location, after.location},
} {
if kv[1] != kv[2] {
t.Errorf("%s 行数不一致: 原 %v vs 基 %v", kv[0], kv[1], kv[2])
}
}
}
// sqliteShape 是「表/索引集合 + 关键表行数」,用于对比两个库的结构。
type sqliteShape struct {
tables []string
indexes []string
meta int
metaPlist int
modelCache int
rawVisit int
keyword int
visit int
location int
}
func shapeOf(t *testing.T, archive string) sqliteShape {
t.Helper()
dir := t.TempDir()
db, err := Open(archive, dir)
if err != nil {
t.Fatalf("打开 %s 失败: %v", archive, err)
}
defer db.Close()
return readShape(t, db)
}
// shapeOfFile 与 shapeOf 相同,但直接读一个已解包的 .sqlite 文件(基线库形态)。
func shapeOfFile(t *testing.T, path string) sqliteShape {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatalf("打开 %s 失败: %v", path, err)
}
defer db.Close()
return readShape(t, db)
}
func readShape(t *testing.T, db *sql.DB) sqliteShape {
t.Helper()
return sqliteShape{
tables: queryNames(t, db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`),
indexes: queryNames(t, db, `SELECT name FROM sqlite_master WHERE type='index' ORDER BY name`),
meta: countRows(t, db, `SELECT count(*) FROM Z_METADATA`),
metaPlist: countRows(t, db, `SELECT count(*) FROM Z_METADATA WHERE length(Z_PLIST)>0`),
modelCache: countRows(t, db, `SELECT count(*) FROM Z_MODELCACHE`),
rawVisit: countRows(t, db, `SELECT count(*) FROM ZRAWVISIT`),
keyword: countRows(t, db, `SELECT count(*) FROM ZKEYWORD`),
visit: countRows(t, db, `SELECT count(*) FROM ZVISIT`),
location: countRows(t, db, `SELECT count(*) FROM ZLOCATION`),
}
}
func queryNames(t *testing.T, db *sql.DB, q string) []string {
t.Helper()
rows, err := db.Query(q)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
out = append(out, s)
}
return out
}
func countRows(t *testing.T, db *sql.DB, q string) int {
t.Helper()
var n int
if err := db.QueryRow(q).Scan(&n); err != nil {
t.Fatalf("查询失败 %s: %v", q, err)
}
return n
}
func diffList(have, want []string) []string {
set := map[string]bool{}
for _, v := range want {
set[v] = true
}
var out []string
for _, v := range have {
if !set[v] {
out = append(out, v)
}
}
return out
}
// TestExportRoundtrip 用 rond 自己的 Open+Parse 反向解析「导出包」,与原始备份逐项比对,
// 验证从 PostgreSQL 重建的 .rondbackup 与 rond 原始格式一致。
// 需要环境变量 ROND_SRC(原始 .rondbackup)与 ROND_EXPORT(导出包);未设置时跳过。
func TestExportRoundtrip(t *testing.T) {
src := os.Getenv("ROND_SRC")
exp := os.Getenv("ROND_EXPORT")
if src == "" || exp == "" {
t.Skip("设置 ROND_SRC 与 ROND_EXPORT 后运行往返对比")
}
a := parseForTest(t, src)
c := parseForTest(t, exp)
na := countArrival(a)
nc := countArrival(c)
t.Logf("原始: visits(有到达)=%d places=%d tags=%d acts=%d moves=%d weather=%d rawvisits=%d",
na, len(a.Locations), len(a.Tags), len(a.Activities), len(a.Movements), len(a.Weather), a.RawVisitNum)
t.Logf("导出: visits(有到达)=%d places=%d tags=%d acts=%d moves=%d weather=%d rawvisits=%d",
nc, len(c.Locations), len(c.Tags), len(c.Activities), len(c.Movements), len(c.Weather), c.RawVisitNum)
if na != nc {
t.Errorf("到访数不一致: 原 %d vs 导 %d", na, nc)
}
if len(a.Locations) != len(c.Locations) {
t.Errorf("地点数不一致: 原 %d vs 导 %d", len(a.Locations), len(c.Locations))
}
if len(a.Activities) != len(c.Activities) {
t.Errorf("活动类型数不一致: 原 %d vs 导 %d", len(a.Activities), len(c.Activities))
}
if len(a.Tags) != len(c.Tags) {
t.Errorf("标签数不一致: 原 %d vs 导 %d", len(a.Tags), len(c.Tags))
}
if len(a.Movements) != len(c.Movements) {
t.Errorf("位移数不一致: 原 %d vs 导 %d", len(a.Movements), len(c.Movements))
}
if len(a.Weather) != len(c.Weather) {
t.Errorf("天气数不一致: 原 %d vs 导 %d", len(a.Weather), len(c.Weather))
}
// 逐条抽查时间与坐标是否一致(按 SrcPK 对齐)
loc := map[int]Location{}
for _, l := range a.Locations {
loc[l.SrcPK] = l
}
maxDiff := 0.0
for _, l := range c.Locations {
o, ok := loc[l.SrcPK]
if !ok {
t.Errorf("导出多出地点 %d", l.SrcPK)
continue
}
if d := absf(o.Lat - l.Lat); d > maxDiff {
maxDiff = d
}
if d := absf(o.Lon - l.Lon); d > maxDiff {
maxDiff = d
}
}
t.Logf("坐标最大偏差: %.9f", maxDiff)
if maxDiff > 1e-9 {
t.Errorf("坐标出现偏差: %v", maxDiff)
}
vm := map[int]Visit{}
for _, v := range a.Visits {
vm[v.SrcPK] = v
}
var maxTimeDelta time.Duration
for _, v := range c.Visits {
o, ok := vm[v.SrcPK]
if !ok {
t.Errorf("导出多出到访 %d", v.SrcPK)
continue
}
if (o.Arrival == nil) != (v.Arrival == nil) {
t.Errorf("到访 %d 到达时间有无不一致", v.SrcPK)
continue
}
if o.Arrival == nil {
continue
}
// Core Data 用 float64 存秒数,往返存在亚微秒级舍入,毫秒容差内视为一致
d := o.Arrival.Sub(*v.Arrival)
if d < 0 {
d = -d
}
if d > maxTimeDelta {
maxTimeDelta = d
}
if d > time.Millisecond {
t.Errorf("到访 %d 到达时间偏差过大: 原 %v vs 导 %v", v.SrcPK, o.Arrival, *v.Arrival)
}
}
t.Logf("时间最大偏差: %v", maxTimeDelta)
}
func parseForTest(t *testing.T, path string) *Backup {
t.Helper()
dir := t.TempDir()
db, err := Open(path, dir)
if err != nil {
t.Fatalf("打开 %s 失败: %v", path, err)
}
defer db.Close()
b, err := Parse(db)
if err != nil {
t.Fatalf("解析 %s 失败: %v", path, err)
}
return b
}
func countArrival(b *Backup) int {
n := 0
for _, v := range b.Visits {
if v.Arrival != nil {
n++
}
}
return n
}
// TestLastArrivalDateMatchesVisits 守住地点详情的「最近活跃时间」。
// rond 直接显示 ZLOCATION.ZLASTARRIVALDATE_,它是该地点最晚一次到访到达时间的缓存:
// 有到访却留空,rond 里就永远显示「无数据」;时间对不上,显示的就是过期值。
// 需要环境变量 ROND_EXPORT(导出后的 .rondbackup);未设置时跳过。
func TestLastArrivalDateMatchesVisits(t *testing.T) {
exp := os.Getenv("ROND_EXPORT")
if exp == "" {
t.Skip("设置 ROND_EXPORT 后运行「最近活跃时间」一致性检查")
}
db, err := Open(exp, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", exp, err)
}
defer db.Close()
rows, err := db.Query(`SELECT l.Z_PK, COALESCE(l.ZNAME_,''), l.ZLASTARRIVALDATE_, v.mx
FROM ZLOCATION l
LEFT JOIN (SELECT ZLOCATION AS loc, max(ZARRIVALDATE_) mx FROM ZVISIT GROUP BY ZLOCATION) v
ON v.loc = l.Z_PK`)
if err != nil {
t.Fatalf("查询地点: %v", err)
}
defer rows.Close()
checked, unvisited := 0, 0
for rows.Next() {
var pk int
var name string
var last, mx sql.NullFloat64
if err := rows.Scan(&pk, &name, &last, &mx); err != nil {
t.Fatalf("读取地点: %v", err)
}
switch {
case !mx.Valid:
// 从未到访:rond 自己写的是 distantPast 哨兵或干脆不写,两种都行
unvisited++
case !last.Valid:
t.Errorf("地点 %d(%s) 有到访记录,但 ZLASTARRIVALDATE_ 为空——rond 会显示「无数据」", pk, name)
default:
checked++
// Core Data 用 float64 存秒数,毫秒容差内视为一致
if d := absf(last.Float64 - mx.Float64); d > 1e-3 {
t.Errorf("地点 %d(%s) 最近活跃时间与最新到访不符: 差 %.3f 秒", pk, name, d)
}
}
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历地点: %v", err)
}
t.Logf("已核对 %d 个有到访的地点(另有 %d 个从未到访)", checked, unvisited)
if checked == 0 {
t.Error("没有核对到任何有到访的地点,样本可能不对")
}
}
// TestUUIDv5 守住补 UUID 的确定性:同一条记录每次导出必须拿到同一个 UUID。
// 若退化成随机,每次导出的包都会让 rond 把同一批记录当成新的。
func TestUUIDv5(t *testing.T) {
a, b := uuidV5("ZVISIT/42"), uuidV5("ZVISIT/42")
if !bytes.Equal(a, b) {
t.Errorf("同名两次生成不一致: %x vs %x", a, b)
}
if len(a) != 16 {
t.Fatalf("长度应为 16 字节,实际 %d", len(a))
}
if a[6]>>4 != 5 {
t.Errorf("版本位应为 5,实际 %d", a[6]>>4)
}
if a[8]>>6 != 2 {
t.Errorf("变体位应为 RFC 4122(10xx),实际 %d", a[8]>>6)
}
if bytes.Equal(a, uuidV5("ZVISIT/43")) {
t.Error("不同记录不应拿到同一个 UUID")
}
if bytes.Equal(uuidV5("ZVISIT/42"), uuidV5("ZACTIVITY/42")) {
t.Error("不同表不应拿到同一个 UUID")
}
}
// TestProgramRowsFillNativeColumns 守住「站点新建的行也要带上真机必有的列」。
// ZLOCATION 的原始 GPS 与 ZVISIT/ZACTIVITY 的 UUID 在真机包里几乎 100% 有值
// (实测 323/323、822/824、30/30),而站点这边没有对应字段,导出必须替新行补上,
// 否则 rond 里就会缺东西——「最近活跃时间」显示「无数据」就是同一个坑。
// 需要环境变量 ROND_SRC(原始包)与 ROND_EXPORT(导出包);未设置时跳过。
func TestProgramRowsFillNativeColumns(t *testing.T) {
src, exp := os.Getenv("ROND_SRC"), os.Getenv("ROND_EXPORT")
if src == "" || exp == "" {
t.Skip("设置 ROND_SRC 与 ROND_EXPORT 后运行原生列补全检查")
}
old, cur := openBackup(t, src), openBackup(t, exp)
defer old.Close()
defer cur.Close()
for _, c := range []struct{ table, col string }{
{"ZLOCATION", "ZRAWLATITUDE"},
{"ZLOCATION", "ZRAWLONGITUDE"},
{"ZVISIT", "ZIDENTIFIER_"},
{"ZACTIVITY", "ZUID_"},
{"ZMOVEMENT", "ZTYPE_"},
} {
var base int
if err := old.QueryRow(`SELECT COALESCE(max(Z_PK),0) FROM ` + c.table).Scan(&base); err != nil {
t.Fatalf("读取 %s 基线最大主键: %v", c.table, err)
}
var total, missing int
if err := cur.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN `+c.col+` IS NULL THEN 1 ELSE 0 END),0)
FROM `+c.table+` WHERE Z_PK > ?`, base).Scan(&total, &missing); err != nil {
t.Fatalf("统计 %s.%s: %v", c.table, c.col, err)
}
if total == 0 {
continue // 这一版没有新增行
}
if missing > 0 {
t.Errorf("%s.%s:%d 个站点新建的行里有 %d 个为空(真机几乎全部有值)", c.table, c.col, total, missing)
}
t.Logf("%s.%s:新建 %d 行,全部有值", c.table, c.col, total)
}
}
// TestMovementTypeNeverNull 守住 ZMOVEMENT.ZTYPE_ 全表非空。
// 真机 791/791 都有值(2=无交通方式/步行、5=有交通方式,另有少量 0/4),
// 说明这个属性在 Core Data 里是非可选的——留 NULL 轻则显示异常,重则整包恢复失败。
// 需要环境变量 ROND_EXPORT(导出包);未设置时跳过。
func TestMovementTypeNeverNull(t *testing.T) {
exp := os.Getenv("ROND_EXPORT")
if exp == "" {
t.Skip("设置 ROND_EXPORT 后运行位移类型非空检查")
}
db := openBackup(t, exp)
defer db.Close()
var total, missing int
if err := db.QueryRow(`SELECT count(*),
COALESCE(sum(CASE WHEN ZTYPE_ IS NULL THEN 1 ELSE 0 END),0) FROM ZMOVEMENT`).
Scan(&total, &missing); err != nil {
t.Fatalf("统计 ZMOVEMENT.ZTYPE_: %v", err)
}
if total == 0 {
t.Skip("导出包里没有位移记录")
}
if missing > 0 {
t.Errorf("ZMOVEMENT 共 %d 条,其中 %d 条 ZTYPE_ 为空", total, missing)
}
t.Logf("ZMOVEMENT 共 %d 条,ZTYPE_ 全部有值", total)
}
func openBackup(t *testing.T, path string) *sql.DB {
t.Helper()
db, err := Open(path, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", path, err)
}
return db
}
func absf(v float64) float64 {
if v < 0 {
return -v
}
return v
}
+675
View File
@@ -0,0 +1,675 @@
// Package rond 负责解析 rond(iOS 应用 LifeEasy)导出的 .rondbackup 备份包。
//
// 备份包本质是 zip,内含 Core Data 的 SQLite 库 LifeEasy.sqlite 及同名 -wal / -shm。
// 数据主要在 WAL 里,因此必须三个文件一起解出来再打开,否则会丢失绝大部分记录。
package rond
import (
"archive/zip"
"context"
"database/sql"
"encoding/base64"
"fmt"
"io"
"log"
"math"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// Core Data 时间戳基准:2001-01-01 UTC
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
func appleTime(v sql.NullFloat64) *time.Time {
if !v.Valid {
return nil
}
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
// 往返导出后时间戳就对不上了。
sec := math.Floor(v.Float64)
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
return &t
}
type Activity struct {
SrcPK int
Name string
Color string
Icon string
IsHome bool
IsWork bool
IsExcluded bool
IsArchived bool
}
type Tag struct {
SrcPK int
Name string
Color string
GroupName string
}
type Location struct {
SrcPK int
Name string
POICategory string
Lat float64
Lon float64
CountryCode string
Province string
City string
District string
Sublocality string
Thoroughfare string
SubThoroughfare string
Timezone string
}
type Visit struct {
SrcPK int
LocationSrcPK *int
ActivitySrcPK *int
Arrival *time.Time
Departure *time.Time
Bookmarked bool
UserAdded bool
Remark string
Emoji string
WeatherSymbol string
TagSrcPKs []int
}
type Movement struct {
SrcPK int
TransportSrc *int
FromVisitSrc *int
ToVisitSrc *int
Start *time.Time
End *time.Time
}
type Weather struct {
SrcPK int
VisitSrc *int
At *time.Time
IsDaylight bool
TempC *float64
ApparentC *float64
Humidity *float64
PrecipAmt *float64
PrecipPct *float64
WindSpeed *float64
Visibility *float64
UVIndex *int
Condition string
Symbol string
UVCategory string
WindDir string
}
type Transport struct {
SrcPK int
Name string
Color string
Icon string
}
// CoreDataMeta 记录备份包里 Core Data 库的结构信息(实体编号、建表 DDL、关联表),
// 作为数据集的结构快照留档;导出 rondbackup 已改为以「基线库」为底覆盖写入,不再依赖它。
type CoreDataMeta struct {
// 实体名(ZPRIMARYKEY.Z_NAME) -> 实体编号(Z_ENT) 与当前最大主键(Z_MAX)
Entities map[string]EntityInfo `json:"entities"`
// 表名 -> CREATE TABLE 语句(含全部列与默认值,导出只 INSERT 其中部分列)
DDL map[string]string `json:"ddl"`
// 访问↔标签多对多关联表(Core Data 自动生成的 Z_<tagEnt>VISITS_)
JoinTable JoinTableInfo `json:"join_visit_tag"`
}
type EntityInfo struct {
Ent int `json:"ent"`
Max int `json:"max"`
}
type JoinTableInfo struct {
Name string `json:"name"`
VisitCol string `json:"visit_col"`
TagCol string `json:"tag_col"`
}
type Backup struct {
Activities []Activity
Tags []Tag
Transports []Transport
Locations []Location
Visits []Visit
Movements []Movement
Weather []Weather
RawVisitNum int
TagGroups map[int]string
Meta *CoreDataMeta
// RawRows 是各实体在 Core Data 里的完整原始行:「表名 -> Z_PK -> 列名 -> 值」。
// 站点只映射了其中一部分字段,留档让导出能逐列还原,不必依赖基线库补剩下的列。
RawRows map[string]map[int]map[string]any
}
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
// 调用方负责 Close 与删除 dir。
func Open(archivePath, dir string) (*sql.DB, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
zr, err := zip.OpenReader(archivePath)
if err != nil {
return nil, fmt.Errorf("备份包不是有效的 zip: %w", err)
}
defer zr.Close()
var dbPath string
for _, f := range zr.File {
base := filepath.Base(f.Name)
if f.FileInfo().IsDir() || base == "" {
continue
}
// 只取 Core Data 主库及其 WAL / SHM
low := strings.ToLower(base)
if !strings.HasSuffix(low, ".sqlite") && !strings.HasSuffix(low, ".sqlite-wal") && !strings.HasSuffix(low, ".sqlite-shm") {
continue
}
dst := filepath.Join(dir, base)
rc, err := f.Open()
if err != nil {
return nil, err
}
out, err := os.Create(dst)
if err != nil {
rc.Close()
return nil, err
}
_, cerr := io.Copy(out, rc)
rc.Close()
out.Close()
if cerr != nil {
return nil, cerr
}
if strings.HasSuffix(low, ".sqlite") {
dbPath = dst
}
}
if dbPath == "" {
return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
db.Close()
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
}
return db, nil
}
// Parse 读取 Core Data 库,转换为领域模型。
func Parse(db *sql.DB) (*Backup, error) {
b := &Backup{TagGroups: map[int]string{}}
var err error
// 标签分组
rows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTAGGROUP`)
if err != nil {
return nil, fmt.Errorf("读取标签分组: %w", err)
}
for rows.Next() {
var pk int
var name string
if err = rows.Scan(&pk, &name); err != nil {
rows.Close()
return nil, err
}
b.TagGroups[pk] = name
}
rows.Close()
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
tagGroup := map[int]int{}
grows, err := db.Query(`SELECT Z_PK, COALESCE(ZGROUP,0) FROM ZTAG`)
if err != nil {
return nil, fmt.Errorf("读取标签: %w", err)
}
for grows.Next() {
var pk, g int
if err = grows.Scan(&pk, &g); err != nil {
grows.Close()
return nil, err
}
tagGroup[pk] = g
}
grows.Close()
trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
if err != nil {
return nil, fmt.Errorf("读取标签: %w", err)
}
for trows.Next() {
var t Tag
if err = trows.Scan(&t.SrcPK, &t.Name, &t.Color); err != nil {
trows.Close()
return nil, err
}
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
b.Tags = append(b.Tags, t)
}
trows.Close()
// 类型 / 活动
arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''),
COALESCE(ZISHOME,0), COALESCE(ZISWORK,0), COALESCE(ZISEXCLUDED,0), COALESCE(ZISARCHIVED,0) FROM ZACTIVITY`)
if err != nil {
return nil, fmt.Errorf("读取活动类型: %w", err)
}
for arows.Next() {
var a Activity
var h, w, e, ar int
if err = arows.Scan(&a.SrcPK, &a.Name, &a.Color, &a.Icon, &h, &w, &e, &ar); err != nil {
arows.Close()
return nil, err
}
a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
b.Activities = append(b.Activities, a)
}
arows.Close()
// 交通方式
trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`)
if err != nil {
return nil, fmt.Errorf("读取交通方式: %w", err)
}
for trrows.Next() {
var t Transport
if err = trrows.Scan(&t.SrcPK, &t.Name, &t.Color, &t.Icon); err != nil {
trrows.Close()
return nil, err
}
b.Transports = append(b.Transports, t)
}
trrows.Close()
// 地点
lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0),
COALESCE(ZISOCOUNTRYCODE,''), COALESCE(ZADMINISTRATIVEAREA,''), COALESCE(ZLOCALITY,''),
COALESCE(ZSUBLOCALITY,''), COALESCE(ZTHOROUGHFARE,''), COALESCE(ZSUBTHOROUGHFARE,''),
COALESCE(ZTIMEZONE,'') FROM ZLOCATION`)
if err != nil {
return nil, fmt.Errorf("读取地点: %w", err)
}
for lrows.Next() {
var l Location
if err = lrows.Scan(&l.SrcPK, &l.Name, &l.POICategory, &l.Lat, &l.Lon,
&l.CountryCode, &l.Province, &l.City, &l.Sublocality, &l.Thoroughfare,
&l.SubThoroughfare, &l.Timezone); err != nil {
lrows.Close()
return nil, err
}
b.Locations = append(b.Locations, l)
}
lrows.Close()
// 访问记录
vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_,
COALESCE(ZBOOKMARKED,0), COALESCE(ZUSERADDED,0), COALESCE(ZREMARK_,''),
COALESCE(ZEMOJINAME_,''), COALESCE(ZWEATHERSYMBOL_,'') FROM ZVISIT`)
if err != nil {
return nil, fmt.Errorf("读取访问记录: %w", err)
}
visitIdx := map[int]int{}
for vrows.Next() {
var v Visit
var loc, act sql.NullInt64
var arr, dep sql.NullFloat64
var bm, ua int
if err = vrows.Scan(&v.SrcPK, &loc, &act, &arr, &dep, &bm, &ua,
&v.Remark, &v.Emoji, &v.WeatherSymbol); err != nil {
vrows.Close()
return nil, err
}
if loc.Valid {
pk := int(loc.Int64)
v.LocationSrcPK = &pk
}
if act.Valid {
pk := int(act.Int64)
v.ActivitySrcPK = &pk
}
v.Arrival = appleTime(arr)
v.Departure = appleTime(dep)
v.Bookmarked, v.UserAdded = bm == 1, ua == 1
visitIdx[v.SrcPK] = len(b.Visits)
b.Visits = append(b.Visits, v)
}
vrows.Close()
// 访问 → 标签
vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`)
if err != nil {
return nil, fmt.Errorf("读取访问标签: %w", err)
}
for vtrows.Next() {
var vid, tid int
if err = vtrows.Scan(&vid, &tid); err != nil {
vtrows.Close()
return nil, err
}
if i, ok := visitIdx[vid]; ok {
b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid)
}
}
vtrows.Close()
// 位移
mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`)
if err != nil {
return nil, fmt.Errorf("读取位移记录: %w", err)
}
for mrows.Next() {
var m Movement
var tr, from, to sql.NullInt64
var st, en sql.NullFloat64
if err = mrows.Scan(&m.SrcPK, &tr, &from, &to, &st, &en); err != nil {
mrows.Close()
return nil, err
}
if tr.Valid {
pk := int(tr.Int64)
m.TransportSrc = &pk
}
if from.Valid {
pk := int(from.Int64)
m.FromVisitSrc = &pk
}
if to.Valid {
pk := int(to.Int64)
m.ToVisitSrc = &pk
}
m.Start = appleTime(st)
m.End = appleTime(en)
b.Movements = append(b.Movements, m)
}
mrows.Close()
// 小时级天气
wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0),
ZTEMPERATURE_, ZAPPARENTTEMPERATURE_, ZHUMIDITY_, ZPRECIPITATIONAMOUNT_,
ZPRECIPITATIONCHANCE_, ZWINDSPEED_, ZVISIBILITY_, ZUVINDEXVALUE_,
COALESCE(ZCONDITION_,''), COALESCE(ZSYMBOLNAME_,''), COALESCE(ZUVINDEXCATEGORY_,''),
COALESCE(ZWINDCOMPASSDIRECTION_,'') FROM ZHOURLYWEATHER`)
if err != nil {
return nil, fmt.Errorf("读取天气记录: %w", err)
}
for wrows.Next() {
var w Weather
var vis sql.NullInt64
var at sql.NullFloat64
var dl int
var temp, app, hum, pa, pc, ws, vv sql.NullFloat64
var uv sql.NullInt64
if err = wrows.Scan(&w.SrcPK, &vis, &at, &dl, &temp, &app, &hum, &pa, &pc, &ws, &vv, &uv,
&w.Condition, &w.Symbol, &w.UVCategory, &w.WindDir); err != nil {
wrows.Close()
return nil, err
}
if vis.Valid {
pk := int(vis.Int64)
w.VisitSrc = &pk
}
w.At = appleTime(at)
w.IsDaylight = dl == 1
w.TempC = fptr(temp)
w.ApparentC = fptr(app)
w.Humidity = fptr(hum)
w.PrecipAmt = fptr(pa)
w.PrecipPct = fptr(pc)
w.WindSpeed = fptr(ws)
w.Visibility = fptr(vv)
if uv.Valid {
n := int(uv.Int64)
w.UVIndex = &n
}
b.Weather = append(b.Weather, w)
}
wrows.Close()
if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
b.RawVisitNum = 0
}
// 抓取 Core Data 结构元信息(实体编号 / 建表 DDL / 关联表),作为结构快照留档。
// 失败不致命:旧备份可能没有这些表。
if meta, merr := captureMeta(db); merr == nil {
b.Meta = meta
} else {
log.Printf("抓取 Core Data 元信息失败: %v", merr)
}
// 整行留档:导出时逐列还原,不丢任何 rond 自己写的字段。
if raw, rerr := captureRawRows(db); rerr == nil {
b.RawRows = raw
} else {
log.Printf("抓取原始行留档失败(导出将退回基线补列): %v", rerr)
}
return b, nil
}
// rawTables 是需要整行留档的 Core Data 表(站点管理的实体 + 标签分组)。
var rawTables = []string{"ZLOCATION", "ZVISIT", "ZMOVEMENT", "ZHOURLYWEATHER", "ZACTIVITY", "ZTAG", "ZTAGGROUP", "ZTRANSPORT"}
// captureRawRows 读出上表每一行的全部列,供导出逐列还原。
// BLOB 编码成 {"$blob": base64}(JSON 没有字节串类型),时间戳维持 Core Data 浮点秒原值。
func captureRawRows(db *sql.DB) (map[string]map[int]map[string]any, error) {
out := map[string]map[int]map[string]any{}
for _, t := range rawTables {
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, t).Scan(&n); err != nil || n == 0 {
continue // 旧备份可能没这张表
}
cols, err := queryStrings(context.Background(), db, fmt.Sprintf("SELECT name FROM pragma_table_info('%s')", t))
if err != nil || len(cols) == 0 {
continue
}
quoted := make([]string, len(cols))
for i, c := range cols {
quoted[i] = `"` + strings.ReplaceAll(c, `"`, `""`) + `"`
}
rows, err := db.Query(fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ","), t))
if err != nil {
return nil, fmt.Errorf("读取 %s 原始行: %w", t, err)
}
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
m := map[int]map[string]any{}
for rows.Next() {
if err := rows.Scan(ptrs...); err != nil {
rows.Close()
return nil, err
}
var pk int
row := make(map[string]any, len(cols))
for i, c := range cols {
if c == "Z_PK" {
if v, ok := vals[i].(int64); ok {
pk = int(v)
}
}
row[c] = jsonSafe(vals[i])
}
if pk > 0 {
m[pk] = row
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out[t] = m
}
return out, nil
}
// jsonSafe 把驱动值换成能被 JSON 无损表达的形式。
func jsonSafe(v any) any {
if b, ok := v.([]byte); ok {
return map[string]any{"$blob": base64.StdEncoding.EncodeToString(b)}
}
return v
}
// WriteBaseline 把备份包内的 Core Data 库另存为一份「单文件基线」,供导出时作为底稿。
//
// 为什么要有基线:rond 是 Core Data 应用,库里有 Z_METADATA(模型版本哈希)、
// Z_MODELCACHE、持久化历史表(ACHANGE/ATRANSACTION*)、大量索引,以及我们并不落库的实体
// (ZRAWVISIT 原始 GPS 采样点、ZKEYWORD、ZTRIP* 等)。仅凭抓到的建表语句重建只能得到
// 一个「空壳」,Core Data 会判定与模型不兼容而拒绝打开。以基线为底、只覆盖我们管理的
// 那几张实体表,上述内容就都能原样保留。
//
// 备份包里数据主要在 WAL 中,所以这里先按 Open 的方式解包并合并 WAL,再用
// VACUUM INTO 导出一份自洽的单文件副本(不依赖 -wal/-shm)。
func WriteBaseline(archivePath, dst string) error {
dir, err := os.MkdirTemp("", "rond-base-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
db, err := Open(archivePath, dir) // Open 内部已做 wal_checkpoint(TRUNCATE)
if err != nil {
return err
}
defer db.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
_ = os.Remove(dst)
// VACUUM INTO 需要字面量路径(不用参数绑定),单引号按 SQL 规则转义
lit := strings.ReplaceAll(filepath.ToSlash(dst), "'", "''")
if _, err := db.Exec("VACUUM INTO '" + lit + "'"); err != nil {
// 退路:主库在 Open 里已合并过 WAL,直接复制也自洽
src := filepath.Join(dir, "LifeEasy.sqlite")
if cerr := copyFile(src, dst); cerr != nil {
return fmt.Errorf("生成基线库失败: %v(VACUUM INTO 亦失败: %w)", cerr, err)
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// captureMeta 读取 ZPRIMARYKEY(实体编号与最大主键)与所需的 Z* 建表 DDL,
// 并定位「访问↔标签」关联表及其两列。作为数据集上的 Core Data 结构快照留档。
func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
m := &CoreDataMeta{Entities: map[string]EntityInfo{}, DDL: map[string]string{}}
rows, err := db.Query(`SELECT COALESCE(Z_ENT,0), COALESCE(Z_NAME,''), COALESCE(Z_MAX,0) FROM Z_PRIMARYKEY`)
if err != nil {
return nil, fmt.Errorf("读取 Z_PRIMARYKEY: %w", err)
}
for rows.Next() {
var ent, max int
var name string
if err := rows.Scan(&ent, &name, &max); err != nil {
rows.Close()
return nil, err
}
if name != "" {
m.Entities[name] = EntityInfo{Ent: ent, Max: max}
}
}
rows.Close()
drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("读取建表 DDL: %w", err)
}
for drows.Next() {
var name, ddl string
if err := drows.Scan(&name, &ddl); err != nil {
drows.Close()
return nil, err
}
m.DDL[name] = ddl
}
drows.Close()
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
visitEnt, okV := m.Entities["Visit"]
tagEnt, okT := m.Entities["Tag"]
if okV && okT {
for tname, ddl := range m.DDL {
if !strings.HasPrefix(tname, "Z_") || !strings.HasSuffix(tname, "VISITS_") {
continue
}
_ = ddl
crows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tname))
if err != nil {
continue
}
var vcol, tcol string
for crows.Next() {
var cid int
var cname, ctype string
var notnull int
var dflt sql.NullString
var pk int
if err := crows.Scan(&cid, &cname, &ctype, &notnull, &dflt, &pk); err != nil {
crows.Close()
continue
}
if strings.Contains(cname, fmt.Sprintf("Z_%dVISITS", visitEnt.Ent)) {
vcol = cname
}
if strings.Contains(cname, fmt.Sprintf("Z_%dTAGS", tagEnt.Ent)) {
tcol = cname
}
}
crows.Close()
if vcol != "" && tcol != "" {
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
break
}
}
}
return m, nil
}
func fptr(v sql.NullFloat64) *float64 {
if !v.Valid {
return nil
}
f := v.Float64
return &f
}
+263
View File
@@ -0,0 +1,263 @@
package setting
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// Settings 站点级配置,管理员可在后台调整。
type Settings struct {
SiteTitle string `json:"site_title"`
SiteSubtitle string `json:"site_subtitle"`
OwnerName string `json:"owner_name"`
OwnerAvatar string `json:"owner_avatar"`
OwnerBio string `json:"owner_bio"`
Modules []string `json:"modules"`
// Pages 是对访客开放的页面;管理员始终可以访问全部页面
Pages []string `json:"pages"`
// Regions 是访客的区域限制列表(省 / 市 / 区任意层级);
// 空表示不限制,管理员始终看到全部数据。方向由 RegionsMode 决定。
Regions []string `json:"regions"`
// RegionsMode 决定 Regions 是白名单还是黑名单:
// whitelist = 访客只看得到命中这些区域的记录;blacklist = 命中这些区域的记录对访客隐藏。
RegionsMode string `json:"regions_mode"`
HideHome bool `json:"hide_home"`
HideWork bool `json:"hide_work"`
// NotesPublic 决定地点备注是否对访客展示;默认只站长自己可见
NotesPublic bool `json:"notes_public"`
// MarksPublic 决定「推荐 / 踩雷」标记与票数是否对访客展示;默认公开
MarksPublic bool `json:"marks_public"`
MinDwellMin int `json:"min_dwell_min"`
AccentColor string `json:"accent_color"`
// VisitorHome 是访客打开站点根路径时的落地页;VisitorHomeMain 表示就停在主页。
// 站长自己访问根路径始终看主页,不受这里影响。
VisitorHome string `json:"visitor_home"`
// MapDefaultBase 是地图默认铺哪张底图,取值是底图图层的 Key(如 amap-roadnet)。
// 空 = 用第一张。点位密集时换成「纯路网」可以去掉底图自带的 POI 与路名标注,
// 自己的点看得更清楚;访客在图上手动切换过的话,浏览器会记住他自己的选择。
MapDefaultBase string `json:"map_default_base"`
// MediaBackend 是地点图片存哪:local(服务器磁盘)或 s3(S3 兼容对象存储,如 R2)。
// 对象存储的凭据在 conf/app.ini 里(s3_*),不进数据库。
MediaBackend string `json:"media_backend"`
}
// 地点图片的两种存储后端。
const (
MediaBackendLocal = "local"
MediaBackendS3 = "s3"
)
// VisitorHomeMain 表示访客落地到主页本身。
const VisitorHomeMain = "home"
// Regions 的两种方向。
const (
RegionsWhitelist = "whitelist"
RegionsBlacklist = "blacklist"
)
// AllModules 主页可开关的区块。
var AllModules = []Module{
{Key: "overview", Name: "数据总览", Desc: "去过的地方、城市、天数、总里程等核心数字"},
{Key: "map", Name: "足迹地图", Desc: "所有去过的地点在地图上的分布"},
{Key: "types", Name: "类型分布", Desc: "按「餐厅 / 商场 / 景点」等类型统计"},
{Key: "cities", Name: "城市排行", Desc: "按城市汇总到访次数与停留时长"},
{Key: "timeline", Name: "最近足迹", Desc: "最新若干条到访记录"},
{Key: "heatmap", Name: "活跃日历", Desc: "记录热度按天分布"},
{Key: "transport", Name: "出行方式", Desc: "各类交通方式的次数与里程"},
{Key: "hourly", Name: "作息分布", Desc: "到访时间在一天中的分布"},
{Key: "tags", Name: "我的标签", Desc: "自定义标签的统计"},
{Key: "weather", Name: "天气足迹", Desc: "记录期间的气温与天气"},
}
// AllPages 可控制访客可见性的页面。
var AllPages = []Module{
{Key: "map", Name: "足迹地图", Desc: "交互式地图与行程轨迹"},
{Key: "places", Name: "地点库", Desc: "全部地点的列表与详情"},
{Key: "timeline", Name: "时间线", Desc: "按天浏览到访记录"},
{Key: "stats", Name: "数据统计", Desc: "各维度统计图表与迷雾覆盖"},
{Key: "report", Name: "年度报告", Desc: "按年份汇总的足迹报告"},
}
type Module struct {
Key string
Name string
Desc string
}
func Default() Settings {
mods := make([]string, 0, len(AllModules))
for _, m := range AllModules {
mods = append(mods, m.Key)
}
pages := make([]string, 0, len(AllPages))
for _, p := range AllPages {
pages = append(pages, p.Key)
}
return Settings{
SiteTitle: "我的足迹地图",
SiteSubtitle: "记录我去过的每一个地方",
OwnerName: "站长",
OwnerAvatar: "🐾",
OwnerBio: "",
Modules: mods,
Pages: pages,
// 「家」和「工作」暴露的是常住地,默认不对访客展示
HideHome: true,
HideWork: true,
// 「推荐 / 踩雷」属于评价类信息,默认对访客公开
MarksPublic: true,
// 区域限制默认按黑名单:列出要藏起来的区域,其余照常对访客开放
RegionsMode: RegionsBlacklist,
MinDwellMin: 0,
AccentColor: "#2f7d6e",
// 默认所有人都从主页进;想让人一进来就看到地图时再改
VisitorHome: VisitorHomeMain,
// 图片默认存服务器本地磁盘;配好 R2 之后再在后台切过去
MediaBackend: MediaBackendLocal,
}
}
func (s Settings) Has(module string) bool {
for _, m := range s.Modules {
if m == module {
return true
}
}
return false
}
// HasPage 报告某页面是否对访客开放。
func (s Settings) HasPage(page string) bool {
for _, p := range s.Pages {
if p == page {
return true
}
}
return false
}
// VisitorHomePath 返回访客访问根路径时应跳转到的路径,空串表示不跳。
//
// 只在目标页面已对访客开放时才返回路径:否则相当于把访客送到一个他自己看不到的
// 页面(那里会 404),比留在主页更糟。
func (s Settings) VisitorHomePath() string {
if s.VisitorHome == "" || s.VisitorHome == VisitorHomeMain {
return ""
}
for _, p := range AllPages {
if p.Key == s.VisitorHome && s.HasPage(p.Key) {
return "/" + p.Key
}
}
return ""
}
// NormalizeVisitorHome 把表单值收敛到合法取值(主页或 AllPages 里的页)。
// 表单被改坏时回落到主页——这是个「跳错地方」比不跳更糟的开关。
func NormalizeVisitorHome(v string) string {
for _, p := range AllPages {
if p.Key == v {
return v
}
}
return VisitorHomeMain
}
// RegionBlacklist 报告 Regions 是黑名单(命中即对访客隐藏)还是白名单(命中才放行)。
func (s Settings) RegionBlacklist() bool { return s.RegionsMode == RegionsBlacklist }
type Store struct{ DB *sql.DB }
func (st *Store) Load(ctx context.Context) (Settings, error) {
cur := Default()
var raw []byte
err := st.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&raw)
if err == sql.ErrNoRows {
return cur, nil
}
if err != nil {
return cur, err
}
// 先看原始 JSON 里出现过哪些键:布尔项的默认值是 true,
// 直接用零值覆盖会让「历史配置里没这一项」被误判成「显式关掉了」。
var present map[string]json.RawMessage
if err := json.Unmarshal(raw, &present); err != nil {
return cur, fmt.Errorf("站点设置解析失败: %w", err)
}
var saved Settings
if err := json.Unmarshal(raw, &saved); err != nil {
return cur, fmt.Errorf("站点设置解析失败: %w", err)
}
// 逐项覆盖,保证新增字段拿到默认值
if saved.SiteTitle != "" {
cur.SiteTitle = saved.SiteTitle
}
if saved.SiteSubtitle != "" {
cur.SiteSubtitle = saved.SiteSubtitle
}
if saved.OwnerName != "" {
cur.OwnerName = saved.OwnerName
}
if saved.OwnerAvatar != "" {
cur.OwnerAvatar = saved.OwnerAvatar
}
cur.OwnerBio = saved.OwnerBio
if saved.Modules != nil {
cur.Modules = saved.Modules
}
// pages 键出现即视为显式配置:空数组表示站长明确关闭了所有页面
if _, ok := present["pages"]; ok {
cur.Pages = saved.Pages
}
if _, ok := present["regions"]; ok {
cur.Regions = saved.Regions
}
// regions_mode 是后加的键,而它出现之前 regions 一直是白名单。
// 缺键时若直接套用新默认值(黑名单),历史配置里填过的白名单会静默反转成黑名单
// —— 本该放行的区域全被藏起来,所以缺键一律按白名单解释。
if _, ok := present["regions_mode"]; ok && saved.RegionsMode == RegionsBlacklist {
cur.RegionsMode = RegionsBlacklist
} else {
cur.RegionsMode = RegionsWhitelist
}
if _, ok := present["hide_home"]; ok {
cur.HideHome = saved.HideHome
}
if _, ok := present["hide_work"]; ok {
cur.HideWork = saved.HideWork
}
if _, ok := present["notes_public"]; ok {
cur.NotesPublic = saved.NotesPublic
}
if _, ok := present["marks_public"]; ok {
cur.MarksPublic = saved.MarksPublic
}
if saved.VisitorHome != "" {
cur.VisitorHome = NormalizeVisitorHome(saved.VisitorHome)
}
// 空串是合法值(= 跟随第一张),所以直接赋值,不做非空判断
cur.MapDefaultBase = saved.MapDefaultBase
if saved.MediaBackend == MediaBackendS3 {
cur.MediaBackend = MediaBackendS3
} else {
cur.MediaBackend = MediaBackendLocal
}
cur.MinDwellMin = saved.MinDwellMin
if saved.AccentColor != "" {
cur.AccentColor = saved.AccentColor
}
return cur, nil
}
func (st *Store) Save(ctx context.Context, s Settings) error {
raw, err := json.Marshal(s)
if err != nil {
return err
}
_, err = st.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('site', $1)
ON CONFLICT (key) DO UPDATE SET value=$1, updated_at=now()`, raw)
return err
}
+133
View File
@@ -0,0 +1,133 @@
package setting
import (
"context"
"database/sql"
"os"
"strings"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
)
func TestVisitorHomePath(t *testing.T) {
full := Default() // Pages 默认全开
noMap := full
noMap.Pages = []string{"places", "stats"}
cases := []struct {
name string
set Settings
want string
}{
{"默认停在主页", full, ""},
{"空值(历史配置)停在主页", func() Settings { s := full; s.VisitorHome = ""; return s }(), ""},
{"显式主页", func() Settings { s := full; s.VisitorHome = "home"; return s }(), ""},
{"跳到地图", func() Settings { s := full; s.VisitorHome = "map"; return s }(), "/map"},
{"跳到统计", func() Settings { s := full; s.VisitorHome = "stats"; return s }(), "/stats"},
{"目标页没对访客开放则不跳", noMap, ""},
{"非法值不跳", func() Settings { s := full; s.VisitorHome = "javascript:alert(1)"; return s }(), ""},
{"不给访客任何页面时不跳", func() Settings { s := full; s.Pages = nil; s.VisitorHome = "map"; return s }(), ""},
}
for _, c := range cases {
if got := c.set.VisitorHomePath(); got != c.want {
t.Errorf("%s: VisitorHomePath = %q,期望 %q", c.name, got, c.want)
}
}
}
func TestNormalizeVisitorHome(t *testing.T) {
for _, v := range []string{"", "home", "nope", "/map", "../admin", "MAP"} {
if got := NormalizeVisitorHome(v); got != VisitorHomeMain {
t.Errorf("NormalizeVisitorHome(%q) = %q,期望 %q", v, got, VisitorHomeMain)
}
}
for _, p := range AllPages {
if got := NormalizeVisitorHome(p.Key); got != p.Key {
t.Errorf("NormalizeVisitorHome(%q) = %q,期望原样保留", p.Key, got)
}
}
}
// TestVisitorHomeStore 覆盖存取:旧配置(没有该键)必须回落到主页,
// 而不是把零值当成「访客落地到空路径」。
// 只在库名带 test 的库上跑——它会改写 settings 里的 site 键,不能碰正式库。
func TestVisitorHomeStore(t *testing.T) {
dsn := os.Getenv("ROND_DSN")
if dsn == "" {
t.Skip("未设置 ROND_DSN")
}
if !strings.Contains(dsn, "test") {
t.Skipf("库名不含 test,跳过以免动到正式站的设置:%s", dsn)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
st := Store{DB: db}
var orig []byte
had := true
if err := db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&orig); err != nil {
if err != sql.ErrNoRows {
t.Fatal(err)
}
had = false
}
defer func() { // 还原原配置,别把测试库的设置留下改动
if had {
db.ExecContext(ctx, `UPDATE settings SET value=$1 WHERE key='site'`, orig)
} else {
db.ExecContext(ctx, `DELETE FROM settings WHERE key='site'`)
}
}()
set := func(raw string) {
if _, err := db.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('site', $1)
ON CONFLICT (key) DO UPDATE SET value=$1`, []byte(raw)); err != nil {
t.Fatal(err)
}
}
// 旧配置:整个键都不存在 → 默认停在主页
set(`{"pages":["map","stats"]}`)
if s, err := st.Load(ctx); err != nil {
t.Fatal(err)
} else if s.VisitorHome != VisitorHomeMain {
t.Errorf("缺键时 VisitorHome = %q,期望 %q", s.VisitorHome, VisitorHomeMain)
}
// 正常往返
s, err := st.Load(ctx)
if err != nil {
t.Fatal(err)
}
s.VisitorHome = "map"
if err := st.Save(ctx, s); err != nil {
t.Fatal(err)
}
if s2, err := st.Load(ctx); err != nil {
t.Fatal(err)
} else if s2.VisitorHome != "map" {
t.Errorf("存回来成了 %q,期望 map", s2.VisitorHome)
}
// 库里被塞了非法值 → 读出来必须收敛到主页
set(`{"pages":["map"],"visitor_home":"/etc/passwd"}`)
if s3, err := st.Load(ctx); err != nil {
t.Fatal(err)
} else if s3.VisitorHome != VisitorHomeMain {
t.Errorf("非法值读出 %q,期望收敛为 %q", s3.VisitorHome, VisitorHomeMain)
}
// 目标页没开放时不跳(即便设置里写着 map)
set(`{"pages":["stats"],"visitor_home":"map"}`)
if s4, err := st.Load(ctx); err != nil {
t.Fatal(err)
} else if got := s4.VisitorHomePath(); got != "" {
t.Errorf("目标页未开放却仍要跳 %q", got)
}
}
+966
View File
@@ -0,0 +1,966 @@
// Package stats 汇总所有面向页面的查询。
// 所有查询都以 dataset_id 为根作用域,并共享同一套筛选条件,保证各页面口径一致。
package stats
import (
"context"
"database/sql"
"fmt"
"math"
"strings"
"time"
)
// TZ 显示用时区。备份数据全部来自国内,按北京时间切分自然日 / 小时。
const TZ = "Asia/Shanghai"
// maxDwellMin 是统计停留时长时的单次上限(7 天)。
// rond 会把「一直待在同一个地方」记成一条超长到访(实测有 35 天的),
// 不封顶的话一条就能把总时长和平均时长整个带偏;这类记录本身是真的,
// 所以是裁剪而不是剔除。与 handlers_edit.go 的 refreshPlaceAgg 保持一致。
const maxDwellMin = 10080
type Filter struct {
DatasetID int64
From *time.Time
To *time.Time
ActivityIDs []int64
City string
Province string
TagID int64
MinDwell int
ExcludeHome bool
ExcludeWork bool
Keyword string
// Regions 是访客的区域限制列表:省 / 市 / 区任意层级,空表示不限制。
// RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。
Regions []string
RegionsExclude bool
// Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。
Verdict int
}
type where struct {
conds []string
args []any
// off 是占位符起始偏移,用于把同一套条件嵌进外层查询的子查询里。
off int
}
func (w *where) ph(v any) string {
w.args = append(w.args, v)
return fmt.Sprintf("$%d", len(w.args)+w.off)
}
func (w *where) cond(expr string, vals ...any) {
parts := strings.Split(expr, "?")
var b strings.Builder
b.WriteString(parts[0])
for i, v := range vals {
b.WriteString(w.ph(v))
if i+1 < len(parts) {
b.WriteString(parts[i+1])
}
}
w.conds = append(w.conds, b.String())
}
func (w *where) inList(col string, ids []int64) {
if len(ids) == 0 {
return
}
ph := make([]string, 0, len(ids))
for _, id := range ids {
ph = append(ph, w.ph(id))
}
w.conds = append(w.conds, col+" IN ("+strings.Join(ph, ",")+")")
}
// expr 返回不带 WHERE 前缀的条件表达式,供子查询拼接。
func (w *where) expr() string {
return strings.Join(w.conds, " AND ")
}
func (w *where) SQL() string {
if len(w.conds) == 0 {
return ""
}
return " WHERE " + w.expr()
}
func (w *where) andSQL() string {
if len(w.conds) == 0 {
return ""
}
return " AND " + strings.Join(w.conds, " AND ")
}
// visitsWhere 构造作用于 visits 表别名 v 的过滤条件。
// off 用于嵌套:子查询里的占位符从 off+1 开始编号,避免与外层冲突。
func (f Filter) visitsWhere(off int) *where {
w := &where{off: off}
w.cond("v.dataset_id = ?", f.DatasetID)
if f.From != nil {
w.cond("v.arrival >= ?", *f.From)
}
if f.To != nil {
w.cond("v.arrival < ?", *f.To)
}
w.inList("v.activity_id", f.ActivityIDs)
if f.MinDwell > 0 {
w.cond("COALESCE(v.duration_min,0) >= ?", f.MinDwell)
}
if f.ExcludeHome {
w.cond("NOT v.is_home")
}
if f.ExcludeWork {
w.cond("NOT v.is_work")
}
if f.City != "" {
w.cond("EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city = ?)", f.City)
}
if f.Province != "" {
w.cond("EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.province = ?)", f.Province)
}
if f.TagID > 0 {
w.cond("EXISTS (SELECT 1 FROM visit_tags vtx WHERE vtx.visit_id=v.id AND vtx.tag_id = ?)", f.TagID)
}
if len(f.Regions) > 0 {
// 省 / 市 / 区三级各来一份占位符:填「广东省」命中全省,填「贵阳市」只命中该市。
// 这里不用 COALESCE:条件包在 EXISTS 里,NULL 只是让子查询查不到行,
// 「NOT EXISTS」照样能正确保留不匹配的记录。
var prov, city, dist []string
for _, r := range f.Regions {
prov = append(prov, w.ph(r))
city = append(city, w.ph(r))
dist = append(dist, w.ph(r))
}
expr := `EXISTS (SELECT 1 FROM places pxr WHERE pxr.id=v.place_id
AND (pxr.province IN (` + strings.Join(prov, ",") + `) OR pxr.city IN (` +
strings.Join(city, ",") + `) OR pxr.district IN (` + strings.Join(dist, ",") + `)))`
if f.RegionsExclude {
expr = "NOT " + expr
}
w.conds = append(w.conds, expr)
}
if f.Keyword != "" {
kw := "%" + f.Keyword + "%"
w.cond(`EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id
AND (px.name ILIKE ? OR px.city ILIKE ? OR px.poi_category ILIKE ?))`, kw, kw, kw)
}
return w
}
// hasVisitConds 判断除时间范围外,是否还有「必须落到某次到访上」才能判定的条件。
//
// 用于天气:删掉到访时 weather.visit_src 会被置空而不是删行(见 editVisitDelete),
// 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该
// 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。
func (f Filter) hasVisitConds() bool {
return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork ||
f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != ""
}
// weatherWhere 构造作用于 weather 表别名 w 的过滤条件。
//
// 时间用**观测时刻 w.at**(不是所属到访的 arrival):筛一段时间,要的是「这段时间里
// 经历的天气」,横跨边界的那次到访也只算落在区间内的那些小时。
// 城市 / 类型 / 标签 / 区域 / 关键词这些条件则挂到它所属的那次到访上判定
// (实测 557/557 行都带 visit_src 且能落到 places)。
func (f Filter) weatherWhere() *where {
w := &where{}
w.cond("w.dataset_id = ?", f.DatasetID)
if f.From != nil {
w.cond("w.at >= ?", *f.From)
}
if f.To != nil {
w.cond("w.at < ?", *f.To)
}
if f.hasVisitConds() {
rest := f
rest.From, rest.To = nil, nil // 时间已经按 w.at 判过了
vw := rest.visitsWhere(len(w.args))
w.conds = append(w.conds,
"EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src AND "+vw.expr()+")")
w.args = append(w.args, vw.args...)
}
return w
}
// movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。
//
// 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places
// 判定——任一端落在被隐藏的区域,这段里程 / 这次出行就不该出现在访客看到的数据里
// (地图上的轨迹图层用的是同一套判定)。
func (f Filter) movementsWhere(off int) *where {
w := &where{off: off}
w.cond("movements.dataset_id = ?", f.DatasetID)
if f.From != nil {
w.cond("movements.started_at >= ?", *f.From)
}
if f.To != nil {
w.cond("movements.started_at < ?", *f.To)
}
if f.ExcludeHome || f.ExcludeWork {
var flags []string
if f.ExcludeHome {
flags = append(flags, "vf.is_home")
}
if f.ExcludeWork {
flags = append(flags, "vf.is_work")
}
w.cond(`NOT EXISTS (
SELECT 1 FROM visits vf WHERE vf.dataset_id = movements.dataset_id
AND (vf.src_pk = movements.from_visit_src OR vf.src_pk = movements.to_visit_src)
AND (` + strings.Join(flags, " OR ") + `))`)
}
if len(f.Regions) > 0 {
var prov, city, dist []string
for _, r := range f.Regions {
prov = append(prov, w.ph(r))
city = append(city, w.ph(r))
dist = append(dist, w.ph(r))
}
expr := `EXISTS (SELECT 1 FROM places pmv
WHERE pmv.id IN (movements.from_place_id, movements.to_place_id)
AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` +
strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))`
if f.RegionsExclude {
expr = "NOT " + expr
}
w.conds = append(w.conds, expr)
}
return w
}
type DB struct{ *sql.DB }
// ---------- 总览 ----------
type Overview struct {
Places int
Cities int
Provinces int
Days int
Visits int
DwellMin int64
DistanceKm float64
Movements int
FirstAt *time.Time
LastAt *time.Time
}
func (d DB) Overview(ctx context.Context, f Filter) (Overview, error) {
var o Overview
w := f.visitsWhere(0)
q := `SELECT count(DISTINCT v.place_id),
count(DISTINCT p.city), count(DISTINCT p.province),
count(DISTINCT (v.arrival AT TIME ZONE '` + TZ + `')::date),
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), min(v.arrival), max(v.arrival)
FROM visits v LEFT JOIN places p ON p.id = v.place_id` + w.SQL()
err := d.QueryRowContext(ctx, q, w.args...).Scan(&o.Places, &o.Cities, &o.Provinces,
&o.Days, &o.Visits, &o.DwellMin, &o.FirstAt, &o.LastAt)
if err != nil {
return o, err
}
mw := f.movementsWhere(0)
if err := d.QueryRowContext(ctx, `SELECT COALESCE(sum(distance_km),0), count(*) FROM movements`+mw.SQL(),
mw.args...).Scan(&o.DistanceKm, &o.Movements); err != nil {
return o, err
}
return o, nil
}
// ---------- 类型分布 ----------
type ActivityStat struct {
ID int64
Name string
Color string
Icon string
Count int
Dwell int64
Places int
}
func (d DB) ActivityStats(ctx context.Context, f Filter) ([]ActivityStat, error) {
w := f.visitsWhere(0)
q := `SELECT COALESCE(a.id,0), COALESCE(a.name,'未分类'), COALESCE(a.color,''),
COALESCE(a.icon,''), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0),
count(DISTINCT v.place_id)
FROM visits v LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() + `
GROUP BY 1,2,3,4 ORDER BY 5 DESC`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ActivityStat
for rows.Next() {
var a ActivityStat
if err := rows.Scan(&a.ID, &a.Name, &a.Color, &a.Icon, &a.Count, &a.Dwell, &a.Places); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// Activities 列出全部活动类型,供筛选器使用。
func (d DB) Activities(ctx context.Context, datasetID int64) ([]ActivityStat, error) {
rows, err := d.QueryContext(ctx, `SELECT a.id, a.name, COALESCE(a.color,''), COALESCE(a.icon,''),
(SELECT count(*) FROM visits v WHERE v.activity_id=a.id) n,
COALESCE((SELECT sum(LEAST(COALESCE(v.duration_min,0), 10080)) FROM visits v WHERE v.activity_id=a.id),0)
FROM activities a WHERE a.dataset_id=$1 ORDER BY n DESC, a.name`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ActivityStat
for rows.Next() {
var a ActivityStat
if err := rows.Scan(&a.ID, &a.Name, &a.Color, &a.Icon, &a.Count, &a.Dwell); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// ---------- 城市 / 省份 ----------
type CityStat struct {
City string
Count int
Places int
Dwell int64
}
func (d DB) CityStats(ctx context.Context, f Filter, limit int) ([]CityStat, error) {
w := f.visitsWhere(0)
w.cond("p.city IS NOT NULL AND p.city <> ''")
q := `SELECT p.city, count(*), count(DISTINCT v.place_id), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
FROM visits v JOIN places p ON p.id = v.place_id` + w.SQL() + `
GROUP BY p.city ORDER BY 2 DESC LIMIT ` + fmt.Sprint(limit)
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CityStat
for rows.Next() {
var c CityStat
if err := rows.Scan(&c.City, &c.Count, &c.Places, &c.Dwell); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
type ProvinceStat struct {
Province string
Cities int
Count int
Places int
}
func (d DB) ProvinceStats(ctx context.Context, f Filter) ([]ProvinceStat, error) {
w := f.visitsWhere(0)
w.cond("p.province IS NOT NULL AND p.province <> ''")
q := `SELECT p.province, count(DISTINCT p.city), count(*), count(DISTINCT v.place_id)
FROM visits v JOIN places p ON p.id = v.place_id` + w.SQL() + `
GROUP BY p.province ORDER BY 3 DESC`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProvinceStat
for rows.Next() {
var p ProvinceStat
if err := rows.Scan(&p.Province, &p.Cities, &p.Count, &p.Places); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// Cities 返回去重后的城市名列表(按到访地点数降序),供筛选下拉框使用。
// regions 非空时对访客按区域过滤:exclude=true 为黑名单(命中即排除),
// false 为白名单(命中才保留)。省/市/区三级都参与匹配,且必须 COALESCE——
// district 常为 NULL,NULL = 'x' 得到 NULL,黑名单的 NOT (...) 会变 NULL,
// 整行被静默丢弃。传空 regions 表示不限制。
func (d DB) Cities(ctx context.Context, datasetID int64, regions []string, exclude bool) ([]string, error) {
args := []any{datasetID}
regSQL := ""
if len(regions) > 0 {
var conds []string
for _, r := range regions {
args = append(args, r)
ph := fmt.Sprintf("$%d", len(args))
conds = append(conds, "COALESCE(p.province,'') = "+ph,
"COALESCE(p.city,'') = "+ph, "COALESCE(p.district,'') = "+ph)
}
cond := "(" + strings.Join(conds, " OR ") + ")"
if exclude {
cond = "NOT " + cond
}
regSQL = " AND " + cond
}
rows, err := d.QueryContext(ctx, `SELECT p.city FROM places p WHERE p.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>''`+regSQL+`
GROUP BY p.city ORDER BY count(*) DESC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ---------- 时间分布 ----------
type Bucket struct {
Key string
Label string
Count int
Dwell int64
}
func (d DB) Monthly(ctx context.Context, f Filter) ([]Bucket, error) {
w := f.visitsWhere(0)
q := `SELECT to_char(date_trunc('month', v.arrival AT TIME ZONE '` + TZ + `'), 'YYYY-MM'),
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
return d.buckets(ctx, q, w.args, false)
}
// Calendar 返回每天的记录数,用于活跃日历。
func (d DB) Calendar(ctx context.Context, f Filter) (map[string]int, error) {
w := f.visitsWhere(0)
q := `SELECT to_char((v.arrival AT TIME ZONE '` + TZ + `')::date, 'YYYY-MM-DD'), count(*)
FROM visits v` + w.SQL() + ` GROUP BY 1`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var k string
var n int
if err := rows.Scan(&k, &n); err != nil {
return nil, err
}
out[k] = n
}
return out, rows.Err()
}
func (d DB) Hourly(ctx context.Context, f Filter) ([]Bucket, error) {
w := f.visitsWhere(0)
q := `SELECT to_char(extract(hour from v.arrival AT TIME ZONE '` + TZ + `')::int, 'FM00'),
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
return d.buckets(ctx, q, w.args, false)
}
func (d DB) Weekday(ctx context.Context, f Filter) ([]Bucket, error) {
w := f.visitsWhere(0)
q := `SELECT extract(isodow from v.arrival AT TIME ZONE '` + TZ + `')::int::text,
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
b, err := d.buckets(ctx, q, w.args, false)
if err != nil {
return nil, err
}
names := map[string]string{"1": "周一", "2": "周二", "3": "周三", "4": "周四", "5": "周五", "6": "周六", "7": "周日"}
for i := range b {
b[i].Label = names[b[i].Key]
}
return b, nil
}
func (d DB) buckets(ctx context.Context, q string, args []any, withLabel bool) ([]Bucket, error) {
rows, err := d.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Bucket
for rows.Next() {
var b Bucket
if err := rows.Scan(&b.Key, &b.Count, &b.Dwell); err != nil {
return nil, err
}
b.Label = b.Key
out = append(out, b)
}
return out, rows.Err()
}
// ---------- 出行方式 ----------
type TransportStat struct {
Name string
Color string
Icon string
Count int
Distance float64
DwellMin int64
}
func (d DB) TransportStats(ctx context.Context, f Filter) ([]TransportStat, error) {
w := f.movementsWhere(0)
q := `SELECT COALESCE(transport_name,'未知'), COALESCE(transport_color,''), COALESCE(transport_icon,''),
count(*), COALESCE(sum(distance_km),0), COALESCE(sum(duration_min),0)
FROM movements` + w.SQL() + ` GROUP BY 1,2,3 ORDER BY 4 DESC`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TransportStat
for rows.Next() {
var t TransportStat
if err := rows.Scan(&t.Name, &t.Color, &t.Icon, &t.Count, &t.Distance, &t.DwellMin); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// ---------- 标签 ----------
type TagStat struct {
ID int64
Name string
Color string
Group string
Count int
}
func (d DB) TagStats(ctx context.Context, f Filter) ([]TagStat, error) {
w := f.visitsWhere(0)
q := `SELECT t.id, t.name, COALESCE(t.color,''), COALESCE(t.group_name,''), count(*)
FROM visit_tags vt JOIN tags t ON t.id = vt.tag_id JOIN visits v ON v.id = vt.visit_id` + w.SQL() + `
GROUP BY 1,2,3,4 ORDER BY 5 DESC`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TagStat
for rows.Next() {
var t TagStat
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Group, &t.Count); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (d DB) Tags(ctx context.Context, datasetID int64) ([]TagStat, error) {
rows, err := d.QueryContext(ctx, `SELECT t.id, t.name, COALESCE(t.color,''), COALESCE(t.group_name,''),
(SELECT count(*) FROM visit_tags vt WHERE vt.tag_id=t.id)
FROM tags t WHERE t.dataset_id=$1 ORDER BY 5 DESC, 2`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TagStat
for rows.Next() {
var t TagStat
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Group, &t.Count); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// ---------- 地点 ----------
type Place struct {
ID int64
Name string
POICategory string
Lat float64
Lon float64
City string
Province string
District string
Timezone string
VisitCount int
DwellMinutes int64
FirstAt *time.Time
LastAt *time.Time
TopActivity string
TopColor string
TopIcon string
IsHome bool
IsWork bool
// SrcPK 是 rond 内部的地点 ID,跨数据集快照稳定,用于挂备注与别名
SrcPK int
// Alias / Note 来自 place_notes:别名对外替代原名显示,备注仅站长可见
Alias string
Note string
// Verdict / Up / Down 是「推荐 / 踩雷」的站长结论与访客票数,
// 由查询后单独填充(它们按 src_pk 存在另外两张表里)。
Verdict int
Up int
Down int
}
const placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.district,''), COALESCE(p.timezone,''),
p.visit_count, p.dwell_minutes, p.first_visit_at, p.last_visit_at,
COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.is_home),
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.is_work),
p.src_pk`
// PlaceCols 与 ScanPlace 配对,供包外构造只取单个地点的查询。
const PlaceCols = placeCols
func scanPlace(s interface{ Scan(...any) error }) (Place, error) {
var p Place
err := s.Scan(&p.ID, &p.Name, &p.POICategory, &p.Lat, &p.Lon, &p.City, &p.Province,
&p.District, &p.Timezone, &p.VisitCount, &p.DwellMinutes, &p.FirstAt, &p.LastAt,
&p.TopActivity, &p.TopColor, &p.TopIcon, &p.IsHome, &p.IsWork, &p.SrcPK)
return p, err
}
// ScanPlace 把一行按 placeCols 列序扫描进 Place。
func ScanPlace(s interface{ Scan(...any) error }, p *Place) error {
got, err := scanPlace(s)
if err != nil {
return err
}
*p = got
return nil
}
// Places 分页返回地点库;筛选条件最终落在 visits 上,确保与地图口径一致。
func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset int) ([]Place, int, error) {
inner := f.visitsWhere(1)
inner.cond("v.place_id = p.id")
order := "p.visit_count DESC, p.dwell_minutes DESC, p.name"
switch sortBy {
case "dwell":
order = "p.dwell_minutes DESC, p.visit_count DESC"
case "recent":
order = "p.last_visit_at DESC NULLS LAST"
case "name":
order = "p.name"
case "first":
order = "p.first_visit_at ASC NULLS LAST"
}
args := append([]any{f.DatasetID}, inner.args...)
base := `FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)`
if f.Verdict != 0 {
// 结论存在 place_votes,按 src_pk 关联;占位符接在内层条件之后。
// dataset_id 也要对上:src_pk 只在单个数据集内唯一。
args = append(args, f.Verdict)
base += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM place_votes pv
WHERE pv.src_pk = p.src_pk AND pv.dataset_id = p.dataset_id AND pv.verdict = $%d)`, len(args))
}
q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
rows, err := d.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Place
for rows.Next() {
p, err := scanPlace(rows)
if err != nil {
return nil, 0, err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var total int
if err := d.QueryRowContext(ctx, `SELECT count(*) `+base, args...).Scan(&total); err != nil {
return nil, 0, err
}
return out, total, nil
}
// MapPlaces 返回地图所需的轻量地点集合。
func (d DB) MapPlaces(ctx context.Context, f Filter, limit int) ([]Place, error) {
inner := f.visitsWhere(1)
inner.cond("v.place_id = p.id")
q := `SELECT ` + placeCols + ` FROM places p WHERE p.dataset_id = $1
AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)
ORDER BY p.visit_count DESC LIMIT ` + fmt.Sprint(limit)
args := append([]any{f.DatasetID}, inner.args...)
rows, err := d.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Place
for rows.Next() {
p, err := scanPlace(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (d DB) Place(ctx context.Context, datasetID, id int64) (Place, error) {
q := `SELECT ` + placeCols + ` FROM places p WHERE p.dataset_id=$1 AND p.id=$2`
return scanPlace(d.QueryRowContext(ctx, q, datasetID, id))
}
// ---------- 访问记录 ----------
type VisitRow struct {
ID int64
SrcPK int
Arrival time.Time
Departure *time.Time
DurationMin int
PlaceID *int64
PlaceName string
City string
POICategory string
ActivityName string
Color string
Icon string
IsHome bool
IsWork bool
Remark string
Tags []string
}
const visitCols = `v.id, v.src_pk, v.arrival, v.departure, COALESCE(v.duration_min,0),
v.place_id, COALESCE(p.name,''), COALESCE(p.city,''), COALESCE(p.poi_category,''),
COALESCE(a.name,''), COALESCE(a.color,''), COALESCE(a.icon,''),
v.is_home, v.is_work, COALESCE(v.remark,''),
COALESCE((SELECT string_agg(t.name, '、' ORDER BY t.name) FROM visit_tags vt JOIN tags t ON t.id=vt.tag_id
WHERE vt.visit_id = v.id), '')`
func scanVisit(s interface{ Scan(...any) error }) (VisitRow, error) {
var v VisitRow
var tags string
err := s.Scan(&v.ID, &v.SrcPK, &v.Arrival, &v.Departure, &v.DurationMin,
&v.PlaceID, &v.PlaceName, &v.City, &v.POICategory,
&v.ActivityName, &v.Color, &v.Icon, &v.IsHome, &v.IsWork, &v.Remark, &tags)
if tags != "" {
v.Tags = strings.Split(tags, "、")
}
return v, err
}
func (d DB) Visits(ctx context.Context, f Filter, limit, offset int, order string) ([]VisitRow, int, error) {
w := f.visitsWhere(0)
dir := "DESC"
if order == "asc" {
dir = "ASC"
}
q := `SELECT ` + visitCols + ` FROM visits v
LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() +
` ORDER BY v.arrival ` + dir + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []VisitRow
for rows.Next() {
v, err := scanVisit(rows)
if err != nil {
return nil, 0, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var total int
cq := `SELECT count(*) FROM visits v` + w.SQL()
if err := d.QueryRowContext(ctx, cq, w.args...).Scan(&total); err != nil {
return nil, 0, err
}
return out, total, nil
}
// RecentVisits 最近足迹,主页使用。
func (d DB) RecentVisits(ctx context.Context, f Filter, limit int) ([]VisitRow, error) {
w := f.visitsWhere(0)
q := `SELECT ` + visitCols + ` FROM visits v
LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() +
` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []VisitRow
for rows.Next() {
v, err := scanVisit(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (d DB) VisitsByPlace(ctx context.Context, datasetID, placeID int64, limit int) ([]VisitRow, error) {
q := `SELECT ` + visitCols + ` FROM visits v
LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_id
WHERE v.dataset_id=$1 AND v.place_id=$2 ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
rows, err := d.QueryContext(ctx, q, datasetID, placeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []VisitRow
for rows.Next() {
v, err := scanVisit(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (d DB) Bookmarked(ctx context.Context, datasetID int64, limit int) ([]VisitRow, error) {
q := `SELECT ` + visitCols + ` FROM visits v
LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN activities a ON a.id = v.activity_id
WHERE v.dataset_id=$1 AND v.bookmarked ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
rows, err := d.QueryContext(ctx, q, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []VisitRow
for rows.Next() {
v, err := scanVisit(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// ---------- 天气 ----------
type WeatherStat struct {
Condition string
Symbol string
Count int
AvgTemp float64
MinTemp float64
MaxTemp float64
}
func (d DB) WeatherStats(ctx context.Context, f Filter) ([]WeatherStat, error) {
// 这里以前只用了 f.DatasetID,筛选(时间范围 / 城市 / 类型 …)对它完全不起作用——
// 统计页筛完之后「气温」还是全量汇总,就是这么来的。
w := f.weatherWhere()
q := `SELECT COALESCE(w.condition,'未知'), COALESCE(w.symbol,''), count(*),
COALESCE(avg(w.temperature_c),0)-273.15, COALESCE(min(w.temperature_c),0)-273.15, COALESCE(max(w.temperature_c),0)-273.15
FROM weather w` + w.SQL() + ` GROUP BY 1,2 ORDER BY 3 DESC LIMIT 12`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WeatherStat
for rows.Next() {
var w WeatherStat
if err := rows.Scan(&w.Condition, &w.Symbol, &w.Count, &w.AvgTemp, &w.MinTemp, &w.MaxTemp); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
// TempBucket 是逐月平均气温。
//
// 原先这里借用 Bucket,把「平均气温」塞进了 Dwell 字段(因为 buckets() 的第三列一律读成
// Dwell),读代码的人根本猜不到,所以单开一个结构。
type TempBucket struct {
Key string // 2026-09
Label string // 标签用短写(26-09),十几根柱子横排时 7 个字符会挤在一起
Hours int // 这个月有多少小时有记录
AvgC float64 // 平均气温(摄氏度)
}
// HourlyTemp 逐月平均气温(同样跟着筛选走)。
func (d DB) HourlyTemp(ctx context.Context, f Filter) ([]TempBucket, error) {
w := f.weatherWhere()
w.conds = append(w.conds, "w.temperature_c IS NOT NULL")
q := `SELECT to_char(date_trunc('month', w.at AT TIME ZONE '` + TZ + `'), 'YYYY-MM'),
count(*), COALESCE(avg(w.temperature_c)-273.15, 0)
FROM weather w` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
rows, err := d.QueryContext(ctx, q, w.args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TempBucket
for rows.Next() {
var b TempBucket
if err := rows.Scan(&b.Key, &b.Hours, &b.AvgC); err != nil {
return nil, err
}
b.AvgC = math.Round(b.AvgC*10) / 10
if len(b.Key) >= 7 { // 2026-09 → 26-09
b.Label = b.Key[2:]
} else {
b.Label = b.Key
}
out = append(out, b)
}
return out, rows.Err()
}
// ConditionName 把 Apple 的天气枚举翻成中文。
func ConditionName(c string) string {
m := map[string]string{
"clear": "晴", "mostlyClear": "晴间多云", "partlyCloudy": "多云", "mostlyCloudy": "阴天多云",
"cloudy": "阴", "foggy": "有雾", "haze": "霾", "mostlyCloudyNight": "夜间多云",
"drizzle": "毛毛雨", "rain": "雨", "heavyRain": "大雨", "showers": "阵雨",
"thunderstorms": "雷雨", "snow": "雪", "heavySnow": "大雪", "sleet": "雨夹雪",
"windy": "大风", "blizzard": "暴风雪", "freezingRain": "冻雨", "hot": "高温", "cold": "寒冷",
}
if v, ok := m[c]; ok {
return v
}
return c
}
+252
View File
@@ -0,0 +1,252 @@
package stats_test
import (
"context"
"database/sql"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"rond-with-you/internal/stats"
)
// 这些是只读的对照测试:把统计结果和「手写 SQL 数一遍」的结果比对,
// 专门钉住「区域限制漏在某个查询里」这类问题(轨迹、里程、出行方式都犯过)。
func setup(t *testing.T) (*sql.DB, int64) {
t.Helper()
dsn := os.Getenv("ROND_DSN")
if dsn == "" {
t.Skip("未设置 ROND_DSN")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
var id int64
if err := db.QueryRow(`SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&id); err != nil {
t.Skipf("没有生效数据集:%v", err)
}
return db, id
}
func TestOverviewMatchesManualSQL(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
f := stats.Filter{DatasetID: dsID}
got, err := d.Overview(context.Background(), f)
if err != nil {
t.Fatal(err)
}
var places, cities, days, visits, movements int
var dist float64
if err := db.QueryRow(`SELECT count(DISTINCT v.place_id), count(DISTINCT p.city),
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date), count(*)
FROM visits v LEFT JOIN places p ON p.id = v.place_id WHERE v.dataset_id=$1`, dsID).
Scan(&places, &cities, &days, &visits); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT COALESCE(sum(distance_km),0), count(*) FROM movements WHERE dataset_id=$1`, dsID).
Scan(&dist, &movements); err != nil {
t.Fatal(err)
}
if got.Places != places || got.Cities != cities || got.Days != days || got.Visits != visits {
t.Errorf("总览与手工 SQL 不一致:%+v vs places=%d cities=%d days=%d visits=%d",
got, places, cities, days, visits)
}
if diff := got.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
t.Errorf("里程 %.4f,手工 SQL %.4f", got.DistanceKm, dist)
}
if got.Movements != movements {
t.Errorf("行程数 %d,手工 SQL %d", got.Movements, movements)
}
}
// 区域限制必须同时作用于 visits 与 movements:只改其中一边,
// 访客会看到「到访次数藏了、里程还留着」这种自相矛盾的数字。
func TestRegionFilterAppliesToVisitsAndMovements(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
// 挑一个有记录的省份做白名单,避免用空区域(那样等于没限制)
var region string
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> ''
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&region); err != nil {
t.Skipf("找不到可用省份:%v", err)
}
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
only, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
if err != nil {
t.Fatal(err)
}
if only.Visits >= all.Visits {
t.Errorf("白名单 %s 后到访数没减少:%d → %d", region, all.Visits, only.Visits)
}
if only.DistanceKm >= all.DistanceKm {
t.Errorf("白名单 %s 后里程没减少:%.1f → %.1f(movements 的区域过滤可能漏了)",
region, all.DistanceKm, only.DistanceKm)
}
// 黑名单:反过来,排除这个省后剩下的应等于「全部 - 只有它」
var other string
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> '' AND p.province <> $2
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID, region).Scan(&other); err == nil {
notRegion, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}, RegionsExclude: true})
if err != nil {
t.Fatal(err)
}
if notRegion.Visits == all.Visits {
t.Errorf("黑名单 %s 后到访数没减少:仍为 %d", region, all.Visits)
}
}
// 出行方式统计同样要跟着缩水
tAll, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
tOnly, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
if err != nil {
t.Fatal(err)
}
sum := func(ts []stats.TransportStat) float64 {
var v float64
for _, x := range ts {
v += x.Distance
}
return v
}
if sum(tOnly) >= sum(tAll) {
t.Errorf("白名单 %s 后出行方式里程没减少:%.1f → %.1f", region, sum(tAll), sum(tOnly))
}
}
// 过滤条件不该把时间范围弄丢。
func TestOverviewTimeRangeStillApplies(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
if all.FirstAt == nil || all.LastAt == nil {
t.Skip("没有时间范围可测")
}
mid := all.FirstAt.Add(all.LastAt.Sub(*all.FirstAt) / 2)
half, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, From: &mid})
if err != nil {
t.Fatal(err)
}
if half.Visits >= all.Visits {
t.Errorf("限定后半段后到访数没减少:%d → %d", all.Visits, half.Visits)
}
if half.LastAt == nil || all.LastAt == nil || half.LastAt.Before(mid) {
t.Errorf("后半段的最后时间 %v 早于起点 %v", half.LastAt, mid)
}
}
// TestWeatherStatsRespectsFilter 天气统计必须跟着筛选走。
//
// 以前 WeatherStats 声明了 Filter 却只用了 DatasetID,HourlyTemp 干脆只收 datasetID:
// 统计页筛完之后「气温」还是全量汇总,跟同一页的到访数对不上。
func TestWeatherStatsRespectsFilter(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
var minAt, maxAt time.Time
if err := db.QueryRow(`SELECT min(at), max(at) FROM weather WHERE dataset_id=$1`, dsID).
Scan(&minAt, &maxAt); err != nil {
t.Skipf("没有天气数据可测:%v", err)
}
// 取「最后一个月」当区间:它必然只覆盖一部分天气
from := time.Date(maxAt.Year(), maxAt.Month(), 1, 0, 0, 0, 0, maxAt.Location())
if !from.After(minAt) {
t.Skip("天气数据只落在一个月内,构造不出有效区间")
}
sum := func(ws []stats.WeatherStat) int {
n := 0
for _, w := range ws {
n += w.Count
}
return n
}
all, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
got, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, From: &from})
if err != nil {
t.Fatal(err)
}
// 与手写 SQL 对账
var wantTotal, wantWindow int
if err := db.QueryRow(`SELECT count(*) FROM weather WHERE dataset_id=$1`, dsID).Scan(&wantTotal); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT count(*) FROM weather w WHERE w.dataset_id=$1 AND w.at >= $2`,
dsID, from).Scan(&wantWindow); err != nil {
t.Fatal(err)
}
// 分组数是 12 上限,只有没截断时求和才等于总数
if len(got) < 12 && sum(got) != wantWindow {
t.Errorf("按时间筛选后天气行数 %d,手写 SQL %d", sum(got), wantWindow)
}
if wantWindow < wantTotal && sum(got) >= sum(all) {
t.Errorf("筛了时间但天气行数没减少(%d → %d),筛选没生效", sum(all), sum(got))
}
// 逐月气温同样要跟着区间走
temps, err := d.HourlyTemp(ctx, stats.Filter{DatasetID: dsID, From: &from})
if err != nil {
t.Fatal(err)
}
if len(temps) != 1 {
t.Errorf("区间只覆盖 %s 一个月,逐月气温应当只剩 1 组,实际 %d 组:%+v",
from.Format("2006-01"), len(temps), temps)
}
// 城市筛选:只有归属于该城市到访的天气算数
var city string
if err := db.QueryRow(`SELECT p.city FROM weather w
JOIN visits v ON v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
JOIN places p ON p.id=v.place_id
WHERE w.dataset_id=$1 AND p.city IS NOT NULL
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
t.Skipf("没有带城市的天气:%v", err)
}
byCity, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, City: city})
if err != nil {
t.Fatal(err)
}
var wantCity int
if err := db.QueryRow(`SELECT count(*) FROM weather w
WHERE w.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
WHERE v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2))`,
dsID, city).Scan(&wantCity); err != nil {
t.Fatal(err)
}
if len(byCity) < 12 && sum(byCity) != wantCity {
t.Errorf("按城市「%s」筛选后天气行数 %d,手写 SQL %d", city, sum(byCity), wantCity)
}
if sum(byCity) >= wantTotal {
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal)
}
}
+78
View File
@@ -0,0 +1,78 @@
package stats
import (
"strings"
"testing"
"time"
)
// TestWeatherWhereSemantics 钉住天气筛选的两条语义,它们都不是显而易见的:
//
// 1. 时间用**观测时刻 w.at**,不是所属到访的 arrival —— 筛一段时间要的是
// 「这段时间里经历的天气」,横跨边界的那次到访只算落在区间内的小时。
// 2. 只按时间筛时**不能**要求天气能关联到访。删到访时 weather.visit_src 是被置空
// 而不是删行(editVisitDelete),那些失去归属的天气行仍属于「某段时间的天气」;
// 一旦加上 EXISTS,删掉一次到访就会让它那几天的天气从统计里凭空消失。
// 而城市 / 类型 / 标签这类条件必须挂到到访上,那时才该有 EXISTS。
//
// 纯白盒:直接看生成的 SQL 形状,不需要真实数据。
func TestWeatherWhereSemantics(t *testing.T) {
from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
t.Run("时间按观测时刻", func(t *testing.T) {
w := Filter{DatasetID: 9, From: &from, To: &to}.weatherWhere()
got := w.SQL()
if !strings.Contains(got, "w.at >= ") || !strings.Contains(got, "w.at < ") {
t.Errorf("时间条件应落在 w.at 上,实际:%s", got)
}
if strings.Contains(got, "v.arrival") {
t.Errorf("不该用所属到访的 arrival 判时间,实际:%s", got)
}
if strings.Contains(got, "EXISTS") {
t.Errorf("只按时间筛选时不该要求能关联到到访(否则删过到访的天气会消失),实际:%s", got)
}
if len(w.args) != 3 {
t.Errorf("参数个数应为 3(数据集 + 起 + 止),实际 %d:%v", len(w.args), w.args)
}
})
t.Run("城市条件挂到到访上", func(t *testing.T) {
w := Filter{DatasetID: 9, City: "贵阳市"}.weatherWhere()
got := w.SQL()
if !strings.Contains(got, "EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src") {
t.Errorf("城市条件应通过所属到访判定,实际:%s", got)
}
if !strings.Contains(got, "v.dataset_id = ") {
t.Errorf("EXISTS 里应当带上数据集条件,实际:%s", got)
}
if strings.Contains(got, "v.arrival") {
t.Errorf("时间参数已置空,不该出现 arrival 条件,实际:%s", got)
}
})
t.Run("占位符编号连续", func(t *testing.T) {
// 外层先写了 dataset + 起止时间($1~$3),EXISTS 里的条件必须从 $4 接着编,
// 否则参数会错位(这类错误只在有数据时炸,很难查)。
// 城市条件本身会带一条 v.dataset_id,所以 EXISTS 里有两个占位符。
w := Filter{DatasetID: 9, From: &from, To: &to, City: "贵阳市"}.weatherWhere()
got := w.SQL()
if !strings.Contains(got, "v.dataset_id = $4") {
t.Errorf("EXISTS 的第一个占位符应当是 $4,实际:%s", got)
}
if !strings.Contains(got, "px.city = $5") {
t.Errorf("城市条件应当是 $5,实际:%s", got)
}
if len(w.args) != 5 {
t.Errorf("参数个数应为 5(数据集 + 起 + 止 + v.dataset_id + 城市),实际 %d:%v",
len(w.args), w.args)
}
})
t.Run("区域限制也算到访条件", func(t *testing.T) {
w := Filter{DatasetID: 9, Regions: []string{"贵阳市"}, RegionsExclude: true}.weatherWhere()
if !strings.Contains(w.SQL(), "EXISTS") {
t.Errorf("区域限制应当触发 EXISTS,实际:%s", w.SQL())
}
})
}
+164
View File
@@ -0,0 +1,164 @@
// Package votes 存放地点的「推荐 / 踩雷」两类表态:
// - place_votes 站长对地点的官方结论(单值)
// - place_visitor_votes 访客点赞 / 点踩(一人一票,可改可撤)
//
// 两者都按 rond 的地点 ID(src_pk)存储,重新上传备份、地点换了新 id 也不丢。
package votes
import (
"context"
"database/sql"
)
// VerdictRecommend / VerdictAvoid 是官方结论的取值。
const (
VerdictAvoid = -1
VerdictRecommend = 1
)
type Store struct{ DB *sql.DB }
// Tally 是一个地点的访客票数汇总。
type Tally struct {
Up int `json:"up"`
Down int `json:"down"`
}
// Verdicts 返回某个数据集的站长结论:src_pk -> verdict(1/-1)。
// 必须按 dataset_id 过滤:src_pk 只在单个数据集内唯一。
func (s *Store) Verdicts(ctx context.Context, datasetID int64) (map[int]int, error) {
out := map[int]int{}
rows, err := s.DB.QueryContext(ctx, `SELECT src_pk, verdict FROM place_votes WHERE dataset_id=$1`, datasetID)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var pk, v int
if err := rows.Scan(&pk, &v); err != nil {
return out, err
}
out[pk] = v
}
return out, rows.Err()
}
// Verdict 取单个地点的站长结论;未标注返回 0。
func (s *Store) Verdict(ctx context.Context, datasetID int64, srcPK int) (int, error) {
var v int
err := s.DB.QueryRowContext(ctx,
`SELECT verdict FROM place_votes WHERE dataset_id=$1 AND src_pk=$2`, datasetID, srcPK).Scan(&v)
if err == sql.ErrNoRows {
return 0, nil
}
return v, err
}
// SaveVerdict 设置站长结论;verdict 为 0 表示取消结论。
func (s *Store) SaveVerdict(ctx context.Context, userID, datasetID int64, srcPK, verdict int) error {
if verdict == 0 {
_, err := s.DB.ExecContext(ctx,
`DELETE FROM place_votes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`, userID, datasetID, srcPK)
return err
}
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_votes (user_id, dataset_id, src_pk, verdict, updated_at)
VALUES ($1,$2,$3,$4,now())
ON CONFLICT (user_id, dataset_id, src_pk) DO UPDATE SET verdict=EXCLUDED.verdict, updated_at=now()`,
userID, datasetID, srcPK, verdict)
return err
}
// Tallies 返回全部地点的访客票数。票表规模很小(个人站点),一次读全更省事,
// 也避免给 pgx 传数组参数带来的类型歧义。
func (s *Store) Tallies(ctx context.Context) (map[int]Tally, error) {
out := map[int]Tally{}
rows, err := s.DB.QueryContext(ctx, `SELECT src_pk,
count(*) FILTER (WHERE vote=1), count(*) FILTER (WHERE vote=-1)
FROM place_visitor_votes GROUP BY src_pk`)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var pk int
var t Tally
if err := rows.Scan(&pk, &t.Up, &t.Down); err != nil {
return out, err
}
out[pk] = t
}
return out, rows.Err()
}
// Tally 取单个地点的访客票数。
func (s *Store) Tally(ctx context.Context, srcPK int) (Tally, error) {
var t Tally
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FILTER (WHERE vote=1), count(*) FILTER (WHERE vote=-1)
FROM place_visitor_votes WHERE src_pk=$1`, srcPK).Scan(&t.Up, &t.Down)
return t, err
}
// VoterAll 返回该访客投过的全部票:src_pk -> vote,供地图一次性标注「我投的」。
func (s *Store) VoterAll(ctx context.Context, voterKey string) (map[int]int, error) {
out := map[int]int{}
if voterKey == "" {
return out, nil
}
rows, err := s.DB.QueryContext(ctx,
`SELECT src_pk, vote FROM place_visitor_votes WHERE voter_key=$1`, voterKey)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var pk, v int
if err := rows.Scan(&pk, &v); err != nil {
return out, err
}
out[pk] = v
}
return out, rows.Err()
}
// Voter 返回该访客对某地点的既有投票(1/-1/0)。voterKey 为空表示拿不到身份。
func (s *Store) Voter(ctx context.Context, srcPK int, voterKey string) int {
if voterKey == "" {
return 0
}
var v int
if err := s.DB.QueryRowContext(ctx,
`SELECT vote FROM place_visitor_votes WHERE src_pk=$1 AND voter_key=$2`, srcPK, voterKey).Scan(&v); err != nil {
return 0
}
return v
}
// Cast 记录访客投票:未投过则新增;投同一票则撤销(再点一次取消);投另一票则改为该票。
// 返回更新后的票数与该访客最终的选择。
func (s *Store) Cast(ctx context.Context, srcPK int, voterKey string, vote int) (Tally, int, error) {
var cur int
err := s.DB.QueryRowContext(ctx,
`SELECT vote FROM place_visitor_votes WHERE src_pk=$1 AND voter_key=$2`, srcPK, voterKey).Scan(&cur)
switch {
case err == sql.ErrNoRows:
_, err = s.DB.ExecContext(ctx,
`INSERT INTO place_visitor_votes (src_pk, voter_key, vote) VALUES ($1,$2,$3)`, srcPK, voterKey, vote)
cur = vote
case err != nil:
return Tally{}, 0, err
case cur == vote:
_, err = s.DB.ExecContext(ctx,
`DELETE FROM place_visitor_votes WHERE src_pk=$1 AND voter_key=$2`, srcPK, voterKey)
cur = 0
default:
_, err = s.DB.ExecContext(ctx,
`UPDATE place_visitor_votes SET vote=$3, updated_at=now() WHERE src_pk=$1 AND voter_key=$2`,
srcPK, voterKey, vote)
cur = vote
}
if err != nil {
return Tally{}, 0, err
}
t, err := s.Tally(ctx, srcPK)
return t, cur, err
}
+60
View File
@@ -0,0 +1,60 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
)
// assetVersion 是静态资源的版本号,页面链接带 ?v=它,一致才允许长期缓存。
// 重新编译或改动静态文件后版本号随之变化,旧缓存自动失效。
var assetVersion = "0"
// initAssetVersion 在启动时计算版本号:调试模式读磁盘(改样式即时生效),
// 正式模式读 embed 的文件内容。
func initAssetVersion(debug bool) {
h := sha256.New()
if debug {
var paths []string
filepath.Walk("internal/web/static", func(p string, info os.FileInfo, err error) error {
if err == nil && info != nil && !info.IsDir() {
paths = append(paths, p)
}
return nil
})
sort.Strings(paths)
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return
}
io.Copy(h, f)
f.Close()
io.WriteString(h, p)
}
assetVersion = hex.EncodeToString(h.Sum(nil))[:12]
return
}
var names []string
fs.WalkDir(staticFS, ".", func(p string, d fs.DirEntry, err error) error {
if err == nil && d != nil && !d.IsDir() {
names = append(names, p)
}
return nil
})
sort.Strings(names)
for _, p := range names {
f, err := staticFS.Open(p)
if err != nil {
return
}
io.Copy(h, f)
f.Close()
io.WriteString(h, p)
}
assetVersion = hex.EncodeToString(h.Sum(nil))[:12]
}
+151
View File
@@ -0,0 +1,151 @@
package web
import (
"context"
"log"
"net/http"
"strconv"
"strings"
)
// backfillNeed 是被判定为「信息不全」的地点:地址全空(区域黑名单匹配不上、统计里也没城市),
// 或者名字为空(地图上显示成「未命名」)。
type backfillNeed struct {
ID int64
Lat, Lon float64
Name string
Province, City, Distr string
}
// backfillPlaceAddresses 给缺地址 / 没名字的地点补上反查结果。
//
// 只填空着的列,不覆盖已有值——宁可少改。名字为空时用最近的 POI 命名(和 rond 自己的做法一致),
// 周边 200m 没有收录就退回街道 / 乡镇;再取不到就只补地址,名字保持空。
// limit 用来兜住高德配额;返回值 filled 是补成功的条数,remain 是还剩下的候选数。
func (s *Server) backfillPlaceAddresses(ctx context.Context, datasetID int64, limit int) (filled, remain int, err error) {
if s.cfg.GeocodeKey == "" {
return 0, 0, nil // 没配 key,这个功能整体关闭
}
rows, err := s.db.QueryContext(ctx, `SELECT id, lat, lon, COALESCE(name,''),
COALESCE(province,''), COALESCE(city,''), COALESCE(district,'')
FROM places
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL
AND (COALESCE(city,'')='' OR COALESCE(name,'')='')
ORDER BY id`, datasetID)
if err != nil {
return 0, 0, err
}
var todo []backfillNeed
for rows.Next() {
var t backfillNeed
if err := rows.Scan(&t.ID, &t.Lat, &t.Lon, &t.Name, &t.Province, &t.City, &t.Distr); err != nil {
rows.Close()
return 0, 0, err
}
todo = append(todo, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, err
}
if len(todo) == 0 {
return 0, 0, nil
}
if limit > 0 && len(todo) > limit {
remain = len(todo) - limit
todo = todo[:limit]
}
for _, t := range todo {
if ctx.Err() != nil {
break
}
_, prov, city, dist, street, town, ok := s.amapRegeo(ctx, t.Lat, t.Lon)
if !ok {
remain++
continue
}
// 名字为空:优先用最近的 POI,退而用街道 / 乡镇
name := t.Name
if strings.TrimSpace(name) == "" {
if poi, _, hit := s.amapNearestPOI(ctx, t.Lat, t.Lon); hit {
name = poi
} else if street != "" {
name = street
} else if town != "" {
name = town
}
}
// 只填空着的列
newProv, newCity, newDist := t.Province, t.City, t.Distr
if newProv == "" {
newProv = prov
}
if newCity == "" {
newCity = city
}
if newDist == "" {
newDist = dist
}
if name == t.Name && newProv == t.Province && newCity == t.City && newDist == t.Distr {
continue // 反查也没给出新信息
}
if _, err := s.db.ExecContext(ctx, `UPDATE places SET
name = COALESCE(NULLIF($2,''), name),
province = COALESCE(NULLIF($3,''), province),
city = COALESCE(NULLIF($4,''), city),
district = COALESCE(NULLIF($5,''), district)
WHERE id=$1`, t.ID, name, newProv, newCity, newDist); err != nil {
return filled, remain, err
}
filled++
}
return filled, remain, nil
}
// backfillAfterImport 是导入后的自动补全:只处理少量,别让上传等太久。
func (s *Server) backfillAfterImport(ctx context.Context, datasetID int64) string {
filled, remain, err := s.backfillPlaceAddresses(ctx, datasetID, 20)
if err != nil {
log.Printf("导入后补全地址失败: %v", err)
return ""
}
if filled == 0 && remain == 0 {
return ""
}
msg := ""
if filled > 0 {
msg = ";已自动反查补全 " + strconv.Itoa(filled) + " 个缺地址的地点"
}
if remain > 0 {
msg += "(还有 " + strconv.Itoa(remain) + " 个可在后台点「补全缺失地址」继续)"
}
return msg
}
// adminBackfillPlaces 手动把当前数据集里缺地址 / 没名字的地点补全一遍。
func (s *Server) adminBackfillPlaces(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有导入任何备份")
return
}
if s.cfg.GeocodeKey == "" {
s.adminFlash(w, r, "err", "没有配置 geocode_key(高德 Web 服务密钥),无法反查地址")
return
}
filled, remain, err := s.backfillPlaceAddresses(ctx, ds.ID, 100)
if err != nil {
s.serverError(w, r, err)
return
}
switch {
case filled == 0 && remain == 0:
s.adminFlash(w, r, "ok", "没有需要补全的地点:地址与名称都齐全")
case remain > 0:
s.adminFlash(w, r, "ok", "已补全 "+strconv.Itoa(filled)+" 个地点,还剩 "+strconv.Itoa(remain)+" 个,再点一次继续")
default:
s.adminFlash(w, r, "ok", "已补全 "+strconv.Itoa(filled)+" 个地点的地址 / 名称")
}
}
+49
View File
@@ -0,0 +1,49 @@
package web
import (
"bytes"
"strings"
"testing"
"rond-with-you/internal/stats"
)
// TestTagDropdownKeepsAllOptionsUnderFilter 盯住一个真实踩过的坑。
//
// 统计页的「标签排行」把数据放在自己的 Tags 字段上,而它内嵌了 Page —— 模板里的
// `.Tags` 会优先取外层那个,于是公共筛选里的标签下拉被换成了「筛选后剩下的标签」:
// 一旦选中某个标签,其它标签就从下拉里消失,用户再也切不回去。
// 现在下拉专用字段叫 Page.TagOptions,与排行用的 Tags 分开。
func TestTagDropdownKeepsAllOptionsUnderFilter(t *testing.T) {
f := newMovementFixture(t)
tmpl := f.srv.pages["stats"].t // layout.html + stats.html 一起解析过,「filters」在里面
data := StatsData{Page: Page{
FilterBase: "/stats",
TagOptions: []stats.TagStat{{ID: 1, Name: "甲"}, {ID: 2, Name: "乙"}},
TagID: 1, // 当前正按「甲」筛选
}}
// 排行只筛出被选中的那一个
data.Tags = []stats.TagStat{{ID: 1, Name: "甲"}}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "filters", data); err != nil {
t.Fatal(err)
}
html := buf.String()
// 只看标签那一个 select:整个筛选表单里还有城市、最短停留等下拉
start := strings.Index(html, `<select name="tag">`)
if start < 0 {
t.Fatalf("没找到标签下拉:\n%s", html)
}
seg := html[start:]
if end := strings.Index(seg, "</select>"); end >= 0 {
seg = seg[:end]
}
if got := strings.Count(seg, "<option"); got != 3 { // 全部标签 + 甲 + 乙
t.Errorf("标签下拉应当始终列出全部标签(3 项),实际 %d 项\n%s", got, seg)
}
if !strings.Contains(seg, ">乙</option>") {
t.Error("未被选中的标签「乙」从下拉里消失了")
}
}
+356
View File
@@ -0,0 +1,356 @@
package web
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"io"
"log"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"rond-with-you/internal/fog"
"rond-with-you/internal/geo"
)
const maxFogUploadBytes = 64 << 20 // .fwss 快照一般几 MB,放宽到 64MB
// 迷雾是站点级数据(与公开页读数据不区分归属的约定一致),
// 内存里只持一份索引,首次访问时从库里加载。
var (
fogOnce sync.Once
fogIndexV *fog.Index
)
func (s *Server) fogIndex() (*fog.Index, error) {
fogOnce.Do(func() {
fogIndexV = fog.NewIndex()
if err := fogIndexV.LoadAll(s.db); err != nil {
fogIndexV = nil
}
})
if fogIndexV == nil {
return nil, fmt.Errorf("迷雾数据加载失败")
}
return fogIndexV, nil
}
// handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是
// GCJ-02(高德),前端会带 ?crs=gcj02,服务端把每个块做 WGS→GCJ 正偏移后再画。
func (s *Server) handleFogTile(w http.ResponseWriter, r *http.Request) {
z, err1 := strconv.Atoi(r.PathValue("z"))
x, err2 := strconv.Atoi(r.PathValue("x"))
y, err3 := strconv.Atoi(r.PathValue("y"))
if err1 != nil || err2 != nil || err3 != nil || z < 0 || z > 22 ||
x < 0 || y < 0 || x >= 1<<uint(z) || y >= 1<<uint(z) {
http.NotFound(w, r)
return
}
ix, err := s.fogIndex()
if err != nil {
s.serverError(w, r, err)
return
}
crs := r.URL.Query().Get("crs")
if crs != "gcj02" && crs != "wgs84" {
crs = s.cfg.MapCRS
}
// 遮罩只对访客生效:站长自己要能看到完整迷雾,否则没法核对遮哪里
isAdmin := false
if u, _ := s.sessionUser(r); u != nil {
isAdmin = true
}
// 渲染结果缓存:一张瓦片要逐格画位图再做 PNG 编码,而内容只随
// 「迷雾数据版本 + 遮罩 + 底图坐标系」变化。站长的瓦片不含遮罩,指纹用 "-",
// 这样改遮罩时不必连带丢掉站长的缓存。
fp := "-"
if !isAdmin {
fp = s.maskFP()
}
cacheKey := strconv.Itoa(z) + "/" + strconv.Itoa(x) + "/" + strconv.Itoa(y) + "/" + crs + "/" + fp
if t, ok := s.fogTiles.get(cacheKey); ok {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", fogTileCacheControl(isAdmin))
w.Write(t.body)
return
}
masks := s.maskSetFor(isAdmin)
var img *image.NRGBA
ix.View(func(blocks map[[2]int][]byte) {
img = fog.RenderTile(blocks, z, x, y, crs, masks)
})
if img == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", fogTileCacheControl(isAdmin))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
s.serverError(w, r, err)
return
}
s.fogTiles.put(cacheKey, cachedTile{body: buf.Bytes(), ct: "image/png", at: time.Now()})
w.Write(buf.Bytes())
}
// fogTileCacheControl 决定迷雾瓦片的缓存策略。站长与访客的瓦片内容不同(一个含遮罩、
// 一个不含)而 URL 相同,所以站长的响应绝不能进共享缓存——否则 CDN / 反向代理会把
// 未遮罩的瓦片原样发给访客。访客那份内容对所有人一致,允许公开缓存。
func fogTileCacheControl(isAdmin bool) string {
if isAdmin {
return "private, no-store"
}
return "public, max-age=3600"
}
// adminFogUpload 导入 .fwss:mode=overlay 按位或合并进现有迷雾,
// mode=cover 清空后整体替换。
func (s *Server) adminFogUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
r.Body = http.MaxBytesReader(w, r.Body, maxFogUploadBytes+1<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.adminFlash(w, r, "err", "读取上传内容失败:"+err.Error())
return
}
file, hdr, err := r.FormFile("fwss")
if err != nil {
s.adminFlash(w, r, "err", "请选择要上传的 .fwss 迷雾快照")
return
}
defer file.Close()
name := strings.ToLower(filepath.Base(hdr.Filename))
if !strings.HasSuffix(name, ".fwss") && !strings.HasSuffix(name, ".zip") {
s.adminFlash(w, r, "err", "文件类型不正确,请上传 Fog of World 导出的 .fwss 快照")
return
}
mode := r.PostFormValue("mode")
if mode != "overlay" && mode != "cover" {
mode = "overlay"
}
data, err := io.ReadAll(io.LimitReader(file, maxFogUploadBytes+1))
if err != nil {
s.adminFlash(w, r, "err", "读取文件失败:"+err.Error())
return
}
if int64(len(data)) > maxFogUploadBytes {
s.adminFlash(w, r, "err", "迷雾快照超过 64MB,疑似文件有误")
return
}
blocks, cells, err := fog.Decode(data)
if err != nil {
s.adminFlash(w, r, "err", "解析迷雾快照失败:"+err.Error())
return
}
// 快照里还有 Model/~ 与 Model/# 两层(约占一半体积):站点不用它们渲染,
// 但导出时要原样写回,否则重导出的快照在 fog of world 里是残缺的
layers, err := fog.RawLayers(data)
if err != nil {
s.adminFlash(w, r, "err", "解析快照的其它图层失败:"+err.Error())
return
}
if len(blocks) == 0 {
s.adminFlash(w, r, "err", "快照里没有找到迷雾瓦片数据")
return
}
ix, err := s.fogIndex()
if err != nil {
s.serverError(w, r, err)
return
}
applied, err := ix.Import(s.db, u.ID, blocks, mode == "overlay")
if err != nil {
s.serverError(w, r, err)
return
}
// 「整体替换」时图层也整体换掉,避免留下上一份快照里已经不存在的图层条目
if err := ix.SaveRawLayers(s.db, u.ID, layers, mode == "cover"); err != nil {
log.Printf("留存迷雾其它图层失败: %v", err)
}
b, c := ix.Stats()
verb := "叠加合并"
if mode == "cover" {
verb = "整体替换"
}
s.adminFlash(w, r, "ok", fmt.Sprintf(
"迷雾导入成功(%s):本次写入 %d 块 / %d 格,当前共 %d 块 / %d 格", verb, applied, cells, b, c))
}
// ---------- 迷雾覆盖率 ----------
// FogCity 是单个城市(或省份)的迷雾点亮统计:地点里有多少个落在已探索格内。
type FogCity struct {
Name string `json:"name"`
Lit int `json:"lit"`
Total int `json:"total"`
}
// FogCoverage 是统计页展示的迷雾覆盖率数据。
type FogCoverage struct {
AreaKm2 float64 `json:"area_km2"`
Blocks int `json:"blocks"`
Cells int `json:"cells"`
LitPlaces int `json:"lit_places"`
Places int `json:"places"`
Cities []FogCity `json:"cities"`
Provinces []FogCity `json:"provinces"`
}
type fogCounter struct{ lit, total map[string]int }
func newFogCounter() *fogCounter {
return &fogCounter{lit: map[string]int{}, total: map[string]int{}}
}
func (fc *fogCounter) add(name string, lit bool) {
fc.total[name]++
if lit {
fc.lit[name]++
}
}
func (fc *fogCounter) top() []FogCity {
out := make([]FogCity, 0, len(fc.total))
for name, total := range fc.total {
out = append(out, FogCity{Name: name, Lit: fc.lit[name], Total: total})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Lit != out[j].Lit {
return out[i].Lit > out[j].Lit
}
return out[i].Total > out[j].Total
})
if len(out) > 12 {
out = out[:12]
}
return out
}
// fogCoverage 计算当前数据集地点与迷雾位图的叠加:迷雾网格是 WGS-84,
// 库里的地点是 GCJ-02,先反算到 WGS 再查格位图。
// 没导入迷雾或没数据时返回 nil,模板据此隐藏整个区块。
// fogCoverage 统计迷雾覆盖率。regions/exclude 来自 filterOf(站长为空),
// 与其它公开统计保持一致——否则黑名单里的城市名照样会出现在「涉及城市」里。
func (s *Server) fogCoverage(ctx context.Context, datasetID int64, regions []string, exclude bool, admin bool) *FogCoverage {
ix, err := s.fogIndex()
if err != nil {
return nil
}
blocks, cells := ix.Stats()
if blocks == 0 {
return nil
}
// 遮罩内的迷雾对访客不算数:地点要跳过,面积也要扣掉这部分格子
masks := s.maskSetFor(admin)
args := []any{datasetID}
q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''),
COALESCE(p.lat,0), COALESCE(p.lon,0), p.lat IS NULL
FROM places p WHERE p.dataset_id=$1` + regionSQL("p", regions, exclude, &args)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil
}
defer rows.Close()
cov := &FogCoverage{Blocks: blocks, Cells: cells}
cities, provs := newFogCounter(), newFogCounter()
for rows.Next() {
var city, province string
var lat, lon float64
var nullCoord bool
if err := rows.Scan(&city, &province, &lat, &lon, &nullCoord); err != nil {
return nil
}
cov.Places++
if nullCoord {
continue
}
if masks.HitLatLon(lat, lon) {
continue
}
wlat, wlon := geo.GCJ02ToWGS84(lat, lon)
cx, cy := fog.CellOf(wlat, wlon)
lit := ix.CellLit(cx, cy)
if lit {
cov.LitPlaces++
}
if city != "" {
cities.add(city, lit)
}
if province != "" {
provs.add(province, lit)
}
}
if err := rows.Err(); err != nil {
return nil
}
cov.Cities = cities.top()
cov.Provinces = provs.top()
// 面积与格数都用索引里预计算好的总量,再减掉遮罩盖住的部分
// (以前是每次现扫一遍全部位图、还顺手复制排序了三次整张块表)。
cov.AreaKm2 = ix.AreaKm2()
if mc, ma := ix.MaskedStats(masks); mc > 0 {
cov.Cells -= mc
cov.AreaKm2 -= ma / 1e6
}
return cov
}
// fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用),
// 下次访问时按空库重建。
func fogResetIndex() {
fogIndexV = fog.NewIndex()
}
// adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。
func (s *Server) adminFogClear(w http.ResponseWriter, r *http.Request) {
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM fog_blocks`); err != nil {
s.serverError(w, r, err)
return
}
// 其它图层一并丢弃:留着的话导出的快照会有图层条目却没有对应的 Model/* 瓦片
if err := fog.DropRawLayers(s.db); err != nil {
log.Printf("清空迷雾其它图层失败: %v", err)
}
fogResetIndex()
s.adminFlash(w, r, "ok", "迷雾已清空")
}
// adminFogExport 把当前迷雾还原成 .fwss 供迁移。
func (s *Server) adminFogExport(w http.ResponseWriter, r *http.Request) {
ix, err := s.fogIndex()
if err != nil {
s.serverError(w, r, err)
return
}
blocks := ix.All()
if len(blocks) == 0 {
s.adminFlash(w, r, "err", "还没有导入任何迷雾数据")
return
}
layers, err := fog.LoadRawLayers(s.db, userFrom(r.Context()).ID)
if err != nil {
log.Printf("读取迷雾其它图层失败: %v", err)
}
out, err := fog.Encode(blocks, layers)
if err != nil {
s.serverError(w, r, err)
return
}
_, cells := ix.Stats()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="fog-export-%s.fwss"`, time.Now().Format("20060102-150405")))
w.Header().Set("X-Fog-Cells", strconv.Itoa(cells))
w.Write(out)
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"rond-with-you/internal/fog"
"rond-with-you/internal/geo"
)
// fogMaskPage 管理「对访客隐藏」的迷雾范围。
func (s *Server) fogMaskPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "迷雾遮罩区")
if err != nil {
s.serverError(w, r, err)
return
}
p.SetFlashFromQuery(r)
p.HasMap = true // 布局按这个标记引入 Leaflet,漏了地图整块不出现
masks := s.masksFor()
if err != nil {
s.serverError(w, r, err)
return
}
d := FogMaskData{Page: p, Masks: masks, MapInfo: s.mapInfo()}
s.render(w, "admin/fog-mask", d)
}
// fogMaskSave 新增或更新一块遮罩区。
// 表单里的坐标来自底图(可能是 WGS-84 的天地图),要换算成站点统一的 GCJ-02 再存。
func (s *Server) fogMaskSave(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
id, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("id")), 10, 64)
lat, ok1 := parseFloat(r.PostFormValue("lat"))
lon, ok2 := parseFloat(r.PostFormValue("lon"))
radius, ok3 := parseFloat(r.PostFormValue("radius_m"))
name := strings.TrimSpace(r.PostFormValue("name"))
if !ok1 || !ok2 || math.Abs(lat) > 90 || math.Abs(lon) > 180 {
redirectFogMask(w, r, "err", "请先在地图上点选圆心")
return
}
if !ok3 || radius < 50 || radius > 50000 {
redirectFogMask(w, r, "err", "半径请填 50 ~ 50000 米")
return
}
if r.PostFormValue("crs") == "wgs84" {
lat, lon = geo.WGS84ToGCJ02(lat, lon)
}
// 保存成功后失效两处缓存:站长点完保存就该看到访客视角的变化
defer func() { s.masks.invalidate(); s.fogTiles.clear() }()
if _, err := fog.SaveMask(s.db, u.ID, fog.Mask{ID: id, Name: name, Lat: lat, Lon: lon, RadiusM: radius}); err != nil {
s.serverError(w, r, err)
return
}
verb := "已新增"
if id > 0 {
verb = "已更新"
}
label := name
if label == "" {
label = fmt.Sprintf("%.4f, %.4f", lat, lon)
}
redirectFogMask(w, r, "ok", fmt.Sprintf("%s遮罩区「%s」(半径 %s 米)", verb, label, trimNum(radius)))
}
// fogMaskDelete 删除一块遮罩区。
func (s *Server) fogMaskDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
id, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("id")), 10, 64)
if id <= 0 {
redirectFogMask(w, r, "err", "遮罩区不存在")
return
}
// 删除后同样立刻失效:站长的操作要立即反映到访客视角
defer func() { s.masks.invalidate(); s.fogTiles.clear() }()
if err := fog.DeleteMask(s.db, u.ID, id); err != nil {
s.serverError(w, r, err)
return
}
redirectFogMask(w, r, "ok", "已删除该遮罩区")
}
func redirectFogMask(w http.ResponseWriter, r *http.Request, kind, msg string) {
http.Redirect(w, r, "/admin/fog/mask?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
// trimNum 把 500.0 这种整数半径显示成 500。
func trimNum(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
+411
View File
@@ -0,0 +1,411 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"rond-with-you/internal/geo"
)
// 按名称搜坐标:调用高德 Web 服务接口,密钥只在服务端拼接。
// 高德返回的就是 GCJ-02,与库内坐标同系,前端拿到即可直接入库,无需任何坐标转换。
const (
amapPlaceTextURL = "https://restapi.amap.com/v3/place/text" // 关键字/POI 搜索,适合地名
amapGeocodeURL = "https://restapi.amap.com/v3/geocode/geo" // 结构化地址解析,POI 无结果时兜底
)
var geocodeHTTPClient = &http.Client{Timeout: 8 * time.Second}
// geocodeItem 是返回给前端的候选点。
type geocodeItem struct {
Name string `json:"name"`
Address string `json:"address"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
}
// apiGeocode 供后台「新增地点」的搜索框使用,仅管理员可调用。
func (s *Server) apiGeocode(w http.ResponseWriter, r *http.Request) {
if s.cfg.GeocodeKey == "" {
s.json(w, map[string]any{"error": "服务端未配置 geocode_key,暂时无法按名称搜索坐标"})
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
s.json(w, map[string]any{"error": "请输入要搜索的名称"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
items, err := s.amapSearchPOI(ctx, q)
if err != nil {
s.json(w, map[string]any{"error": err.Error()})
return
}
if len(items) == 0 {
// 地块/门牌这类不是 POI 的地址,再走一次结构化地理编码
items, err = s.amapGeocode(ctx, q)
if err != nil {
s.json(w, map[string]any{"error": err.Error()})
return
}
}
if len(items) == 0 {
s.json(w, map[string]any{"error": "没有找到匹配的地点,换个更完整的名称试试"})
return
}
s.json(w, map[string]any{"items": items})
}
func (s *Server) amapSearchPOI(ctx context.Context, q string) ([]geocodeItem, error) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
v.Set("keywords", q)
v.Set("offset", "8")
v.Set("page", "1")
v.Set("extensions", "base")
body, err := s.amapGet(ctx, amapPlaceTextURL+"?"+v.Encode())
if err != nil {
return nil, err
}
var raw struct {
Status string `json:"status"`
Info string `json:"info"`
Pois []struct {
Name string `json:"name"`
Location string `json:"location"`
Address json.RawMessage `json:"address"`
Pname json.RawMessage `json:"pname"`
Cityname json.RawMessage `json:"cityname"`
Adname json.RawMessage `json:"adname"`
} `json:"pois"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
if raw.Status != "1" {
return nil, &geoErr{"高德返回:" + raw.Info}
}
out := make([]geocodeItem, 0, len(raw.Pois))
for _, p := range raw.Pois {
lon, lat, ok := splitLocation(p.Location)
if !ok {
continue
}
out = append(out, geocodeItem{
Name: p.Name, Address: amapStr(p.Address), Lat: lat, Lon: lon,
Province: amapStr(p.Pname), City: amapStr(p.Cityname), District: amapStr(p.Adname),
})
}
return out, nil
}
func (s *Server) amapGeocode(ctx context.Context, q string) ([]geocodeItem, error) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
v.Set("address", q)
body, err := s.amapGet(ctx, amapGeocodeURL+"?"+v.Encode())
if err != nil {
return nil, err
}
var raw struct {
Status string `json:"status"`
Info string `json:"info"`
Geocodes []struct {
FormattedAddress string `json:"formatted_address"`
Location string `json:"location"`
Province json.RawMessage `json:"province"`
City json.RawMessage `json:"city"`
District json.RawMessage `json:"district"`
} `json:"geocodes"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
if raw.Status != "1" {
return nil, &geoErr{"高德返回:" + raw.Info}
}
out := make([]geocodeItem, 0, len(raw.Geocodes))
for _, g := range raw.Geocodes {
lon, lat, ok := splitLocation(g.Location)
if !ok {
continue
}
out = append(out, geocodeItem{
Name: g.FormattedAddress, Address: g.FormattedAddress, Lat: lat, Lon: lon,
Province: amapStr(g.Province), City: amapStr(g.City), District: amapStr(g.District),
})
}
return out, nil
}
func (s *Server) amapGet(ctx context.Context, u string) ([]byte, error) {
// 网络抖动很常见,失败退避重试几次;限速令牌每次请求都要重新领
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-time.After(time.Duration(attempt) * 400 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
}
select {
case <-amapLimiter.C:
case <-ctx.Done():
return nil, ctx.Err()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "rond-with-you/1.0")
resp, err := geocodeHTTPClient.Do(req)
if err != nil {
lastErr = &geoErr{"请求高德失败:" + err.Error()}
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = &geoErr{"高德返回 " + resp.Status}
continue
}
if readErr != nil {
lastErr = &geoErr{"读取高德响应失败:" + readErr.Error()}
continue
}
return body, nil
}
return nil, lastErr
}
// amapStr 处理高德字段既可能是字符串、也可能是空数组的情况(直辖市常为空数组)。
func amapStr(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var arr []string
if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {
return arr[0]
}
return ""
}
// splitLocation 解析高德的 "经度,纬度"。
func splitLocation(v string) (lon, lat float64, ok bool) {
a, b, found := strings.Cut(strings.TrimSpace(v), ",")
if !found {
return 0, 0, false
}
lon, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64)
lat, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
return lon, lat, true
}
type geoErr struct{ msg string }
func (e *geoErr) Error() string { return e.msg }
// ---------- 逆地理编码(坐标 -> 地址),照片导入用 ----------
// regeoItem 是一个坐标反查到的地址信息。坐标是**转换后**的 GCJ-02
// (EXIF 是 WGS-84,前端把原始值发上来,这里统一转,避免两份实现)。
type regeoItem struct {
// Key 由调用方给(照片导入里是行号):同一坐标可能被多张照片共用,
// 服务端做了去重,没有这个 key 就配不回原来的行。
Key string `json:"key"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Address string `json:"address"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Street string `json:"street"`
Township string `json:"township"`
// Poi / PoiDist 是最近的兴趣点(店名)与距离(米)——
// 照片导入里用它判断「这个点到底在哪儿」,比只给街道好认得多
Poi string `json:"poi"`
PoiDist float64 `json:"poiDist"`
}
const regeoURL = "https://restapi.amap.com/v3/geocode/regeo"
const aroundURL = "https://restapi.amap.com/v3/place/around"
// 高德对免费 key 有 QPS 限制,一次性甩几十个请求容易被限流(返回
// CUQPS_HAS_EXCEEDED_THE_LIMIT)。所有高德请求统一在这里排队,按 10 次/秒放行。
const amapQPS = 10
// amapLimiter 是全局限速器:令牌在每次真正发请求前领取,
// 所以一个点位内部发几次请求(regeo + 周边搜索)也照样受限。
var amapLimiter = time.NewTicker(time.Second / amapQPS)
// apiRegeo 批量把坐标换成地址:照片导入时用来让用户判断「这个点对不对」。
// 只传坐标、不传照片;相同坐标会去重,避免同一地点拍多张时重复调用。
func (s *Server) apiRegeo(w http.ResponseWriter, r *http.Request) {
if s.cfg.GeocodeKey == "" {
s.json(w, map[string]any{"error": "服务端未配置 geocode_key,无法反查地址"})
return
}
var req struct {
Coord string `json:"coord"`
Points []struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Key string `json:"key"`
} `json:"points"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
s.json(w, map[string]any{"error": "请求格式不对"})
return
}
if len(req.Points) > 200 {
s.json(w, map[string]any{"error": "一次最多反查 200 个坐标"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// 先把坐标规整成 GCJ-02(EXIF 是 WGS-84;部分国产手机可能已经是 GCJ-02,由前端选择)
type job struct {
lat, lon float64
dedup string // 去重用
keys []string
}
jobs := make([]job, 0, len(req.Points))
at := map[string]int{}
for _, p := range req.Points {
lat, lon := p.Lat, p.Lon
if req.Coord != "gcj02" {
lat, lon = geo.WGS84ToGCJ02(lat, lon)
}
// 5 位小数约 1 米:同一地点拍的多张只查一次,省配额也省时间
k := fmt.Sprintf("%.5f,%.5f", lat, lon)
if i, ok := at[k]; ok {
jobs[i].keys = append(jobs[i].keys, p.Key)
continue
}
at[k] = len(jobs)
jobs = append(jobs, job{lat: lat, lon: lon, dedup: k, keys: []string{p.Key}})
}
// 一个去重后的点位可能对应多行,结果按行返回(key 原样带回)
perJob := make([]regeoItem, len(jobs))
sem := make(chan struct{}, 4) // 限并发;速率由 amapGet 里的全局限速器控制
var wg sync.WaitGroup
for i, j := range jobs {
if ctx.Err() != nil {
break
}
wg.Add(1)
go func(i int, j job) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
item := regeoItem{Lat: j.lat, Lon: j.lon}
if addr, prov, city, dist, street, town, ok := s.amapRegeo(ctx, j.lat, j.lon); ok {
item.Address, item.Province, item.City = addr, prov, city
item.District, item.Street, item.Township = dist, street, town
}
// 最近的地点(店名):regeo 返回的 pois 多是行政单位,
// 用周边搜索按距离排序取第一条才是店名
if name, dist, ok := s.amapNearestPOI(ctx, j.lat, j.lon); ok {
item.Poi, item.PoiDist = name, dist
}
perJob[i] = item
}(i, j)
}
wg.Wait()
items := make([]regeoItem, 0, len(req.Points))
for i, j := range jobs {
for _, k := range j.keys {
it := perJob[i]
it.Key = k
items = append(items, it)
}
}
s.json(w, map[string]any{"items": items})
}
// amapNearestPOI 找坐标附近最近的一个兴趣点(店名)。
// 半径给 200 米:再大容易匹配到马路边上不相干的店;取不到就返回 ok=false,
// 界面上只显示地址。
func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name string, dist float64, ok bool) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat))
v.Set("radius", "200")
v.Set("offset", "1")
v.Set("sortrule", "distance") // 按距离排序,第一条就是最近的
body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode())
if err != nil {
return "", 0, false
}
var raw struct {
Status string `json:"status"`
Pois []struct {
Name string `json:"name"`
Distance string `json:"distance"`
} `json:"pois"`
}
if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" || len(raw.Pois) == 0 {
return "", 0, false
}
d, _ := strconv.ParseFloat(raw.Pois[0].Distance, 64)
return raw.Pois[0].Name, d, true
}
// amapRegeo 调一次高德的逆地理编码。失败返回 ok=false,调用方留空即可。
func (s *Server) amapRegeo(ctx context.Context, lat, lon float64) (addr, prov, city, dist, street, town string, ok bool) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
// 高德要的是「经度,纬度」
v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat))
v.Set("extensions", "base")
body, err := s.amapGet(ctx, regeoURL+"?"+v.Encode())
if err != nil {
return
}
var raw struct {
Status string `json:"status"`
Regeocode struct {
Formatted string `json:"formatted_address"`
Comp struct {
Province json.RawMessage `json:"province"`
City json.RawMessage `json:"city"`
District json.RawMessage `json:"district"`
Township json.RawMessage `json:"township"`
StreetN struct {
Street json.RawMessage `json:"street"`
} `json:"streetNumber"`
} `json:"addressComponent"`
} `json:"regeocode"`
}
if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" {
return "", "", "", "", "", "", false
}
return raw.Regeocode.Formatted,
amapStr(raw.Regeocode.Comp.Province), amapStr(raw.Regeocode.Comp.City),
amapStr(raw.Regeocode.Comp.District), amapStr(raw.Regeocode.Comp.StreetN.Street),
amapStr(raw.Regeocode.Comp.Township), true
}
+115
View File
@@ -0,0 +1,115 @@
package web
import (
"compress/gzip"
"net/http"
"strings"
"sync"
)
// 可压缩的响应类型。图片(迷雾 PNG、图标)本身已是压缩格式,再压纯属浪费 CPU。
func compressible(ct string) bool {
ct = strings.ToLower(ct)
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.TrimSpace(ct)
switch ct {
case "text/html", "text/css", "text/plain", "text/javascript",
"application/javascript", "application/json", "application/x-javascript",
"image/svg+xml", "application/manifest+json", "application/xml":
return true
}
return strings.HasPrefix(ct, "text/") && ct != "text/cache-manifest"
}
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
comp bool
seen bool // 首部是否已定下来(WriteHeader 已调用,或首字节已写)
}
var gzipPool = sync.Pool{New: func() any {
return gzip.NewWriter(nil)
}}
// WriteHeader 必须在首部提交前决定压不压。静态文件由 http.FileServer 提供,
// 它会先设好 Content-Length 再显式 WriteHeader(200/206);若拖到 Write 里再改首部,
// 首部早已提交,会出现「声明长度 ≠ 实际压缩后长度」,浏览器报
// ERR_CONTENT_LENGTH_MISMATCH 并把 CSS/JS 截断。只有 200 才压缩,
// 206(Range)/304 等自带长度语义,一律原样透传。
func (g *gzipWriter) WriteHeader(code int) {
if g.seen {
return
}
g.seen = true
if code == http.StatusOK && compressible(g.Header().Get("Content-Type")) {
g.comp = true
g.Header().Set("Content-Encoding", "gzip")
g.Header().Set("Vary", "Accept-Encoding")
g.Header().Del("Content-Length")
}
g.ResponseWriter.WriteHeader(code)
}
func (g *gzipWriter) Write(b []byte) (int, error) {
if !g.seen {
g.seen = true
// 上游没有显式 WriteHeader 时,首字节写入才能确定内容类型,此时决定压不压
ct := g.Header().Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(b)
g.Header().Set("Content-Type", ct)
}
if compressible(ct) {
g.comp = true
g.Header().Set("Content-Encoding", "gzip")
g.Header().Set("Vary", "Accept-Encoding")
g.Header().Del("Content-Length")
}
}
if !g.comp {
return g.ResponseWriter.Write(b)
}
if g.gz == nil {
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
return g.gz.Write(b)
}
func (g *gzipWriter) Flush() {
if g.gz != nil {
g.gz.Flush()
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (g *gzipWriter) close() {
if g.gz != nil {
g.gz.Close()
gzipPool.Put(g.gz)
g.gz = nil
}
}
// gzipHandler 对声明了 gzip 且内容类型可压缩的响应做在线压缩。
func gzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 带 Range 的请求(浏览器校验 / 续传 immutable 缓存的静态资源)会返回 206 并自带
// Content-Length,gzip 无法在首部已提交后再改写长度,会导致浏览器把 CSS/JS 截断
// (ERR_CONTENT_LENGTH_MISMATCH)。这类请求直接透传、不压缩。
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") ||
r.Header.Get("Upgrade") != "" ||
r.Header.Get("Range") != "" {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
package web
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
"rond-with-you/internal/stats"
)
// 这两个导出面向站长自己的数据迁移,因此不做「家 / 工作」过滤——
// 能进后台的人本来就拥有全部数据的查看权。
func (s *Server) exportPlaces(w http.ResponseWriter, r *http.Request) []stats.Place {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return nil
}
// 直接按 dataset 全量取,绕过筛选与隐私开关
rows, err := s.db.QueryContext(ctx, `SELECT `+stats.PlaceCols+`
FROM places p WHERE p.dataset_id=$1 ORDER BY p.id`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return nil
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var p stats.Place
if err := stats.ScanPlace(rows, &p); err != nil {
s.serverError(w, r, err)
return nil
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
s.serverError(w, r, err)
return nil
}
return out
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
b.WriteString(`{"type":"FeatureCollection","features":[`)
for i, p := range places {
if i > 0 {
b.WriteByte(',')
}
var first, last string
if p.FirstAt != nil {
first = p.FirstAt.UTC().Format("2006-01-02")
}
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02")
}
// GeoJSON 坐标是 [lon, lat];库里的坐标本身是 GCJ-02,原样导出并在说明里注明
fmt.Fprintf(&b, `{"type":"Feature","geometry":{"type":"Point","coordinates":[%v,%v]},`+
`"properties":{"name":%q,"city":%q,"province":%q,"category":%q,`+
`"visits":%d,"dwell_min":%d,"first":%q,"last":%q,"crs":"gcj02"}}`,
p.Lon, p.Lat, p.Name, p.City, p.Province, p.POICategory,
p.VisitCount, p.DwellMinutes, first, last)
}
b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="places.geojson"`)
w.Write([]byte(b.String()))
}
func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<gpx version="1.1" creator="rond-with-you" xmlns="http://www.topografix.com/GPX/1/1">` + "\n")
for _, p := range places {
var parts []string
if p.City != "" {
parts = append(parts, p.Province, p.City)
}
if p.POICategory != "" {
parts = append(parts, poiName(p.POICategory))
}
desc := strings.Join(strings.Fields(strings.Join(parts, " · ")), " ")
var last string
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02T15:04:05Z")
}
fmt.Fprintf(&b, "<wpt lat=\"%v\" lon=\"%v\"><name>%s</name>", p.Lat, p.Lon, xmlEscape(p.Name))
if desc != "" {
fmt.Fprintf(&b, "<desc>%s</desc>", xmlEscape(desc))
}
if last != "" {
fmt.Fprintf(&b, "<time>%s</time>", last)
}
b.WriteString("</wpt>\n")
}
b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="places-%s.gpx"`, time.Now().Format("20060102")))
w.Write([]byte(b.String()))
}
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
func (s *Server) baselinePath(datasetID int64) string {
return filepath.Join(s.cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", datasetID))
}
// ensureBaseline 确保基线库存在;缺失时从当初上传的原始备份包重建。
func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, error) {
p := s.baselinePath(datasetID)
if _, err := os.Stat(p); err == nil {
return p, nil
}
archive, err := s.findSourceArchive(ctx, datasetID)
if err != nil {
return "", err
}
if err := rond.WriteBaseline(archive, p); err != nil {
return "", fmt.Errorf("重建基线库失败:%w", err)
}
return p, nil
}
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,'') FROM datasets WHERE id=$1`, datasetID).Scan(&sha); err != nil {
return "", err
}
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
// 新的在前,命中即止
sort.Slice(files, func(i, j int) bool {
fi, ei := os.Stat(files[i])
fj, ej := os.Stat(files[j])
if ei != nil || ej != nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
for _, f := range files {
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
continue
}
got, _, err := ingest.File(f)
if err != nil {
continue
}
if sha == "" || got == sha {
return f, nil
}
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
}
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
// 坐标是 GCJ-02,与 rond 同系,原样写回;新增/编辑的记录随数据集一并导出。
func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return
}
base, err := s.ensureBaseline(ctx, ds.ID)
if err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
// 先构建到缓冲,成功后再写响应头,避免半截输出破坏下载文件
var buf bytes.Buffer
if err := rond.ExportRondbackup(ctx, s.db, ds.ID, base, &buf); err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
stem := strings.TrimSuffix(filepath.Base(ds.Name), filepath.Ext(ds.Name))
if stem == "" {
stem = "LifeEasy"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102")))
w.Write(buf.Bytes())
}
+267
View File
@@ -0,0 +1,267 @@
package web
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/setting"
)
// 地点图片的几条硬约束。字节由浏览器转好 WebP 才发过来,服务端只做形状校验,
// 不重新编码(1 核机器扛不住解码大图)。
const (
imageMaxBytes = 6 << 20 // 单张上限 6MB:长边 1440px、按 220KB 目标降质的 WebP 通常不到 300KB
imageMaxPer = 12 // 单个地点最多留几张,防止误拖一堆进来
imageUploadMem = 8 << 20 // multipart 解析用的内存上限
)
// PlaceImage 是模板里用的地点图片。
type PlaceImage struct {
ID int64
URL string
Width int
Height int
Bytes int64
}
// savePlaceImage 把一段已经转好的 WebP 字节存到当前后端,落一行记录,并返回可用的地址。
// 上传接口与照片导入共用这一条路径,省得两处各写一遍 key 规则。
// url 为空表示「存是存进去了,但没配 s3_public_base,浏览器取不到」——调用方要据此提示。
func (s *Server) savePlaceImage(ctx context.Context, datasetID int64, srcPK int, data []byte, w, h int) (int64, string, error) {
if !isWebP(data) {
return 0, "", fmt.Errorf("不是 WebP 数据")
}
backend, err := s.imageBackend(ctx)
if err != nil {
return 0, "", err
}
sum := sha256.Sum256(data)
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
// 按月份分目录:一次出行传几十张,按月归堆最好找、也最好整月备份/迁移;
// 若按地点分目录,几百个地点会摊出几百个大多只有零张图的空夹。
// 文件名带地点号:在对象存储控制台里一眼看出这张是谁的,不用回库查。
// 带内容哈希:同一张图重复上传落到同一个 key,天然去重。
key := fmt.Sprintf("%d/%s/%d-%s.webp", datasetID, time.Now().Format("200601"),
srcPK, hex.EncodeToString(sum[:])[:16])
if err := s.media.Save(ctx, backend, key, data, "image/webp"); err != nil {
return 0, "", err
}
var id int64
if err := s.db.QueryRowContext(ctx, `INSERT INTO place_images
(dataset_id, src_pk, storage, object_key, width, height, bytes, position)
VALUES ($1,$2,$3,$4,$5,$6,$7,
COALESCE((SELECT max(position)+1 FROM place_images WHERE dataset_id=$1 AND src_pk=$2), 0))
ON CONFLICT (dataset_id, src_pk, object_key) DO UPDATE SET bytes=EXCLUDED.bytes
RETURNING id`,
datasetID, srcPK, backend, key, w, h, int64(len(data))).Scan(&id); err != nil {
return 0, "", err
}
return id, s.media.PublicURL(backend, key), nil
}
// adminPlaceImageUpload 收下浏览器转好的 WebP 并落库。
// 存哪边由后台设置决定;对象存储的凭据只在服务端,前端拿到的永远是最终 URL。
func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
if err := r.ParseMultipartForm(imageUploadMem); err != nil {
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
return
}
src, err := strconv.Atoi(strings.TrimSpace(r.FormValue("src_pk")))
if err != nil {
s.json(w, map[string]any{"error": "地点标识无效"})
return
}
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT true FROM places
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&exists); err != nil {
s.json(w, map[string]any{"error": "地点不存在"})
return
}
var count int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM place_images
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&count); err != nil {
s.serverError(w, r, err)
return
}
if count >= imageMaxPer {
s.json(w, map[string]any{"error": fmt.Sprintf("一个地点最多 %d 张,先删几张再传", imageMaxPer)})
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
s.json(w, map[string]any{"error": "没有收到图片"})
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, imageMaxBytes+1))
if err != nil {
s.json(w, map[string]any{"error": "读取上传内容失败"})
return
}
if len(data) > imageMaxBytes {
s.json(w, map[string]any{"error": fmt.Sprintf("图片超过 %dMB", imageMaxBytes>>20)})
return
}
if !isWebP(data) {
// 前端没转成功(老浏览器不支持 canvas.toBlob('image/webp'))时给出明确提示
s.json(w, map[string]any{"error": "只接受 WebP:请用较新的浏览器,或在转换完成后再保存"})
return
}
id, url, err := s.savePlaceImage(ctx, ds.ID, src, data,
atoiOr(r.FormValue("w"), 0), atoiOr(r.FormValue("h"), 0))
if err != nil {
log.Printf("地点图片写入失败(src_pk=%d): %v", src, err)
s.json(w, map[string]any{"error": "保存图片失败:" + err.Error()})
return
}
backend, _ := s.imageBackend(ctx)
out := map[string]any{"ok": true, "id": id, "url": url, "bytes": len(data),
"name": hdr.Filename, "storage": backend}
if url == "" {
out["error"] = "图片已存进对象存储,但没配 s3_public_base,浏览器取不到;" +
"请在 conf/app.ini 里补上公开域名后重新上传"
}
s.json(w, out)
}
// adminPlaceImageDelete 删掉一张图:先删对象再删行(对象删失败也不留孤儿行)。
func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
// 前端是用 FormData 提交的(multipart),这里**不要**先 r.ParseForm():
// 它会把 r.Form 先填成「不含 multipart 字段」的那一份,之后再怎么读都是空的
// (FormValue 只在 r.Form == nil 时才去解析)。直接用 FormValue,它自己会挑对解析方式。
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil {
s.json(w, map[string]any{"error": "图片标识无效"})
return
}
var backend, key string
if err := s.db.QueryRowContext(ctx, `SELECT storage, object_key FROM place_images
WHERE id=$1 AND dataset_id=$2`, id, ds.ID).Scan(&backend, &key); err != nil {
s.json(w, map[string]any{"error": "图片不存在"})
return
}
if err := s.media.Delete(ctx, backend, key); err != nil {
// 对象删不掉就留着行,否则文件永远无人回收
log.Printf("删除图片对象失败(%s %s): %v", backend, key, err)
s.json(w, map[string]any{"error": "删除文件失败:" + err.Error()})
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1 AND dataset_id=$2`,
id, ds.ID); err != nil {
s.serverError(w, r, err)
return
}
s.json(w, map[string]any{"ok": true})
}
// imageBackend 取当前设置的存储后端;选了对象存储但凭据不全时明确报出来,
// 不要静默退回本地(那样用户以为存到 R2 了,其实落在服务器磁盘上)。
func (s *Server) imageBackend(ctx context.Context) (string, error) {
st, err := s.sets.Load(ctx)
if err != nil {
return "", err
}
if st.MediaBackend == setting.MediaBackendS3 {
if !s.media.S3Ready() {
return "", fmt.Errorf("后台选了对象存储,但 conf/app.ini 里的 s3_* 没配齐")
}
return setting.MediaBackendS3, nil
}
return setting.MediaBackendLocal, nil
}
// placeImagesOf 读某个地点的图片,按 position 排序。
func (s *Server) placeImagesOf(ctx context.Context, datasetID int64, srcPK int) ([]PlaceImage, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, storage, object_key, width, height, bytes
FROM place_images WHERE dataset_id=$1 AND src_pk=$2 ORDER BY position, id`, datasetID, srcPK)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PlaceImage
for rows.Next() {
var img PlaceImage
var backend, key string
if err := rows.Scan(&img.ID, &backend, &key, &img.Width, &img.Height, &img.Bytes); err != nil {
return nil, err
}
url := s.media.PublicURL(backend, key)
if url == "" {
continue // 存储配置被撤掉后取不到址,跳过而不是给出破图
}
img.URL = url
out = append(out, img)
}
return out, rows.Err()
}
// serveMedia 提供本地存储的图片。key 是内容寻址的(带 sha1 前缀),
// 内容永不变化,所以可以长期强缓存。
func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/media/")
full, err := s.media.LocalPath(rel)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(full)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mimeOfExt(filepath.Ext(full)))
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, path.Base(full), fi.ModTime(), f)
}
func mimeOfExt(ext string) string {
switch strings.ToLower(ext) {
case ".webp":
return "image/webp"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
default:
return "application/octet-stream"
}
}
// isWebP 校验 RIFF....WEBP 头。前端已经转过一道,这里是防御性的最后一道。
func isWebP(b []byte) bool {
return len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP"
}
+281
View File
@@ -0,0 +1,281 @@
package web
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"rond-with-you/internal/geo"
)
// 照片批量导入:浏览器读 EXIF(static/js/photo-exif.js),只把坐标与时间提交上来。
//
// 照片本身默认不上传:手机相册一次几十张、几百 MB,为了几十字节的 EXIF 全传一遍
// 既慢又占服务器磁盘(早期版本就是这么做的,51 张 334MB 直接把请求撑爆)。
// 例外是行内勾了「存图」的那几张——那几张会在浏览器里先转成 WebP(长边 2000px、
// 通常几百 KB)再随表单发过来,作为地点的配图。
const (
photoMaxRows = 300
// photoMaxBodyBytes 是整个导入请求的上限。不勾「存图」时表单只有几十 KB;
// 勾了之后每张图约 100~250KB(长边 1440px 的 WebP),60MB 够一次带两三百张。
photoMaxBodyBytes = 60 << 20
// 默认离开时间 = 到达后 10 分钟(用户可改)
photoDefaultStay = 10 * time.Minute
)
// PhotoData 是照片导入页的数据。页面只放一个文件选择框,
// 预览表由前端脚本填,所以这里没有表格数据。
type PhotoData struct {
Page
Err string
Flash string
// GeocodeReady 决定前端能否反查地址(没配高德 key 就只能看经纬度)
GeocodeReady bool
// 与「新增地点」同一套选项:类别 / 活动 / 标签
Categories []EditOption
Activities []EditOption
Tags []EditOption
}
func (s *Server) adminPhotosPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "照片导入")
if err != nil {
s.serverError(w, r, err)
return
}
p.Flatpickr = true
p.HasMap = true
d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""}
ds, err := s.datasetFor(r.Context())
if err != nil {
s.serverError(w, r, err)
return
}
if ds != nil {
// 与数据编辑页共用同一批选项,保证两边能选的东西一致
if d.Activities, err = s.editOptions(r.Context(), `SELECT a.id, COALESCE(a.name,'') FROM activities a
WHERE a.dataset_id=$1 ORDER BY a.name`, ds.ID); err != nil {
s.serverError(w, r, err)
return
}
if d.Tags, err = s.editOptions(r.Context(), `SELECT t.id, COALESCE(t.name,'') FROM tags t
WHERE t.dataset_id=$1 ORDER BY t.name`, ds.ID); err != nil {
s.serverError(w, r, err)
return
}
crows, err := s.db.QueryContext(r.Context(), `SELECT DISTINCT poi_category FROM places
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' ORDER BY poi_category`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
for crows.Next() {
var c string
if err := crows.Scan(&c); err != nil {
crows.Close()
s.serverError(w, r, err)
return
}
d.Categories = append(d.Categories, EditOption{Label: c, Name: poiCategoryLabel(c)})
}
crows.Close()
}
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
s.render(w, "admin/photos", d)
}
// adminPhotosImport 把前端解析好、用户确认过的行写入 places + visits。
// 坐标默认按 WGS-84 处理(EXIF 就是 WGS-84),转成站点用的 GCJ-02 再落库。
func (s *Server) adminPhotosImport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectPhotos(w, r, "err", "还没有可编辑的数据集")
return
}
// 整个请求体封顶:勾了「存图」时表单是 multipart,最多可能带 300 张小图。
// 不设上限的话,一个畸形请求就能把这台 1 核机器吃满。
r.Body = http.MaxBytesReader(w, r.Body, photoMaxBodyBytes)
// 必须用 ParseMultipartForm 而不是 ParseForm:ParseForm 只认 urlencoded,
// 会把 PostForm 先填成空的那一份,后面 PostFormValue/FormFile 全是空的。
// urlencoded 的调用方(脚本、测试)会拿到 ErrNotMultipart —— 那种情况它已经
// 把普通表单解析好了,放过即可。
if err := r.ParseMultipartForm(imageUploadMem); err != nil && !errors.Is(err, http.ErrNotMultipart) {
redirectPhotos(w, r, "err", "表单太大或读不出来(图片总量请控制在 60MB 以内)")
return
}
n := atoiOr(r.PostFormValue("n"), 0)
if n <= 0 {
redirectPhotos(w, r, "err", "没有可导入的照片")
return
}
if n > photoMaxRows {
redirectPhotos(w, r, "err", fmt.Sprintf("一次最多导入 %d 张,当前 %d 张", photoMaxRows, n))
return
}
// EXIF 标准是 WGS-84,但部分国产手机直接写 GCJ-02——由用户在页面上选择,
// 只有明确说「已是 GCJ-02」时才不转换
fromWGS84 := r.PostFormValue("coord") != "gcj02"
// 这次导入用的坐标系原样留档:以后要复算、要核对,都从这一个字段读,
// 不用再去猜「当初到底选了什么」(2026-09 那批 63 个点就是栽在没有它)
coordSys := "wgs84"
if !fromWGS84 {
coordSys = "gcj02"
}
// 地点的编号逐条分配(见下面 nextSrcPK);到访的主键由 insertVisit 自己取
var imported, skipped, withImage int
var firstErr, imageErr string
for i := 0; i < n; i++ {
if r.PostFormValue(fmt.Sprintf("keep_%d", i)) != "on" {
skipped++
continue
}
lat, ok1 := parseFloat(r.PostFormValue(fmt.Sprintf("lat_%d", i)))
lon, ok2 := parseFloat(r.PostFormValue(fmt.Sprintf("lon_%d", i)))
if !ok1 || !ok2 || (lat == 0 && lon == 0) {
firstErr = fmt.Sprintf("第 %d 行坐标无效,已跳过", i+1)
skipped++
continue
}
// 换算前的原值先存下来:显示坐标换算错了还能反推回去,也能和原图 EXIF 直接对账
srcLat, srcLon := lat, lon
if fromWGS84 {
lat, lon = geo.WGS84ToGCJ02(lat, lon)
}
arrival, ok := parseLocalDT(r.PostFormValue(fmt.Sprintf("arrival_%d", i)))
if !ok {
firstErr = fmt.Sprintf("第 %d 行到达时间无效,已跳过", i+1)
skipped++
continue
}
var departure *time.Time
if v := strings.TrimSpace(r.PostFormValue(fmt.Sprintf("departure_%d", i))); v != "" {
if t, ok := parseLocalDT(v); ok && !t.Before(arrival) {
departure = &t
}
}
if departure == nil {
d := arrival.Add(photoDefaultStay)
departure = &d
}
name := strings.TrimSpace(r.PostFormValue(fmt.Sprintf("name_%d", i)))
if name == "" {
name = fmt.Sprintf("照片地点 %d", i+1)
}
var placeID int64
// 字段与「新增地点」一致:地址相关字段来自反查(也可能被用户改过),
// 空串交给 NULLIF 存成 NULL。类别支持下拉里的「自定义…」。
category := r.PostFormValue(fmt.Sprintf("category_%d", i))
if category == categoryCustomValue {
category = r.PostFormValue(fmt.Sprintf("category_custom_%d", i))
}
// 地点与到访一个事务:以前分两步走,写到访失败时地点会留下成为孤儿
//(visit_count=0),导进 rond 就是个永远「无数据」的点。
// 到访走与「新增到访」同一条路径:活动 / 备注 / 收藏 / 标签都能带上
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue(fmt.Sprintf("activity_%d", i)))
var placeSrc int
if err := s.withTx(ctx, func(ex execer) error {
// 编号按「库里 + Core Data + 留档」三处最大值取:留档里存着导入时缺必填
// 字段的 skipped 行,那些号没有对应实体行却一直占着,只看 MAX(places) 会复用,
// 导出时新行被当旧行、继承对方的未管理列。
pk, err := s.nextSrcPK(ctx, ex, ds.ID, placeSeq)
if err != nil {
return err
}
if err := clearStaleRaw(ctx, ex, ds.ID, "ZLOCATION", pk); err != nil {
return err
}
placeSrc = pk
if err := ex.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, poi_category, lat, lon, country_code,
province, city, district, thoroughfare, timezone,
coord_source, coord_sys, src_lat, src_lon)
VALUES ($1,$2,$3,NULLIF($4,''),$5,$6,NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),NULLIF($10,''),NULLIF($11,''),NULLIF($12,''),$13,$14,$15,$16)
RETURNING id`,
ds.ID, pk, name, category,
lat, lon, r.PostFormValue(fmt.Sprintf("country_%d", i)),
r.PostFormValue(fmt.Sprintf("province_%d", i)),
r.PostFormValue(fmt.Sprintf("city_%d", i)), r.PostFormValue(fmt.Sprintf("district_%d", i)),
r.PostFormValue(fmt.Sprintf("street_%d", i)), r.PostFormValue(fmt.Sprintf("timezone_%d", i)),
"photo", coordSys, srcLat, srcLon).Scan(&placeID); err != nil {
return fmt.Errorf("写地点: %w", err)
}
if _, err := s.insertVisit(ctx, ex, ds.ID, u.ID, placeID, arrival, departure, actID, isHome, isWork,
r.PostFormValue(fmt.Sprintf("bookmarked_%d", i)) == "on",
strings.TrimSpace(r.PostFormValue(fmt.Sprintf("remark_%d", i))), "",
r.PostForm[fmt.Sprintf("tags_%d", i)]); err != nil {
return fmt.Errorf("写到访: %w", err)
}
return nil
}); err != nil {
log.Printf("照片导入:第 %d 行落库失败(已整体回滚)%v", i+1, err)
firstErr = "部分照片落库失败,已跳过"
skipped++
continue
}
// 勾了「存图」的行,浏览器已经把这张照片转成 WebP 一起发过来了。
// 图片存失败只记日志、不回滚地点:地点已经写好,丢一张图不该连点一起丢。
if n, err := s.attachImportedImage(ctx, r, ds.ID, placeSrc, i); err != nil {
log.Printf("照片导入:第 %d 行配图失败(地点已保留): %v", i+1, err)
imageErr = "部分照片的配图没存上(地点已导入)"
} else if n > 0 {
withImage += n
}
imported++
}
s.refreshDatasetAgg(ctx, ds.ID)
msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported)
if withImage > 0 {
msg += fmt.Sprintf(",配了 %d 张图", withImage)
}
if skipped > 0 {
msg += fmt.Sprintf(",跳过 %d 张", skipped)
}
if firstErr != "" {
msg += "。" + firstErr
}
if imageErr != "" {
msg += "。" + imageErr
}
redirectPhotos(w, r, "ok", msg)
}
// attachImportedImage 收下行内附带的 WebP 并挂到刚建好的地点上。
// 浏览器已经把原图转码过了,这里只做形状校验与转存;没有这一项就返回 0。
func (s *Server) attachImportedImage(ctx context.Context, r *http.Request, datasetID int64, srcPK, row int) (int, error) {
f, _, err := r.FormFile(fmt.Sprintf("image_%d", row))
if err != nil {
return 0, nil // 这一行没勾「存图」
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, imageMaxBytes+1))
if err != nil {
return 0, fmt.Errorf("读图失败: %w", err)
}
if len(data) == 0 {
return 0, nil
}
if len(data) > imageMaxBytes {
return 0, fmt.Errorf("图片超过 %dMB", imageMaxBytes>>20)
}
if _, _, err := s.savePlaceImage(ctx, datasetID, srcPK, data,
atoiOr(r.PostFormValue(fmt.Sprintf("image_w_%d", row)), 0),
atoiOr(r.PostFormValue(fmt.Sprintf("image_h_%d", row)), 0)); err != nil {
return 0, err
}
return 1, nil
}
// redirectPhotos 把结果带回照片导入页。
func redirectPhotos(w http.ResponseWriter, r *http.Request, kind, msg string) {
http.Redirect(w, r, "/admin/photos?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
package web
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// 专题 = 某段时间 + 某个区域内的足迹。地点来自「时间范围内的到访」,
// 迷雾不落库、由地图瓦片按视口自然呈现——所以专题只要把地图视野限定在
// 区域(或地点包围盒)上,「区域内的迷雾」就成立了。
const (
// 表单现在精确到分钟;纯日期形式仍然接受(老表单缓存 / 手工拼接参数)
topicTimeLayout = "2006-01-02"
topicDateTimeLayout = "2006-01-02T15:04"
)
// parseTopicTime 解析表单时间,两种格式都认。
func parseTopicTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
if t, err := time.ParseInLocation(topicDateTimeLayout, raw, time.Local); err == nil {
return t, true
}
if t, err := time.ParseInLocation(topicTimeLayout, raw, time.Local); err == nil {
return t, true
}
return time.Time{}, false
}
// ---------- 查询 ----------
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
func (s *Server) loadTopics(ctx context.Context, datasetID int64, onlyEnabled bool) ([]Topic, error) {
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
radius_km, show_fog, enabled, sort_order
FROM topics WHERE dataset_id=$1`
if onlyEnabled {
q += ` AND enabled`
}
q += ` ORDER BY sort_order, id`
rows, err := s.db.QueryContext(ctx, q, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Topic
for rows.Next() {
var t Topic
if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder); err != nil {
return nil, err
}
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
out = append(out, t)
}
return out, rows.Err()
}
// topicRangeLabel 给完整时间范围。整天(00:00 ~ 23:59)时只显示日期——
// 这种情况多是「按天圈的范围」,写成「00:00 ~ 23:59」只是噪音;
// 只要有一端带了具体时刻,就完整显示到分钟。
func topicRangeLabel(start, end time.Time) string {
a, b := start.Local(), end.Local()
wholeDays := a.Hour() == 0 && a.Minute() == 0 && b.Hour() == 23 && b.Minute() >= 59
if wholeDays {
if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() {
return a.Format("2006-01-02")
}
return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02"), b.Format("2006-01-02"))
}
if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() {
return fmt.Sprintf("%s %s ~ %s", a.Format("2006-01-02"), a.Format("15:04"), b.Format("15:04"))
}
return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02 15:04"), b.Format("2006-01-02 15:04"))
}
// topicHaversineSQL 是写进 SQL 的球面距离(公里)表达式,参数依次是中心纬度、中心经度。
// 放在 SQL 里是为了让「区域过滤」和聚合统计一次算完,不必先查一遍再回 Go 过滤。
const topicHaversineSQL = `6371 * 2 * asin(sqrt(
power(sin(radians((%s - p.lat) / 2)), 2) +
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
regions []string, exclude bool) ([]MapPoint, int, int, int, error) {
args := []any{datasetID, t.StartAt, t.EndAt}
area := ""
if t.HasArea() {
args = append(args, t.CenterLat, t.CenterLon, t.RadiusKm)
n := len(args)
area = fmt.Sprintf(" AND %s <= $%d",
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
}
// 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
// 不能把这份(已被追加过的)args 整个传进去。
baseArgs := len(args)
q := `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
COALESCE(p.city,''),
COALESCE((SELECT a.name FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL
GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.color FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL
GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), max(v.arrival)
FROM places p JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3` + area +
regionSQL("p", regions, exclude, &args) + `
GROUP BY p.id ORDER BY count(*) DESC, p.id`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, 0, 0, err
}
defer rows.Close()
var points []MapPoint
cities := map[string]bool{}
totalVisits := 0
var totalDwell int64
for rows.Next() {
var m MapPoint
var last time.Time
if err := rows.Scan(&m.ID, &m.SrcPK, &m.Name, &m.Lat, &m.Lon, &m.City,
&m.Act, &m.Color, &m.Count, &m.Dwell, &last); err != nil {
return nil, 0, 0, 0, err
}
m.LastAt = last.Local().Format("2006-01-02")
points = append(points, m)
totalVisits += m.Count
totalDwell += m.Dwell
if m.City != "" {
cities[m.City] = true
}
}
if err := rows.Err(); err != nil {
return nil, 0, 0, 0, err
}
// 天数:按本地日期去重(专题可能只覆盖几天,直接在 SQL 里算更准)。
//
// 参数必须按**这条 SQL 真正用到的占位符**来凑:$1..$3 固定,area 用 $4..$6。
// 以前这里直接把上面那条 SQL 的 args 整个传进来,而它已经被 regionSQL 追加过区域
// 参数 —— 这条 SQL 当时又没有区域条件,参数就比占位符多,pgx 直接报
// 「expected 3 arguments, got 4」。而且专题列表的统计会把这个错误吞掉,
// 表现成「专题内容全变 0、点进去才 500」,很难查。
days := 0
dargs := append([]any{}, args[:baseArgs]...)
daysRegion := regionSQL("p", regions, exclude, &dargs)
if err := s.db.QueryRowContext(ctx, `SELECT count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date)
FROM visits v JOIN places p ON p.id = v.place_id
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3`+area+daysRegion, dargs...).Scan(&days); err != nil {
return nil, 0, 0, 0, err
}
return points, totalVisits, days, len(cities), nil
}
// fillTopicStats 给专题填上内容统计(列表页展示用)。
func (s *Server) fillTopicStats(ctx context.Context, t *Topic, datasetID int64, regions []string, exclude bool) {
points, visits, days, cities, err := s.topicContent(ctx, t, datasetID, regions, exclude)
if err != nil {
// 以前这里直接 return:专题列表会静默显示成「0 个地点」,看不出是出错了。
log.Printf("专题 #%d「%s」统计失败: %v", t.ID, t.Title, err)
return
}
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
for _, p := range points {
t.DwellMin += p.Dwell
}
}
// parseTopicForm 解析后台表单。日期按本地时区补成当天 00:00 / 23:59:59,
// 这样「9月1日到9月3日」能把 3 号整天的记录都收进来。
func parseTopicForm(r *http.Request) (Topic, error) {
var t Topic
t.Title = strings.TrimSpace(r.PostFormValue("title"))
t.Subtitle = strings.TrimSpace(r.PostFormValue("subtitle"))
t.Description = strings.TrimSpace(r.PostFormValue("description"))
if t.Title == "" {
return t, fmt.Errorf("请填写专题标题")
}
startRaw := strings.TrimSpace(r.PostFormValue("start_at"))
start, ok := parseTopicTime(startRaw)
if !ok {
return t, fmt.Errorf("请填写有效的开始时间")
}
endRaw := strings.TrimSpace(r.PostFormValue("end_at"))
end, ok := parseTopicTime(endRaw)
if !ok {
return t, fmt.Errorf("请填写有效的结束时间")
}
// 只给了日期(没给时刻)时把结束补到当天最后一刻,不然那天整天的记录会被漏掉
if len(endRaw) <= len(topicTimeLayout) {
end = end.Add(24*time.Hour - time.Second)
}
if end.Before(start) {
return t, fmt.Errorf("结束时间不能早于开始时间")
}
t.StartAt, t.EndAt = start, end
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
if t.RadiusKm < 0 {
t.RadiusKm = 0
}
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
return t, fmt.Errorf("限定区域时要填中心点坐标")
}
t.ShowFog = r.PostFormValue("show_fog") == "on"
t.Enabled = r.PostFormValue("enabled") == "on"
t.SortOrder = atoiOr(r.PostFormValue("sort_order"), 0)
t.StartDate, t.EndDate = start.Format(topicDateTimeLayout), t.EndAt.Format(topicDateTimeLayout)
return t, nil
}
// ---------- 后台:专题管理 ----------
func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "admin", "专题管理")
if err != nil {
s.serverError(w, r, err)
return
}
p.Flatpickr = true
d := TopicAdminData{Page: p}
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds != nil {
list, err := s.loadTopics(ctx, ds.ID, false)
if err != nil {
s.serverError(w, r, err)
return
}
for i := range list {
s.fillTopicStats(ctx, &list[i], ds.ID, nil, false) // 后台管理页:站长看全部
}
d.Topics = list
// 编辑模式:?edit=<id>
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
for _, t := range list {
if t.ID == int64(id) {
d.Form = t
}
}
}
}
if d.Form.Title == "" {
// 新建时给个合理默认:最近 30 天
now := time.Now()
d.Form.StartDate = now.AddDate(0, 0, -30).Format(topicDateTimeLayout)
d.Form.EndDate = now.Format(topicDateTimeLayout)
d.Form.ShowFog, d.Form.Enabled = true, true
d.IsNew = true
}
s.render(w, "admin/topics", d)
}
func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectTopics(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
t, err := parseTopicForm(r)
if err != nil {
redirectTopics(w, r, "err", err.Error())
return
}
id := atoiOr(r.PostFormValue("id"), 0)
if id > 0 {
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
show_fog=$11, enabled=$12, sort_order=$13
WHERE id=$1 AND dataset_id=$2`,
id, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
s.serverError(w, r, err)
return
}
redirectTopics(w, r, "ok", "专题已更新")
return
}
var newID int64
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder).Scan(&newID); err != nil {
s.serverError(w, r, err)
return
}
redirectTopics(w, r, "ok", "专题已创建")
}
func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND dataset_id=$2`, id, dsID)
return err
}, "专题已删除")
}
func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND dataset_id=$2`, id, dsID)
return err
}, "展示状态已切换")
}
// topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。
func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectTopics(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
id := atoiOr(r.PostFormValue("id"), 0)
if id <= 0 {
redirectTopics(w, r, "err", "专题标识无效")
return
}
if err := fn(ctx, ds.ID, id); err != nil {
s.serverError(w, r, err)
return
}
redirectTopics(w, r, "ok", okMsg)
}
// ---------- 前台:专题列表与详情 ----------
func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "topics", "专题")
if err != nil {
s.serverError(w, r, err)
return
}
d := TopicListData{Page: p}
if p.Dataset != nil {
list, err := s.loadTopics(ctx, p.Dataset.ID, !p.IsAdmin())
if err != nil {
s.serverError(w, r, err)
return
}
regions, exclude := visitorRegions(p)
for i := range list {
s.fillTopicStats(ctx, &list[i], p.Dataset.ID, regions, exclude)
}
d.Topics = list
}
s.render(w, "topics", d)
}
func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "topics", "专题")
if err != nil {
s.serverError(w, r, err)
return
}
p.HasMap = true
id := atoiOr(r.PathValue("id"), 0)
if id <= 0 || p.Dataset == nil {
http.NotFound(w, r)
return
}
var t Topic
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
FROM topics WHERE id=$1 AND dataset_id=$2`, id, p.Dataset.ID).
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
if err == sql.ErrNoRows {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, r, err)
return
}
// 未对外展示的专题只有站长能看
if !t.Enabled && !p.IsAdmin() {
http.NotFound(w, r)
return
}
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
regions, exclude := visitorRegions(p)
points, visits, days, cities, err := s.topicContent(ctx, &t, p.Dataset.ID, regions, exclude)
if err != nil {
s.serverError(w, r, err)
return
}
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
for _, mp := range points {
t.DwellMin += mp.Dwell
}
p.Title = t.Title
// 站点设置里的「备注对外可见」同样适用于专题页
if p.IsAdmin() || p.Settings.NotesPublic {
s.applyNotesToPoints(ctx, p.Dataset.ID, points)
}
js, err := json.Marshal(points)
if err != nil {
s.serverError(w, r, err)
return
}
fitCircle, fitBBox := fitParams(t, points)
d := TopicData{
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
PointsJSON: template.JS(js), FitCircleJSON: fitCircle, FitBBoxJSON: fitBBox,
}
s.render(w, "topic", d)
}
// applyNotesToPoints 把别名/备注贴到地图点上(专题页的迷你地点列表也用)。
func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points []MapPoint) {
ns, err := s.notes.All(ctx, datasetID)
if err != nil || len(ns) == 0 {
return
}
for i := range points {
if n, ok := ns[points[i].SrcPK]; ok {
if n.Alias != "" {
points[i].Name = n.Alias
}
points[i].Note = n.Content
}
}
}
// fitParams 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
if t.HasArea() {
b, _ := json.Marshal(map[string]any{
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
})
return template.JS(b), template.JS("null")
}
if len(points) == 0 {
return template.JS("null"), template.JS("null")
}
minLat, maxLat := points[0].Lat, points[0].Lat
minLon, maxLon := points[0].Lon, points[0].Lon
for _, p := range points {
minLat, maxLat = math.Min(minLat, p.Lat), math.Max(maxLat, p.Lat)
minLon, maxLon = math.Min(minLon, p.Lon), math.Max(maxLon, p.Lon)
}
b, _ := json.Marshal(map[string]any{
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
})
return template.JS("null"), template.JS(b)
}
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。
func redirectTopics(w http.ResponseWriter, r *http.Request, kind, msg string) {
back := r.Referer()
if !strings.Contains(back, "/admin/topics") {
back = "/admin/topics"
}
sep := "?"
if strings.Contains(back, "?") {
sep = "&"
}
http.Redirect(w, r, back+sep+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
+234
View File
@@ -0,0 +1,234 @@
package web
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"strconv"
"strings"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
// voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。
const voterCookie = "rond_voter"
// clientIP 取访客 IP,优先用反代透传的 X-Forwarded-For 第一段。
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。
// GET 页面据此显示「你自己的票」,但不主动种 cookie。
func (s *Server) voterKeyFromRequest(r *http.Request) string {
c, err := r.Cookie(voterCookie)
if err != nil || len(c.Value) < 16 {
return ""
}
return hashVoter(c.Value, clientIP(r))
}
// ensureVoterKey 在投票时调用:没有 cookie 就种一个,再返回匿名身份。
// 叠加 IP 后即便访客清 cookie 重来,也会被识别为同一来源,起轻度防刷作用。
func (s *Server) ensureVoterKey(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(voterCookie)
var uid string
if err == nil && len(c.Value) >= 16 {
uid = c.Value
} else {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return ""
}
uid = hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: voterCookie, Value: uid, Path: "/",
MaxAge: 3600 * 24 * 365, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
return hashVoter(uid, clientIP(r))
}
func hashVoter(uid, ip string) string {
sum := sha256.Sum256([]byte(uid + "|" + ip))
return hex.EncodeToString(sum[:16])
}
// votePayload 是投票数据的对外结构(含访客票数、站长结论与「我投的」)。
// Admin / Public 让前端自己决定气泡里该显示「我的结论」还是「大家怎么说」。
type votePayload struct {
Verdicts map[int]int `json:"verdicts"`
Tallies map[int]votes.Tally `json:"tallies"`
Mine map[int]int `json:"mine"`
Admin bool `json:"admin"`
Public bool `json:"public"`
}
// apiVotes 返回全部地点的结论、票数与当前访客自己的票,供地点库 / 地图一次性取回。
// 标记不对访客公开时,访客拿到空集。
func (s *Server) apiVotes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
isAdmin := userFrom(ctx) != nil
public := true
if sets, err := s.sets.Load(ctx); err == nil {
public = sets.MarksPublic
}
if !isAdmin && !public {
s.json(w, votePayload{
Verdicts: map[int]int{}, Tallies: map[int]votes.Tally{}, Mine: map[int]int{},
Public: false,
})
return
}
var votesDatasetID int64
if ds, err := s.datasetFor(ctx); err == nil && ds != nil {
votesDatasetID = ds.ID
}
verdicts, err := s.votes.Verdicts(ctx, votesDatasetID)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
tallies, err := s.votes.Tallies(ctx)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
mine, err := s.votes.VoterAll(ctx, s.voterKeyFromRequest(r))
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, votePayload{Verdicts: verdicts, Tallies: tallies, Mine: mine, Admin: isAdmin, Public: public})
}
// apiVerdict 供地图气泡以 AJAX 设置站长结论(不刷新页面)。
func (s *Server) apiVerdict(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.json(w, map[string]string{"error": "请求解析失败"})
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.json(w, map[string]string{"error": "还没有可编辑的数据集"})
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
verdict, _ := strconv.Atoi(r.PostFormValue("verdict"))
if err != nil {
s.json(w, map[string]string{"error": "地点标识无效"})
return
}
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, srcPK, verdict); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, map[string]any{"verdict": verdict})
}
// apiVote 记录访客对某地点的点赞 / 点踩,返回最新票数与该访客的选择。
func (s *Server) apiVote(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.json(w, map[string]string{"error": "请求解析失败"})
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
vote, _ := strconv.Atoi(r.PostFormValue("vote"))
if err != nil || (vote != 1 && vote != -1) {
s.json(w, map[string]string{"error": "参数无效"})
return
}
key := s.ensureVoterKey(w, r)
if key == "" {
s.json(w, map[string]string{"error": "无法识别访客"})
return
}
t, mine, err := s.votes.Cast(r.Context(), srcPK, key, vote)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, map[string]any{"up": t.Up, "down": t.Down, "mine": mine})
}
// voteTotals 汇总站长结论数量与访客票数,供统计页展示。
func (s *Server) voteTotals(ctx context.Context, datasetID int64) (rec, avoid, up, down int, err error) {
err = s.db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM place_votes WHERE dataset_id=$1 AND verdict=1),
(SELECT count(*) FROM place_votes WHERE dataset_id=$1 AND verdict=-1),
(SELECT count(*) FROM place_visitor_votes WHERE vote=1),
(SELECT count(*) FROM place_visitor_votes WHERE vote=-1)`, datasetID).
Scan(&rec, &avoid, &up, &down)
return
}
// verdictPlaces 返回站长标为某结论的地点,按到访次数排序。
func (s *Server) verdictPlaces(ctx context.Context, datasetID int64, verdict, limit int) ([]stats.Place, error) {
q := `SELECT ` + stats.PlaceCols + ` FROM places p
WHERE p.dataset_id=$1 AND EXISTS (SELECT 1 FROM place_votes pv
WHERE pv.src_pk=p.src_pk AND pv.dataset_id=p.dataset_id AND pv.verdict=$2)
ORDER BY p.visit_count DESC LIMIT ` + strconv.Itoa(limit)
rows, err := s.db.QueryContext(ctx, q, datasetID, verdict)
if err != nil {
return nil, err
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var pl stats.Place
if err := stats.ScanPlace(rows, &pl); err != nil {
return nil, err
}
out = append(out, pl)
}
return out, rows.Err()
}
// adminPlaceVote 设置站长对地点的官方结论(1 推荐 / -1 踩雷 / 0 取消)。
func (s *Server) adminPlaceVote(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可编辑的数据集")
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
verdict, _ := strconv.Atoi(r.PostFormValue("verdict"))
if err != nil {
s.adminFlash(w, r, "err", "地点标识无效")
return
}
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, srcPK, verdict); err != nil {
s.serverError(w, r, err)
return
}
next := r.PostFormValue("next")
if !strings.HasPrefix(next, "/") {
next = "/admin"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}
+84
View File
@@ -0,0 +1,84 @@
package web
import (
"sync"
"time"
"rond-with-you/internal/fog"
)
// maskTTL 是遮罩列表的缓存时长。保存/删除遮罩时会主动失效,这里的 TTL 只是兜底
// (比如多实例部署时另一个进程改了库)。所以可以取长一点。
const maskTTL = 30 * time.Second
// maskCache 缓存迷雾遮罩列表。
//
// 为什么需要:遮罩不在 fogIndex 的内存索引里,每个迷雾瓦片请求都会查一次库,
// 而地图一屏就是几十张瓦片——同一份数据被反复查几十遍。指纹也顺带缓存,
// 它被瓦片 URL 与缓存键用到。
type maskCache struct {
mu sync.Mutex
at time.Time
list []fog.Mask
fp string
valid bool
}
// get 返回缓存的遮罩列表与指纹;ok 为 false 表示需要查库。
// 「站点一块遮罩都没有」也是有效缓存(list 为 nil),所以不能拿 list 判空。
func (c *maskCache) get() (list []fog.Mask, fp string, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.valid && time.Since(c.at) < maskTTL {
return c.list, c.fp, true
}
return nil, "", false
}
// put 写入缓存(由调用方查库后调用)。
func (c *maskCache) put(list []fog.Mask) ([]fog.Mask, string) {
fp := fog.MaskFingerprint(list)
c.mu.Lock()
c.list, c.fp, c.at, c.valid = list, fp, time.Now(), true
c.mu.Unlock()
return list, fp
}
// invalidate 在遮罩被保存 / 删除后立刻失效,保证改完立即对所有访客生效。
func (c *maskCache) invalidate() {
c.mu.Lock()
c.valid = false
c.mu.Unlock()
}
// masksFor 取遮罩列表(带缓存)。查库失败时返回 nil(等于不遮),与原先的行为一致。
func (s *Server) masksFor() []fog.Mask {
if list, _, ok := s.masks.get(); ok {
return list
}
list, err := fog.LoadMasks(s.db)
if err != nil {
return nil
}
out, _ := s.masks.put(list)
return out
}
// maskSetFor 取遮罩判定集合(带缓存);没有遮罩时返回 nil。
//
// admin 为 true 时不返回遮罩——站长永远看完整迷雾,否则没法核对遮哪里。
func (s *Server) maskSetFor(admin bool) *fog.MaskSet {
if admin {
return nil
}
return fog.NewMaskSet(s.masksFor())
}
// maskFP 取遮罩指纹(带缓存),供瓦片 URL 与缓存键使用。
func (s *Server) maskFP() string {
if _, fp, ok := s.masks.get(); ok {
return fp
}
list := s.masksFor()
return fog.MaskFingerprint(list)
}
+683
View File
@@ -0,0 +1,683 @@
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
"rond-with-you/internal/geo"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// movementFixture 是一份「刚导入、还没被改过」的数据集,外加可直接调用的后台服务。
//
// 需要本地才有的测试库配置与原始备份包,用环境变量开启(缺一个就跳过):
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run Export -v
//
// 它会在测试库里建一个专用账号,跑完连同它的数据集一起删掉,不碰别的数据。
type movementFixture struct {
cfg *config.Config
pg *sql.DB
srv *Server
dsID int64
src string // 原始 .rondbackup 的路径
ctx context.Context // 已注入登录态,可直接喂给后台处理函数
}
func newMovementFixture(t *testing.T) *movementFixture {
t.Helper()
confPath, src := os.Getenv("ROND_TEST_CONF"), os.Getenv("ROND_SRC")
if confPath == "" || src == "" {
t.Skip("设置 ROND_TEST_CONF 与 ROND_SRC 后运行导入导出校验")
}
// go test 的工作目录是包目录(internal/web),而配置里的相对路径、以及调试模式下
// 模板的磁盘路径(internal/web/templates/...)都是相对仓库根的,先切过去
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
// 注册得比后面几项早,t.Cleanup 是后进先出,所以连接会最后才关
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
// 用一次性账号隔离:导入去重是按 (user_id, sha256) 判的,共用账号会命中
// 上一次跑剩的数据集,测的就不是「刚导入的这份」了。
// 顺带清掉上次跑到一半留下的账号,免得越攒越多。
purgeE2EUsers(t, ctx, pg, cfg)
user := fmt.Sprintf("e2e-movement-%d", time.Now().UnixNano())
hash, err := auth.HashPassword("e2e-password")
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
var prevActive int64
pg.QueryRowContext(ctx, `SELECT COALESCE(id,0) FROM datasets WHERE is_active LIMIT 1`).Scan(&prevActive)
t.Cleanup(func() {
dropE2EUser(t, ctx, pg, cfg, uid)
if prevActive > 0 {
pg.ExecContext(ctx, `UPDATE datasets SET is_active=(id=$1) WHERE id=$1`, prevActive)
}
})
// 导入原始备份包
rdb, err := rond.Open(src, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", src, err)
}
defer rdb.Close()
backup, err := rond.Parse(rdb)
if err != nil {
t.Fatalf("解析 %s 失败: %v", src, err)
}
sha, size, err := ingest.File(src)
if err != nil {
t.Fatalf("计算摘要失败: %v", err)
}
dsID, _, err := ingest.Import(ctx, pg, uid, filepath.Base(src), sha, size, backup)
if err != nil {
t.Fatalf("导入失败: %v", err)
}
// 导出取基线库时要按 sha 在 uploads 目录里找回原包,这里补一份
if _, err := os.Stat(cfg.UploadDir); errors.Is(err, os.ErrNotExist) {
os.MkdirAll(cfg.UploadDir, 0o755)
}
archived := filepath.Join(cfg.UploadDir, fmt.Sprintf("e2e-%d-%s", time.Now().Unix(), filepath.Base(src)))
if err := copyTo(archived, src); err != nil {
t.Fatalf("复制原始包到上传目录失败: %v", err)
}
t.Cleanup(func() { os.Remove(archived) })
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
return &movementFixture{cfg: cfg, pg: pg, srv: srv, dsID: dsID, src: src,
ctx: ctxWithUser(&auth.User{ID: uid, Username: user})}
}
// openSource 再开一次原始包,用来跟导出结果做对照。
func (f *movementFixture) openSource(t *testing.T) *sql.DB {
t.Helper()
db, err := rond.Open(f.src, t.TempDir())
if err != nil {
t.Fatalf("打开原始包失败: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// export 走后台真实的导出处理函数,返回 .rondbackup 的字节。
func (f *movementFixture) export(t *testing.T) []byte {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/export/rondbackup", nil).WithContext(f.ctx)
f.srv.exportRondbackup(rec, req)
if rec.Code != 200 {
t.Fatalf("导出失败: code=%d body=%s", rec.Code, rec.Body.String())
}
return rec.Body.Bytes()
}
// TestMovementCRUDExportE2E 端到端验证「后台增改删行程 → 导出 rondbackup」这条链路。
//
// 真的导入、真的调用后台处理函数、真的导出,然后直接读导出的 SQLite 逐项核对:
// 新建/修改的位移有没有带上 rond 必需的列(ZTYPE_ 等)、派生字段是否落库、
// 删掉的是否真没了、以及没被碰过的数据是不是一字未动。
func TestMovementCRUDExportE2E(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv, base := f.srv, f.ctx
// 选一对合法的起止到访:起点有离开时间,终点到访在其之后
var fromVisit, toVisit int
if err := pg.QueryRowContext(ctx, `SELECT vf.src_pk, vt.src_pk
FROM visits vf JOIN visits vt ON vt.dataset_id=vf.dataset_id AND vt.arrival > vf.departure + interval '30 min'
WHERE vf.dataset_id=$1 AND vf.departure IS NOT NULL
ORDER BY vf.arrival DESC LIMIT 1`, dsID).Scan(&fromVisit, &toVisit); err != nil {
t.Fatalf("挑不出合法的起止到访: %v", err)
}
var wantStart, wantEnd time.Time
if err := pg.QueryRowContext(ctx, `SELECT vf.departure, vt.arrival FROM visits vf JOIN visits vt
ON vt.dataset_id=vf.dataset_id AND vt.src_pk=$3
WHERE vf.dataset_id=$1 AND vf.src_pk=$2`, dsID, fromVisit, toVisit).Scan(&wantStart, &wantEnd); err != nil {
t.Fatalf("读取两端到访时间失败: %v", err)
}
t.Logf("起止到访 #%d → #%d,合法区间 %v ~ %v", fromVisit, toVisit, wantStart.Local(), wantEnd.Local())
// 3) 编号复用防护:先删掉基线里编号最大的那条位移,再新建。
// 若按「库内 MAX(src_pk)+1」分配,这里会原样拿回 791——那个号在基线里已存在,
// 导出时新行会被当成旧行去 UPDATE,ZTYPE_ 之类就继承成别人的值。
var baselineMax int
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(MAX(src_pk),0) FROM movements WHERE dataset_id=$1`,
dsID).Scan(&baselineMax); err != nil {
t.Fatal(err)
}
post(t, srv.editMovementDelete, base, "/admin/edit/movement/delete", url.Values{
"src_pk": {fmt.Sprint(baselineMax)},
})
post(t, srv.editMovementCreate, base, "/admin/edit/movement", url.Values{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
})
plainMove, _ := lastMovement(t, ctx, pg, dsID)
if plainMove <= baselineMax {
t.Errorf("删掉 #%d 后新建的行程拿到了 #%d:复用了基线里的编号", baselineMax, plainMove)
}
t.Logf("删掉基线里最大的位移 #%d 后新建得到 #%d", baselineMax, plainMove)
// 4) 新建:时间留空(应自动推断)、顺带新建一个交通方式
post(t, srv.editMovementCreate, base, "/admin/edit/movement", url.Values{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"transport_src": {transportNewValue},
"transport_name": {"共享单车"},
"transport_color": {"green"},
"transport_icon": {"bicycle"},
})
newMove, newTrans := lastMovement(t, ctx, pg, dsID)
if newTrans <= 0 {
t.Fatal("新建交通方式没有分配编号")
}
t.Logf("新建行程 src_pk=%d,交通方式 #%d", newMove, newTrans)
// 5) 改:把新建那条的时间改成区间内的一小段,并把方式改名。
// 表单是分钟精度,断言也要按分钟对齐,否则差的就是被截掉的秒数
mid := wantStart.Add(wantEnd.Sub(wantStart) / 3).Truncate(time.Minute)
post(t, srv.editMovementUpdate, base, "/admin/edit/movement/update", url.Values{
"src_pk": {fmt.Sprint(newMove)},
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"start": {mid.Format("2006-01-02T15:04")},
"end": {mid.Add(10 * time.Minute).Format("2006-01-02T15:04")},
"transport_src": {fmt.Sprint(newTrans)},
"transport_name": {"单车"},
"transport_color": {"green"},
"transport_icon": {"bicycle"},
})
// 5b) 只改交通方式:时间必须原样不动。表单是分钟精度,回填后原样提交若被当成
// 「改了时间」写回,真机的小数秒就被抹掉了,而且截断后还早于起点到访的离开时间,
// 会被校验拒掉(线上就是「改方式提示开始时间不能早于离开时间」)。
var pickPK, pickTrans, otherTrans int
var pickFrom, pickTo int
var pickSt, pickEn time.Time
// 优先挑「把开始时间截到分钟就会早于起点到访的离开时间」的那类行——线上报的就是它
const pickCols = `SELECT m.src_pk, m.transport_src, m.started_at, m.ended_at,
m.from_visit_src, m.to_visit_src,
(SELECT min(transport_src) FROM movements WHERE dataset_id=m.dataset_id
AND transport_src IS NOT NULL AND transport_src <> m.transport_src)`
const pickWhere = ` FROM movements m
JOIN visits vf ON vf.dataset_id=m.dataset_id AND vf.src_pk=m.from_visit_src
WHERE m.dataset_id=$1 AND m.to_visit_src IS NOT NULL
AND m.transport_src IS NOT NULL AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL`
const pickTail = ` ORDER BY m.src_pk LIMIT 1`
err := pg.QueryRowContext(ctx, pickCols+pickWhere+` AND date_trunc('minute', m.started_at) < vf.departure`+pickTail, dsID).
Scan(&pickPK, &pickTrans, &pickSt, &pickEn, &pickFrom, &pickTo, &otherTrans)
if errors.Is(err, sql.ErrNoRows) {
err = pg.QueryRowContext(ctx, pickCols+pickWhere+pickTail, dsID).
Scan(&pickPK, &pickTrans, &pickSt, &pickEn, &pickFrom, &pickTo, &otherTrans)
}
if err != nil {
t.Fatalf("挑不出可改方式的已有行程: %v", err)
}
var otherName, otherColor string
pg.QueryRowContext(ctx, `SELECT COALESCE(max(transport_name),''), COALESCE(max(transport_color),'')
FROM movements WHERE dataset_id=$1 AND transport_src=$2`, dsID, otherTrans).Scan(&otherName, &otherColor)
post(t, srv.editMovementUpdate, base, "/admin/edit/movement/update", url.Values{
"src_pk": {fmt.Sprint(pickPK)},
"from_visit": {fmt.Sprint(pickFrom)},
"to_visit": {fmt.Sprint(pickTo)},
"start": {pickSt.Local().Format("2006-01-02T15:04")},
"end": {pickEn.Local().Format("2006-01-02T15:04")},
"transport_src": {fmt.Sprint(otherTrans)},
"transport_name": {otherName},
"transport_color": {otherColor},
})
var gotSt, gotEn time.Time
var gotTrans int
if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at, transport_src FROM movements
WHERE dataset_id=$1 AND src_pk=$2`, dsID, pickPK).Scan(&gotSt, &gotEn, &gotTrans); err != nil {
t.Fatal(err)
}
if !gotSt.Equal(pickSt) || !gotEn.Equal(pickEn) {
t.Errorf("只改方式却动了时间: %v~%v → %v~%v", pickSt, pickEn, gotSt, gotEn)
}
if gotTrans != otherTrans {
t.Errorf("交通方式没改成: 期望 %d,实际 %d", otherTrans, gotTrans)
}
t.Logf("只改方式 #%d:%d→%d,时间保持 %v ~ %v", pickPK, pickTrans, gotTrans, gotSt, gotEn)
// 5c) 坐标换算:真机里有一批点的显示坐标其实没做过 GCJ-02 偏移(留档里的原始
// GPS 与显示坐标一字不差)。把它们转成 GCJ-02 后,导出包里
// 「gcj(原始GPS) == 显示坐标」这条不变量必须重新成立,否则 rond 上还是偏的。
var susPK int
var susLat, susLon float64
var susName, susCat, susProv, susCity, susDist string
err = pg.QueryRowContext(ctx, `SELECT p.src_pk, p.lat, p.lon, COALESCE(p.name,''),
COALESCE(p.poi_category,''), COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,'')
FROM places p`+placeRawJoin+` WHERE p.dataset_id=$1 AND `+suspectWGS+` ORDER BY p.src_pk LIMIT 1`,
dsID).Scan(&susPK, &susLat, &susLon, &susName, &susCat, &susProv, &susCity, &susDist)
if errors.Is(err, sql.ErrNoRows) {
t.Error("这份数据集里应当有「显示坐标其实是 WGS-84」的点,一个都没判出来")
} else if err != nil {
t.Fatalf("挑选待换算的地点失败: %v", err)
} else {
nlat, nlon := geo.WGS84ToGCJ02(susLat, susLon)
loc := post(t, srv.editPlaceUpdate, base, "/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(susPK)},
"name": {susName},
"category": {susCat},
"lat": {fmt.Sprintf("%.6f", nlat)},
"lon": {fmt.Sprintf("%.6f", nlon)},
"province": {susProv},
"city": {susCity},
"district": {susDist},
})
if !strings.Contains(loc, "err=") && !strings.Contains(loc, url.QueryEscape("坐标移动")) {
t.Errorf("保存后应提示坐标移动了多少,实际跳转: %s", loc)
}
var gotLat, gotLon float64
pg.QueryRowContext(ctx, `SELECT lat, lon FROM places WHERE dataset_id=$1 AND src_pk=$2`,
dsID, susPK).Scan(&gotLat, &gotLon)
if absFloat(gotLat-nlat) > 1e-6 || absFloat(gotLon-nlon) > 1e-6 {
t.Errorf("坐标没写进去: %.6f,%.6f(期望 %.6f,%.6f)", gotLat, gotLon, nlat, nlon)
}
var still int
pg.QueryRowContext(ctx, `SELECT count(*) FROM places p`+placeRawJoin+
` WHERE p.dataset_id=$1 AND p.src_pk=$2 AND `+suspectWGS, dsID, susPK).Scan(&still)
if still != 0 {
t.Error("换算过的点仍被判为「疑似 WGS-84」")
}
t.Logf("坐标换算 #%d %s:%.6f,%.6f → %.6f,%.6f", susPK, susName, susLat, susLon, nlat, nlon)
}
// 5d) 站点新建的地点(照片导入就是这一类,没有 rond 留档):换算后导出时要把
// 「换算前用户填的那个值」当成原始 GPS 写进 ZRAWLATITUDE/ZRAWLONGITUDE。
// 新行的 ZRAW 是导出时按显示坐标反算的,所以换算一次正好把它还原成真值——
// rond 里位置对了,原始 GPS 也留着。
const siteLat, siteLon = 22.853216, 113.253067
post(t, srv.editPlaceCreate, base, "/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"e2e-坐标换算"}, "lat": {fmt.Sprint(siteLat)}, "lon": {fmt.Sprint(siteLon)},
})
var sitePK int
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 ORDER BY id DESC LIMIT 1`,
dsID).Scan(&sitePK); err != nil {
t.Fatalf("读取新建地点失败: %v", err)
}
siteConvLat, siteConvLon := geo.WGS84ToGCJ02(siteLat, siteLon)
post(t, srv.editPlaceUpdate, base, "/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(sitePK)},
"name": {"e2e-坐标换算"},
"lat": {fmt.Sprintf("%.6f", siteConvLat)},
"lon": {fmt.Sprintf("%.6f", siteConvLon)},
})
t.Logf("站点新建地点 #%d 换算:%.6f,%.6f → %.6f,%.6f", sitePK, siteLat, siteLon, siteConvLat, siteConvLon)
// 6) 派生字段必须落库:网页地图靠 from/to_place_id 画线,访客侧的区域过滤也按它们判定,
// 留空的话这段行程会绕开黑名单区域直接露出来
var dist sql.NullFloat64
var fromID, toID, dur sql.NullInt64
if err := pg.QueryRowContext(ctx, `SELECT from_place_id, to_place_id, distance_km,
COALESCE(duration_min,0) FROM movements WHERE dataset_id=$1 AND src_pk=$2`,
dsID, newMove).Scan(&fromID, &toID, &dist, &dur); err != nil {
t.Fatalf("读取新建行程失败: %v", err)
}
if !dur.Valid || dur.Int64 != 10 {
t.Errorf("改后的时长应为 10 分钟,实际 %v", dur)
}
if !fromID.Valid || !toID.Valid {
t.Error("新建行程的 from_place_id / to_place_id 为空:地图轨迹与区域过滤都会失灵")
}
if !dist.Valid || dist.Float64 <= 0 {
t.Errorf("新建行程的里程没有算出来: %v", dist)
}
// 7) 编辑页要能渲染出来:行程列表的 SQL、交通方式聚合、模板执行都在这条路径上,
// 光测处理函数看不见它们
for _, q := range []string{"/admin/edit",
fmt.Sprintf("/admin/edit?mt=%d", newTrans),
fmt.Sprintf("/admin/edit?mp=%d", fromID.Int64),
"/admin/edit?mfrom=2026-09-01&mto=2026-09-30",
"/admin/edit?mt=none",
} {
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", q, nil).WithContext(base))
if rec.Code != 200 {
t.Fatalf("渲染 %s 失败: code=%d", q, rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"行程记录", "新增行程", "新建方式…"} {
if !strings.Contains(body, want) {
t.Errorf("%s 的页面里没有 %q", q, want)
}
}
}
// 8) 导出
blob := f.export(t)
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
if p := os.Getenv("ROND_EXPORT_OUT"); p != "" {
os.WriteFile(p, blob, 0o644)
t.Logf("导出包已保存到 %s", p)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
// 9) 核对导出包
var mvType sql.NullInt64
var mvTrans sql.NullInt64
var mvStart, mvEnd sql.NullFloat64
var mvFrom, mvTo sql.NullInt64
if err := edb.QueryRow(`SELECT ZTYPE_, ZTRANSPORT_, ZSTART_, ZEND_, ZVISITFROM_, ZVISITTO_
FROM ZMOVEMENT WHERE Z_PK=?`, newMove).Scan(&mvType, &mvTrans, &mvStart, &mvEnd, &mvFrom, &mvTo); err != nil {
t.Fatalf("导出包里找不到新建的行程 #%d: %v", newMove, err)
}
if !mvType.Valid {
t.Error("新建行程的 ZTYPE_ 为空:真机 791/791 都有值,Core Data 里它是非可选属性")
} else if mvType.Int64 != 5 {
t.Errorf("有交通方式的新建行程 ZTYPE_ 应为 5,实际 %d", mvType.Int64)
}
if !mvTrans.Valid || int(mvTrans.Int64) != newTrans {
t.Errorf("新建行程的 ZTRANSPORT_ 应为 %d,实际 %v", newTrans, mvTrans)
}
if int(mvFrom.Int64) != fromVisit || int(mvTo.Int64) != toVisit {
t.Errorf("两端到访写回不对: %d/%d,应为 %d/%d", mvFrom.Int64, mvTo.Int64, fromVisit, toVisit)
}
if d := absFloat(mvStart.Float64 - appleSec(mid)); d > 1 {
t.Errorf("开始时间偏差 %.3f 秒(应为改后的 %v)", d, mid.Local())
}
if d := absFloat(mvEnd.Float64 - appleSec(mid.Add(10*time.Minute))); d > 1 {
t.Errorf("结束时间偏差 %.3f 秒", d)
}
var n int
edb.QueryRow(`SELECT count(*) FROM ZMOVEMENT WHERE Z_PK=?`, baselineMax).Scan(&n)
if n != 0 {
t.Errorf("删掉的位移 #%d 仍在导出包里", baselineMax)
}
// 无交通方式的新建行程 → ZTYPE_ 应为 2;这条同时验证了编号没有复用
// (复用了就会继承基线里那条记录的取值)
var plainType sql.NullInt64
if err := edb.QueryRow(`SELECT ZTYPE_ FROM ZMOVEMENT WHERE Z_PK=?`, plainMove).Scan(&plainType); err != nil {
t.Fatalf("导出包里找不到 #%d: %v", plainMove, err)
}
if !plainType.Valid || plainType.Int64 != 2 {
t.Errorf("无交通方式的新建行程 ZTYPE_ 应为 2,实际 %v", plainType)
}
// 交通方式:改名要作用到该方式的全部行程,导出后 ZTRANSPORT 只有一行这个名字
var tName, tColor string
if err := edb.QueryRow(`SELECT COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTRANSPORT WHERE Z_PK=?`,
newTrans).Scan(&tName, &tColor); err != nil {
t.Fatalf("导出包里找不到新建的交通方式 #%d: %v", newTrans, err)
}
if tName != "单车" || tColor != "green" {
t.Errorf("交通方式应为「单车/green」,实际「%s/%s」", tName, tColor)
}
// 原有交通方式一个都不能少(删掉某方式的最后一条行程 ≠ 删除这个方式)
var transCount, unnamed int
edb.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN COALESCE(ZNAME_,'')='' THEN 1 ELSE 0 END),0)
FROM ZTRANSPORT`).Scan(&transCount, &unnamed)
if transCount != 6 {
t.Errorf("ZTRANSPORT 应有 6 个(原 5 个 + 新建 1 个),实际 %d", transCount)
}
if unnamed != 0 {
t.Errorf("有 %d 个交通方式没有名字", unnamed)
}
var dup int
edb.QueryRow(`SELECT count(*) FROM (SELECT Z_PK FROM ZTRANSPORT GROUP BY Z_PK HAVING count(*)>1) x`).Scan(&dup)
if dup != 0 {
t.Error("ZTRANSPORT 出现重复主键")
}
// 全表 ZTYPE_ 非空
var total, missing int
edb.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN ZTYPE_ IS NULL THEN 1 ELSE 0 END),0) FROM ZMOVEMENT`).
Scan(&total, &missing)
if missing > 0 {
t.Errorf("ZMOVEMENT 共 %d 条,其中 %d 条 ZTYPE_ 为空", total, missing)
}
// 只改方式的那条,导出后的 ZSTART_/ZEND_ 必须与原始包逐位一致(没被分钟精度截断)
srcDB := f.openSource(t)
var origSt, origEn float64
if err := srcDB.QueryRow(`SELECT ZSTART_, ZEND_ FROM ZMOVEMENT WHERE Z_PK=?`, pickPK).Scan(&origSt, &origEn); err != nil {
t.Fatalf("原始包里找不到 #%d: %v", pickPK, err)
}
var expSt, expEn sql.NullFloat64
if err := edb.QueryRow(`SELECT ZSTART_, ZEND_ FROM ZMOVEMENT WHERE Z_PK=?`, pickPK).Scan(&expSt, &expEn); err != nil {
t.Fatalf("导出包里找不到 #%d: %v", pickPK, err)
}
if d := absFloat(expSt.Float64 - origSt); d > 1e-6 {
t.Errorf("只改方式却把 ZSTART_ 改了 %.6f 秒(%.6f → %.6f)", d, origSt, expSt.Float64)
}
if d := absFloat(expEn.Float64 - origEn); d > 1e-6 {
t.Errorf("只改方式却把 ZEND_ 改了 %.6f 秒", d)
}
// 换算过的那个点:导出包里 gcj(原始GPS) 必须等于显示坐标(rond 的坐标模型),
// 且原始 GPS 本身不能被动过——它才是这条记录真正的 WGS-84。
if susPK > 0 {
var rawLat, rawLon, disLat, disLon float64
if err := edb.QueryRow(`SELECT ZRAWLATITUDE, ZRAWLONGITUDE, ZLATITUDE, ZLONGITUDE
FROM ZLOCATION WHERE Z_PK=?`, susPK).Scan(&rawLat, &rawLon, &disLat, &disLon); err != nil {
t.Fatalf("导出包里找不到换算过的地点 #%d: %v", susPK, err)
}
expLat, expLon := geo.WGS84ToGCJ02(rawLat, rawLon)
if absFloat(expLat-disLat) > 1e-6 || absFloat(expLon-disLon) > 1e-6 {
t.Errorf("导出包里 gcj(原始GPS) 与显示坐标对不上:gcj(%.6f,%.6f)=%.6f,%.6f vs %.6f,%.6f",
rawLat, rawLon, expLat, expLon, disLat, disLon)
}
if absFloat(rawLat-susLat) > 1e-9 {
t.Errorf("原始 GPS 被改动了: %.6f → %.6f", susLat, rawLat)
}
t.Logf("导出核对:%s 的 gcj(原始GPS)=%.6f,%.6f == 显示坐标 ✓", susName, expLat, expLon)
}
// 站点新建的那个地点:显示坐标是换算后的 GCJ-02,原始 GPS 应写回换算前用户填的值
{
var zrawLat, zrawLon, zdisLat, zdisLon float64
if err := edb.QueryRow(`SELECT ZRAWLATITUDE, ZRAWLONGITUDE, ZLATITUDE, ZLONGITUDE FROM ZLOCATION
WHERE Z_PK=?`, sitePK).Scan(&zrawLat, &zrawLon, &zdisLat, &zdisLon); err != nil {
t.Fatalf("导出包里找不到新建地点 #%d: %v", sitePK, err)
}
if absFloat(zdisLat-siteConvLat) > 1e-6 || absFloat(zdisLon-siteConvLon) > 1e-6 {
t.Errorf("新建地点的显示坐标应是换算后的值: %.6f,%.6f", zdisLat, zdisLon)
}
if absFloat(zrawLat-siteLat) > 1e-6 || absFloat(zrawLon-siteLon) > 1e-6 {
t.Errorf("新建地点的原始 GPS 应写回换算前输入的值 %.6f,%.6f,实际 %.6f,%.6f",
siteLat, siteLon, zrawLat, zrawLon)
}
if gLat, gLon := geo.WGS84ToGCJ02(zrawLat, zrawLon); absFloat(gLat-zdisLat) > 1e-6 || absFloat(gLon-zdisLon) > 1e-6 {
t.Errorf("gcj(原始GPS) 与显示坐标对不上: %.6f,%.6f", gLat, gLon)
}
t.Logf("新建地点导出核对:原始 GPS %.6f,%.6f / 显示坐标 %.6f,%.6f ✓", zrawLat, zrawLon, zdisLat, zdisLon)
}
// 未落库的实体必须原样带回来
var raw, rawAfter int
srcDB.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&raw)
edb.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&rawAfter)
if raw != rawAfter {
t.Errorf("ZRAWVISIT 行数变了: %d → %d", raw, rawAfter)
}
t.Logf("导出核对通过:位移 %d 条、交通方式 %d 个、ZRAWVISIT %d 条", total, transCount, rawAfter)
}
// TestMovementCleanExport 只做「导入后原样导出」,把包写到 ROND_CLEAN_OUT。
// 它是上面那个用例的对照组:一个没被改过的数据集导出来必须是纯净往返,
// 交给 tools/verify_rondbackup.py 逐字段比对,用来证明这轮改动没有波及别处。
func TestMovementCleanExport(t *testing.T) {
out := os.Getenv("ROND_CLEAN_OUT")
if out == "" {
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
}
f := newMovementFixture(t)
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
t.Logf("纯净往返包已保存到 %s", out)
}
// dropE2EUser 删掉测试账号:数据集表上的外键会级联清掉各事实表与整行留档,
// 留在磁盘上的基线库文件要自己删。
func dropE2EUser(t *testing.T, ctx context.Context, pg *sql.DB, cfg *config.Config, uid int64) {
t.Helper()
var ids []int64
rows, err := pg.QueryContext(ctx, `SELECT id FROM datasets WHERE user_id=$1`, uid)
if err != nil {
t.Logf("清理测试账号失败: %v", err)
return
}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
if _, err := pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid); err != nil {
t.Logf("清理测试账号失败: %v", err)
}
for _, id := range ids {
os.Remove(filepath.Join(cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", id)))
}
}
// purgeE2EUsers 清掉上次跑到一半留下的测试账号(它的 username 前缀是本测试专用的)。
func purgeE2EUsers(t *testing.T, ctx context.Context, pg *sql.DB, cfg *config.Config) {
t.Helper()
var ids []int64
rows, err := pg.QueryContext(ctx, `SELECT id FROM users WHERE username LIKE 'e2e-movement-%'`)
if err != nil {
return
}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
for _, id := range ids {
t.Logf("清掉上次遗留的测试账号 #%d", id)
dropE2EUser(t, ctx, pg, cfg, id)
}
}
// chdirModuleRoot 把工作目录切到仓库根(含 go.mod 的那一层)。
// 调试模式下模板是从磁盘按「internal/web/templates/...」读的,配置里的
// storage 路径也是相对仓库根,只有站在仓库根上才和真跑起来时一致。
func chdirModuleRoot(t *testing.T) {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
return
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("找不到 go.mod,无法定位仓库根目录")
}
dir = parent
}
}
// post 直接调用后台处理函数,返回重定向地址(带 ok=/err= 提示)。
// 绕过 requireAdmin 是刻意的:这里要测的是处理逻辑本身,登录态由 ctxWithUser 直接注入。
func post(t *testing.T, h func(http.ResponseWriter, *http.Request), ctx context.Context, path string, form url.Values) string {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h(w, r)
// 处理函数出错时会 500;正常路径是 303 重定向 + ?err= 提示
if w.Code >= 500 {
t.Fatalf("%s 处理失败: code=%d", path, w.Code)
}
loc := w.Header().Get("Location")
if strings.Contains(loc, "err=") {
t.Fatalf("%s 被拒绝: %s", path, loc)
}
return loc
}
func ctxWithUser(u *auth.User) context.Context {
return context.WithValue(context.Background(), userKey{}, u)
}
func lastMovement(t *testing.T, ctx context.Context, pg *sql.DB, datasetID int64) (int, int) {
t.Helper()
var pk int
var tr sql.NullInt64
if err := pg.QueryRowContext(ctx, `SELECT src_pk, transport_src FROM movements
WHERE dataset_id=$1 ORDER BY id DESC LIMIT 1`, datasetID).Scan(&pk, &tr); err != nil {
t.Fatalf("读取刚写入的行程失败: %v", err)
}
return pk, int(tr.Int64)
}
func copyTo(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func appleSec(t time.Time) float64 {
return float64(t.Unix()-978307200) + float64(t.Nanosecond())/1e9
}
+122
View File
@@ -0,0 +1,122 @@
package web
import (
"strings"
"testing"
"time"
)
func mt(s string) time.Time {
t, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local)
if err != nil {
panic(err)
}
return t
}
// TestMovementWindow 守住行程时间区间的推导与校验。
// 规则来自真机包实测(760 条起点 / 756 条终点全部满足):位移不早于起点到访的
// 离开时间,也不晚于终点到访的到达时间;ZSTART_/ZEND_ 在真机里 791/791 都有值,
// 所以留空必须能推断出值,不能写 NULL。
func TestMovementWindow(t *testing.T) {
dep := mt("2026-09-16 10:00")
from := movementEnds{SrcPK: 1, Arrive: mt("2026-09-16 09:00"), Depart: &dep}
to := movementEnds{SrcPK: 2, Arrive: mt("2026-09-16 11:00")}
in := func(s, e string) movementWindowInput {
return movementWindowInput{From: from, To: to, StartRaw: s, EndRaw: e}
}
// 留空:按两端补默认值,即「起点离开 → 终点到达」
start, end, msg := movementWindow(in("", ""))
if msg != "" {
t.Fatalf("留空应能推断,得到错误: %s", msg)
}
if !start.Equal(dep) || !end.Equal(to.Arrive) {
t.Fatalf("推断结果不对: %v ~ %v", start, end)
}
// 起点没有离开时间时退回到达时间
fromNoDep := movementEnds{SrcPK: 1, Arrive: mt("2026-09-16 09:00")}
if start, _, msg = movementWindow(movementWindowInput{From: fromNoDep, To: to}); msg != "" || !start.Equal(fromNoDep.Arrive) {
t.Fatalf("缺离开时间应以到达时间为起点: %v %s", start, msg)
}
// 合法的手填区间
if _, _, msg = movementWindow(in("2026-09-16T10:10", "2026-09-16T10:50")); msg != "" {
t.Fatalf("区间合法却被拒: %s", msg)
}
for _, c := range []struct {
name string
start, end string
wantMsgSubstring string
}{
{"结束早于开始", "2026-09-16T10:50", "2026-09-16T10:10", "必须晚于开始时间"},
{"开始早于起点离开", "2026-09-16T09:30", "2026-09-16T10:50", "不能早于起点到访的离开时间"},
{"结束晚于终点到达", "2026-09-16T10:10", "2026-09-16T11:30", "不能晚于终点到访的到达时间"},
{"只填开始且越界", "2026-09-16T09:00", "", "不能早于起点到访的离开时间"},
{"时间格式错", "2026/09/16 10:00", "", "格式不正确"},
} {
_, _, msg := movementWindow(in(c.start, c.end))
if msg == "" {
t.Errorf("%s:应被拒绝但通过了", c.name)
continue
}
if !strings.Contains(msg, c.wantMsgSubstring) {
t.Errorf("%s:错误信息里应包含 %q,实际为 %q", c.name, c.wantMsgSubstring, msg)
}
}
}
// TestKeepStoredTime 守住分钟精度的表单不该磨掉库里的小数秒(到访与行程共用)。
func TestKeepStoredTime(t *testing.T) {
stored := mt("2026-09-13 20:03").Add(21*time.Second + 323*time.Millisecond)
typed := mt("2026-09-13 20:03") // 表单回填/手填都只到分钟
if got := keepStoredTime(typed, &stored); !got.Equal(stored) {
t.Errorf("同一分钟应沿用库里的值: %v", got)
}
// 真改了分钟就按提交值走
other := mt("2026-09-13 20:10")
if got := keepStoredTime(other, &stored); !got.Equal(other) {
t.Errorf("改了分钟应按提交值生效: %v", got)
}
// 没有原值(新建)时原样返回
if got := keepStoredTime(typed, nil); !got.Equal(typed) {
t.Errorf("无原值时应原样返回: %v", got)
}
}
// TestMovementWindowKeepsStoredSeconds 守住「只改方式不该动时间」。
// 表单是分钟精度,回填后原样提交会被解析成不带秒的整分钟;与库里同分钟就必须
// 沿用库里原值,否则每次编辑都把真机的小数秒截掉,而且截断后早于起点到访的
// 离开时间,会被校验拒掉——「改方式提示开始时间不能早于离开时间」就是这么来的。
func TestMovementWindowKeepsStoredSeconds(t *testing.T) {
dep := mt("2026-09-13 20:03").Add(21*time.Second + 323*time.Millisecond)
from := movementEnds{SrcPK: 1, Arrive: mt("2026-09-13 19:00"), Depart: &dep}
to := movementEnds{SrcPK: 2, Arrive: mt("2026-09-13 20:31").Add(9 * time.Second)}
curStart := mt("2026-09-13 20:03").Add(21*time.Second + 323*time.Millisecond)
curEnd := mt("2026-09-13 20:31").Add(9 * time.Second)
in := movementWindowInput{From: from, To: to, CurStart: &curStart, CurEnd: &curEnd}
// 表单回填的就是截到分钟的值
in.StartRaw, in.EndRaw = "2026-09-13T20:03", "2026-09-13T20:31"
start, end, msg := movementWindow(in)
if msg != "" {
t.Fatalf("只改方式的时间提交应被接受,实际: %s", msg)
}
if !start.Equal(curStart) || !end.Equal(curEnd) {
t.Fatalf("时间被截断了: %v ~ %v,应为 %v ~ %v", start, end, curStart, curEnd)
}
// 真的改了分钟才按提交值走
in.StartRaw, in.EndRaw = "2026-09-13T20:10", "2026-09-13T20:31"
if start, _, msg = movementWindow(in); msg != "" || start.Equal(curStart) {
t.Fatalf("改了分钟应按提交值生效: %v %s", start, msg)
}
// 手填到同一分钟之外的越界值仍要拒绝
in.StartRaw, in.EndRaw = "2026-09-13T19:30", "2026-09-13T20:31"
if _, _, msg = movementWindow(in); !strings.Contains(msg, "不能早于起点到访的离开时间") {
t.Fatalf("越界的手填值应被拒,实际: %s", msg)
}
}
+49
View File
@@ -0,0 +1,49 @@
package web
import (
"fmt"
"html"
"io"
"net/http"
)
// pausePage 渲染一个「停一下」的极简提示页:给一句人话、一个回首页的按钮,
// 需要时再自动跳回首页。用于「内容对访客不开放」这类情况——
// 直接甩 404 会让人以为站点坏了,说清楚再引导走开才不突兀。
//
// autoSeconds > 0 时页面会自己跳回首页(同时把按钮留给人手动点)。
func (s *Server) pausePage(w http.ResponseWriter, status int, icon, title, msg string, autoSeconds int) {
var auto string
if autoSeconds > 0 {
auto = fmt.Sprintf(`<meta http-equiv="refresh" content="%d;url=/">`, autoSeconds)
}
if icon == "" {
icon = "🔒"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
fmt.Fprintf(w, `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`+
`<meta name="viewport" content="width=device-width,initial-scale=1">`+
`<title>%s</title>%s`+
`<link rel="stylesheet" href="/static/css/app.css?v=%s"></head>`+
`<body class="notice-body"><div class="notice-card">`+
`<div class="notice-ico">%s</div><h1>%s</h1><p>%s</p>`+
`<a class="btn primary" href="/">返回首页</a>`,
html.EscapeString(title), auto, assetVersion, icon,
html.EscapeString(title), html.EscapeString(msg))
if autoSeconds > 0 {
fmt.Fprintf(w, `<div class="notice-tip">%d 秒后自动回到首页</div>`, autoSeconds)
}
io.WriteString(w, `</div></body></html>`)
}
// placeHiddenPage 是「这个地点没对访客开放」时的回应。
//
// 注意取舍:以前这里直接 404,好处是访客分不清「被藏起来了」和「根本不存在」。
// 现在改成明确告知,更好懂,代价是能反推出某个 ID 确实存在。个人站点这个取舍划算,
// 想换回原样只需要把这一处改回 http.NotFound。
func (s *Server) placeHiddenPage(w http.ResponseWriter) {
s.pausePage(w, http.StatusForbidden, "🔒", "这个地点没有对访客开放",
"站长按区域设置隐藏了这个地点的记录。带你去别处看看。", 4)
}
+66
View File
@@ -0,0 +1,66 @@
package web
import (
"context"
"log"
"sync"
"time"
)
// parallelLimit 是页面内并行查询的并发上限。
// 站点常跑在 1C1G 的小机器上,并发太高会把 PostgreSQL 和 CPU 一起挤住,
// 反而比串行更慢(同一份磁盘在排队)。
const parallelLimit = 6
// slowJobLog 是打慢查询日志的阈值(debug 模式下)。
const slowJobLog = 30 * time.Millisecond
// pageJob 是一次页面内的独立查询。name 只用于日志,方便定位是哪个查询慢。
type pageJob struct {
name string
run func(context.Context) error
}
// runJobs 并发执行互不依赖的查询,返回第一个(按传入顺序)非 nil 错误。
//
// 统计页这类页面有十几个互不依赖的查询,串行执行时页面耗时≈它们之和(实测 ~400ms),
// 并发之后≈最慢的那一个。**任务之间不能写同一份数据**:这里只保证并发安全,
// 不管业务上的写入冲突。
func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
if len(jobs) <= 1 {
for _, j := range jobs {
if err := j.run(ctx); err != nil {
return err
}
}
return nil
}
sem := make(chan struct{}, parallelLimit)
errs := make([]error, len(jobs))
var wg sync.WaitGroup
for i := range jobs {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
errs[i] = ctx.Err()
return
}
defer func() { <-sem }()
start := time.Now()
errs[i] = jobs[i].run(ctx)
if d := time.Since(start); d > slowJobLog && s.cfg.Debug {
log.Printf("慢查询 %s: %v", jobs[i].name, d.Round(time.Millisecond))
}
}(i)
}
wg.Wait()
for _, err := range errs {
if err != nil {
return err
}
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"rond-with-you/internal/config"
)
func testServer() *Server { return &Server{cfg: &config.Config{}} }
func TestRunJobsRunsConcurrently(t *testing.T) {
s := testServer()
const n = 8
const each = 60 * time.Millisecond
var done int32
jobs := make([]pageJob, 0, n)
for i := 0; i < n; i++ {
jobs = append(jobs, pageJob{"job", func(context.Context) error {
time.Sleep(each)
atomic.AddInt32(&done, 1)
return nil
}})
}
start := time.Now()
if err := s.runJobs(context.Background(), jobs...); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
if int(done) != n {
t.Fatalf("只完成了 %d 个任务", done)
}
// 并发执行:总耗时应接近单个任务,而不是 n 倍。给足余量避免机器慢时抖动。
if elapsed > each*n/2 {
t.Errorf("8 个 %v 的任务耗时 %v,看起来没并发(串行约 %v)", each, elapsed, each*n)
}
}
func TestRunJobsReturnsFirstErrorInOrder(t *testing.T) {
s := testServer()
errA, errB := errors.New("A 错"), errors.New("B 错")
// 故意让靠后的任务先失败,返回值仍应是传入顺序里的第一个错误
err := s.runJobs(context.Background(),
pageJob{"slow-fail", func(context.Context) error {
time.Sleep(50 * time.Millisecond)
return errA
}},
pageJob{"fast-fail", func(context.Context) error { return errB }},
pageJob{"ok", func(context.Context) error { return nil }},
)
if !errors.Is(err, errA) {
t.Errorf("返回 %v,期望传入顺序里的第一个错误 %v", err, errA)
}
}
func TestRunJobsSingleJobRunsInline(t *testing.T) {
s := testServer()
ran := false
if err := s.runJobs(context.Background(), pageJob{"only", func(context.Context) error {
ran = true
return nil
}}); err != nil {
t.Fatal(err)
}
if !ran {
t.Error("单个任务也应执行")
}
}
func TestRunJobsStopsOnCancelledContext(t *testing.T) {
s := testServer()
ctx, cancel := context.WithCancel(context.Background())
cancel()
var ran int32
jobs := make([]pageJob, 0, 4)
for i := 0; i < 4; i++ {
jobs = append(jobs, pageJob{"job", func(ctx context.Context) error {
// 真实查询在 ctx 取消后就是这样返回的
select {
case <-ctx.Done():
return ctx.Err()
default:
}
atomic.AddInt32(&ran, 1)
return nil
}})
}
if err := s.runJobs(ctx, jobs...); !errors.Is(err, context.Canceled) {
t.Errorf("ctx 已取消时应返回 context.Canceled,实际 %v", err)
}
if n := atomic.LoadInt32(&ran); n != 0 {
t.Errorf("ctx 一开始就已取消,不该有任务真正执行(实际 %d 个)", n)
}
}
+46
View File
@@ -0,0 +1,46 @@
package web
import (
"net/http"
"net/url"
"strings"
"testing"
)
func visitReq(form string) *http.Request {
vals := url.Values{}
for _, kv := range strings.Split(form, "&") {
p := strings.SplitN(kv, "=", 2)
vals.Set(p[0], p[1])
}
r, _ := http.NewRequest("POST", "/admin/edit/visit", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
func TestParseVisitTimes(t *testing.T) {
// 只记到达:离开留空应该合法
arr, dep, ok, msg := parseVisitTimes(visitReq("arrival=2026-09-16T10:00&departure="))
if !ok {
t.Fatalf("只记到达应合法,得到: %s", msg)
}
if dep != nil {
t.Fatalf("离开应为 nil,得到 %v", dep)
}
if arr.IsZero() {
t.Fatal("到达时间不应为零值")
}
// 两个时间都有
_, dep, ok, _ = parseVisitTimes(visitReq("arrival=2026-09-16T10:00&departure=2026-09-16T11:30"))
if !ok || dep == nil {
t.Fatalf("双时间应合法且 departure 非空, ok=%v dep=%v", ok, dep)
}
// 缺到达
if _, _, ok, _ = parseVisitTimes(visitReq("arrival=&departure=2026-09-16T11:30")); ok {
t.Fatal("缺到达应不合法")
}
// 离开早于到达
if _, _, ok, _ = parseVisitTimes(visitReq("arrival=2026-09-16T10:00&departure=2026-09-16T09:00")); ok {
t.Fatal("离开早于到达应不合法")
}
}
+440
View File
@@ -0,0 +1,440 @@
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/geo"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// TestPlaceCoordSource 盯住「坐标来源留档」这条链路:建点时就记下来、编辑时不被误改、
// 换算时跟着坐标一起修正。
//
// 为什么要这么细:2026-09 那次照片导入把 WGS-84 的 EXIF 当成 GCJ-02 存了进去,
// 63 个点整体偏 500~670m,而库里没有任何记录能说明「当初选的是什么坐标系」,
// 最后只能逐张翻原图 EXIF 才裁出来。留档就是为了让这种事下一次能一眼看出。
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run CoordSource -v
func TestPlaceCoordSource(t *testing.T) {
f := newMovementFixture(t)
// ---- 1. rond 导入:留档必须自动补上,且原始坐标就是留档里的 ZRAWLATITUDE ----
var n, bad int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*),
count(*) FILTER (WHERE coord_source<>'rond' OR coord_sys<>'wgs84'
OR src_lat IS DISTINCT FROM (SELECT (r.raw->>'ZRAWLATITUDE')::float8 FROM entity_raw r
WHERE r.dataset_id=places.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=places.src_pk))
FROM places WHERE dataset_id=$1`, f.dsID).Scan(&n, &bad); err != nil {
t.Fatalf("统计留档失败: %v", err)
}
if n == 0 {
t.Fatal("数据集里没有地点,导入没成功")
}
if bad != 0 {
t.Fatalf("%d/%d 个地点没补上 rond 留档(或原始坐标与留档对不上)", bad, n)
}
// 挑一个「留档里的原始坐标 != 显示坐标」的点,也就是 rond 正常做过偏移的那种
var pk int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND src_lat IS NOT NULL AND abs(src_lat-lat)>1e-9
ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&pk); err != nil {
t.Fatalf("找不到做过偏移的点: %v", err)
}
type snapshot struct {
lat, lon float64
coordSource, coordSys string
srcLat, srcLon float64
hasSrc bool
name, province, street string
}
load := func() snapshot {
t.Helper()
var s snapshot
var sl, so *float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT lat, lon, COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon, COALESCE(name,''), COALESCE(province,''), COALESCE(thoroughfare,'')
FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).
Scan(&s.lat, &s.lon, &s.coordSource, &s.coordSys, &sl, &so, &s.name, &s.province, &s.street); err != nil {
t.Fatalf("读地点失败: %v", err)
}
if sl != nil && so != nil {
s.srcLat, s.srcLon, s.hasSrc = *sl, *so, true
}
return s
}
before := load()
if !before.hasSrc {
t.Fatal("rond 导入的点应当留下原始坐标")
}
// ---- 2. 只改名称:留档一个字都不能动,街道也不能被没提交的字段清掉 ----
post := func(path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/edit/place/update":
f.srv.editPlaceUpdate(rec, req)
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
return rec
}
// 先给这一行塞一个街道值,验「表单没提交就不该被清空」
f.mustExec(t, `UPDATE places SET thoroughfare='测试街道' WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk)
// 批量换算工具就是这么提交的:只有坐标与地址,没有留档字段
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name}, "category": {""},
"lat": {fmt.Sprintf("%.6f", before.lat)}, "lon": {fmt.Sprintf("%.6f", before.lon)},
"province": {before.province}, "city": {""}, "district": {""},
})
after := load()
if after.coordSource != before.coordSource || after.coordSys != before.coordSys ||
after.srcLat != before.srcLat || after.srcLon != before.srcLon {
t.Fatalf("只改名称却动了留档:%+v -> %+v", before, after)
}
if after.street != "测试街道" {
t.Fatalf("表单没提交街道,却被清成了 %q", after.street)
}
// ---- 3. 走了「坐标系换算」:留档要记成来源坐标系 + 换算前的坐标 ----
// 复刻页面的真实行为:JavaScript 先调 /api/coords 把坐标换算好再提交,
// 同时把「换算前的值 + 它属于哪个坐标系」一起带上(见 edit.html 的 coord-sel)。
// 这里反过来做:把这条 rond 点的显示坐标(GCJ)当 WGS 提交,看留档跟不跟着走。
// 隐藏字段是原值全精度带出来的(不是 %.6f),所以这里也用全精度提交,
// 免得把留档里那份原始坐标截成 6 位。
rawLat, rawLon := before.srcLat, before.srcLon
subLat, subLon := geo.WGS84ToGCJ02(rawLat, rawLon)
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name},
"lat": {fmt.Sprintf("%.6f", subLat)}, "lon": {fmt.Sprintf("%.6f", subLon)},
"province": {""}, "city": {""}, "district": {""},
"coord_source": {"manual"}, "coord_sys": {"wgs84"},
"src_lat": {fmt.Sprintf("%v", rawLat)}, "src_lon": {fmt.Sprintf("%v", rawLon)},
})
after = load()
if after.coordSys != "wgs84" || after.coordSource != "manual" {
t.Fatalf("留档没跟着换算走:%+v", after)
}
if after.srcLat != rawLat || after.srcLon != rawLon {
t.Fatalf("原始坐标没按表单给的记:想要 %v,%v 实际 %v,%v", rawLat, rawLon, after.srcLat, after.srcLon)
}
// 显示坐标 == 按留档换算原始坐标(表单按 6 位小数提交,所以留 1e-6 的余量)
if wlat, wlon := geo.WGS84ToGCJ02(after.srcLat, after.srcLon); abs(wlat-after.lat) > 1e-6 || abs(wlon-after.lon) > 1e-6 {
t.Fatalf("「显示坐标 == 按留档换算原始坐标」不成立:%v,%v vs %v,%v", wlat, wlon, after.lat, after.lon)
}
// ---- 4. 明确选「未记录」:整组留档一起清掉 ----
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name},
"lat": {fmt.Sprintf("%.6f", after.lat)}, "lon": {fmt.Sprintf("%.6f", after.lon)},
"province": {""}, "city": {""}, "district": {""},
"coord_source": {"none"}, "coord_sys": {"none"},
})
after = load()
if after.coordSource != "" || after.coordSys != "" || after.hasSrc {
t.Fatalf("选了「未记录」却没清干净:%+v", after)
}
// ---- 5. 后台新增地点:来源 manual / 坐标系 gcj02(地图点选与高德搜索都是 GCJ) ----
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"留档测试点"}, "lat": {"30.123456"}, "lon": {"120.123456"},
})
var newPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 AND name='留档测试点'`,
f.dsID).Scan(&newPK); err != nil {
t.Fatalf("新增地点没落库: %v", err)
}
var cs, cy string
var sl, so *float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, newPK).
Scan(&cs, &cy, &sl, &so); err != nil {
t.Fatalf("读新增点失败: %v", err)
}
if cs != "manual" || cy != "gcj02" || sl == nil || *sl != 30.123456 || so == nil || *so != 120.123456 {
t.Fatalf("新增地点的留档不对:source=%q sys=%q src=%v,%v", cs, cy, sl, so)
}
// ---- 6. 照片导入:来源 photo,原始坐标是换算前那一份 ----
// 选「已经是 GCJ-02」正是当初出事的选项,这里把它固化下来:一定要记下来。
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"gcj02"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"照片留档测试点"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
var pcs, pcy string
var psl, pso float64
var plat, plon float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT coord_source, coord_sys, src_lat, src_lon, lat, lon
FROM places WHERE dataset_id=$1 AND name='照片留档测试点'`, f.dsID).
Scan(&pcs, &pcy, &psl, &pso, &plat, &plon); err != nil {
t.Fatalf("照片导入没落库: %v", err)
}
if pcs != "photo" || pcy != "gcj02" {
t.Fatalf("照片导入的留档不对:source=%q sys=%q", pcs, pcy)
}
if psl != 22.853216 || pso != 113.253067 {
t.Fatalf("照片导入没记下换算前的原始坐标:%v,%v", psl, pso)
}
if plat != 22.853216 || plon != 113.253067 {
t.Fatalf("选了「已经是 GCJ-02」就不该换算,实际存成了 %v,%v", plat, plon)
}
// 再导一张按 WGS-84 处理的:显示坐标必须是换算后的值,原始坐标保持 EXIF 原样
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"照片留档测试点2"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
if err := f.pg.QueryRowContext(f.ctx, `SELECT coord_source, coord_sys, src_lat, src_lon, lat, lon
FROM places WHERE dataset_id=$1 AND name='照片留档测试点2'`, f.dsID).
Scan(&pcs, &pcy, &psl, &pso, &plat, &plon); err != nil {
t.Fatalf("照片导入(WGS)没落库: %v", err)
}
if pcs != "photo" || pcy != "wgs84" || psl != 22.853216 || pso != 113.253067 {
t.Fatalf("WGS-84 照片导入的留档不对:source=%q sys=%q src=%v,%v", pcs, pcy, psl, pso)
}
if wlat, wlon := geo.WGS84ToGCJ02(psl, pso); abs(wlat-plat) > 1e-9 || abs(wlon-plon) > 1e-9 {
t.Fatalf("显示坐标应当是换算后的值:%v,%v", plat, plon)
}
}
// TestCoordSourceSkipsExportSynthetic 盯住回填判据里最容易踩的那个坑:站点导出的备份会
// 给新建地点**合成** ZLOCATION 行,把 ZRAWLATITUDE 写成 GCJ02ToWGS84(导出那一刻的显示坐标)。
// 这种包被回导之后,「有 ZLOCATION 留档」就不再等于「rond 导入」,早期回填据此把 62 个
// 照片导入的点全标成了 rond。判据必须落在「ZRAW 是否恰好等于 gcj2wgs(该行自己的 ZLAT)」上。
//
// 这里手工造两行留档:一行合成(ZRAW = gcj2wgs(ZLAT)),一行真机(ZRAW 是独立采样值),
// 然后跑回填,断言只有真机那行被认领、合成那行不碰。
func TestCoordSourceSkipsExportSynthetic(t *testing.T) {
f := newMovementFixture(t)
// 造两个站点新建的点:编号取在基线之外,模拟「导出时还不存在」的新建地点
var maxPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(max(src_pk),0) FROM places
WHERE dataset_id=$1`, f.dsID).Scan(&maxPK); err != nil {
t.Fatalf("读编号上限失败: %v", err)
}
synthPK, devicePK := maxPK+1, maxPK+2
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) VALUES
($1,$2,'合成行测试点',30.5,120.5), ($1,$3,'真机行测试点',30.6,120.6)`, f.dsID, synthPK, devicePK)
// 合成行:ZRAW 严格等于 gcj2wgs(ZLAT)——正是 export.go 给新建地点的写法
synLat, synLon := geo.GCJ02ToWGS84(30.5, 120.5)
// 真机行:ZRAW 是独立采样值,显示坐标是 app 自己按 GCJ 偏移算的,两者不会位级吻合
// (真机基线 321 行零命中就是这个原因)。这里按同样的关系构造,但不让往返精确相等。
devRawLat, devRawLon := 30.7, 120.7
devLat, devLon := geo.WGS84ToGCJ02(devRawLat, devRawLon)
devLat, devLon = devLat+1e-9, devLon+1e-9
raw := func(lat, lon, rlat, rlon float64) string {
return fmt.Sprintf(`{"ZLATITUDE":%v,"ZLONGITUDE":%v,"ZRAWLATITUDE":%v,"ZRAWLONGITUDE":%v}`,
lat, lon, rlat, rlon)
}
f.mustExec(t, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped) VALUES
($1,'ZLOCATION',$2,$3::jsonb,false), ($1,'ZLOCATION',$4,$5::jsonb,false)`,
f.dsID, synthPK, raw(30.5, 120.5, synLat, synLon), devicePK, raw(devLat, devLon, devRawLat, devRawLon))
// 先故意把合成行写成早期回填的错误结果,验证回填会把它撤回
f.mustExec(t, `UPDATE places SET coord_source='rond', coord_sys='wgs84', src_lat=$3, src_lon=$4
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, synthPK, synLat, synLon)
if _, err := ingest.BackfillCoordSource(f.ctx, f.pg, f.dsID); err != nil {
t.Fatalf("回填失败: %v", err)
}
var source, sys string
var srcLat, srcLon *float64
load := func(pk int) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).
Scan(&source, &sys, &srcLat, &srcLon); err != nil {
t.Fatalf("读留档失败: %v", err)
}
}
// 合成行必须被撤回:它不是真机留档,来源应当是「未记录」
load(synthPK)
if source != "" || sys != "" || srcLat != nil || srcLon != nil {
t.Fatalf("导出合成行没被撤回:source=%q sys=%q src=%v,%v", source, sys, srcLat, srcLon)
}
// 真机行必须被认领,且原始坐标就是留档里的 ZRAW
load(devicePK)
if source != "rond" || sys != "wgs84" || srcLat == nil || *srcLat != devRawLat || srcLon == nil || *srcLon != devRawLon {
t.Fatalf("真机留档行没被正确认领:source=%q sys=%q src=%v,%v(想要 %v,%v)",
source, sys, srcLat, srcLon, devRawLat, devRawLon)
}
// 再跑一次必须幂等(启动时每次都会跑)
if _, err := ingest.BackfillCoordSource(f.ctx, f.pg, f.dsID); err != nil {
t.Fatalf("二次回填失败: %v", err)
}
load(devicePK)
if source != "rond" || srcLat == nil || *srcLat != devRawLat {
t.Fatalf("回填不幂等:%q %v", source, srcLat)
}
}
// TestExportPhotoImportFlag 盯住导出侧补的 ZISPHOTOIMPORT:rond 靠这一位区分
// 「照片导入」与「手工新增」,站点没有对应字段,只能从坐标来源留档推。
// 真机行(coord_source=rond)必须原样保留,不能被我们改写。
func TestExportPhotoImportFlag(t *testing.T) {
f := newMovementFixture(t)
// 照片导入一个点 → photo;手工新增一个点 → manual
post := func(path string, form url.Values) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
}
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"导出照片标志位测试点"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"导出手工点测试点"}, "lat": {"30.123456"}, "lon": {"120.123456"},
})
var photoPK, manualPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name='导出照片标志位测试点'`, f.dsID).Scan(&photoPK); err != nil {
t.Fatalf("照片导入点没落库: %v", err)
}
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name='导出手工点测试点'`, f.dsID).Scan(&manualPK); err != nil {
t.Fatalf("手工点没落库: %v", err)
}
// 基线里的真机行:留档里记着它自己那份 ZISPHOTOIMPORT,导出后必须一字不改
var devPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND coord_source='rond' ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&devPK); err != nil {
t.Fatalf("找不到真机点: %v", err)
}
srcDB := f.openSource(t)
var devWant int
if err := srcDB.QueryRow(`SELECT ZISPHOTOIMPORT FROM ZLOCATION WHERE Z_PK=?`, devPK).Scan(&devWant); err != nil {
t.Fatalf("读原包 ZISPHOTOIMPORT 失败: %v", err)
}
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
flagOf := func(pk int) int {
t.Helper()
var v sql.NullInt64
if err := edb.QueryRow(`SELECT ZISPHOTOIMPORT FROM ZLOCATION WHERE Z_PK=?`, pk).Scan(&v); err != nil {
t.Fatalf("读导出包 Z_PK=%d 失败: %v", pk, err)
}
if !v.Valid {
return -1
}
return int(v.Int64)
}
if got := flagOf(photoPK); got != 1 {
t.Errorf("照片导入的点 ZISPHOTOIMPORT 应为 1,实际 %d", got)
}
if got := flagOf(manualPK); got != 0 {
t.Errorf("手工新增的点 ZISPHOTOIMPORT 应为 0,实际 %d", got)
}
if got := flagOf(devPK); got != devWant {
t.Errorf("真机点 #%d 的 ZISPHOTOIMPORT 被改写:原有 %d,导出成 %d", devPK, devWant, got)
}
}
// TestInlineSaveKeepsFullPrecisionCoord 盯住「行内保存会把坐标截到 6 位小数」。
// 表单的经纬度框是 %.6f,而真机基线的点带长小数(26.46800422211455 这种)。
// 只改备注/名称时原样回贴,旧实现会把坐标写短——导出与原始包逐位对照就对不上。
// 与时间字段同一套规矩:落在同一个 6 位小数上就沿用库里那份。
func TestInlineSaveKeepsFullPrecisionCoord(t *testing.T) {
f := newMovementFixture(t)
var pk int
var lat0, lon0, srcLat0 float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk, lat, lon, src_lat FROM places
WHERE dataset_id=$1 AND coord_source='rond' AND src_lat IS NOT NULL
AND lat <> round(lat::numeric, 6)::float8 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(
&pk, &lat0, &lon0, &srcLat0); err != nil {
t.Skipf("本地基线的坐标都已经是 6 位,测不出截断: %v", err)
}
// 只改名称:经纬度按表单的 6 位小数原样回贴
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {"改了名字"},
"lat": {fmt.Sprintf("%.6f", lat0)}, "lon": {fmt.Sprintf("%.6f", lon0)},
"province": {""}, "city": {""}, "district": {""},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
f.srv.editPlaceUpdate(httptest.NewRecorder(), req)
var lat1, lon1, srcLat1 float64
var name string
if err := f.pg.QueryRowContext(f.ctx, `SELECT lat, lon, src_lat, name FROM places
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).Scan(&lat1, &lon1, &srcLat1, &name); err != nil {
t.Fatalf("读回失败: %v", err)
}
if name != "改了名字" {
t.Fatalf("名称没改成功: %q", name)
}
if lat1 != lat0 || lon1 != lon0 {
t.Errorf("只改名称却把坐标截短了:%v,%v → %v,%v", lat0, lon0, lat1, lon1)
}
if srcLat1 != srcLat0 {
t.Errorf("原始坐标被动过:%v → %v", srcLat0, srcLat1)
}
}
func (f *movementFixture) mustExec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, q, args...); err != nil {
t.Fatalf("执行 %s 失败: %v", q, err)
}
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
+304
View File
@@ -0,0 +1,304 @@
package web
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"
)
// webpBytes 造一段带正确 RIFF/WEBP 魔数的假数据。服务端只校验文件头
// (转码在浏览器做),所以这里不需要真的 WebP 编码器。
func webpBytes(n int) []byte {
b := make([]byte, 12+n)
copy(b[0:4], "RIFF")
copy(b[8:12], "WEBP")
for i := 12; i < len(b); i++ {
b[i] = byte(i)
}
return b
}
// postMultipart 按浏览器 FormData 的形态发一个 multipart 请求。
func postMultipart(t *testing.T, f *movementFixture, handler http.HandlerFunc, fields map[string]string, files map[string][]byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatal(err)
}
}
for field, data := range files {
part, err := w.CreateFormFile(field, "image.webp")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/x", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
handler(rec, req)
return rec
}
// setMediaBackend 把「图片存哪边」临时改成 backend,测试结束再恢复。
// 多个测试共用一个库,不还原就会互相干扰(前一个设了 s3,后一个的本地上传就失败了)。
func setMediaBackend(t *testing.T, f *movementFixture, backend string) {
t.Helper()
var prev string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(value->>'media_backend','local')
FROM settings WHERE key='site'`).Scan(&prev); err != nil {
t.Fatalf("读图片存储设置失败: %v", err)
}
write := func(v string) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, `UPDATE settings
SET value = jsonb_set(value, '{media_backend}', to_jsonb($1::text)) WHERE key='site'`, v); err != nil {
t.Fatalf("写图片存储设置失败: %v", err)
}
}
write(backend)
t.Cleanup(func() { write(prev) })
}
// TestPlaceImageUploadDelete 端到端跑一遍浏览器那条路:
// multipart 上传 → 落库 + 落盘 → /media 取回同样的字节 → multipart 删除 → 两处都没了。
//
// 这里刻意用 multipart 而不是 urlencoded:前端是用 FormData 发的,
// 而「先 ParseForm 再读 multipart」会把字段读成空——这个坑真踩过(删除按钮点了没反应)。
func TestPlaceImageUploadDelete(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir() // 图片写进临时目录,别脏了 storage/
setMediaBackend(t, f, "local")
// 借一个现成的地点
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
raw := webpBytes(64)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src), "w": "1200", "h": "900"},
map[string][]byte{"file": raw})
var up struct {
OK bool `json:"ok"`
ID int64 `json:"id"`
URL string `json:"url"`
Bytes int `json:"bytes"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
}
if !up.OK || up.Error != "" {
t.Fatalf("上传失败: %s", rec.Body.String())
}
if !strings.HasPrefix(up.URL, "/media/") {
t.Fatalf("本地存储的地址应以 /media/ 开头,实际 %q", up.URL)
}
// 落盘 + 落库
full, err := f.srv.media.LocalPath(strings.TrimPrefix(up.URL, "/media/"))
if err != nil {
t.Fatalf("取路径失败: %v", err)
}
if got, err := os.ReadFile(full); err != nil || !bytes.Equal(got, raw) {
t.Fatalf("落盘的字节与上传的不一致: %v", err)
}
// /media 直出同样的字节
rec = httptest.NewRecorder()
req := httptest.NewRequest("GET", up.URL, nil).WithContext(f.ctx)
f.srv.serveMedia(rec, req)
if rec.Code != 200 {
t.Fatalf("取图返回 %d", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), raw) {
t.Fatal("取回的字节与上传的不一致")
}
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
t.Errorf("Content-Type 不对: %q", ct)
}
// 非 WebP 必须被挡住(前端转码失败时会发来 PNG)
rec = postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)},
map[string][]byte{"file": append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)})
if !strings.Contains(rec.Body.String(), "只接受 WebP") {
t.Errorf("非 WebP 没被拒绝: %s", rec.Body.String())
}
// 删除:同样走 multipart
rec = postMultipart(t, f, f.srv.adminPlaceImageDelete,
map[string]string{"id": fmt.Sprint(up.ID)}, nil)
if !strings.Contains(rec.Body.String(), `"ok":true`) {
t.Fatalf("删除失败: %s", rec.Body.String())
}
if _, err := os.Stat(full); !os.IsNotExist(err) {
t.Errorf("文件没删掉: %v", err)
}
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE id=$1`, up.ID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("库里的记录没删掉")
}
}
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
// 并且图片挂在正确的 src_pk 上。
func TestPhotoImportWithImage(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
setMediaBackend(t, f, "local")
raw := webpBytes(48)
rec := postMultipart(t, f, f.srv.adminPhotosImport, map[string]string{
"n": "1", "coord": "wgs84", "keep_0": "on",
"lat_0": "30.123456", "lon_0": "120.123456",
"arrival_0": "2026-04-18T18:14", "name_0": "带图的导入点",
"country_0": "CN", "timezone_0": "Asia/Shanghai",
"image_w_0": "1200", "image_h_0": "900",
}, map[string][]byte{"image_0": raw})
if rec.Code != http.StatusSeeOther {
t.Fatalf("导入没返回重定向: %d %s", rec.Code, rec.Body.String())
}
loc, err := url.QueryUnescape(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(loc, "err=") {
t.Fatalf("导入报错: %s", loc)
}
if !strings.Contains(loc, "配了 1 张图") {
t.Errorf("结果消息里应提到配了图: %s", loc)
}
var src int
var w, h int
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, pi.width, pi.height FROM places p
JOIN place_images pi ON pi.dataset_id=p.dataset_id AND pi.src_pk=p.src_pk
WHERE p.dataset_id=$1 AND p.name='带图的导入点'`, f.dsID).Scan(&src, &w, &h); err != nil {
t.Fatalf("配图没挂到地点上: %v", err)
}
if w != 1200 || h != 900 {
t.Errorf("宽高没记对: %dx%d", w, h)
}
// 字节确实落盘了:目录是 <数据集>/<上传月份>/,文件名是 <地点号>-<哈希>.webp
dir := filepath.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID), time.Now().Format("200601"))
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("月份目录不存在: %v", err)
}
if len(entries) != 1 {
t.Fatalf("期望落盘 1 个文件,实际 %d", len(entries))
}
if name := entries[0].Name(); !strings.HasPrefix(name, fmt.Sprint(src)+"-") ||
!strings.HasSuffix(name, ".webp") {
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", name)
}
}
// TestPlaceImageS3Backend 走一遍对象存储那条路:用 httptest 假装 S3 端点,
// 断言请求打到「路径式 + 桶名 + 对象名」上,并带上了 SigV4 的 Authorization。
// 真去校验签名的是 media 包里的官方向量测试,这里盯的是接线对不对。
func TestPlaceImageS3Backend(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
var gotPath, gotAuth, gotCT string
var gotBody []byte
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotCT = r.Header.Get("Content-Type")
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer fake.Close()
f.cfg.S3Endpoint = fake.URL
f.cfg.S3Region = "auto"
f.cfg.S3Bucket = "pics"
f.cfg.S3AccessKey = "AKIATEST"
f.cfg.S3SecretKey = "secret"
f.cfg.S3PublicBase = "https://img.example.com"
setMediaBackend(t, f, "s3")
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
raw := webpBytes(32)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)}, map[string][]byte{"file": raw})
var up struct {
OK bool `json:"ok"`
URL string `json:"url"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
}
if !up.OK || up.Error != "" {
t.Fatalf("对象存储上传失败: %s", rec.Body.String())
}
if !strings.HasPrefix(up.URL, "https://img.example.com/") {
t.Errorf("对象存储的地址应当用 s3_public_base 拼,实际 %q", up.URL)
}
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
parts := strings.Split(strings.TrimPrefix(gotPath, "/pics/"), "/")
if len(parts) != 3 {
t.Fatalf("对象名层级不对(应为 数据集/月份/文件名): %q", gotPath)
}
if parts[0] != fmt.Sprint(f.dsID) {
t.Errorf("第一层应是数据集号,实际 %q", parts[0])
}
if len(parts[1]) != 6 || !strings.HasPrefix(parts[1], time.Now().Format("2006")) {
t.Errorf("第二层应是上传月份(YYYYMM),实际 %q", parts[1])
}
if !strings.HasPrefix(parts[2], fmt.Sprint(src)+"-") || !strings.HasSuffix(parts[2], ".webp") {
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", parts[2])
}
if !strings.Contains(gotAuth, "AWS4-HMAC-SHA256 Credential=AKIATEST/") ||
!strings.Contains(gotAuth, "/auto/s3/aws4_request") {
t.Errorf("没带上 SigV4 签名: %q", gotAuth)
}
if gotCT != "image/webp" {
t.Errorf("Content-Type 不对: %q", gotCT)
}
if !bytes.Equal(gotBody, raw) {
t.Error("发出去的字节与上传的不一致")
}
// 存储被切到对象存储后,本地不应再落盘
entries, _ := os.ReadDir(path.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID)))
if len(entries) != 0 {
t.Errorf("切到对象存储后不该再写本地磁盘,却发现 %d 个条目", len(entries))
}
}
+94
View File
@@ -0,0 +1,94 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestNewRowNumberingSkipsArchivedPKs 盯住「站点新建行的编号」。
//
// 编号不能只看 `MAX(places.src_pk)`:`entity_raw` 里还躺着导入时缺必填字段的 skipped 行
// (导出要把它们原样补回去,所以一直留着),那些号没有对应实体行却占着号位。
// 只看 places 的话,新行会拿到一个已经被留档占用的号 —— 导出时被当成「已存在的行」走 UPDATE,
// 于是继承对方 rond 自己写的那些列(ZRADIUS / ZTYPE_ / ZNOTE_…)。
//
// 这里造一条「只有留档、没有实体行」的 ZLOCATION,再分别走两条新建路径,断言编号绕开它。
func TestNewRowNumberingSkipsArchivedPKs(t *testing.T) {
f := newMovementFixture(t)
// 与 nextSrcPK 同一套口径,先算出「当前有效上限」
var cur int
if err := f.pg.QueryRowContext(f.ctx, `SELECT GREATEST(
(SELECT COALESCE(MAX(src_pk),0) FROM places WHERE dataset_id=$1),
COALESCE((coredata_meta->'entities'->'Location'->>'max')::int,0),
(SELECT COALESCE(MAX((raw->>'Z_PK')::numeric),0)::int FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION' AND (raw->>'Z_PK') ~ '^[0-9]+$')
) FROM datasets WHERE id=$1`, f.dsID).Scan(&cur); err != nil {
t.Fatalf("算编号上限失败: %v", err)
}
stalePK := cur + 3
// 留档存的是整行 Core Data 记录,Z_PK 就在里面——nextSrcPK 正是靠它算上限
f.mustExec(t, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
VALUES ($1,'ZLOCATION',$2,$3::jsonb,true)`, f.dsID, stalePK,
fmt.Sprintf(`{"Z_PK":%d,"ZLATITUDE":30.0,"ZLONGITUDE":120.0}`, stalePK))
post := func(path string, form url.Values) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
if rec.Code >= 400 {
t.Fatalf("%s 返回 %d: %s", path, rec.Code, rec.Body.String())
}
}
srcPKOf := func(name string) int {
t.Helper()
var pk int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name=$2`, f.dsID, name).Scan(&pk); err != nil {
t.Fatalf("地点 %q 没落库: %v", name, err)
}
return pk
}
// 路径一:后台「新增地点」
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"编号测试点-手工"}, "lat": {"30.11"}, "lon": {"120.11"},
})
if got := srcPKOf("编号测试点-手工"); got <= stalePK {
t.Errorf("手工新增的编号 %d 没绕开留档占用的 %d(旧实现会拿到 %d)", got, stalePK, cur+1)
}
// 路径二:照片导入(同一套规则)
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"30.22"}, "lon_0": {"120.22"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"编号测试点-照片"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
if got := srcPKOf("编号测试点-照片"); got <= stalePK {
t.Errorf("照片导入的编号 %d 没绕开留档占用的 %d", got, stalePK)
}
// 留档那条本身还在(newSrcPK 绕开了它,就不该被清掉)
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, f.dsID, stalePK).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("留档行被误清:期望 1 行,实际 %d", n)
}
}
+472
View File
@@ -0,0 +1,472 @@
package web
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/captcha"
"rond-with-you/internal/config"
"rond-with-you/internal/media"
"rond-with-you/internal/notes"
"rond-with-you/internal/setting"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
//go:embed templates/*.html templates/admin/*.html
var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
// pageFiles 每个页面单独成组解析:页面文件都定义 "content",
// 与 layout.html 一起构成一个独立模板集,避免定义名冲突。
var pageFiles = map[string]struct {
files []string
entry string
}{
"home": {[]string{"templates/layout.html", "templates/home.html"}, "layout"},
"map": {[]string{"templates/layout.html", "templates/map.html"}, "layout"},
"places": {[]string{"templates/layout.html", "templates/places.html"}, "layout"},
"place": {[]string{"templates/layout.html", "templates/place.html"}, "layout"},
"timeline": {[]string{"templates/layout.html", "templates/timeline.html"}, "layout"},
"stats": {[]string{"templates/layout.html", "templates/stats.html"}, "layout"},
"report": {[]string{"templates/layout.html", "templates/report.html"}, "layout"},
"admin/index": {[]string{"templates/layout.html", "templates/admin/index.html"}, "layout"},
"admin/settings": {[]string{"templates/layout.html", "templates/admin/settings.html"}, "layout"},
"admin/password": {[]string{"templates/layout.html", "templates/admin/password.html"}, "layout"},
"admin/login": {[]string{"templates/admin/login.html"}, "login"},
"admin/twofa-login": {[]string{"templates/admin/twofa-login.html"}, "login2"},
"admin/twofa": {[]string{"templates/layout.html", "templates/admin/twofa.html"}, "layout"},
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
"admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"},
"admin/photos": {[]string{"templates/layout.html", "templates/admin/photos.html"}, "layout"},
"topics": {[]string{"templates/layout.html", "templates/topics.html"}, "layout"},
"topic": {[]string{"templates/layout.html", "templates/topic.html"}, "layout"},
}
type pageSet struct {
t *template.Template
entry string
}
type Server struct {
cfg *config.Config
db *sql.DB
q stats.DB
auth *auth.Store
sets *setting.Store
notes *notes.Store
votes *votes.Store
pages map[string]pageSet
mux *http.ServeMux
cookie string
tiles tileSet
tileCache *tileCache
// fogTiles 缓存渲染好的迷雾瓦片:每张都要现画逐格位图再 PNG 编码,
// 而内容只随「迷雾版本 + 遮罩 + 底图坐标系」变化。
fogTiles *tileCache
// masks 缓存遮罩列表:它不在 fogIndex 里,不然每张瓦片都要查一次库。
masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store
}
func New(cfg *config.Config, db *sql.DB) (*Server, error) {
initAssetVersion(cfg.Debug)
pages := make(map[string]pageSet, len(pageFiles))
for key, def := range pageFiles {
t, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...)
if err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", key, err)
}
pages[key] = pageSet{t: t, entry: def.entry}
}
s := &Server{
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
media: media.New(cfg),
}
s.routes()
return s, nil
}
// guardPage 拦截「站长已关闭」的公开页面:管理员照常访问,访客拿到 404。
// withUser 在 mux 之前运行,这里能直接拿到登录态。
func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if userFrom(r.Context()) == nil {
if sets, err := s.sets.Load(r.Context()); err == nil && !sets.HasPage(nav) {
s.pausePage(w, http.StatusNotFound, "🔒", "该页面未对访客开放",
"站长还没有把这一页开放给访客。", 0)
return
}
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Handler() http.Handler {
return s.recover(gzipHandler(s.logRequests(s.withUser(s.mux))))
}
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
// 版本号随编译产物变化(静态文件内容哈希),旧链接即使被缓存也不会命中新版本。
func (s *Server) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("v"); v != "" && v == assetVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
// withUser 为所有请求解析登录态。公开页面也要知道访问者是访客还是站长,
// 才能决定是否渲染「含家/工作」这类只给自己看的开关。
// 未携带会话 cookie 时不会查库,匿名访问没有额外开销。
func (s *Server) withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, err := s.sessionUser(r); err == nil && u != nil {
r = r.WithContext(context.WithValue(r.Context(), userKey{}, u))
}
next.ServeHTTP(w, r)
})
}
func (s *Server) routes() {
// 静态资源:调试模式下直接读磁盘,改样式/脚本不用重新编译。
// 页面链接都带 ?v=<版本>,版本一致才给一年强缓存,重新编译后版本变了自然失效。
if s.cfg.Debug {
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static")))))
} else {
sub, _ := fs.Sub(staticFS, "static")
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.FS(sub)))))
}
// 公开页面:受「公开页面」开关控制,关闭后仅管理员可访问
s.mux.HandleFunc("GET /{$}", s.home)
s.mux.Handle("GET /map", s.guardPage("map", s.mapPage))
s.mux.Handle("GET /places", s.guardPage("places", s.placesPage))
s.mux.HandleFunc("GET /places/{id}", s.placePage)
s.mux.Handle("GET /timeline", s.guardPage("timeline", s.timelinePage))
s.mux.Handle("GET /stats", s.guardPage("stats", s.statsPage))
s.mux.HandleFunc("GET /api/points", s.apiPoints)
s.mux.HandleFunc("GET /api/track", s.apiTrack)
s.mux.HandleFunc("GET /api/cities", s.apiCityAgg)
s.mux.HandleFunc("GET /api/gpx", s.apiGpx)
s.mux.HandleFunc("GET /api/votes", s.apiVotes)
s.mux.HandleFunc("POST /api/vote", s.apiVote)
s.mux.HandleFunc("POST /api/verdict", s.requireAdmin(s.apiVerdict))
s.mux.HandleFunc("GET /api/map-config", s.mapConfig)
// 地理编码走服务端代理,高德密钥不出现在前端源码里;仅管理员可用,避免配额被外部消耗
s.mux.HandleFunc("GET /api/geocode", s.requireAdmin(s.apiGeocode))
s.mux.HandleFunc("GET /api/coords", s.requireAdmin(s.apiCoords))
s.mux.HandleFunc("POST /api/regeo", s.requireAdmin(s.apiRegeo))
s.mux.HandleFunc("GET /tiles/{layer}/{z}/{x}/{y}", s.handleTile)
s.mux.HandleFunc("GET /tiles/fog/{z}/{x}/{y}", s.handleFogTile)
// 专题:把某段时间 / 某个区域的足迹打包成可对外展示的页面。
// 不做页面级开关——访客能不能看由「每个专题的展示开关」决定(详情页对未展示的专题 404)。
s.mux.HandleFunc("GET /admin/topics", s.requireAdmin(s.topicsAdminPage))
s.mux.HandleFunc("POST /admin/topics/save", s.requireAdmin(s.adminTopicSave))
s.mux.HandleFunc("POST /admin/topics/delete", s.requireAdmin(s.adminTopicDelete))
s.mux.HandleFunc("POST /admin/topics/toggle", s.requireAdmin(s.adminTopicToggle))
s.mux.HandleFunc("GET /topics", s.topicsListPage)
s.mux.HandleFunc("GET /topics/{id}", s.topicPage)
s.mux.Handle("GET /report", s.guardPage("report", s.reportPage))
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok")
})
// 后台
s.mux.HandleFunc("GET /captcha", s.captchaImage)
s.mux.HandleFunc("GET /admin/login", s.loginPage)
s.mux.HandleFunc("POST /admin/login", s.loginSubmit)
s.mux.HandleFunc("POST /admin/login/twofa", s.login2FA)
s.mux.HandleFunc("POST /admin/logout", s.logout)
s.mux.HandleFunc("GET /admin/twofa", s.requireAdmin(s.twofaPage))
s.mux.HandleFunc("POST /admin/twofa/setup", s.requireAdmin(s.twofaSetup))
s.mux.HandleFunc("POST /admin/twofa/confirm", s.requireAdmin(s.twofaConfirm))
s.mux.HandleFunc("POST /admin/twofa/disable", s.requireAdmin(s.twofaDisable))
s.mux.HandleFunc("GET /admin", s.requireAdmin(s.adminHome))
s.mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload))
s.mux.HandleFunc("POST /admin/datasets/{id}/activate", s.requireAdmin(s.adminActivate))
s.mux.HandleFunc("POST /admin/datasets/{id}/delete", s.requireAdmin(s.adminDelete))
s.mux.HandleFunc("POST /admin/place/note", s.requireAdmin(s.adminPlaceNote))
s.mux.HandleFunc("POST /admin/place/vote", s.requireAdmin(s.adminPlaceVote))
// 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址
s.mux.HandleFunc("POST /admin/place/images", s.requireAdmin(s.adminPlaceImageUpload))
s.mux.HandleFunc("POST /admin/place/images/delete", s.requireAdmin(s.adminPlaceImageDelete))
// 本地存储的图片直出;对象存储的图走公开域名,不经过这里
s.mux.HandleFunc("GET /media/", s.serveMedia)
s.mux.HandleFunc("POST /admin/notes/bulk", s.requireAdmin(s.adminNotesBulk))
s.mux.HandleFunc("POST /admin/notes/rond", s.requireAdmin(s.adminNotesRond))
s.mux.HandleFunc("GET /admin/notes", s.requireAdmin(s.notesPage))
// 数据编辑:手动补录记录点,改完可导出回 rondbackup
s.mux.HandleFunc("GET /admin/edit", s.requireAdmin(s.editPage))
s.mux.HandleFunc("POST /admin/edit/place", s.requireAdmin(s.editPlaceCreate))
s.mux.HandleFunc("POST /admin/edit/place/update", s.requireAdmin(s.editPlaceUpdate))
s.mux.HandleFunc("POST /admin/edit/place/delete", s.requireAdmin(s.editPlaceDelete))
s.mux.HandleFunc("POST /admin/edit/visit/update", s.requireAdmin(s.editVisitUpdate))
s.mux.HandleFunc("POST /admin/edit/activity", s.requireAdmin(s.editActivityCreate))
s.mux.HandleFunc("POST /admin/edit/activity/update", s.requireAdmin(s.editActivityUpdate))
s.mux.HandleFunc("POST /admin/edit/activity/delete", s.requireAdmin(s.editActivityDelete))
// 照片导入:EXIF 由浏览器读(不上传照片),确认后只把元数据提交上来落库
s.mux.HandleFunc("GET /admin/photos", s.requireAdmin(s.adminPhotosPage))
s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport))
s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete))
s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark))
s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate))
s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate))
s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete))
s.mux.HandleFunc("POST /admin/edit/weather", s.requireAdmin(s.editWeatherCreate))
s.mux.HandleFunc("POST /admin/gpx", s.requireAdmin(s.adminGpxUpload))
s.mux.HandleFunc("POST /admin/gpx/delete", s.requireAdmin(s.adminGpxDelete))
s.mux.HandleFunc("POST /admin/fog", s.requireAdmin(s.adminFogUpload))
s.mux.HandleFunc("POST /admin/fog/clear", s.requireAdmin(s.adminFogClear))
s.mux.HandleFunc("GET /admin/fog/export", s.requireAdmin(s.adminFogExport))
s.mux.HandleFunc("POST /admin/places/backfill", s.requireAdmin(s.adminBackfillPlaces))
s.mux.HandleFunc("GET /admin/fog/mask", s.requireAdmin(s.fogMaskPage))
s.mux.HandleFunc("POST /admin/fog/mask/save", s.requireAdmin(s.fogMaskSave))
s.mux.HandleFunc("POST /admin/fog/mask/delete", s.requireAdmin(s.fogMaskDelete))
s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON))
s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX))
s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup))
s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage))
s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
s.mux.HandleFunc("POST /admin/password", s.requireAdmin(s.passwordSubmit))
s.mux.HandleFunc("POST /admin/username", s.requireAdmin(s.usernameSubmit))
}
// ---------- 中间件 ----------
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if s.cfg.Debug || strings.HasPrefix(r.URL.Path, "/admin") {
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
}
})
}
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// sessionUser 从签名 cookie 还原登录态;口令变更后旧会话自动失效。
func (s *Server) sessionUser(r *http.Request) (*auth.User, error) {
c, err := r.Cookie(s.cookie)
if err != nil || c.Value == "" {
return nil, nil
}
uidStr, _, ok := strings.Cut(c.Value, "|")
if !ok {
return nil, nil
}
uid, err := strconv.ParseInt(uidStr, 10, 64)
if err != nil {
return nil, nil
}
u, err := s.auth.UserByID(r.Context(), uid)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if _, ok := s.auth.ParseToken(c.Value, u.Hash); !ok {
return nil, nil
}
return u, nil
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if u == nil {
http.Redirect(w, r, "/admin/login?next="+path.Clean(r.URL.Path), http.StatusSeeOther)
return
}
next(w, r)
}
}
type userKey struct{}
func userFrom(ctx context.Context) *auth.User {
u, _ := ctx.Value(userKey{}).(*auth.User)
return u
}
func (s *Server) login(w http.ResponseWriter, userID int64, hash string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie,
Value: s.auth.SignToken(userID, hash),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: s.cookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// ---------- 渲染 ----------
// render 渲染指定页面(key 见 pageFiles)。
func (s *Server) render(w http.ResponseWriter, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
log.Printf("加载页面模板 %s 失败: %v", key, err)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
func (s *Server) renderStatus(w http.ResponseWriter, code int, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
// pageSet 返回模板集;调试模式下每次从磁盘重新解析,改完模板刷新即可见效。
func (s *Server) pageSet(key string) (pageSet, error) {
def, ok := pageFiles[key]
if !ok {
return pageSet{}, fmt.Errorf("未知页面模板 %s", key)
}
if !s.cfg.Debug {
return s.pages[key], nil
}
files := make([]string, len(def.files))
for i, f := range def.files {
// 嵌入路径相对包目录,调试时按工作目录拼接
files[i] = filepath.Join("internal", "web", f)
}
t, err := template.New(filepath.Base(files[0])).Funcs(templateFuncs()).ParseFiles(files...)
if err != nil {
return pageSet{}, err
}
return pageSet{t: t, entry: def.entry}, nil
}
func (s *Server) json(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("JSON 输出失败: %v", err)
}
}
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
http.Error(w, "服务器内部错误: "+err.Error(), http.StatusInternalServerError)
}
func atoiOr(s string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseInt64s(vals []string) []int64 {
var out []int64
for _, v := range vals {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
out = append(out, n)
}
}
return out
}
// datasetFor 取当前生效的数据集;无数据时返回 nil。
func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
var d Dataset
err := s.db.QueryRowContext(ctx, `SELECT id, original_name, uploaded_at, visit_count, place_count,
raw_visit_count, first_visit_at, last_visit_at, size_bytes
FROM datasets WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).
Scan(&d.ID, &d.Name, &d.UploadedAt, &d.Visits, &d.Places, &d.RawVisits, &d.First, &d.Last, &d.Size)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
type Dataset struct {
ID int64
Name string
UploadedAt time.Time
Visits int
Places int
RawVisits int
First *time.Time
Last *time.Time
Size int64
}
+900
View File
@@ -0,0 +1,900 @@
:root {
--accent: #2f7d6e;
--accent-soft: #e8f3f0;
--bg: #f6f7f8;
--panel: #ffffff;
--line: #e5e7ea;
--line-soft: #f0f1f3;
--text: #1f2328;
--text-2: #5b6169;
/* 次要文字。原来是 #8b9199,对白底只有 3.18:1、对浅灰底 2.81:1,
低于 WCAG AA 的 4.5:1(页面副标题、KPI 标签、筛选计数全用这个色阶)。
改成 #666d75 之后:白底 5.24 / 浅灰底 4.63 / 绿浅底 4.62,
既达标又仍比 --text-2(5.53)浅,层次没丢。 */
--text-3: #666d75;
/* 会随明暗切换的「面」色,抽出来暗色只需换这一组 */
--surface: #ffffff; /* 输入框 / 按钮 / 浮层的底 */
--glass: rgba(255, 255, 255, .92); /* 顶栏毛玻璃 */
--glass-strong: rgba(255, 255, 255, .97); /* 地图面板 / 图例 */
--row-hover: #fbfcfc;
--bg-2: #fafbfb; /* 比卡片略深一层的面(行内编辑表单、空态框) */
--on-accent: #ffffff; /* 主色底上的文字 */
--ok-bg: #eef8f5; --ok-fg: #17604f; --ok-line: #cfe6e0;
--err-bg: #fdf3f2; --err-fg: #b42318; --err-line: #f3dcda;
--info-bg: #f2f6fd; --info-fg: #2c5282; --info-line: #dbe5f5;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */
--nav-h: 54px;
/* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */
--safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
/* ---------- 顶栏 ---------- */
.nav {
position: sticky; top: 0; z-index: 500;
background: var(--glass);
backdrop-filter: saturate(1.6) blur(10px);
border-bottom: 1px solid var(--line);
/* 横屏时刘海在左右,竖屏时状态栏在顶部 */
padding: var(--safe-t) var(--safe-r) 0 var(--safe-l);
}
.nav-inner {
max-width: 1120px; margin: 0 auto; padding: 0 20px;
height: var(--nav-h); display: flex; align-items: center; gap: 22px;
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); font-size: 15px; }
.brand:hover { text-decoration: none; }
.brand .avatar { font-size: 18px; }
.nav-links { display: flex; gap: 4px; flex: 1; }
.nav-links a {
padding: 5px 11px; border-radius: 7px; color: var(--text-2); font-size: 13.5px; white-space: nowrap;
}
.nav-links a:hover { background: var(--line-soft); color: var(--text); text-decoration: none; }
.nav-links a.on { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.nav-right { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-3); }
/* ---------- 布局 ---------- */
.wrap { max-width: 1120px; margin: 0 auto;
padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); }
.wrap-wide { max-width: 100%; padding: 0; }
.page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 21px; }
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 18px; margin-bottom: 16px;
}
.card > h2 { font-size: 15px; margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
/* .card-body 是数据编辑页折叠用的容器,卡片内容被它包住后不再是 .card 的直接子元素,
所以下面两条用「后代」而不是「子」选择器——否则折叠脚本一加上,间距就全没了。 */
.card .card-sub { color: var(--text-3); font-size: 12.5px; margin-bottom: 14px; }
.card.collapsed .card-body { display: none; }
/* 只有带 data-card 的卡片(数据编辑页)能折叠:标题整行可点,右侧一个箭头 */
.card[data-card] > h2 { cursor: pointer; }
.card[data-card] > h2 .card-toggle {
margin-left: auto; flex: 0 0 auto; cursor: pointer;
background: none; border: 0; padding: 0 2px; color: var(--text-3);
font: inherit; font-size: 12px; line-height: 1;
}
.card[data-card] > h2 .card-toggle:hover { color: var(--text); }
.grid { display: grid; gap: 16px; }
/* grid 子项默认 min-width:auto(= 不小于自身 min-content),而卡片里常常放着
带 min-width 的表格——不加这条,卡片会拒绝收缩、把**整页**顶宽,出现横向滚动条
(/stats、/report 在手机上整页 692px 就是这么来的)。压到 0 之后由卡片内部的
.tw 自己横向滚动。 */
.grid > * { min-width: 0; }
.g2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.g4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 900px) { .g3, .g4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) {
:root { --nav-h: 50px; }
.g2, .g3, .g4 { grid-template-columns: 1fr; }
.wrap { padding: 16px calc(14px + var(--safe-r)) calc(48px + var(--safe-b)) calc(14px + var(--safe-l)); }
.nav-inner { padding: 0 10px; gap: 6px; }
/* 窄屏只留头像,导航可横向滚动,避免换行把布局撑坏 */
.brand .t { display: none; }
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a { padding: 5px 7px; font-size: 13px; }
.nav-right .who { display: none; }
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
}
/* ---------- 窄屏触控与表单(移动端适配) ---------- */
/* 手指的落点比鼠标粗得多:导航链接原本只有 40×31、筛选标签 59×28、小按钮 45×30,
都低于 44×44 的通行下限,最容易点的就是它们。这里只放宽尺寸与内边距,不动结构。 */
@media (max-width: 620px) {
.nav-links a, .btn, .btn.sm, .chip { min-height: 44px; }
.nav-links a { display: inline-flex; align-items: center; padding: 8px 9px; }
.chip { padding: 9px 12px; }
.btn, .btn.sm { padding: 10px 14px; }
.check input { width: 20px; height: 20px; }
/* iOS 聚焦字号小于 16px 的控件时会自动放大整页——用户点完输入框页面突然变大,
还要手动缩回去。窄屏统一提到 16px 规避(桌面端不受影响,保持紧凑)。 */
:root { --field-fs: 16px; }
/* 表格里的控件仍然紧凑:后台那张表列很多,16px 会把列撑得很难用。
表格里横向可滚,iOS 缩放的影响也小。 */
.photo-table, table.tb { --field-fs: 14px; }
}
.only-sm { display: none; }
@media (max-width: 620px) {
.hide-sm { display: none; }
.only-sm { display: inline; }
/* 窄屏用地势与手势缩放,把控件让给底部 */
.map-panel { top: 8px; }
}
/* ---------- 统计数字 ---------- */
/* grid-auto-rows: 1fr 让所有格子等高:否则某一格的值换行时,那一整行都比别行高 */
/* 用 flex + grow 而不是固定列数的网格:KPI 个数常常是 6、7 这种除不尽列数的,
网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。
flex 会让最后一行自己撑满,任何个数都不会有空格。 */
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); }
@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; }
/* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */
.kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.kpi .v small { font-size: 13px; font-weight: 500; color: var(--text-3); margin-left: 3px; }
.kpi .k { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
/* ---------- 图表(纯 CSS/SVG) ---------- */
.bars { display: flex; flex-direction: column; gap: 9px; }
.bar-row { display: grid; grid-template-columns: 128px 1fr 104px; align-items: center; gap: 10px; font-size: 13px; }
/* 整行可点(下钻为筛选):给一点悬停反馈,并让点击区域覆盖整行;
柱条本身只有 9px 高,靠「行」来接收点击才够手指点 */
a.bar-row { margin: 0 -6px; padding: 4px 6px; border-radius: 6px; color: inherit; text-decoration: none; }
a.bar-row:hover { background: var(--line-soft); text-decoration: none; }
a.col { display: block; text-decoration: none; }
a.col:hover { opacity: 1; filter: brightness(1.12); }
.bar-row .nm { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-track { background: var(--line-soft); border-radius: 5px; height: 9px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; background: var(--accent); min-width: 2px; }
.bar-row .vv { text-align: right; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12.5px; white-space: nowrap; }
@media (max-width: 620px) {
.bar-row { grid-template-columns: 84px 1fr 92px; }
/* 图表行在手机上要能点中:整行 ~37px。再往上加(44px)会把「类型分布」这种
60 多行的图撑到两倍高,翻起来更难受,这里取个折中。 */
a.bar-row { padding: 8px 6px; }
}
.cols { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
.cols .col { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; opacity: .85; }
.cols .col:hover { opacity: 1; }
.cols-x { display: flex; gap: 3px; font-size: 10px; color: var(--text-3); margin-top: 5px; }
.cols-x span { flex: 1; text-align: center; overflow: hidden; white-space: nowrap; }
/* ---------- 逐月气温 ---------- */
.temp-chart { margin-top: 16px; }
.temp-head { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
/* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */
.temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
.temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; }
.temp-col .tc-v { font-size: 10.5px; color: var(--text-2); font-variant-numeric: tabular-nums; }
.temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; }
.temp-col .tc-bar { width: 100%; border-radius: 4px 4px 0 0; min-height: 3px; }
.temp-col .tc-m { font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
.temp-col:hover .tc-bar { filter: brightness(1.08); }
.temp-col:hover .tc-v { color: var(--text); }
/* ---------- 日历热力 ---------- */
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
.heat .wk { display: flex; flex-direction: column; gap: 3px; }
.heat .d { width: 11px; height: 11px; border-radius: 2.5px; background: var(--line-soft); }
.heat .d.l1 { background: #cfe6e0; }
.heat .d.l2 { background: #9dcfc3; }
.heat .d.l3 { background: #5fae9c; }
.heat .d.l4 { background: var(--accent); }
.heat-legend { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--text-3); margin-top: 8px; }
.heat-legend .d { width: 11px; height: 11px; border-radius: 2.5px; }
/* ---------- 列表 ---------- */
.list { display: flex; flex-direction: column; }
.item {
display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center;
padding: 11px 2px; border-bottom: 1px solid var(--line-soft);
}
.item:last-child { border-bottom: 0; }
.item .ic {
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
background: var(--line-soft); font-size: 17px;
}
.item .t { font-weight: 500; }
.item .t a { color: var(--text); }
.item .m { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
.item .r { text-align: right; color: var(--text-2); font-size: 12.5px; white-space: nowrap; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 20px;
background: var(--line-soft); color: var(--text-2); font-size: 12.5px; border: 1px solid transparent;
}
.chip.on { background: var(--accent-soft); color: var(--accent); border-color: #cfe6e0; font-weight: 500; }
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-size: 11.5px; }
/* ---------- 表单 ---------- */
label.f { display: block; margin-bottom: 13px; }
label.f > span { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 5px; }
/* 必填标记:字段名后的红色星号,以及表单顶部的统一说明 */
.req { color: #b42318; font-weight: 700; margin-left: 2px; }
.req-tip { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
.req-tip .req { margin-right: 3px; }
/* flatpickr 日期时间选择器:贴合站点风格 */
.flatpickr-calendar { border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.14); font-family: inherit; }
.flatpickr-day.selected, .flatpickr-day.selected:hover { background: var(--accent); border-color: var(--accent); }
.flatpickr-day.today { border-color: var(--accent); }
.flatpickr-day.today:hover { background: var(--accent-soft); color: var(--text); }
.flatpickr-day:hover { background: var(--accent-soft); }
/* 控件字号走变量:窄屏要整体提到 16px(iOS 聚焦小于 16px 会自动放大整页),
而基础选择器 input[type=…], select, textarea 的特异性高于媒体查询里的单选择器,
直接在里面写 font-size 是压不过的——用可继承的自定义属性就没有这个问题。 */
/* 按「排除掉不该套文本框样式的类型」来写,而不是逐个枚举 text/number/date——
枚举漏了 datetime-local(后台编辑页有 160 多个),那些框就一直是浏览器默认外观:
没有圆角、没有内边距、13.33px 的字,跟旁边的输入框明显不是一套。 */
input:not([type=checkbox]):not([type=radio]):not([type=color]):not([type=range]):not([type=file]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=hidden]),
select, textarea {
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px; font: inherit;
font-size: var(--field-fs, inherit);
background: var(--surface); color: var(--text); outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.check { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
.check input { width: 15px; height: 15px; accent-color: var(--accent); }
.nobr { white-space: nowrap; }
.btn {
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 7px;
border: 1px solid var(--line); background: var(--surface); color: var(--text); font: inherit; font-size: 13.5px; cursor: pointer;
}
.btn:hover { background: var(--line-soft); text-decoration: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn.primary:hover { filter: brightness(1.06); }
.btn.sm { padding: 4px 10px; font-size: 12.5px; }
.btn.danger { color: #b42318; border-color: #f0cecb; }
.btn.danger:hover { background: var(--err-bg); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
/* ---------- 表格 ---------- */
table.tb { width: 100%; border-collapse: collapse; font-size: 13px; }
table.tb th {
text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px;
padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
table.tb td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); }
table.tb tr:hover td { background: var(--row-hover); }
table.tb td.num, table.tb th.num { text-align: right; font-variant-numeric: tabular-nums; }
.tw { overflow-x: auto; }
/* 表格要给个内容下限:table 是 width:100%,而 CJK 允许逐字断行,没有下限时窄屏会把
每列压到一两个字宽(「一般公共场所」会排成 6 行、一行一个字)。有了下限,放不下就
交给上面的 .tw 横向滚动,列宽不再被榨干。 */
.tw > table.tb { min-width: 640px; }
/* 从地点详情页跳进来定位到的那一行(?open=):淡色底,长列表里一眼能认出目标。
用底色而不是动画——动画会被 prefers-reduced-motion 跳过,那时就完全看不出来了。 */
table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-soft); }
/* ---------- 地图 ---------- */
#map { height: 100%; width: 100%; background: #eef1f2; }
.map-page {
position: fixed;
inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l);
}
.map-panel {
position: absolute; top: 12px; left: 12px; z-index: 400; width: 286px; max-height: calc(100% - 24px);
overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px);
border: 1px solid var(--line); border-radius: var(--radius); padding: 13px; box-shadow: var(--shadow);
}
.map-panel h3 { font-size: 13px; margin-bottom: 9px; color: var(--text-2); }
.map-legend {
/* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */
position: absolute; bottom: 26px; right: 12px; z-index: 400; background: var(--glass-strong);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px;
max-height: 40%; overflow-y: auto; box-shadow: var(--shadow);
}
.map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); }
.map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; }
.maplink { display: block; padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.maplink b { font-weight: 500; }
.maplink span { color: var(--text-3); font-size: 12px; display: block; }
.dot-marker { border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.16); }
/* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签——
不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */
.fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; }
.fog-opacity .fog-label {
display: block; text-align: left; border: 0; background: none; padding: 0;
font: inherit; font-size: 11px; color: var(--text-2); cursor: default;
}
.fog-opacity .fog-slider { display: block; width: 96px; margin: 4px 0 0; accent-color: var(--accent); }
@media (max-width: 620px) {
.fog-opacity .fog-label { cursor: pointer; font-size: 12px; }
.fog-opacity .fog-slider { display: none; }
.fog-opacity.open .fog-slider { display: block; }
}
/* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */
.to-top {
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: 900;
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line);
background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1;
cursor: pointer; box-shadow: var(--shadow);
}
.to-top[hidden] { display: none; }
.to-top:hover { color: var(--text); border-color: var(--text-3); }
/* 时间轴回放时新出现的点:冒出来 + 淡入(配合 JS 里的逐个延时,像"扫过来")。
点在 Leaflet 里是 overlay pane 里的 <path>,用 transform-box: fill-box 让缩放以自身
中心为原点,才不会跑到左上角去。 */
@media (prefers-reduced-motion: no-preference) {
@keyframes pt-in {
from { opacity: .1; transform: scale(.2); }
55% { opacity: 1; }
to { opacity: .9; transform: scale(1); }
}
.leaflet-overlay-pane path.pt-new {
animation: pt-in .55s cubic-bezier(.22, .9, .28, 1) both;
transform-box: fill-box;
transform-origin: center;
}
/* 聚合圆圈是 DOM 元素、而且 Leaflet 用 transform 给它定位,
所以只能动透明度 —— 再加 scale 会把它从原位挪走。 */
@keyframes pt-in-dom {
from { opacity: .1; }
60% { opacity: 1; }
to { opacity: .92; }
}
.leaflet-marker-pane .marker-cluster.pt-new {
animation: pt-in-dom .5s ease-out both;
}
/* 数字变了轻弹一下:弹内层 div(只放数字),外层带 Leaflet 的定位 transform 不能动 */
@keyframes pt-pulse {
0% { transform: scale(1); }
45% { transform: scale(1.3); }
100% { transform: scale(1); }
}
.leaflet-marker-pane .marker-cluster > div.pt-pulse {
animation: pt-pulse .32s ease-out;
}
}
/* 时间轴回放:地图底部中间的一条。窄屏上抬高一档,给右下角的底图版权让位。
和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */
.timeline-bar {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: 12px; z-index: 650;
display: flex; align-items: center; gap: 10px;
max-width: min(560px, calc(100% - 24px));
padding: 7px 12px; border-radius: 10px;
background: var(--glass-strong); border: 1px solid var(--line); box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.timeline-bar[hidden] { display: none; }
.timeline-bar input[type=range] { flex: 1 1 auto; min-width: 90px; accent-color: var(--accent); margin: 0; }
.timeline-bar .tl-label { flex: 0 0 auto; font-size: 12px; color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.timeline-bar button {
flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface); color: var(--text);
border-radius: 7px; cursor: pointer; font: inherit; font-size: 12px; line-height: 1;
}
.timeline-bar .tl-play { width: 30px; height: 30px; font-size: 13px; padding: 0; }
.timeline-bar .tl-reset { padding: 7px 9px; }
.timeline-bar button:hover { background: var(--line-soft); }
@media (max-width: 620px) {
.timeline-bar { bottom: 26px; gap: 8px; padding: 6px 9px; }
.timeline-bar .tl-label { font-size: 11.5px; }
}
/* 地图上的状态条(加载失败 + 重试)。放底部中间:左上角是筛选面板、右下是图例与版权。 */
.map-status {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: 62px;
z-index: 600; display: flex; align-items: center; gap: 10px;
max-width: calc(100% - 24px); background: rgba(30,40,50,.92); color: #fff;
border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: var(--shadow);
}
.map-status[hidden] { display: none; }
.map-status .btn { border-color: rgba(255,255,255,.35); background: rgba(255,255,255,.12); color: #fff; flex: 0 0 auto; }
.map-status .btn:hover { background: rgba(255,255,255,.22); }
@media (max-width: 620px) {
.map-panel { left: 8px; right: 8px; width: auto; max-height: 44%; top: 8px; }
.map-legend { display: none; }
}
.map-preview { height: 320px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); }
/* ---------- 地点图片 ---------- */
/* 管理面板里的缩略图墙 */
.imgs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.imgs-cell { position: relative; margin: 0; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); background: var(--line-soft); aspect-ratio: 4 / 3; }
.imgs-cell img { width: 100%; height: 100%; object-fit: cover; display: block; cursor: zoom-in; }
.imgs-del {
position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%;
border: 0; background: rgba(0,0,0,.55); color: #fff; font-size: 15px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.imgs-del:hover { background: #b42318; }
.imgs-busy { opacity: .45; pointer-events: none; }
.imgs-hint { font-size: 12.5px; color: var(--text-3); margin-top: 8px; min-height: 18px; }
.imgs-hint.ok { color: #17604f; }
.imgs-hint.err { color: #b42318; }
/* 存储没配好时把「选择图片」变成不可点的样子,而不是留个能点但必失败的按钮 */
.btn.is-disabled { opacity: .5; cursor: not-allowed; }
/* 展示用:全屏画廊。深色底是为了不干扰照片本身,白底会把照片衬灰。
一次只看一张 —— 以前把图竖着堆起来,手机上既不能左右滑也不能放大。 */
.imgs-modal { position: fixed; inset: 0; z-index: 2000; background: rgba(12,14,16,.94); overflow: hidden; }
.imgs-modal[hidden] { display: none; }
.imgs-stage {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
overflow: auto; padding: calc(56px + var(--safe-t)) calc(14px + var(--safe-r)) calc(24px + var(--safe-b)) calc(14px + var(--safe-l));
/* 横滑留给我们做切换,纵向留给浏览器;pinch-zoom 让双指放大仍可用系统手势 */
touch-action: pan-y pinch-zoom;
}
.imgs-stage img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 8px; }
/* 双击放大:图放大到两倍、容器可拖动看局部(用尺寸而不是 transform,放大后不糊) */
.imgs-stage.zoomed { touch-action: pan-x pan-y; align-items: flex-start; justify-content: flex-start; }
.imgs-stage.zoomed img { max-width: none; max-height: none; width: 200%; border-radius: 0; }
.imgs-close {
position: fixed; top: calc(12px + var(--safe-t)); right: calc(14px + var(--safe-r)); z-index: 2001;
width: 44px; height: 44px; border-radius: 50%;
border: 1px solid rgba(255,255,255,.3); background: rgba(0,0,0,.4); color: #fff; font-size: 22px;
line-height: 1; cursor: pointer;
}
.imgs-close:hover { background: rgba(255,255,255,.18); }
.imgs-count {
position: fixed; top: calc(20px + var(--safe-t)); left: calc(18px + var(--safe-l)); z-index: 2001;
color: rgba(255,255,255,.78); font-size: 13px;
}
/* 上一张 / 下一张:做成整条竖带,拇指够得着,不用瞄小按钮 */
.imgs-nav {
position: fixed; top: 50%; transform: translateY(-50%); z-index: 2001;
width: 44px; height: 76px; border: 0; border-radius: 10px; cursor: pointer;
background: rgba(255,255,255,.12); color: #fff; font-size: 30px; line-height: 1;
}
.imgs-nav:hover { background: rgba(255,255,255,.24); }
.imgs-prev { left: calc(6px + var(--safe-l)); }
.imgs-next { right: calc(6px + var(--safe-r)); }
/* ---------- 「停一下」提示页(内容对访客不开放之类) ---------- */
.notice-body {
min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center;
padding: 24px 18px; background: var(--bg);
}
.notice-card {
max-width: 420px; text-align: center; background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 30px 26px; box-shadow: var(--shadow);
}
.notice-card .notice-ico { font-size: 40px; line-height: 1; margin-bottom: 12px; }
.notice-card h1 { font-size: 18px; margin: 0 0 8px; }
.notice-card p { color: var(--text-2); font-size: 13.5px; margin: 0 0 18px; }
.notice-card .notice-tip { color: var(--text-3); font-size: 12px; margin-top: 12px; }
/* 后台设置页的「标签 / 值」信息行(地图底图、图片存储状态)。
这个 class 一直没写样式,之前是两段光秃秃的 inline 文本;长一点的 URL
(比如对象存储的 endpoint)会把整页撑宽 —— 所以要 minmax(0,1fr) + 允许断词。 */
.kv { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 4px 12px; font-size: 13px; }
.kv .k { color: var(--text-3); }
.kv .v { color: var(--text); overflow-wrap: anywhere; }
@media (max-width: 620px) {
.kv { grid-template-columns: 1fr; gap: 2px; }
.kv .k { font-size: 12px; }
}
/* URL 这类超长 token 不该把容器顶宽 */
.mono { overflow-wrap: anywhere; }
/* ---------- 暗色模式 ----------
跟随系统(prefers-color-scheme)。配色本来就是变量驱动的,所以这里主要换一套变量,
再覆盖几处硬编码的白色面。地图瓦片保持浅色——上游只有这一套免密钥瓦片,
所以地图页只暗掉面板与控件,瓦片不动。
色值是算过的:主文字 13.6:1、次要 8.0:1、弱化 5.4:1(都在 AA 之上)。 */
@media (prefers-color-scheme: dark) {
/* 用 html:root 而不是 :root:模板会在 CSS 之后注入一条 :root{--accent:…}(站长自定义主色),
同特异性下后者胜出,所以这里必须高一级才盖得住。 */
html:root {
/* 站长选的主色原值在深底上往往太暗(深绿只有 3.3:1),按比例掺白提亮 */
--accent: color-mix(in srgb, var(--accent-base, #2f7d6e) 58%, #ffffff);
--accent-soft: color-mix(in srgb, var(--accent-base, #2f7d6e) 24%, #1c2024);
--bg: #14171a;
--panel: #1c2024;
--line: #2b3137;
--line-soft: #23272c;
--text: #e8eaed;
--text-2: #b0b6bd;
--text-3: #99a0a8;
--surface: #22262b;
--glass: rgba(28, 32, 36, .92);
--glass-strong: rgba(28, 32, 36, .96);
--row-hover: #212529;
--bg-2: #191d21;
/* 浅绿底上用白字只有 2.4:1,反过来用深字才够 */
--on-accent: #0f1b18;
--ok-bg: #16302a; --ok-fg: #7fd3bb; --ok-line: #245046;
--err-bg: #33201e; --err-fg: #f0a49b; --err-line: #5a3330;
--info-bg: #1b2534; --info-fg: #9dbdea; --info-line: #2f4055;
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .35);
}
/* 兜底:还有两处没走变量的白底 */
.flatpickr-calendar { background: var(--panel); border-color: var(--line); box-shadow: var(--shadow); }
.flatpickr-day { color: var(--text); }
.flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay { color: var(--text-3); }
.flatpickr-day:hover { background: var(--line-soft); color: var(--text); }
.flatpickr-months .flatpickr-month, .flatpickr-weekdays, .flatpickr-weekday { color: var(--text-2); fill: var(--text-2); background: transparent; }
.flatpickr-current-month .flatpickr-monthDropdown-months { background: var(--panel); color: var(--text); }
.flatpickr-time input, .flatpickr-time .flatpickr-am-pm { color: var(--text); }
/* Leaflet 自带的控件是白底,暗色下要跟着换,否则浅字压浅底看不清。
注意 leaflet.css 里角标的背景写的是 `.leaflet-container .leaflet-control-attribution`
(两级),所以这里必须带上 .leaflet-container 前缀,否则盖不住。 */
.leaflet-container .leaflet-bar a,
.leaflet-container .leaflet-bar a:hover,
.leaflet-container .leaflet-control-layers,
.leaflet-container .leaflet-control-layers-expanded,
.leaflet-container .leaflet-control-attribution,
.fog-opacity {
background: var(--surface); color: var(--text);
}
.leaflet-container .leaflet-bar a { border-bottom-color: var(--line); }
.leaflet-container .leaflet-bar a:hover { background: var(--line-soft); }
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-attribution a { color: var(--text-2); }
.leaflet-control-scale-line {
background: rgba(28, 32, 36, .7); border-color: var(--text-3); color: var(--text-2);
}
.leaflet-container { background: #101315; }
/* 地图上的浅色瓦片在暗色 UI 里反差过大,整体压一点点亮度 */
#map .leaflet-tile-pane { filter: brightness(.86) saturate(.9); }
}
/* ---------- 键盘焦点 ----------
全站原来没有任何 :focus-visible 规则:用 Tab 走查时看不出焦点落在哪,
键盘用户(以及用读屏的)基本没法用。只在键盘操作时出现,鼠标点击不会看到这个圈。 */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:focus-visible, .chip:focus-visible, .vote-btn:focus-visible,
.nav-links a:focus-visible, .pager a:focus-visible, .imgs-nav:focus-visible, .to-top:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
/* 输入框本来就有 focus 描边,这里只补键盘态的对比度 */
input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px;
}
/* ---------- 顶部加载进度条 ---------- */
.load-bar {
position: fixed; top: 0; left: 0; height: 2px; width: 0; z-index: 3000;
background: var(--accent); opacity: 0;
transition: width .18s ease-out, opacity .26s ease;
}
.load-bar[hidden] { display: none; }
/* ---------- 「已选条件」行 ---------- */
.active-filters { margin-bottom: 12px; align-items: center; }
.active-filters .af-label { font-size: 12.5px; color: var(--text-3); }
.active-filters .chip { user-select: none; }
.active-filters .af-x { margin-left: 4px; opacity: .6; font-size: 13px; }
.active-filters .chip:hover .af-x { opacity: 1; }
.active-filters .af-clear { background: transparent; color: var(--text-3); border-color: var(--line); }
/* ---------- 其他 ---------- */
.alert { padding: 9px 13px; border-radius: 8px; font-size: 13px; margin-bottom: 14px; }
.alert.err { background: var(--err-bg); color: var(--err-fg); border: 1px solid var(--err-line); }
.alert.ok { background: var(--ok-bg); color: var(--ok-fg); border: 1px solid var(--ok-line); }
.alert.info { background: var(--info-bg); color: var(--info-fg); border: 1px solid var(--info-line); }
.muted { color: var(--text-3); }
.mono { font-family: var(--mono); font-size: 12px; }
.empty { text-align: center; padding: 40px 16px; color: var(--text-3); }
.empty .big { font-size: 30px; margin-bottom: 8px; }
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; font-size: 13px; }
.pager a, .pager span { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); color: var(--text-2); }
.pager a:hover { background: var(--line-soft); text-decoration: none; }
.pager .cur { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; }
.timeline-day { margin-bottom: 20px; }
.timeline-day .dh {
position: sticky; top: 54px; background: var(--bg); z-index: 5; padding: 6px 0 7px;
font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
}
.timeline-day .dh .muted { font-size: 12px; }
.tl-item { display: grid; grid-template-columns: 62px 20px 1fr; gap: 10px; align-items: start; padding: 7px 0; }
.tl-item .tm { color: var(--text-3); font-size: 12.5px; font-variant-numeric: tabular-nums; padding-top: 1px; }
.tl-item .ln { display: flex; justify-content: center; position: relative; }
.tl-item .ln::before { content: ""; position: absolute; top: 12px; bottom: -12px; width: 1px; background: var(--line); }
.tl-item:last-child .ln::before { display: none; }
.tl-item .ln i { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); margin-top: 5px; position: relative; z-index: 1; }
.tl-item .bd { font-size: 13.5px; }
.tl-item .bd .meta { color: var(--text-3); font-size: 12.5px; }
/* 聚合气泡改用主题色,默认的黄蓝配色和站点风格不一致 */
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large { background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div { background-color: var(--accent); color: var(--on-accent); font-weight: 500; }
/* 筛选面板占据左上角,把缩放控件挪到右上与图层切换并列 */
.map-panel { left: 12px; }
@media (max-width: 620px) { .map-panel { left: 8px; right: 8px; } }
/* 更多筛选类型 */
.more-types { margin: 2px 0 10px; }
.more-types summary { font-size: 12.5px; color: var(--text-3); cursor: pointer; padding: 3px 0; }
.more-types summary:hover { color: var(--accent); }
.more-types .chips { margin-top: 8px; }
@media (max-width: 620px) {
.leaflet-bottom.leaflet-right .leaflet-control-zoom { margin-bottom: 34px; }
}
/* 推荐 / 踩雷 表态按钮 */
.vote-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.vote-label { color: var(--text-3); font-size: 12.5px; min-width: 62px; }
/* 「推荐 / 踩雷」必须并排:把它们包成不可换行的组,窄屏时只让后面那句提示换行 */
.vote-btns { display: inline-flex; gap: 8px; flex-wrap: nowrap; }
.vote-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 999px; white-space: nowrap; flex: 0 0 auto;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 13.5px; font-weight: 500; cursor: pointer; line-height: 1;
transition: background .12s, border-color .12s, color .12s;
}
.vote-btn:hover { border-color: var(--accent); color: var(--accent); }
.vote-btn.up.on { background: #e7f6ec; border-color: #34c759; color: #1f8f43; }
.vote-btn.down.on { background: #fdeceb; border-color: #ff3b30; color: #c62a20; }
@media (prefers-color-scheme: dark) {
/* 浅底深字在深色页里太扎眼,换成深底亮字(对比度都在 AA 之上) */
.vote-btn.up.on { background: #16302a; border-color: #34c759; color: #7fd3bb; }
.vote-btn.down.on { background: #33201e; border-color: #ff3b30; color: #f0a49b; }
.vote-tip.up { color: #7fd3bb; }
.vote-tip.down { color: #f0a49b; }
/* 深红字在深底上只有 2.3:1,换成亮红 */
.btn.danger { color: var(--err-fg); border-color: var(--err-line); }
}
.vote-btn:disabled { opacity: .55; cursor: default; }
.vote-tip { font-size: 12.5px; color: var(--text-3); }
.vote-tip.up { color: #1f8f43; }
.vote-tip.down { color: #c62a20; }
/* 站长页面上访客票数只读展示:沿用「我的结论」同一套行结构,尺寸/行距完全一致,只弱化配色 */
.vote-btn.ro {
cursor: default; color: var(--text-2);
border-style: dashed; border-color: var(--line); background: transparent;
}
.vote-btn.ro:hover { border-color: var(--line); color: var(--text-2); }
/* 列表 / 气泡里的推荐踩雷小标签 */
/* 坐标疑似没做过 GCJ-02 偏移的提醒标 */
.tag.warn { background: #fdf3e3; color: #a15c00; }
.tag.vote-up { background: #e7f6ec; color: #1f8f43; }
.tag.vote-down { background: #fdeceb; color: #c62a20; }
/* 地图气泡:地点信息 + 只读备注 + 就地表决 */
.mp { min-width: 240px; max-width: 300px; }
.mp-n { font-weight: 600; margin-bottom: 2px; }
.mp-m { color: var(--text-3); font-size: 12px; line-height: 1.6; }
.mp-note {
margin-top: 6px; padding: 6px 8px; border-radius: 6px;
background: var(--bg, #f5f6f8); color: var(--text);
font-size: 12.5px; line-height: 1.55; white-space: pre-line;
max-height: 150px; overflow-y: auto;
}
.mp-foot { margin-top: 7px; font-size: 12.5px; }
/* nowrap:气泡本来就窄,「👎 踩雷」被挤到第二行会很难看 */
.mp-vote { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: nowrap; }
.mp-vl { flex: 0 0 auto; color: var(--text-3); font-size: 12px; white-space: nowrap; }
.mp-vb {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 12.5px; font-weight: 500; line-height: 1; cursor: pointer;
flex: 0 0 auto; white-space: nowrap;
transition: background .12s, border-color .12s, color .12s;
}
.mp-vb b { font-weight: 600; }
.mp-vb:hover { border-color: var(--accent); color: var(--accent); }
.mp-vb.up.on { background: #e7f6ec; border-color: #34c759; color: #1f8f43; }
.mp-vb.down.on { background: #fdeceb; border-color: #ff3b30; color: #c62a20; }
.mp-vb:disabled { opacity: .55; cursor: default; }
/* 备注块:rond 回填的备注是按次一行的,必须保留换行 */
.note-block {
margin-top: 4px; padding: 7px 9px; border-radius: 8px;
background: var(--bg, #f5f6f8); color: var(--text-2);
font-size: 12.8px; line-height: 1.65; white-space: pre-line;
}
/* 报告导出为 PDF / 长图:隐藏导航、页脚与操作按钮,卡片避免跨页断裂 */
@media print {
.nav, .ft, .no-print { display: none !important; }
body { background: #fff; }
.wrap { max-width: 820px; margin: 0 auto; padding: 0 12px; }
.page-head h1 { margin-top: 8px; }
.card, .kpi, .kpis { break-inside: avoid; }
.grid.g2 { grid-template-columns: 1fr 1fr; }
a { color: inherit; text-decoration: none; }
.bar-fill { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.chip.on { background: var(--accent-soft); color: var(--accent); }
}
/* 数据编辑页:行内编辑用 details 折叠,summary 复用 .btn 外观 */
.inline-edit { display: inline-block; position: relative; }
.inline-edit summary { list-style: none; display: inline-block; }
.inline-edit summary::-webkit-details-marker { display: none; }
.inline-edit[open] { display: block; }
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
.inline-edit form .f { margin-bottom: 8px; }
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
* 不能直接复用 .row —— label.f 自带 margin-bottom,flex-end 按外边距盒对齐会把它顶高;
* 且 .row 会换行。这里显式 nowrap + 归零外边距,并让输入框可收缩。 */
.search-row { display: flex; flex-wrap: nowrap; align-items: flex-end; gap: 8px; margin-bottom: 12px; }
.search-row > label.f { flex: 1 1 auto; min-width: 0; margin-bottom: 0; }
.search-row .btn { flex: 0 0 auto; font-size: 14px; }
.search-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
/* 按名称搜坐标的候选列表:整行可点,名称与地址分两行 */
.geo-list { display: flex; flex-direction: column; gap: 4px; margin: 0 0 12px; }
/* 到访选择器的候选列表有 max-height(200~240px),但 flex 子项不会因为上限而收缩、
overflow 又是 visible —— 30 条候选会实打实溢出上千像素,被后面的表单元素盖住。
必须给个 overflow 让它在自己的框里滚。 */
.picker-results { overflow-y: auto; }
.geo-list:empty { margin: 0; }
/* 行程列表的路线列:窄屏下每列只剩几十像素,城市名会把行高撑到到访列表的近两倍,
藏掉它(编号 + 地点名够认了,城市在紧邻的到访列表里就有),title 里仍留着全称。 */
@media (max-width: 620px) { .mv-city { display: none; } }
/* 筛选行:和 .row 一样窄屏会换行,但控件按底边对齐。
不能直接用 .row + align-items:flex-end——label.f 自带 margin-bottom,
flex-end 按外边距盒对齐会把按钮顶低几像素(就是那种「按钮有点歪」)。 */
.filter-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; }
.filter-row > label.f { margin-bottom: 0; }
.filter-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
.geo-item {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--panel); color: var(--text); font: inherit;
}
.geo-item:hover { background: var(--line-soft); }
.geo-name { display: block; font-size: 13.5px; }
.geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; }
/* 「同时创建到访」折叠区 */
.opt-visit { margin: 0 0 12px; }
.opt-visit > summary { cursor: pointer; color: var(--text-3); font-size: 12.5px; margin-bottom: 8px; }
.opt-visit .row { margin-bottom: 8px; }
/* ---------- 专题 ---------- */
/* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器,
单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */
#map.topic-map { height: 520px; width: 100%; border-radius: 10px; overflow: hidden; }
@media (max-width: 620px) { #map.topic-map { height: 360px; } }
.topic-card { display: block; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
.topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); }
.topic-head { display: flex; align-items: center; gap: 8px; }
.topic-head h2 { margin: 0; }
.topic-card .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 620px) { .topic-card .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */
.map-fallback {
display: flex; align-items: center; justify-content: center; height: 100%;
padding: 20px; text-align: center; color: var(--text-3); font-size: 13px;
}
/* 专题:时间范围独立成行(精确到分钟,塞进 KPI 网格会被挤成多行) */
.topic-range { margin-top: 5px; color: var(--text-2); font-size: 13.5px; font-variant-numeric: tabular-nums; }
.topic-card .topic-range { margin: 7px 0 0; }
.topic-desc { color: var(--text-2); white-space: pre-wrap; line-height: 1.65; }
.kpis.kpis-3 .kpi { flex: 1 1 30%; }
@media (max-width: 620px) {
/* 三个数字都很短,窄屏保持三列比 2+1 更好看 */
.topic-card .kpis.kpis-3 .kpi { flex: 1 1 30%; }
.topic-card .kpi { padding: 11px 12px; }
.topic-range { font-size: 12.5px; }
}
/* ---------- 表单里的「文字 + 内联按钮」组合 ---------- */
/* label 的文字行右侧放次要操作(活动改名/删除),不额外占高度,也就不会把整格撑高 */
label.f > span.span-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.mini-actions { display: flex; gap: 8px; font-size: 12px; }
.mini-actions a { color: var(--text-3); }
.mini-actions a:hover { color: var(--accent); }
.mini-actions a.danger:hover { color: #b42318; }
/* 输入框 + 按钮同一行:input 自适应、按钮不压缩(不设 min-width:0 的话 input 会被按钮顶到下一行) */
.input-btn { display: flex; gap: 6px; }
.input-btn > input { flex: 1 1 auto; min-width: 0; }
.input-btn > .btn { flex: 0 0 auto; padding-left: 12px; padding-right: 12px; }
/* 到访列表里的收藏星标:点一下就切换,不用展开行内编辑 */
.star {
background: none; border: 0; padding: 0 3px; cursor: pointer; line-height: 1;
font-size: 15px; color: var(--text-3); vertical-align: -1px;
}
.star:hover { color: #e8b700; }
.star.on { color: #e8b700; }
/* 单个点位的地图弹窗 */
#point-modal {
position: fixed; inset: 0; z-index: 1200; display: flex; align-items: flex-start;
justify-content: center; padding: 40px 16px; overflow: auto;
background: rgba(0, 0, 0, .45);
}
.point-modal-box {
width: 100%; max-width: 660px; background: var(--panel); border-radius: 12px;
padding: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
}
.point-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.point-modal-map { height: 360px; border-radius: 10px; overflow: hidden; }
@media (max-width: 620px) {
#point-modal { padding: 16px 10px; }
.point-modal-map { height: 260px; }
}
/* 表格里的行内操作链接不许折行:中文默认能在任意字间断开,
「地图」「编辑」这类两字链接在窄列里会被拆成两行 */
.tb a { white-space: nowrap; }
/* 照片导入表:固定列宽比例,输入框跟着列宽走。
否则每个 input 自带 min-width,8 列加起来必然超出视口,最右边的「说明」列要横向拖动才看得到 */
.photo-table { table-layout: fixed; width: 100%; min-width: 880px; }
.photo-table th, .photo-table td { padding: 7px 8px; vertical-align: top; }
.photo-table input, .photo-table select { width: 100%; min-width: 0; box-sizing: border-box; }
.photo-table input[type=datetime-local] { font-size: 12.5px; padding: 6px 6px; }
.photo-table input[type=checkbox] { width: auto; }
.photo-table .chip { white-space: nowrap; }
/* 窄屏:9 列在手机上放不下,把信息量最低的「文件」列去掉(地点名已经能认出是哪张),
同时把最小宽度调小,需要横向滑动时也能更快看到最右边的操作 */
@media (max-width: 700px) {
.photo-table { min-width: 640px; }
.photo-table col:nth-child(3),
.photo-table thead th:nth-child(3),
.photo-table tbody tr td:nth-child(3) { display: none; }
}
/* 照片导入:确认表上方的总览图。
做成「粘」的是为了让「鼠标划到某一行 → 图上那个点跳出来」始终看得见——
表格几十行,图一滚出屏幕这个联动就白搭了。粘住时必须自带不透明底,
否则下面滚过去的行会透上来。 */
#map-card {
position: sticky; top: calc(var(--nav-h) + 8px); z-index: 5;
background: var(--panel); padding: 2px 0 10px; margin-bottom: 2px;
border-bottom: 1px solid var(--line-soft);
}
.photo-map {
height: 200px; width: 100%; border-radius: 10px; overflow: hidden;
border: 1px solid var(--line); transition: height .18s ease;
}
/* 粘住以后收矮:200px 的浮层会挡住从下面滚过去的半个屏幕,收到 132px 还能看清
高亮出来的那个点,占的高度也只剩一条。说明文字这时候就没必要留着了。 */
#map-card.stuck .photo-map { height: 132px; }
#map-card.stuck .card-sub { display: none; }
#map-card.stuck { box-shadow: 0 6px 14px rgba(16, 24, 40, .07); }
@media (max-width: 700px) {
.photo-map { height: 170px; }
#map-card.stuck .photo-map { height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.photo-map { transition: none; }
}
/* 照片导入的进度/结果提示:脚本直接往元素里写文本,空的时候自己收起 */
#parse-status { margin: 12px 0 0; }
#parse-status:empty { display: none; }
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#2f7d6e"/>
<path d="M16 6c-4.4 0-8 3.6-8 8 0 5.6 8 12 8 12s8-6.4 8-12c0-4.4-3.6-8-8-8z" fill="#fff"/>
<circle cx="16" cy="14" r="3.2" fill="#2f7d6e"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+278
View File
@@ -0,0 +1,278 @@
// 浏览器端读取照片的 EXIF(拍摄时间 + GPS),只读文件头附近的一小段,
// 不把整张照片传到服务器——几十张手机照片动辄几百 MB,上传既慢又占磁盘。
//
// 支持的容器:JPEG(APP1 段)与 HEIC/HEIF(ISO BMFF 的 meta box)。
// 算法与 internal/exif 的 Go 实现一致,但这里必须分片读取:
// JPEG 的 APP1 在最前面,通常 < 64KB;
// HEIC 的 Exif item 位置由 iloc 指定(绝对偏移),可能落在文件靠后,
// 所以先读 64KB 解析出偏移,必要时再按需读那一小段。
(function () {
'use strict';
var HEAD_BYTES = 64 * 1024;
function readRange(file, start, end) {
var blob = file.slice(start, Math.min(end, file.size));
return blob.arrayBuffer().then(function (buf) {
return new Uint8Array(buf);
});
}
function str(u8, off, len) {
var s = '';
for (var i = 0; i < len && off + i < u8.length; i++) s += String.fromCharCode(u8[off + i]);
return s;
}
// ---------- TIFF(JPEG 与 HEIC 共用,HEIC 里是 Exif item 的负载) ----------
function readIFD(dv, u8, base, off, le) {
var out = {};
if (off < 0 || base + off + 2 > u8.length) return out;
var n = dv.getUint16(base + off, le);
var p0 = base + off + 2;
if (p0 + n * 12 > u8.length) n = Math.max(0, Math.floor((u8.length - p0) / 12));
for (var i = 0; i < n; i++) {
var p = p0 + i * 12;
out[dv.getUint16(p, le)] = {
type: dv.getUint16(p + 2, le),
count: dv.getUint32(p + 4, le),
valOff: p + 8
};
}
return out;
}
function typeSize(t) {
switch (t) {
case 1: case 2: case 6: case 7: return 1;
case 3: case 8: return 2;
case 4: case 9: case 11: return 4;
case 5: case 10: case 12: return 8;
}
return 1;
}
// ascii 读 ASCII 值:总长 ≤ 4 字节时内联在条目里,否则 valOff 是偏移
function ascii(dv, u8, base, e, le) {
var total = e.count;
if (total <= 0) return '';
var start;
if (total <= 4) {
start = e.valOff;
} else {
start = base + dv.getUint32(e.valOff, le);
}
var out = '';
for (var i = 0; i < total && start + i < u8.length; i++) {
var c = u8[start + i];
if (c === 0) break;
out += String.fromCharCode(c);
}
return out;
}
function rationals(dv, u8, base, e, le, n) {
var off = base + dv.getUint32(e.valOff, le);
if (off < 0 || off + n * 8 > u8.length) return null;
var out = [];
for (var i = 0; i < n; i++) {
var num = dv.getUint32(off + i * 8, le);
var den = dv.getUint32(off + i * 8 + 4, le);
if (den === 0) return null;
out.push(num / den);
}
return out;
}
// gpsCoord 把「度分秒 + 半球」拼成十进制度
function gpsCoord(dv, u8, base, le, gps, refTag, valTag) {
var refE = gps[refTag], valE = gps[valTag];
if (!refE || !valE) return null;
var parts = rationals(dv, u8, base, valE, le, 3);
if (!parts) return null;
var v = parts[0] + parts[1] / 60 + parts[2] / 3600;
var ref = ascii(dv, u8, base, refE, le).toUpperCase();
if (ref === 'S' || ref === 'W') v = -v;
return v;
}
function parseTIFF(u8, base) {
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
if (base + 8 > u8.length) throw new Error('EXIF 数据不完整');
var mark = str(u8, base, 2);
if (mark !== 'II' && mark !== 'MM') throw new Error('EXIF 标识不对');
var le = mark === 'II';
if (dv.getUint16(base + 2, le) !== 0x002A) throw new Error('EXIF 标识不对');
var res = { hasGPS: false, hasTime: false, lat: 0, lon: 0, shot: null };
var ifd0 = readIFD(dv, u8, base, dv.getUint32(base + 4, le), le);
if (ifd0[0x8769]) {
var sub = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8769].valOff, le), le);
if (sub[0x9003]) {
var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim());
if (m) {
res.shot = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
res.hasTime = true;
}
}
}
if (ifd0[0x8825]) {
var gps = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8825].valOff, le), le);
var la = gpsCoord(dv, u8, base, le, gps, 1, 2);
var lo = gpsCoord(dv, u8, base, le, gps, 3, 4);
if (la !== null && lo !== null && (la !== 0 || lo !== 0)) {
res.lat = la; res.lon = lo; res.hasGPS = true;
}
}
return res;
}
// ---------- JPEG ----------
function parseJPEG(u8) {
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
var i = 2;
while (i + 4 <= u8.length) {
if (u8[i] !== 0xFF) { i++; continue; }
var marker = u8[i + 1];
if (marker === 0xD8 || marker === 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { i += 2; continue; }
if (marker === 0xDA || marker === 0xD9) break; // 图像数据开始
var size = (u8[i + 2] << 8) | u8[i + 3];
if (size < 2 || i + 2 + size > u8.length) break;
if (marker === 0xE1 && size > 8 && str(u8, i + 4, 6) === 'Exif\x00\x00') {
return parseTIFF(u8, i + 10);
}
i += 2 + size;
}
throw new Error('这张 JPEG 里没有 EXIF 信息');
}
// ---------- HEIC / HEIF ----------
function boxHeader(dv, u8, off) {
if (off + 8 > u8.length) return null;
var size = dv.getUint32(off);
var hdr = 8;
if (size === 1) {
if (off + 16 > u8.length) return null;
size = Number(dv.getBigUint64(off + 8));
hdr = 16;
} else if (size === 0) {
size = u8.length - off;
}
if (size < hdr) return null;
// next 允许超出已读范围:大文件的 meta 往往只读到头部,后面的数据按需再取
return { typ: str(u8, off + 4, 4), payload: off + hdr, next: off + size };
}
function findExifItemID(dv, u8, start, end) {
if (start + 4 > u8.length) return 0;
var ver = u8[start];
var p = start + 4;
var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p);
p += ver === 0 ? 2 : 4;
for (var i = 0; i < count; i++) {
var b = boxHeader(dv, u8, p);
if (!b) break;
if (b.typ === 'infe' && b.payload < u8.length && u8[b.payload] >= 2 && b.payload + 12 <= u8.length) {
if (str(u8, b.payload + 8, 4) === 'Exif') return dv.getUint16(b.payload + 4);
}
p = b.next;
}
return 0;
}
function findItemExtent(dv, u8, start, wantID) {
if (start + 8 > u8.length) return null;
var ver = u8[start];
var p = start + 4;
var offsetSize = u8[p] >> 4, lengthSize = u8[p] & 0x0F;
p++;
var baseSize = u8[p] >> 4, indexSize = ver >= 1 ? (u8[p] & 0x0F) : 0;
p++;
if (!offsetSize || !lengthSize) return null;
var count = dv.getUint16(p);
p += 2;
function readN(n) {
if (n < 0 || p + n > u8.length) return null;
var v = 0;
for (var i = 0; i < n; i++) v = v * 256 + u8[p + i];
p += n;
return v;
}
for (var i = 0; i < count; i++) {
var id = ver === 0 ? readN(2) : readN(4);
if (id === null) return null;
if (ver >= 1 && readN(2) === null) return null;
if (readN(2) === null) return null; // data_reference_index
var base = readN(baseSize);
if (base === null) return null;
var extents = readN(2);
if (extents === null) return null;
for (var e = 0; e < extents; e++) {
if (ver >= 1 && indexSize > 0 && readN(indexSize) === null) return null;
var off = readN(offsetSize), len = readN(lengthSize);
if (off === null || len === null) return null;
if (id === wantID) return { offset: base + off, length: len };
}
}
return null;
}
function parseHEIF(file, u8) {
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
var meta = null;
for (var off = 0; off + 8 <= u8.length;) {
var b = boxHeader(dv, u8, off);
if (!b) break;
if (b.typ === 'meta') { meta = b; break; }
off = b.next;
}
if (!meta) throw new Error('HEIC 文件里没有找到 meta');
var start = meta.payload + 4; // meta 是 FullBox
var iinf = null, iloc = null;
for (var o = start; o + 8 <= u8.length && o < meta.next;) {
var sb = boxHeader(dv, u8, o);
if (!sb) break;
if (sb.typ === 'iinf') iinf = sb;
if (sb.typ === 'iloc') iloc = sb;
o = sb.next;
}
if (!iinf || !iloc) throw new Error('HEIC 里缺少 iinf/iloc');
var itemID = findExifItemID(dv, u8, iinf.payload, iinf.next);
if (!itemID) throw new Error('这张 HEIC 里没有 EXIF');
var ext = findItemExtent(dv, u8, iloc.payload, itemID);
if (!ext || ext.length <= 8) throw new Error('HEIC 的 EXIF 位置无效');
function finish(bytes) {
var tiff = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
if (tiff + 8 > bytes.length) tiff = 4;
return parseTIFF(bytes, tiff);
}
// Exif item 已经读进来了就直接解析,否则按偏移再取那一小段
if (ext.offset >= 0 && ext.offset + ext.length <= u8.length) {
return finish(u8.subarray(ext.offset, ext.offset + ext.length));
}
return readRange(file, ext.offset, ext.offset + ext.length).then(finish);
}
// ---------- 对外入口 ----------
function read(file) {
return readRange(file, 0, HEAD_BYTES).then(function (head) {
if (head.length >= 2 && head[0] === 0xFF && head[1] === 0xD8) {
return Promise.resolve(parseJPEG(head));
}
if (head.length >= 12 && str(head, 4, 4) === 'ftyp') {
return parseHEIF(file, head);
}
return Promise.reject(new Error('不认识的图片格式(支持 JPEG 与 HEIC/HEIF)'));
});
}
window.rondExif = { read: read };
})();
+119
View File
@@ -0,0 +1,119 @@
/* 地点图片:在浏览器里转成 WebP 再上传。
*
* 为什么放在前端转:1 核的服务器解一张 4000px 的原图要好几秒,几张就够卡住整站;
* 而用户自己的手机/电脑转一张是几十毫秒的事。服务端只负责把收到的 WebP 字节原样写出去。
*
* 尺寸与质量策略(在真机照片上量过才定的):
* 长边 1440px,质量 0.72 → 超 220KB 再试 0.58 → 还超就把整张缩到 1080px 重编。
* 关键在于「体积大头是像素数,不是质量」:实测一张 13MB 的城市照片,
* 1600px 从 q0.74 一路降到 q0.5 只从 483KB 掉到 391KB(掉 19%,画面却明显糊了),
* 而同样 q0.72 把长边从 1600 缩到 1024 直接掉到 217KB(掉 55%)。
* 所以阶梯走到底还超标时,宁可缩尺寸也不再压质量。
* 这几张图只用于详情页画廊与灯箱(最大显示到一千出头的宽度),1440px 留了余量。
*/
(function () {
var MAX_EDGE = 1440;
var QUALITY_LADDER = [0.72, 0.58];
var TARGET_BYTES = 220 * 1024;
// 兜底档:纹理特别密的照片在 1440px 下怎么降质都超标时才用
var FALLBACK_EDGE = 1080;
var FALLBACK_QUALITY = 0.7;
function loadImage(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('这张图片读不出来')); };
img.src = url;
});
}
function encode(cv, quality) {
return new Promise(function (resolve, reject) {
cv.toBlob(function (blob) {
// 不支持 WebP 编码的浏览器会静默给回 PNG,这里必须显式挡住——
// 否则上传的是 PNG,服务端会以「只接受 WebP」拒掉,用户却看不出原因
if (!blob || blob.type !== 'image/webp') {
reject(new Error('这个浏览器不支持 WebP 转码,请换新版 Chrome / Edge / Safari'));
return;
}
resolve(blob);
}, 'image/webp', quality);
});
}
// render 把原图按长边上限放到一张画布上,返回画布与落地的宽高
function render(img, limit) {
var w = img.naturalWidth || img.width || 1;
var h = img.naturalHeight || img.height || 1;
var scale = Math.min(1, limit / Math.max(w, h));
var tw = Math.max(1, Math.round(w * scale));
var th = Math.max(1, Math.round(h * scale));
var cv = document.createElement('canvas');
cv.width = tw;
cv.height = th;
var g = cv.getContext('2d');
if (g.imageSmoothingQuality !== undefined) g.imageSmoothingQuality = 'high';
// 白底:源图带透明通道时(截图、PNG)不会在 WebP 里变成黑块
g.fillStyle = '#fff';
g.fillRect(0, 0, tw, th);
g.drawImage(img, 0, 0, tw, th);
return { cv: cv, width: tw, height: th };
}
// toWebP(file) -> Promise<{blob, width, height, quality, bytes}>
function toWebP(file, opts) {
opts = opts || {};
var maxEdge = opts.maxEdge || MAX_EDGE;
var target = opts.targetBytes || TARGET_BYTES;
var ladder = opts.ladder || QUALITY_LADDER;
var fallbackEdge = opts.fallbackEdge === undefined ? FALLBACK_EDGE : opts.fallbackEdge;
var fallbackQuality = opts.fallbackQuality || FALLBACK_QUALITY;
return loadImage(file).then(function (img) {
var box = render(img, maxEdge);
function attempt(i) {
var q = ladder[i];
return encode(box.cv, q).then(function (blob) {
var out = { blob: blob, width: box.width, height: box.height, quality: q, bytes: blob.size };
if (out.bytes <= target || i === ladder.length - 1) return out;
return attempt(i + 1);
});
}
return attempt(0).then(function (out) {
// 阶梯走到底还超标 ⇒ 这张图纹理特别密,再降质量只会又糊又大,改成缩尺寸
var longest = Math.max(box.width, box.height);
if (out.bytes <= target || !fallbackEdge || longest <= fallbackEdge) return out;
var small = render(img, fallbackEdge);
return encode(small.cv, fallbackQuality).then(function (blob) {
// 兜底档同样是有损重编,纹理极端的图上未必比阶梯档小,所以比一次体积、取小的
if (blob.size >= out.bytes) return out;
return { blob: blob, width: small.width, height: small.height, quality: fallbackQuality, bytes: blob.size };
});
});
});
}
function post(url, form) {
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
.then(function (r) { return r.json(); });
}
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
function upload(srcPK, blob, w, h) {
var fd = new FormData();
fd.append('src_pk', srcPK);
fd.append('w', w);
fd.append('h', h);
fd.append('file', blob, 'image.webp');
return post('/admin/place/images', fd);
}
function remove(id) {
var fd = new FormData();
fd.append('id', id);
return post('/admin/place/images/delete', fd);
}
window.rondImage = { toWebP: toWebP, upload: upload, remove: remove };
})();
+930
View File
@@ -0,0 +1,930 @@
/* rond 足迹地图:底图坐标系感知 + Leaflet 渲染
*
* 坐标系约定:数据库里的地点坐标是 GCJ-02(火星坐标,见 internal/geo/geo.go 的说明),
* 因此画在 GCJ-02 底图(高德/腾讯)上时**不做任何转换**;
* 只有换成 WGS-84 底图(天地图 / OSM / 世界迷雾)时才反算一次,否则会偏 500 米以上。
*/
(function () {
'use strict';
var PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323;
var DATA_CRS = 'gcj02';
// 交通方式图例:轨迹开启时告诉用户颜色含义
function modeLegendControl(modes) {
var Ctl = L.Control.extend({
options: { position: 'bottomleft' },
onAdd: function () {
var box = L.DomUtil.create('div', 'mode-legend');
box.style.cssText = 'background:rgba(255,255,255,.92);padding:6px 9px;border-radius:8px;' +
'box-shadow:0 1px 4px rgba(0,0,0,.18);font-size:11.5px;line-height:1.7';
modes.forEach(function (m) {
var row = L.DomUtil.create('div');
row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' +
'background:' + (m.color || '#30b0c7') + ';margin-right:6px"></span>' +
m.name + ' <span style="color:#8a9199">' + m.count + '</span>';
box.appendChild(row);
});
L.DomEvent.disableClickPropagation(box);
return box;
}
});
return new Ctl();
}
// GPX 轨迹按需加载:勾选时才请求,切底图后按新坐标系重取
var gpxCache = {};
function ensureGpx(crs, done) {
if (!gpxCache[crs]) {
gpxCache[crs] = fetch('/api/gpx?crs=' + crs)
.then(function (r) { return r.json(); })
.then(function (d) { return d.tracks || []; })
.catch(function () { delete gpxCache[crs]; return []; });
}
gpxCache[crs].then(done);
}
// 按需加载 leaflet-heat:同一个 promise 复用,避免连点重复插入脚本
var heatLoading = null;
function ensureHeat(done) {
if (L.heatLayer) { done(); return; }
if (!heatLoading) {
heatLoading = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = '/static/vendor/heat/leaflet-heat.js' + (window.__assetV ? '?v=' + window.__assetV : '');
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
heatLoading.then(done, function () { heatLoading = null; });
}
// 迷雾浓度滑杆:不同底图明暗差异大,让用户自己调节更直观。
// 返回控件本身,便于迷雾被关掉时把滑杆一并收起来——图层关着、滑杆还在,
// 会让人以为迷雾已经打开了。
function addFogOpacityControl(map, layer, corner) {
var Ctl = L.Control.extend({
options: { position: corner },
onAdd: function () {
var box = L.DomUtil.create('div', 'leaflet-bar fog-opacity');
// 标签做成按钮:窄屏用它把滑杆收起来(右下角控件太多时太挤)
box.innerHTML = '<button type="button" class="fog-label">迷雾浓度</button>';
var label = box.querySelector('.fog-label');
var slider = L.DomUtil.create('input', 'fog-slider');
slider.type = 'range';
slider.min = '20';
slider.max = '100';
slider.step = '5';
slider.value = String(Math.round(layer.options.opacity * 100));
box.appendChild(slider);
L.DomEvent.disableClickPropagation(box);
L.DomEvent.disableScrollPropagation(box);
L.DomEvent.on(label, 'click', function () { box.classList.toggle('open'); });
L.DomEvent.on(slider, 'input', function () {
var v = parseInt(slider.value, 10) / 100;
layer.setOpacity(v);
localStorage.setItem('rond.fogOpacity', String(v));
});
return box;
}
});
return new Ctl().addTo(map);
}
function inChina(lat, lon) {
return lon >= 73.66 && lon <= 135.05 && lat >= 3.86 && lat <= 53.55;
}
function transformLat(x, y) {
var r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
r += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
r += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
return r;
}
function transformLon(x, y) {
var r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
r += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
r += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
return r;
}
// WGS-84 -> GCJ-02
function wgsToGcj(lat, lon) {
if (!inChina(lat, lon)) return [lat, lon];
var dLat = transformLat(lon - 105, lat - 35);
var dLon = transformLon(lon - 105, lat - 35);
var radLat = lat / 180 * PI;
var magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
var sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI);
return [lat + dLat, lon + dLon];
}
// GCJ-02 -> WGS-84,正变换没有解析反函数,不动点迭代即可
function gcjToWgs(lat, lon) {
if (!inChina(lat, lon)) return [lat, lon];
var wLat = lat, wLon = lon;
for (var i = 0; i < 5; i++) {
var g = wgsToGcj(wLat, wLon);
wLat += lat - g[0];
wLon += lon - g[1];
}
return [wLat, wLon];
}
function project(lat, lon, crs) {
if (crs === 'wgs84') return gcjToWgs(lat, lon);
return [lat, lon];
}
var APPLE = {
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'
};
var maps = {};
var cfgCache = null;
function register(id, map) {
maps[id] = map;
window.__rondMaps = maps;
return map;
}
function colorOf(name) {
if (!name) return '#5b6169';
if (APPLE[name]) return APPLE[name];
return /^#/.test(name) ? name : '#5b6169';
}
// 底图配置由服务端下发:需要密钥的图层走 /tiles/... 代理,密钥不进页面
function loadConfig() {
if (cfgCache) return Promise.resolve(cfgCache);
return fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
if (!c || !c.base || !c.base.length) throw new Error('底图配置为空');
cfgCache = c;
return c;
})
.catch(function () {
// 兜底:直接用免密钥高德瓦片,保证地图不会空白
cfgCache = {
base: [{
key: 'amap-street', label: '街道', crs: 'gcj02', maxZoom: 18,
url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
attr: '© 高德地图'
}],
overlays: []
};
return cfgCache;
});
}
function tileLayer(def) {
return L.tileLayer(def.url, {
subdomains: ['1', '2', '3', '4'],
maxZoom: def.maxZoom || 18,
minZoom: 3,
attribution: def.attr || ''
});
}
/* ---------- 默认底图 ----------
* 三处来源,优先级从高到低:
* 1) 访客自己在图上切过的那张(localStorage,换设备就没了,但不用每次重切)
* 2) 后台「设置 → 地图底图 → 默认用哪张底图」设的那张
* 3) 配置里的第一张
* Key 对不上(换过 map_provider、旧配置残留)就往下落,绝不让地图空着。
*/
var BASE_PICK = 'rond.base';
function pickBase(cfg) {
var byKey = {};
cfg.base.forEach(function (b) { byKey[b.key] = b; });
try {
var saved = window.localStorage.getItem(BASE_PICK);
if (saved && byKey[saved]) return byKey[saved];
} catch (e) { /* 隐私模式禁用了 localStorage,忽略即可 */ }
if (cfg.default && byKey[cfg.default]) return byKey[cfg.default];
return cfg.base[0];
}
function rememberBase(key) {
if (!key) return;
try { window.localStorage.setItem(BASE_PICK, key); } catch (e) { /* 同上 */ }
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function dur(min) {
min = Math.round(min || 0);
if (min <= 0) return '—';
var d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
if (d > 0) return d + '天' + (h ? h + '小时' : '');
if (h > 0) return h + '小时' + (m ? m + '分' : '');
return m + '分';
}
/* ---------- 推荐 / 踩雷:地图气泡里直接表决 ----------
* 票数一次性从 /api/votes 取回(个人站点规模很小),气泡渲染时同步查表,
* 不为每个点单独发请求;投票就地 AJAX,不刷新页面。
*/
var voteData = null; // {verdicts:{}, tallies:{}, mine:{}}
var voteMeta = { admin: false, public: false };
var voteLoad = null;
function loadVotes() {
if (voteLoad) return voteLoad;
voteLoad = fetch('/api/votes', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
voteMeta.admin = !!d.admin;
voteMeta.public = !!d.public;
voteData = { verdicts: d.verdicts || {}, tallies: d.tallies || {}, mine: d.mine || {} };
repaintVotes();
return voteData;
})
.catch(function () { voteLoad = null; return null; });
return voteLoad;
}
function verdictOf(pk, fallback) {
if (voteData && voteData.verdicts[pk] != null) return voteData.verdicts[pk];
return fallback || 0;
}
function tallyOf(pk, up, down) {
var t = voteData && voteData.tallies[pk];
return t || { up: up || 0, down: down || 0 };
}
function paintVote(box, p) {
var pk = p.sp;
// 票数与身份(站长 / 访客)还没拿到时先不画,免得先闪出「大家怎么说」再变成「我的结论」
if (!voteData) { box.style.display = 'none'; return; }
if (!(voteMeta.admin || voteMeta.public)) { box.style.display = 'none'; return; }
box.style.display = '';
// 管理员看自己的结论(点已选中的按钮可取消);访客看大家怎么说
if (voteMeta.admin) {
var v = verdictOf(pk, p.vd);
box.innerHTML = '<span class="mp-vl">我的结论</span>' +
'<button type="button" class="mp-vb up' + (v === 1 ? ' on' : '') + '" data-set="' + (v === 1 ? 0 : 1) + '">👍 推荐</button>' +
'<button type="button" class="mp-vb down' + (v === -1 ? ' on' : '') + '" data-set="' + (v === -1 ? 0 : -1) + '">👎 踩雷</button>';
} else {
var t = tallyOf(pk, p.u, p.dn), m = mineArea(pk);
box.innerHTML = '<span class="mp-vl">大家怎么说</span>' +
'<button type="button" class="mp-vb up' + (m === 1 ? ' on' : '') + '" data-set="1">👍 <b>' + t.up + '</b></button>' +
'<button type="button" class="mp-vb down' + (m === -1 ? ' on' : '') + '" data-set="-1">👎 <b>' + t.down + '</b></button>';
}
Array.prototype.forEach.call(box.querySelectorAll('.mp-vb'), function (b) {
b.addEventListener('click', function () {
if (b.disabled) return;
b.disabled = true;
var body = voteMeta.admin
? 'src_pk=' + pk + '&verdict=' + b.getAttribute('data-set')
: 'src_pk=' + pk + '&vote=' + b.getAttribute('data-set');
fetch(voteMeta.admin ? '/api/verdict' : '/api/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: body
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { b.disabled = false; return; }
if (!voteData) voteData = { verdicts: {}, tallies: {}, mine: {} };
if (voteMeta.admin) {
if (d.verdict) voteData.verdicts[pk] = d.verdict; else delete voteData.verdicts[pk];
} else {
voteData.tallies[pk] = { up: d.up, down: d.down };
if (d.mine) voteData.mine[pk] = d.mine; else delete voteData.mine[pk];
}
paintVote(box, p);
})
.catch(function () { b.disabled = false; });
});
});
}
function mineArea(pk) {
return voteData && voteData.mine[pk] ? voteData.mine[pk] : 0;
}
// 票数异步到货后,把已经打开的气泡补上真实票数
function repaintVotes() {
Array.prototype.forEach.call(document.querySelectorAll('.mp-vote'), function (box) {
if (box.__p) paintVote(box, box.__p);
});
}
function popup(p, vote) {
var wrap = document.createElement('div');
wrap.className = 'mp';
var parts = ['<div class="mp-n">' + esc(p.n || '未命名地点') + '</div>'];
var meta = [];
if (p.a) meta.push(esc(p.a));
if (p.c) meta.push(esc(p.c));
if (meta.length) parts.push('<div class="mp-m">' + meta.join(' · ') + '</div>');
parts.push('<div class="mp-m">到访 <b>' + p.v + '</b> 次 · 停留 <b>' + dur(p.d) + '</b></div>');
// 备注只读直出,省得多点一次详情页
if (p.note) parts.push('<div class="mp-note">' + esc(p.note) + '</div>');
if (p.t) parts.push('<div class="mp-m">最近一次 ' + esc(p.t) + '</div>');
parts.push('<div class="mp-foot"><a href="/places/' + p.id + '">查看详情 →</a></div>');
wrap.innerHTML = parts.join('');
if (vote && p.sp) {
// 无论如何都挂上,交给 paintVote 决定显不显示;
// 不挂的话票数到货后的 repaintVotes() 就找不到它了
var box = L.DomUtil.create('div', 'mp-vote');
box.__p = p;
paintVote(box, p);
wrap.appendChild(box);
}
return wrap;
}
// radiusOf 让点的大小反映到访次数(按当前这批里最多的那个归一化)
function radiusOf(p, max) {
return 4 + Math.min(9, Math.sqrt(p.v / max) * 9);
}
function maxVisit(points) {
var max = 1;
points.forEach(function (p) { if (p.v > max) max = p.v; });
return max;
}
// makeMarker 造一个点。抽出来是为了让 setPoints 能只补差集:
// 整体重建会让所有点同时闪一下,聚合的展开状态也会丢。
function makeMarker(p, crs, max, vote) {
var c = project(p.lat, p.lon, crs);
var m = L.circleMarker([c[0], c[1]], baseStyle(p, max));
// 传函数而不是字符串:每次打开都重算票数与「我投的」,避免陈旧的闭包数据
// maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行
m.bindPopup(function () { return popup(p, vote); },
{ closeButton: true, maxWidth: 360, minWidth: 240 });
m.bindTooltip(p.n || '未命名', { direction: 'top', offset: [0, -4] });
m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」
return m;
}
function makeGroup(useCluster) {
return useCluster && L.markerClusterGroup
? L.markerClusterGroup({ maxClusterRadius: 46, disableClusteringAtZoom: 15, showCoverageOnHover: false })
: L.layerGroup();
}
// index 可选:传了就顺手填好「点 id → 图层」,供 setPoints 做增量更新,
// 也供 highlight() 按 key 找到某个点。没有 id 的点用调用方给的 p.key 当键
// (照片导入页传的是行号,它本来就没有库内 id)。
function markers(points, crs, useCluster, vote, index) {
var group = makeGroup(useCluster);
var max = maxVisit(points);
points.forEach(function (p) {
var m = makeMarker(p, crs, max, vote);
if (index) index[p.id !== undefined ? p.id : p.key] = m;
group.addLayer(m);
});
return group;
}
// 点的「常态样式」:highlight 复原时要用同一套值,所以抽出来
function baseStyle(p, max) {
return {
radius: radiusOf(p, max), color: '#fff', weight: 1.5,
fillColor: colorOf(p.k), fillOpacity: 0.9
};
}
function reducedMotion() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
// popIn 给一个元素打上入场动画。动画结束后把类和延时清掉,
// 免得这个元素之后被复用(缩放时 Leaflet 会换元素)时莫名其妙又跑一遍。
function popIn(el, delay) {
if (!el || !el.classList) return;
el.style.animationDelay = delay;
el.classList.add('pt-new');
el.addEventListener('animationend', function () {
el.classList.remove('pt-new');
el.style.animationDelay = '';
}, { once: true });
}
// 新出现的点做一次「冒出」动画,一批按顺序错开一点,看起来是"扫过来"的。
//
// 必须等图层真正上屏再打类:markerClusterGroup 是**异步**把子标记渲染成 DOM 的,
// addLayer 之后立刻 getElement() 拿到的是 null(这也是第一版动画完全没生效的原因)。
// 所以挂 add 事件,并且顺手判一次「是不是已经上屏了」。
function animateIn(markers) {
if (reducedMotion()) return;
markers.forEach(function (m, i) {
var delay = Math.min(i, 24) * 10 + 'ms';
var done = false;
var fire = function () {
if (done) return;
done = true;
popIn(m.getElement && m.getElement(), delay);
};
if (m.once) m.once('add', fire);
fire();
});
}
// 聚合图标不能用 DOM 元素本身当身份:markerCluster 每次数据变动都会重建它们
// (实测一轮更新里往 marker pane 加了 791 个元素),拿元素当 key 每次都被当成
// 「第一次见到」,结果就是什么都不会触发。用它的**位置**当身份才稳。
function clusterKey(el) {
var t = el.style.transform || '';
var m = t.match(/-?\d+(?:\.\d+)?/g);
if (m && m.length >= 2) {
// 位置量化到 40px 一格:点集一变,聚合中心会跟着微微挪动,
// 不量化的话同一个圈每次都被当成「新出现的」,回放时一片乱闪。
return Math.round(Number(m[0]) / 40) + ',' + Math.round(Number(m[1]) / 40);
}
return el.style.left + ',' + el.style.top;
}
// clusterState 把当前所有聚合圈的位置 → 数字记下来,并在数字变化 /
// 出现新位置时给相应元素打动画类。返回给下一次调用当基线。
function clusterState(map, prev) {
var now = {};
var changed = [];
var appeared = [];
[].slice.call(map.getContainer().querySelectorAll('.marker-cluster')).forEach(function (el) {
var inner = el.firstElementChild;
if (!inner) return;
var key = clusterKey(el);
var n = (inner.textContent || '').trim();
now[key] = n;
if (!prev) return; // 第一次只记基线:别一上来满屏都在动
if (prev[key] === undefined) appeared.push(el);
else if (prev[key] !== n) changed.push(inner);
});
if (!prev || reducedMotion()) return now;
appeared.forEach(function (el, i) { popIn(el, Math.min(i, 24) * 10 + 'ms'); });
// 数字变了轻弹一下。回放时在聚合视图下主要看到的就是数字在跳,
// 光靠新圆圈淡入几乎看不出来。弹内层 div —— 外层带着 Leaflet 的定位 transform。
changed.forEach(function (inner, i) {
setTimeout(function () {
inner.classList.remove('pt-pulse');
void inner.offsetWidth; // 强制回流:连着两次同样的动画不加这句不会重放
inner.classList.add('pt-pulse');
inner.addEventListener('animationend', function () {
inner.classList.remove('pt-pulse');
}, { once: true });
}, Math.min(i, 24) * 10);
});
return now;
}
function fit(map, points, crs, pad) {
var lls = points.map(function (p) {
var c = project(p.lat, p.lon, crs);
return [c[0], c[1]];
}).filter(function (c) { return c[0] || c[1]; });
if (!lls.length) {
map.setView([26.6, 106.7], 11);
return;
}
map.fitBounds(L.latLngBounds(lls).pad(pad == null ? 0.15 : pad));
}
// 底图坐标系可能随图层切换而改变(例如高德 -> 天地图),此时必须重画标记
function state4(map, points, group, crs, vote) {
return {
map: map, points: points, group: group, crs: crs, vote: vote,
apply: function (nextCrs) {
if (nextCrs === this.crs) return;
this.map.removeLayer(this.group);
this.crs = nextCrs;
this.group = markers(this.points, this.crs, this.clustered, this.vote);
this.group.addTo(this.map);
fit(this.map, this.points, this.crs, this.pad);
}
};
}
window.rondMap = {
gcj: wgsToGcj,
gcjToWgs: gcjToWgs,
colorOf: colorOf,
esc: esc,
dur: dur,
// 简易预览:主页 / 地点详情用,无聚类、无控件。
// 默认不带就地表决(详情页下方本来就有表决卡片,重复一套按钮反而混乱);
// 需要时传 vote: true。
preview: function (opts) {
var points = opts.points || [];
var vote = opts.vote === true;
// 同一个容器画第二次(照片导入页反查完画一次、点「重新反查」又画一次):
// Leaflet 不允许对已经有地图的容器再 L.map(),会抛
// "Map container is already initialized."——那个错会被调用方的 catch 接住,
// 于是「重新反查」明明成功了却报一句失败。这里直接复用那份实例换一批点。
// 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」:
// 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。
// 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。
var live = maps[opts.id];
if (live) {
live.invalidateSize();
var prev = window.rondMap._state[opts.id];
if (prev && prev.setPoints) prev.setPoints(points);
else if (prev) prev.previewPending = points; // 配置还没回来,等 setPoints 建好再补画
return;
}
var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false });
var st = null;
register(opts.id, map);
if (vote) loadVotes();
loadConfig().then(function (cfg) {
var def = pickBase(cfg);
tileLayer(def).addTo(map);
var byId = {};
var group = markers(points, def.crs, false, vote, byId);
group.addTo(map);
var applyFit = function () { fit(map, points, def.crs, 0.06); };
applyFit();
// 点击缩略图进全屏地图:只有主页那种「预览」需要(opts.clickToFull)。
// 地点详情页不能要——它上面本来就有「在地图中查看」按钮,点地图就走很突兀;
// 后台照片页更不能要——点一下地图就被甩出后台。
if (opts.clickToFull) {
map.on('click', function () { window.location.href = '/map'; });
}
// 容器尺寸变化后重新贴合视野(卡片布局在字体加载后可能变高)
window.addEventListener('resize', function () { map.invalidateSize(); applyFit(); });
st = state4(map, points, group, def.crs, vote);
st.clustered = false;
st.pad = 0.06;
st.byId = byId;
st.max = maxVisit(points);
st.setPoints = function (pts) {
points = st.points = pts;
if (st.hl) st.hl = null; // 旧图层马上要被丢掉,别留着悬空引用
map.removeLayer(st.group);
st.byId = {};
st.max = maxVisit(pts);
st.group = markers(pts, st.crs, false, vote, st.byId);
st.group.addTo(map);
applyFit();
};
window.rondMap._state[opts.id] = st;
// 配置就绪前有别的调用挂上来的点,现在补画一次
if (st.previewPending) {
var queued = st.previewPending;
st.previewPending = null;
st.setPoints(queued);
}
});
},
// highlight(id, key):把某个点临时挑出来(放大、换醒目色、浮出名字、压到最上层)。
// key 传 null / undefined 复原。key 取点的 id(full() 那套),没有 id 时取 p.key
// ——照片导入页传的是行号,拿它做「鼠标划到哪一行,图上哪个点跳出来」。
// 视野里没有这个点时才把它平移进来:划过一行就动一次地图会很晃。
highlight: function (id, key) {
var st = window.rondMap._state[id];
if (!st || !st.byId || !st.map) return;
if (st.hl) {
st.hl.setStyle(baseStyle(st.hl.__p || {}, st.max || 1));
st.hl.closeTooltip();
st.hl = null;
}
if (key === null || key === undefined) return;
var m = st.byId[key];
if (!m) return;
m.setStyle({
radius: radiusOf(m.__p || {}, st.max || 1) + 8,
color: '#fff', weight: 3, fillColor: '#e8590c', fillOpacity: 1
});
m.bringToFront();
m.openTooltip();
var ll = m.getLatLng();
if (!st.map.getBounds().pad(-0.12).contains(ll)) st.map.panTo(ll);
st.hl = m;
},
// 全屏地图:聚类 + 图层切换 + 坐标纠偏
full: function (opts) {
var map = L.map(opts.id, { zoomControl: false, worldCopyJump: false, attributionControl: false });
// 默认那个角标会显示「Leaflet | © 高德地图」,其中「Leaflet」是渲染库自己的链接,
// 对访客没有任何意义,去掉(prefix: false 只去掉这一半)。
// 底图版权(© 高德地图 / © OpenStreetMap)必须留着——那是上游的使用条款要求。
L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(map);
var points = opts.points || [];
// 窄屏筛选面板占满宽度,控件改挂右下角(该尺寸下图例已隐藏)
var corner = window.matchMedia('(max-width: 620px)').matches ? 'bottomright' : 'topright';
L.control.zoom({ position: corner }).addTo(map);
L.control.scale({ imperial: false, position: 'bottomleft' }).addTo(map);
register(opts.id, map);
window.rondMap._state[opts.id] = { pending: true, points: points };
loadVotes();
loadConfig().then(function (cfg) {
var bases = {}, def = pickBase(cfg);
cfg.base.forEach(function (b) {
var l = tileLayer(b);
l.__crs = b.crs;
l.__key = b.key;
bases[b.label] = l;
});
var active = bases[def.label];
active.addTo(map);
// 瓦片加载兜底:当前底图短时间内连续加载失败就自动切到别的底图。
// Leaflet 只是本地渲染库,失败的是上游瓦片源(高德等),这里把选择权自动接过来。
var errCount = 0, errTimer = null;
function noteFallback(msg) {
var el = document.createElement('div');
el.textContent = msg;
el.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);top:10px;z-index:1000;' +
'background:rgba(30,40,50,.85);color:#fff;padding:7px 14px;border-radius:8px;font-size:12.5px';
map.getContainer().appendChild(el);
setTimeout(function () { el.remove(); }, 6000);
}
Object.keys(bases).forEach(function (label) {
bases[label].on('tileerror', function () {
if (map.hasLayer(bases[label]) && bases[label] === active) {
errCount++;
clearTimeout(errTimer);
errTimer = setTimeout(function () { errCount = 0; }, 10000);
if (errCount >= 8) {
errCount = 0;
var others = Object.keys(bases).filter(function (l) { return l !== label; });
if (!others.length) return;
map.removeLayer(active);
active = bases[others[0]];
active.addTo(map);
st.apply(active.__crs);
noteFallback('「' + label + '」瓦片加载失败,已自动切换到「' + others[0] + '」');
}
}
});
});
// 天地图的注记是独立图层,必须叠在底图之上
(cfg.overlays || []).forEach(function (o) {
var ol = tileLayer(o);
ol.__crs = o.crs;
ol.addTo(map);
});
// 世界迷雾图层:瓦片由服务端按当前底图坐标系纠偏后渲染,
// 切换底图坐标系时必须换 URL 重新拉取。
// 专题可以在管理页关掉迷雾(opts.showFog=false),此时图层、浓度滑杆、图层开关都不出现。
var wantFog = opts.showFog !== false;
var fogLayer = null;
if (wantFog && cfg.fog && cfg.fog.blocks > 0) {
fogLayer = L.tileLayer('/tiles/fog/{z}/{x}/{y}?crs=' + def.crs + '&v=' + cfg.fog.ver, {
minZoom: 3, maxZoom: 18, opacity: 0.9
});
}
var overlayDefs = {};
if (fogLayer) {
overlayDefs['世界迷雾'] = fogLayer;
var savedFog = parseFloat(localStorage.getItem('rond.fogOpacity'));
if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog);
var fogCtl = addFogOpacityControl(map, fogLayer, corner);
// 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了;
// 只有用户自己关过(rond.fogOff=1)才不显示,关掉时把滑杆一并收起来。
var syncFog = function () {
var on = map.hasLayer(fogLayer);
var box = fogCtl.getContainer();
if (box) box.style.display = on ? '' : 'none';
localStorage.setItem('rond.fogOff', on ? '0' : '1');
};
if (localStorage.getItem('rond.fogOff') === '1') syncFog();
else fogLayer.addTo(map);
map.on('overlayadd overlayremove', function (e) {
if (e.name === '世界迷雾') syncFog();
});
}
if (Object.keys(bases).length > 1 || fogLayer) {
L.control.layers(bases, overlayDefs, { position: corner }).addTo(map);
// 记住访客挑的底图:下次进来直接铺他喜欢的那张(含「纯路网」这种少标注的)
map.on('baselayerchange', function (e) {
if (e.layer) rememberBase(e.layer.__key);
});
}
var group = markers(points, def.crs, true, true);
group.addTo(map);
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
// 传 fitCircle(中心 + 半径)或 fitBBox 就能覆盖默认的点集贴合。
var applyFit = function () {
if (opts.fitCircle && opts.fitCircle.radiusKm > 0) {
var c = project(opts.fitCircle.lat, opts.fitCircle.lon, def.crs);
map.fitBounds(L.latLng(c[0], c[1]).toBounds(opts.fitCircle.radiusKm * 2000));
return;
}
if (opts.fitBBox) {
var b = opts.fitBBox;
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
map.fitBounds(L.latLngBounds([[p1[0], p1[1]], [p2[0], p2[1]]]).pad(0.12));
return;
}
fit(map, points, def.crs);
};
applyFit();
var st = state4(map, points, group, def.crs, true);
st.clustered = true;
st.pad = 0.15;
st.apply = function (next) {
if (next === this.crs) return;
this.crs = next;
this.map.removeLayer(this.group);
this.group = markers(this.points, this.crs, true, this.vote);
this.group.addTo(this.map);
applyFit();
};
window.rondMap._state[opts.id] = st;
// 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影
var trackLayer = null, trackSegs = null, heatLayer = null;
var cityLayer = null, gpxLayer = null, modeLegend = null;
function buildTrack(segs, crs) {
var g = L.layerGroup();
segs.forEach(function (s) {
var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs);
L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 })
.bindTooltip((s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(g);
});
return g;
}
function buildHeat(crs) {
// 必须用 st.points:本页首屏是用 points: [] 构造的(地点随后经 /api/points 拉回),
// setPoints 只更新了 st.points,闭包里那个 points 一直是空数组 —— 用它热力图恒为空。
var pts = (st.points || []).map(function (p) {
var c = project(p.lat, p.lon, crs);
return [c[0], c[1], Math.min(p.v || 1, 25)];
}).filter(function (c) { return c[0] || c[1]; });
return L.heatLayer(pts, { radius: 22, blur: 18, maxZoom: 17, minOpacity: 0.35 });
}
window.rondMap.setTrack = function (segs, modes) {
if (trackLayer) { map.removeLayer(trackLayer); trackLayer = null; }
if (segs && segs.length) {
trackSegs = segs;
trackLayer = buildTrack(segs, st.crs).addTo(map);
} else {
trackSegs = null;
}
// 交通方式图例:告诉用户哪种颜色是哪类出行
if (modeLegend) { map.removeControl(modeLegend); modeLegend = null; }
if (modes && modes.length && segs && segs.length) {
modeLegend = modeLegendControl(modes);
modeLegend.addTo(map);
}
};
// 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑
window.rondMap.setCityAgg = function (on) {
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; }
if (!on) return;
fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) {
var cities = d.cities || [];
if (!cities.length) return;
var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; }));
cityLayer = L.layerGroup();
cities.forEach(function (c) {
var pos = project(c.lat, c.lon, st.crs);
var r = 8 + 22 * Math.sqrt((c.visits || 1) / max);
L.circleMarker(pos, {
radius: r, color: '#2f7d6e', weight: 1.5,
fillColor: '#2f7d6e', fillOpacity: 0.18
}).bindPopup('<b>' + c.city + '</b>' +
(c.province ? ' · ' + c.province : '') +
'<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' +
(c.first ? '<br>首次到达 ' + c.first : '')).addTo(cityLayer);
});
cityLayer.addTo(map);
});
};
// GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好
window.rondMap.setGpx = function (on) {
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; }
if (!on) return;
ensureGpx(st.crs, function (tracks) {
gpxLayer = L.layerGroup();
tracks.forEach(function (t) {
if (!t.points || t.points.length < 2) return;
L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 })
.bindTooltip(t.name + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(gpxLayer);
});
gpxLayer.addTo(map);
});
};
window.rondMap.setHeat = function (on) {
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
if (!on) return;
// heat 库只在第一次开热力图时才下载,平时不进首屏
ensureHeat(function () {
if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map);
});
};
map.on('baselayerchange', function (e) {
if (e.layer && e.layer.__crs) {
active = e.layer;
errCount = 0;
st.apply(e.layer.__crs);
if (fogLayer && map.hasLayer(fogLayer)) {
fogLayer.setUrl('/tiles/fog/{z}/{x}/{y}?crs=' + e.layer.__crs + '&v=' + cfg.fog.ver);
}
if (trackLayer && trackSegs) {
map.removeLayer(trackLayer);
trackLayer = buildTrack(trackSegs, e.layer.__crs).addTo(map);
}
if (heatLayer) {
map.removeLayer(heatLayer);
heatLayer = buildHeat(e.layer.__crs).addTo(map);
}
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; window.rondMap.setCityAgg(true); }
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; window.rondMap.setGpx(true); }
}
});
// byId 记住「点 → 图层」,setPoints 才能只补差集;
// max 是当前这批里最多的到访次数,用于点的半径归一化
st.byId = {};
st.max = 1;
window.rondMap.setPoints = function (pts, opts) {
opts = opts || {};
var prev = st.byId || {};
var max = 1;
pts.forEach(function (p) { if (p.v > max) max = p.v; });
// 数据是重新拉的(对象不是同一个)就整体重建:挂着的 popup 里存的是旧对象,
// 复用会导致打开气泡看到的还是上一次筛选的数字。
// 时间轴只是在这批点上做子集筛选,对象是同一批,因此能安全走增量。
var refetch = pts.some(function (p) { return prev[p.id] && prev[p.id].__p !== p; });
if (refetch || Object.keys(prev).length === 0) {
points = st.points = pts;
map.removeLayer(st.group);
st.byId = {};
st.group = markers(pts, st.crs, true, true, st.byId);
st.group.addTo(map);
st.max = max;
fit(map, pts, st.crs);
return;
}
var next = {};
var added = [];
pts.forEach(function (p) {
var m = prev[p.id];
if (m) {
next[p.id] = m;
delete prev[p.id];
return;
}
m = makeMarker(p, st.crs, max, true);
next[p.id] = m;
added.push(m);
st.group.addLayer(m);
});
// 剩下的就是这一批里没有的点,逐个移除(不重建,其余点纹丝不动)
Object.keys(prev).forEach(function (id) { st.group.removeLayer(prev[id]); });
// 到访次数上限变了就顺手调一下半径,免得新旧点大小不一致
if (max !== st.max) {
pts.forEach(function (p) {
var m = next[p.id];
if (m && m.setRadius) m.setRadius(radiusOf(p, max));
});
st.max = max;
}
st.byId = next;
points = st.points = pts;
animateIn(added);
st.clusterIcons = clusterState(map, st.clusterIcons);
// 时间轴拖动的每一帧都重新 fit 会让地图不停缩放,很晕;只有换筛选时才 fit
if (!opts.keepView) fit(map, pts, st.crs);
};
window.addEventListener('resize', function () { map.invalidateSize(); });
});
return map;
},
_state: {}
};
})();
+15
View File
@@ -0,0 +1,15 @@
{
"name": "我的足迹地图",
"short_name": "足迹",
"description": "rond 备份驱动的个人足迹地图",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f7f8f8",
"theme_color": "#2f7d6e",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.zh = {}));
}(this, (function (exports) { 'use strict';
var fp = typeof window !== "undefined" && window.flatpickr !== undefined
? window.flatpickr
: {
l10ns: {},
};
var Mandarin = {
weekdays: {
shorthand: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
longhand: [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
],
},
months: {
shorthand: [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月",
],
longhand: [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月",
],
},
rangeSeparator: " 至 ",
weekAbbreviation: "周",
scrollTitle: "滚动切换",
toggleTitle: "点击切换 12/24 小时时制",
};
fp.l10ns.zh = Mandarin;
var zh = fp.l10ns;
exports.Mandarin = Mandarin;
exports.default = zh;
Object.defineProperty(exports, '__esModule', { value: true });
})));
+11
View File
@@ -0,0 +1,11 @@
/*
(c) 2014, Vladimir Agafonkin
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
https://github.com/mourner/simpleheat
*/
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
(c) 2014, Vladimir Agafonkin
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
https://github.com/Leaflet/Leaflet.heat
*/
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
/* IE 6-8 fallback colors */
.leaflet-oldie .marker-cluster-small {
background-color: rgb(181, 226, 140);
}
.leaflet-oldie .marker-cluster-small div {
background-color: rgb(110, 204, 57);
}
.leaflet-oldie .marker-cluster-medium {
background-color: rgb(241, 211, 87);
}
.leaflet-oldie .marker-cluster-medium div {
background-color: rgb(240, 194, 12);
}
.leaflet-oldie .marker-cluster-large {
background-color: rgb(253, 156, 115);
}
.leaflet-oldie .marker-cluster-large div {
background-color: rgb(241, 128, 23);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.marker-cluster span {
line-height: 30px;
}
@@ -0,0 +1,14 @@
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}
.leaflet-cluster-spider-leg {
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>迷雾遮罩区</h1>
<div class="sub">这些范围内的迷雾<strong>对访客隐藏</strong>,你自己仍然看得到。在地图上点一下就是圆心,半径可调。</div>
</div>
<div class="row">
<a class="btn" href="/admin">← 返回后台</a>
</div>
</div>
<div class="card">
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div id="mask-map" style="height:380px;border-radius:10px;background:var(--line-soft)"></div>
<form method="post" action="/admin/fog/mask/save" id="mask-form" style="margin-top:12px">
<input type="hidden" name="id">
<input type="hidden" name="lat">
<input type="hidden" name="lon">
<input type="hidden" name="crs">
<div class="row">
<label class="f" style="flex:1 1 200px;margin-bottom:0">
<span>名称(可选)</span>
<input type="text" name="name" maxlength="40" placeholder="例如:家附近、公司周边">
</label>
<label class="f" style="flex:0 1 150px;margin-bottom:0">
<span>半径(米)</span>
<input type="number" name="radius_m" min="50" max="50000" step="50" value="500" required>
</label>
<button class="btn primary" type="submit" id="mask-save">保存</button>
<button class="btn" type="button" id="mask-reset">清除选择</button>
</div>
<div class="muted" id="mask-hint" style="font-size:12.5px;margin-top:6px">在下方地图上点选圆心</div>
</form>
</div>
<div class="card">
<h2>已有遮罩区({{len .Masks}})</h2>
{{if .Masks}}
<table class="tb">
<thead><tr><th>名称</th><th>圆心(纬度, 经度)</th><th>半径</th><th></th></tr></thead>
<tbody>
{{range .Masks}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
<td>{{printf "%.0f" .RadiusM}} 米</td>
<td>
<button class="btn sm" type="button" data-edit
data-id="{{.ID}}" data-name="{{.Name}}"
data-lat="{{.Lat}}" data-lon="{{.Lon}}" data-radius="{{.RadiusM}}">编辑</button>
<form method="post" action="/admin/fog/mask/delete" style="display:inline"
onsubmit="return confirm('删除后这块范围的迷雾会重新对访客显示。确认删除?')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="muted">还没有遮罩区。在地图上点选圆心、设置半径后保存即可。</div>
{{end}}
<div class="card-sub" style="margin-top:10px">遮罩只影响访客:站长登录状态下看到的始终是完整迷雾。</div>
</div>
</div>
<script>
(function () {
// Leaflet 由 layout 在 content 之后引入,脚本执行时 window.L 还没有,
// 必须等 load 事件之后再初始化,否则地图整个不出现(与后台「新增地点」同一条约定)
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
var el = document.getElementById('mask-map');
var form = document.getElementById('mask-form');
if (!el || !form) return;
var latIn = form.querySelector('[name=lat]'), lonIn = form.querySelector('[name=lon]');
var idIn = form.querySelector('[name=id]'), nameIn = form.querySelector('[name=name]');
var radiusIn = form.querySelector('[name=radius_m]'), crsIn = form.querySelector('[name=crs]');
var hint = document.getElementById('mask-hint');
var saveBtn = document.getElementById('mask-save');
// 底图走站点配置(与后台「新增地点」同一套取法):带密钥的图层由服务端转发,
// 免密钥的是浏览器直连的 {s} 模板,不能硬写 /tiles/ 路由
var fallback = {
def: { url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', maxZoom: 18, attr: '© 高德地图', crs: 'gcj02' },
subs: ['1', '2', '3', '4']
};
function init(cfg) {
var def = cfg.def;
var map = L.map(el).setView([34.5, 108.9], 4);
L.tileLayer(def.url, { subdomains: cfg.subs, maxZoom: def.maxZoom || 18, minZoom: 3, attribution: def.attr || '' }).addTo(map);
crsIn.value = def.crs || 'gcj02';
// 已有遮罩区画出来(只读),供对照
var existing = L.layerGroup().addTo(map);
[].forEach.call(document.querySelectorAll('[data-edit]'), function (b) {
var lat = parseFloat(b.dataset.lat), lon = parseFloat(b.dataset.lon), r = parseFloat(b.dataset.radius);
L.marker([lat, lon], { opacity: 0.65 }).addTo(existing);
L.circle([lat, lon], { radius: r, color: '#d9534f', weight: 1.5, fillOpacity: 0.12 }).addTo(existing);
});
var marker = null, circle = null;
function place(lat, lon) {
if (!marker) {
marker = L.marker([lat, lon], { draggable: true }).addTo(map);
marker.on('drag', function (e) { circle.setLatLng(e.target.getLatLng()); });
marker.on('dragend', function (e) { fillCoord(e.target.getLatLng()); });
} else {
marker.setLatLng([lat, lon]);
}
if (!circle) {
circle = L.circle([lat, lon], { radius: parseFloat(radiusIn.value) || 500, color: '#d9534f', fillOpacity: 0.18 }).addTo(map);
} else {
circle.setLatLng([lat, lon]);
}
}
function fillCoord(ll) { latIn.value = ll.lat.toFixed(6); lonIn.value = ll.lng.toFixed(6); }
radiusIn.addEventListener('input', function () {
if (circle) circle.setRadius(Math.max(50, parseFloat(radiusIn.value) || 500));
});
map.on('click', function (e) { place(e.latlng.lat, e.latlng.lng); fillCoord(e.latlng); });
marker = null;
// 列表里的「编辑」:把该行灌进表单,地图上跟过去
[].forEach.call(document.querySelectorAll('[data-edit]'), function (b) {
b.addEventListener('click', function () {
idIn.value = b.dataset.id;
nameIn.value = b.dataset.name || '';
radiusIn.value = Math.round(parseFloat(b.dataset.radius) || 500);
var lat = parseFloat(b.dataset.lat), lon = parseFloat(b.dataset.lon);
place(lat, lon);
fillCoord({ lat: lat, lng: lon });
map.setView([lat, lon], 13);
saveBtn.textContent = '保存修改';
hint.textContent = '正在编辑「' + (b.dataset.name || b.dataset.id) + '」,也可以在地图上点选新圆心';
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
});
document.getElementById('mask-reset').addEventListener('click', function () {
form.reset();
idIn.value = '';
radiusIn.value = 500;
if (marker) { map.removeLayer(marker); marker = null; }
if (circle) { map.removeLayer(circle); circle = null; }
saveBtn.textContent = '保存';
hint.textContent = '在下方地图上点选圆心';
});
}
whenReady(function () {
if (!window.L) return;
fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
var def = (c && c.base && c.base.length) ? c.base[0] : fallback.def;
init({ def: def, subs: (c && c.subs && c.subs.length) ? c.subs : fallback.subs });
})
.catch(function () { init(fallback); });
});
})();
</script>
{{end}}
+169
View File
@@ -0,0 +1,169 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>后台管理</h1>
<div class="sub">上传 rond 备份、切换展示的数据、调整站点设置</div>
</div>
<div class="row">
<a class="btn" href="/admin/edit">数据编辑</a>
<a class="btn" href="/admin/topics">专题管理</a>
<a class="btn" href="/admin/photos">照片导入</a>
<a class="btn" href="/admin/settings">站点设置</a>
<a class="btn" href="/admin/twofa">两步验证</a>
<a class="btn" href="/admin/notes">地点备注</a>
<a class="btn" href="/admin/password">账号设置</a>
<a class="btn" href="/" target="_blank">查看站点</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .DefaultPW}}<div class="alert err">⚠️ 您仍在使用初始口令 <span class="mono">rond-admin</span>,站点对公网开放时任何人都可以用这组口令登录后台。请尽快<a href="/admin/password">修改密码</a>,并在<a href="/admin/twofa">两步验证</a>中绑定动态口令。</div>{{end}}
<div class="card">
<h2>上传备份</h2>
<div class="card-sub">
在 rond App 中导出备份,得到 <span class="mono">.rondbackup</span> 文件后上传。
单次最大 {{size .MaxSize}}。
<b>上传是以备份为准重建数据</b>:站点里删掉的地点、改过的记录,只要下次传一份
<b>内容不同</b>的备份,就会被备份里的原样覆盖回来(重复传同一份文件不会,会提示「完全一致」)。
在站点上新加的地点属于当时那个数据集,传新备份后要看它们,可在下面的数据集列表里切回。
</div>
<form method="post" action="/admin/upload" enctype="multipart/form-data">
<label class="f" style="max-width:520px">
<span>选择文件</span>
<input type="file" name="backup" accept=".rondbackup,.zip" required
style="padding:8px;border:1px dashed var(--line);border-radius:8px;background:var(--bg-2)">
</label>
<button class="btn primary" type="submit">上传并导入</button>
</form>
</div>
<div class="card">
<h2>世界迷雾</h2>
<div class="card-sub">
导入 Fog of World 导出的 <span class="mono">.fwss</span> 快照,在足迹地图上叠加已探索区域。
迷雾网格为 WGS-84,站点会自动对高德等 GCJ-02 底图做纠偏。
迷雾是站点级数据,不属于任何数据集,删除数据集不影响迷雾。
{{if gt .FogBlocks 0}}
当前已入库 <span class="mono">{{num .FogBlocks}}</span> 块 / <span class="mono">{{num .FogCells}}</span> 格,
估算已探索 <span class="mono">{{printf "%.1f" .FogArea}}</span> km²{{if .FogUpdatedAt}},最近更新 {{dtt .FogUpdatedAt}}{{end}}。
{{else}}
<b>尚未导入迷雾</b>——把 Fog of World 的 <span class="mono">.fwss</span>(在 App 内导出,通常在 zip 里)上传即可点亮地图。
{{end}}
</div>
<form method="post" action="/admin/fog" enctype="multipart/form-data">
<label class="f" style="max-width:520px">
<span>选择快照</span>
<input type="file" name="fwss" accept=".fwss,.zip" required
style="padding:8px;border:1px dashed var(--line);border-radius:8px;background:var(--bg-2)">
</label>
<label class="f" style="max-width:520px">
<span>导入方式</span>
<select name="mode">
<option value="overlay" selected>叠加 — 保留现有迷雾,把新路线合并进来</option>
<option value="cover">覆盖 — 清空现有迷雾,整体替换</option>
</select>
</label>
<button class="btn primary" type="submit">导入迷雾</button>
</form>
{{if gt .FogBlocks 0}}
<div style="margin:10px 0 0;display:flex;gap:8px;flex-wrap:wrap">
<a class="btn" href="/admin/fog/export">导出当前迷雾(.fwss)</a>
<a class="btn" href="/admin/fog/mask">迷雾遮罩区{{if .FogMasks}}({{.FogMasks}}){{end}}</a>
<form method="post" action="/admin/fog/clear"
onsubmit="return confirm('将删除全部迷雾数据(数据库与地图图层同步清空),且不可恢复。确认清空?')">
<button class="btn danger" type="submit">清空迷雾</button>
</form>
</div>
{{end}}
</div>
<div class="card">
<h2>数据导出</h2>
<div class="card-sub">
把当前展示的数据集导出为通用格式,方便备份或迁移到其他平台。
坐标保持入库时的 GCJ-02,GeoJSON 里已注明坐标系。
</div>
<div class="row">
<a class="btn" href="/admin/export/places.geojson">导出地点(GeoJSON)</a>
<a class="btn" href="/admin/export/places.gpx">导出地点(GPX)</a>
</div>
<div class="card-sub" style="margin-top:14px">
也可导出为 rond 原始格式的 <span class="mono">.rondbackup</span>(含你在<a href="/admin/edit">数据编辑</a>里补录的记录),可直接导回 rond App。
坐标为 GCJ-02,原样写回、无需纠偏。
</div>
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup(可回导 rond)</a>
</div>
<div class="card">
<div class="row" style="justify-content:space-between;align-items:center">
<h2 style="margin:0">已导入的数据集</h2>
<form method="post" action="/admin/places/backfill"
onsubmit="return confirm('对缺地址 / 没名字的地点逐个反查高德补全?(每次处理 100 个)')">
<button class="btn sm" type="submit">补全缺失地址</button>
</form>
</div>
<div class="card-sub">同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它</div>
{{if .Datasets}}
<div class="tw">
<table class="tb">
<thead>
<tr><th>文件</th><th>导入时间</th><th class="num">到访</th><th class="num">地点</th><th>数据跨度</th><th>状态</th><th></th></tr>
</thead>
<tbody>
{{range .Datasets}}
<tr>
<td><span class="mono">{{trunc .Name 34}}</span></td>
<td class="muted">{{dt .UploadedAt}}</td>
<td class="num">{{num .Visits}}</td>
<td class="num">{{num .Places}}</td>
<td class="muted">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
<td>
{{if and $.Dataset (eq $.Dataset.ID .ID)}}
<span class="tag">展示中</span>
{{else}}
<span class="muted">未启用</span>
{{end}}
</td>
<td style="white-space:nowrap">
{{if not (and $.Dataset (eq $.Dataset.ID .ID))}}
<form method="post" action="/admin/datasets/{{.ID}}/activate" style="display:inline">
<button class="btn sm" type="submit">启用</button>
</form>
{{end}}
<form method="post" action="/admin/datasets/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('删除后该数据集的所有记录都会从数据库移除,且不可恢复。确认删除?')">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty"><div class="big">📦</div>还没有导入任何数据集</div>
{{end}}
</div>
<div class="card">
<h2>数据说明</h2>
<div class="card-sub">从备份中读取并转换的字段</div>
<div class="tw">
<table class="tb">
<thead><tr><th>来源表</th><th>内容</th><th>用途</th></tr></thead>
<tbody>
<tr><td class="mono">ZVISIT</td><td>到访记录(到达 / 离开时间、类型、地点、备注、标签)</td><td>时间线、统计、地图</td></tr>
<tr><td class="mono">ZLOCATION</td><td>地点(名称、经纬度、城市、省份、POI 分类)</td><td>地图打点、地点库</td></tr>
<tr><td class="mono">ZACTIVITY</td><td>自定义类型(颜色、图标、是否家 / 工作)</td><td>类型分布</td></tr>
<tr><td class="mono">ZMOVEMENT</td><td>位移段(交通方式、起止时间)</td><td>出行方式统计</td></tr>
<tr><td class="mono">ZTAG</td><td>自定义标签及分组</td><td>标签筛选</td></tr>
<tr><td class="mono">ZHOURLYWEATHER</td><td>逐小时天气(气温、天气现象、湿度)</td><td>天气足迹</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "login"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>登录后台 · {{.Settings.SiteTitle}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css">
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a}</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">{{.Settings.OwnerAvatar}}</div>
<h1 style="font-size:19px;margin-top:6px">{{.Settings.SiteTitle}}</h1>
<div class="muted" style="font-size:13px">后台管理</div>
</div>
<div class="card" style="margin:0">
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .Flash}}<div class="alert info">{{.Flash}}</div>{{end}}
<form method="post" action="/admin/login">
<input type="hidden" name="next" value="{{.Next}}">
<label class="f"><span>用户名</span><input type="text" name="username" autocomplete="username" autofocus required></label>
<label class="f"><span>密码</span><input type="password" name="password" autocomplete="current-password" required></label>
<label class="f"><span>验证码</span>
{{/* 用 div 而不是 span:label.f > span 会把 display 压成 block,.row 的 flex 就失效了。
输入框要显式 width:auto —— 全局的 input[type=text]{width:100%} 会把图挤到下一行。 */}}
<div class="row" style="flex-wrap:nowrap;gap:8px">
<input type="text" name="captcha" inputmode="latin" autocomplete="off"
spellcheck="false" maxlength="6" required style="flex:1 1 0;width:auto;min-width:0">
<img id="captcha-img" src="/captcha" alt="验证码" width="130" height="40"
title="看不清?点击换一张"
style="border:1px solid var(--line);border-radius:6px;cursor:pointer;flex:0 0 auto">
</div>
</label>
<button class="btn primary" type="submit" style="width:100%;justify-content:center">登录</button>
</form>
<script>
// 换一张:带上时间戳绕开缓存;服务端每次出图都会下发新的 id cookie
document.getElementById('captcha-img').addEventListener('click', function () {
this.src = '/captcha?t=' + Date.now();
});
</script>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:13px">← 返回主页</a></div>
</div>
</div>
</body>
</html>{{end}}
+189
View File
@@ -0,0 +1,189 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>地点备注</h1>
<div class="sub">列出当前数据集的全部地点,可给任意地点起别名、写备注、标「推荐 / 踩雷」;别名会替代原名显示</div>
</div>
<div class="row">
<a class="btn" href="/admin/settings">可见性设置</a>
<a class="btn" href="/admin">← 返回后台</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<div class="card-sub" style="margin:0 0 10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<span>
备注对外{{if .NotesPublic}}<b>公开</b>(访客也能看到){{else}}<b>不公开</b>(只有你能看到){{end}}
· 共 {{num .Total}} 个地点
</span>
<form method="post" action="/admin/notes/rond" style="margin:0">
<button class="btn sm" type="submit"
title="把 rond 备份里按次到访写的备注汇总成地点备注;只重写「来自 rond」那一块,你手写的内容不动">
从 rond 备份回填备注
</button>
</form>
</div>
<form method="get" action="/admin/notes" class="row" style="gap:8px;flex-wrap:wrap;align-items:flex-end;margin:0">
<label class="f" style="margin:0;flex:1 1 200px"><span>关键词</span>
<input type="text" name="q" value="{{.Query}}" placeholder="地点名 / 别名 / 备注 / 城市">
</label>
<label class="f" style="margin:0;flex:0 0 145px"><span>状态</span>
<select name="status">
<option value="all" {{if eq .Status "all"}}selected{{end}}>全部地点</option>
<option value="has" {{if eq .Status "has"}}selected{{end}}>仅有备注</option>
<option value="none" {{if eq .Status "none"}}selected{{end}}>仅无备注</option>
</select>
</label>
<label class="f" style="margin:0;flex:0 0 150px"><span>城市</span>
<select name="city">
<option value="">全部城市</option>
{{range .CityOptions}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label class="f" style="margin:0;flex:0 0 140px"><span>排序</span>
<select name="sort">
<option value="visit" {{if eq .Sort "visit"}}selected{{end}}>到访次数</option>
<option value="note" {{if eq .Sort "note"}}selected{{end}}>最近编辑</option>
<option value="talk" {{if eq .Sort "talk"}}selected{{end}}>票数</option>
<option value="name" {{if eq .Sort "name"}}selected{{end}}>名称</option>
</select>
</label>
<button class="btn primary" type="submit">筛选</button>
<a class="btn" href="/admin/notes">重置</a>
</form>
</div>
{{if .Rows}}
<form id="bulk-form" method="post" action="/admin/notes/bulk"></form>
<div class="card" style="position:sticky;top:8px;z-index:3;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="check" style="margin:0"><input type="checkbox" id="chk-all"> 全选本页</label>
<span class="muted" style="font-size:12.5px">批量:</span>
<select name="verdict" form="bulk-form" style="padding:5px 8px;border:1px solid var(--line);border-radius:8px">
<option value="1">👍 推荐</option>
<option value="-1">👎 踩雷</option>
<option value="0">取消结论</option>
</select>
<button class="btn sm" type="submit" form="bulk-form" name="action" value="verdict">设为该结论</button>
<button class="btn sm" type="submit" form="bulk-form" name="action" value="clear"
onclick="return confirm('清空所选地点的手写备注?(别名与「来自 rond」的备注都会保留)')">清空备注</button>
<button class="btn sm danger" type="submit" form="bulk-form" name="action" value="delete"
onclick="return confirm('删除所选地点的手写备注与别名?(rond 备份里的备注下次回填还会再出现)')">删除备注</button>
</div>
<div class="card" style="padding:0">
<div class="list">
{{range .Rows}}
<div class="item" style="grid-template-columns:28px 1fr;gap:10px;align-items:stretch">
<div style="display:flex;align-items:flex-start;padding-top:10px">
<input type="checkbox" name="src_pk" value="{{.SrcPK}}" form="bulk-form" class="row-chk">
</div>
<div>
<form method="post" action="/admin/place/note" style="display:grid;grid-template-columns:1fr 1.4fr auto;gap:10px;align-items:end">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<input type="hidden" name="next" value="{{$.BackURL}}">
<label class="f" style="margin:0">
<span>别名{{if .PlaceName}}(原名:{{.PlaceName}}{{if .PlaceCity}} · {{.PlaceCity}}{{end}}){{end}}</span>
<input type="text" name="alias" value="{{.Alias}}" maxlength="60" placeholder="{{.PlaceName}}">
</label>
<label class="f" style="margin:0">
<span>备注{{if .PlaceID}} <a href="/places/{{.PlaceID}}" target="_blank">查看 ↗</a>{{end}}</span>
<input type="text" name="note" value="{{.Content}}" maxlength="2000">
</label> <div class="row" style="gap:6px;flex-wrap:nowrap">
<button class="btn primary" type="submit">保存</button>
<button class="vote-btn up {{if eq .Verdict 1}}on{{end}}" type="button"
data-verdict-btn data-src="{{.SrcPK}}" data-v="1" title="标为推荐">👍</button>
<button class="vote-btn down {{if eq .Verdict -1}}on{{end}}" type="button"
data-verdict-btn data-src="{{.SrcPK}}" data-v="-1" title="标为踩雷">👎</button>
</div>
</form>
<div class="muted" style="font-size:12px;margin-top:4px">
地点 ID {{.SrcPK}}{{if .VisitCount}} · 到访 {{num .VisitCount}} 次{{end}}
<span class="vote-tip" data-verdict-status="{{.SrcPK}}">{{if eq .Verdict 1}} · 已标推荐{{else if eq .Verdict -1}} · 已标踩雷{{end}}</span>
{{if or .Up .Down}} · 访客 👍{{.Up}} 👎{{.Down}}{{end}}
{{if .Content}} · 更新于 {{dt .UpdatedAt}}{{end}}
</div>
{{if .RondNote}}<div class="note-block">来自 rond:{{.RondNote}}</div>{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
<script>
(function () {
var all = document.getElementById('chk-all');
var boxes = document.querySelectorAll('.row-chk');
if (all) {
all.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = all.checked; });
});
}
// 行内 👍/👎 用 AJAX(原来是 formaction 提交 → 整页刷新,会把同一行没保存的备注编辑一起丢掉)
document.querySelectorAll('.vote-btn[data-verdict-btn]').forEach(function (b) {
b.addEventListener('click', function () {
var src = b.getAttribute('data-src');
var v = Number(b.getAttribute('data-v'));
fetch('/api/verdict', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: 'src_pk=' + src + '&verdict=' + (b.classList.contains('on') ? 0 : v)
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
var cur = Number(d.verdict) || 0;
b.parentElement.querySelectorAll('.vote-btn').forEach(function (x) {
x.classList.toggle('on', Number(x.getAttribute('data-v')) === cur);
});
var st = document.querySelector('[data-verdict-status="' + src + '"]');
if (st) {
st.className = 'vote-tip' + (cur === 1 ? ' up' : cur === -1 ? ' down' : '');
st.textContent = cur === 1 ? ' · 已标推荐' : cur === -1 ? ' · 已标踩雷' : '';
}
}).catch(function () {});
});
});
})();
</script>
{{else}}
<div class="empty"><div class="big">📝</div>{{if .Query}}没有匹配的地点{{else}}当前数据集里还没有地点{{end}}</div>
{{end}}
{{if .Orphans}}
<div class="card" style="padding:0;margin-top:16px">
<div style="padding:12px 14px 4px"><h2 style="margin:0">已不在当前数据集的备注</h2>
<div class="card-sub">这些备注挂在当前备份里已不存在的地点上(换了快照的旧地点),可逐条清理。</div>
</div>
<div class="list">
{{range .Orphans}}
<div class="item" style="grid-template-columns:1fr auto;gap:10px;align-items:center">
<div>
<div>{{if .Alias}}{{.Alias}}{{else}}<span class="muted">(无别名)</span>{{end}}{{if .Content}} · <span class="muted">{{.Content}}</span>{{end}}{{if .RondNote}} · <span class="muted">来自 rond:{{.RondNote}}</span>{{end}}</div>
<div class="muted" style="font-size:12px">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
</div>
<form method="post" action="/admin/place/note">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<input type="hidden" name="alias" value="">
<input type="hidden" name="note" value="">
<input type="hidden" name="next" value="{{$.BackURL}}">
<button class="btn danger" type="submit" onclick="return confirm('删除这条备注?')">删除</button>
</form>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
@@ -0,0 +1,35 @@
{{define "content"}}
<div class="wrap" style="max-width:520px">
<div class="page-head">
<div>
<h1>账号设置</h1>
<div class="sub">修改密码后当前会话会失效,需要用新密码重新登录</div>
</div>
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">用户名</h2>
{{if eq .Flash "name"}}<div class="alert ok">用户名已更新</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<form method="post" action="/admin/username">
<label class="f"><span>用户名(2~32 个字符,不含空格)</span><input type="text" name="username" value="{{.Username}}" maxlength="32" autocomplete="username" required></label>
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
<div class="row"><button class="btn primary" type="submit">修改用户名</button></div>
</form>
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">密码</h2>
{{if and .Flash (ne .Flash "name")}}<div class="alert ok">密码已更新</div>{{end}}
<form method="post" action="/admin/password">
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
<label class="f"><span>新密码(至少 8 位)</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<label class="f"><span>确认新密码</span><input type="password" name="confirm" autocomplete="new-password" minlength="8" required></label>
<div class="row">
<button class="btn primary" type="submit">修改密码</button>
<a class="btn" href="/admin">返回后台</a>
</div>
</form>
</div>
</div>
{{end}}
+746
View File
@@ -0,0 +1,746 @@
{{define "content"}}
{{/* 解析器放最前:它必须在用户选完文件之前就绪,放页面底部会晚一步 */}}
<script src="/static/js/photo-exif.js?v={{.AssetV}}"></script>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
{{/* 选项数据放隐藏 select 里给脚本读:内联 JSON 会被模板的 JS 上下文转义搞坏 */}}
<select id="category-options" hidden>
<option value="">未指定</option>
{{range .Categories}}<option value="{{.Label}}">{{.Name}}</option>{{end}}
<option value="__custom__">自定义…</option>
</select>
<select id="activity-options" hidden>
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<select id="tag-options" hidden>
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<div class="wrap">
<div class="page-head">
<div>
<h1>照片导入</h1>
<div class="sub">从照片的 EXIF 读拍摄时间与坐标,反查地址后逐条确认,再写入数据库</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<h2>1. 选照片与坐标系</h2>
<div class="card-sub">
支持 JPEG 与 HEIC/HEIF。<b>EXIF 在浏览器里读取,照片不会上传到服务器</b>——
一次几十张、几百 MB 都没有上传耗时或存储压力。
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<span class="muted" style="font-size:12.5px">照片里的坐标是</span>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="wgs84" checked> WGS-84(绝大多数手机)</label>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="gcj02"> 已经是 GCJ-02(部分国产手机会这样写)</label>
</div>
{{if not .GeocodeReady}}
<div class="alert err" style="margin-bottom:10px">未配置高德 <span class="mono">geocode_key</span>,无法反查地址,只能靠坐标判断。</div>
{{end}}
<div class="row" style="align-items:center;gap:10px">
<label class="btn primary" for="photo-input" style="cursor:pointer">+ 选择照片</label>
<input type="file" id="photo-input" accept="image/*" multiple hidden>
<span class="muted" id="pick-count" style="font-size:12.5px">可多选,JPEG / HEIC 都行</span>
</div>
<div id="parse-status" class="alert info"></div>
</div>
<form method="post" action="/admin/photos/import" id="import-form" enctype="multipart/form-data">
<input type="hidden" name="n" id="row-count" value="0">
<input type="hidden" name="coord" id="coord-field" value="wgs84">
<div class="card" id="preview-card" style="display:none">
<h2>2. 确认与修改</h2>
<div class="card-sub">对照「地址」列确认点是否正确;点该行的「更多」可以改类别、省市区街道、活动、标签等——字段与<a href="/admin/edit">新增地点</a>完全一致。</div>
<div id="map-card" style="display:none">
<div id="photo-map" class="photo-map"></div>
<div class="card-sub" style="margin:6px 2px 0">地图上是所有已读取的点(坐标已按上面的选择换算);点跑到国外或海里说明坐标系选错了。<b>鼠标移到下面任意一行,地图上对应的点会跳出来</b>,点「地图」可以看单点大图。</div>
</div>
<div class="kpis" style="margin:12px 0 14px">
<div class="kpi"><div class="v" id="k-total">0</div><div class="k">读取到的照片</div></div>
<div class="kpi"><div class="v" id="k-gps">0</div><div class="k">含坐标</div></div>
<div class="kpi"><div class="v" id="k-addr">0</div><div class="k">地址反查成功</div></div>
<div class="kpi"><div class="v" id="k-todo">0</div><div class="k">待补坐标 / 时间</div></div>
</div>
{{/* 固定列宽比例:输入框跟着列宽走,表格就不会被撑宽到需要横向拖动 */}}
<div class="tw">
<table class="tb photo-table">
<colgroup>
<col style="width:4%"><col style="width:7%"><col style="width:10%"><col style="width:11%">
<col style="width:11%"><col style="width:19%"><col style="width:12%"><col style="width:12%">
<col style="width:14%">
</colgroup>
<thead>
<tr>
<th>导入</th><th>存图</th><th>文件</th><th>地点名</th><th>坐标</th><th>地址(反查)</th>
<th>到达时间</th><th>离开时间</th><th>说明</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="row" style="margin-top:12px;gap:10px">
<button class="btn primary" type="submit">写入数据库</button>
<button class="btn" type="button" id="retry-btn">重新反查未成功的</button>
<button class="btn" type="button" id="reset-btn">重新选照片</button>
</div>
<div class="req-tip" style="margin-top:8px">导入后每张照片会生成一个地点 + 一条到访(标记为「补录」),可在<a href="/admin/edit">数据编辑</a>里继续调整或删除。</div>
</div>
</form>
</div>
{{/* 单个点位的地图:总图留着看分布,这一个用来看「这个点到底在哪」 */}}
<div id="point-modal" style="display:none">
<div class="point-modal-box">
<div class="point-modal-head">
<b id="point-modal-title">点位</b>
<button class="btn sm" type="button" id="point-modal-close">关闭</button>
</div>
<div id="point-modal-map" class="point-modal-map"></div>
<div id="point-modal-meta" class="card-sub" style="margin:8px 2px 0"></div>
</div>
</div>
<script>
(function () {
var DEFAULT_STAY_MIN = 10;
var input = document.getElementById('photo-input');
var rowsBox = document.getElementById('rows');
var previewCard = document.getElementById('preview-card');
var mapCard = document.getElementById('map-card');
var status = document.getElementById('parse-status');
var rowCount = document.getElementById('row-count');
var coordField = document.getElementById('coord-field');
if (!input || !window.rondExif) return;
function coordSel() {
var el = document.querySelector('input[name=coord-sel]:checked');
return el ? el.value : 'wgs84';
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
// datetime-local 需要的本地时间串(EXIF 没有时区,按拍摄地当地时间理解)
function dtLocal(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function tInput(name, value, placeholder, width) {
var el = document.createElement('input');
el.type = 'text';
el.name = name;
el.value = value || '';
el.placeholder = placeholder || '';
// 宽度交给列宽(photo-table 是 fixed 布局,input 撑满格子即可)
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function dtInput(name, value) {
var el = document.createElement('input');
el.type = 'datetime-local';
el.name = name;
el.value = value || '';
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function fieldLabel(text, control, flex) {
var lab = document.createElement('label');
lab.className = 'f';
lab.style.cssText = flex + ';margin-bottom:0';
var sp = document.createElement('span');
sp.textContent = text;
lab.appendChild(sp);
lab.appendChild(control);
return lab;
}
// 更多里的分组小标题:一堆输入框挤在一起像锅粥,每组给一句说明
function groupCap(text) {
var d = document.createElement('div');
d.className = 'card-sub';
d.style.cssText = 'margin:0 0 4px';
d.textContent = text;
return d;
}
// 鼠标划到某一行时把图上那个点挑出来(index 为 null 表示复原)。
// 记一下当前高亮的是谁:鼠标在「主行 ↔ 更多块」之间移动会连发几次事件,
// 行号没变就不必让地图重设一遍样式(会闪)。
var hoveredRow = null;
function highlightRow(i) {
if (hoveredRow === i) return;
hoveredRow = i;
if (!window.rondMap) return;
window.rondMap.highlight('photo-map', i === null ? null : 'row-' + i);
}
// 主行和它的「更多」块是两条独立的 <tr>,各自绑 mouseleave 的话,
// 鼠标一进「更多」块就会被主行的 leave 把高亮清掉——而改字段往往就在那块里。
// 所以两行打上同一个行号,统一按「光标底下那一行是谁」来算。
rowsBox.addEventListener('mouseover', function (e) {
var tr = e.target && e.target.closest ? e.target.closest('tr') : null;
highlightRow(tr && tr.dataset.rowIndex !== undefined ? Number(tr.dataset.rowIndex) : null);
});
rowsBox.addEventListener('mouseleave', function () { highlightRow(null); });
// 按隐藏 select 的选项克隆一个独立的下拉(每行一份,互不影响)
function makeSelect(name, srcId) {
var src = document.getElementById(srcId);
var sel = document.createElement('select');
sel.name = name;
if (src) {
[].forEach.call(src.options, function (o) {
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.textContent;
sel.appendChild(opt);
});
}
return sel;
}
function setVal(name, v) {
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
if (el && v) el.value = v;
}
function valOf(idx, field) {
var el = rowsBox.querySelector('[name="' + field + '_' + idx + '"]');
return el ? el.value : '';
}
// 一行 = 主行(常用字段)+ 默认收起的「更多」行(字段与新增地点一致)
function addRow(index, file, info, errMsg) {
var tr = document.createElement('tr');
var td = document.createElement('td');
var keep = document.createElement('input');
keep.type = 'checkbox';
keep.name = 'keep_' + index;
keep.checked = !!(info && info.hasGPS); // 没读到坐标的默认不导入
td.appendChild(keep);
tr.appendChild(td);
// 存图:勾上就把这张照片在浏览器里转成 WebP 一起提交,作为该地点的配图。
// 默认不勾——一次导入几十张全存会占不少空间,也可能把相册里的截图一起带进去。
td = document.createElement('td');
var keepImg = document.createElement('input');
keepImg.type = 'checkbox';
keepImg.name = 'keepimg_' + index;
keepImg.title = '把这张照片在浏览器里转成 WebP(长边 1440px、约 100~250KB)存为该地点的图片,地点页可查看、可删';
td.appendChild(keepImg);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
td.textContent = file.name;
td.title = file.name;
tr.appendChild(td);
td = document.createElement('td');
var initialName = file.name.replace(/\.[^.]+$/, '');
var nameInput = tInput('name_' + index, initialName, '地点名', '120px');
nameInput.dataset.initial = initialName;
td.appendChild(nameInput);
tr.appendChild(td);
td = document.createElement('td');
var latEl = tInput('lat_' + index, info && info.hasGPS ? info.lat.toFixed(6) : '', '纬度', '98px');
var lonEl = tInput('lon_' + index, info && info.hasGPS ? info.lon.toFixed(6) : '', '经度', '98px');
latEl.style.marginBottom = '4px';
td.appendChild(latEl);
td.appendChild(lonEl);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:260px';
var addrEl = document.createElement('div');
addrEl.textContent = '—';
td.appendChild(addrEl);
tr.appendChild(td);
var arrival = '', departure = '';
if (info && info.hasTime && info.shot) {
arrival = dtLocal(info.shot);
departure = dtLocal(new Date(info.shot.getTime() + DEFAULT_STAY_MIN * 60000));
}
td = document.createElement('td');
td.appendChild(dtInput('arrival_' + index, arrival));
tr.appendChild(td);
td = document.createElement('td');
td.appendChild(dtInput('departure_' + index, departure));
tr.appendChild(td);
var notes = [];
if (errMsg) notes.push(errMsg);
if (info && info.hasGPS && !info.hasTime) notes.push('没有拍摄时间,请手工填写');
if (info && !info.hasGPS && info.hasTime) notes.push('没有 GPS 坐标,请手工填写');
td = document.createElement('td');
td.className = 'muted';
td.style.maxWidth = '220px';
var noteEl = document.createElement('div');
noteEl.textContent = notes.join(';');
td.appendChild(noteEl);
// 操作链接单独一行且整体不折行——中文默认能在任意字间断开,
// 「地图」会被拆成「地」「图」两行
var acts = document.createElement('div');
acts.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-top:6px';
var mapLink = document.createElement('a');
mapLink.href = 'javascript:void(0)';
mapLink.textContent = '地图';
mapLink.className = 'btn sm';
mapLink.addEventListener('click', function () {
var lat = parseFloat(valOf(index, 'lat')), lon = parseFloat(valOf(index, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) { window.alert('这一行还没有坐标'); return; }
openPointMap(lat, lon, valOf(index, 'name'), addrEl.textContent);
});
acts.appendChild(mapLink);
var moreLink = document.createElement('a');
moreLink.href = 'javascript:void(0)';
moreLink.textContent = '更多';
moreLink.className = 'btn sm';
acts.appendChild(moreLink);
td.appendChild(acts);
tr.appendChild(td);
rowsBox.appendChild(tr);
// ---- 更多:地点属性(与新增地点一致)----
var tr2 = document.createElement('tr');
tr2.style.display = 'none';
var td2 = document.createElement('td');
// 主行是 9 列(colSpan 少一列的话,这块灰底就比上面窄一格,看着很突兀)
td2.colSpan = 9;
td2.style.cssText = 'background:var(--bg-2);padding:12px 14px'
var row1 = document.createElement('div');
row1.className = 'row';
var catSel = makeSelect('category_' + index, 'category-options');
var catCustom = tInput('category_custom_' + index, '', '如 公园', '140px');
var catCustomWrap = fieldLabel('自定义类别', catCustom, 'flex:0 1 150px');
catCustomWrap.style.display = 'none';
catSel.addEventListener('change', function () {
catCustomWrap.style.display = catSel.value === '__custom__' ? '' : 'none';
});
td2.appendChild(groupCap('地点属性'));
row1.appendChild(fieldLabel('类别', catSel, 'flex:0 1 160px'));
row1.appendChild(catCustomWrap);
[
['country_' + index, '国家代码', 'CN', '90px'],
['province_' + index, '省份', '', '100px'],
['city_' + index, '城市', '', '100px'],
['district_' + index, '区县', '', '100px'],
['street_' + index, '街道', '', '140px'],
['timezone_' + index, '时区', 'Asia/Shanghai', '130px']
].forEach(function (spec) {
row1.appendChild(fieldLabel(spec[1], tInput(spec[0], spec[2], '', spec[3]), 'flex:0 1 ' + spec[3]));
});
td2.appendChild(row1);
// ---- 更多:到访属性(与新增到访一致)----
var row2 = document.createElement('div');
row2.className = 'row';
var cap2 = groupCap('到访属性');
cap2.style.marginTop = '14px';
td2.appendChild(cap2);
row2.appendChild(fieldLabel('活动', makeSelect('activity_' + index, 'activity-options'), 'flex:0 1 170px'));
// 备注不设 flex-grow:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
var favWrap = document.createElement('label');
favWrap.className = 'check';
favWrap.style.cssText = 'margin:0;flex:0 0 auto;align-self:flex-end;padding-bottom:9px';
var fav = document.createElement('input');
fav.type = 'checkbox';
fav.name = 'bookmarked_' + index;
favWrap.appendChild(fav);
favWrap.appendChild(document.createTextNode(' 标记为收藏'));
row2.appendChild(favWrap);
td2.appendChild(row2);
// ---- 更多:标签(多选 chips)----
var tagSrc = document.getElementById('tag-options');
if (tagSrc && tagSrc.options.length) {
var cap3 = groupCap('标签');
cap3.style.marginTop = '14px';
td2.appendChild(cap3);
var row3 = document.createElement('div');
row3.className = 'chips';
[].forEach.call(tagSrc.options, function (o) {
var chip = document.createElement('label');
chip.className = 'chip';
chip.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.name = 'tags_' + index;
cb.value = o.value;
cb.style.marginRight = '5px';
chip.appendChild(cb);
chip.appendChild(document.createTextNode(o.textContent));
row3.appendChild(chip);
});
td2.appendChild(row3);
}
tr2.appendChild(td2);
rowsBox.appendChild(tr2);
moreLink.addEventListener('click', function () {
var show = tr2.style.display === 'none';
tr2.style.display = show ? '' : 'none';
moreLink.textContent = show ? '收起' : '更多';
});
// 行号打在两行 <tr> 上(主行 + 「更多」行),rowsBox 的 mouseover 靠它找回来
tr.dataset.rowIndex = index;
tr2.dataset.rowIndex = index;
return { addrEl: addrEl, file: file, keepImg: keepImg };
}
function initPickers() {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
if (el._flatpickr) return;
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
}
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
function lookup(coords) {
return fetch('/api/regeo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coord: coordSel(), points: coords })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) throw new Error(d.error);
return d.items || [];
});
}
// 世代号:连续选两次文件时,旧那一轮的解析结果一律丢弃,避免表格出现重复行
var gen = 0;
// rowRefs 提到外层:applyOne 与「重新反查」按钮都要按行号找回那一行的地址元素
var rowRefs = {};
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
if (!files.length) return;
var my = ++gen;
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = '';
mapCard.style.display = 'none';
status.textContent = '正在读取 ' + files.length + ' 张照片的 EXIF…';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '已选 ' + files.length + ' 张';
rowRefs = {};
var done = 0, withGPS = 0;
var chain = Promise.resolve();
files.forEach(function (file, i) {
chain = chain.then(function () {
if (my !== gen) return;
return window.rondExif.read(file).then(function (info) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, info, null);
if (info.hasGPS) withGPS++;
}).catch(function (err) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, null, (err && err.message) || '读取失败');
}).then(function () {
if (my !== gen) return;
done++;
status.textContent = '正在读取 ' + done + '/' + files.length + '…';
initPickers();
});
});
});
chain.then(function () {
if (my !== gen) return;
rowCount.value = String(files.length);
// EXIF 一读完就把数字亮出来(此时还没反查,地址数为 0);后面每条路径都会再刷
updateKpis();
if (!withGPS) {
status.textContent = '已读取 ' + files.length + ' 张,但都没有可用的 GPS 坐标。';
return;
}
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),正在反查地址…';
// 收集坐标:key 用行号,服务端去重后仍能配回每一行
var coords = [], owner = [];
files.forEach(function (file, i) {
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
coords.push({ lat: lat, lon: lon, key: String(i) });
owner.push(i);
});
if (!coords.length) {
status.textContent = '已读取 ' + files.length + ' 张,没有可反查的坐标。';
return;
}
lookup(coords).then(function (items) {
if (my !== gen) return;
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
owner.forEach(function (i) {
var it = byKey[String(i)];
if (!it || !it.address) return;
hit++;
applyOne(i, it);
});
finishLookup(files.length, withGPS, hit, owner.length);
}).catch(function (err) {
if (my !== gen) return;
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),但地址反查失败:' +
(err.message || err) + '。可以点「重新反查未成功的」再试,或只看坐标判断。';
drawMap();
});
});
});
// applyOne 把一条反查结果写回它的行:地址、店名、坐标、省市区街道、地点名
function applyOne(i, it) {
if (rowRefs[i]) {
// 有店名时显示成「店名 · 完整地址」,比干巴巴的街道好认
var text = it.address || '';
if (it.poi) {
text = it.poi + (it.poiDist ? '(' + Math.round(it.poiDist) + 'm)' : '') + ' · ' + text;
}
rowRefs[i].addrEl.textContent = text;
rowRefs[i].addrEl.title = text;
}
// 服务端返回的是换算后的 GCJ-02 坐标,写回表格保证导入的就是它
if (it.lat && it.lon) {
var latEl = rowsBox.querySelector('input[name=lat_' + i + ']');
var lonEl = rowsBox.querySelector('input[name=lon_' + i + ']');
if (latEl) latEl.value = it.lat.toFixed(6);
if (lonEl) lonEl.value = it.lon.toFixed(6);
}
setVal('province_' + i, it.province);
setVal('city_' + i, it.city);
setVal('district_' + i, it.district);
setVal('street_' + i, it.street);
if (it.country) setVal('country_' + i, it.country);
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
if (nameEl && nameEl.value === nameEl.dataset.initial) {
// 名称还停在文件名(用户没改过)时优先用店名,其次「区县 + 街道/乡镇」
var better = it.poi || [it.district, it.street || it.township].filter(Boolean).join('');
if (better) nameEl.value = better;
}
}
// 四个数字比一行长句子好扫。待补=没有坐标或没有到达时间的行(这两项不补就导不进去)
function kpi(id, v) { var el = document.getElementById(id); if (el) el.textContent = String(v); }
// 全部从 DOM 现算,不靠调用方传参:谁改了表格谁就调一次 updateKpis() 即可。
// 之前是「哪些路径记得传参才更新」,漏了「全都没 GPS」「没有可反查的坐标」「重新反查」
// 这几条,数字会停在上一批的值上——比显示 0 更容易误导。
function updateKpis() {
var total = 0, gps = 0, addr = 0, todo = 0;
Object.keys(rowRefs).forEach(function (k) {
var i = Number(k);
total++;
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
var hasPoint = !isNaN(lat) && !isNaN(lon) && !!(lat || lon);
if (hasPoint) gps++;
// 地址列还停在占位符 = 这一行没反查成功(与「重新反查」挑行的判据同源)
var ref = rowRefs[i];
if (ref && ref.addrEl && ref.addrEl.textContent !== '—') addr++;
if (!hasPoint || !valOf(i, 'arrival')) todo++;
});
kpi('k-total', total); kpi('k-gps', gps); kpi('k-addr', addr); kpi('k-todo', todo);
}
// 手工改过坐标/时间之后数字要跟着变,不然一直挂在旧值上
rowsBox.addEventListener('change', updateKpis);
function finishLookup(total, withGPS, hit, asked) {
status.textContent = '已读取 ' + total + ' 张(' + withGPS + ' 张含坐标),' +
hit + '/' + asked + ' 个点位反查成功' +
(hit < asked ? ',可点「重新反查未成功的」再试' : '') + '。确认无误后点下面的按钮写入数据库。';
drawMap();
}
// ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ----
var modal = document.getElementById('point-modal');
var modalInited = false;
function openPointMap(lat, lon, name, addr) {
document.getElementById('point-modal-title').textContent = name || '点位';
document.getElementById('point-modal-meta').textContent =
(addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6);
modal.style.display = 'flex';
var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }];
if (!modalInited) {
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
window.rondMap.preview({ id: 'point-modal-map', points: pts });
modalInited = true;
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
var pin = function () {
var st = window.rondMap._state['point-modal-map'];
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
};
setTimeout(pin, 400);
setTimeout(pin, 1200);
} else {
var st = window.rondMap._state['point-modal-map'];
if (st && st.setPoints) st.setPoints(pts);
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
}
}
function closePointMap() { modal.style.display = 'none'; }
document.getElementById('point-modal-close').addEventListener('click', closePointMap);
modal.addEventListener('click', function (e) { if (e.target === modal) closePointMap(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePointMap(); });
function drawMap() {
// 放最前面:这是「表格改完了」的统一出口,KPI 在这里收口就不会再漏路径
updateKpis();
if (!window.rondMap) return;
var points = [];
[].forEach.call(rowsBox.querySelectorAll('input[name^=lat_]'), function (el) {
var i = el.name.replace('lat_', '');
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
var lat = parseFloat(el.value), lon = parseFloat(valOf(i, 'lon'));
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
points.push({ lat: lat, lon: lon, n: nameEl ? nameEl.value : '', v: 1, key: 'row-' + i });
});
if (!points.length) { mapCard.style.display = 'none'; return; }
mapCard.style.display = '';
window.rondMap.preview({ id: 'photo-map', points: points });
syncStuck();
}
// 图粘住时收矮(.stuck):不收的话 200px 的浮层会挡住从下面滚过去的半个表格。
// 容器高度变了必须告诉 Leaflet 重新量一次,否则底图会缺一块。
// 导航高度只在窗口尺寸变化时才会变,所以在滚动路径上不能每帧去解析 --nav-h
// (读自定义属性会触发一次样式计算);滚动里只留必须实时的 getBoundingClientRect。
var stuckOffset = 0;
function measureNav() {
stuckOffset = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h') || '54') + 8;
}
function syncStuck() {
var stuck = mapCard.getBoundingClientRect().top <= stuckOffset + 2;
if (stuck === mapCard.classList.contains('stuck')) return;
mapCard.classList.toggle('stuck', stuck);
var st = window.rondMap && window.rondMap._state['photo-map'];
if (st && st.map) setTimeout(function () { st.map.invalidateSize(); }, 260);
}
measureNav();
window.addEventListener('scroll', syncStuck, { passive: true });
window.addEventListener('resize', function () { measureNav(); syncStuck(); });
document.getElementById('retry-btn').addEventListener('click', function () {
var todo = [];
Object.keys(rowRefs).forEach(function (k) {
var ref = rowRefs[k];
// 地址列还是占位符说明这行没查到(或查失败)
if (!ref || !ref.addrEl || ref.addrEl.textContent !== '—') return;
var lat = parseFloat(valOf(k, 'lat')), lon = parseFloat(valOf(k, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
todo.push({ lat: lat, lon: lon, key: String(k) });
});
if (!todo.length) { status.textContent = '没有需要重新反查的行。'; return; }
status.textContent = '正在重新反查 ' + todo.length + ' 个点位…';
lookup(todo).then(function (items) {
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
todo.forEach(function (c) {
var it = byKey[c.key];
if (!it || !it.address) return;
hit++;
applyOne(Number(c.key), it);
});
status.textContent = '重新反查完成:' + hit + '/' + todo.length + ' 个成功' +
(hit < todo.length ? '(其余的周边可能确实没有收录,或坐标不在境内)' : '') + '。';
drawMap();
}).catch(function (err) {
status.textContent = '重新反查失败:' + ((err && err.message) || err);
});
});
document.getElementById('reset-btn').addEventListener('click', function () {
gen++;
input.value = '';
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = 'none';
mapCard.style.display = 'none';
status.textContent = '';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '可多选,JPEG / HEIC 都行';
window.scrollTo(0, 0);
});
[].forEach.call(document.querySelectorAll('input[name=coord-sel]'), function (el) {
el.addEventListener('change', function () { coordField.value = coordSel(); });
});
/* ---------- 提交前把勾了「存图」的照片转成 WebP ----------
* 转码是异步的,所以先拦下这次提交,转完再 form.submit() 放行;
* form.submit() 不触发 submit 事件,不会自己套自己。
* 转好的字节塞进隐藏的 <input type=file>,随表单一起以 multipart 发出去。
*/
var form = document.getElementById('import-form');
var submitBtn = form.querySelector('button[type=submit]');
form.addEventListener('submit', function (e) {
if (form.dataset.ready === '1') return; // 放行那一趟
var picked = [];
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
if (!cb.checked) return;
var i = Number(cb.name.slice('keepimg_'.length));
var ref = rowRefs[i];
if (ref && ref.file) picked.push({ i: i, file: ref.file, cb: cb });
});
if (!picked.length) { form.dataset.ready = '1'; return; }
// 服务端对整包有 60MB 上限:按转码后的体积(约 100~250KB)估,这里保守按 200 张提醒一次
if (picked.length > 200 && !window.confirm('这次勾了 ' + picked.length + ' 张要存为图片,' +
'总量可能超过服务端 60MB 的上限导致整包提交失败。建议分两次导入,是否继续?')) {
if (submitBtn) submitBtn.disabled = false;
return;
}
e.preventDefault();
if (submitBtn) submitBtn.disabled = true;
var at = 0, failed = 0;
function next() {
if (at >= picked.length) {
if (failed) {
// 有转码失败的:提示后仍然继续导入地点,只是那几张不带图
status.textContent = '已跳过 ' + failed + ' 张转码失败的图片,正在写入数据库…';
} else {
status.textContent = '图片已就绪,正在写入数据库…';
}
form.dataset.ready = '1';
form.submit();
return;
}
var it = picked[at++];
status.textContent = '正在转码图片 ' + at + '/' + picked.length + '…';
window.rondImage.toWebP(it.file).then(function (out) {
var holder = document.createElement('input');
holder.type = 'file';
holder.name = 'image_' + it.i;
holder.style.display = 'none';
var dt = new DataTransfer();
dt.items.add(new File([out.blob], 'image.webp', { type: 'image/webp' }));
holder.files = dt.files;
form.appendChild(holder);
// 宽高随文件一起发:服务端不解码图片,只记账
[['image_w_', out.width], ['image_h_', out.height]].forEach(function (pair) {
var h = document.createElement('input');
h.type = 'hidden';
h.name = pair[0] + it.i;
h.value = pair[1];
form.appendChild(h);
});
}).catch(function (err) {
failed++;
it.cb.checked = false;
status.textContent = '第 ' + (it.i + 1) + ' 行的图片转码失败(已跳过):' +
((err && err.message) || err);
}).then(next);
}
next();
});
})();
</script>
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>站点设置</h1>
<div class="sub">控制主页展示的模块、隐私处理与地图底图</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">设置已保存</div>{{end}}
<form method="post" action="/admin/settings">
<div class="card">
<h2>基本信息</h2>
<div class="card-sub">展示在主页顶部与浏览器标题</div>
<div class="grid g2">
<label class="f"><span>站点标题</span><input type="text" name="site_title" value="{{.Settings.SiteTitle}}" maxlength="40"></label>
<label class="f"><span>副标题</span><input type="text" name="site_subtitle" value="{{.Settings.SiteSubtitle}}" maxlength="80" placeholder="记录我去过的每一个地方"></label>
<label class="f"><span>署名</span><input type="text" name="owner_name" value="{{.Settings.OwnerName}}" maxlength="30"></label>
<label class="f"><span>头像(单个 emoji)</span><input type="text" name="owner_avatar" value="{{.Settings.OwnerAvatar}}" maxlength="8"></label>
</div>
<label class="f"><span>主色调</span>
<div class="row">
<input type="color" name="accent_color" value="{{.Settings.AccentColor}}" style="width:56px;height:34px;padding:2px">
<span class="muted">用于按钮、图表与选中状态</span>
</div>
</label>
</div>
<div class="card">
<h2>主页展示模块</h2>
<div class="card-sub">取消勾选的模块不会出现在主页上;一个都不选时按全部展示处理</div>
<div class="grid g2">
{{range .Modules}}
<label class="check">
<input type="checkbox" name="modules" value="{{.Key}}" {{if $.Settings.Has .Key}}checked{{end}}>
<span><b style="font-weight:500">{{.Name}}</b> <span class="muted">— {{.Desc}}</span></span>
</label>
{{end}}
</div>
</div>
<div class="card">
<h2>公开页面</h2>
<div class="card-sub">控制访客能在导航里看到并访问哪些页面;你登录后始终可以访问全部页面</div>
<div class="grid g2">
{{range .Pages}}
<label class="check">
<input type="checkbox" name="pages" value="{{.Key}}" {{if $.Settings.HasPage .Key}}checked{{end}}>
<span><b style="font-weight:500">{{.Name}}</b> <span class="muted">— {{.Desc}}</span></span>
</label>
{{end}}
</div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
关闭的页面在访客导航中隐藏,直接输入网址也会得到 404。主页不受此开关影响。
</div>
<label class="f" style="max-width:320px;margin-top:14px">
<span>访客默认页面</span>
<select name="visitor_home">
<option value="home" {{if eq .Settings.VisitorHome "home"}}selected{{end}}>主页(默认)</option>
{{range .Pages}}<option value="{{.Key}}" {{if eq $.Settings.VisitorHome .Key}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<div class="muted" style="font-size:12.5px;margin-top:6px">
访客访问站点根路径时直接落到这里;你登录后访问根路径仍然看主页。
选择了一个没对访客开放的页面时不会跳转(会留在主页)。
</div>
</div>
<div class="card">
<h2>访客可见区域</h2>
<div class="card-sub">
一行一个,省 / 市 / 区都认(例如「广东省」「贵阳市」「南明区」)。
留空表示不限制,访客能看到全部数据;你自己登录后始终是全量。
</div>
<label class="check"><input type="radio" name="regions_mode" value="whitelist" {{if eq .Settings.RegionsMode "whitelist"}}checked{{end}}> 白名单:访客<span class="nobr">只能看到这些区域</span>的记录</label>
<label class="check"><input type="radio" name="regions_mode" value="blacklist" {{if eq .Settings.RegionsMode "blacklist"}}checked{{end}}> 黑名单:这些区域的记录<span class="nobr">对访客隐藏</span>,其余照常开放</label>
<label class="f" style="max-width:520px;margin-top:10px">
<span>区域列表</span>
<textarea name="regions" rows="5" placeholder="广东省&#10;贵阳市&#10;南明区">{{range .Settings.Regions}}{{.}}
{{end}}</textarea>
</label>
</div>
<div class="card">
<h2>隐私处理</h2>
<div class="card-sub">「家」和「工作」通常是敏感位置,默认已隐藏</div>
<label class="check"><input type="checkbox" name="hide_home" {{if .Settings.HideHome}}checked{{end}}> 隐藏类型为「家」的记录</label>
<label class="check"><input type="checkbox" name="hide_work" {{if .Settings.HideWork}}checked{{end}}> 隐藏类型为「工作」的记录</label>
<label class="check"><input type="checkbox" name="notes_public" {{if .Settings.NotesPublic}}checked{{end}}> 地点备注对访客公开(不勾选则只有你自己能看到)</label>
<label class="check"><input type="checkbox" name="marks_public" {{if .Settings.MarksPublic}}checked{{end}}> 「推荐 / 踩雷」标记与票数对访客公开</label>
<div class="muted" style="font-size:12.5px">
隐藏后匿名访客看不到这两类记录。你登录后可在任意公开页面用筛选器上的
<span class="mono">含家/工作</span> 开关临时显示给自己看。
</div>
<label class="f" style="max-width:280px;margin-top:12px">
<span>忽略停留时间过短的记录</span>
<select name="min_dwell_min">
<option value="0" {{if eq .Settings.MinDwellMin 0}}selected{{end}}>不过滤</option>
<option value="2" {{if eq .Settings.MinDwellMin 2}}selected{{end}}>少于 2 分钟</option>
<option value="5" {{if eq .Settings.MinDwellMin 5}}selected{{end}}>少于 5 分钟</option>
<option value="10" {{if eq .Settings.MinDwellMin 10}}selected{{end}}>少于 10 分钟</option>
</select>
</label>
<div class="muted" style="font-size:12.5px">该设置作用于全站统计与地图,可有效过滤路过点。</div>
</div>
<div class="card">
<h2>地图底图</h2>
<div class="card-sub">在 <span class="mono">conf/app.ini</span> 中配置,改完重启生效</div>
<div class="kv"><span class="k">当前生效</span><span class="v">{{.MapInfo}}</span></div>
{{if .MapLayers}}
<label class="f" style="max-width:340px;margin-top:10px"><span>默认用哪张底图</span>
<select name="map_default_base">
<option value="" {{if not .Settings.MapDefaultBase}}selected{{end}}>跟随第一张({{(index .MapLayers 0).Label}})</option>
{{range .MapLayers}}<option value="{{.Key}}" {{if eq .Key $.Settings.MapDefaultBase}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<div class="card-sub" style="margin-top:0">放大后底图自带的 POI 与路名标注太密、看不清自己的点时,换成<b>纯路网</b>。
访客在地图上手动切换过的话,浏览器会记住他自己的选择。</div>
{{end}}
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div><span class="mono">map_provider</span>:<span class="mono">amap</span>(默认,免密钥)/ <span class="mono">tianditu</span>(需 <span class="mono">map_key</span>)/ <span class="mono">custom</span>(自备 <span class="mono">map_tile_url</span>)</div>
<div><span class="mono">map_key</span>:填写后瓦片改由服务端代理,密钥不会出现在页面源码里。</div>
<div><span class="mono">map_crs</span>:仅 <span class="mono">custom</span> 使用,填 <span class="mono">gcj02</span> 或 <span class="mono">wgs84</span>;站点数据本身是 GCJ-02,换成 WGS-84 底图(天地图 / OSM)时会自动纠偏。</div>
</div>
</div>
<div class="card">
<h2>图片存储</h2>
<div class="card-sub">地点图片存到哪儿。图片在<b>浏览器里</b>转成 WebP 后才上传,服务器只负责收下与转存</div>
<label class="check"><input type="radio" name="media_backend" value="local" {{if ne .Settings.MediaBackend "s3"}}checked{{end}}> 服务器本地磁盘</label>
<label class="check"><input type="radio" name="media_backend" value="s3" {{if eq .Settings.MediaBackend "s3"}}checked{{end}}> 对象存储(S3 兼容,如 Cloudflare R2)</label>
<div class="kv" style="margin-top:8px"><span class="k">对象存储</span><span class="v">{{.S3Info}}</span></div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div>凭据放在 <span class="mono">conf/app.ini</span>,不进数据库、也不会下发到前端:</div>
<div><span class="mono">s3_endpoint</span>(R2 形如 <span class="mono">https://&lt;账号&gt;.r2.cloudflarestorage.com</span>)、<span class="mono">s3_bucket</span>、<span class="mono">s3_access_key</span>、<span class="mono">s3_secret_key</span>、<span class="mono">s3_region</span>(R2 填 <span class="mono">auto</span>)。</div>
<div><span class="mono">s3_public_base</span>:桶的公开域名(自定义域或 r2.dev),拼上对象名就是 <span class="mono">&lt;img src&gt;</span>;不填的话图片存得进去但浏览器取不到。</div>
<div>改了 <span class="mono">conf/app.ini</span> 需要重启;这里的「存哪边」保存后立即生效,已经存过的老图仍按各自原来的位置取,不受影响。</div>
</div>
</div>
<div class="row">
<button class="btn primary" type="submit">保存设置</button>
<a class="btn" href="/admin">取消</a>
</div>
</form>
</div>
{{end}}
+105
View File
@@ -0,0 +1,105 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>专题管理</h1>
<div class="sub">把某段时间、某个区域里的足迹打包成一个可对外展示的页面(地点 + 世界迷雾)</div>
</div>
<div class="row">
<a class="btn" href="/admin">← 返回后台</a>
<a class="btn" href="/topics" target="_blank">查看前台</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<h2>{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}</h2>
<div class="card-sub">时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选。</div>
<form method="post" action="/admin/topics/save">
{{if not .IsNew}}<input type="hidden" name="id" value="{{.Form.ID}}">{{end}}
<div class="row">
<label class="f" style="flex:2 1 240px"><span>标题<b class="req">*</b></span><input type="text" name="title" value="{{.Form.Title}}" placeholder="如:2026 大理之行"></label>
<label class="f" style="flex:2 1 240px"><span>副标题</span><input type="text" name="subtitle" value="{{.Form.Subtitle}}" placeholder="如:环洱海骑行四天"></label>
</div>
<label class="f"><span>说明</span><textarea name="description" rows="3" placeholder="可选:这段行程的来龙去脉">{{.Form.Description}}</textarea></label>
<div class="row">
<label class="f" style="flex:1 1 200px"><span>开始时间<b class="req">*</b></span><input type="datetime-local" name="start_at" value="{{.Form.StartDate}}"></label>
<label class="f" style="flex:1 1 200px"><span>结束时间<b class="req">*</b></span><input type="datetime-local" name="end_at" value="{{.Form.EndDate}}"></label>
<label class="f" style="flex:1 1 120px"><span>排序</span><input type="number" name="sort_order" value="{{.Form.SortOrder}}" placeholder="小的在前"></label>
</div>
<div class="card-sub" style="margin:0 0 8px">区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域</div>
<div class="row">
<label class="f" style="flex:1 1 150px"><span>中心纬度</span><input type="text" name="center_lat" value="{{if .Form.CenterLat}}{{printf "%.6f" .Form.CenterLat}}{{end}}" placeholder="25.606486"></label>
<label class="f" style="flex:1 1 150px"><span>中心经度</span><input type="text" name="center_lon" value="{{if .Form.CenterLon}}{{printf "%.6f" .Form.CenterLon}}{{end}}" placeholder="100.267638"></label>
<label class="f" style="flex:1 1 120px"><span>半径(公里)</span><input type="text" name="radius_km" value="{{if .Form.RadiusKm}}{{printf "%.1f" .Form.RadiusKm}}{{end}}" placeholder="0 或留空=不限"></label>
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<label class="check" style="margin:0"><input type="checkbox" name="show_fog" {{if .Form.ShowFog}}checked{{end}}> 展示世界迷雾</label>
<label class="check" style="margin:0"><input type="checkbox" name="enabled" {{if .Form.Enabled}}checked{{end}}> 对外展示(取消勾选则访客看不到)</label>
</div>
<button class="btn primary" type="submit">{{if .IsNew}}创建专题{{else}}保存修改{{end}}</button>
{{if not .IsNew}}<a class="btn" href="/admin/topics">取消编辑</a>{{end}}
</form>
</div>
<div class="card">
<h2>专题列表</h2>
<div class="card-sub">共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览</div>
{{if .Topics}}
<div class="tw">
<table class="tb">
<thead><tr><th>标题</th><th>时间范围</th><th class="num">地点</th><th class="num">到访</th><th>区域</th><th>对访客</th><th></th></tr></thead>
<tbody>
{{range .Topics}}
<tr>
<td><a href="/topics/{{.ID}}" target="_blank">{{.Title}}</a>{{if .Subtitle}} <span class="muted">· {{.Subtitle}}</span>{{end}}</td>
<td class="muted mono">{{.RangeLabel}}</td>
<td class="num">{{num .PlaceCount}}</td>
<td class="num">{{num .VisitCount}}</td>
<td class="muted">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
<td>
<form method="post" action="/admin/topics/toggle" style="display:inline">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
</form>
</td>
<td style="white-space:nowrap">
<a class="btn sm" href="/admin/topics?edit={{.ID}}">编辑</a>
<form method="post" action="/admin/topics/delete" style="display:inline"
onsubmit="return confirm('删除专题「{{.Title}}」?地点数据不受影响。')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">还没有专题,用上面的表单建一个吧</div>
{{end}}
</div>
</div>
<script>
(function () {
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
whenReady(function () {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
});
})();
</script>
{{end}}

Some files were not shown because too many files have changed in this diff Show More