diff --git a/internal/web/clamp_movement_test.go b/internal/web/clamp_movement_test.go new file mode 100644 index 0000000..614fc3d --- /dev/null +++ b/internal/web/clamp_movement_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "database/sql" + "testing" + "time" +) + +// 改到访时间后把引用它的行程夹回窗口 —— 规则本身的回归。 +// +// 真机不变量:位移不早于起点到访的离开时间、不晚于终点到访的到达时间。 +// 线上出过的事:把某次到访的到达时间改早之后,原先合法的行程就变成越界 +// (实测 2 条超出 26~47 秒,导出的包里带着这个违例)。 +func TestClampMovementWindow(t *testing.T) { + base := time.Date(2026, 9, 13, 3, 30, 0, 0, time.Local) + at := func(d time.Duration) time.Time { return base.Add(d) } + valid := func(tt time.Time) sql.NullTime { return sql.NullTime{Time: tt, Valid: true} } + + cases := []struct { + name string + start, end time.Time + lo, hi sql.NullTime + wantStart time.Time + wantEnd time.Time + }{ + { + name: "都在窗口内:不动", + start: at(10 * time.Minute), end: at(30 * time.Minute), + lo: valid(at(5 * time.Minute)), hi: valid(at(40 * time.Minute)), + wantStart: at(10 * time.Minute), wantEnd: at(30 * time.Minute), + }, + { + name: "起点被改晚导致开始时间早于离开:开始推到离开", + start: at(1 * time.Minute), end: at(30 * time.Minute), + lo: valid(at(10 * time.Minute)), hi: valid(at(40 * time.Minute)), + wantStart: at(10 * time.Minute), wantEnd: at(30 * time.Minute), + }, + { + name: "终点被改早导致结束晚于到达:结束拉回到达", + start: at(10 * time.Minute), end: at(50 * time.Minute), + lo: valid(at(5 * time.Minute)), hi: valid(at(20 * time.Minute)), + wantStart: at(10 * time.Minute), wantEnd: at(20 * time.Minute), + }, + { + name: "两端都越界:一起夹", + start: at(1 * time.Minute), end: at(50 * time.Minute), + lo: valid(at(10 * time.Minute)), hi: valid(at(20 * time.Minute)), + wantStart: at(10 * time.Minute), wantEnd: at(20 * time.Minute), + }, + { + name: "窗口被压没了:结束推到开始之后 1 分钟(不能写 NULL)", + start: at(10 * time.Minute), end: at(50 * time.Minute), + lo: valid(at(30 * time.Minute)), hi: valid(at(20 * time.Minute)), + wantStart: at(30 * time.Minute), wantEnd: at(31 * time.Minute), + }, + { + name: "两端到访都没有时间:不夹", + start: at(10 * time.Minute), end: at(30 * time.Minute), + lo: sql.NullTime{}, hi: sql.NullTime{}, + wantStart: at(10 * time.Minute), wantEnd: at(30 * time.Minute), + }, + { + name: "终点到访缺失:只夹起点侧", + start: at(1 * time.Minute), end: at(30 * time.Minute), + lo: valid(at(10 * time.Minute)), hi: sql.NullTime{}, + wantStart: at(10 * time.Minute), wantEnd: at(30 * time.Minute), + }, + } + for _, c := range cases { + gs, ge := clampMovementWindow(c.start, c.end, c.lo, c.hi) + if !gs.Equal(c.wantStart) { + t.Errorf("%s: start = %v,期望 %v", c.name, gs, c.wantStart) + } + if !ge.Equal(c.wantEnd) { + t.Errorf("%s: end = %v,期望 %v", c.name, ge, c.wantEnd) + } + if !ge.After(gs) { + t.Errorf("%s: 夹完 end(%v) 必须晚于 start(%v)", c.name, ge, gs) + } + } +} diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index 2b8bdfb..8555291 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -1037,6 +1037,11 @@ func (s *Server) editVisitUpdate(w http.ResponseWriter, r *http.Request) { s.serverError(w, r, err) return } + // 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements) + if err := s.clampVisitMovements(ctx, s.db, ds.ID, src); err != nil { + s.serverError(w, r, err) + return + } s.saveVisitTags(ctx, s.db, ds.ID, src, r.PostForm["tags"]) if oldPlace > 0 { s.refreshPlaceAgg(ctx, s.db, oldPlace) @@ -1256,6 +1261,84 @@ func resolveMovementEnd(raw string, cur *time.Time, def time.Time, label string) return kept, kept.Equal(t), "" } +// clampVisitMovements 把「引用这条到访」的行程夹回合法窗口。 +// +// 真机不变量(判据同 movementWindow):位移不早于起点到访的离开时间、不晚于终点到访的到达时间。 +// 但表单对手填时间有 1 分钟容差,而**改到访时间时不会回头校验引用它的行程** —— 把某次到访的 +// 到达时间改早之后,原先合法的行程就变成越界(实测有 2 条超出 26~47 秒,导出包里带着这个违例)。 +// 所以每次改到访时间后按两端到访的当前时间把行程夹回来。 +// +// 夹不动的情况(窗口被压得比 1 分钟还窄)把结束时间推到开始之后 1 分钟:真机 791/791 条 +// ZSTART_/ZEND_ 都有值,宁可略微越界也不能写 NULL。 +func (s *Server) clampVisitMovements(ctx context.Context, ex execer, datasetID int64, visitSrcPK int) error { + rows, err := ex.QueryContext(ctx, `SELECT m.src_pk, m.started_at, m.ended_at, + fl.departure, fl.arrival, tl.arrival + FROM movements m + LEFT JOIN visits fl ON fl.dataset_id = m.dataset_id AND fl.src_pk = m.from_visit_src + LEFT JOIN visits tl ON tl.dataset_id = m.dataset_id AND tl.src_pk = m.to_visit_src + WHERE m.dataset_id = $1 AND (m.from_visit_src = $2 OR m.to_visit_src = $2)`, + datasetID, visitSrcPK) + if err != nil { + return err + } + type fix struct { + pk int + start, end time.Time + } + var fixes []fix + for rows.Next() { + var pk int + var start, end time.Time + var fDep, fArr, tArr sql.NullTime + if err := rows.Scan(&pk, &start, &end, &fDep, &fArr, &tArr); err != nil { + rows.Close() + return err + } + // 起点侧的下界:有离开时间用离开,没有就用到达 + lo := sql.NullTime{} + if fDep.Valid { + lo = fDep + } else { + lo = fArr + } + ns, ne := clampMovementWindow(start, end, lo, tArr) + if !ns.Equal(start) || !ne.Equal(end) { + fixes = append(fixes, fix{pk: pk, start: ns, end: ne}) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + for _, f := range fixes { + if _, err := ex.ExecContext(ctx, `UPDATE movements SET started_at=$3, ended_at=$4, duration_min=$5 + WHERE dataset_id=$1 AND src_pk=$2`, + datasetID, f.pk, f.start, f.end, int(f.end.Sub(f.start).Minutes())); err != nil { + return err + } + } + return nil +} + +// clampMovementWindow 把一条行程的起止夹进两端到访给出的窗口: +// lo = 起点到访的离开时间(没有就用到达),hi = 终点到访的到达时间。 +// 任一端的到访缺失(NullTime 无效)就那一侧不夹。 +// +// 夹完必须 end > start —— 窗口被压得比 1 分钟还窄时把结束推到开始之后 1 分钟: +// 真机 791/791 条 ZSTART_/ZEND_ 都有值,宁可略微越界也不能写 NULL。 +func clampMovementWindow(start, end time.Time, lo, hi sql.NullTime) (time.Time, time.Time) { + if lo.Valid && start.Before(lo.Time) { + start = lo.Time + } + if hi.Valid && end.After(hi.Time) { + end = hi.Time + } + if !end.After(start) { + end = start.Add(time.Minute) + } + return start, end +} + // sameMinute 比的是本地时区下的「哪一分钟」:表单值是按本地时区解析出来的, // 而库里取回的 timestamptz 常在 UTC,先统一到本地再比。 func sameMinute(a, b time.Time) bool { @@ -1714,6 +1797,7 @@ func (s *Server) resolveActivity(ctx context.Context, datasetID int64, raw strin // 「建地点 + 建到访」必须落在同一个事务里,这些写方法因此不能写死用连接池。 type execer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index 8cfc104..e3d4776 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -497,17 +497,17 @@ func (s *Server) placePage(w http.ResponseWriter, r *http.Request) { } p.HasMap = true if p.Dataset == nil { - http.NotFound(w, r) + s.notFoundPage(w, "这个地点不存在", "站点还没有导入任何数据,这一页自然也没有内容。") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { - http.NotFound(w, r) + s.notFoundPage(w, "这个地址不对", "地点的编号应该是数字,链接可能被截断了。") return } place, err := s.q.Place(r.Context(), p.Dataset.ID, id) if err != nil { - http.NotFound(w, r) + s.notFoundPage(w, "这个地点不存在", "") return } if regionHidden(p.Settings, p.IsAdmin(), place) { diff --git a/internal/web/handlers_topics.go b/internal/web/handlers_topics.go index b55760a..e097e59 100644 --- a/internal/web/handlers_topics.go +++ b/internal/web/handlers_topics.go @@ -390,7 +390,7 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) { p.HasMap = true id := atoiOr(r.PathValue("id"), 0) if id <= 0 || p.Dataset == nil { - http.NotFound(w, r) + s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。") return } var t Topic @@ -400,16 +400,17 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) { Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt, &t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder) if err == sql.ErrNoRows { - http.NotFound(w, r) + s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。") return } if err != nil { s.serverError(w, r, err) return } - // 未对外展示的专题只有站长能看 + // 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致—— + // 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。 if !t.Enabled && !p.IsAdmin() { - http.NotFound(w, r) + s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。") return } t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout) diff --git a/internal/web/notice.go b/internal/web/notice.go index e879d7e..5f6913f 100644 --- a/internal/web/notice.go +++ b/internal/web/notice.go @@ -47,3 +47,20 @@ func (s *Server) placeHiddenPage(w http.ResponseWriter) { s.pausePage(w, http.StatusForbidden, "🔒", "这个地点没有对访客开放", "站长按区域设置隐藏了这个地点的记录。带你去别处看看。", 4) } + +// notFoundPage 是「这个地址没有东西」时的回应。 +// +// 状态码仍是 404(脚本和搜索引擎照旧当成不存在),但给人看的是站点样式的提示页 —— +// Go 默认那句纯文本 `404 page not found` 摆在白底上像是站点坏了。这个站其它「不给你看」 +// 的场景(内容未开放、地点被区域隐藏)早就都走 pausePage 了,只有「压根没有」这类漏在外头。 +// +// 与 placeHiddenPage 的区别要保持:那个是 403「存在但不给看」,这个是 404「就没有」。 +func (s *Server) notFoundPage(w http.ResponseWriter, title, msg string) { + if title == "" { + title = "这个页面不存在" + } + if msg == "" { + msg = "地址可能写错了,或者这条记录已经被删掉。带你去别处看看。" + } + s.pausePage(w, http.StatusNotFound, "🧭", title, msg, 4) +} diff --git a/internal/web/server.go b/internal/web/server.go index a152683..92076a7 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -276,6 +276,14 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage)) s.mux.HandleFunc("POST /admin/password", s.requireAdmin(s.passwordSubmit)) s.mux.HandleFunc("POST /admin/username", s.requireAdmin(s.usernameSubmit)) + + // 兜底:没匹配上任何路由的地址(打错的链接、爬虫乱扫)也给站点样式的中间页。 + // 不注册这条的话走的是 ServeMux 默认响应,白底一行 `404 page not found`,看着像站点坏了。 + // 更具体的模式优先级更高,所以静态资源、瓦片、图片这些不受影响; + // 瓦片/图片那几处仍然保留纯文本 404 —— 那些是给浏览器和爬虫取的资源,不是给人看的页面。 + s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + s.notFoundPage(w, "", "") + }) } // ---------- 中间件 ---------- diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index f8f5de4..7a42a0b 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -270,12 +270,21 @@