# 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` 变成 `"-<指纹>"`(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()` 只在**目标页已对访客开放**时返回 `/`,否则返回空串不跳。 收敛用 `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 = `//.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 → 逐张转码 → 塞进隐藏的 ``(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_*` 空键(不填 = 只能存本地)。