Files
rond-with-you/.workbuddy/memory/2026-09-17.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

627 lines
49 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-17
## 迷雾遮罩:生效时机、重叠语义、访客默认页(提问)回复
用户三问:①遮罩生效需要时间吗 ②重叠遮罩有效吗 ③能否设置访客默认页面。
**① 生效时机:服务端即时,访客端原来最长 1 小时**
`handleFogTile` 每个请求都 `LoadMasks` 实时读库(遮罩**不在** `fogIndex` 的 sync.Once 缓存里),
所以保存/删除后下一次请求即生效、无需重启。**但**访客瓦片是 `public, max-age=3600`,
而瓦片 URL 的 `?v=` 来自 `ix.Version()`(只跟**迷雾数据**走)→ 设完遮罩 URL 不变,
访客浏览器/CDN 继续用旧瓦片,看起来像「遮罩没生效」。
修:新增 `fog.MaskFingerprint(masks)`(FNV-1a 64,按 id|lat|lon|radius 求哈希),
`mapConfig` 的 `fog.ver` 变成 `"<ix.Version()>-<指纹>"`(string)。遮罩一变 URL 就变,缓存自然失效。
实测:无遮罩 `1789578442-0` → 加一块 `…-7e72b20aa455d2f9` → 再加一块 `…-5cc45ddb03dc0230` → 删光回到 `…-0` ✓
**② 重叠遮罩:渲染有效,但统计原来会重复扣数(已修)**
- 渲染/判定本来就是并集:`MaskSet.Hit` 遍历所有圆、任一命中即隐藏 ✓,重叠无副作用。
- **但 `MaskedCells` 是「逐遮罩累加命中格数」**——重叠区域被统计多遍,`cov.Cells` 扣多
(面积走的是另一套逐格算法 `AreaKm2Excluding`,于是两个数字互相矛盾)。
修:改成**逐格判一次 `Hit`** + 用 `done` 集合对「遮罩包围盒覆盖到的块」去重。
- 新增 `internal/fog/mask_test.go`:
`TestMaskedCellsUnion`(重叠时 == 暴力并集)、`TestMaskedAreaMatchesCells`(面积差换算成格数与 MaskedCells 一致,1e-6 m²)、
`TestMaskFingerprint`(增删改都会换指纹)。
**验证测试有效**:退回旧算法 → 303019 vs 并集 189470(多扣 60%)→ FAIL ✓
- 实测(测试库 8701 块迷雾):无遮罩访客=站长;一块 2000m 遮罩 → 访客像素 77959→71689、格数 575753→571692,站长不变;
再加一块**完全相同**的遮罩 → 访客数字**一点不变**(旧实现会扣两倍 8122 格)✓;删光后双方复原 ✓
**③ 访客默认页面:目前没有这个功能**
`/` 固定走 `home`;页面级控制只有 `setting.AllPages`(map/places/timeline/stats/report 的可见性开关)。
已在回复里给方案让用户选(后台下拉 / .ini 配置 / 不做)。
## 新增「访客默认页面」(后台设置下拉)
需求确认后按「后台设置加下拉」实现:
- `setting.Settings.VisitorHome`(json `visitor_home`,缺键/非法值一律回落 `VisitorHomeMain="home"`),
`VisitorHomePath()` 只在**目标页已对访客开放**时返回 `/<key>`,否则返回空串不跳。
收敛用 `NormalizeVisitorHome()`(表单被改坏时回落主页)。
- `home` handler:在「没导入数据」分支之后,`!IsAdmin && VisitorHomePath()!=""` → 302。
刻意不跳的三种情况:站长自己访问、没配、目标页没开放;另外**没数据时不跳**
(让访客留在主页看引导,比落到空地图有用)。
- 后台「公开页面」卡片下方加下拉(主页 + AllPages 五项)。
- 新增 `internal/setting/setting_test.go`:纯逻辑用例 + `TestVisitorHomeStore`(ROND_DSN 门控,
**库名不含 test 时自动跳过**,避免动到正式站的 settings;用例内先备份原值、defer 还原)。
实测(测试库 HTTP):
| 场景 | 访客 / | 站长 / |
|---|---|---|
| 默认(home) | 200 主页 | 200 主页 |
| 设为 map(页面全开) | **302 → /map** | 200 主页(不受影响) |
| 设为 map 但 map 未对访客开放 | 200 不跳(/map 本身 404) | 200 |
| 库里被塞非法值 `//evil.com` | 200 不跳 | 200 |
CDP 复核下拉:6 个选项、正确回填(map 时选中「足迹地图」)、无 JS 报错。
生产库没有这个键 → 缺键回落默认,访客行为不变 ✓
## 到访热力无效 + CPU/内存优化 + 点地图跳 /map(+ 回归)
### 1. 「到访热力」无效:热力图恒为空
`full()` 里 `var points = opts.points || []`,而地图页首屏是 `full({points: []})`(地点随后走
`/api/points` 拉回),`setPoints` 只写了 `st.points`,**没同步闭包里的 `points`**;
`buildHeat` 用的正是闭包那个 → 恒为空数组。
修:`buildHeat` 改用 `st.points`,并让 `setPoints` 同时写 `points = st.points = pts`
(与 `preview()` 里已有的写法一致)。
**实测(awaitPromise 探针 + canvas 像素统计)**:旧写法画布在但**非透明像素 0**;
修复后 59258 像素(生产站访客视角 51049)✓ 这就是「勾了没反应」。
### 2. 点击地图跳 /map:改成按需启用
`preview()` 里硬编码 `map.on('click', () => location.href='/map')`,而 preview 被 4 处复用:
主页(要)、地点详情页(不想要,上面本来就有「在地图中查看」按钮)、后台照片页 2 处
(**最不该有,点一下就被甩出后台**)。
修:加 `opts.clickToFull`,只有主页传 true。实测:主页 `home-map` 有 1 个 click 监听;
地点页 `place-map` 为 **0** ✓
### 3. CPU / 内存优化(先测量后动手)
**基线**:`/stats` 379ms(访客)/ 505ms(站长),fog 瓦片 7.9ms,其余 <50ms。
**根因(测出来的,不是猜的)**:
- 单个统计查询只要 **2–3ms**(psql 也是 3ms),但页面 379ms → 逐段计时日志显示
`weather` 任务 400–700ms。测出**建一条 PG 连接要 307ms**(psql 建连同样 0.55–0.7s)——
Windows 上新建 backend 进程就是这么贵。
- 池配置 `MaxOpenConns(12)` + **`MaxIdleConns(4)`**:串行页面只需 1 条连接(首次付 300ms,
之后复用),**并发超过 4 条时反复重建连接**,于是「并行反而更慢」。
- 另外 `fogCoverage` 每次要 `ix.All()` **三次**,而 `All()` 会复制 10542 个块并**排序**。
**改动**:
1. `internal/db/db.go`:`MaxIdleConns` 提到与 `MaxOpenConns` 相同(8/8),
新增 `WarmUp(d, 6)` 在启动时并发预建连接(main.go 调用)。
2. `internal/web/parallel.go`:`runJobs` 并发执行页面里互不依赖的查询(上限 6),
debug 下打印 >30ms 的慢查询(**这条日志就是定位到建连问题的关键**);statsPage 的 13 个查询改用它。
3. `internal/fog`:`Index` 预存 `areaM2`(写入索引时顺手算),新增 `Index.AreaKm2()`;
`maskedStats`/`Index.MaskedStats` 直接在索引上按遮罩包围盒算(不再复制整张块表),
删掉旧的 `MaskedCells` 与 `AreaKm2Excluding`。
4. `internal/web/mask_cache.go`:缓存遮罩列表 + 指纹(30s TTL,保存/删除时**主动失效**),
瓦片渲染结果也进 `fogTiles` 缓存(key 含 crs 与遮罩指纹,站长用 "-")。
**结果**:`/stats` **379→38ms / 505→29ms**;fog 瓦片 **7.9→1.8ms**;
rond.exe RSS 42–44MB(压测 200 次请求后不涨,无泄漏);
单次 `/stats` 约 15.6ms CPU。剩下的 2 条慢查询是启动后第一次请求的冷连接。
### 4. 回归检查又发现两处区域限制遗漏(同「轨迹」那类)
`Overview` 的**总里程**与 `TransportStats`(出行方式)都重建了 `where` 而**丢掉 `f.Regions`**
→ 访客看到的里程/出行里程里含黑名单区域。新增 `Filter.movementsWhere()` 统一(按两端的
`places` 判定,黑白名单语义与 `visitsWhere` 一致),两处都换过去。
实测生产站:访客里程 **16861.9 → 4678.8 km**,站长不变 ✓
新增 `internal/stats/stats_test.go`(ROND_DSN 门控、**只读**):
与手工 SQL 逐项对账 + 区域过滤对 visits/movements 双向生效 + 时间范围仍有效。
**验证测试有效**:退回旧写法 → 立刻报「白名单 广西壮族自治区 后里程没减少」✓
其余巡检:8 个公开页 + 7 个后台页全部 200;遮罩保存/删除**立即生效**(28ms 内访客数字与瓦片
同步变化,站长不受影响);全量测试通过。
### 主实例「退出」事件排查:不是崩溃,是端口被另一个进程接管
收到后台任务完成通知(我启动的 8080 实例结束),排查结论:
- **事件日志没有任何 rond 崩溃记录**(Application 日志里 Application Error / WER 均无);
rond 自己的日志也没有 panic → 是**被正常终止**,不是崩溃。
- 进程清单(Get-CimInstance Win32_Process)给出了真相:
| PID | 启动时间 | 命令行 |
|---|---|---|
| 11992 | 01:33:08 | `bin\rond.exe -c conf/app-test.ini`(我起的 8081 测试实例) |
| 14556 | **02:36:42** | `bin\rond.exe -c conf/app.ini` —— **没有日志重定向** |
- 日志里的 `02:36:46 已启动` + `02:36:47 服务异常退出: listen tcp :8080: bind ...`
是**我的重启尝试**:它在 02:36:42 那个进程占住端口后才启动,于是 bind 失败退出。
(main.go 是先打印「已启动」再 ListenAndServe,所以会看到这两行连在一起。)
- 结论:**用户在 02:36 自己重启了服务**(命令行没有 `tee` 重定向,所以它的日志不落在
storage/rond-main.log 里,这正是「日志里看不到我这次请求」的原因)。我停止抢占 8080。
- 已验证当前实例是**最新构建**(`/api/map-config` 的 `fog.ver` 带 `-0` 遮罩指纹后缀,
这是本轮才有的),/stats 实测 59ms、8081 对照 45ms(整机当时有浏览器等负载,属正常波动)。
**运维提示(值得记住)**:`main.go` 先打印「足迹站点已启动」再真正 ListenAndServe,
所以端口被占时会出现「已启动」+「服务异常退出: bind」两行连在一起 —— 看到这对组合
就是**有另一个实例抢着占了端口**,而不是启动成功后又崩了。
---
## 出行(ZMOVEMENT)后台增改删 + 导出保真(本轮新增功能)
需求:编辑页能对「出行区间的方式 / 时长 / 时间」做新增、修改、删除,且导回 rond 正常可用。
**先反推真机语义**(拿 `data/20260914_151649.rondbackup` 逐列实测,脚本跑完即删):
- `ZMOVEMENT` 11 列,`ZTYPE_` **791/791 非空**:2=无交通方式(中位速度 5.0km/h≈步行)、
5=有交通方式(中位 17km/h),另有 23 条 4、1 条 0 → **不能写 NULL**,站点只给新行补 2/5。
- 时间窗口硬不变量:`ZSTART_ >= 起点到访离开`、`ZEND_ <= 终点到访到达`(760/756 条 0 违例)。
- `ZTRANSPORT` 是全局表,站点侧从位移聚合;`ZVISITFROM_/ZVISITTO_` 真机就有 35 条为空。
**改动**(`internal/rond/export.go`、`internal/web/handlers_edit.go`、`view.go`、`server.go`、
`templates/admin/edit.html`、`static/css/app.css`):
1. 编辑页新增「行程记录」卡片:搜索(地点/方式/日期/#编号)+ 分页 + 行内改(起止到访、
时间区间、方式)+ 删除;「新增行程」表单重做(时间留空自动按两端到访推断、方式可内联新建)。
2. 落库补齐派生字段:`from_place_id`/`to_place_id`(**区域黑名单按两端 places 判定,留空会绕过过滤**)、
`distance_km`、`duration_min`,与导入路径同源。
3. 编号复用防护 `nextSrcPK`(取库内/coredata_meta/留档三者上限 +1)。
4. 导出侧:`ZMOVEMENT.ZTYPE_` 进 `keepNilCols` + 新行补值;`queryTransports` 改 `GROUP BY`
(原来 `DISTINCT` 会在同名冲突时产出两个同 `Z_PK` → 撞主键让整次导出失败);
`ZTRANSPORT` 的名称/颜色/图标「空则保留基线」+ `keepMissing`(没有行程引用的方式不再被删)。
**验证(可复现,全部实测)**
- 新增 `internal/web/movement_e2e_test.go`:真导入真机包 → 调真实处理函数 → 导出 → 读导出 SQLite 核对。
门控 `ROND_TEST_CONF` + `ROND_SRC`,在测试库建一次性账号、跑完连数据集一起删。
实测:删基线最大位移 #791 后新建拿到 #792(防复用生效);新行 `ZTYPE_`=5/2、`ZTRANSPORT` 指向新方式、
原名保真;`tools/verify_rondbackup.py` 比对 → **ZMOVEMENT/ZTRANSPORT valuediff=0**,只有我动过的行在差。
- 纯净往返(不增删改)对照组:新旧代码的比对输出**逐行相同**(总差异 3,全是 `ZLOCATION.
ZLASTARRIVALDATE_` 的「站点重算最近活跃时间」,属既有行为)→ 证明改动没波及别处。
- 项目自带断言全过:`TestExportRoundtrip`(坐标 0 偏差、时间 0 偏差)、`TestBaselineKeepsCoreData`、
`TestProgramRowsFillNativeColumns`(新行 2 条 `ZTYPE_` 全有值)、新加 `TestMovementTypeNeverNull`。
- 无头 Chrome(`rond-web-verify` 技能)实测编辑页:**0 JS 报错**,6 张卡片顺序正确,40 行行内表单、
41 个方式下拉、165 个 flatpickr 全初始化;方式联动(选已有→预填名称/颜色/图标、新建→清空、
无→禁用)与时长实时提示(「时长 1 小时 35 分钟」/「结束时间必须晚于开始时间」)均按预期。
- 移动端:发现行程行高 206px(到访行 115px),把时间压成一行 + 窄屏隐藏城市名后降到 165px
(卡片 10607→7332px),320/390 无横向溢出;桌面 61px vs 到访 57px,基本齐平。
**注意**:8080 主实例已用新构建重启(`bin/rond.exe~` 是改动前的旧二进制,可回滚)。
### 反馈三点:改方式不生效(真 bug)、卡片折叠、筛选改选项框
**1) 「改方式保存不了」的根因(我上一轮引入的)**
页面时间输入只到分钟,回填后原样提交 → 服务端把 `20:03:21.323` 截成 `20:03:00`:
① 顺带改掉了时间(数据漂移);② 截断值早于起点到访的离开时间(`20:03:21`),被上一轮
新加的时间窗口校验直接拒掉。用户那行是 **movement #385(09-13 20:03→20:31,电🐔)**,
起点到访 #403 的离开时间正是 `20:03:21.323088`——卡在秒上。
修法:**与库里同分钟就沿用库里原值**(`keepStoredTime`;行程走 `resolveMovementEnd`),
手填越界仍给硬校验(带 1 分钟取整余量)。到访的 `editVisitUpdate` 是同一类病,一起修了
(改备注也会磨掉 `ZARRIVALDATE_` 的秒)。回归用例:
`TestKeepStoredTime`、`TestMovementWindowKeepsStoredSeconds`,E2E 的「只改方式」用例
专门挑「截到分钟就会越界」的行,并断言导出包里 `ZSTART_/ZEND_` 与原始包逐位一致。
**2) 卡片折叠**:编辑页 6 张卡片各加 `data-card` 键 + 标题右侧箭头(标题整行可点),
状态记 localStorage,页头加「全部折叠/展开」。选点地图做了延迟初始化
(折叠着就不建,展开时经 `window.__pickerMapInit` 补建)——否则 Leaflet 在
`display:none` 容器里初始化会拿到 0 尺寸。
**3) 行程筛选**:把手输关键词换成三个选项框——交通方式(全部/无方式/各方式含段数)、
地点(起或止,取前 300 个地点)、开始时间从/到(date)。分页链接改由 Go 生成
(`EditData.PageLink` 返回 `template.URL`)——直接拼在 href 里会被转义成 `mt%3d4`,
翻页会静默丢筛选。
**实测**:`mt=4`→332 段(与下拉里的「332 段」一致)、`mt=none`→390 段(= 791−401 ✓)、
日期区间 2026-09-01~30→37 段;`?mt=4&mpage=2` 第 2/9 页且选中态保持;折叠/展开/全部折叠
与 localStorage 读取均正常;桌面筛选行底边极差 0(按钮不歪)、390/320 无横向溢出;
全程 0 JS 报错。8080 已用最新构建重启。
### 再反馈:默认折叠 + 选择器收不回/被覆盖
**默认折叠**:折叠脚本的默认值从「未记录=展开」翻成「未记录=折叠」(`state[k] !== false`),
只有明确记过展开的卡片才展开。实测 6/6 折叠、页面高度 = 视口高(不再一屏拉不到底)。
选点地图的延迟初始化因此成了常态路径:折叠时 `startMap()` 不执行、也不建 Leaflet,
展开时经 `window.__pickerMapInit` 补建(实测展开瞬间就发出 `/api/map-config` 请求)。
**选择器「只能选不能收回 + 被其他元素覆盖」**——量出来的根因是 CSS 不是 z-index:
`.picker-results` 有 `max-height:200px` 但 `overflow:visible`(flex 子项不会因上限收缩),
30 条候选实际溢出 **1020px**,溢出部分被后面 DOM 里的表单元素压住
(`elementFromPoint` 在框下方 20px 处拿到的是 `BUTTON.geo-item`,即「看得见点不着」)。
修法:`.picker-results{overflow-y:auto}` → 200px 内滚(框下方 20px 处已变回 `DIV.row`)。
交互上补:点空白处 / Esc / 失焦都收回候选列表;行内编辑表单里隐藏域已有值时
直接渲染成「已选: #173 2026-09-13 20:40 家 + 重选」,用户能看到当前值、也能清空重选
(原来预填了隐藏域却不显示,看着像「选了就收不回」)。
### 地点坐标换算(有些点没做过 GCJ-02 偏移)
需求:地点列表每个点能就地做坐标系换算,并能看到前后变动,不用删了重填。
**先反推判据**(实测 `data/20260914_151649.rondbackup` 的 323 个地点):
`gcj(raw)==lat` **305** 个(正常)、`raw` 与 `lat` **一字不差 18 个**(= 当初没偏移,显示坐标
其实还是 WGS-84,偏 500~540m)、其他 0 个。⇒ 「哪个点没偏移」可自动判定:`raw == lat`
且在中国外接矩形内(境外的点本就不该偏移,必须排除,否则误报)。
顺带纠正了记忆里「293/323」的旧数字(实测 305)。
**实现**:`GET /api/coords?mode=wgs2gcj|gcj2wgs`(站长限定,geo 包是唯一实现,实测
21.428836,109.116822 → 21.426460,109.121113 / 517m 往东南,与离线 Python 分析逐位一致);
编辑页每行加「坐标系换算」下拉 + 前后对照行(`原 → 新(移动 N 米 · 往X)`),
换算前先记 base,来回切方向不会越换越偏;地点列表加「疑似 WGS-84」橙标 + 「坐标系」筛选
(实测生产站正好筛出 18 个)+ 「比导入时移动了 N 米」;保存后的提示带上移动距离与方位。
「导入时的原值」取自 entity_raw 留档里的 ZLATITUDE/ZLONGITUDE,所以改动后能一直对照。
**实测(端到端)**:换算 亚平村二巷 #33 后,导出包里 `ZRAWLATITUDE/ZRAWLONGITUDE` **一字未动**、
只有 `ZLATITUDE/ZLONGITUDE` 变了,且 `gcj(原始GPS) == 显示坐标` 重新成立(rond 的坐标模型)。
`verify_rondbackup.py` 差异明细:`PK 33 differs: [('ZLATITUDE', 21.428836, 21.42646),
('ZLONGITUDE', 109.116822, 109.121113)]` —— 正是要动的那两列。
另外给验证脚本 `site_cdp.py` 加了 `--await`(async 表达式等 resolve 再取值),
这次要验 fetch 驱动的换算界面才发现原来不支持;SKILL.md 补了用法。
### 站长给的实例:IMG_20260418_181404.jpg 的坐标到底对不对
站长截图给了「源图 GPS」,我读不了图,但照片本体在 `Downloads/temp2/` 找到了 —— 直接读 EXIF:
`22°51'11.58"N 113°15'11.04"E` = **22.853216, 113.253067(与站点里存的完全相同)**,
相机 HUAWEI Mate 80 Pro Max。结论:**导入时没做偏移**(导入页默认会转,所以是当时选了
「已经是 GCJ-02」)。EXIF 按标准就是 WGS-84 → 这个点在 GCJ 底图上偏了 **626m**。
**怎么裁决**(不靠猜):站长说手机地图上显示在「银星雅苑西北门/柏景楼二期/翠庭轩/咖研社
这个十字路口」。用这些地物名走 `/api/geocode` 取坐标再比距离:
- 银星雅苑 22.849159,113.258707 → 距现状 733m、距换算后的点 **144m**
- 翠庭轩 22.850376,113.259255 → 距现状 708m、距换算后的点 **91m**
⇒ 手机是对的,站点偏了。已按真实保存路径换算:**22.853216,113.253067 → 22.850420,113.258373**,
提示「坐标移动到 22.850420, 113.258373(626 米,东南方向)」,复测新位置距地物 91~144m。
**补了回归断言**(`movement_e2e_test.go` 5d):站点新建(= 照片导入同类)的地点换算后,
导出包必须 `ZRAWLATITUDE=22.853216,113.253067`(换算前输入值)+ `ZLATITUDE=22.850420,113.258373`
(换算后),且 `gcj(原始GPS)==显示坐标`。这条覆盖了「新行 ZRAW 由显示坐标反算」的分支。
站长选了**先不补**「照片导入的坐标来源」记录 → 这类点仍然不在「疑似 WGS-84」判据里,
只能按上头的「地物名裁决」人工核。这条限制已写进 MEMORY.md。
### 生产环境「这两天导入的照片全是错坐标」——排查与补救清单
站长在生产(life.shiroko.one)导了一批照片,都是按「已经是 GCJ-02」导的 → 坐标没转换。
**排查手段(不登录也能做)**:公开 `GET /api/points` 带 `sp`(=src_pk) 与全精度经纬度。
生产 281 个可见点里:195 个长小数全部 sp≤323(= rond 导入基线),85 个 ≤6 位小数全部 sp>323
(= 站点新建,即照片导入)。**站长从没手工点选新增过地点**,所以这 85 个就是全量受影响面。
(生产中还有被区域黑名单/迷雾遮罩挡住的点,完整清单要登录后取。)
**判定命中**:拿仓库 `data/images/` 的 51 张长沙原图 EXIF 对账生产数据,24 个可判定的点
**全部**是 `stored == round(exif,6)`(没转换),**0 个**是 `gcj(exif)`。本地环境同样 24/24。
偏移 351~673m,方向南/东南(随纬度变化)。清单:`tmpfog/photo-coord-fix-preview.csv`。
**关键坑**:不能靠库内字段自证——逆地理用的是同一个错坐标,省市区/街道/名称自洽;
`places`/`visits` 无 created_at,也没记录导入时的坐标系选择。只有原图 EXIF 能定罪。
**生产环境事实**:跑的是 v7(`/api/coords` 在、`app.css` 含 `.tag.warn`),所以现成接口能用;
只走 IPv4(curl 必须 `-4`,IPv6 超时);登录有验证码,cookie 形如 `uid|token`(HMAC,不绑
IP/UA),站长复制过来即可用 curl 操作。
待站长定:会话怎么给 / 照片目录是否提供(把 61 个未匹配的点也逐张核实)/ 是否补「导入来源」记录。
### 照片错坐标:86 个站点新建地点的最终裁定(含判据标定)
对生产 86 个 `sp>323` 的点做了三路证据对账,结论 **63 修 / 23 不动**。
**判据 A(铁证)· 47 个**:库内坐标与某张原图 EXIF **六位小数逐位相等** → 当时没换算。
长沙 24(sp344-367)+ 佛山 23(sp387-409)。原图在本机 Downloads/桌面等 4 个目录下。
**判据 B(列签名)· 16 个**:`places.country_code='CN'` 且 `timezone='Asia/Shanghai'`。
机制:`photos.html` 的 `tInput(name,value,...)` 把 country/timezone 写成**表单默认值**,
而 `edit.html` 的同名字段只有 placeholder(空 value),且 `search()` 只回填
name/province/city/district/坐标,**不回填** country/timezone/街道。
标定(60 个已知答案):47 个照片导入**全部**命中;13 个按名搜索只有 1 个例外
(324 昆明站,坐标与 POI 完全一致、且与 325 是重复点 → 归为「不动」)。→ 判据可信。
命中的是荔波 368-383(荔波原图不在本机,无法用 A 复核)。
**判据 C(不动)· 23 个**:昆明 2 + 大理 18 + 荔波 384/385 + 南宁 386。创建于「新增地点」页,
坐标来自高德 POI 或地图点选,本来就是 GCJ-02。
**被否证的两条判据(别再捡起来)**:
- **「按名搜索同名 POI」的对称距离判据不可用**。在 47 个已知「需修」点上标定:
只有 17/31 有候选的点判对(55%),**14 个把需修的点判成「正确」**(如 sp409 柠季
same=16.7/fix=630 → 判「正确」但实际是 EXIF 原值);16 个连候选都搜不到。
只有 `same<=10m`(坐标就是 POI 坐标本身)这一档是可信的——47 个需修点里**没有一个**
落在该档。即「same≈0 ⇒ 按名搜索」成立,其余档位一律不可信。
- **迷雾栅格不可用**。荔波 16 个点里 12 个两侧都是 0%;大理更是出现与已知正确点
(sp333 喜洲古镇北区停车场,same=0.0)**相反**的结果(C=0%、gcj(C)=23%)。作废。
**其他相关事实**:
- 导出的 `ZISPHOTOIMPORT` 恒为 0(站点导出不给新建行补这个值),所以 rond 里分不出
「照片导入」与「手工新增」——这是继「坐标来源」之后第二个该补的缺口。
- `editPlaceUpdate` 的 UPDATE 不含 `thoroughfare`,所以「只改坐标」不会动到街道字段(好消息);
但省/市/区/街道是按**错坐标**反查来的,改完坐标后会与新位置差约 600m(多为同区,可接受)。
- 生产备份里 `ZVISIT.ZUSERADDED` 85 个新建到访**全部为 1**,`ZISPHOTOIMPORT` 全 0。
产物:`tmpfog/fix-list-final.csv`(63 行)、`tmpfog/keep-list-final.csv`(23 行)、
`tmpfog/fix-review.html`(前后对照表)、`tmpfog/live-preview.csv`(对生产只读试运行,
已验证会话有效、线上值与备份一致)。写库走 `tools/photo_coord_audit.py --apply`,
换算调服务端 `/api/coords?mode=wgs2gcj`(与错导入正好互逆)。**尚未写库,等站长确认范围**。
### 已执行:63 个坐标全部换算回 GCJ-02(生产)
站长选「全部 63 个 / 只改坐标」。用 `tools/photo_coord_audit.py --include-unverified --only <63> --apply`
写库(换算走服务端 `/api/coords?mode=wgs2gcj`):**成功 63 / 失败 0**。随后重新拉 `/api/points` 逐点复核,
`63 一致 / 0 不符 / 最大偏差 0.00m`,23 个「本来正确」的点坐标一字未动。
**执行后才发现并解决的重大疑点(差点误判成「改错了」)**:用同名高德 POI 当标尺时,有 6 个点显示
「旧值离 POI 只有 17~78m」→ 像是在说旧值本来就是 GCJ。**真相是循环论证**:这些点的**名称本身就是
「用那个错坐标做逆地理」生成的**(`/api/regeo` 会把最近的 POI 名回填到名称框),所以名称必然贴着旧坐标。
用 `/api/regeo` 在旧坐标上重跑一遍即可判定:名称出现在反查结果里 ⇒ 名称无证据力。
逐点跑完:**63 个里 13 个名称是反查来的、50 个是站长自己命名的**。
- 6 个「疑似改错」的点(349 天心阁、364、372、387 钟楼公园、394 顺德体育中心、409 柠季)**全部**落在
「反查来的」这 13 个里 → 全是伪证。
- 而「修后离同名 POI 4~39m」的(348 茶颜悦色、352 长沙南站、354 肉丸店、390 萨莉亚、407 大地醉鹅、
375 小七孔古桥…)**全部**落在「站长自己命名」那 50 个里 —— 站长按自己当时所在命名,新坐标落在同名
地物上,这才是有效证据。两组完全互斥,逻辑自洽。
**EXIF 基准的决定性证明(回答「照片 EXIF 到底是不是 WGS-84」)**:13 个按名搜索的点,坐标与高德 POI
六位小数完全一致 ⇒ 它们是已知 GCJ 真值。拿大理原图去对,3 个真值点各有一张照片,**EXIF 换算成 GCJ 后
落在 5.2 / 19.0 / 31.4m 内,且拍摄时刻与到访时刻相差 8~78 分钟**(`IMG_20260815_101254.heic` → 九孃
豌豆粉店 5.2m、晚 8 分钟)。不换算则离真值 238~370m。⇒ **EXIF 确为 WGS-84,换算方向正确**。
换算函数另在基线真机数据上验证 **305/323 逐位一致**,各地偏移量与真值吻合(贵阳实测 -0.00354/+0.00373,
函数给 -0.00353/+0.00377)。
**旁证:高德 POI 是 GCJ-02**(别再怀疑):拿贵阳基线的 76 个真值点验证,64 个高德 POI 落在
「显示坐标(GCJ)」3m 内、离「原始 GPS(WGS)」约 540m。
**修复后新出现的瑕疵**:13 个「名称/地址由错坐标反查而来」的点,坐标改对后名称与位置差了约 600m
(如 sp349 现在在天心阁东南 630m,名字还叫「天心阁」)。已列清单,等站长定是否按新坐标重跑一次逆地理刷新。
产物:`tmpfog/fix-report.html`(最终报告,含前后坐标、验证结论、遗留项)、`tmpfog/applied.csv`、
`tmpfog/_name_origin.json`、`tmpfog/_old_datum.json`、`tmpfog/_exif_dist.json`。
### 按新坐标刷新地址(生产)+ 坐标来源留档上线(根治方案)
站长指令:**「1、按新坐标重跑一次逆地理刷一致;2、按根治方案动手」**。两件都做完了。
#### 一、名称「与位置差 600m」的裁定:**不改名称,只刷省市区街道**
上一轮说「13 个点的名称/地址是旧坐标反查产物」用的是**宽松判据**(名称出现在旧坐标 regeo 的 blob 里),
这轮校准出它**过宽**:`#349` 的 blob 里出现「天心阁」只是因为里面有「8117咖啡(天心阁**店**)」和「天心**公园**」。
真正的判据只有两条(照片导入页 `applyOne` 只会用这两种方式自动填名):
① 名称 == 旧坐标 200m 内最近 POI 的店名;② 名称 == 「区县 + 街道/乡镇」兜底拼法。
按这两条重判:**13 个里只有 `#409`(柠季,旧坐标最近 POI 一字不差、17m)和 `#377`(荔波县瑶山瑶族乡,兜底拼法)是软件填的**,
其余 11 个是站长自己的标注 → **一律不动**。
**「同名地物离旧坐标更近」不是反证**(差点把它当成改错了):13 个里有 7 个被判「名称属于旧坐标」,
但旧坐标附近往往有一批同名 POI(天心阁古玩城/钟楼公园店/华莱士·钟楼公园店…),
更多是**站长照着导入页显示的那一行地址(`店名(N米) · 完整地址`)抄下来的**——地址也是错坐标反查出来的。
站长的 `#409` 就是实例。
**同一批次同一定律(这才是决定性的)**:一次导入里所有点用的是同一个坐标系选择、同一台手机。
长沙批次里 `#348 茶颜悦色`(旧坐标距同名 POI **647m**,远超反查半径 200m ⇒ 不可能是自动填的)、
`#354 肉丸店`(674m→11m)、`#352 长沙南站`(566m→12m)、`#360 四方坪三十栋饭店`(629m→35m)、
`#356 坡子街派出所`(322m→32m) 等 **8 个站长自己命名的点,换算后全部落在同名地物 11~383m 内**
⇒ 换算方向对整个批次成立 ⇒ 剩下那几个「旧坐标更近」的只能是名称伪证。
**设备同一性(补完最后一块证据)**:长沙原图 `data/images/IMG_20251219_*.jpg`、大理 `IMG_20260815_*.heic`、
佛山 `IMG_20260418_181404.jpg` **全是 HUAWEI Mate 80 Pro Max**。大理那 3 张已独立证明 EXIF=WGS-84
(gcj(EXIF) 落在高德真值 5.2/19.0/31.4m 内),佛山那张经站长肉眼复核(银星雅苑/翠庭轩 91~144m)。
同机同约定 ⇒ 长沙批次也是 WGS-84 ⇒ 上一轮 63 个点的换算全部正确,**无需回退**。
#### 二、地址刷新(已落库,39 行)
派生字段(省/市/区/街道)还是按旧坐标反查来的——长沙 24 个、荔波 16 个的**「区县」干脆是空的**
(顺带说明:这 40 行是**老版**照片导入页建的,那时表单还没有区县字段;佛山那批才带区县——
所以「区县为空」可以给「老版照片导入」当旁证)。
以**新坐标**调 `/api/regeo`(coord=gcj02,不再换算),只覆盖反查给出值的字段,反查失败不清空。
`tmpfog/addr_apply.py --apply` → **成功 39 / 失败 0**;复核 **区县异常 0 | 名称/类别变动 0 | 坐标偏移 0**
(回退清单 `tmpfog/addr-rollback.csv`)。佛山 23 行本来就对。`#367` 首次反查空返回,单独重试补上(开福区)。
**街道(thoroughfare)这轮没提交**——线上还是旧二进制会忽略该字段,等部署后再跑一轮。
#### 三、根治方案:places 加 4 列坐标来源留档(已实现,待部署)
列:`coord_source`(rond / photo / manual,空=历史行无留档)、`coord_sys`(wgs84 / gcj02)、
`src_lat`/`src_lon`(**换算前**的原始坐标)。三者齐全时 **显示坐标 == 按 coord_sys 换算 src_lat/src_lon**。
- `schema.sql`:4 列 + 存量回填(有 ZLOCATION 留档 → rond/wgs84/`ZRAWLATITUDE`)。**不拿启发式猜**——
猜错比留空更糟,那 45 行(生产 86 行)宁可先空着,标「未留档」。
本地实测回填 323 行,其中 18 行 src==显示坐标(正是「当初没偏移」的那批)。
- `ingest.go` 加 `backfillCoordSource`:导入完 rondbackup 后按同一判据补,新导入的包立刻有档。
- `handlers_photos.go`:写入 `photo` + 用户选的坐标系 + 换算前坐标(换算前先存 `srcLat/srcLon`)。
- `handlers_edit.go`:新增记 `manual`/`gcj02`;`editPlaceUpdate` 拆出 `updatePlaceRow`,规则:
**表单没提交的列一个字不动**(批量工具只改坐标时不能把留档/街道抹掉);**坐标动过才更新留档**(否则改个名字就冲掉原始坐标);
走换算时原始坐标记换算前那份 + `coord_sys` 记来源坐标系;选「未记录」整组清空。
- `edit.html`:列表加来源标记(`rond 导入 · WGS-84` / `未留档`)+ 来源筛选下拉(带各来源计数);
行内新增「按坐标刷新地址」按钮、`coord_source`/`coord_sys` 下拉、街道输入框;换算下拉联动修正留档
(`coord_sys` 设成来源系、`src_lat/src_lon` 填换算前的值),隐藏字段用全精度带出(原先 `%.6f` 会截断留档)。
- `place_coord_test.go`(新增,6 条断言):导入自动补档、只改名称不动留档与街道、换算带原始坐标且 `显示==gcj(src)`、
选「未记录」清空、后台新增 manual/gcj02、照片导入 photo/(wgs84|gcj02) + 原始坐标。
**实测**:`bin/rond.exe` 重编后本地起站(伪造会话 uid=2)→ 列表标记/筛选正确(368 / rond 323 / 未留档 45 / photo 0);
用 `rond-web-verify` 的无头 Chrome 真点按钮:反查把空的区县填成「金城江区」、街道保留、提示带地址与最近 POI(13m)、**0 JS 报错**;
换算下拉实测 `24.689928,108.042479`(WGS)→ `24.687112,108.046675`(527m 往东南),
`src_lat/src_lon` 变成换算前的值、`coord_sys=wgs84`,切回「保持原样」原值(含全精度)完整还原。
导出侧回归:`TestMovementCRUDExportE2E` PASS;纯净往返 `verify_rondbackup.py` **总差异 3**
(全是既有的 `ZLOCATION.ZLASTARRIVALDATE_` 站点重算,与改动前逐行相同)。
**部署**:`bash scripts/build-linux.sh` → `dist/rond-linux-amd64` 已生成,替换后 `systemctl restart rond-with-you`
(启动自动跑增量迁移,幂等)。留档列不参与导出,rond 侧列一字不变。
**遗留**:① 街道需部署后再跑一轮(目标值在 `tmpfog/addr-plan.csv`);② 86 个历史行的留档仍为空
(审计结论已能逐行定性,等站长要就按结论回填);③ `#324–343、#384、#385` 缺区县(新增地点页建的正确点);
④ 导出 `ZISPHOTOIMPORT` 恒 0(有了 `coord_source` 后很容易补,属导出侧改动,单独一轮)。
产物:`tmpfog/report-coordsource.html`(本轮报告)、`tmpfog/addr-plan.csv`、`tmpfog/addr-rollback.csv`、
`tmpfog/regeo-plan.csv`(13 个名称点的新旧反查对照)、`tmpfog/regeo_refresh.py` / `addr_plan.py` / `addr_apply.py` / `addr_verify.py` / `name_check.py`。
### 部署后复测:发现并修掉「导出合成行」这个真 bug(v7 之后)
站长指令:**「继续未完成的任务,并检查最近的修改是否引入了什么逻辑错误或者bug」**。
#### 一、真 bug:62 个照片导入的点被误标成 `rond 导入 · WGS-84`
**根因(两条,都在我上一轮自己写的代码里)**:
1. **判据错**:回填写的是「有 ZLOCATION 留档 = rond 导入」。但生产这份数据集是**用站点自己
导出的包回导**出来的,而 `export.go:247` 会给站点新建的地点**合成** ZLOCATION 行
(`ZRAWLATITUDE = GCJ02ToWGS84(导出那一刻的显示坐标)`)。于是 324–385 这些照片/手工点
全都"有留档",被整批当成 rond。386–409 是那次导出之后才建的,没有留档行,所以是空的。
2. **列错位**:`src_lon` 取的是 `ZLONGITUDE`(显示经度),应为 `ZRAWLONGITUDE`(原始经度)。
纬度取对了、经度复制粘贴错。本地实测 323 行里只有 18 行碰巧对。
**判据(唯一可靠的那条)**:合成行的 `ZRAW` 是我们自己写进去的,所以**位级**满足
`ZRAWLATITUDE == GCJ02ToWGS84(该留档行自己的 ZLATITUDE)`;真机行的 ZRAW 是独立采样值,
从不满足(基线 321 行**零命中**,Go 实测)。**必须用留档行自己的 ZLAT 比,不能用 `places.lat`**
——344–383 的显示坐标在导出之后被改过,拿当前值比会漏掉这 40 行。
**修法**:`internal/ingest/coord.go`(新)写 `BackfillCoordSource`:
- 合成行 → 有 `coord_source='rond'` 且 `src_lat==ZRAW` 的**撤回**(清成 NULL,宁可留空不猜)
- 非合成行 → 空的补 rond/wgs84 + src=ZRAW;src_lon 是显示经度的**顺手改回原始经度**
- **境外点跳过**:境外不做偏移,`GCJ02ToWGS84` 是恒等,合成行与真机行长得一模一样、判不出来
启动时 `BackfillAllCoordSource` 跑一遍(`main.go`),schema.sql 里那段 SQL 回填删掉(写不出 geo 判据)。
#### 二、生产数据订正(`tmpfog/repair_coord_source.py`,86/86 成功)
| 组 | 行数 | 目标留档 | 依据 |
|---|---|---|---|
| 344–383、387–409 | 63 | `photo` / `wgs84` / src=换算前的 EXIF | `tmpfog/fix-list-final.csv` |
| 324–343、384、385、386 | 23 | `manual` / `gcj02` / src=显示坐标 | `tmpfog/keep-list-final.csv` |
顺带补完上一轮遗留:**街道**(`addr-plan.csv` 的「新街道」;84/86 有值)+ **324–343、384、385
的区县**(反查补齐,86 行零缺失)。写库前先验不变式「显示坐标 == 按 coord_sys 换算 src」,
86 行全过才提交。复核:321 rond / 63 照片导入 / 23 后台新增 / **0 未留档**。
回退清单 `tmpfog/repair-rollback.csv`(注意:留档那几列回退等于退回已知有 bug 的状态)。
#### 三、导出侧补 `ZISPHOTOIMPORT`(`export.go`)
rond 靠这一位分「照片导入 / 手工新增」,站点没这个字段,但 `coord_source='photo'` 说得清楚。
做法:`queryPlaces` 多取 `coord_source`,ZLOCATION 写完统一 `UPDATE ZLOCATION SET
ZISPHOTOIMPORT=1 WHERE Z_PK IN (...)`(分块 500)——新建行与「走整行还原」的行都覆盖得到,
而真机行(`coord_source='rond'`)不碰。列不存在时跳过(`loc.typeOf`)。
#### 四、顺手发现并修掉的两个 bug
1. **「按坐标刷新地址」按钮从来不填街道**(`edit.html`):接口给的是 `street`/`township`,
JS 却读 `it['thoroughfare']` → 永远 undefined。改成 `it.street || it.township`。
无头 Chrome 实测:修前街道保持空,修后填出「西环路」,0 JS 报错。
2. **行内保存会把坐标截到 6 位小数**(`handlers_edit.go`):表单经纬度框是 `%.6f`,真机基线
的点带长小数。实测只改名称也把 `26.46800422211455` 写成 `26.468004`。照 `keepStoredTime`
的同一套办法加 `keepStoredCoord`:落在同一个 6 位小数上就沿用库里那份。实测修后不再截断。
#### 五、还没动、留给站长拍板的
`adminPhotosImport` / `editPlaceCreate` / 天气新增 都用 `SELECT MAX(src_pk)+1`,没走
`nextSrcPK`(那个还会看 `coredata_meta` 与 `entity_raw` 的最大编号),也没调 `clearStaleRaw`。
`entity_raw` 里**确实存在 skipped 行**(本地 ZVISIT#191),意味着某个编号可能没有对应实体行却
一直占着号;删掉最大号的地点后再新建就会复用,导出时被当旧行。位移/交通方式两条路径是走对了的,
地点与天气没走。当前生产没踩到(places max 409 > entity_raw max 385,且删地点有 pruneEntityRaw 兜底)。
产物:`internal/ingest/coord.go`、`tmpfog/repair_coord_source.py`、`tmpfog/repair-regeo.csv`、
`tmpfog/repair-rollback.csv`、`dist/rond-linux-amd64`。新增测试:
`TestCoordSourceSkipsExportSynthetic`、`TestExportPhotoImportFlag`、`TestInlineSaveKeepsFullPrecisionCoord`。
纯净往返差异仍是 **3**(全是既有的 `ZLASTARRIVALDATE_`)。
### 站长部署后的复核 + 编号分配收口
站长指令:**「我已经在正式环境部署了最新的源码编译版本,你再检查下你在本地测试的结果是否正确。
另外你发现的地点与天气没走 nextSrcPK 问题,一并收口,做完再次完整测试。」**
#### 一、生产复核(`tmpfog/verify_deploy.py`,全绿)
| 检查项 | 结果 |
|---|---|
| 来源标记分布 | 321 rond / 63 照片导入 / 23 后台新增 / **0 未留档** |
| 基础行留档自愈(321 行) | `src_lat`、`src_lon` 与导出包 `ZRAWLAT/ZRAWLON` **全精度逐行相等,0 不符** |
| 导出侧 `ZISPHOTOIMPORT` | 照片行全 = 1、手工行全 = 0 |
| 不变式「显示 == 按 coord_sys 换算 src」 | 407 行 0 不符 |
| 新增行地址完整度 | 区县 0 缺失;街道缺 367、386(高德未返回,可接受) |
⇒ **启动自愈真的跑了、跑对了**:`src_lon` 那个「取成显示经度」的笔误在生产上被自动改回原始经度,
且 86 行手工订正的留档一个没被动。本地测试结论在真实环境成立。
#### 二、编号分配收口(本轮新改动)
原来只有位移/交通方式走 `nextSrcPK`,其余全是 `SELECT MAX(src_pk)+1`。**统一改成
`nextSrcPK` + `clearStaleRaw`**,新增四个编号空间:
```go
placeSeq = srcPKSeq{"places", "src_pk", "Location", "ZLOCATION"}
visitSeq = srcPKSeq{"visits", "src_pk", "Visit", "ZVISIT"}
activitySeq = srcPKSeq{"activities", "src_pk", "Activity", "ZACTIVITY"}
weatherSeq = srcPKSeq{"weather", "src_pk", "HourlyWeather", "ZHOURLYWEATHER"}
```
改动点:`adminPhotosImport`(原来在循环外算一次、失败还 `nextPlaceSrc--`,现在每行在事务里
按三处上限取号)、`editPlaceCreate`、`editWeatherCreate`(原来是裸 `s.db` 插入,顺手包进事务)、
`insertVisit`(新增到访 / 照片导入共用)、活动内联新建。全项目已无 `COALESCE(MAX(src_pk),0)+1`。
新增 `internal/web/seq_test.go` 的 `TestNewRowNumberingSkipsArchivedPKs`:造一条
「只有留档、没有实体行」的 ZLOCATION(`skipped=true`,**raw 里必须带 `Z_PK`**,判据就是靠它算上限),
再走手工新增与照片导入两条路径,断言编号绕开它、且留档行本身不被误清。
**踩过的坑**:第一版测试 fixture 的 raw JSON 忘了写 `Z_PK`,`nextSrcPK` 读不到,
于是「绕开」断言失败 —— 反过来说明这条测试确实在盯 `entity_raw` 的最大号。
#### 三、复测
`go test ./internal/...` 全绿;`go vet` 干净;纯净往返 `verify_rondbackup.py` 总差异仍是 **3**。
产物 `dist/rond-linux-amd64`(19:29)与 `bin/rond.exe` 均已重编。本轮只改编号分配、不涉及数据订正,
部署替换二进制即可。
### 三个新需求:地图底图可选 / 文字折行 / 地点图片(前端转 WebP)
站长指令原文:**「一,地图放到最大时会显示很多信息显得杂乱,看不清自己的点,能否只显示街道或
有其他表现形式可选(可在后台选择);二,不少地方文字显示异常,一行只有一个字就截断到第二行;
三,加一个展示图片的功能,点开地点用按钮显示管理员维护的图片,图片可存本地或 S3(R2),
已有地点可编辑添加、新增地点可选择是否保留图片,要转 WebP 且**在前端完成转码**。
请逐项完成,每项做完测试是否引入新 bug 或与其他功能冲突。」**
#### 一、文字「一字一行」:表格被压扁(已修,全站回归 0 处)
**取证方式**:写了段 CDP 诊断脚本,用 `Range.getClientRects()` 数**真实行盒**
(按 top 以半字号为容差聚类,避免同一行里不同字体被算成多行),再算「每行平均字数」,
扫 10 个页面 × 6 个宽度(360/390/480/620/768/1024)。
**发现**:只有 `/places` 与 `/admin` 两处,都在 `table.tb` 上。根因是
`table.tb { width: 100% }` + CJK 允许逐字断行 ⇒ 窄屏时**列被榨到一两个字宽**而不是溢出
(`/places` 7 列在 390px 下每列约 68px,「一般公共场所」排成 **6 行、一行一个字**)。
**修法**:`.tw > table.tb { min-width: 640px }` —— 给表格一个内容下限,放不下交给 `.tw` 横滚。
修后 60 个组合全部 `[]`(无挤压)。
#### 二、地图底图可选「纯路网」(已做,后台可切 + 访客自选优先)
**先确认了「杂乱」来自底图自带的 POI/路名标注**(地图上我们自己的点只有 hover 气泡,没有常驻标注)。
`style=8`(当前「街道」)与 `style=7` 对比实测:同一块的**近黑像素占比**(≈文字量)
长沙 1.46% vs 0.21%、香港 1.27% vs 0.82% —— **style=7 是纯路网,几乎不标注**。
改动:`tiles.go` 加 `amap-roadnet`(style=7);后台「设置 → 地图底图」加「默认用哪张底图」
下拉(`setting.MapDefaultBase`,取值按当前实际图层校验,换过 map_provider 的旧值自动失效);
`/api/map-config` 下发 `default`;`rond-map.js` 的 `pickBase()` 三级回落
**访客在图上切过的那张(localStorage `rond.base`)→ 后台默认 → 第一张**,
并监听 `baselayerchange` 记住。无头 Chrome 实测:后台设 `amap-roadnet` 后地图真的铺 style=7;
同一会话里写 localStorage 为 amap-street/amap-roadnet,铺的分别是 style=8/style=7。
**踩过的坑**:`setting.Load()` 是**逐字段显式覆盖**的,新加的字段忘了写那一行 ⇒ 库里存了、
读出来是默认值。加设置字段时**必须同时改 Default()/Load()/settingsSubmit() 三处**。
#### 三、地点图片(已做,前端转 WebP + 本地/R2 双后端)
站长拍板:**照片导入时勾选保留** / **经本站后端中转** / **选择放后台、凭据放 app.ini**。
- `internal/media`(新):`Save/Delete/PublicURL`,后端 `local`(`data_dir/media/...`,
由 `GET /media/` 直出、内容寻址可强缓存)或 `s3`(**手写 SigV4**,PUT/DELETE,路径式)。
零新依赖——go.mod 只有 pgx 与 sqlite,为这个引一个 SDK 不划算。
- `place_images` 表:`storage` + `object_key` 分别记住每张图**实际**在哪,
换存储后端后老图仍取得到。key = `<dataset>/<src_pk>/<sha256[:20]>.webp`(内容寻址、天然去重)。
- `POST /admin/place/images`(multipart)、`POST /admin/place/images/delete`、`GET /media/`。
- 前端 `static/js/place-image.js`:canvas 转 WebP(长边 2000px、q0.82),
**`blob.type !== 'image/webp'` 必须显式判错**——不支持编码的浏览器会静默给回 PNG。
- 地点页:访客一个「图片 N」按钮 → 全屏画廊(深底、Esc/点空白关闭、锁背景滚动);
站长多一块管理面板(缩略图墙 + 多选上传 + 单张删除)。
- 照片导入页:新增「存图」列(默认不勾),提交时**拦下 submit → 逐张转码 → 塞进隐藏的
`<input type=file>`(DataTransfer)→ 再 `form.submit()` 放行**;表单改 `multipart/form-data`。
**两个真 bug(都是测试逼出来的)**:
1. **`r.ParseForm()` 会毒化后续的 multipart 读取**。前端用 `FormData` 发(multipart),
`ParseForm` 只解析 urlencoded,却会把 `r.Form` 先填成「不含 multipart 字段」的那一份;
`FormValue` 只在 `r.Form == nil` 时才去解析 ⇒ 之后再怎么读都是空。图片删除按钮点了没反应
就是这个。**规则:收 multipart 的 handler 不要先 ParseForm**,直接 `ParseMultipartForm`
(注意 urlencoded 调用方会收到 `ErrNotMultipart`,要 `errors.Is` 放过,否则测试与脚本全挂)。
2. **把照片导入表单改成 multipart 后,原 `ParseForm` 直接让 `n` 读成 0**,整批导入静默失败
(报「没有可导入的照片」)。同一个根因,同一次修。
顺带加了防护:导入请求 `http.MaxBytesReader` 60MB(勾了存图才可能有几百 KB×N),
前端勾超过 60 张先 confirm 提示。
**验收**:`TestPlaceImageUploadDelete`(multipart 上传→落盘→`/media` 字节一致→非 WebP 被拒→
multipart 删除两处都清)、`TestPhotoImportWithImage`(勾选存图→地点与图都建好、宽高记对)、
`TestPlaceImageS3Backend`(httptest 假装 S3,断言路径式 URL + SigV4 Authorization + 公开域名取址 +
切 s3 后本地不再落盘)、`internal/media/media_test.go` 的
**`TestSignV4MatchesAWSExample`(AWS 官方向量,签名逐位相符)** + escape/canonicalURI/取址/路径穿越。
浏览器实测:CDP 造一张 PNG 塞进文件框,页面自己转成 WebP(落盘确为 `WEBP (800,600) 4954 bytes`)
并出现在缩略图墙;全站 8 个页面 JS 报错 0。
**测试间互相污染**:多个测试共用一个库,S3 那个测试把 `media_backend` 留着没还,
后面的本地上传就失败。加了 `setMediaBackend()` + `t.Cleanup` 还原本。
#### 复测
`go test ./internal/...` 全绿;`go vet` 干净;纯净往返总差异仍是 **3**。
产物 `dist/rond-linux-amd64`(22:58)。本轮**新增表 `place_images`**,启动迁移会自动建;
`conf/app.ini` 多了 6 个 `s3_*` 空键(不填 = 只能存本地)。