Files
rond-with-you/.workbuddy/memory/2026-09-16.md
T
tamakyi 3f1b737a78 记忆:把 .workbuddy/memory 纳入版本控制
用户要求这份记忆跟着仓库走,而不是只留在本机。

.gitignore 里挡路的是 `.workbuddy/`。改成反忽略六行,并且**只放行 .md**:
别用 `!.workbuddy/memory/**`——它会把 memory 下任何文件都放行,本项目真发生过
`web.test.exe` 混进暂存。复核用 `git check-ignore -q --no-index` 逐条打靶:
memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED,
17 个 .md 全 included;`git add -An .workbuddy/` 恰好 16 个文件。
其余 .workbuddy 内容(计划、本地设置、临时产物)继续忽略。记忆里没有真凭据
(唯一命中是 2026-09-14.md 里的 `password=rond-admin`,那是项目默认口令)。

顺带做两件整理:

1. 取证了注入上限。以前按「字节数」判断 MEMORY.md 是否超限是错的:
   WorkBuddy 的 `getWorkingMemoryContent()` 用 `MAX_MEMORY_CHARS = 1e4` 比 JS
   `content.length`,即**字符数**(中文 1 字算 1);超了 substring 截断、尾部静默丢失。
   本 details 与每日日志都不参与注入,只有 MEMORY.md 受约束。
   现已从 6604 字符收到上限的 66%,并把这条上限写进索引抬头。
2. 把「备份合并」「导出/恢复三条铁律」两段长文迁进 MEMORY-details.md 的卅一/卅二节,
   索引只留关键词指针。行尾统一为 LF(仓库 .gitattributes 是 `* text=auto eol=lf`)。
2026-09-30 18:31:08 +08:00

924 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-09-16
## rond-with-you:编辑页表单必填收紧 + 换 flatpickr 时间选择器
### 需求
到达时间、离开时间、活动设为必选;备注可选但不折叠;原生 datetime-local 不好用,换别的。
### 改动
- **新建地点**:`<details class="opt-visit">` 折叠移除,到访三要素平铺显示且必填(到达/离开/活动),
备注可选;语义从「可选同时创建到访」变为「与地点同时创建到访」。
- **新增到访**:离开时间、活动必填;「类型」改叫「活动」,默认 option 从「(无)」改「请选择活动」。
- **编辑到访(行内)**:离开时间也必填(历史 departure 0 条 NULL,无副作用);
活动不强制(历史 73 条 NULL 活动,强制会拦住编辑)。
- **时间选择器**:引入 flatpickr v4.6.13(vendor 到 `internal/web/static/vendor/flatpickr/`,
含 l10n/zh.js,共 ~69KB,离线可用);`Page.Flatpickr` 开关控制 layout 按页加载;
24 小时制、中文、`allowInput` 可手输,`dateFormat: 'Y-m-dTH:i'` 保证提交值仍是
datetime-local 格式,服务端 `parseLocalDT` 无需改动。
- **服务端**:`parseVisitTimes` 离开时间必填;`editVisitCreate` / `editPlaceCreate` 活动必填
(resolveActivity 返回 nil 即拒);editPlaceCreate 删除「arrival 空则不建到访」分支。
- **CSS**:`.flatpickr-calendar` 圆角/阴影与站点一致,选中/今天/hover 用 `--accent`。
### 验证
HTTP 实测:缺活动 → 「请选择活动类型」;缺离开 → 「请填写离开时间」;三要素齐 → 创建成功;
测试数据已删(visits 回到 823)。管理员密码已还原。
## 追加修复:搜索失效(flatpickr l10n 崩溃)+ 地点下拉信息不足
### 搜索失效根因
配置文件没动过(`conf/app.ini` 的 `geocode_key` 原样在,直调高德 API 也正常)。
真正根因是我给编辑页加的 flatpickr 初始化写错了 API:flatpickr 的语言包静态属性是
**`flatpickr.l10ns`(复数)**,我写成 `flatpickr.l10n.zh` → 读取 undefined 的 `.zh` 抛
TypeError → 同一个 `whenReady` 回调里**后面的地图初始化和地点搜索绑定全部没执行**。
表现就是「搜索按钮点了没反应」。
修复:`flatpickr.l10ns.zh` + 整段 try/catch 兜底(防止一处错毁掉全部初始化)。
教训:给页面加同步脚本时,先于其它初始化的代码必须自行兜错。
### 第二个坑(排查中新引入又修复)
改「新增到访」地点下拉时,`COALESCE(NULLIF(sublocality,''), NULLIF(district,''))` 在两列
同为 NULL 时结果仍是 NULL,pgx 扫进 string 报错 → **整个 /admin/edit 页面 500**。
COALESCE 兜底要兜到底:末尾再加一个 `''`。
### 地点下拉信息不足(用户反馈「看不出是哪次到访、有重复、太笼统」)
原 Label 只有「城市 · 名称」。改为从库里的 province/city/区县(sublocality 缺失时退 district)/
街道拼完整地址 + 到访次数 + src_pk:
`#196 家 — 广西壮族自治区 河池市 金城江区 西环路(246次)`
src_pk 直接对应「新增行程」表单里要填的值。相邻重复段去重(直辖市不会出现「北京市北京市」)。
### 无头验证结论
CDP 实测:0 JS 报错、flatpickr 中文包注册成功、Leaflet 地图初始化完成(执行流走完整)、
页内 XHR 调 `/api/geocode?q=南宁东站` 返回 8 条。
## 再修正:离开时间不该必填(用户纠正)
用户指出逻辑错误:补录「家」这类高频地点的到访,常常只知道到达时间(刚到家/只记到达),
强制填离开时间不成立。已回退:离开时间恢复可选,留空=只记了到达(原语义),
前端 required/星号移除,`parseVisitTimes` 恢复原实现。
新增单元测试 `internal/web/parse_visit_times_test.go` 覆盖四种情况
(只到达/双时间/缺到达/离开早于到达),无需重置密码即可验证服务端逻辑。
保持必填的:到达时间、活动(用户未质疑)。
教训:把「必填」要求落到每个字段前先想补录场景——必填清单该由「这条数据不填就无意义」决定,
而不是照单全收;用户说的"必选"清单可能覆盖不了真实的补录姿势。
## 再修正:到达时间默认当前时刻(用户质疑「246 次到家还让我手填到达时间」)
**这版方向错了,用户又否定了**(见下节)。到达时间预填当前时刻已回退。
## 根因澄清:不是必填问题,是「新增到访」语义传达失败
用户三层质疑(离开必填→到达重复→填一个时间代表什么)拼起来,真正的意思是:
**用户以为选了「家(246次)」后,表单填的时间要「代表」这个地点的全部到访**——
"246 次到达时间都不一样,填一个时间代表它们当然不合理"。
而功能的真实语义是「往时间线追加一条**独立的**新到访(比如某次忘带手机的那段)」,
每次提交一条、互不影响。UI 从没把这层讲清楚。
修正:
1. 回退「预填当前时刻」——补录的永远是历史时刻(忘带手机的那天),预填当下只会诱导提交错误数据。
2. 表单文案写明:「追加一次单独的到访……每提交一次就是一条全新的独立记录,和该地点已有的到访互不影响,
更不是去『代表』它们;下拉里的次数只是参考。时间凭当时记忆填个大概即可」。
3. flatpickr `minuteIncrement` 1→5:补录靠回忆,没人记得 12:59:47,5 分钟步进贴合「大概那个点」。
教训(重要):**用户连续质疑表单逻辑时,先怀疑语义传达,而不是急着调必填/默认值**。
我顺着「必填是否合理」修了三轮,实际是用户没看懂这个表单在创建什么。
## 融合表单:新增地点 + 新增到访合一(用户最终指令)
用户拍板:「把新增地点和新增到访融合起来,而不是分开,到访时间和日期选填,但是不要折叠选项。」
实现(internal/web/handlers_edit.go + templates/admin/edit.html + server.go):
- 一个融合表单(action 仍为 /admin/edit/place),radio 切两种模式:
`new`=新建地点(名称/坐标必填,地图选点+搜索);`existing`=给已有地点补到访(下拉选地点)。
- 到访时段**整体选填平铺**(到达/离开/活动/备注/收藏/标签):
到达留空 → 只建地点;填了到达 → 同时追加一条独立到访(活动也可不选,存 NULL)。
补已有地点时到达必填(否则没意义),后端给出明确提示。
- 删除独立「新增到访」card 与 `editVisitCreate` + 路由 POST /admin/edit/visit
(update/delete 保留)。到访的增删改现在全部走融合表单。
- HTML5 required 不能写在会被隐藏的字段上(display:none 仍会拦截提交),校验全交后端。
- 模式切换时 Leaflet 在 display:none 容器里会拿到 0 尺寸,切回 new 模式时
`window.dispatchEvent(new Event('resize'))` 触发 invalidateSize。
端到端实测(用 site_cdp.py 签名 cookie,无需动密码):四条路径
(补到访缺到达→拒 / 补到访→成 / 新地点无到访→只建地点 / 新地点带到访→地点+到访)
全部正确;测试数据经 HTTP 删除,计数核对 places=324(323 原始+昆明站)、visits=823、家=246。
## 编辑页三项易用性改进(用户第 4 轮反馈)
1. **补到访的地点选择器**:324 个地点用原生 `<select>` 无法搜索/收起。改为
`input#place-search`(按名称/城市/#编号过滤)+ `#place-results` 结果列表(最多 30 条,
复用 `.geo-item` 样式)+ `hidden input[name=existing_place]`;选中后收起输入框、
显示「已选:xxx 重选」。
2. **类别可直选**:新建地点的「类别」加 `datalist`,选项来自库里 `DISTINCT poi_category`。
原值是 MapKit 的 `MKPOICategoryRestaurant` 这类标识符(12 个),配了中文映射
(`poiCategoryText`),`<option value="原值" label="中文">`——提交值仍是原值,
与 rond 保持一致(rond 靠它取图标)。
3. **活动可新建**:新增 `POST /admin/edit/activity`(JSON),表单里「或新建活动」输入框 +
按钮,创建后即时 append 进活动下拉并选中;Enter 键也触发(preventDefault 防止误提交)。
新活动 `src_pk=MAX+1`、`color='blue'`。
### 踩坑
- **模板里别用 `{{printf "%q" .Label}}` 生成 JS 字面量**:html/template 会在 `<script>`
上下文再做一次 JS 转义,得到 `"\"#196 家…\""`(值里混进引号)。改成把数据渲染进隐藏的
`<select id="place-options">`,JS 读它的 option——彻底绕开模板转义问题。
- **验证脚本 site_cdp.py 默认只等 2.5s**,本页含地图瓦片,`whenReady`(等 load)还没跑完就去
eval 会得到「事件没绑上、结果为空」的假象。测这个页面要 `--wait 5`。
- **`<datalist>` 无输入时不展开、`<option label>` 兼容性差**,用户反馈「类别下拉是空的」。
类别只有十来个,直接用 `<select>` + 「自定义…」项。
## 类别下拉(second take)+ 数据库/站点审阅
### 类别改成 select(新建 + 每行行内编辑)
`EditPlace.CategoryOptions`(已有类别 + 当前值若不在其中则补上,避免真机里少见类别被静默改掉);
`pickCategory()` 处理「自定义…」→ 旁边 `category_custom` 输入框;`poiCategoryText` 中文映射
(value 仍是 `MKPOICategoryXxx` 原值,与 rond 一致)。行内编辑 40 行全部生效。
### 审计发现(tools/audit_db.sql,可复用)
全绿:聚合漂移 0、悬空引用 0(含 movements 的 place/visit 两端、weather、visit_tags 跨数据集)、
时间倒挂 0、笔记/投票错位 0。留档差集只有 1 行且是 `skipped=t`(那条孤独到访,正常)。
**已加固(防患于未然,当前数据未触发)**:
1. `pruneEntityRaw()`:删除地点/到访后清掉留档里已不在库的行。风险链是
「删除 → 新建取 MAX(src_pk)+1 → 复用被删编号 → 新行继承旧留档的 rond 私有字段」。
`skipped=TRUE` 的行保留。
2. 删除地点/到访时清理 `movements`(端点没了就是悬空引用)、`weather.visit_src` 置 NULL
(天气有独立价值,只解除关联)。
**数据特征(非缺陷,rond 原始数据自带)**:11 组同名同坐标重复地点(柳州站×3…)、
1 条零时长、1 条超 30 天(家 50517 分钟)、70 条无活动、218 个地点无类别。
**待用户决定的优化**:行程/天气表单仍要手填 src_pk(建议改可搜索下拉);
活动只能新建不能删改;place_notes/place_votes 缺 dataset_id(多数据集切换会串)。
## rond-with-you:导入导出字段级一致(entity_raw 全字段留档)
### 需求
「引入 rondbackup 时就应该保留所有字段和数据」——把现有数据清空、用 `data/20260914_151649.rondbackup` 重新导入,
并要求导出包与原始包**逐字段一致**;编辑表单把必填字段标出来。
### 做法
1. `entity_raw` 表(JSONB)在导入时存下每个实体的**完整原始行**(8 张业务表共 2540 行),
导出时不依赖基线库补字段,直接按留档逐列还原。
2. 写入改成 `INSERT OR REPLACE` 写全列:管理列取 PG 当前值,其余列取留档原值。
3. `entity_raw.skipped` 标记「因缺必填字段没能进 PG」的行(如无到达时间的孤独到访 ZVISIT#191),
导出时按留档原样补回——用户在站点上删掉的行不在此列,不会被复活。
### 本轮修掉的 5 个不一致(对比脚本 tools/verify_rondbackup.py)
| 问题 | 根因 | 修法 |
|---|---|---|
| ZVISIT 少 1 条 | 导入时 `Arrival == nil` 被跳过 | `skipped` 标记 + 导出补回 |
| ZVISIT.ZLOCATION/ZACTIVITY_ 全成 NULL | `placeSrcByID` 用 `SrcPK` 当 key,而 `v.PlaceID` 是 PG 自增 id | queryPlaces/queryActivities 改查 `id`,用 id 作 key |
| 时间戳差 1 微秒 | `Duration(float64秒 * 1e9)` 超出 float64 精确整数范围(2^53) | 整数秒与小数分开算:`int64(sec)*1e6 + round(frac*1e6)` |
| ZREMARK_/ZEMOJINAME_ 空串变 NULL | 空串当未填写存成 NULL | `keepEmptyString()`:PG 值为 nil 且留档是 `""` 时写回 `""` |
| 新建地点区县丢失 | 表单「区县」写的是 `district` 列,而导出映射的是 `sublocality` | 导出取 `COALESCE(sublocality, district)` |
另外给 `newRowDefaults["ZLOCATION"]` 补 `ZRADIUS: 100.0`(真机地点都有值,NULL 可能触发 rond 异常状态)。
### 结果
12 张表(8 托管 + ZRAWVISIT/ZKEYWORD/ZPOICATEGORY/ZACTIVITYGROUP)逐列逐行 **总差异 = 0**;
31 张表 / 47 个索引 / Z_METADATA(1585B) 结构完全一致;导出包 2871700B。
新建「昆明站」后重导出:仅 ZLOCATION 多 1 条,其余仍 0 差异;
新地点 ZPINSTYLE_=0 / ZTYPE_=0 / ZUSERADDED=0(不会再显示「固定」)。
### 编辑表单必填标记
- CSS 加 `.req`(红色星号)与 `.req-tip`(表单顶部说明行)。
- 必填:地点=名称/纬度/经度;到访=地点/到达时间;行程=起止到访 src_pk;天气=时间。
- 服务端补了 `editPlaceCreate` 的名称非空校验;`editPlaceUpdate` 名称留空时保留原值
(历史数据里有 1 个无名称地点,不该逼着补名字)。
### 踩坑
- `patchSQLite` 里 `sdb.SetMaxOpenConns(1)`:`BeginTx` 后任何 `sdb.QueryContext` 都会静默死锁
(表现为导出卡 5 分钟无输出)。所有只读查询必须在 `BeginTx` 之前做完。
- Git Bash 的 `/tmp` ≠ `C:/tmp`,脚本要写到项目内路径。
- psql 在 `/c/OtherFiles/PostgreSQL/18/bin/`(不是 Program Files);`pg_restore` 必须带 `-f`。
### 数据
- 清库前备份 `storage/backup/rond-with-you-20260916-0012.dump`(custom 格式)。
- 清库时误删了 place_notes(14) / place_votes(4),已从该备份用 `pg_restore -t ... --data-only` 恢复。
- 管理员凭据被临时重置过,已从备份还原原始 password_hash(原本就没启用 2FA)。
## 第 5 轮:4 项优化 + 专题展示功能
### 四项优化(用户「都修改」)
1. **行程 / 天气表单不再手填 src_pk**:抽了通用可搜索选择器 `.picker`
(`data-source` 指向隐藏 select,JS 读 option 当数据源——避开模板 JS 上下文转义),
地点、起始到访、结束到访、天气关联到访四处复用;新增 `EditData.VisitOptions`(最近 500 条到访)。
2. **活动可改名 / 删除**:`/admin/edit/activity/update|delete`;删除时把引用它的到访
`activity_id` 置 NULL 并清 `is_home/is_work` 冗余标记,再 pruneEntityRaw。
3. **place_notes / place_votes 加 dataset_id**:主键扩成 (user_id, dataset_id, src_pk),
用 DO 块判断主键列是否已是三列再重建(幂等);旧数据回填到当前激活数据集。
notes/votes 包所有方法加 datasetID 参数,web 层调用点全部跟着改;
stats 里 `place_votes` 的 EXISTS 关联加 `pv.dataset_id = p.dataset_id`。
place_visitor_votes 保持不挂 dataset_id(设计上刻意跟地点走,注释已写明)。
4. **异常时长裁剪**:`maxDwellMin = 10080`(7 天),stats 里 6 处 sum + 1 处子查询、
refreshPlaceAgg 全部改成 `sum(LEAST(COALESCE(duration_min,0), 10080))`;
存量 `places.dwell_minutes` 用等价 SQL 重算过(总停留 259096 → 218659 分钟)。
### 专题展示(新功能)
- **数据模型** `topics`:标题/副标题/说明、时间范围、中心点+半径(半径 0 = 不限区域)、
show_fog、enabled(是否对外展示)、sort_order。
- **内容**:时间范围内的到访聚合出地点列表;区域过滤用写进 SQL 的 haversine 表达式
(`topicHaversineSQL`),一次查询同时完成过滤与统计。
- **迷雾**不落库:地图用 `rondMap.full()`,瓦片按视口自动呈现——所以只要把视野限到
区域(或地点包围盒)即可。为此给 rond-map.js 的 full() 加了 `fitCircle` / `fitBBox` 覆盖默认的点集贴合。
- **页面**:`/admin/topics`(新建/编辑/删除/开关/排序 + 列表)、`/topics`(卡片列表,只列 enabled,
站长看得到未展示的)、`/topics/{id}`(KPI + 地图 + 地点表;未展示的专题访客 404、站长可预览)。
- 导航入口仅在数据集有专题时出现(`Page.HasTopics`,pageBase 里用一次 EXISTS 查)。
### 两个坑
- **`#map { height: 100% }` 是 ID 选择器**,`.topic-map { height: 520px }` 压不过它 →
容器高度算成 0,地图一片空白且 `fitBounds` 退化到 zoom 3。规则要写成 `#map.topic-map`。
- 新增页面模板必须在 `pageFiles` 里登记,否则报「未知页面模板」——embed 的 glob 只是把文件带进
二进制,页面集仍按 map 显式列举。
### 补:专题页地图不可见(用户反馈)
用户看到专题页只有表格、没有地图——是我修 `#map` 高度塌陷之前的版本(CSS 优先级 bug)。
服务端渲染一直是正常的(容器 1058×520、18 张瓦片、3 个聚类)。
加固:地图高度改为 inline style(`style="height:520px"`,窄屏由 JS 改 360),
再也不会被外部 CSS 的 ID 选择器影响;容器内加「地图加载中/被拦」兜底提示,
脚本就绪(容器带上 leaflet-container 类)后 1.2s 自动隐藏。
### 专题页布局与时间精度(用户第 6 轮反馈)
用户嫌时间范围「缩成一团拆成三行」。根因:时间范围原本塞在 4 列 KPI 网格里,
23px 字号放不下 `2026-08-14 09:00 ~ 2026-08-16 18:30`。
- **时间范围独立成行**(`.topic-range`,13.5px),详情页放在标题/副标题之下;
列表页卡片同样独立一行。
- **KPI 只放数字**:详情页去掉「时间范围」项,保留 地点/到访次数/涉及城市/累计停留(4 项,桌面 4 列、移动 2 列);
列表页卡片改为 地点/到访/天数(`.kpis-3`,移动也保持 3 列)。
- **说明文字**从 KPI 卡片里挪出来独立成卡(`.topic-desc`)。
- **时间精确到分钟**:后台表单 date → datetime-local,并加载 flatpickr(`p.Flatpickr = true`);
后端 `parseTopicTime` 同时接受 `2006-01-02T15:04` 与 `2006-01-02`(老表单兼容),
只给日期时把结束补到当天 23:59:59。
- **显示规则**:整天(00:00 ~ 23:59)只显示日期,避免「00:00 ~ 23:59」噪音;
任一端带时刻则完整显示到分钟。
- 移动端:KPI 2 列、卡片 3 列、时间范围 1 行、地图 360px、无横向滚动(均已实测)。
**坑**:flatpickr 初始化后会把 input 的 `type` 改成 `text`,
验证脚本里 `querySelectorAll('input[type=datetime-local]')` 会得到空数组——要按 `name` 查。
### 到访记录加搜索(用户第 7 轮)
之前只有地点列表能搜,到访记录只有分页。现在两处各有独立搜索词:
- 地点列表 `q`、到访记录 `vq`(`EditData.VisitQuery`);两个搜索表单互相带隐藏字段,
分页链接也带上对方的关键词,切来切去不会丢条件。
- 到访搜索匹配:地点名 / 城市 / 备注 / 活动名 / **日期**(`to_char(arrival,'YYYY-MM-DD') ILIKE`)
/ src_pk 精确命中。实测:「喜洲」5 条、「2026-08-14」7 条、「08-14」9 条(模糊)、「196」1 条。
- 加了 `escapeLike()` 转义 ILIKE 通配符(搜 `%` 不再命中全部、`_` 不再变成任意字符),
地点列表的 `q` 也一并转义。
**坑(第二次踩)**:Go 源文件是 CRLF,Python 里用 `"\n".join(...)` 拼出的替换串永远匹配不到。
这类批量替换必须 `io.open(..., newline='')` 读入后按实际换行符拼串;替换后要 grep 验证真的写进去了
(本次日期条件第一遍就是静默没生效,靠「搜日期返回 0 条」才发现的)。
### 后台操作后不再跳回页顶(用户第 8 轮)
后台的收藏 / 删除 / 搜索 / 翻页 / 专题开关都是传统表单提交 + 整页刷新,浏览器一律回到顶部。
做法(layout.html 底部一段通用脚本,只在 `/admin` 下生效):
- `submit` 事件(捕获阶段)与「同路径 <a> 点击」时把 `scrollY` + 时间戳写进
`sessionStorage["rond:scroll:"+pathname]`;
- 新页面 **load 之后**读出来滚回去(`document.readyState==="complete"` 时直接执行)。
- 10 秒有效期:用户点了删除又在确认框里取消时留下的记录,不该在下次打开页面时把视图拽走。
- 只对后台生效:前台「主页 → 详情」本来就该从头看。
**两个坑**:
1. 恢复必须在 `load` 之后做。放在 `</body>` 前的同步脚本里,文档高度还没定,
`scrollTo` 会被截断到 0(表现为「偶尔不生效」,CDP 直接 Page.navigate 时必现)。
2. 验证要模拟真实路径:`form.submit()` 不触发 submit 事件,得点 `button[type=submit]`;
两步导航的验证脚本 import `site_cdp` 复用它的 CDP 类即可。
## 新功能:照片批量导入(EXIF → 预览确认 → 落库)
用户要「批量选照片 → 读 EXIF 生成地点 → 逐个修改确认 → 入库;拍照时间=到达时间,
离开默认 +10 分钟可改」。
### 实现
- **`internal/exif`(新包,零依赖)**:手写 JPEG EXIF 解析。
只读两样:ExifIFD 的 `DateTimeOriginal(0x9003)` 与 GPS IFD 的度分秒 + 南北/东西半球。
流程:SOI 后逐段扫描 → APP1 且以 `Exif\0\0` 开头 → TIFF header(II/MM)→ IFD0 →
0x8769 指向 ExifIFD、0x8825 指向 GPS IFD。值 ≤4 字节内联在条目里,否则是偏移。
测试用「手工拼的最小 JPEG」覆盖:正常坐标 / 南纬西经 / 非 JPEG / 无 EXIF。
- **`handlers_photos.go` + `templates/admin/photos.html`**:
`GET /admin/photos` 选照片 → `POST /admin/photos/parse` 解析并渲染可编辑确认表
(每行:是否导入 / 地点名 / 经纬度 / 到达 / 离开 / 说明)→ `POST /admin/photos/import` 落库。
上传与导入分两步,预览表单带着全部数据,不需要服务端暂存。
- **坐标必须转**:EXIF 是 WGS-84,库内是 GCJ-02,用 `geo.WGS84ToGCJ02`。
实测 (25.6890,100.1560) → (25.686043,100.157229),偏移约 350m,符合纠偏特征。
- 每张照片 → 一个 place + 一条 visit(`is_user_added=TRUE`),src_pk 取 MAX+1 递增。
- 上限:60 张 / 单张 25MB;HEIC 直接给出「暂无 EXIF 支持」的明确提示。
### 边界
- 无 GPS / 无拍摄时间 → 该行给出说明,默认取消勾选。
- **GPS 写 (0,0) 的情况**(相机没定位时的常见写法)要当成无 GPS,否则会生成几内亚湾的点。
- 导入时再校验一次坐标与时间,无效行跳过并在 flash 里说明跳了几张。
### 追加:HEIC/HEIF 支持(用户反馈「现在手机默认都是 HEIC」)
在 `internal/exif` 里加了 HEIF 容器解析(`heif.go`),并把 `Parse` 改成按魔数分发:
JPEG(FFD8)走 `parseJPEG`,ISO BMFF(第 5~8 字节是 ftyp)走 `parseHEIF`。
HEIC 的结构与取 EXIF 的路径:
- 顶层找 `meta`(FullBox,负载前 4 字节是 version+flags);
- `iinf` 里遍历 `infe`,取 `item_type == "Exif"` 的 item_ID(v2 起 item_type 在 payload+8);
- `iloc` 里按 item_ID 找数据段:注意 offset/length/base_offset 的长度是「字节长度字段」,
v1/v2 还多一个 construction_method 与 index_size,必须逐个 readN 推进指针;
- Exif item = 4 字节 TIFF 偏移 + 标准 TIFF → 后半段直接复用 `parseTIFF`(与 JPEG 完全共用)。
要点:
- 不引 libheif:内网/离线构建下 CGO 依赖太麻烦,而我们只要 EXIF;
- `Parse` 的读取上限提到 32MB:HEIC 的 Exif item 位置由 iloc 指到绝对偏移,可能在文件靠后;
- 新增测试:HEIC 正常解析、只有 meta 没有 Exif item、以及**截断到各种长度都不 panic**
(上传中断/文件损坏在真实世界都会出现,边界检查得逐个 readN 做)。
- 实测:同一个表单里 HEIC + JPEG 混合上传,两张都正确解析出坐标与拍摄时间并成功入库。
### 时间线排序按钮无效(用户第 9 轮)
症状:「最早在前」点了没变化。
根因:`timelinePage` 里把排序方向**硬编码**写死成 `"desc"`:
`s.q.Visits(..., "desc")` —— 按钮生成的 `?sort=asc` 被完全忽略(查询层 `Visits` 本来就支持 asc)。
修好后按 `p.Sort` 决定方向。
实测:默认首日 2026-09-13 / 首条 20:40;`?sort=asc` 首日 2025-02-10 / 首条 12:00,
按天分组的**天序与组内顺序都跟着反转**(groupByDay 按输入顺序建组,无需另改)。
顺带核实地点库的 4 个排序按钮(到访次数 / 停留时长 / 最近到访 / 名称)数据确实会重排,无同类问题;
翻页链接里也带了 sort(filterPairs 已包含),切页不会丢排序。
**教训**:这类「按钮看得见但没反应」的 bug,根因多半是 handler 没用页面传来的参数
(硬编码常量兜底),排查时先顺着「模板生成什么参数 → handler 读了没有」这条线走,比查 SQL 快得多。
### 排序按钮(续):切回默认不生效的真根因
上一轮只修了「handler 硬编码 desc」,用户复现出第三类问题:
**进入(默认)→ 最早在前(成功)→ 再点最新在前(失败)**。
两个叠加的根因(都在链接拼装层,与 SQL 无关):
1. 「最新在前」按钮写成 `{{.Link .FilterBase}}`,而 `filterPairs()` 会把当前的 `sort=asc`
一起带上 → 点它得到的还是 `?sort=asc`。模板要显式清键:`{{.Link .FilterBase "sort" ""}}`
(`buildURL` 会跳过空值,所以空串等价于删除该参数)。
2. `Link()` 把 extra 直接 append 到筛选参数后面,同键会拼成 `?sort=dwell&sort=name`,
而 `q.Get()` 只取**第一个** → 地点库从一个排序切到另一个也「点了没反应」。
一并修掉:`Link()` 现在让 extra **覆盖**同名键(先按 extra 的键过滤 kv,再追加),
值为空则等于删掉。地点库「到访次数」也改成显式清 sort。
实测完整链路(进入 → 最早在前 → 最新在前):首日 2026-09-13 → 2025-02-10 → **回到 2026-09-13** ✓;
地点库在任一排序下点其他项,href 都是单一 sort 参数、点「到访次数」回到 `/places`。
**教训**:页面筛选类的「点了没反应」,除了看 handler 有没有读参数,还要看**链接里参数有没有被正确替换/清除**——
重复参数(`?a=1&a=2`)会让 `Get` 只认第一个,表现为「只能单向切」。
### 照片导入改为「浏览器读 EXIF」(用户实测 51 张 334MB 崩掉)
用户问得对:**原来的 EXIF 是在后端读的**,意味着每张照片都要先传到服务器。
实测 51 张 / 334MB → 直接崩,原因链:
- `http.MaxBytesReader(..., photoMaxBytes*4)` = **100MB 请求体上限**,334MB 当场被截断;
- 即使放宽,`ParseMultipartForm` 会把超出内存的部分落磁盘,1C1G 上很难看;
- 而且早期 handler 里只读前 2MB 交给解析器,**HEIC 的 Exif item 可能在文件后部**,本来就会漏。
**改成前端解析**(`static/js/photo-exif.js`,算法与 Go 版一一对应,但必须分片读):
- 先 `file.slice(0, 64KB)` 读头部:JPEG 的 APP1、HEIC 的 meta/iinf/iloc 都在这一段;
- HEIC 的 Exif item 位置由 iloc 给的是**绝对偏移**,若超出已读范围就再按偏移读那一小段;
- 串行逐个读(并发几十个 FileReader 在手机上容易吃满内存);
- 页面只提交文本:`n` + 每行 name/lat/lon/arrival/departure + `wgs84=1`;
坐标转换仍放在服务端(`geo.WGS84ToGCJ02`),避免前后端两份实现跑偏。
实测:**51 张 334MB → 1.2 秒**读完、51 张全部拿到坐标、表格 51 行、0 报错;
服务端确认照片不过网(file input 无 name、form 无 multipart)。
坐标链路单独验过:提交 WGS-84 (28.191654,106.123456) → 库存 GCJ-02 (28.188390,106.127306),
与 `geo.WGS84ToGCJ02` 的期望值一致。
**加了世代号保护**:测试时发现 change 触发两次会让表格出现**双份行**(真实场景里用户连续选两次文件同样会中招)。
用 `gen` 计数,旧一轮的解析结果一律丢弃。
服务端 `internal/exif` 包保留(已无调用点):它是 JS 实现的对照物,未来 JS 解析出问题时可以拿 Go 版交叉验证;
若确定不再需要,可连同 `internal/web/handlers_photos.go` 里已删的上传路径一起清理。
### 照片导入:地址反查 + 地图预览 + 坐标系可选
用户反馈「只看经纬度看不出点对不对」+「不是所有手机都用 WGS-84」。
1. **逆地理编码(新增 `POST /api/regeo`)**:坐标交给服务端 → 转成 GCJ-02 → 调高德 regeo
→ 返回 formatted_address + 省/市/区/街道/乡镇。要点:
- **按 5 位小数去重**:同一地点拍的多张只查一次(省配额也省时间);
- 4 并发 + 30 秒超时;
- 结果带 `key` 原样回传(前端传行号),否则去重后配不回原来的行。
2. **名称默认用地名**:反查成功后,若名称还停在文件名(用 `dataset.initial` 判断用户是否改过),
换成「区县 + 街道/乡镇」——`IMG_20251219_095042` → `雨花区花侯路`。
3. **地图预览**:`rondMap.preview` 把所有点画出来;坐标系选错时会跑到国外/海里,一眼可见。
4. **坐标系可选**:WGS-84(默认)/ 已经是 GCJ-02(部分国产手机直接写 GCJ-02)。
实测同一坐标两种选择反查出的街道不同(相差几百米),说明选择真的生效。
5. **每行「更多」**:类别/省份/城市/区县/街道/时区,反查结果自动预填,可改;导入时一并落库。
实测 51 张 334MB:4.2 秒(含 51 次反查),51 个点位全部成功,0 报错。
**坑**:`photo-exif.js` 一开始放在页面底部,DOM 还没解析到它就执行了内联脚本,
`window.rondExif` 还是 undefined → 内联脚本直接 return,表现为「选了文件毫无反应」。
把脚本移到页面最前面解决。另外 curl 在 Git Bash 里测静态文件大小会显示 0B(write error),
用 `curl -s ... | wc -c` 才准。
### 照片导入的字段补齐到与「新增地点」一致
用户要求:类别、活动也要像新增地点那样是**可选下拉**,字段完全对齐。
「更多」区现在分三行:
1. **类别(下拉,16 项:已有类别中文 + 「自定义…」)**、国家代码、省份、城市、区县、街道、时区;
2. **活动(下拉,31 个活动)**、备注、标记为收藏;
3. 标签(多选 chips,与新增地点同款)。
反查到的省/市/区/街道自动预填,类别与活动由用户选。
实现要点:
- 选项数据与数据编辑页**同源**(`editOptions` 查 activities/tags,类别查 `DISTINCT poi_category`),
保证两处能选的东西完全一致;
- 选项通过**隐藏 select** 传给前端(内联 JSON 会被模板 JS 上下文转义搞坏,这个坑踩过);
- 写入复用 `insertVisit()`(内部含标签关联与 `refreshPlaceAgg`),
与「新增到访」走同一条路径——活动/备注/收藏/标签的行为因此天然一致;
- 类别支持「自定义…」(`categoryCustomValue` 常量与数据编辑页共用)。
实测落库:poi_category / country_code / province / city / district / thoroughfare / timezone /
activity_id(纪念馆) / remark / bookmarked / visit_tags(羊肉粉店) 全部正确。
**验证脚本的坑**:`DOM.getDocument` 拿到的 nodeId 在页面尚未就绪时会失效,
报 `Could not find node with given id`——要等页面稳了再取,并加重试。
### 照片导入:单点地图 + 高德限速
用户反馈「所有点挤在一张图看不出哪个是哪个」+「一次性请求太多也不好」。
1. **总图保留、每行可单独看图**:说明列加了「地图」链接(与「更多」并列),点开是个弹窗,
里面一张街区级(zoom 16)的地图指着这个点,标题是地点名、副标题是「地址 · 坐标」。
- **只建一个 Leaflet 实例**反复用(`setPoints` + `setView` + `invalidateSize`),
否则 51 行各建一个地图会直接卡死浏览器;
- 单点 `fitBounds` 会顶到最大缩放,所以底图就绪后用 `setView(..., 16)` 覆盖;
- 支持 Esc / 点遮罩关闭。
2. **高德限速 10 QPS**:`time.NewTicker(time.Second / regeoQPS)` 逐个发令牌,
超出的请求排队等待(原来的「4 并发」只限制了并发数、没限制速率)。
51 个点位的反查耗时从 4.2 秒变成 6.3 秒——变慢正是限速生效的证据,也避免触发
`CUQPS_HAS_EXCEEDED_THE_LIMIT`。请求超时相应放宽到 60 秒。
实测:总图 51 个点;单点弹窗标题「雨花区花侯路」、副标题「湖南省长沙市雨花区东山街道平阳路 · 28.141772, 113.067569」、
zoom=16、9 张瓦片;关掉再点另一行,地图中心切到新点(实例复用成功)。
### 「地图」两字被拆成两行
原因:**中文默认可以在任意字之间断行**(`word-break: normal` 对 CJK 就是逐字断),
说明列窄(220px)里放着说明文字 + 「地图」「更多」两个链接,链接被挤到只剩一个字宽。
修法:
1. 两个操作链接包进一个 `white-space:nowrap` 的容器,与说明文字分行;
2. CSS 加一条通用规则 `.tb a { white-space: nowrap; }` —— 表格里的行内链接一律不许折行,
免得以后再加「编辑」「删除」这类两字链接又踩同一个坑。
**扫描全站时学到的判据**:想查「短文本有没有被拆行」,别用元素高度
(按钮的 padding、emoji 都会让高度超标),要用 Range 量文本实际占几行:
```js
var r = document.createRange(); r.selectNodeContents(el);
r.getClientRects() 里 top 去重后的个数 = 行数
```
并且要跳过**含子元素**的节点(emoji 圆点都是子元素,会量出多个矩形)。
收紧判据后扫 12 个页面:零命中。
### 照片导入表:把「更多」挤出视口 → 改固定列宽
症状:8 列加起来超出视口,最右边的「地图/更多」要横向拖动才看得到。
根因:每个 input 自带 `min-width`(我原来给地点名 120px、坐标 98px、时间 170px…),
列宽由内容撑开,总和必然超过屏幕。
改法:`table-layout: fixed` + `<colgroup>` 百分比列宽(4/10/12/12/20/14/14/14),
输入框统一 `width:100%; min-width:0` 跟着格子走。
实测:1440 / 1280 / 1024 三种宽度**都没有横向滚动**、8 列全部可见,
「地图@1098-1122」「更多@1132-1156」都在 1440 视口内。
手机端(≤700px)额外隐藏信息量最低的「文件」列,最小宽度从 880px 降到 640px
(8 列表格在 390px 屏上物理上放不下,滑动仍不可避免,但滚动量小了)。
**注意**:`tInput` 里我一度写了个未定义的 `inPhotoTable` 变量——`node --check` 只查语法,
这种运行时 ReferenceError 查不出来,是靠读代码发现的。这类模板内联 JS 的改动,
除了语法检查还要过一眼变量引用。
### 照片导入:失败重试 + 最近店名
用户问「查不到的地址能否再查」「能否给最近的地点(店名)而不是街道」。
1. **重试**:
- 网络层:`amapGet` 失败退避重试 3 次(400ms / 800ms);
- 交互层:新增「重新反查未成功的」按钮,只挑地址列还是占位符「—」的行重发。
2. **店名(关键)**:不要用 regeo 的 `pois`——`extensions=all` 返回的 30 条多是行政单位
(实测「雨花区黎托乡东山村村民委员会」)。改用**周边搜索** `place/around`:
`radius=200 & offset=1 & sortrule=distance`,按距离排序取第一条才是店名
(实测「悦达餐厅」95m、「浏阳蒸菜…」145m)。
显示成「店名(距离)· 完整地址」,地点名默认也用店名;取不到就退化成「区县+街道」。
3. **限速位置调整**:原来的令牌桶放在 `apiRegeo` 的循环里(按点位发),
现在每个点位要发 2 次请求(regeo + around),按点位限速就不准了 →
把限速挪进 `amapGet`,所有高德请求统一 10 QPS。51 张照片耗时 6.3s → 11.3s(102 个请求),符合预期。
4. 实测:51/51 点位反查成功,其中 **31 个拿到店名**(其余 20 个周边 200m 内没收录)。
**踩的坑**:`rowRefs` 定义在 change 回调内部,而 `applyOne`/重试按钮在外层作用域 →
运行时报 `rowRefs is not defined`,表格地址全空。提到外层解决。
另有:用 Python 改模板 JS 时,替换后残留了一段旧回调代码,`node --check` 抓到了多余的大括号
——**改完模板内联 JS 一定要跑一次语法检查**。
### 导出 fwss 缺内容(用户怀疑得对)+ 收藏按钮被挤出视口
**1. 收藏被挤出**:`.row` 里的备注设了 `flex:1 1 220px`(会自动拉伸占满剩余空间),
把最右边的「标记为收藏」顶出了可见区域。改成 `flex:0 1 260px`(不拉伸)后三者紧凑排列:
活动右边界 376 / 备注 646 / 收藏 748,都在 1440 视口内。
**2. fwss 导出确实缺数据**(这是本轮最重要的发现)。
fog of world 的快照里有**三类瓦片**,我们原来只解析了第一类:
| 目录 | 原始包里 | 每块字节 | 原来 |
|---|---|---|---|
| `Model/*` | 52 个 | 512 + 3(位图 + 元信息) | 解析并导出 ✓ |
| `Model/~` | 52 个 | 512(纯位图) | **被跳过** ✗ |
| `Model/#` | 54 个 | 3(仅元信息) | **被跳过** ✗ |
体积正好对得上:`*` 类未压缩 6.18MB / 三类 13.8MB ≈ 45%,而导出是 299KB / 原始 680KB ≈ 44%。
修法(`internal/fog/layers.go` + `fog_raw_files` 表):
- 新增 `RawLayers(data)`:从快照里挑出**非 `Model/*`** 的条目,存**原始压缩字节**(不解压、不重组);
- 上传时调 `SaveRawLayers` 落库;导出时 `LoadRawLayers` 读出、原样写回 zip;
- `Encode(blocks, layers)` 签名相应增加一个参数;
- 已用一次性脚本把现有快照的 106 个文件补录进库。
实测:导出 **299KB → 701KB**,条目 158→169(`*` 63 + `~` 52 + `#` 54),
往返解析块数/格数完全一致(10542 块 / 670565 格)。
(`*` 比原始多 11 个瓦片,是因为库里是多次导入合并后的 10542 块,比单个快照的 8701 块多。)
**教训**:解析第三方文件格式时,先**枚举压缩包里的全部条目并按目录分组**,
别看到一类目录就以为全了——`Model/*` 那 52 个文件只是三分之一。
### 程序新建的地点显示「无数据」——最近活跃时间(ZLOCATION.ZLASTARRIVALDATE_)
**现象**:app 里加的地点(如贺州站(出站口))能显示「最近活跃时间」,程序(照片导入 / 手工新增)建的地点一律显示「无数据」。
**取证**(别再靠猜,实测数据如下):
- 枚举 ZLOCATION 全部 33 列,时间类只有 `ZLASTARRIVALDATE_` 一列,
贺州站(#97) 该列 = 2025-09-21 17:43 = 它唯一一次到访的到达时间 → 它就是「最近活跃时间」。
- 原始包 323 个地点的分布:有到访 281 个写最新到访时间、2 个写 distantPast 哨兵(app 自己漏更新);
无到访 38 个写哨兵、2 个写 NULL。**哨兵值 = -63114076800**(Core Data 的 distantPast,
换算即公元 1 年),判空时不能只看 IS NULL。
- 我们这边:`ZLASTARRIVALDATE_` 在代码里**零引用**——parse 不读、export 不写。
数据库里 367 个地点有 326 个 `places.last_visit_at` 有值(refreshPlaceAgg 算得很准),
只是导出没把它写进包,新行留 NULL → rond 显示「无数据」。
**修法**(`internal/rond/export.go`):
1. `ZLASTARRIVALDATE_` 加进 `managedCols["ZLOCATION"]`,值取 `places.last_visit_at`(`appleSeconds`);
2. 新增 `keepNilCols`:PG 侧为 NULL 时**保持基线原值**——从没到访过的地点,
app 写的是哨兵或 NULL,站点复现不了就别用 NULL 去覆盖。
落到 `tableWriter.keep`:UPDATE 分支跳过该列、writeFull 分支回退留档原值(新增 `typeOf` 取列类型);
3. `pgPlace` 加 `LastVisitAt sql.NullTime`,`queryPlaces` 多取一列。
**实测**:导出 367 个地点里「有到访却为空」从 43 个降到 **0**;程序新建的地点现在都带时间
(茶颜悦色 → 2025-12-21 14:01)。无到访的 41 个保持原样(38 哨兵 + 3 NULL)。
与原始包逐行比对:除用户手改过的 4 条到访(导出跟随真值,是应该的)+ 哨兵回填 2 条,零漂移。
**新增回归测试** `TestLastArrivalDateMatchesVisits`(需 ROND_EXPORT):断言每个有到访的地点
该列 == max(ZVISIT.ZARRIVALDATE_)。拿旧导出跑会失败 —— 已验证它抓得住这个 bug。
**顺带**:
- `tools/verify_rondbackup.py` 原来把两个包路径写死在文件头,改成可传命令行参数。
- `TestExportRoundtrip` 是「同一份数据」的严格对比,数据一旦被编辑过就会失败
(旧导出文件现在跑同样失败),不是本次改动引起的。
**留了个待批的小问题**:照片导入是「先 INSERT places、再 insertVisit」两步且无事务,
写到访失败时地点会留下成为孤儿(visit_count=0),它导出去照样显示「无数据」。
另外手工新增地点的「到访时段」是选填的,留空就只建地点——这种本该显示「无数据」,属正常。
### 照片导入并入事务 + 全库「输入了但导出缺东西」的系统排查
**1. 照片导入/手工新增改成事务**(`internal/web`)
- 新增 `execer` 接口(`*sql.DB` 与 `*sql.Tx` 的公共子集)与 `Server.withTx`;
`insertVisit` / `saveVisitTags` / `refreshPlaceAgg` 加一个 `ex execer` 参数,其余调用点传 `s.db`。
- 照片导入:每张照片的「建地点 + 建到访」放进一个事务;失败整体回滚并把 `nextPlaceSrc--` 让出主键。
- 手工新增(`editPlaceCreate`):**把所有校验提到落库之前**,再开事务写「地点 + 到访」。
以前是先建地点、再校验时段,时段不合法就留一个没有到访的地点(提示还写「地点已新增」)。
实测:非法到达时间提交后 places/visits 计数完全不变;合法提交则地点+到访一起落库。
**2. 系统性排查「用户输入了、导出到 rond 却缺东西」**(这次最有价值的部分)
两步法,缺一不可:
- **代码层**:把 `internal/web` 的 INSERT/UPDATE 列与 `export.go` 的 `managedCols` /
`newRowDefaults` 对照,找出「站点写了但导出不写」的列;
- **数据层**:拿原始包(app 造的行)与导出包(程序新建的行,`Z_PK > 基线最大主键`)
**逐列比对非空计数**。注意各表主键区间不同,基线最大主键要**按表分别算**。
凡是「app 高覆盖、新建行全空」的就是缺口。
查出并修掉的缺口(`internal/rond/export.go`):
| 列 | 真机覆盖 | 修法 |
|---|---|---|
| `ZLOCATION.ZRAWLATITUDE/ZRAWLONGITUDE` | 323/323 | 新地点用 `geo.GCJ02ToWGS84(lat,lon)` 反算填上 |
| `ZVISIT.ZIDENTIFIER_` | 822/824 | 新到访补确定性 UUID(16 字节) |
| `ZACTIVITY.ZUID_` | 30/30 | 新活动补确定性 UUID |
| `ZACTIVITY.ZLASTARRIVALDATE_` | 29/30 | 由「该活动下最晚到访」算(已逐行验证 28/30 精确相等) |
| `ZTAG.ZLASTARRIVALDATE_` | 8/8 | 由关联表聚合(验证 6/8 相等) |
| `ZACTIVITY.ZICON_` | 30/30 | 站点无图标入口,缺省 `📍`(同站点模板 `iconor` 兜底) |
**关键发现:raw 坐标是 WGS-84**。`ZLATITUDE` 是 `gcj(ZRAWLATITUDE)`——用 Python 复刻 GCJ-02
正变换验证 293/323 精确命中(另 30 个是用户挪过点的)。所以新地点要反算 WGS-84,不能直接复制。
**UUID 必须确定性**:真机存的是 v4 随机值,但站点用它当身份标识,随机会导致每次导出都变、
rond 把同一批记录当成新的。改用 UUIDv5(命名空间 + `表名/src_pk`,src_pk 跨快照稳定)。
**新增测试**(`internal/rond/export_roundtrip_test.go`):
- `TestUUIDv5`:同名校验确定性、版本/变体位、跨表不碰撞;
- `TestProgramRowsFillNativeColumns`:导出包里「新建行」的原生列必须全部有值
(拿旧包跑会失败,已验证抓得住)。
**确认不是缺口的列**(真机自己也是空的,别去「修」):`ZVISIT.ZRAW`(是整数,原始采样点
**数量**;真机手工加的到访同样为空)、`ZMERGEDTO`/`ZPARENT`/`ZTIMEZONEIDENTIFIER`、
`ZLOCATION.ZNOTE_`(站点另有 place_notes,且是 rond→站点的单向汇总,不回写)。
**顺带确认的两件事**:
- `TestExportRoundtrip` 是「同一份数据」的严格对比,数据被编辑过就会失败(旧包现在也失败),
别把它当成本次改动的回归信号。
- `gofmt -l` 会把全仓库都列出来:文件是 CRLF 行尾,属既有状态,不是格式坏了。
### 迷雾(fog of world)这边有没有同类问题——有,修掉了
用户问「另一边的迷雾数据库会不会有类似问题」。方法是把整条往返链路**逐字节**比对:
写一个临时程序(`fog.Decode` + `RawLayers` → `fog.Encode`)拿真机快照
`data/Snapshot-20260914T154839+0800.fwss` 做「解析→重打包」,再逐条目、逐块、按格比对。
**结论:位图 0 差异、瓦片名 0 差异、块索引结构 0 差异,但 3 字节元信息 8701/8701 全部不一致。**
- 真机:`[0x00, hi, lo]`,那 3 字节其实是一个**大端 24 位数 = 置位数×2+1**
(118 格 → 0x0000ED = 237 ✓)。64×64 块最多 4096 格 → 值 ≤ 0x2001,**最高字节在数学上恒为 0**。
- 旧 `extraBytes` 却按臆造的 `0x10, 0x80|…` 生成(大概是早先写体积对比实验时的猜测被抄进了实现)。
修法:`return []byte{0x00, byte(sum>>8), byte(sum&0xFF)}`,并写清楚为什么最高字节恒为 0。
顺带搞清了包的层级结构(之前只模糊知道「三类瓦片」):
- `Model/*/` = 头 32768 + 每块 515(512 位图 + 3 元信息),id 区间 110996~115099;
- `Model/~/` = 头 + 每块 512(纯位图);`Model/#/` = 头 + 每块 3(纯元信息)。
这两层的**文件名用另一套编码,与 `*` 完全不重叠**(三者的名字集合两两交集为 0),
所以无法一一对应;只要**原样留住**就不会丢东西——实测这两层导出后逐字节一致。
- `.fwss` 的 zip **条目内容本身就是 zlib 流**,不是明文(这点搞错过一次:拿解压后内容去比压缩字节,
白白困惑了一阵)。
**另外两处同类隐患,一并修了**:
- `RawLayers` 原来只留 `Model/` 之下的条目,若快照出现别的顶层条目就会被丢掉;
改成「留住全部条目,剔除已探索层与被 Decode 解析掉的瓦片(按文件名判断,避免解析一遍又写一遍)」。
顺手把「超 8MB 就报错」加上,不再用 `LimitReader` 静默截断。
- `清空迷雾` 只删了 `fog_blocks`,留存的 `~`/`#` 还在 → 导出的快照会有图层条目却没有对应的
`Model/*` 瓦片。现在 `清空` 与 `整体替换(cover)` 都会连 `fog_raw_files` 一起处理。
**实测(站点实际导出,10542 块)**:
- 每块元信息符合公式 **10542/10542**,首字节全为 0;
- `Model/~` 52/52、`Model/#` 54/54 与原始快照**逐字节一致**;`Model/*` 瓦片 0 缺失;
- 「导出 → 清空 → 重新上传 → 再导出」**169 个条目 0 差异**(清空后库为 0/0,还原后 10542/106)。
**新增测试** `internal/fog/fog_test.go`:
- `TestNativeSnapshotRoundtrip`(需 FOG_SRC):条目名集合、按格逐块比对位图与元信息、其它层逐字节;
拿旧的 `extraBytes` 跑会失败(已验证);
- `TestExtraBytesEncoding`:钉住 3 字节编码,含满块 4096 格 → 002001。
**留待真机验证的一点**:`~`/`#` 两层覆盖的瓦片与 `*` 完全不同,我们无法为「新点亮的迷雾」生成这两层
的条目。若 fog of world 实际读的是这两层,那么站内新加的迷雾就不会体现在那里——但站点本身没有
手工点亮格子之类的功能,迷雾只会来自上传的快照,所以这条影响面很小,标记为待真机确认。
### 迷雾图层默认打开(前端)
用户反馈:迷雾浓度滑杆一直摆在图上,但图层默认是关的,会让人以为功能已经开了。
根因:`rond-map.js` 的 `full()` 里 `fogLayer` 只注册进 `L.control.layers()` 的 overlays,
**从没 `addTo(map)`**(Leaflet 的 overlay 默认不勾选),而滑杆却无条件创建。
改法(`internal/web/static/js/rond-map.js`):
- `addFogOpacityControl` 返回控件本身(原来是 `new Ctl().addTo(map)` 丢弃返回值),
这样能拿到 `getContainer()` 去显隐;
- 默认 `fogLayer.addTo(map)`;只有用户自己关过(`localStorage['rond.fogOff']==='1'`)才不显示;
- 监听 `map.on('overlayadd overlayremove')`(判 `e.name === '世界迷雾'`)同步:
图层关掉就把浓度滑杆一并 `display:none`,并把开关状态写回 `rond.fogOff`。
这样「控制项」和「实际状态」永远一致,不会再出现「滑杆在、迷雾不在」。
**注意**:静态资源走 `//go:embed static`,只有 `debug=true` 时才直读磁盘
(见 `server.go` 的 routes:debug 下 `http.Dir("internal/web/static")`)。
本项目 conf/app.ini 是 `debug = true`,所以改 JS 不用重编即可验证;但**生产模式必须重编**。
**无头实测**(`rond-web-verify` 技能,1440 与 390 两个视口):
- 首次打开:图层勾选 true、浓度控件可见(116×49,在视口内、与图层/缩放控件不重叠)、
滑杆默认值 90、迷雾瓦片 30 个全部加载(移动端 12 个)、无 JS 报错;
- 取消勾选:瓦片全部撤下、浓度控件 `display:none`、`rond.fogOff=1`;
- 再勾选:全部恢复;
- 专题页 `/topics/3` 同样 18/18 瓦片加载;首页走 `preview()`,本来就没有迷雾与滑杆,未受影响。
**顺手发现的(未改,等确认)**:专题管理页那个「展示世界迷雾」勾选框(`topics.show_fog`)
**在前端根本没被读**——`topic.html` 调 `rondMap.full()` 时没把 `ShowFog` 传进去,
`view.go` 的 `ShowFog` 字段也没有渲染消费方。也就是说它一直是个死控件。
库里唯一的专题 `show_fog=t`,所以当前行为无差异;要不要把它接上(专题页可不显示迷雾)等用户定。
### 专题迷雾开关接上 + 用户名可改 + 登录验证码
**1. 专题的「展示世界迷雾」生效**
`topics.show_fog` 一直是个死控件:`topic.html` 调 `rondMap.full()` 时没传,`view.go` 也没有消费方。
现在模板传 `showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}}`,
JS 侧 `var wantFog = opts.showFog !== false;` —— 为 false 时图层、浓度滑杆、图层选项都不出现。
实测:把 show_fog 置 false → fog 瓦片 0 个、无浓度滑杆;还原 true → 18/18 瓦片、滑杆回来。
**2. 用户名可改**(`/admin/password` 页面升级为「账号设置」,导航按钮同步改名)
- `auth.Store.ChangeUsername(ctx, userID, current, name)`:先验当前口令,再查占用,最后 UPDATE。
新增 `ErrUsernameTaken`。**改名不会踢掉当前登录**——会话 cookie 只含 uid 与口令摘要,都不含用户名。
- 校验(`validateUsername`):2~32 个字符(按 rune 数)、不含空白与控制字符、不能与当前同名。
- 新增路由 `POST /admin/username`;失败时带上 `Username` 回显。
- 实测:四条负路径(同名/太短/含空格/口令错)文案都对且库未被改动;
正路径用一个**临时用户**的 DB 测试覆盖(`ROND_DSN` 门控):口令错 → ErrInvalidCredentials、
占用 → ErrUsernameTaken、改名成功 → UserByID 拿到新名、旧会话 token 仍有效、原口令仍可登录,
结束自动删除临时用户。
**3. 登录图形验证码**(新包 `internal/captcha`)
- 自绘 PNG,**不引新依赖**:内置 5×7 点阵(数字 + 大写字母),放大 4 倍后逐字符抖动,
再叠干扰线与噪点。取码字符集去掉 0/O、1/I/L。
**踩过的坑**:字符宽 20、步长 26 时抖动后最小间距只剩 2px,相邻笔画会串在一起
(回读测试把 L 读成了 E)→ 步长改 30,画布 130×40。
- 服务端只存答案:`Store`(内存 map + 过期 + 上限剔除),一次性使用,10 分钟过期。
`Issue()` 出图并返回 id(写进 `rond_captcha` cookie),`Verify()` 无论对错都作废。
- `GET /captcha` 出图(`Cache-Control: no-store`);登录页 `<img>` 引用它,点击带时间戳换一张;
`loginSubmit` **先验码再验口令**,失败提示「验证码不正确或已过期」。
- 测试:`internal/captcha` 覆盖一次性/过期/大小写容错/清理,以及**从图里逐位回读答案**
(模板匹配,命中加分误命中扣分)——这条同时保证「图里画的确实是答案」,否则用户会被自己锁在门外。
- 端到端(真实 HTTP + 从 PNG 回读验证码):正确码 + 错口令 → 走到「用户名或密码错误」;
同一张码二次使用 → 被拒;新码 + 错码 → 被拒;无 cookie → 被拒。
**登录页布局踩的坑**:`input[type=text]` 全局 `width:100%` 会把验证码图挤到下一行;
且 `label.f > span` 的 `display:block` 会盖掉 `.row` 的 flex(`.f > span` 特异性更高)。
改用 `div class="row"` + 输入框内联 `flex:1 1 0;width:auto;min-width:0`。
实测 1440/390/320 三个宽度:输入框与图同一行、间距 8px、无横向溢出、无 JS 报错。
**说明**:验证码能挡住傻瓜机器人,但**挡不住 OCR**(可回读就是证明)。
真正的防线仍是已有的 TOTP 两步验证——这点在回复里跟用户讲清楚了。
### app.ini 里的库不存在时自动创建
`db.Open` 原先只会 Ping 一次然后报错。现在:Ping 失败且错误是**「库不存在」**(SQLSTATE 3D000,
驱动只给文本时按消息兜底)时,连维护库把它建出来再重试一次。
- 维护库优先 `postgres`,没有就退回 `template1`;库名走 `pgx.Identifier{}.Sanitize()` 转义,
配置里写什么名字都拼不坏语句;并发启动撞到 "already exists" 视为成功。
- **只在库不存在时兜底**:端口不通、口令错这些原样报错,不会被误当成「那就建个新的」——
否则 DSN 里库名打错也会静悄悄建个空库,用户看到的是个空站点。这条专门写了测试。
- 建库时打日志「数据库 X 不存在,已自动创建」,让配置写错的情况看得见。
- 权限不足时报「(当前账号可能没有 CREATEDB 权限)」。
- 用 `pgx.ParseConfig` 复用 dsn 里除库名外的全部参数(主机/端口/账号/口令/sslmode),
不用自己拆 URL(pgx v5 的 `ConnConfig` 没有 `ConnString()`,所以维护连接用 `pgx.ConnectConfig` 直连)。
实测:
- 临时配置指向 `rond_autocreate_check`(库不存在)→ 日志出现「已自动创建」,
库建好、18 张表建全、初始管理员也建了、服务正常监听;
- 端口改成 5499 → 报连接失败且**没有**建库;口令改错 → 报认证失败(28P01);
- 新增 `internal/db/db_test.go`(ROND_DSN 门控):自动建库 + 建表 + 用完自动 DROP,
既有库正常连接,以及 `isDatabaseMissing` 的识别边界。跑完测试库已清理干净。
### 上传备份后没有任何提示 + 配置样例不完整
**1. 提示被吞掉(根因)**
`adminFlash` 把消息挂在 URL 上重定向到 `/admin?ok=`/`?err=`,但 `adminHome` **从来没读这两个参数**
(photos/notes/topics/edit/settings/password 都读了,唯独后台首页漏了)。
于是上传备份、导入/清空迷雾、开关两步验证、删除数据集的所有提示全都无声无息——
用户反馈的「rond 文件上传后没有任何提示」就是这么来的。
修法:给 `Page` 加 `SetFlashFromQuery(r)`(把「跳回本页的处理必须接住提示」写在注释里,
免得下次再漏),`adminHome` 与 `twofaPage` 都改用它。
实测:错误文件 → 页面显示「文件类型不正确…」;重复内容 → 显示「与已有数据集 #9 完全一致
(866 条到访、367 个地点),未新建快照」。
**顺手校正的数据**:`datasets.visit_count/place_count` 还是 867/368,比实时数据多 1——
是我上次用裸 SQL 删测试地点时没走 `refreshDatasetAgg`。已按同一语句重算为 866/367。
(教训:动 places/visits 一定要同步数据集缓存计数。)
**2. 配置样例不完整**
对照 `config.Load` 真正读取的键(以 `get(kv,...)`/`getInt(kv,...)` 为准)扫了一遍:
- 样例缺 `geocode_key`(后台「按名称搜坐标」的高德 Web 服务 Key)→ 补上,并写清它与瓦片 JS Key
不是同一种、留空则功能关闭。
- 顺带发现 **`base_url` 是个死键**:struct 里有、会解析,但全仓库(代码/模板/JS/README)无人使用
→ 直接把 `Config.BaseURL` 删掉。留着「填了没反应」的键,比没有更容易让人怀疑自己。
- 样例里补上:库不存在会自动创建(及需要 CREATEDB 权限)、目录会自动创建、环境变量
`ROND_ADMIN_USER` / `ROND_ADMIN_PASSWORD` 的作用。
- 新增 `internal/config/config_test.go` **双向**校验样例与代码:代码读的键必须在样例里出现,
样例里出现的键也必须真被读取(拼错或死键都会失败)。已验证能抓到(注释掉 geocode_key 即红)。
### 调查:删掉的地点又出现 + 城市黑名单与迷雾
**1.「建设路」删掉后地图还在显示——没复现出 bug,是「快照式导入」把它带回来了**
在测试库(`conf/app-test.ini` → `rond_fog_test`,现在已自动建库并导入了同一份备份)完整复现:
- 通过站点删除 src_pk=119 的建设路 → `/api/points` 立刻不再返回它(278 个点),删除**本身是好的**;
- 再传**同一份**文件 → 走「内容完全一致」分支,**不会**重新导入,删除保持 ✓;
- 传一份**内容不同**的备份 → 新建数据集 #2 并接管展示,**建设路又出现了** ✓ ← 这就是用户看到的现象。
结论:站点是快照式语义(每次导入 = 一个新数据集接管展示,内容以备份为准),
所以「先删、后传备份」必然把删掉的地点带回来。生产库的活动数据集是今天 00:48 导入的,时间线吻合。
- 顺手把上传卡片的说明改写清楚(原话只有一句「重复上传同一份备份会替换原有数据」,会误导);
- 另记:站点上新加的地点属于当时那个数据集,传新备份后要看它们得在数据集列表里切回。
- 待用户决定(未实现):是否要「删除墓碑」——导入时把站点上删除过的 src_pk 再删一遍,让删除真正持久。
**2. 城市黑名单(`settings.regions` + `regions_mode`)**
- **只对访客生效**:`filterOf` 里写着「站长自己要看全部数据」,所以登录状态下试是不会有变化的
——这很可能是「好像无效」的第一层原因。
- 审计了所有直接查 `places` 的公开路径,修掉三处漏过滤(`apiCityAgg` 作者已经处理过并留了注释):
| 位置 | 泄漏内容 | 修法 |
|---|---|---|
| `fogCoverage` | 迷雾「涉及城市」列表里带出黑名单城市 | 加 `regionSQL(f.Regions, f.RegionsExclude)` |
| `extremePlace` | 报告页「最北/最南」直接显示**地点名与城市** | 同上 |
| 报告页「新地点」计数 | 数字里含黑名单区域的地点 | 同上 |
实测(测试库把河池市/长沙市/贵阳市设为黑名单):访客的迷雾城市列表不再出现河池市(站长仍可见);
报告页「最北」由「小平香辣老素粉·贵阳市」变成「喜洲古镇洱海环境监测站」;新地点计数 47 → 24。
- **迷雾瓦片图层无法按城市过滤**:迷雾是站点级数据(`fog_blocks` 不分数据集),
格子上没有任何城市归属信息,要按城市遮罩得先有行政区划边界。这条只能如实告诉用户。
### 追查「未命名」点:不是删除留下的残影
用户提示「删掉建设路后刷新页面大概率会变成未命名」。在测试库照做,结论是**不会**:
- 删掉建设路(src_pk=119)后 `/api/points` 322 → 321,不含建设路,`visits` 里 place_id 为空的记录 0 条
(`visits.place_id` 是 ON DELETE CASCADE ✓ 到访跟着删);
- 空名字的点只有一个,**删除前就已经存在**:测试库 id=365 / 生产库 id=1987,都是 **src_pk=42**,
地址字段全空、1 次到访(2025-10-16 22:26→22:46),坐标 24.6898/108.0365,
与建设路相距约 1.9km、到访日期也不同 → **两条不同记录**。
- 名字为空的源头在**原始备份里**:`ZLOCATION` Z_PK=42 本来就是 `ZNAME_=NULL, ZLOCALITY=NULL`
(整包仅此 1 条),是 rond 自己记的无名停留点,不是我们导入丢的。
- 生产库的建设路**仍然存在**(id=2064,1 次到访 2025-10-18),我未动生产库。
**黑名单「无效」的完整解释(三层)**:
1. **只对访客生效**(`filterOf` 有意为之)→ 登录状态下试必然没变化;
2. 迷雾瓦片无法按城市过滤(雾是站点级、格子无城市归属);
3. 黑名单匹配的是 `province/city/district` **文本字段** → 地址全空的点匹配不上。
实测黑名单=河池市:访客侧 92 个 `city='河池市'` 的点全部消失,但 src_pk=42 那个空地址点**照样返回**。
**可提议的改进(未实现,等用户定)**:导入时对地址为空的点用高德 regeo 反查补上 city/district
(站点已有 `geocode_key`),这样黑名单能覆盖它们,顺便这类点也能显示成真实地名而不是「未命名」。
代价是要吃反查配额,得限量/串行。
### 反查补全无地址地点 + 迷雾遮罩区(对访客隐藏的范围)
**A. 反查补全**(`internal/web/backfill.go` 新增)
- `backfillPlaceAddresses(ctx, datasetID, limit)`:只挑「地址全空或名字为空」的地点,
**只填空着的列**(不覆盖已有值);名字为空时依次尝试:最近 POI(`amapNearestPOI`,与 rond 自己的命名方式一致)
→ 街道 → 乡镇。复用现成的 `amapRegeo` 与 `amapGet` 里的全局 10QPS 限速,`limit` 兜住配额。
- 三处接线:① 导入成功后自动跑(限 20 条,结果拼进 flash);
② 后台首页数据集卡片上的「补全缺失地址」按钮(每次 100 条,点了会告知还剩多少);
③ 路由 `POST /admin/places/backfill`。
- **实测**:测试库那条 `name=''`/`city=NULL` 的点 → 「寿乡雪花牛肉 · 广西壮族自治区 河池市 金城江区」;
**生产库也跑了一遍**(用户说「有的都反查」):id=1987/src_pk=42 同样的结果,库里已无缺地址的地点;
导入钩子单独验证 —— 先清空该点名字、从测试库导出一份**内容不同**的备份再上传,
提示里出现「已自动反查补全 1 个缺地址的地点」✓(内容相同的备份会走「完全一致」分支,不触发导入)。
- 顺带:`conf/app-test.ini` 原来没配 `geocode_key`,这功能在测试环境是关闭的 → 补上。
**B. 迷雾遮罩区**(新表 `fog_masks`、`internal/fog/mask.go`、`internal/web/fog_mask.go` + 管理页)
- 数据模型是**圆**:中心(存 GCJ-02,与 places 同系)+ 半径(米)。
- 判定前把中心换算到迷雾网格(WGS-84);注意网格纵向边长恒定、横向随纬度乘 cos(φ) 收缩,
所以「地上的圆」在网格里是**椭圆**(横向更宽)——按正圆算的话高纬度会明显偏窄。
- `RenderTile(blocks,z,x,y,crs,masks)`:被遮住的格子既不画填充,**也不算「未探索」的邻居**,
否则遮罩边缘会被描出一圈轮廓,等于把隐藏范围本身暴露了(为此加了 hole 缓冲)。
低缩放级(格子小于 1px)按块中心判定。
- `handleFogTile` 只给**访客**带遮罩(站长要看完整迷雾才能核对遮哪里);覆盖率同样只对访客扣掉
遮罩内的地点与格子(`MaskSet.MaskedCells` 只遍历遮罩包围盒内的块,不做全库扫描)。
- 管理页 `/admin/fog/mask`(入口在后台迷雾卡片上,带数量):Leaflet 地图点选圆心、marker 可拖、
半径输入实时改圈、已有区域画出来;列表可编辑(回填表单 + 地图跟过去)与删除。
- **实测**:无遮罩时访客与站长的同一张瓦片完全一致(2104 像素);加一块 1500m 遮罩后
访客只剩 346 像素、站长不变;覆盖率 575,753 格/47.8 km²(站长)vs 570,569 格/47.4 km²(访客),
差的正是圆内的迷雾。
- **踩的坑**:页面漏了 `p.HasMap = true` —— 布局按这个标记条件加载 Leaflet,漏了地图整块不出现
(页面只有 3 个 script、`window.L` 是 undefined)。CDP 检查抓到的。
另外 inline 脚本要等 `load` 再初始化(Leallet 在 content 之后引入),与后台「新增地点」同一条约定。
### 行程轨迹泄漏黑名单城市 + 统计页的备份文件名
**1. 轨迹(`apiTrack`)漏了区域限制**
根因:那个 handler 只处理了 `f.From/f.To` 与「家/工作」,**没套区域限制**——而它是
`movements JOIN places pf/pt` 后返回两端坐标,所以只要线段碰了黑名单城市,那个坐标就露出来了。
修法:区域限制套在**两端**上(任一端在被隐藏区域,整段都不返回;只按起点过滤的话终点照样画得出来)。
为此把 `regionSQL` 加上表别名参数(原来写死 `p.`),7 处调用点同步更新。
实测:
- 测试库(黑名单=贵阳市):站长 192 段 / 访客 145 段 / 隐藏 47 段,
**47 段全部是贵阳市相关、0 段误伤**,访客结果里贵阳市相关 0 段(可复现,跑两次一致)。
- **生产库**(黑名单=河池市,正是用户设的):站长 298 / 访客 146 / 隐藏 **152** 段,
152 段全部河池市相关、0 误伤,访客侧 0 泄漏 —— 也就是线上原本漏了 152 段。
**2. 统计页对访客显示「数据来自 xxx.rondbackup」**
`stats.html` 那行副标题把备份文件名给了所有人看。改成只有站长可见
(`{{if .IsAdmin}}`),访客只留时间范围。
顺手把访客可见的 7 个页面(/ /stats /report /places /map /timeline /topics)扫了一遍:
「rondbackup」「数据来自」字样均为 0 ✓。
**待说明**:GPX 图层(用户上传的 .gpx 轨迹)没有城市信息,无法按区域过滤——那是另一份独立数据。
### 报告页占位卡片 / KPI 灰格 / 追踪脚本 / 完整 debug
**1. 报告页「迷雾版图」是空白** —— 不是渲染问题:那张卡里**从来只有标题和一句说明**,
没接任何内容(本来想放地图但没接上)。报告页本身也没有地图(只有首页/地图页/地点页有)。
改成与统计页一致的四个数字(面积 / 点亮率 / 点亮地点 / 快照块数),标题改为「世界迷雾」。
**2. KPI 灰格**(用户说「有两个格子是灰色的,其他地方也有」)
`.kpis` 原是 `grid-template-columns: repeat(4, …)` + `gap:1px` + **底色画间隔**,
所以不满行时空格子会露出底色 → 灰块。KPI 个数又常是 6(报告)、7(统计)这种除不尽的。
改成 `display:flex; flex-wrap:wrap` + `.kpi{flex:1 1 158px}`:最后一行自己撑满,
任何个数、任何宽度都不会有空格。`.kpis-3` 同步改成 `flex: 1 1 30%`。
实测(CDP 量每行缺口):1440 下 6 个 KPI 一行排满、4 个一行排满;700 下 4+2 与 3+1 都撑满;
390 下 2+2+2 与 2+2 —— 全部 1px(就是边框),无灰格 ✓
**3. app.ini 新增 `track_script`(第三方统计脚本)**
填 URL(http/https 开头)→ 渲染成 `<script defer src="…">`;含尖括号则整段原样输出
(百度统计/Plausible 这类要带属性或初始化代码的)。形如 URL 却夹引号/尖括号的视为配置写错,宁可不挂。
只注入**公开页面**(`{{if and .TrackScript (ne .Nav "admin")}}`),后台与登录页不挂。
实测:`/`、`/map`、`/stats`、`/report` 有;`/admin`、`/admin/login` 无;主实例没配则为 0 ✓
**4. 完整 debug(按用户要求)—— 找到并修掉的问题**
- 🔴 **专题页泄漏**:`topicContent`(专题详情页 + 专题列表页的统计)**没套区域限制** →
访客能在 `/topics/{id}` 与其地图上看到黑名单城市的全部地点。修:加区域参数 +
新增 `visitorRegions(p)` 把「只对访客生效」收敛成一个入口(filterOf 也改用它)。
实测:访客 0 个点、站长 76 个点 ✓
- 🔴 **迷雾瓦片的共享缓存泄漏**:访客与站长的瓦片 **URL 完全相同**,却都返回
`Cache-Control: public, max-age=3600` → CDN/反代会把站长那份**未遮罩**的瓦片发给访客。
修:站长那份改 `private, no-store` ✓
- 🟠 **删除数据集后的静默失败**:`adminDelete` 里「自动启用最新一份」那步**忽略错误** →
失败时站上一份生效数据都没有、页面全空,提示却说「已删除」。修:检查错误并明确提示 ✓
- 已确认**没问题**的:所有 `/admin/*` 都有 requireAdmin ✓;排序/方向/表名都是白名单或转义
(无 SQL 注入面)✓;设置**没有**内存缓存(不存在改了不生效)✓;瓦片代理有图层白名单
(不是开放代理)✓;上传与表单都有大小上限 ✓;访客可写入口只有投票(整型参数)✓。
- 记下**未改**的:`apiVotes` 仍下发黑名单地点的票数(按 src_pk、无名称坐标,信息量极低);
GPX 图层无城市信息无法过滤;登录只有验证码、没有失败次数限流。