用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
924 lines
70 KiB
Markdown
924 lines
70 KiB
Markdown
# 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 图层无城市信息无法过滤;登录只有验证码、没有失败次数限流。
|