# 2026-09-15 ## rond-with-you:修复静态资源被截断(ERR_CONTENT_LENGTH_MISMATCH) ### 现象 浏览器控制台大量报错,页面样式/脚本失效: ``` GET /static/vendor/leaflet/leaflet.css?v=... net::ERR_CONTENT_LENGTH_MISMATCH 206 (Partial Content) GET /static/css/app.css?v=... net::ERR_CONTENT_LENGTH_MISMATCH 206 GET /static/js/rond-map.js?v=... net::ERR_CONTENT_LENGTH_MISMATCH 206 Manifest: Line 1, column 1, Syntax error. Uncaught TypeError: Cannot read properties of undefined (reading 'preview') ← home.html 里 window.rondMap.preview(...) ``` ### 根因(两层,缺一不可) gzip 中间件(internal/web/gzip.go)与 `http.FileServer` 冲突: 1. **206 层**:浏览器对 `immutable` 缓存的静态资源会发 `Range` 请求 → `http.FileServer` 返回 206 并自带 `Content-Length`;gzip 无法在首部提交后再改长度 → 长度不符,文件被截断。 2. **200 层(更隐蔽)**:`http.FileServer`(`serveContent`) 对**任何**请求都是 **先设 `Content-Length`、 再显式调用 `WriteHeader(200/206)`、最后才写 body**。原 `gzipWriter` 没有覆盖 `WriteHeader`, 所以 `WriteHeader(200)` 已经把 `Content-Length: <未压缩长度>` 提交出去;等到 `Write` 里才 `Set("Content-Encoding","gzip")` + `Del("Content-Length")` 全都太晚 → 响应既没有 Content-Encoding、 又声明了未压缩长度,body 却是 gzip → 浏览器按声明长度读不到那么多字节 → 截断。 (动态页面 `s.render`/`s.json` 不显式 WriteHeader、也不设 Content-Length,所以一直是好的—— 这解释了「页面 HTML 正常、静态 CSS/JS 全坏」。之前重编译后 `?v=` 哈希变化强制重新拉取才暴露。) ### 修复(internal/web/gzip.go) - **给 `gzipWriter` 增加 `WriteHeader` 覆盖**:在首部提交*之前*决定压不压。 只有 `200` 且 `compressible(Content-Type)` 才设 `Content-Encoding: gzip` + `Del Content-Length`; `206/304/404/500` 等一律原样透传(自带长度语义,不能压)。 - `Write` 路径保留「无显式 WriteHeader 时按首字节推断 Content-Type」的逻辑;`gz` 改懒初始化。 - `gzipHandler` 额外加一道 `Range != ""` 直接透传的守卫(双保险)。 ### 验证(curl 实测,全部通过) - 普通静态请求:`200 + Content-Encoding: gzip`、**无 Content-Length**、body 解压后 = 完整 21587B ✓ - Range 请求:`206`、`Content-Length: 100` = 实际 100B、无 Content-Encoding ✓ - `curl --compressed` 全量拉 app.css = 14643B(完整)✓;manifest 解压后是合法 JSON ✓ - PNG 图标:`200 + image/png + Content-Length: 1492`、无 Content-Encoding(透传未被压)✓ - 首页 `--compressed` = 99259B 完整 HTML;`/ /map /report /stats /places /timeline /api/cities /api/track` 全 200 ✓ ### 教训 Go 里做 gzip 中间件,**必须覆盖 `WriteHeader`**,否则任何「先 WriteHeader 再写 body」或 「显式设置 Content-Length」的 handler(典型如 `http.FileServer`/`http.ServeContent`)都会踩坑。 压缩决策要在首部提交前完成,不能拖到第一次 `Write`。 --- ## 推荐 / 踩雷 投票 + 备注批量管理(A/B/C 三批次,均实测) ### 需求 & 决策(用户拍板) - 备注与「踩雷 / 推荐」分开:踩雷/推荐改成按钮式,统计里单独计。 - 投票主体:**站长官方结论 + 访客投票计数并存**;展示:**详情 + 地点库 + 地图**; 备注管理:**改为「全部地点可编辑」**(含新增 / 批量)。 ### 数据模型(internal/db/schema.sql) - `place_votes(user_id, src_pk, verdict, updated_at)`:站长结论,verdict 1 推荐 / -1 踩雷,单值。 - `place_visitor_votes(src_pk, voter_key, vote, updated_at)` PK(src_pk, voter_key):访客一人一票。 - 都按 src_pk 存(与 place_notes 一致),换快照不丢。 ### 批次 A:投票后端 + 详情页卡片 - 新包 `internal/votes`:Verdicts/Verdict/SaveVerdict、Tallies/Tally、Voter、Cast (Cast 逻辑:没投过→插入;投同一票→**撤销**;投另一票→改票)。 - `internal/web/handlers_vote.go`:`voterKeyFromRequest` / `ensureVoterKey`(cookie `rond_voter` + IP 一起 sha256 取前 16 字节作 voter_key,清 cookie 也认同一来源,轻度防刷);clientIP 取 XFF。 `apiVotes`(GET /api/votes)、`apiVote`(POST /api/vote)、`adminPlaceVote`(POST /admin/place/vote)。 - setting 加 `MarksPublic`(marks_public,默认 true)+ 设置页勾选项。 - 详情页 place.html:备注卡片下独立「推荐 / 踩雷」卡片;站长区是**表单**(提交即刷新,无需 JS), 访客区是 AJAX 投票(就地刷新票数);备注框删掉「踩雷还是推荐」占位文案。 - 实测:匿名看不到站长控件、管理员可标结论、访客投票 up→撤销→down 正确。 ### 批次 B:地点库 / 地图 / 统计 - `stats.Filter` 加 `Verdict`,`stats.Places` 用 `EXISTS(place_votes ...)` 支持按结论筛选 (占位符接在内层条件之后,args 复用)。 - `stats.Place` 加 Verdict/Up/Down;`web.attachVotes` 查询后填充(按 src_pk)。 - Page 加 Verdict 字段 + filterPairs + `VerdictLink`;places.html 加标记筛选 chips + 行内徽章。 - MapPoint 加 `sp/vd/u/dn`(omitempty);rond-map.js popup 显示结论与票数。 - stats.html 加「推荐 & 踩雷」区块(VoteRec/VoteAvoid/VoteUp/VoteDown + 两个榜单)。 - 实测:列表徽章、?verdict=±1 筛选、统计区块、api/points 字段全部正确。 ### 批次 C:备注管理页重写 - 数据源从 place_notes 改为「places LEFT JOIN place_notes LEFT JOIN place_votes」, 筛选 q/status(all|has|none)/city/sort(visit|note|talk|name) + 分页(50/页); 底部单列「孤儿备注」(src_pk 已不在当前数据集)。 - NotesData 加 Orphans/CityOptions/Status/City/Sort/BackURL + `PageLink` 方法(保留筛选)。 - 端点:`POST /admin/notes/bulk`(action=delete|clear|verdict);notes 存储加 `SetNote`(保留别名) / `Delete`。 - notes.html 重写:**关键点**——批量复选框用 `form="bulk-form"` 关联到独立的批量表单, 避免「批量表单」与「每行编辑表单」嵌套(HTML 不允许嵌套 form);行内保存/打标用同一表单 + `formaction` 分流到 /admin/place/note 或 /admin/place/vote。 - adminPlaceNote 优先用表单 `next`(带完整筛选)回跳,其次 Referer。 - 实测:323 地点全列出、status=has/none 筛选、行内保存、批量设置结论/清空备注(保留别名)/删除 全部正确。 ### 踩坑 - 嵌套 `