134 KiB
rond-with-you 项目长期记忆
只留「改代码前必须知道的结论」,一句话一条。细节别在这儿找:rond 包/迷雾/导出验收见技能
rond-backup-parse,量法与探针见技能rond-web-verify,推导过程见.workbuddy/memory/YYYY-MM-DD.md。
一、站点侧落库规矩
- 「建地点 + 建到访」必须同一事务(
Server.withTx+execer):两步写留下孤儿地点,导进 rond 永远「无数据」。到访时段选填,但校验必须先于落库。 - 行程派生字段必须与导入路径一致(
validateMovementForm):from_place_id/to_place_id(轨迹靠它画线,区域黑名单也按两端 places 判定,留空 = 绕过过滤直接露给访客)、distance_km、duration_min。 - 新行编号一律走
nextSrcPK+clearStaleRaw:取三处最大 +1 —— 库内该列、coredata_meta.entities.<实体>.max、entity_raw.raw->>'Z_PK'(留档存着 skipped 行,占号但无实体行)。只看库内MAX会复用 → 新行被当旧行、继承对方 rond 写的列。绝不再写SELECT MAX(src_pk)+1;pk 分配在事务内。回归internal/web/seq_test.go。
二、rond 备份包导出(细节见技能 rond-backup-parse)
真机包 data/20260914_151649.rondbackup 是唯一标准答案。
- 两条铁律:① PG 是真值来源、
entity_raw是原值来源——管理列由 PG 覆盖,其余列只能从导入时留的原始整行还原;② 区分基线已有行与站点新建行(keepNilCols+known[pk]),前者永远保持真机原值。 - 只给新行补的列:
ZLOCATION.ZRAWLATITUDE/LONGITUDE= 原始 WGS-84(ZLATITUDE/ZLONGITUDE是 GCJ-02),用geo.GCJ02ToWGS84反算;ZVISIT.ZIDENTIFIER_/ZACTIVITY.ZUID_必须确定性(表名 + src_pk),随机则每次导出都变、rond 当新记录;ZACTIVITY.ZICON_缺省📍。 - 派生缓存列
Z*_.ZLASTARRIVALDATE_= 该对象最晚一次到访到达时间;没用过写哨兵-63114076800或 NULL(判空不能只看IS NULL),别留空否则 rond 显示「无数据」。 - ZMOVEMENT:
ZTYPE_791/791 非空、不能写 NULL(2 = 无交通方式、5 = 有);进keepNilCols且只给新建行补,别做成管理列(否则 23 条type=4被洗掉)。硬不变量ZSTART_ >= 起点离开、ZEND_ <= 终点到达(0 违例)。ZTRANSPORT聚合(queryTransports)必须GROUP BY transport_src(DISTINCT会给重复 Z_PK → 撞主键、整次导出失败)。 - 不是缺口:
ZVISIT.ZRAW(采样点数量)、ZMERGEDTO/ZPARENT、ZLOCATION.ZNOTE_;place_notes仅站长可见,单向从ZVISIT.ZREMARK_汇总、不回写 rond。
三、迷雾(Fog of World).fwss
zip 包,条目内容本身是 zlib 流;Model/*/ = 头 32768B + 每块 515B,已探索层由 fog_blocks 重建;Model/~/、Model/#/ 不解析只原样写回(少了快照残缺约 55%)。块尾 3 字节 = 大端 24 位数 = 置位数×2+1(真机 8701/8701 精确满足)、上限 4096 ⇒ 最高字节恒 0。
Decode→Encode除压缩级别与块体槽位顺序外完全一致;回归internal/fog/fog_test.go(需FOG_SRC)。清空迷雾与**整体替换(cover)**都要连fog_raw_files一起处理,否则残留「无对应Model/*瓦片」的图层条目。
四、精度陷阱:时间与坐标都不能「原样回贴」
表单精度低于库里真值,只改备注也会悄悄改掉时间和坐标:时间 datetime-local 只到分钟、真机带小数秒(20:03:21.323088,截断后还会早于起点离开时间被行程窗口拒掉——线上「改方式提示开始时间不能早于离开时间」就是这个);坐标控件 %.6f、真机长小数(26.46800422211455),只改名称就把点挪走。规矩:与库里已有值落在同一精度上就沿用库里那份(keepStoredTime/resolveMovementEnd/keepStoredCoord),行程、到访、地点三条编辑路径都要用。
五、坐标来源留档(places 四列)
coord_source(rond/photo/manual,空 = 历史行)、coord_sys(wgs84/gcj02)、src_lat/src_lon(换算前)。不变式:显示坐标 == 按 coord_sys 换算 src_lat/src_lon。
- 写入点:
ingest/coord.go的BackfillCoordSource(启动全量跑;判据要 geo 往返换算,不写进 schema.sql)、adminPhotosImport、editPlaceCreate;editPlaceUpdate走updatePlaceRow——表单没提交的列不动、坐标动过才更新留档、走换算时记换算前那份。 - 「有 ZLOCATION 留档」≠「rond 导入」:
export.go给站点新建点合成 ZLOCATION 行(ZRAWLATITUDE = GCJ02ToWGS84(导出那一刻的显示坐标)),与真机行无异。唯一判据:ZRAWLATITUDE == GCJ02ToWGS84(该行自己的 ZLATITUDE)位级相等 ⇒ 合成行(基线 321 行零命中);境外点跳过(换算是恒等)。 ZISPHOTOIMPORT由coord_source推;换算统一走GET /api/coords?mode=wgs2gcj|gcj2wgs,geo 包是全站唯一实现,前端别另抄。高德 POI 就是 GCJ-02;/api/regeo返回street/township(表单里叫thoroughfare,edit.html曾读错键导致街道填不进去)。- 存量坐标裁决:同名 POI 对称距离、迷雾栅格两条判据已否证别再试;用批次同一定律(一批 = 一坐标系 + 一台手机、批内同向,凡几个旧坐标距同名 POI >200m 的点换算后落回地物几十米内即成立;名称无证据力)。修库
tools/photo_coord_audit.py --apply,修完拉/api/points复核;/api/points带sp(=src_pk),基线 sp ≤ 323 用长小数、照片导入 > 323 是 6 位小数。
六、前端 / 模板 / CSS 坑
- 改 Go 必须重启(旧进程占 8080 时新进程报
bind: Only one usage,看着像构建失败);模板走//go:embed,正式态改.html要重编二进制,但debug=true时每次从磁盘重解析、刷新即生效;CSS/JS 只在debug=true直读磁盘。带地图的页面必须p.HasMap = true,否则 Leaflet 不引入 →「地图容器在、整块不出现」。 - 模板拼查询串 href 必须走返回
template.URL的方法(Page.PlaceLink/EditPlaceLink/MapLink):?{{.FilterQS}}被 URL 上下文转义(=→%3d)→ 筛选静默失效(翻页最易忽略),{{safe}}挡不住;返回 template.URL 时不要再手工做 HTML 属性转义(&会被再转成&amp;)。 - CSS 顺序陷阱:同特异性靠后的赢,而
.chip/.btn/.check input的基础规则在app.css中部 ⇒ 写在文件前半的@media (max-width:620px)覆盖是死代码(.check input{width:20px}一直没生效)。窄屏尺寸/内边距覆盖一律写进文件末尾那个块(已标注「必须放最后」);基础规则里没有的属性(min-height/display/--field-fs)写前面没事。 - 控件字号:
input:not([type=file]):not([type=color])…,select,textarea{font-size:var(--field-fs,inherit)},窄屏--field-fs:16px、表格里 14px。被:not()排除的类型(file/color/range/checkbox)吃不到,要单独给字号——iOS 聚焦 <16px 控件会自放大整页(file/color 已提 16px;range 是审计误报,滑块不弹键盘)。窄屏下限:触控 44px(.btn/.chip/.nav-links a+ 导航 logo.brand)、select{min-height:44px}(表格里除外)、checkbox/radio{20px}、.tag{12px}。 - 表格别让 CJK 把列榨干:
table.tb{width:100%}+ CJK 逐字可断 ⇒ 窄屏列压到 1~2 字宽;靠.tw > table.tb{min-width:…}+.tw横滚。展开行与主行等宽靠colSpan(主行 9 列 ⇒colSpan=9),浅底用var(--bg-2)别用--line-soft。 rond-map.js:full()首屏用points: []再靠setPoints填,按需构建的图层必须读st.points;preview()的「点击进全屏」要传opts.clickToFull(只有主页该有);同容器二次L.map()抛already initialized(被 catch = 假失败),_state[id]已有setPoints就复用实例;时间轴增量更新判据是对象引用、拖动传{keepView:true};markerCluster 子标记异步渲染,动画类要挂add。map.html筛选面板:三个坑(内边距须挂#filter-form而非面板、sticky 元素后面不能还挂内容、「查询/重置」按钮要width:100%)与量法见技能rond-web-verify的probe_map_panel.js;act/hw两个 hidden 必须留在details外面否则收起即丢筛选。
七、访客 vs 站长:双身份差异(新 bug 都容易出在这)
访客页与站长页的 DOM 不一样——只在访客视角复现、站长自己点一遍一切正常。遇到「某功能只有站长有」先查这类。
- 定性手法:用不带 cookie 拉原始 HTML(urllib)与 CDP 求值后的 DOM 比,能区分「服务端没渲染」与「渲染了被 JS 删掉」,比看截图快。
- 已修(
96e7631):详情页画廊syncGallery()从#imgs-grid反推地址,而缩略图墙整块在{{if .IsAdmin}}里 ⇒ 访客页数到 0 张,把服务端渲染好的「图片 N」按钮remove掉。修法:PlaceData.ImageURLs随页面下发;cellURLs()判据改成**「缩略图墙在不在」而非「墙里有几张」**(站长删光图时墙还在,按张数回退会把删掉的图数回来)。回归TestVisitorPlacePageCarriesImageURLs。 - 同类扫描(条件块内声明的
id被 JSgetElementById读)目前只有 place.html 命中,其余是「本页元素被本页脚本读」的正常形态;改模板时别新增这种耦合。
八、地点图片与转码
- 对象名 =
<数据集>/<上传月份>/<地点号>-<内容哈希>.webp(按月分目录便于备份、带地点号便于认领、哈希天然去重);不要去地点号(删图按 key 精确删)。key 存 DB 行,改规则不影响旧图。排序ORDER BY position, id。 - 转码在浏览器 canvas 做(
place-image.js),服务端只校验文件头、原样写盘、不解码 ⇒ 任何平台都不需要 libwebp;canvas.toBlob在不支持 WebP 的浏览器会静默返回 PNG,必须判blob.type。 - 压缩参数(2026-09-18 重定,别退回旧值):长边 1440、质量阶梯
[0.72, 0.58]、命中 220KB 即停,走到底仍超标就整张缩到 1080 重编(≤3 次编码)。依据:压质量掉 19% 画面就糊,缩尺寸同 q 掉 55% ⇒ 超标该缩尺寸。透明 PNG 先铺白底。 - 存哪边由后台设置决定(local/s3),凭据只在
conf/app.ini的s3_*;internal/media手写 SigV4、零新依赖。s3_public_base必须带协议(裸域名 = 相对地址 = 图全变破图且不报错)。上传不能只放<input type=file>:用<label class="btn primary" for=...>包隐藏输入框,文案写明选中即上传。 - 照片导入页:地图卡在「2. 确认与修改」内
position:sticky,.stuck时收矮到 132px(移动 110)——收矮后必须invalidateSize(),否则瓦片不全。改坐标/时间要重算 KPI(updateKpis()从 DOM 现算,不能只挂在finishLookup)。
九、迷雾遮罩区 / 区域限制
fog_masks圆心存 GCJ-02 + 半径(米),判定前换算到网格的 WGS-84;网格横向随纬度乘 cos(φ) 收缩 ⇒「地上的圆」在网格里是椭圆。遮罩只对访客生效;被遮格子不画填充、也不当「未探索」邻居(否则边缘描出轮廓 = 暴露范围)。stats.Filter有两套 where:visitsWhere与movementsWhere(按两端 places 判定),漏一个就「到访数藏了、里程还留着」;internal/stats/stats_test.go对账。被区域规则挡的地点详情不直接 404,走notice.go的pausePage()。
十、底图、后端与性能
- 高德免密钥瓦片:
style=8街道(带 POI/路名)vsstyle=7纯路网(近黑像素 1.46% vs 0.21%),点位密到觉得乱就用 7。默认底图 = 后台MapDefaultBase;pickBase()三级回落:访客切过的(localStorage['rond.base'])→ 后台默认 → 第一张。 r.ParseForm()会毒化后续 multipart 读取:它把r.Form先填成「不含 multipart 字段」的那份,而FormValue只在r.Form == nil时解析 ⇒ 字段一律读成空(表现为「删图按钮点了没反应」)。收 multipart 的 handler 直接用ParseMultipartForm;urlencoded 调用方会收到ErrNotMultipart,用errors.Is放过。- 加
setting字段要同时改三处:Default()、Load()(逐字段显式覆盖,漏了 = 存进去读出来是默认值)、settingsSubmit();conf/app.example.ini与解析器同步有测试守着(键名要允许数字s3_*)。 - PG 建连接比查询贵两个数量级(~300ms vs ~2ms)→
SetMaxIdleConns必须等于SetMaxOpenConns(8/8),启动db.WarmUp(d, 6);页面里的独立查询用runJobs并发(上限 6)。fog.Index.All()会复制排序整张块表,统计别用(面积读ix.AreaKm2());保存或删除遮罩必须s.masks.invalidate()+s.fogTiles.clear()。
十一、导出包验收(改完代码要跑的那套)
分两段跑,别指错变量:ROND_CLEAN_OUT=… go test ./internal/web/ -run TestMovementCleanExport 产纯净包 → ROND_EXPORT=… go test ./internal/rond/ -run TestExportRoundtrip 比。指到真库的导出会大批报「导出多出到访」——不是 bug。干净往返应 visits/places/acts/moves/weather/rawvisits 全等、坐标偏差 0、时间偏差 0s。go test -count=1 ./... 要 0 skip 才算真跑过(缺 ROND_DSN/ROND_SRC/ROND_TEST_CONF/FOG_SRC/ROND_EXPORT 会静默跳过);e2e 会往真库导一份基线再清掉,跑前 pg_dump,跑后核 users like 'e2e-%'=0 且 is_active 没被改。三处「差异正常」见技能最后一节,别当 bug 修。
十二、404 / 中间页的分工(改路由前先看)
notice.go 的 pausePage 是站点样式的「停一下」页;三种话术别混:placeHiddenPage → 403「这个地点没有对访客开放」(区域隐藏,刻意告知,代价是能反推 ID 存在);notFoundPage → 404「不存在」(未对外展示的专题对访客走这套、刻意不区分);guardPage → 404「该页面未对访客开放」。fog.go/tiles.go/handlers_images.go 的 http.NotFound 故意保留纯文本(瓦片/图片是给浏览器爬虫取的)。
十三、指标口径与筛选计数(先对齐语义,再怀疑算法)
- 「累计停留」= 各次到访时长之和,不是「首到达 → 末离开」的跨度:
sum(LEAST(COALESCE(duration_min,0),10080))(首页/统计页/专题同一个口径)。全站没有跨度 KPI;夜间没记录到的到访、在途时间都不进「停留」。改口径前先确认站长要的是哪个。 - 筛选条上的类型计数必须是分面计数:
pageBase里用stats.DB.ActivityCounts(ctx, s.filterOf(p))覆盖Activities(ds.ID)那份全量数字,且必须把ActivityIDs清空(否则选中一个类型后其它类型全变 0)。Activities(ds.ID)只适合取「有哪些类型、名字/颜色/图标」。
十四、「新增 XX…」两种形态(别照抄)
- 自由文本型(
类别,存poi_category原串):放__custom__哨兵 + 文本框,不需要先建实体行;新值要addCategoryOption/promoteCategory挂到所有select[name^="category_"](含隐藏选项源、插在__custom__之前),否则同一批里第 2 张还得重打。触发点是 change(失焦)或回车。 - 实体型(
活动→activities):必须先POST /admin/edit/activity建行拿 id。该接口不去重,同名会真建两行 ⇒ 客户端按名字去重;新选项插在__new__哨兵之前;并加提交守卫(仍停在__new__就preventDefault),否则解析不出整数、静默当成「未指定」。 - 两者都靠「隐藏 select(
#activity-options/#tag-options)+makeSelect克隆」分发 ⇒ 往隐藏源里插,之后新解析出来的行也自动带上。活动的新增入口只在admin/edit.html与admin/photos.html;admin/edit.html:273(行内编辑某条到访)仍没有。 - 2026-09-18 晚补记:三处活动下拉现在共用同一套委派处理器(
select.activity-id-inline+ 同表单里的.visit-act-new/.visit-act-add/.visit-act-name,都在admin/edit.html):新建地点卡、到访行内编辑、地点行内编辑。别再给某个下拉单独挂change/click——会建出两条活动(顶部原来那对#activity-add/#new-activity-name已因此删掉)。地点行内编辑的「活动」= 批量改该地点名下全部到访(place/update的activity_id,默认空值「(不修改)」;与地点行同一事务提交;哨兵__new__/不存在的 id/别的数据集一律拒;is_home/is_work跟着活动走)。回归internal/web/place_activity_test.go(做了两次变异验证)。
十五、?back= 贯穿(筛选不丢)
Page.Back只放行站内路径(拒//host、非/开头、引号/尖括号/反斜杠——因为当template.URL用,放行就是开放重定向/XSS);Page.BackQS是当前地址编码后的参数值,要剔除 back 自己否则套娃。非 href 位置(表单 hidden)用BackRaw(string)。EditData.PageLink翻页要带 back;/admin/edit三个 GET 筛选表单各有 hidden back,三个「重置」走EditData.EditLink(clear…),别在模板里手拼。BackLabel取路径段必须先剥?/#。一次性 flash 不进 back(backQS要Del("ok")/Del("err"));别把用户输入直接内插进 href(/map?q={{.Place.Name}}是反例,已改走Page.MapLink)。回归internal/web/pagelink_test.go。
十六、改到访时间后必须夹行程(已实现)
真机硬不变量:位移不早于起点到访的离开、不晚于终点到访的到达。movementWindow 的 1 分钟容差(表单只到分钟)会让改到访时间这条路径漏出越界。现在 editVisitUpdate 调 clampVisitMovements(纯函数 clampMovementWindow 可单测,回归 clamp_movement_test.go):① 夹完必须 end > start,窗口压没了就 end = start + 1min(真机 791/791 条都有值,不能写 NULL);② execer 接口为此补了 QueryContext。残留:行程表单那条路的容差没动,手工把结束填到终点到达的下一分钟仍会产生 ≤60 秒越界。
十七、加表格列必看(会把别的列挤换行)
table.tb 是 width:100%、下限只有 .tw > table.tb{min-width:640px},CJK 逐字可断 ⇒ 加的列会把别人的空间吃掉(地点库加「图片」列后「河池市」折成两行)。实测:只有抬表格下限有效(min-width 700 → 城市回到一行),收窄新列自己的 padding 没用;给那张表单独一个类(.tw > table.tb.places-table{min-width:700px}),别动全局 640。PlaceCols/scanPlace 成对,且 PlaceCols 还被 geojson/gpx 导出、单地点查询、投票页用着 ⇒ 新列一律追加在最后,只需同步改 scanPlace。
十八、重启与操作纪律
- 重编重启:
go build -o rond.exe .(只构建到仓库根,bin/已废弃、2026-10-09 删除)→ 停掉占 8080 的进程 →./rond.exe -c conf/app.ini。taskkill //F的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID +Stop-Process。 - 生产
life.shiroko.one只走 IPv4(curl 必须-4);会话 cookierond_session=uid|token(HMAC);HTTP 303 不能跟随(Location 里带ok=/err=)。/tmpfog/不是运行必需,tmpfog/_ck.txt是死票(实打接口被 302)⇒ 不必轮换secret_key。 gofmt -l会报假警:仓库存 LF、core.autocrlf=true把工作区换成 CRLF(已加.gitattributes钉住);用 pythonopen(p,'w')回写 Go/CSS 会把行尾变 CRLF,要二进制模式读写。- 同一文件绝不并行改:多条 Edit 一起发给同一文件时,各自「读整份-替换-写整份」会互相覆盖——工具报 success 但磁盘上没落(实测一次发 3 条给
map.html,map-count那条丢了)。一个文件串行改,改完grep复核关键标记,再编译。 - CDP 探针里的
click()可能静默落空(落点在 HTML 解析中途,监听还没挂;errs也空):要先触发才成立的断言一律写成轮询。新写的回归测试要做变异验证:把修复处临时改坏跑一遍确认它会失败,再改回来。
十九、跨备份:src_pk 不可用,只能认 UUID(2026-09-21 并入)
- rond 每次导出/同步都会重排 Z_PK:实测 0914→0919 两份备份 822 条到访
ZVISIT.ZIDENTIFIER_全部相同、Z_PK 100% 不同。跨备份唯一稳定键 = 到访ZIDENTIFIER_、活动ZUID_;地点/标签/交通方式/位移/天气都没有 UUID,只能「名称+坐标」「名称」「起止到访 UUID+时间」兜底。 contentHash拿SrcPK|Arrival当指纹会被 PK 重排骗过 ⇒ 同一批数据重导被判「内容不同」→ 建新数据集 → 备注/投票按dataset_id隔离后全部失联。跨备份判重必须用ZIDENTIFIER_。- 差异预演(
internal/rond/diff.go):到访按VisitUUID、活动按ActivityUUID;库侧值在entity_raw.raw #>> '{ZIDENTIFIER_,$blob}',留档是 base64,必须uuidFromBase64转 hex 再比。地点只认坐标(PlaceCoordKey6 位小数),名称不参与(backfillAfterImport会反查改名);配不上再按名称兜底。真机约 2/824 条到访没有 UUID ⇒ 必须有「到达时间(到秒)+ 坐标」兜底配对,否则同一条既算「新增」又算「仅站内有」(timeKey到秒即可:苹果时间戳浮点秒 vs PG 微秒,逐位比永远配不上)。预演只读,backup_diff_test.go钉「渲染前后三张表行数不变」。
二十、附属数据主键 = 坐标键(不是 dataset_id+src_pk)(2026-09-21 并入)
place_notes/place_votes/place_images一律挂place_key(经纬度各 6 位小数,约 0.1m);dataset_id/src_pk只留作「最后写在哪儿」的排查线索。- Go 与 SQL 两套实现必须逐位一致:
rond.PlaceCoordKey(%.6f|%.6f)↔to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000'),同款表达式散在notes、votes、web/handlers_images.go(placeKeyExpr)、stats/placeCols四处,改一处搜全仓。实测 368/368 一致。 - 主键:
place_notes/place_votes=(user_id, place_key);place_visitor_votes=(place_key, voter_key);place_images用自己的 id +place_key索引。 - 访客票是站点自有数据(rondbackup 里没有),表里没有 dataset_id ⇒ 迁移时只能按活跃数据集认领编号;两边都有同编号时也只认活跃的;认不出留
orphan:<pk>,票不丢。 - 凡按这两张表统计的都要改成按坐标键 join
places(voteTotals、verdictPlaces、备注页排序/关联、stats的Verdict过滤)——不能按它们自己的 dataset_id 过滤,那会漏掉从别的数据集写进来的票。 - ⚠️ 迁移块必须收敛:改主键的两段(老块改回三列 / 新块改 place_key),老块先判「没有 place_key 列」,否则每次启动来回翻;新块判据用「主键列组合」而非「列在不在」,半迁移的库才自愈。
- ⚠️ 迁移块必须排在依赖表之后:坐标键迁移块一度插在
CREATE TABLE place_images之前,开发库无症(表早存在),新库直接起不来。只被TestOpenCreatesMissingDatabase抓到 —— 加迁移块后务必跑ROND_DSN=… go test ./internal/db/。
二十一、合并执行 internal/web/backup_merge.go(2026-09-21 并入)
- 顺序:地点 → 活动 → 标签 → 到访 → 位移/天气 → 重算聚合,全在一个
withTx,失败整体回滚。不做删除(删地点会级联删掉它的全部到访)。 - 只有库里那套编号有效:新行走
nextSrcPK发新号 ⇒movements.from/to_visit_src、weather.visit_src必须写库里的 src_pk。 - 位移/天气只随「这一轮新插入的到访」走(
addedVisit:至少一端是新行);少了判断会把库里已有位移整份复制(实测 791 → 1547)。 - 新行必须
writeRawRow写entity_raw:下次比对靠那里的 UUID 认人,漏了幂等性立刻破。 - 插天气前先认领「同一时刻、
visit_src IS NULL」的记录(站内删到访就是这么置空的),否则多一份重复天气。 - 冲突逐条选;页面用
form=属性把散落各表的 radio/checkbox 关联到同一个(空)<form id="merge-form">。 - 删除勾了才做,默认一个不删(键是
src_pk)。顺序:到访 → 活动 → 地点;删到访要连movements、entity_raw一起清、并把weather.visit_src置空。名下有到访的地点直接拒绝(外键ON DELETE CASCADE,真删会带走到访)。
二十二、迷雾导入三段式 + 差异地图(2026-09-21 并入)
- 别退回一步直写:
POST /admin/fog/preview(暂存<data_dir>/fog-staging/,令牌 32 位 hex、TTL 2h)→GET /admin/fog/preview/{token}(fog.DiffBlocks现算、不落库)→POST /admin/fog?stash=…。带 stash 的是 urlencoded(PostFormValue可读 mode),无 stash 是 multipart(readFogSnapshot);两条路都得留。 - 差异页必须摆出:整体替换「会丢多少块/格/面积」+ 快照缺
Model/~、Model/#层 —— 实测替换静默丢过 1841 块 / 14%。 - 差异瓦片
GET /admin/fog/preview/{token}/{z}/{x}/{y}?crs=…→fog.RenderDiffTile(绿=新增 / 灰=两边都有 / 红=会丢)。两条硬前提:① 瓦片必须与底图同crs(服务端逐块 WGS→GCJ 纠偏);② 页面必须p.HasMap=true,否则 layout 不引 Leaflet,脚本if(!window.L) return静默退出、控制台无报错。精度两级:z≥14 逐格精确;更粗按块内多数类涂色(dominantKind,绘制序 both→gain→lost)。两边块集按 token 单槽缓存 10 分钟(fogDiffSets)。 - 迷雾测试用
conf/app-test.ini(库rond_fog_test):fog_blocks是站点级、LoadAll不分用户,跑开发库会污染真实迷雾。fogIndex()只在sync.Once里跑一次 ⇒ 测试里清库/写库后必须手动LoadAll(fog_preview_test.go的reloadIndex)。
二十三、宽表窄屏「字段+值」堆叠(2026-09-21 并入)
- 表加
class="tb tb-stack"(类名成对,缺tb则 CSS 全不命中)、非首格加data-label(首格=主角不带;表头为空的列不用,如操作格)。 - CSS 写在
app.css末尾,判据@media (max-width:800px)(卡片内容宽≈视口−93、最宽表 min-width 700 ⇒ 793 才放得下;内边距变了要重算)。别用@container(iOS<16/Chrome<105 整块失效)也别退回 620。 - 堆叠态放开桌面 nowrap/宽度下限:行内表单
min-width:var(--efw,280px)(模板 inline--efw)、操作格td.ops{white-space:nowrap}(white-space会继承进表单顶破卡片)。宽度别用行内style="min-width"写死。 - 宽表卡片不进 g2(半宽时 1440 也横滑)。回归
tb_stack_test.go+wide_table_layout_test.go(通用判据:凡被.tw包住的表必须带 tb-stack;每条 tb-stack 规则都在max-width≥792的 @media 里;有 tb-stack 必须有 tb)。 - 横滑是
.tw内部滚的、页面本身不溢出,肉眼极难发现(2026-09-20 漏过一次)。
二十四、活动 / 类别(分类字典)(2026-09-21 并入)
- 活动是表(
activities),类别不是——类别只是places.poi_category字符串列 ⇒「改/删类别」只能是按值批量 UPDATE 地点(poi_category=$to/=NULL)。 - 类别候选来源:数据编辑页/照片页 =
SELECT poi_category, count(*) FROM places … GROUP BY 1(只有真在用的才出现);补全页 = 在用(标「(在用 N)」,按地点数降序)∪ 静态表(fillupCategories(inUse):真机用过的 12 种标「真机用过」+ 其余 18 种)。加新页面/改候选照这个来。 - 「改名」和「合并」是同一条 UPDATE;类别不预置空字典项,「新增类别」= 地点行内编辑选「自定义…」。
- 活动改名/删除/新增/合并:
/admin/edit/activity{,/update,/delete,/merge};类别:/admin/edit/category/rename、/admin/edit/category/clear。都返回 JSON。合并要把源的到访转过去并把is_home/is_work抄成目标的,删完pruneEntityRaw。测试taxonomy_test.go;页面卡片edit.html的data-card="taxonomy"(唯一默认展开)。 - ⚠️
ZCATEGORY_真机只有MKPOICategoryXxx枚举串(实测 122 条全枚举、0 条非枚举)。别说「真机也写中文」(2026-09-19 误判、09-20 证伪)。库里中文类别(一般道路/彩票店/租车点/美食街)全是站内写的。这列导出原样写回(managedCols)⇒ 改任意串不坏数据,但 app 里可能显示不出类别(未实测)。想稳用MKPOICategoryXxx。判「这行谁写的」:站内新建行entity_raw.raw IS NULL。 - 类别中文名 = 内置
poiCategoryText(42 条)+ 站点覆盖category_labels(value PK, 站点级)。取用一律categoryLabel(v, custom):自定义 > 内置 >TrimPrefix(v,"MKPOICategory");categoryLabelMissing只对「是 MKPOICategory* 但内置表没有」为真。⚠️ 显示名绝不写进places.poi_category(TestCategoryLabelNeverTouchesValue)。历史两张中文名表已合并、poiName与模板函数catName/poiname已删——模板里算不出显示名,一律 Go 侧算好塞数据。 Missing(类别缺名)必须同时看覆盖:categoryLabelMissing(v) && custom[v]==""。- 写
poi_category的两个入口都要过okEnum()提醒(类别台账「改名」+ 地点行内编辑「自定义类别」):不在内置枚举表就 confirm 并猜「你是不是想写 X」。 - ⚠️ 改模板 JS 别用「shell heredoc + python」写
\n:多转义一层 → 落到文件成真换行 → 单引号字符串含裸换行 → 整块<script>语法错误、交互全哑,而模板解析测试照样通过。一律用 Edit 工具或chr(92)+'n'。守卫inline_script_test.go。改完模板仍要用 CDP 确认「JS 报错」为空。
二十五、补全页交互(2026-09-21 并入)
- 活动和类别同款:
select.fill-sel+ 末尾「其它(自己写)…」,选中后露出同名__custom文本框(后端act_/cat_<id>==__custom__时读<key>__custom)。共用一套 CSS 类与脚本(select.fill-sel一次遍历、input[name$="__custom"]自动配对)——加新字段照这个模板抄。 - 下拉必须留「其它(自己写)」出口:纯 select 会把「列表里没有的新名字」堵死。默认一律「不动」,绝不预选建议(预选后点「用高德分析」整页重载,像被锁死);批量采纳走「采纳本页建议」按钮。
- 点「用高德分析」前把本页填的存 sessionStorage,回来恢复;提交时清掉。草稿恢复时对下拉派发
change,让「其它」文本框跟着露出。 - 手工填的值补进同类下拉(标「新填:X」、带
data-local、插在建议之后真候选之前),存sessionStorage['rond-fillup-newopts']。重画候选前先留住本格已选中的新填值。文本框监听直接挂(input不冒泡)。
二十六、导出包导不回真机时的排除顺序(2026-09-21 并入)
① tools/verify_export_structure.py rondbackup <原版> <导出>(结构/不变量);② tools/verify_rondbackup.py(逐字段);③ 工具没覆盖、必须自己补三项:NOT NULL 违例(integrity_check/foreign_key_check 都不查,真出就是 Core Data 打不开)、Z_METADATA.Z_PLIST/Z_UUID/Z_MODELCACHE.Z_CONTENT 的字节哈希、所有列逐值比对;④ 时间戳末位差 ~0.1µs 是 PG timestamptz 只有微秒精度,属 ≤1ms 容差,别当 bug 修。
- ⚠️ 新建行的「真机从不留空的列」不能写 NULL:判据 =「基线里 100% 有值的列,新行也不许为空」。已知
ZTRANSPORT.ZCOLOR_/ZICON_(站点不维护方式表、从movements聚合 ⇒ 用户没选就空)。修的时候只补新建行(!known[tbl][src_pk])。守卫export_placeholder_test.go。 - ⚠️
PRAGMA foreign_key_check在 rond 库里毫无意义(SQLite 表没有外键约束),关系完整性必须自己按「关系列 → 目标表 Z_PK」扫悬空。已知 8 组:ZRAWVISIT.{ZVISIT,ZLOCATION,ZACTIVITY}、ZSTAYSEGMENT.ZLOCATION、ZUSERPHOTO.ZVISIT_、ZVISITPHOTOFAVORITE.ZVISIT_、ZKEYWORD.ZACTIVITY_、ZPOICATEGORY.ZACTIVITY_。典型场景:站点删掉到访/地点/活动后,不托管的子表留悬空引用 ⇒ 真机拒收(「备份恢复失败,你的当前数据未被更改」=先校验、整体回滚)。ZRAWVISIT失去到访时删掉该行。已进tools/verify_export_structure.py1.45 节 +TestExportDropsOrphanedRawVisits。
二十七、其余散条(2026-09-21 并入)
- 补全(
internal/web/fillup.go):补visits.activity_id(活动挂在到访上)+places.poi_category;按地点批量填该地点名下所有activity_id IS NULL的到访,否则同一地点在统计里分裂成两种活动。只填空(两条 UPDATE 各带AND … IS NULL/AND poi_category=)。来源三层:本地词表(fillup_rules.go)→ 高德周边搜索(按坐标place/around)→ 人工。规则「词长优先 + 先掐掉括号里的地址」;单字关键词一律不要(「山」「站」)。高德结果缓存fillupCache,查过不再重试(含没查着的),批次跑几轮自然收敛。 coords留档与判据见第五节;/api/regeo返回street/township。- 口径:「累计停留」= Σ 各次到访时长(
LEAST(COALESCE(duration_min,0),10080)),不是首到末跨;筛选条类型计数必须用分面ActivityCounts且清空ActivityIDs。 - 404 三种别混:
placeHiddenPage=403、notFoundPage=404、guardPage=404;瓦片/图片故意纯文本 404。 - 导航横向溢出:
.nav-links可滑,脚本按scrollWidth-clientWidth-scrollLeft>2加/去.overflow-right,CSS 用mask-image渐隐。 - 窄屏折叠筛选卡(
layout.html的filters片段):挂载点form.card.filters-card+#filters-fold;CSS 只在末尾窄屏块;脚本必须紧跟表单(放末尾会闪一帧);状态键rond-filters-fold;收起只是 CSS 隐藏,控件照旧提交。回归filters_fold_test.go。 - 验收两段:
ROND_CLEAN_OUT=… go test ./internal/web/ -run TestMovementCleanExport→ROND_EXPORT=… go test ./internal/rond/ -run TestExportRoundtrip;go test -count=1 ./...要 0 skip。⚠️ 跑-p 1 ./internal/...别用新的ROND_CLEAN_OUT文件名(internal/rond字母序在web前 ⇒ 导出文件还没生成 ⇒ 4 个测试一起红,假警报)。 - 纪律:生产只走 IPv4(
curl -4)、303 不跟随;gofmt -l会被 CRLF 假警(python 回写用二进制模式);同一文件绝不并行发多条 Edit;CDP 探针点击写成轮询,新回归测试做变异验证。 verify_*工具与验收脚本见技能rond-backup-parse;量法探针见技能rond-web-verify。
二十八、安全 / 性能 / 验收补充规矩(2026-09-21~22 并入)
这一节是原
MEMORY.md索引里「违反会静默出 bug」那批判据的完整表述(索引为控制注入长度已压缩,正文放这)。
A. 落库与导出补充
- 建地点+到访必须同一事务,校验先于落库。
- 新行号只走
nextSrcPK+clearStaleRaw(取「库列 /coredata_meta/entity_raw.raw->>'Z_PK'」三处最大 +1);禁MAX(src_pk)+1(撞号会继承对方 rond 列)。守卫seq_test.go。 - 行程派生列必须与导入一致(
validateMovementForm):from/to_place_id(留空=绕过区域过滤直接露给访客)、distance_km、duration_min。 - 表单精度低于真值(分钟 /
%.6f)⇒ 只改备注也会静默改时间坐标;同精度就沿用库里那份(keepStoredTime/resolveMovementEnd/keepStoredCoord,行程/到访/地点三条路)。改到访时间要clampVisitMovements夹行程(窗口空则end=start+1min,不能 NULL)。 src_pk只在单数据集内有效(重导 Z_PK 100% 变);跨备份稳定键=到访ZIDENTIFIER_/活动ZUID_,其余靠名称+坐标。contentHash别用SrcPK|Arrival(重导即建新数据集 ⇒ 备注投票失联)。- 坐标留档
coord_source/coord_sys/src_lat/src_lon;不变式「显示坐标 == 按 coord_sys 换算 src」;换算只走 geo 包与/api/coords;高德 POI 即 GCJ-02。 - 脏备份守卫在
parse:appleTime对 NaN/±Inf 或秒数超[1970,2100)返 nil——Go 超范围浮点转 int64 是回绕不是饱和(NaN/±Inf/1e30→2001、1e12→2123,回绕还可能又落合法区间 ⇒ 先卡输入秒数再乘);nil 接「装不下就 skip+留档」。validCoord把非法坐标归(0,0)。rond/dirty_test.go+ingest/dirty_import_test.go。 - 聚合刷新(
saveVisitTags/refreshPlaceAgg/refreshDatasetAgg/activityFlags)必须返回 error:事务内上抛、事务外logAggFailure(只记日志、不放错给用户)。agg_error_test.go。 - 收藏切换走
UPDATE … RETURNING bookmarked一条语句(分两步回读,失败会误报「已取消」)。visit_bookmark_test.go。 writeRawRow留档写入必须上抛错误让withTx回滚;只 log 后return nil⇒ 合并照常提交、原始列永久缺失。- rows 循环收尾必须
drainRows(rows, what)(先Err()再Close()):只 Close ⇒ 半张表当完整交出。parse_rows_test.go。
B. 安全 / 跳转
- 跳转唯一判据
safeInternalPath(view.go):挡//evil.com、/\evil.com、控制字符、引号尖括号。?back=/表单next/投票后/登录后四处共用。redirect_target_test.go(含禁弱判据的静态扫描)。 - cookie 的
Secure必须按请求判断(cookieSecure:r.TLS或X-Forwarded-Proto首段):无条件打会让本机 http 调试与 CDP 谁都登不进。 - CSRF 只加同源 Origin/Referer 守卫(
csrf.go,不动模板):仅写方法、Origin: null拒、两头都缺则放行。前提是反代传原始 Host(改写需带X-Forwarded-Host),否则本站表单也 403。 clientIP只认X-Forwarded-For最后一段(首段可伪造;nginx 把真对端补在最后),须netip.ParseAddr通过否则退RemoteAddr。⚠️ 投票匿名身份 =hash(cookie,IP)⇒ 改取法即改身份。- GPX 按属主收口:
/api/gpx(无鉴权)先取生效数据集user_id(is_active AND status='done'最近一份)取不到返空,再WHERE user_id=$1;adminGpxDelete用DELETE … WHERE id=$1 AND user_id=$2且查RowsAffected()==0。gpx_owner_test.go(静态扫描只认反引号里的 SQL)。 - 下载名走
downloadDisposition:filename=ASCII 兜底(asciiSafe)+filename*=UTF-8''(rfc5987Escape只放行A-Za-z0-9.-_~)。四个出口(geojson/gpx/rondbackup/fwss)都经此。download_name_test.go。 - 两步验证两闸都要:
login2FA接loginGuard(键"2fa:"+用户名,与口令环分开计数);CodeReplay.Consume(uid,code)保证同码在 ±1 步窗口(replayTTL=90s)内只换一会话;重放不计入爆破计数。twofa_guard_test.go+code_replay_test.go。
C. 后端 / 性能
findSourceArchive先按size_bytes筛再算 sha256(pickArchiveBySha);sha=''退化路径不筛。- PG 建连接贵 ⇒ 池 8/8 +
db.WarmUp(d,6);页面内独立查询走runJobs(≤6);别用fog.Index.All();改遮罩必须masks.invalidate()+fogTiles.clear()。 placeCols6 个相关子查询必须带vN.dataset_id=p.dataset_id(索引(dataset_id,place_id);实测 Index Searches 2371→366、53→31ms)。stats/placecols_test.go(按子查询分段判,v2 出现 3 次)。- 收 multipart 的 handler 直接
ParseMultipartForm(r.ParseForm()毒化字段);urlencoded 调用方errors.Is(err, ErrNotMultipart)放过。之前必须r.Body = http.MaxBytesReader(...)(maxMemory只限内存,超出无上限落临时文件)。五个上传口:备份/GPX/迷雾/照片/地点图片。 - 包了
ResponseWriter必须实现Unwrap()(gzipWriter、headerSent已补):否则NewResponseController拿不到SetWriteDeadline/Flush/Hijack。导出路由靠它放宽写死线到 10 分钟。 recover写错误页前须看「首部是否已提交」(headerSent):写了半页才 panic 再http.Error会二次 WriteHeader 且错误体接半页后。server_error_test.go。- 上传 handler「先落盘再解析」时,落盘后每条失败路径都要删半成品(
adminUpload:keepStored+defer os.Remove,只「新导入成功」保留——上传件是导出基线源,findSourceArchive按datasets.sha256在 uploads 找它)。upload_cleanup_test.go(断言前先从 Location 确认真走到写盘后失败分支,否则空过)。 - 批量勾选操作必须走
*Bulk+p.src_pk = ANY($3::bigint[])(pgx stdlib 直接吃[]int64,不必pq.Array,也不用手写IN占位符):逐条循环是 N 次往返,1C1G 上体感明显。全局内存缓存必须带上限(fillupCache用 FIFO 淘汰最旧 1/4,覆盖写同一键不重复记账)。守卫admin_notes_bulk_test.go(静态扫描「不许退回逐条循环」+端到端「每步断言全部生效」)+fillup_cache_test.go。 - 同一查询别在一个 handler 里查两遍(统计页
Weekday曾在runJobs的 job 之外又同步查一次,白跑一次);守卫stats_weekday_once_test.go。 - 加
setting字段改三处:Default()/Load()/settingsSubmit()。 fogIndexV是atomic.Pointer[fog.Index](fogIndex()Load /fogResetIndex()Store):普通指针会成真 data race。fog_race_test.go(需-race)。- 瓦片编号走
tileDigits(补前导 0):tileName的 M2 尾缀起点len(s)-2在单数字编号时为 -1 ⇒ panic;解码守卫len(name)<8(旧<7放行 7 字节伪造名,md5 可算 ⇒ 上传 .fwss 即触发)。
D. 前端补充
- 异步回包一律发序号作废:勾图层→请求在飞→取消→回包照画 ⇒ 开关关着图层还在画(CDP 实测少守卫多画 756 条、
errs=0)。var seq=++xxxSeq;须在if(!on) return;之前,回包首行if(seq!==xxxSeq) return;,.then/.catch都挡。map_async_cancel_test.go。 - ⚠️ 改模板 JS 别用「heredoc+python」写
\n(多转义→真换行→整块<script>语法错、交互全哑而模板测试照过);一律用 Edit。inline_script_test.go;改完 CDP 确认 JS 报错为空。 - 窄屏覆盖必须写
app.css末尾那块(靠后赢);被:not()排除的 file/color 单独给字号;触控 44px、checkbox 20px。 - 宽表窄屏堆叠:
class="tb tb-stack"(类名成对,缺tb全不命中)、非首格data-label;@media (max-width:800px);别用@container/ 别退回 620。tb_stack_test.go+wide_table_layout_test.go。 - 加表格列:
PlaceCols/scanPlace成对、新列追加最后;CJK 挤换行只抬该表min-width。 - 访客页与站长页 DOM 不同:只在访客复现的 bug 先「无 cookie 原始 HTML vs CDP 后 DOM」定性;别让 JS 从
{{if .IsAdmin}}块读id。 - ES5 风格 JS 里访问
window.localStorage/sessionStorage一律包 try(可走lsGet/lsSet包装)——隐私模式下裸访问会抛异常,异常冒出去会打断整段脚本(地图白屏 / 下拉哑掉),而模板测试照过。守卫TestMapLocalStorageGoesThroughGuard、TestFillupSessionStorageIsGuarded;previewQueue[id]必须在if (live)之前排队,回调里消费后删除,不能只在「已初始化」分支保留 points。
E. 口径 / 404 / 验收
- 区域判定必须「两端各过一遍再 AND」,与
regionSQL同款:apiTrack=regionSQL("pf")+regionSQL("pt");movementsWhere也必须两端各判(不能EXISTS(... IN (from,to) ...)任一端命中——黑名单下等价、白名单下分叉 ⇒「图层画不出、总里程却算进去」)。比较列一律COALESCE(col,'')=$n(district常 NULL ⇒NULL IN (...)得 NULL、黑名单NOT跟着 NULL ⇒ 整行误删)。对账internal/web/region_consistency_test.go。 - 「累计停留」= Σ 各次到访时长(
LEAST(COALESCE(duration_min,0),10080)),非首到末跨;筛选条类型计数用分面ActivityCounts且清空ActivityIDs。 Filter.Verdict全域一致:verdict 条件下沉到visitsWhere(place_key坐标键EXISTS(place_votes…)),Cities/ActivityCounts/ActivityStats跟着走;hasVisitConds()要加|| f.Verdict != 0。place_key抽placeKeyExpr(alias)。stats/verdict_scope_test.go(fixture 自建封闭数据集、不设 is_active 免串味)。- 404 三种别混:
placeHiddenPage=403、notFoundPage=404、guardPage=404;瓦片/图片故意纯文本 404。 - 验收四件套环境变量:
ROND_TEST_CONF+ROND_SRC(绝对路径)+FOG_SRC+ROND_DSN(须指rond_fog_test;指postgres⇒TestChangeUsername报「users 不存在」+6 个 stats skip)。go test -count=1 -v ./...要 0 fail / 0 skip。 - ⚠️ 「全绿」的假信号规模(2026-09-24 实测):不设四件套跑
./internal/web/ ./internal/rond/ ./internal/stats/⇒ 三个包全ok、exit=0,但 95 PASS / 86 SKIP(原始报告基线是 62 SKIP,随回归守卫增多而变大)。永远-v数 SKIP。 - 📊 稳定性基线(2026-09-24 更正,见 §二十九):口径必须用
-coverpkg=./internal/...。此前记的「加权 ≈51%;rond 38.8% / stats 62.2%」是-cover假读数——它只统计「测试文件所在那个包」自己的执行,而 rond 的导出、geo/notes/votes 都由internal/web驱动。真值:rond 82.2%、geo 92.9%、stats 83.7%、web 53.2%、db 53.4%、fog 65.9%、auth 73.6%、exif 69.7%、setting 92.5%、captcha 90.6%、项目合计 62.3%;gpx曾真报 0%(整包零测试,已补到 96.0%)。无 CI / Makefile / linter,0-skip 验收靠scripts/verify.sh(别再靠人记环境变量)。 - ⚠️ 两个「假绿」坑:①
newMovementFixture缺ROND_SRC就t.Skip而结果仍ok;②往共用测试库Import(切is_active)会让internal/stats用例假 skip(夹具SELECT id FROM datasets WHERE is_active LIMIT 1,无 user 过滤)⇒ 必须用专用库(url.Parse(ROND_DSN)加后缀、db.Open自建),且t.Cleanup在测试自己的defer之后才跑(defer d.Close()+Cleanup 删数据 = 连接已关、静默失败)。 - 静态守卫不要用相对路径读文件,走
moduleRoot(t)/pkgFile(t,name);?占位符按 SQL 出现顺序绑定;-race需~/scoop/apps/mingw/current/bin在 PATH。 - 纪律:生产只走 IPv4(
curl -4)、303 不跟随;gofmt -l会被 CRLF 假警;同一文件绝不并行发多条 Edit;新回归测试要做变异验证(注入 bug→确认红且理由正确→还原→确认绿)。
二十九、覆盖率口径 / 死代码 / 关键页冒烟(2026-09-24 并入)
A. 覆盖率:先确认口径,再看数字
go test -cover只统计「测试文件所在那个包」自己的执行。 本项目大量代码被别的包驱动 (rond的export.go由internal/web的TestMovementCleanExport跑;geo/notes/votes由web/stats), 按-cover量这些包一律假报 0%。判据:换个口径数字会翻倍以上 ⇒ 先怀疑口径,别急着补测试。 正确用法:go test -coverpkg=./internal/... -coverprofile=… ./...;verify.sh --cover已固化。- ⚠️ 解析 profile 必须按
(文件,行段)取 max,不能求和:-coverpkg下每个测试二进制 都给同一段代码写一条记录(一条count=0、一条count>0),求和 = 语句数乘份数 ⇒ 每包都像只有1/N(踩过:web印成 4.4%,真值 53.2%)。go tool cover -func会自己合并,优先用它。 - 补测试只补「线上会走到、且一条用例都没有」的路径。
nil分支与if err != nil { return err }一律不追——那是覆盖率灌水。抓到的真缺口:BuildMergePlan的 地点/活动冲突(placePick/activityPick长期 0%)、internal/gpx整包 0/61。
B. 死代码:删,别给它写测试
- 删前必须全仓核查、且要连模板一起搜——Go 模板能调方法(
{{.Counts.Total}}),只搜.go会漏。 - 已删:
rond.Counts.Total()(模板里的.Total都是各页 view 自己的字段)、gpx.Track.BBox()(handlers_topics.go的fitBBox是无关变量)。给死代码补用例 = 把死代码固化,比不管更糟。 - 「实测否证」也要写下来:
gpx.haversine缺geo.Haversine那道math.Min(1,…)夹子, 本以为会出 NaN;实测对跖点h最多到1+2^-52,sqrt(1+2^-52)回舍成正好1.0,asin(1.0)=π/2⇒ 够不着,不改代码,只留一条用例钉住该性质并在注释里写明是实测结论。 另有三条 haversine 公式、两个半径(6371.0 / 6371.0088),差 0.20 m/146 km,属口径不统一、不影响功能。
C. 关键页冒烟 scripts/smoke.py(模板测试过了 ≠ 页面能看)
- 模板守卫只证明「模板里有那段文字」;白屏、JS 抛错、内容区被压成 0 高,模板测试全是绿的。
- 三种口径:站长浏览器(200 + 落点路径 + 内容区尺寸非 0 + 零 JS 报错)、访客纯 HTTP
(公开页 200;后台页必须 303 跳登录,
urllib不跟随跳转即可,比开 Chrome 快一个数量级)、 访客浏览器抽检(只在访客侧复现的 bug 藏这里)。verify.sh --smoke(只跑)/--all(全量+冒烟)。 - ⚠️ 关键区块不能取
nav.nav:它在layout.html里,内容块整个没渲染它照样在,等于没断言。 地图页取地图容器(#home-map/#map/#place-map),其余取首个.card。 - ⚠️ 用
.card不要div.card:专题卡片是<a class="card topic-card">,写div.card一个都选不中, 而querySelector返回 null 时尺寸也是 0,打印出来跟「元素被压成 0 宽」一模一样(踩过,差点去查布局)。 日志必须把「没选中」与「选中了但 0×0」分开印。 - ⚠️
--min-body只能兜「整页空白」:专题列表正文天生 166 字(就一张卡片),阈值定 200 会假红。 公开页受总开关控制 ⇒ 先探测/places的访客状态码再定同类页期望值。 - ⚠️
verify.sh给 Windows 版 python 传脚本路径要用$ABS(C:\...),传$ROOT(/c/...) 会被拼成C:\c\...报No such file。site_cdp.py不报 HTTP 状态 ⇒ 用performance.getEntriesByType('navigation')[0].responseStatus(给的是最终文档状态, 所以「访客访问后台必须跳登录」要看location.pathname或直接用urllib)。 - 变异验证手法:往公开模板塞
<script>throw new Error('…')</script>,debug=true下模板热加载 不用重启,冒烟立刻红在「JS 报错」而状态码/内容区尺寸全正常 ⇒ 证明没这条断言就会放过它。
D. 一键验收的完整形态(scripts/verify.sh)
--fast(gofmt/vet/build)|--race|--cover(-coverpkg + 按包表)|--smoke(只跑冒烟)|
--all(全量+冒烟)|--pkg/--run 定点。默认全量要 0 FAIL / 0 SKIP(SKIP 计数含子测试)。
2026-09-24 收尾实测:284 PASS / 0 FAIL / 0 SKIP,冒烟 16 页全绿,项目覆盖率 62.3%。
三十、「重复到访」清理做进编辑页(2026-09-29)
A. 判定与落点
- 实现:
internal/web/edit_duplicates.go(读基线库 + 检出 +cleanDuplicateVisits)、视图字段view.go的EditData.DupVisits/DupUnavailable、 装配在handlers_edit.go的editPage(ensureBaseline之后)、路由POST /admin/edit/dup/clean、卡片在templates/admin/edit.html到访记录卡片之前。 - 判定与
tools/dedup_site_visits.py同一套语义:参照 =s.baselinePath(ds.ID)(就是那份手机包,实测 9.sqlite 与 20260914_151649.rondbackup 的 ZVISIT 完全一致); UUID 是前提——站点到访的 UUID 不在基线里、且基线里有一条「到达时间 + 地点」完全相同的才算副本。只按时间+地点判会把手机包自身合法的成对条目一起删掉。 - 「时间 + 地点」折成
round(apple秒*1000) + "|" + 名称/坐标(%.6f|%.6f);两侧用同一个dupKey,先到先得(Python 的setdefault),顺序一致结果才一致。 - 读基线库:
sql.Open("sqlite", "file:…?_pragma=query_only(1)")。驱动不支持 mode=ro,但基线是 VACUUM INTO 的产物(rollback journal 模式),只 SELECT 不落旁写文件;internal/web不必自己 import 驱动(rond里已_ "modernc.org/sqlite",blank import 随包 init)。 - 基线不可用 / 跟本站 UUID 一条都对不上(
matched == 0)⇒ 只给灰字说明、不给按钮。缺了这道闸,拿别人的手机包当参照会把全站到访都算成副本。
B. 与工具刻意不同的一处:改指合并标记只动「本来就有的那个键」
- 工具用
jsonb_set(..., '{ZMERGEDTO}', …, true)把两个键都写成保留项 ⇒ 「只有 ZPARENT 指向副本」的行会凭空长出一个 ZMERGEDTO (真机里那是「这条已并入别处」,会白丢一条记录)。实测基线 956 条 ZVISIT 留档都带着这两个键,但只有 54 / 10 条有值,凭空补键很容易踩到。 - 所以 Go 侧逐键更新、
create_if_missing=false;raw->>$4 = $2必须按字符串比(拿数字比报「text = integer 操作符不存在」,整批清理会失败)。TestDupCleanKeepsMergeKeysAsIs静态守这两条。
C. 顺带修掉:站点补录天气会让整包导出失败(rond/export.go 的 queryWeather)
is_daylight被扫进裸bool,而站点「新增天气」(editWeatherCreate) 不写这一列 ⇒ NULL ⇒sql: Scan error … couldn't convert <nil> into type bool,导出整包 303 + err。- 修法:
COALESCE(is_daylight,false),与导入侧同口径(parse.go读真机包用的就是COALESCE(ZISDAYLIGHT,0))。真机 557/557 都有值 ⇒ 兜 0 而不是 NULL。
D. ⚠️ 时间线上「看着重复」还有另一类:手机自带的 ZMERGEDTO / ZPARENT 行(尚未处理,等方案)
- 现象(2026-09-04 家 20:21 两条):
#422(20:21→01:52, 331min,ZMERGEDTO=#562) 与#562(20:21→15:37, 1156min)。两行 UUID 都在手机包里,是手机自己「已并入」的碎片行—— 真机 UI 只显示#562,本站两条都显示,日停留也被重复计入(那天「1天12小时」里 5h31m 是重复的:TimelineDay.Dwell就是当天各行DurationMin之和)。 - 不能删:导出要靠它告诉 rond「这次合并发生过」;本卡片的 UUID 前提也天然把它们排除在外(删了就是毁手机自己的记录)。
- 规模(数据集 9):54 条
ZMERGEDTO非空、10 条ZPARENT非空,其中 26 条并入行与原件到达时间完全相同。ZPARENT的形状是「子段拼成父段」:#64(11:35→14:21) +#133(09:01→11:35) = 父#374(09:01→14:21)。 - 洞在哪:全站(
internal/web、internal/stats)只有 edit_duplicates.go 提到过这两个字段,没有任何查询识别它们。 - 收口点(真修的话):
stats.Filter.visitsWhere(时间线/首页/统计/城市/省份/活动/星期/地点列的公共出口)+movementsWhere(memory 铁律:两套 where 都要加)- 三处材料化聚合(
ingest.Import、refreshPlaceAgg/refreshDatasetAgg、backup_merge),否则列表与计数会不一致。两条路:加派生列(快,要迁移)或 where 里NOT EXISTS子查询(无迁移,稍慢)。
- 三处材料化聚合(
- 先例:
parse.go读真机包时对这两列没有任何处理 ⇒ 导入侧也没过滤,所以这不是「某一处漏判」,是口径从没建过。
E. 测试/取证手法(这一轮踩到的新坑)
- ⚠️ 相对计数断言会假绿:
len(pairs) != len(before)+N在「把前提删掉导致 before 一起膨胀」的变异下自洽(实测 0 → 822,断言照样过)。 必须补定义性不变量:副本的 UUID 一定不在基线里、保留项的 UUID 一定在。变异验证要专门挑这种「让基准值一起变」的改法。 - ⚠️ 核对导出包不能按留档里的 UUID 找行:站点侧新建的行(副本就是)导出时会被补上本站派生的确定性 UUID(
coreDataUUID/uuidV5 表名+src_pk), 留档那个 UUID 根本不会写进包。要按「时间 + 地点」或整包条数差分来断言。 - 夹具复用
newMovementFixture(真实导入 + 真实基线),造副本时照抄原件那一行再换Z_PK/ZIDENTIFIER_,列形状才和真实副本一致。 - ⚠️
render是流式直写:模板执行到一半出错也只记日志、状态码照样 200、body 截断 ⇒ 断言只看code==200等于没测。 卡片之后的字(全部保留在{{.EditLink}}之后)、以及末尾</html>都要断。
F. 全量验收的环境前置(2026-09-29 卡了一小时,先做这两件事)
- 先
VACUUM (FULL, ANALYZE):rond_fog_test跑几十轮全量后死元组能到 3.3GB(entity_raw2.2GB), 级联DELETE FROM users秒变 IO 爬虫 ⇒ 全量跑 30 分钟跑不完(看着像 hang:测试进程 CPU 20 秒纹丝不动)。 清完 3311MB → 16MB,全量 5m49s 跑完。 - 先确保库里有生效数据集(
is_active):internal/stats的setup()取不到就t.Skipf("没有生效数据集"), 6 个对照用例一起假 skip。本次UPDATE datasets SET is_active=(id=1)之后才 0 skip。 - ⚠️ 验收跑着时别并发跑定点测试(同一个库);中途 kill 测试进程会留下孤儿 e2e 数据集(fixture 只在正常退出时清理),
下次
purgeE2EUsers删除它时又赶上膨胀 ⇒ 更慢。 - 结果口径:PASS=305 / FAIL=0 / SKIP=0(基线 297 + 重复到访 4 个 + 收口 3 个 + 城市聚合 1 个用例);三个提交
152af81/4597047/3d03aae均已推到 origin/master,推送后复测过。
G. 已并入 / 子段到访在读的一侧收口(2026-09-29 当天完成)
用户报「时间线上出现重复」(09-04 家 20:21 两条):其中一条的留档带 ZMERGEDTO——那是手机自己标了
「已并入别处」的碎片行,app 只显示被并入的那条。不能删(导出要靠它认这次合并发生过),
所以只在读的一侧收口。判据唯一一份:stats.SoloVisitSQL(alias)。
- 收口的落点(改一处要连带改的清单,漏一个就是「数字自相矛盾」):
stats.visitConds(off, solo)←visitsWhere是 solo 版,覆盖时间线/首页/统计/城市/省份/活动/星期/地点列;stats.VisitsByPlace、stats.Bookmarked(这两处自建 where,不走 visitsWhere);stats.placeCols的 v2/v3/v4 子查询;handlers_public.go的 firstArrivalCities + extremePlace;handlers_topics.go的专题(外层 + 2 处子查询 + 天数); 三处聚合的visit_count/dwell_minutes(ingest回填、refreshPlaceAgg、recountPlaces);schema.sql一段幂等回填。 - ⚠️ 天气与位移不收口(
solo=false):那几小时、那段路真实发生过(数据集 9 有 12 条天气、5 条位移挂在被并入的行上)。TestWeatherStatsRespectsFilter/TestVisitSideFiltersApplyToMovements钉的就是这条——一开始我让它俩也继承了判据,两个用例立刻红。 - 导出侧一个字节不动:rond 只读
places.last_visit_at(保持全量),压根不读 visit_count / dwell_minutes; 静态守卫TestSoloVisitCondSharedAndOffTheExport钉住「export.go / backup_diff.go 不许出现 SoloVisitSQL」, 行为上由TestHiddenVisitStaysInExportButLeavesReads钉住(标记后导出,包里那行还在、编号与 ZMERGEDTO 都对)。 - 展示侧兜底:
placeCols把 first/last_visit_at 包在CASE WHEN p.visit_count > 0 THEN ... END里, 否则「到访数按访客口径、日期按全量」会凑出「0 次却有最近到访」(数据集 9 实测 4 个地点)。 - ⚠️ ingest 的回填必须排在
insertEntityRaw之后:判据要读 entity_raw,留档还没写盘时每一行都被当成独立到访 (实测整整多算一个「已并入」的量)。这个坑是新写的用例抓出来的,不是看出来的。 - 实测(数据集 9):places 到访数合计 1001 → 937(= 去掉 64 条),停留 249769 → 222399 分。
- 代价(EXPLAIN ANALYZE,时间线那条查询):0.35ms → 3.5ms(42 次 entity_raw 主键探测),Planning 3.6 → 6.4ms(SQL 文本变长)。
真嫌慢就改派生列(导入/合并时写
visits.solo):查询回落到原样,判据更集中,代价是加列 + 重算。 - 边界(都记着,问起来能答):管理端「地点补全」页、
notes的备注汇总、backup_diff/merge一律全量;datasets.visit_count是后台口径(全量,与时间线会差 64);城市统计的max(p.last_visit_at)仍取列值。
I. 城市聚合(地图城市圈气泡)的既有口径错误(2026-09-29 审阅时发现并修)
/api/cities(apiCityAgg)原来把聚合打在places ⋈ visits上:sum(p.visit_count)/sum(p.dwell_minutes)会按该地点的到访条数重复累加、count(*)被当成「地点数」显示。实测最大城市显示 「到访 72661 次 · 568 个地点」,真值 568 次 / 86 个地点(差 128 倍)。- 修法:改成两级聚合——内层按
p.idGROUP BY 出每个地点的 visits/dwell,外层再按城市sum/count/min/max。 顺带补上stats.SoloVisitSQL("v")(与其它访客口径一致)。ORDER BY 5(城市到访数)与 Scan 列序不变。 - 回归:
city_agg_test.go(手写同口径 SQL 对账 + 「地点数不可能多于到访数」的不变量);变异验证:把sum(pa.visits)改成*2立刻红。这个 bug 是既有的,与「已并入」收口无关,只是同一次审阅里发现。
M. ⚠️ ZLOCATION.ZISOCOUNTRYCODE 保持 NULL,别「美化」成空串(2026-09-30 复查,差点重犯)
- 真机包自己那列会写空串(09-29v2:CN 410 +
''23),但导入侧不接受空串:09-29 做过真机对照实验 (同一份包、同一台设备,唯一差异这一列)——''→ 「备份恢复失败」;NULL→ 能导入(commitd5e96ed)。 - 所以「空值画像」判据在这里要留个例外:参照里 0 NULL 而导出里有 NULL 也不算违规。 同类教训:真机自己写出来的形状不代表它能吃回去。
- 顺带记:2026-09-30 生产导出被拒收这一次,把技能里记的全部判据都跑了一遍仍没定位到形状违规 (打包层 / 骨架 / 空值 / 关系 / 类型 / OPT / UUID / 时间 / 文件头全过);后续结论补在这一节。
L. 一次「功能对、就是慢」的性能回归与定位手法(2026-09-30)
- 症状:整套验收从 6 分钟变成 20+ 分钟;最慢的 4 个用例(309s / 274s / 226s / 115s,占了全套 55%)都是
「渲染若干页面」的布局用例(
TestWideTableCardsAreFullWidth/TestStackedTablesHaveLabels/TestFiltersFoldHook/TestVisitorPagesWithRegionFilter),不是夹具/导出类。 - 定位手法(按有效性排序):
- 写一个临时的「逐页计时探针」(渲染 7~8 个关键页并打印 ms)——一眼看出是 /(44s)与 /places(70s);
- 变异对照:把可疑判据临时改成恒真再跑同一个探针(70s → 0.3s ⇒ 就是它);
- CPU profile:
-cpuprofile显示 135 秒里只用了 3 秒 CPU ⇒ 进程在等,不是算 (排除模板渲染/Go 逻辑,指向「大量往返 / 逐行取大字段」); - 反例:孤立的
EXPLAIN ANALYZE会骗人(同一条判据 12~38ms),因为它只有 40 行、不是整页的量级。
- 根因:判据
raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL打在entity_raw.raw上, 而 raw 是整行 Core Data JSON(TOAST)——每行探针都要取回+解析。 - 修法(两层):派生列
visits.is_merged(读侧只看布尔)+ 部分索引idx_entity_raw_visit_flags(只索引真带标记的那几十行,给「从留档重算」的写入路径用)。 - 防回归:
page_render_budget_test.go给 7 个关键页卡 10 秒上限的灾难探测器 (「功能全对、只是慢」这种退化,之前没有任何用例会红——模板测试、快照、静态守卫都照过)。
K. 「补录行被合并回来」造成的重复(2026-09-30 修,与「已并入」不是一类)
- 现象:生产时间线上成对出现到达时间、离开时间、停留时长全都一样的两条(一屏 53 对)。
与 09-04 那种「离开时间不同」的已并入行不同——用
ZMERGEDTO/ZPARENT判据一条都收不到 (实测 43 对里with_hidden_side = 0)。 - 成因链:站点补录的到访没有
entity_raw留档 ⇒ 没有 UUID;导出时被补上确定性 UUIDv5("ZVISIT/<src_pk>")写进包里,手机于是把它当一条真记录;这份包再合并回站点时, 站点这侧没有 UUID 可对齐 ⇒DiffVisits判成「备份新增」又插一条。 实测:43 对里 41 对的副本 UUID 恰好等于v5("ZVISIT/配对行 src_pk")(另 2 对编号被重排过)。 - 修法(不改导出、不改数据,只认它):
rond.DerivedUUID / DerivedVisitUUID / VisitMatchUUID(导出侧coreDataUUID复用同一个函数);DiffVisits的byUUID索引:无留档的行也挂上它的派生 UUID ⇒ 备份那行能对上;backup_merge的storedByUUID同样处理(位移/天气端点映射也走它);- 「时间 + 地点」兜底配对补
used检查——同一条行现在两个索引里都有,不查会被 classify 两次; - 卡片新增第二类判定(派生 UUID 命中,排在「站点侧副本」之前,证据更强), 清理时把副本名下的位移/天气改挂到保留项(补录行自己没有位移,跟着删就白丢里程)。
- 用例:
TestDiffVisitsAlignsSiteCreatedRowByDerivedUUID+ 反例...DoesNotAlignOtherDerivedUUID; web 端到端TestDupCleanMergedBackSiteRow(造补录行+副本 → 检出 → 清理 → 断言改挂)。 变异验证:去掉改挂语义 / 关掉判定,两条立刻红。 - ⚠️ 测试里造这类数据时,补录行的时间要避开基线里已有的时刻,否则会先被「站点侧副本」 判据认走(用例里刻意错开 7 分钟,注释写明了原因)。
J. 收尾项(同一天,最后一批)
scripts/verify.sh:库 ≥VACUUM_MIN_MB(默认 200MB)时先VACUUM (ANALYZE)。 只做普通 VACUUM:FULL要 ACCESS EXCLUSIVE 锁,万一有别的服务连着测试库会把验收整段卡住; 普通版是并发的。实测同一夹具用例 13.6s → 10.2s(200MB → 62MB),主要防的是「上一轮的死元组留到这一轮」 那种 30 分钟爬不完的最坏情况。缩文件还是要手动VACUUM (FULL, ANALYZE)。- 城市圈的首末到访改成按可见到访算(
min/max(v.arrival)内层聚合),不取places.first/last_visit_at(那两列是全量的,会被已并入 / 子段的行推早推晚)。 - ⚠️ 只比对同口径会假绿:被并入的行大多落在父段区间内,所以「按列值」与「按可见到访」在夹具数据上 结果一样,断言看不出差别。必须主动把某地点最晚那次到访标成已并入,再看城市的最近到访有没有跟着退 (实测:仍停在 09-13 而应退到 09-08 ⇒ 抓到)。这条是变异验证抓出来的,不是看出来的。
- ⚠️ 派生列是必须的,不是可选(2026-09-30 自我更正):我当初按「时间线查询只做 42 次主键探测」判它
「得不偿失」——只量了单条查询,没量整页。实际 /places 有「每个地点 × 5 个子查询」,每个子查询都要
取回并解析 TOAST 过的整行 JSON ⇒ 70 秒才出页面(首页 44 秒)。已改成派生列
visits.is_merged(RefreshMergedFlagsSQL刷、部分索引兜住刷新路径),/places 70s → 0.2s。 教训:性能判断要在「渲染出来的页面」上做,孤立的 EXPLAIN(12~38ms)完全看不出来。 - 改指语句批量化仍然不做:一条语句 +
unnest会遇到「同一行 ZMERGEDTO 与 ZPARENT 指向两个不同副本」时只改一个键; 地点聚合批量化则会把refreshPlaceAgg抄成第二份。现在的循环是对的,别为了省几条语句换来边界错误。 - 文档:README 补了「数据编辑页上的重复到访」与「为什么有些到访在页面上看不到」两节,
以及验收脚本清理死元组、
datasets.is_active的说明。
H. 全量验收的环境维护(每轮都要做)
- 一轮全量会让
rond_fog_test涨 ~370MB 死元组(16MB → 385MB):级联DELETE FROM users随之变慢, 下一轮就能爬到 20~30 分钟(进程 CPU 长时间不动,看着像 hang)。 - 跑前跑后各来一次
VACUUM (FULL, ANALYZE):16MB 状态 5m49s 跑完,385MB 状态 22 分钟还没跑完。 - 顺手确认
datasets里有is_active(UPDATE datasets SET is_active=(id=1)),否则internal/stats6 个用例假 skip。
三十一、从 MEMORY.md 索引迁出的两段长文(2026-09-28/29 备份合并 + 导出恢复三条铁律)
2026-09-30 整理:索引文件(MEMORY.md)注入时被截断,按它自己的规矩把这些长文挪到这里; 与「廿一、合并执行」有部分重叠,后续可以合并去重。
备份合并(差异 → 执行)(details:2026-09-28)
- 链路:
POST /admin/preview(stashPut暂存)→GET /admin/preview/{token}(rond.DiffVisits/Places/Activities)→POST /admin/preview/{token}/merge(BuildMergePlan+applyMergePlan,单事务)。合并前必须重算差异(渲染到点击之间库可能已变)。 - 对齐键:到访 =
ZIDENTIFIER_(库侧从entity_raw.raw #>> '{ZIDENTIFIER_,$blob}'取),地点 =PlaceCoordKey(坐标 6 位小数,不认名称),活动 =ZUID_。 - ⚠️
DiffPlaces对「同名同坐标」的重复地点:2026-09-28 已修——byCoord/byName改为多值索引(map[string][]StoredPlace)+take()逐条认领未使用的候选。旧实现是单槽位 map,同一坐标 N 条备份地点最多配 2 条 ⇒ 第 3 条永远「备份新增」,每次合并多插一条重复地点、差异永不归零(实测「柳州站」3 条 → 稳定每合并一次 +1)。回归用例TestDiffPlacesAlignsDuplicateNameCoordGroups(含「站内真少两条仍报新增 2」反向断言)。 - ⚠️
visitFieldChanges判「活动」:2026-09-28 已改为按身份(ZUID_)比(StoredVisit.ActivityUUID由loadStoredVisits多 JOIN 一次entity_raw/ZACTIVITY 取得),两边都缺 UUID 才退回名称。旧实现按名称比 ⇒ 活动一改名,挂在它下面的每条到访都报「冲突」(实测 16 个活动 → 355 条同因冲突),且对到访选「用实机」也改不掉名字(activity_id按 UUID 映射回同一个)⇒ 冲突不收敛。 - ⚠️
DiffPlaces的「按名称兜底」必须有距离上限:2026-09-29 已加nameMatchMaxKm = 1.0(takeByName)。 名称不是唯一键——连锁店同名是常态(实机里「中国体育彩票」3 条、「长沙南站」2 条、「茶颜悦色」2 条), 没有上限就会把相隔几百公里的两家店配成一对;站长勾「用实机」时站内地点被搬到另一座城市、名下到访集体跳城 (实测把佛山的「中国体育彩票」配给贺州那条,236.8km,合并后站内 place 真的搬了)。geo.Haversine返回公里,别按米写。 - ⚠️
visitFieldChanges的「地点」字段比的是坐标键(PlaceCoordKey),名称相同时必须把坐标印出来 (coordSuffix),否则差异页渲染成「X → X」,站长看不出差在哪、无从选择。 - ⚠️ 导出侧
ns()把空串当空(返回 nil):凡「真机从不留空」的列,写完值都要兜底——transportPlaceholderColor/Icon、activityPlaceholderColor(2026-09-29 加)就是这么来的。 站内「地点补全」建的活动color=''、icon=''(fillup.go),图标那侧早有 📍 兜底、颜色漏了, 导出写出ZACTIVITY.ZCOLOR_ = NULL→ 生产整包被 rond 拒收(「备份恢复失败,你的当前数据未被更改」)。 守这个不变量的用例是TestExportNeverNullsColumnsTheDeviceAlwaysFills(基线里 0 NULL 的列, 导出新行也不许 NULL;改了填值逻辑就顺手给它加个场景)。 - 排查「导出被拒收」的最快路径:拿一份参照包(真机原包 / 同一数据集上次的导出)做逐列空值画像
—— 参照里 0 NULL、导出里有 NULL 的列就是形状违规。判据必须是「参照里 0 个 NULL」:
ZLOCATION.ZISOCOUNTRYCODE真机自己漏过 1 个 NULL ⇒ 它容忍 NULL,别去"修"。 - ⚠️ 「差异页默认选项是用实机」⇒ 站长直接点「合并」= 所有冲突都用实机值覆盖站内
(浏览器提交全部 checked 的 radio)。
写合并测试必须照这个提交(从页面抓
name="(vc_|pc_|ac_)... value="backup");只提交add_new=1走的是「冲突保留站内」,与真实点击行为完全不同。合并后的自检项:没有任何地点被搬动超过 1km。 - 地点配对只有一份实现:
rond.MatchPlaces(2026-09-29 抽出)。差异判定(DiffPlaces)与合并执行 (web.mergePlaces)必须共用它——两处各配一遍就会漂移:合并侧原来用单槽位 map 自己配,多条同名 备份地点全并到同一行站内地点,到访被挂到错的分店(实测「茶颜悦色」差 8.3km)。两侧的地点查询都要ORDER BY id(候选多于一个时靠顺序定谁配谁)。自检不变量:合并后没有任何地点被搬动 >1km, 也没有任何到访离它的实机地点 >1km。 - 计划里不要存「改哪一行」的键:
PlacePatch.CoordKey/ActivityPatch.Key已删(只写不读, 且容易被误当成查找键)。要找行就靠配对结果。 - 测试手法:包内临时测试 +
httptest.NewServer(srv.Handler());必须CheckRedirect: http.ErrUseLastResponse(站点 303,自动跟随会读不到 Location);验证码答案读captcha.Store.Answer(id),不必 OCR。管理员默认口令rond-admin。
导出/恢复链路的三条铁律(2026-09-29 全部踩过一遍)
- app 只能靠 Z_PK 解引用地点与活动(它们没有 UUID)⇒ 导出的编号必须与数据集的基线同一套。
已实现
internal/rond/export_align.go(按身份对齐:地点坐标/名称、活动到访 UUID、标签方式名称、 位移天气按所属到访;匹配不上且原编号没被占用的行保持原编号,保证往返不变)。 ⚠️ 对齐的目标是数据集的基线 ⇒ 基线必须是手机包,否则对到的是「站点自己的编号那套」。 做法:把手机导出作为新数据集上传 → 把旧站点导出包当备份合并进去 → 从它导出。 - rond 的导入是「整库替换」:导入前必须先清空 app 数据,否则会出现成对重复(站长实测)。
自检:app 的到访数 ≈ 包内到访数(减去
ZMERGEDTO/ZPARENT那些已并入的隐藏记录); 地点数 ≈ 包内「有到访的地点」数。 - 站点数据里存在「同时间同地点、UUID 不同」的历史副本(合并按 UUID 认人 ⇒ 它们被判成新增,
导出后就是成对重复)。清理用
tools/dedup_site_visits.py(以手机包为参照,默认只预演,--apply才删;会连带清位移/天气/留档,并把指向副本的ZMERGEDTO/ZPARENT改指到保留项)。 ⚠️ 参照必须是手机包:只按 (时间,地点) 判会把手机包自身合法的成对条目一起删掉。
三十二、从 MEMORY.md 索引压缩出来的各节正文(2026-09-30 整理)
索引(MEMORY.md)注入时会被截断,超过 900 字的小节只留前两条要点,正文挪到这里。 小节标题就是原来的索引标题,按它检索即可。
口径 / 404 / 验收(details 十一~十三、廿七、廿八E)
- ⚠️ 跑全量验收前两件事:①
VACUUM (FULL, ANALYZE)——测试库死元组涨到 3.3GB 后级联 DELETE 变 IO 爬虫,30 分钟跑不完(看着像 hang);清完 5m49s。② 库内必须有is_active数据集,否则internal/stats6 个用例假 skip。跑着时别并发跑定点测试。details 三十F - 区域判定两端各过一遍再 AND(同
regionSQL);movementsWhere也必须两端各判,不能用EXISTS(... IN (from,to) ...);比较列一律COALESCE(col,'')=$n。region_consistency_test.go - 「累计停留」=Σ 各次到访时长(
LEAST(COALESCE(duration_min,0),10080));类型计数用分面ActivityCounts且清空ActivityIDs。 Filter.Verdict全域一致:条件下沉visitsWhere;hasVisitConds()加|| f.Verdict != 0。stats/verdict_scope_test.go- 404 别混:
placeHiddenPage=403、notFoundPage/guardPage=404;瓦片/图片故意纯文本 404。 - 验收四件套
ROND_TEST_CONF+ROND_SRC+FOG_SRC+ROND_DSN(须指rond_fog_test);-count=1 -v ./...要 0 skip。两个假绿坑:缺ROND_SRC静默 Skip;共用库Import让internal/stats假 skip ⇒ 用专用库,t.Cleanup在defer之后才跑。 - 静态守卫走
moduleRoot(t)/pkgFile(t,name);-race需 mingw 在 PATH。纪律:只走 IPv4、303 不跟随;同一文件绝不并行发多条 Edit;新回归测试做变异验证。 - 📐 覆盖率必须
-coverpkg=./internal/...(-cover只算本包 ⇒ rond/geo 假报 0%,真值 82.2%/92.9%);解析 profile 按(文件,行段)取 max 别求和;死代码删掉、别给它补测试。关键页冒烟scripts/smoke.py(verify.sh --smoke/--all):关键区块不能用nav.nav、用.card不用div.card。details 廿九 - ⚠️ 验收要在「安静环境」跑,别和别的东西并发:
db.Open的 ping 是写死 10s(db.go:52), 库被压着时TestOpenCreatesMissingDatabase会以「建库后的首次连接超时」失败 —— 2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉单独跑 4.1s 就过(环境抖动,不是代码问题)。 另外这条测试失败时会把临时库rond_test_autocreate_*留在库里(清库的 defer 在Open成功之后才注册), 跑完记得手动DROP DATABASE清掉,别当成「库被占用 / 测试有 bug」。 同理:变异实验也别和验收并发(2026-10-09 实锤:变异期间正好被验收抓到,那轮结果直接作废)。 - ⚠️ 变异验证「内嵌文件」(
templates/、static/)之后必须go clean -cache再跑验收。//go:embed的内容虽然参与构建,但「改坏 → 还原」之后构建缓存会继续给出变异期间编译的那份内嵌内容: 2026-10-09 实测同一条守卫go test ./internal/web/绿、go test ./...红(同一份磁盘文件、同一个测试);go clean -testcache不管用(它只清测试结果),go clean -cache之后立刻一致。 判据:两种调用方式结果不一致时,先清构建缓存再下结论。只变异.go文件没有这个问题(Go 源码参与缓存键)。
三十三、「换基线」(底座换代)做进后台(2026-09-30)
是什么 / 为什么要
导出的实现是「底座为底 + 只覆盖托管表」(见 internal/rond/export.go 头部注释):
Core Data 元数据、索引、持久化历史、ZRAWVISIT 采样点、ZTRIP* 全从底座原样带出来。
所以底座必须是手机导出的包。底座跟着数据集走,手机重新导出后想换代,
以前唯一办法是整包重新导入成新数据集(站点补录的行、主题、备注全要重收拾)。
新动作:同一份数据的底座换代,站点数据一行不动。
实现要点(internal/web/baseline.go + handlers_export.go + templates/admin/baseline.html)
- 顺序不可挪:写临时底座 → ①重合判据 → ②干跑导出 → 旧底座改名留档 →
rename就位 → 记档 → prune。 干跑(rond.ExportRondbackup(..., tmp, io.Discard))把「底座缺表/缺实体编号」这类失败挡在替换之前, 否则会出现「底座换了、才发现导不出、原来那份已经不知去哪」。 - ①重合判据(唯一能挡住「换错包」的闸):分母是站点侧带 UUID 的到访(站点补录行本来就没有 UUID,
不计入);
minOverlapVisits=20以下不判;matched==0⇒ 硬拒(force 也不给过);matched*2 < withUUID⇒ 要显式确认。理由:干跑只验结构,一份不相干的包照样导得出, 但换下去站点每行都在新底座里认不出身份 ⇒ 全按新行插到最大编号之后 ⇒ 真机上「这些到访全变成新的」。 - ⚠️ 留档名必须防重名:
fmt.Sprintf("%s.bak-%d", dst, time.Now().Unix())是秒级; 「替换后紧接着回滚」两个操作落在同一秒时,回滚那次rename会把刚生成的留档覆盖掉, 于是回滚拿到的还是新底座——实测TestRollbackBaselineRestoresPrevious就是这么红的。 改用baselineBackupName()(20060102-150405+ 冲突自增)。 - 记档三列
datasets.baseline_name/baseline_sha256/baseline_at。ensureBaseline必须先看记档:只认datasets.sha256(= 最初那份包)的话, 底座一丢就静默重建回旧包,上一次换基线白做。共用实现ensureBaselineTo(ctx, dsID, dst); 「重建」入口写临时文件再rename,不做破坏性os.Remove(重建失败不能让数据集没底座)。 - 上传件要落
uploads(stashUpload,按 sha 去重后复用已有那份),否则底座丢了按记档找不回包。 ⚠️ 算摘要的落盘必须在uploads之外(ingest.TempDir下),否则findUploadBySha会把自己认成「已存在」。 - 判据没过不当场报错了事:包已经收好了,页面用
?pending=<sha>&pname=&pm=&pw=渲染一个 「我确认,强制替换」按钮(不必重传几十 MB);POST /admin/baseline/confirm走同一条路。 - 导出内容在换底座前后逐条不变(
ZVISIT的 UUID 集合):编号会换、身份不该换, 这条是回归用例最锋利的断言(谁在底座里没对上,就会多出一条派生 UUID 的行)。 - ⚠️ 导出条数 ≠ 站点条数,别把差值当 bug:
tableWriter.finish()有两段——先删 「基线里有、PG 已没有」的行(known−seen),再把「导入时进不了 PG 但留档还在」的行 (w.skipped×raws)按原样补回。实测「站内 823 / 导出 824」就是这个第二段。 同一段代码也决定了换基线的语义:新底座里站点没有的行会被清掉(进known没进seen), 所以换到「更新」的手机包不会把手机上的新记录带进导出包——想带得先用「分析差异」合并。 页面文案与提示都写明了这点,用例TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave守着它。 - 路由:
GET /admin/baseline、POST /admin/baseline(上传并换)、/confirm、/rollback、/rebuild。
用例 internal/web/baseline_swap_test.go(7 条)
成功路径(文件真换/旧底座留档 sha 一致/记档写入/站点计数与样本时间不变/导出 UUID 集合一致)、
记档优先重建(带反证:清掉记档后同一条路必须走不通,防止用例被别的兜底弄绿)、
部分重合需确认、一条都对不上 force 也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、
页面渲染到底(尾部 </html>)。
三十四、图片挂载恢复 + place_images 摘掉外键(2026-09-30)
症状与根因(用户报「之前上传的照片找不到了」)
place_images.dataset_id 原本带 ON DELETE CASCADE,而读取按 place_key 坐标键、跨数据集。
两套口径打架:删掉旧数据集时图片行被级联删掉,文件却留在 <data_dir>/media/<数据集>/…。
实测(本机 rond_with_you):datasets 只剩 155 号,place_images 0 行,盘上 4 个 .webp 好端端躺着。
同一批附属数据里,备注(17 行)、结论(3+1 行)因为早就迁到坐标键,毫发无损——只有图片倒霉。
恢复动作
- 入口:后台首页「图片挂载恢复 → 扫描并恢复」⇒
POST /admin/images/recover(internal/web/image_recovery.go)。 只插入行:不删文件、不动已有行、可反复点(按object_key判重)。apply=false是预演。 - 认地点的两处来源:① 数据集还在库里 ⇒ 查它的
places(最准);② 数据集已删 ⇒ 读它留下的 底座库baselines/<id>.sqlite(换基线留下的.bak-*也算,新的优先)→ 还原「地点号 → 坐标」。 ⚠️ 底座文件是救图的唯一依据:清掉 baselines 就永远认不出这些图属于哪个地点(只报「认不出」)。 - 对象键两种布局都要认(历史原因):
<dataset>/<YYYYMM>/<srcPK>-<内容哈希>.webp(现在)与<dataset>/<srcPK>/<内容哈希>.webp(早期,目录名就是地点号)。 判据不能只看「中间那层是不是 6 位数字」——地点号也可能刚好 6 位:先看文件名有没有<数字>-前缀,再退到目录名。 width/height无从得知、留 0:页面只用URL(模板里没有宽高),bytes用文件大小。
防复发
schema.sql:CREATE TABLE place_images去掉外键,另加幂等迁移ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey;。dataset_id退化成「出处」这一列——图片认地点本来就不看它。adminPlaceImageDelete不再按dataset_id过滤(数据集删了也得能删图),SELECT/DELETE 都只按id。- 后台「删除数据集」的二次确认文案改成实话:删的是到访/地点/位移/原始行留档 + 专题, 备注/结论/图片按坐标键保留。
- ⚠️ 同类陷阱还剩一个:
topics仍带 CASCADE。专题内容是「时间 + 区域」派生的,本可以跨数据集活, 但列表/详情都按dataset_id过滤,去掉外键就得同时改成用户级查询——未做,待用户拍板。 已丢的那批专题(本机 0 行)没有本地恢复来源。
⚠️ 测试隔离(这批用例踩出来的)
图片「按坐标键跨数据集读」+「按 object_key 判重」,都不看数据集 ⇒ 用例之间会互相污染,两处都要隔离:
- 盘上文件:共用 media 目录里任何一张「坐标相同」的图都会被算成该地点的图 ⇒ 实测「应当 2 张、实际 10 张」(上一轮跑剩的文件全在那张坐标上)。
- 库里的行:上一轮留下的
object_key会让新文件被当成「已经在库里」直接跳过 ⇒ 实测Already:1,兜底分支根本没被执行到,而用例只看「挂回 1 张」差点就放过(假绿)。 做法:isolateImageState(t, f, lat, lon)—— 换空DataDir(t.TempDir())+ 按place_key清历史行(前置清一次、t.Cleanup再清一次)。改名用的文件名再带UnixNano后缀避免撞键。 夹具newMovementFixture也补了收尾:显式DELETE FROM place_images WHERE dataset_id=$1+ 删 media 子目录 (不再级联,必须自己清),注册得最晚 ⇒ 在删用户之前跑。
用例 internal/web/image_recovery_test.go(4 条)
行没了文件还在 ⇒ 按坐标挂回(含预演不写库、幂等、挂回后页面口径 placeImagesOf 看得见)、
早期键布局与 parseImageKey 的表驱动、数据集已删 ⇒ 靠底座库认回(并在报告里说明来源)、
删数据集不再带走图片行与文件。
三十四补记:对象存储(R2)侧 + 专题也一起改了(2026-09-30 23:20)
R2 上的图怎么救:行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
internal/media 加了 ListObjects(ctx, prefix)(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 请求形状是路径式、只到桶:
GET <endpoint>/<bucket>?list-type=2&prefix=…, 查询串必须先百分号编码再签名(canonicalQuery按原样取RawQuery,两边得是同一份字节, prefix 里的/要写成%2F)。翻页靠continuation-token。 - ⚠️ R2 的 API Token 默认只给对象读写,没有
s3:ListBucket就列不出来(403 AccessDenied)。 列桶失败不能让整个恢复失败:本地那半照救,原因作为report.Warnings带出去, 页面上用红色提示条显示(否则站长会以为恢复完了)。 - 候选对象 = 本地目录遍历 ∪ 桶里
.webp,同键以本地为准;行的storage按「对象实际在哪」写,bytes用桶里报的 Size。⚠️ 挂回了却没配s3_public_base⇒ 行有了、页面一片空白 (placeImagesOf会跳过取不到址的行)——这个比「没恢复」更难懂,也要写进 warning。
专题也改成归人不归数据集(用户拍板「一并做」):
topics.dataset_id同样带 CASCADE ⇒ 删数据集带走专题,而专题只存在这张表里,没有恢复来源。- 改法:外键摘掉(
ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey), 所有查询从dataset_id换成user_id(loadTopics、topicPage、保存/删除/开关、pageBase.HasTopics)。 专题内容仍照当前生效数据集算(topicContent的 dataset_id 不动)——变的是「这份定义归谁」。 - 附带:
Dataset补了UserID(datasetFor多查一列),前台与后台都能直接按属主定位用户级数据 (专题/备注/结论都是这一类);topicMutate不再要求有生效数据集。 - 用例盯住迁移本身:直接查
pg_constraint里topics_dataset_id_fkey必须不存在 (只看「删数据集没删专题」可能被别的原因弄绿)。
不影响导出:这轮动的 place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出
(导出只读 datasets/places/visits/movements/weather/entity_raw)。
⚠️ 三十四再补:坐标来源不能「第一个能读的就算了」(2026-09-30 23:50,用户实测)
第一版上线后用戶反馈:扫描 40 个对象,只挂回 7 张,认不出 33 张, 报错是「数据集 #1 里没有编号 389 的地点」——即来源能读出来,但不是上传那会儿的编号空间。
根因:srcPKCoords 是「第一个能读到的来源就返回」,而 换基线会覆盖 baselines/<id>.sqlite,
当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 .bak 留档里。
改成三件事:
- 所有来源全部读进来合并(先读的优先):库里
places→entity_raw里 ZLOCATION 的整行留档 (站点上删掉的地点,places 没了、留档可能还在)→baselines/<id>.sqlite及其每一份.bak-*。 读不动的文件跳过换下一个,不再「第一份成功就返回」。 - 同一个编号保留多组候选坐标,逐个试到能在当前数据集里对上为止: 站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出。
- 认不出要能排查:
unresolvedImage带上Dataset / SrcPK / Note,理由里写明 「已查来源:库里 places / 原始行留档 / 155.sqlite / …」与试过的坐标; 提示条按数据集归并上报(「数据集 #1:31 张(编号 389、390…),已查来源 …」), 不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。
教训(可复用):「能读出数据」不等于「读对了那一版」。凡是从「可能被覆盖过的留档」里还原编号/坐标, 都要把历史版本一起纳入,并且在失败信息里列出已查过的来源 —— 否则用户只能看到「认不出」,无从下手。
三十五、防复发:归属口径做成门禁(2026-10-01,用户「我只想避免类似的事件再次发生」)
用户放弃继续救那批旧图,要求的是结构性防复发。所以把这次的教训固化成三件东西:
-
归属铁律(写进 schema.sql 抬头 + README) 判断标准只有一句:这份记录能不能从备份重新导入?
- 能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可以挂
REFERENCES datasets(id) ON DELETE CASCADE,删数据集时跟着走是对的。 - 不能(place_images/place_notes/place_votes/topics 等站点自建数据)⇒ 只留 dataset_id 当出处、不许挂外键。挂在备份里没有的记录上,删数据集=永久丢失。
- 能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可以挂
-
两道门禁(
internal/web/dataset_ownership_test.go)- 运行时
TestOnlyBackupDerivedTablesCascadeFromDatasets:查pg_constraint, 凡挂datasets的 FK 其表名必须在白名单里;反向也查(白名单里的表不许被顺手摘掉, 否则删数据集会留成片孤儿行、或外键变 RESTRICT 之后干脆删不动)。 - 静态
TestSchemaDeclaresNoDatasetFKForUserData:直接扫schema.sql的CREATE TABLE。 只看运行时有盲点——新建的库走 CREATE TABLE,开发库可能压根没跑过那次迁移。 - 新加一张带 dataset_id 的表时,写 CREATE TABLE 的那一刻就会被拦住。
- 运行时
-
体检常驻(真正的预防):
imageHealth每次进后台都算 「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 > 0 直接在首页报警。 那次事故的本质不是「删数据集」本身,而是行与对象悄悄对不上、没人发现, 等照片看不见了才追——让它当场可见,同样的漂移下次立刻亮 (TestAdminHomeReportsOrphanImages守着这个提示)。 只扫本地盘(毫秒级);对象存储要列一次桶,放在「扫描并恢复」动作里,不在每次进后台的开销里。
顺带订正:后台「删除数据集」的二次确认文案在专题改完之后已经说过时话(说专题会一起删),改成实话。
三十五补记:变异验证又抓出一个真缺口(2026-10-01)
给两道门禁做变异验证时(按项目纪律,新回归测试必须证明它会响):
- 静态门禁:把
topics的 CASCADE 外键补回去 ⇒ 立刻红,还原后绿 ✓ (顺带发现扫描必须先跳过--注释行:规矩的说明文字里就写着REFERENCES datasets(id), 不跳过会把自己写的文档当成违规误报。) - 运行时门禁:手动给
topics加上外键后没响——因为夹具每次都跑db.Migrate, 迁移自己就把它摘了。⇒ 运行时判据单独看不够:它永远不会在不健康的库上报警。 真正的自愈靠迁移,于是补了TestMigrationDropsStaleDatasetForeignKeys: 把事故原样的外键注入回去再跑一次迁移,断言它们没了(并先确认变异真的生效,防假绿)。
而这条自愈用例又抓出一个此前漏掉的缺口:place_notes / place_votes 上的同类外键
(更早那次改坐标键时摘的,老库/生产库上未必摘干净)迁移并不会摘——
原来只写了 place_images 与 topics 两句 DROP。已补成四张自建数据表全覆盖
(含 place_visitor_votes 兜底)。部署出去的库要能自愈,DROP 必须覆盖全部同类表,不是只覆盖这次动过的那两张。
三十六、统计页:站长结论与访客点赞拆成两块(2026-10-01)
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?我觉得应该分开,用户的点赞点踩单独做一块」。
查下来他看到的没错:/stats 那张卡列表全是站长结论(verdictPlaces = place_votes),
访客票只在副标题里露了个总数(👍 N · 👎 M)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。
改动(internal/votes + internal/web + templates/stats.html):
- 新增
votes.TopVisitorVoted(ctx, datasetID, vote, limit):按访客票数列地点榜。HAVING count(*) FILTER (WHERE vv.vote=$2) > 0—— 只算至少有一票的那个方向, 否则 0 票的地点会把榜塞满;同票数按visit_count DESC, p.id排,保证刷新之间顺序不跳。 票按坐标键挂,所以要 join 回当前数据集的地点才定位得到(与Tallies/verdictPlaces同一口径)。 - 「推荐 & 踩雷(我的结论)」:只讲站长,副标题只留
推荐 N · 踩雷 M,标题与列表都改成「我」的口吻。 - 新增「访客点赞」:副标题总票数(复用
voteTotals的VoteUp/VoteDown),两列分别列 👍/👎 票最多的地点(带票数),空态「还没有访客点赞 / 点踩」。 - 加载拆成两个 pageJob(
verdicts/visitorvotes):两者互不依赖,串在一个任务里只会白等。 - 两块共用同一个可见性开关(
IsAdmin || Settings.MarksPublic),与地点页的投票区一致。
⚠️ 用例隔离(又一次同一个坑):两张票表都按坐标键读,与数据集无关,
place_visitor_votes 还没有 user_id、不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。
新增 isolateVoteState(按 place_key 清两张表,前置 + t.Cleanup 各一次)。
断言手法:按卡片标题切出两段 HTML(htmlSection),再断言「A 只在站长块、B 只在访客块」——
比数总数更能证明两块真的没串台。
顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。
三十七、措辞要分「站长视角」与「访客视角」(2026-10-01)
用户发现:未登录也显示「推荐 & 踩雷(我的结论)」,访客没法解读「谁的我的」。
顺着做了一次系统排查(grep "[我你咱]" 扫访客可见模板 + static/js),
发现同一个毛病还有三处,都是写模板时站在站长视角下笔、而那一块对访客公开:
| 位置 | 原文 | 改成 |
|---|---|---|
stats.html 标题/副标题/两列小标题 |
我的结论 / 只统计你自己… / 我推荐的地点 / 我踩雷的地点 | {{if .IsAdmin}}我的…{{else}}站长…{{end}}(按身份分支) |
stats.html 访客块副标题 |
与「我的结论」互不影响:你标你的,访客投访客的 | 「与上面的站长结论互不影响:各存各的表,谁也不会覆盖谁」 |
home.html 标签卡标题 |
我的标签(同级卡片都是中性的:足迹地图/城市足迹/天气足迹…) | 足迹标签 |
report.html |
这一年里,你第一次踏足的这些城市(年报是站长的回顾) | 这一年里第一次踏足的这些城市 |
已经写对的(照它学):place.html 的投票区早就是 {{if .IsAdmin}} 分支
(站长「我的结论」/ 访客「大家怎么说」),rond-map.js 的气泡同理;
layout.html/map.html 的「含家/工作」chip 与一串「只有你自己能看到」都在 {{if .IsAdmin}} 里,没问题。
门禁用例 TestVisitorPagesUseThirdPersonCopy:以访客身份(context.Background(),ctx 里没有 user)
取回 /stats、/、/report,逐个禁用词断言(visitorCopyForbidden),
同时断言站长那份仍旧说「我的」(别把站长自己的视角也改成第三人称)。
前置要把 settings.site.marks_public 打开(访客才看得到那一块),用完还原。
⚠️ 变异验证踩的坑(老坑,载入史册):直接 go test -run … 会因为缺 ROND_TEST_CONF/ROND_SRC
静默 skip 并报 ok —— 我第一次做变异就是这么「假绿」的(0.17s 就 ok,明显不对)。
变异验证必须走 ./scripts/verify.sh --pkg … --run …(它会拦住 SKIP)。
三十八、时间框(flatpickr allowInput)的手输体验(2026-10-08)
症状:手输 2026/6/6 时,年份位要打到 202666 才进月份位。
根因(实测 flatpickr 4.6.13,两条):
- flatpickr 把
<input type=datetime-local>就地改成type="text"(altInput默认 false、不新建节点、sameNode=true)⇒ 原生分段编辑器被替换掉。 allowInput是整串解析语义,不是逐段消费。实测inst.parseDate:2026→2026-1月、20266→同上、202666→2026-6月(就是用户看到的)、2026666→2031-6月、20260606→2030-12月(静默错解不报错)。
落地:internal/web/static/js/datetime-input.js(纯逻辑,无依赖,附 .test.js
34 条断言 node 直跑)。三处页面共用 rondDatetime.attachPicker(el),
脚本挂在 layout.html 的 {{if .Flatpickr}} 块内、flatpickr 之后。
- 严格标准格式(用户拍板):年 4 位、月/日/时/分各 2 位,不自动补零、不前滚。
- 段满即补分隔符 ⇒ 敲完第 4 位年份立刻
2026-且光标跳月份位。 - 解析器纯数字只收 4/6/8/12/14 位(5/7/9/10/11 位拒:说不清是「日=30」还是「时06分30」)。
- 越界(月 66、日 45)原样保留交解析器报错,绝不静默改值。
三个必踩的实现坑
- 挪光标必须「同步 + 微任务」两次。flatpickr 自己监听
input/setDate, 会在我们之后改selectionStart;只同步设一次会被它抢走 ⇒ 下次按键插到分隔符 前面(实测退化成2026-6,月份位错位一格)。 - 逐位取数必须跳过分隔符。框里是
2026-0时数字是20260不是2020; 直接replace(/[-T:]/g,'')会让整体位次错一格。 - 改
el.value就必须同步_rondLaidOut,否则下一轮拿旧串去排,越排越乱; reject 分支则必须 return 且一个字符都不动。
CDP 验输入行为时的坑
- 派发字符必须
keyDown带text/unmodifiedText,否则 Chrome 不执行默认动作。 - 挂载后 input 已改成
type=text⇒querySelectorAll('input[type=datetime-local]')一律数到 0,要用input.flatpickr-input。 - 自定义
parseDate返回false时 flatpickr 走errorHandler("Invalid date provided: …")并清空selectedDates, 所以「被拒绝」在页面上表现为框被清空,不是静默不动。 - 折叠卡片 / 未开
details里的输入框量到 0 个 —— 先展开再量。
本机环境(验后台页必须自己签 cookie)
site_cdp.py 的会话签名算法已过期(还在用「口令摘要前 16 位」)。
现行 internal/auth/auth.go 是 hmac(secret, payload+"\x00"+**完整**password_hash),
且 secret 是裸字符串不是 hex 解码(server.go:118:[]byte(cfg.SecretKey))。
过期脚本签的 cookie 一律 303 到登录页。另:Git Bash 传 --base http://…
会被吃掉 //(报 Cannot navigate to invalid URL),须加 MSYS_NO_PATHCONV=1。
三十九、页面弹窗:不用 window.prompt(2026-10-08)
症状:window.prompt 会把提示文本与默认值写进地址栏
(Chrome 拼成 当前页?新的活动名称=吃饭 并留进历史)——刷新即丢、分享带原文。
confirm / alert 没有这个毛病,只有 prompt 有。
替换物:static/js/prompt-box.js 的 rondPrompt.ask(title, value, {required, readonly})
⇒ Promise<string|null>。用原生 <dialog>.showModal():居中、带 ::backdrop 遮罩、
Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器降级成页面顶部一条
(.prompt-fallback)。样式在 app.css 的 dialog.prompt-box / .prompt-fallback。
已换的两处:report.html 复制链接的兜底(readonly: true,让用户全选 Ctrl+C)、
admin/edit.html 活动改名(required: true,空值视作取消;监听器改成 async)。
脚本在 layout.html 无条件引入(不挂 .Flatpickr 条件,报告页也要用)。
门禁 internal/web/no_prompt_test.go:静态扫全部模板禁止 prompt( 调用
(注释与字符串先 strip 掉,否则注释里写「为什么不用 prompt」会误判);
另一条钉住 prompt-box.js 与 CSS 都在(否则模板能过、点击时才 TypeError)。
变异验证:把 report.html 改回 window.prompt ⇒ FAIL,命中 2 处。
验这个的坑(无头 Chrome)
⚠️ window.prompt 与 showModal() 打开的模态 dialog 都会阻塞 CDP 求值:
在同一个 Runtime.evaluate 里既触发弹窗又读值必然 WebSocketTimeoutException;
Runtime.evaluate 里 .click() 触发 prompt 同理卡死。
可行做法:用 Input.dispatchMouseEvent 派发真实点击(不占 CDP 通道),
或「先触发 → time.sleep → 截图 → 用 Input 派 Esc 关闭 → 再求值」。
(Page.addScriptToEvaluateOnNewDocument 传空 source 也会把 CDP 卡住,别用。)
移动端 390×844 实测:弹层宽 356、按钮高 44(触控下限)、输入框 16px(防 iOS 聚焦缩放)、 横向溢出 0。输入框字号必须 ≥16px,这条已在 CSS 里注明原因。
四十、统计脚本做成后台开关(2026-10-08)
原来只能改 conf/app.ini 的 track_script(要重启),现在后台「站点设置」可开关+ 填写。
形状:Settings.TrackEnabled bool + Settings.TrackScript string(JSON 存settings 表)。
默认关闭(统计脚本会把访问信息发到第三方,不该默认开)。
pageBase 里:开关开 → 用后台填的内容,为空则回落到 ini 的 track_script
(保留「改 ini 就能挂上」的旧用法,但开关始终是唯一总闸);
开关关 → p.TrackScript 为空,layout 的 {{if and .TrackScript …}} 自然不输出。
后台页与登录页仍由 ne .Nav "admin" 挡着(已实测)。
⚠️ 加 setting 字段要改三处:Default()(显式写出默认值,别指望零值恰好对)、
Load() 的逐项覆盖(Save() 用整体 JSON,新增字段自动持久化)、
settingsSubmit() 的表单读取。Load() 里布尔项一律用 present[key] 判:
历史配置没有这个键时若直接套零值,语义会悄悄反转。
cleanTrackScript 的判据(这里踩过两次,2026-10-09 重写)
⚠️ 不能用「出现 </script 就拒」当规则 —— 百度统计、Umami 贴进来的就是完整
HTML,一行里必然带 </script>,一拒就把正常用法挡死了(我第一版就是这么写的,
被自己的测试当场抓住)。
现行判据两条:
- 开闭
<script>标签必须严格配平(open != shut就拒)。少一个</script>会提前闭合、后面内容当正文渲染;多一个<script>会把页面剩下的收尾整段吞进脚本。 ⚠️ 只拒shut > open是不够的(初版就是),末尾未闭合的开标签会被放行 ——<script>x</script><script>y实测通过,已改成严格配平。 src=只在<script …>开标签内部找,只放行http(s)://与/开头的相对路径。 ⚠️ 初版对整段做strings.Index(low, "src="),脚本体里一句img.src=u就被命中、 整段被判危险而清空 —— 而页面照样提示「设置已保存」,站长完全看不出脚本没挂上。 实测:<script>var i=new Image();i.src=u;</script>旧版返回"",现在原样放行。 超 4096 截断。
收尾要显式:函数签名改成 (script, warn string),说明码
(truncated / unbalanced / badsrc)经 ?warn= 跳到设置页、由 trackWarnMessage
翻成人话显示。用固定码而不是回显查询串原文(避免反射内容)。
判为危险时整段不采纳、库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
门禁 TestCleanTrackScript 逐条钉边界 + 定义性不变量:
采纳了非空结果时,里面的开闭标签数必定相等(不是「不得含 </script」——整段贴进来的
本来就带闭合标签)。
四十一、2026-10-09 三处修复:导航口径 / 启动自检 / 二进制只留根目录
1. 导航的页面显隐只管访客(layout.html)
guardPage(server.go)拦的只是访客:userFrom(ctx)==nil && !sets.HasPage(nav) → 404;
站长始终能访问全部公开页。但导航写的是 {{if .Settings.HasPage "map"}},没有 IsAdmin 分支
⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
已改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
门禁 nav_visibility_test.go:直接执行 nav 模板,钉「关掉开关后站长仍有 5 个入口、
访客一个都没有、全开放时访客也看得到」三向不变量(做过变异验证)。
⚠️ 访客侧「只有主页」是配置出来的预期状态,别去"修"数据:库里 settings.pages = null
(= 站长把公开页面全关了),访客 /map /places /timeline /stats /report 一律 404。
2026-10-09 用户明确确认:访客能看哪些页就是按后台配的范围公开,这样才对。
要改就改后台的「公开页面」开关,不要直接动 settings 行。
2. 启动自检 smokeRender(template_smoke.go)
⚠️ 「模板能解析」不等于「渲染得完」:字段不存在、nil 解引用都只在执行期暴露,
而 render 只把错误写进日志 ⇒ 线上是「HTTP 200 + 渲染到一半的半张页面」。
2026-10-09 的 /admin/settings 截断就是这么坏的(提交里同时改了 setting.Settings 和
settings.html,跑的是改动前编译的二进制,debug 下模板走磁盘热加载 → 新模板 + 旧结构体)。
做法:pageProbe(每个页面一份「最小可用数据」)+ smokeRender 在 New() 里把
所有页面各渲染一遍,任一失败就 New 返回 error → main log.Fatalf 拒绝启动。
pageProbe的键必须与pageFiles双向一致:缺项按失败处理(新加页面必须补探针)。- 探针值只求「类型对、不炸」——页面本来就得能渲染空数据。
smokeOne里recover()兜住方法里的 panic,报出「哪一页坏了」而不是崩栈。- 门禁
template_smoke_test.go:一个跑全量自检;一个变异验证(把admin/settings的探针换成字段更少的Page,必须报错且点名该页)。 - 实测:往
settings.html插一个不存在的字段 → 启动即报初始化服务失败: 模板自检未通过…admin/settings: …can't evaluate field …,退出码 1。
3. 二进制只留在仓库根(bin/ 已删,2026-10-09)
go build -o rond.exe . 一个位置。bin/ 整套(15 个 exe、412MB)已删;
.gitignore 的 /bin 保留作为兜底。scripts/build-linux.sh 照旧只产
dist/rond-linux-amd64。相关技能(rond-web-verify)与 details 十八已同步改掉
bin/rond.exe 的写法。
四十二、前端设计令牌(A/B 批次,2026-10-09)
完整方案与实测数据见仓库根 frontend-plan-2026-10-09.md。这里只记以后改前端必须守的规矩。
字阶 / 圆角 / 阴影一律走令牌,别写 px
- 文本 6 档:
--fs-xs/sm/md/lg/xl/2xl= 11 / 13 / 15 / 21 / 26 / 34px。 --fs-control: 16px:窄屏表单控件必须 ≥16px(iOS 聚焦 <16px 会把整页放大)。这是功能约束, 所以它独立于字阶;窄屏媒体查询里用--field-fs覆盖。别把它并进字阶。--glyph-*6 档:✕ / 箭头 / 空态图标 / 表情的字号。字形不是文字层级,按控件尺寸定; 硬塞进字阶(比如把 22px 的 ✕ 提到 26px)只会把按钮撑坏。- 圆角
--r-xs/sm/md/lg= 3 / 6 / 8 / 12px。50%与999px是形状(圆 / 胶囊),不算圆角档。 - 阴影
--sh-1/2/3:⚠️ 暗色模式必须整套覆盖(原来只覆盖一个--shadow,拆档后漏一个就会 在暗色下用错档)。 - 间距
--sp-1..5本批只迁移了卡片内边距这类主节奏,其余没做全量替换。 --card-pad与折叠卡片标题行的负 margin 绑在一起(calc(var(--card-pad) * -1)),改一个即改两处。
门禁 TestNoBareFontSizePx:静态扫 app.css 与全部模板,出现 font-size: <数字>px 就红。
卡片三档
.card--hero(首屏地图 / 主数区)/ .card(默认)/ .card--quiet(只读说明)。
⚠️ quiet 别拿 --bg-2 当底色:它与页面底 --bg(#f6f7f8) 只差 4/255,实测看不出边界,
卡片会像「破了个洞」而不是「轻了一档」。要「页面底色 + 保留细边 + 无阴影」。
首屏主数用 .kpi-lead(不是 .kpi):主数 + 一行上下文 + 左侧 3px 主色条。
⚠️ 改根字号会带动 vendor 库与「没设字号的默认标签」
html, body 的 font-size 一改,至少三处会跟着漂:
- Leaflet:vendor 里
.leaflet-container { font-size: 0.75rem }是相对根字号的, 根从 14 → 13px 后地图署名/比例尺掉到 9.8px、弹窗正文也跟着缩。 app.css 里已钉死(容器--fs-sm、署名/比例尺--fs-xs)。 h1..h4:app.css 原来只给它们设了字重与 margin、没设字号,于是靠浏览器默认的 2em / 1.5em / 1.17em —— 同样跟着根字号漂。已显式给出 h1/h2/h3。<b> / <strong>:浏览器默认 bold(700),比全站最高层级(标题 600)还重。已钉成 600。
判据:量 DOM 的 (字号,字重) 组合。只看截图不会发现这三处(页面「看着正常」, 只是有点不齐)。其它 rem/em 的 vendor 样式同理要查。
四十三、图表 / 类型色 / 窄屏导航(C、D 批次,2026-10-09)
图表(手写 .cols / .bars,不引图表库)
.cols有基线(border-bottom)与padding-top: 16px—— 后者是给峰值柱顶上那个数字(.pv)留的位置, 改高度时别把 padding 弄丢(height走 border-box,内容区才是柱子的 100% 基准)。- 峰值柱:模板里
{{if eq .Count $.MaxMonthly}} peak{{end}},柱顶标次数;副标题里就不再重复写「最高 N 次」。 .cols-band是「6 小时一段」的标尺,必须用 24 列 grid(不是flex: 6),否则段边界与柱子对不齐。 它取代了原来那行只有一个数字的坐标轴。- 首页的月度图峰值靠
HomeData.MaxMonthly(与统计页同口径maxOf),别只改一边。
类型色:只在 CSS 变量里用,别当文字色
- 模板用
csscolor/csssoft(colorof已删)。 ⚠️ 这两个函数必须返回template.CSS:html/template的 CSS 过滤器不认识var(), 返回普通 string 会被换成ZgotmplZ—— 页面上所有圆点当场变透明,而且不报错、模板测试照过(踩过)。 守卫TestCSSColorSurvivesTemplateSanitizer。 - 但挂了
template.CSS就等于关掉那道过滤,所以数据侧色值必须先过严格校验(只有#+ 6 位十六进制放行), 否则#"><img…能直接逃出 style 属性。 - ⚠️ 这套色连图形对象 3:1 都到不了:白底上 yellow 1.87、mint 2.12、orange 2.20、green 2.22、 cyan 2.54、teal 2.57。所以它们只能当身份标记(圆点 / 柱条),绝不能当文字色或承载唯一信息的图形。 实测过、别再被「做成彩色标签更好看」带走。
- 浅色那 6 个色不要改暗:它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 地图那份调色板(
rond-map.js的APPLE)故意独立:瓦片在暗色下仍是浅色,标记要用浅色底那套值。 - 暗色只提亮了
--cat-indigo(2.90 → 3.55:1),其余一律不动。
窄屏导航(≤620px)
- 链接本体是
{{define "navitems"}},渲染两遍:桌面.nav-links平铺、窄屏.nav-menu(原生<details>, 不靠 JS 也能开合;JS 只补点外面 / Esc / 点链接后收起)。 守卫TestNarrowNavCollapsesToMenu钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。 - ⚠️ 顶栏
--z-nav: 1200,必须高于--z-map-overlay(1100):菜单是.nav的子元素、 自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(/map上用elementFromPoint实测验证过)。
四十四、日期控件:原生 type=date 也不能用(2026-10-09)
⚠️ <input type="date"> 与 datetime-local 一样不可靠。 Chrome 的年段允许超过 4 位数字
(年份上限 275760),所以敲 20260606 会被切成 年=202606 + 月=06 —— zh-CN 下显示 202606/06,
而且 el.value 是空(非法)⇒ 筛选条件静默丢掉。原生分段状态读不到、
setSelectionRange 对 date 类输入直接抛,所以「修原生」这条路不通,只能换控件。
规矩:
- 日期字段一律走
rondDatetime.attachAll(root)(在datetime-input.js里):它按el.type自动分派只日期(输出Y-m-d)或带时间(输出Y-m-dTH:i)。 ⚠️ 不要手写querySelector('input[type=...]')—— 2026-10-09 的 bug 就是 「只挂了 datetime-local,后来加的 type=date 没人管」。 - flatpickr 与
datetime-input.js都无条件加载(js+css+l10n 约 18KB gzip): 筛选区在公共页上也有日期框,而且「按页开开关」已经被证明会漏,所以干脆不设开关 (Page.Flatpickr字段也删了)。用户明确接受这点资源增量(手机上有日历弹层比省这 18KB 重要)。 挂载要在 body 末尾立刻跑一次,别只等DOMContentLoaded,否则先露出原生框再变文本框。 - 门禁
TestDateInputsGoThroughAttachAll:模板里出现input[type=date]/input[type=datetime-local]这种选择器即红;并钉住「layout 无条件引入 flatpickr + datetime-input + attachAll」、以及「不许再出现 {{if .Flatpickr}} 这种开关」。 - JS 单测:
layoutTo(spec, raw)是纯函数,逐键模拟敲20260606必须得2026-06-06(datetime-input.test.js,node 直跑,45 条)。
同批修的两处观感问题(都属于「只有量才看得见」):
.grid必须align-items: start。grid 默认stretch,同一行里内容少的那张卡会被拉到与最高的 一样高,卡片内部留一大片空白(/stats 的「类型分布」1411px vs「一天中的分布」256px)。.temp-strip的柱子窄屏固定 36px + 横滚;宽屏要flex: 1 1 36px; max-width: 96px并把轨道加高。 原来固定 36×84,10 个月只占 1042px 里的 396px,右边 600 多像素全空 —— 看着就是「图表很小」。
日历弹层的两处坑(2026-10-10)
- 中文文案要在共享入口设一次:以前是各页
whenReady里自己调flatpickr.localize(l10ns.zh), 公共页开始加载 flatpickr 之后就漏了 —— 日历是英文的(Sun/Mon/January…),跟中文界面明显割裂。 现在放在attachPicker里(localizeOnce(),全局只调一次)。 - ⚠️ 站点全局的
input/select样式会扫进日历内部:那条规则带width:100%、内边距与边框, 落到日历里就是「月份下拉被撑满整行、压在年份输入上」(实测重叠成一团),时间格也变成方框输入框。 修法是在全局规则里排除input.numInput与select.flatpickr-monthDropdown-months(实测日历内部只有这 4 个控件:select.flatpickr-monthDropdown-months+input.numInput.cur-year/.flatpickr-hour/.flatpickr-minute)。 ⚠️ 不要试图在.flatpickr-calendar input{...}里对冲 —— 那条全局选择器带 10 个:not([type=…]), 特异性 (0,10,1),普通对冲压不过(实测年份框仍是站点样式)。input:focus的焦点环同理要一起排除,否则年份框上会套一圈主色光圈。 - 体量差太多的卡片别并排:类型分布(60 多行)与一天中的分布(24 根柱)并排时,右侧下面必然
一大片空白 ——
align-items: start只能消掉卡内的空白,消不掉并排本身的空。各占整行才对 (列表横铺、24 根柱也终于有宽度)。align-items: start仍然保留:其它同行卡片也会体量不等。
四十五、专题的迷雾按区域收口(2026-10-10)
现象:用户建了专题问「迷雾好像无法根据选择显示范围?」。查下来是实现缺口,不是配置问题 ——
迷雾图层一直是全局的,专题的区域(中心+半径)只用来 fitCircle 定视图,跟迷雾完全无关。
管理页那句「区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选」纯属文档与实现不符,
把「没做」写成了「不用做」。用户选的口径:按专题区域收口(不按时间收口)。
实现(rond-map.js + topic.html):opts.fogArea = {lat, lon, radiusKm} 由 topic.html
在 .Topic.HasArea 时下发;脚本给迷雾单独建一个 pane 再对该 pane 做 clip-path: circle()。
⚠️ 三个必踩的坑:
-
不能裁默认
tilePane—— 底图瓦片也在那里面,裁下去地图跟着一起被挖空。所以map.createPane('rondFog')(zIndex=250:底图 200 之上、覆盖物 400 之下,pointerEvents='none'), 迷雾 tileLayer 走pane: fogPane ? 'rondFog' : 'tilePane'。 -
圆心必须
project()到底图坐标系(跟fitCircle一样),直接拿经纬度当容器坐标会整体偏出几百米。 半径用「同经度上偏移 radiusKm/111320 纬度」再量容器像素距离,随缩放自然变化。 -
🔴 注册点必须排在
applyFit()之后。latLngToContainerPoint()在视图未定时会抛 「Set map center and zoom first.」—— 实测放早了:pane 建好了、迷雾瓦片一张都不出来 (那行异常把后面所有图层添加一起打断,且不报错给用户,页面看着只是「迷雾没了」)。 正确写法:applyFit(); if (applyFogClip) { map.on('move zoom viewreset resize', applyFogClip); applyFogClip(); } -
迷雾是累积快照、没有时间维度 ⇒ 只按区域收口,不按专题的时间范围收口(管理页文案已写明)。
-
老浏览器不认
clip-path只是「不裁剪」(退回全局迷雾),不会报错。 -
门禁
TestTopicFogIsClippedToArea:钉住两端 ——topic.html得发fogArea:、rond-map.js得认opts.fogArea/createPane('rondFog')/clipPath,且注册点下标 >var applyFit = function的下标 (防的就是上面那个「放早了整片迷雾消失」)。 -
实测:临时专题(中心 24.698/108.075、半径 3km)→
clip = circle(173px at 529px 260px), 反解半径正好 3000 m,setZoom(9)后仍正确;/map、/stats不受影响(它们没有fogArea)。 -
⚠️ 用户自己的专题
id=15「2026年国庆」radius_km=0⇒ 收口不生效,要在后台把中心与半径填上。
怎么证「裁剪真的生效」(2026-10-10 实测方法)
clip-path 读得到 ≠ 画出来生效,而且目测会骗人:截图上那片「灰色」可能是底图不是迷雾。
可靠判据是一组像素对比,四张图同一个视图、同一个(临时)专题:
| 图 | 迷雾瓦片 | 裁剪 |
|---|---|---|
| A | 显示 | 有 |
| B | 显示 | 无(pane.style.clipPath='none') |
| D | 隐藏(img.style.display='none') |
有 |
- 迷雾掩码 =
|A−D| > 阈值(裁剪态实际画出来的迷雾)、|B−D|(无裁剪态)。 - 结论(半径 1km 那张,阈值 60):裁剪态 83,185 个迷雾像素、圆外 0 个(最远半径 462.4 / 圆 462.1); 无裁剪态 222,510 个、62.3% 溢出圆外(最远 1,117.8)。⇒ 裁剪精准,边界正好落位。
- 想知道裁剪圆到底画在哪,别用红色探针(底图有红路/红 POI 会污染掩码)——
在 pane 里塞一个
background:#ff00ff的巨型子元素(子元素一并被裁剪),品红掩码即裁剪区域,填满率=π r² 反解能同时校出圆心与半径。实测圆心/半径与 CSS 标称差 < 1px。 - 🔴 图像坐标不能用元素坐标直接换算:
site_cdp.py --clip会把裁切框每边外扩pad=10CSS px (×--scale),图像原点在元素左上角左上方 20px。差这一步会得到「圆整体偏 (18.5,19)px」的 假 bug,还会把边界抗锯齿当成「圆外有 1830 个迷雾像素」。正确做法:在同一张图里量一个已知参照定标 (品红探针就是为此)。已写进技能rond-web-verify的「四个必踩的坑」第 4 条。
四十六、专题显示区:一组圆 + 地图点选(2026-10-12)
语义变了:专题的「区域」从一个圆变成一组圆(并集——落在任一圆内即算在区域内)。 起因是用户提的两点:不该只有一个圆;应该在地图上点选而不是手填经纬度和半径。 (原管理页那句「不需要单独圈选」是文档与实现不一致,见四十五。)
存储:topics.areas JSONB,不单开表
areas 是 [{lat, lon, radiusKm}, ...],坐标 GCJ-02(地点过滤是拿它跟
places.lat/lon 直接比球面距离的)。
为什么用 jsonb 而不是一张 topic_areas 表:这些圆是专题定义的组成部分、与专题一起
读写、没有独立生命周期;单开表还要过一遍「能不能从备份重导」的归属口径、改
dataset_ownership_test.go 的表白名单、注意迁移块必须排在建表之后 —— jsonb 一次全绕开。
- 存量回填在 schema.sql 里:
UPDATE topics SET areas = jsonb_build_array(...) WHERE radius_km > 0 AND areas = '[]'。 - ⚠️
center_lat/center_lon/radius_km已废弃但保留,写入时镜像第 1 个圆:回滚旧二进制时 退化成「第一个圆」总比「没有区域」强。判据是三列的注释,别当成还有用的列。 - ⚠️ 出库值必须是
[]不是null:列上有 NOT NULL,而json.Marshal(nil slice)恰好给"null"。用topicAreasColumn()收敛这一处。 Topic.AreaList()做归一:Areas为空时退回老的三列 ⇒ 未回填的老行与测试里直接构造 Topic 的写法都继续工作(topic_stats_test.go就是这么写的)。HasArea()/AreasJSON()都走它,保证「HasArea 为真」与「脚本拿得到圆」永远是同一份判据。
查询:N 个圆的 OR
topicAreaCond 每个圆占 3 个占位符(纬度、经度、半径),条件用 AND (... OR ...)。
⚠️ 占位符纪律照旧(写错就是「专题内容全变 0」那种难查的事故)。判据写成定义性不变量:
本段引用的 $n 必须恰好是 [prefix+1, len(args)] 这个连续区间,且最大值 == len(args)
(列表页那条 UNION ALL 是靠 len(args) 自己算编号的,错位就靠这个抓)。注意断言的是
「本段自己那 3n 个」,前缀那几个由查询别处引用,不该出现在区域条件里。
贴合:fitCircle 已删除,统一 fitBBox
多圆之后「圆心 + 半径」表达不了,留着 fitCircle 就是两套并行的贴合口径。fitParams
改为返回所有圆的并集外接矩形(topicAreasBounds,高纬处夹一下余弦)——
单圆时外接矩形正是那个圆的外接正方形,与原路径等价。
前台:clip-path: path()(circle() 只表达得了一个圆)
- 每个圆一段圆弧子路径、同绕向(sweep=0) ⇒ nonzero 填充规则下多段自然取并集, 重叠处不会互相挖洞。绕向写反的话重叠区会被抵消成一个洞。
- 拼路径抽成独立文件
static/js/fog-clip.js(rond-map.js是个大 IIFE,函数取不出来, 抽出来才能跑 node 单测);⚠️ layout 里必须排在 rond-map.js 之前加载。 - 给迷雾单独 pane 再裁它(不能裁
tilePane,底图在里面);注册时机仍要在applyFit()之后(视图未定时latLngToContainerPoint()会抛错并把后续图层添加一起打断,见四十五)。 topic.html的fogAreas无条件下发(空数组 = 不限区域),别包在{{if}}里 —— 条件分支是「有区域忘了新增分支」这类漏传的温床。- ⚠️ CSSOM 会把
clip-path规范化:style.clipPath读回来会被补空格 (path("M 405 296 a 87 87 …")),而且空格位置不固定(可能出现296 a87)、 第一个子路径尾部还可能多一个空格。任何「解析回几何」的代码必须每处分隔都用\s*/\s+容忍 —— 写死就等着被咬(本轮连错两版正则)。
前端单测接进 verify.sh
internal/web/static/js/*.test.js 以前只靠人记得手动跑 = 等于没有。现在挂在
「第 1.5 段」,且找不到 node 直接失败而不是跳过 —— 跳过会让验收看着全绿、
实际一行前端脚本都没验。
验证方法:自校准(别再手算元素坐标)
site_cdp.py --clip 会把裁切框每边外扩 pad=10 CSS px(×--scale),所以
「CSS 容器坐标 → 图像像素」不能直接换算。做法是:先拍一张只画一个圆的品红探针图,
用已知圆心/半径反解出 scale 与 offset(实测 2.0 / ~19.2),再用它给别处定标。
两张图反解出的 scale/offset 必须互相吻合,否则说明取景变了、比较无意义。
本轮实测(两圆,重叠):A 独有点只有 A 亮、B 独有点只有 B 亮、重叠中心两圆都亮且 并集也亮(无洞),整体集合差异 0.013%;迷雾掩码 86,776 px 按并集解析式判圆外 0 个, 两个圆内分别 52,609 / 46,757 px ⇒ 第二个圆真的被认了。
后台编辑器:地图点选(2026-10-12 当天补完)
三个手填输入框(中心纬度/经度/半径)换成地图 + 圆列表(static/js/topic-areas.js:
点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/定位/删除/清空)。改动立刻写进
隐藏字段 areas(JSON) 与 crs,随专题表单一起提交。复用 /admin/fog-mask 那套约定
(/api/map-config 取底图、底图坐标系随表单带上、后端换算 GCJ-02)。
地图初始视野取生效数据集的地点包围盒(placesBounds),一上来就落在自己的地界上。
⚠️ 三条容易再踩的:
- 🔴
/admin/topics必须p.HasMap = true(topicsAdminPage)。漏了的表现极隐蔽: 脚本加载了、rondTopicAreas也在,就是window.L永远 undefined,init静默返回、 控制台不报错、页面看着像「什么都没发生」。守卫TestTopicsAdminPageLoadsLeaflet(按函数体截取后断言含HasMap = true)。 - 🔴
areas的初值要渲染进隐藏字段(value="{{.Form.AreasJSON}}"),JS 也从隐藏字段 读初值而不是另传一份:脚本没加载上时提交的仍是服务端渲染的原值,否则一次保存就把 已有显示区静默清空。守卫TestTopicAdminUsesMapEditorNotCoordInputs钉住初值来源、 挂点 id 齐全、以及「三个旧输入框必须没了」(留着就是第二套只写第 1 个圆的入口)。 - 上限两边各写一份(JS
MAX_AREAS/MIN_KM/MAX_KMvs GomaxTopicAreas等), 守卫TestTopicAreaLimitsMatchBetweenGoAndJS直接比对数字;前端还据此禁用提交。 不一致的表现是「前端放行、后端拒绝,用户看到红字却不知道改哪里」。
小坑:半径输入框得套一层定宽容器(.area-km-box)——站点全局 input 规则是
width:100% 且带 10 个 :not()(特异性 (0,11,1)),直接给 input 写宽度压不过。
topic-areas.js 只把纯函数暴露给 node 单测,另加最小测试钩子 api._map
供无头验证(同 window.rondMap._state 惯例)—— 不留把手就只能真拿鼠标点地图。
gofmt / 行尾(同一天的附带结论)
gofmt -l 报「整文件重写」有两层原因:① Go 1.19 起 gofmt 会重排文档注释里的缩进列表
(// ① … 要改成 tab 缩进块);② 工作区 CRLF 而 .gitattributes 声明 eol=lf。
gofmt -w 归一后 git diff 没有额外噪声 ⇒ blob 本来就是 LF,CRLF 只是检出产物,
而不是仓库内容问题(之前担心的「模板 CRLF 冲突」可以放下了)。