用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
49 KiB
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(jsonvisitor_home,缺键/非法值一律回落VisitorHomeMain="home"),VisitorHomePath()只在目标页已对访客开放时返回/<key>,否则返回空串不跳。 收敛用NormalizeVisitorHome()(表单被改坏时回落主页)。homehandler:在「没导入数据」分支之后,!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 个块并排序。
改动:
internal/db/db.go:MaxIdleConns提到与MaxOpenConns相同(8/8), 新增WarmUp(d, 6)在启动时并发预建连接(main.go 调用)。internal/web/parallel.go:runJobs并发执行页面里互不依赖的查询(上限 6), debug 下打印 >30ms 的慢查询(这条日志就是定位到建连问题的关键);statsPage 的 13 个查询改用它。internal/fog:Index预存areaM2(写入索引时顺手算),新增Index.AreaKm2();maskedStats/Index.MaskedStats直接在索引上按遮罩包围盒算(不再复制整张块表), 删掉旧的MaskedCells与AreaKm2Excluding。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 逐列实测,脚本跑完即删):
ZMOVEMENT11 列,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):
- 编辑页新增「行程记录」卡片:搜索(地点/方式/日期/#编号)+ 分页 + 行内改(起止到访、 时间区间、方式)+ 删除;「新增行程」表单重做(时间留空自动按两端到访推断、方式可内联新建)。
- 落库补齐派生字段:
from_place_id/to_place_id(区域黑名单按两端 places 判定,留空会绕过过滤)、distance_km、duration_min,与导入路径同源。 - 编号复用防护
nextSrcPK(取库内/coredata_meta/留档三者上限 +1)。 - 导出侧:
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.ZUSERADDED85 个新建到访全部为 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
根因(两条,都在我上一轮自己写的代码里):
- 判据错:回填写的是「有 ZLOCATION 留档 = rond 导入」。但生产这份数据集是用站点自己
导出的包回导出来的,而
export.go:247会给站点新建的地点合成 ZLOCATION 行 (ZRAWLATITUDE = GCJ02ToWGS84(导出那一刻的显示坐标))。于是 324–385 这些照片/手工点 全都"有留档",被整批当成 rond。386–409 是那次导出之后才建的,没有留档行,所以是空的。 - 列错位:
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
- 「按坐标刷新地址」按钮从来不填街道(
edit.html):接口给的是street/township, JS 却读it['thoroughfare']→ 永远 undefined。改成it.street || it.township。 无头 Chrome 实测:修前街道保持空,修后填出「西环路」,0 JS 报错。 - 行内保存会把坐标截到 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,新增四个编号空间:
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(都是测试逼出来的):
r.ParseForm()会毒化后续的 multipart 读取。前端用FormData发(multipart),ParseForm只解析 urlencoded,却会把r.Form先填成「不含 multipart 字段」的那一份;FormValue只在r.Form == nil时才去解析 ⇒ 之后再怎么读都是空。图片删除按钮点了没反应 就是这个。规则:收 multipart 的 handler 不要先 ParseForm,直接ParseMultipartForm(注意 urlencoded 调用方会收到ErrNotMultipart,要errors.Is放过,否则测试与脚本全挂)。- 把照片导入表单改成 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_* 空键(不填 = 只能存本地)。