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

70 KiB
Raw Blame History

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 事件(捕获阶段)与「同路径 点击」时把 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 量文本实际占几行:

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 图层无城市信息无法过滤;登录只有验证码、没有失败次数限流。