用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
79 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 bin/rond.exe .→ 停掉占 8080 的进程 →./bin/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 廿九