From ad2531c113f5d99f2fa35ccbaf88d01c60fe2da8 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 18 Sep 2026 17:06:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=88=86=E7=B1=BB=E6=97=A0=E6=B3=95=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=81=E6=BD=9C=E5=9C=A8movementWindow=E5=AE=B9=E5=B7=AE?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E3=80=81404=E9=A1=B5=E9=9D=A2=E6=9C=AA?= =?UTF-8?q?=E7=94=9F=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/web/clamp_movement_test.go | 81 +++++ internal/web/handlers_edit.go | 84 ++++++ internal/web/handlers_public.go | 6 +- internal/web/handlers_topics.go | 9 +- internal/web/notice.go | 17 ++ internal/web/server.go | 8 + internal/web/templates/admin/edit.html | 117 ++++++- internal/web/templates/admin/photos.html | 82 ++++- tools/verify_export_structure.py | 368 +++++++++++++++++++++++ 9 files changed, 749 insertions(+), 23 deletions(-) create mode 100644 internal/web/clamp_movement_test.go create mode 100644 tools/verify_export_structure.py 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 @@
+ {{/* 选到「+ 新增活动…」就地展开。输入框不写 name:它不该随表单提交, + 选中的活动由 activity_id 带过去。建完由脚本挂到页面所有类型下拉上。 */}} +
@@ -884,34 +893,112 @@ upd(); }); - // ---------- 活动内联新建:创建后即时挂进下拉并选中 ---------- - function addActivity() { - var input = document.getElementById('new-activity-name'); - var name = input.value.trim(); + // ---------- 活动内联新建:创建后即时挂进**所有**类型下拉并选中 ---------- + // 类型下拉在页面上有多处(顶部地点表单 + 每条到访的行内编辑),选项又是服务端渲染的, + // 所以建完必须一起挂上——否则在顶部建好、下面那行还得刷新页面才看得到。 + // 另外 /admin/edit/activity 不做去重,同名会真的建两行,所以先按名字找现成的。 + function findActivityByName(name) { + var found = ''; + [].forEach.call(document.querySelectorAll('select[name="activity_id"]'), function (s) { + if (found) return; + [].forEach.call(s.options, function (o) { + if (o.value && o.value !== '__new__' && o.textContent === name) found = o.value; + }); + }); + return found; + } + function addActivityOption(id, name) { + [].forEach.call(document.querySelectorAll('select[name="activity_id"]'), function (s) { + var opt = document.createElement('option'); + opt.value = id; + opt.textContent = name; + // 插在「+ 新增活动…」前面,别让新建项落到那个特殊项后面 + var tail = s.querySelector('option[value="__new__"]'); + if (tail) s.insertBefore(opt, tail); else s.appendChild(opt); + }); + } + function createActivity(name, onDone) { + name = (name || '').trim(); if (!name) return; + var exist = findActivityByName(name); + if (exist) { onDone(exist); return; } fetch('/admin/edit/activity', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'name=' + encodeURIComponent(name) }).then(function (r) { return r.json(); }).then(function (d) { if (d.error) { alert(d.error); return; } - var sel = document.getElementById('activity-select'); - var opt = document.createElement('option'); - opt.value = d.id; - opt.textContent = d.name; - opt.selected = true; - sel.appendChild(opt); - input.value = ''; + addActivityOption(d.id, d.name); + onDone(String(d.id)); }).catch(function (e) { alert('创建活动失败:' + e); }); } + var actAdd = document.getElementById('activity-add'); if (actAdd) { - actAdd.addEventListener('click', addActivity); - document.getElementById('new-activity-name').addEventListener('keydown', function (e) { - if (e.key === 'Enter') { e.preventDefault(); addActivity(); } + var newActInput = document.getElementById('new-activity-name'); + var addActivityTop = function () { + var sel = document.getElementById('activity-select'); + createActivity(newActInput.value, function (id) { + if (sel) sel.value = id; + newActInput.value = ''; + }); + }; + actAdd.addEventListener('click', addActivityTop); + newActInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); addActivityTop(); } }); } + // ---- 到访行内编辑里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ---- + function inlineActParts(el) { + var form = el.closest ? el.closest('form') : null; + if (!form) return null; + var wrap = form.querySelector('.visit-act-new'); + return { wrap: wrap, input: wrap ? wrap.querySelector('input') : null, + sel: form.querySelector('select.activity-id-inline') }; + } + document.addEventListener('change', function (e) { + var t = e.target; + if (!t || !t.classList || !t.classList.contains('activity-id-inline')) return; + var p = inlineActParts(t); + if (!p || !p.wrap) return; + var on = t.value === '__new__'; + p.wrap.style.display = on ? '' : 'none'; + if (on && p.input) p.input.focus(); + }); + function inlineActCreate(el) { + var p = inlineActParts(el); + if (!p) return; + createActivity(p.input ? p.input.value : '', function (id) { + if (p.sel) p.sel.value = id; + if (p.input) p.input.value = ''; + if (p.wrap) p.wrap.style.display = 'none'; + }); + } + document.addEventListener('click', function (e) { + var t = e.target; + if (!t || !t.classList || !t.classList.contains('visit-act-add')) return; + inlineActCreate(t); + }); + document.addEventListener('keydown', function (e) { + var t = e.target; + if (e.key !== 'Enter' || !t || !t.classList || !t.classList.contains('visit-act-name')) return; + e.preventDefault(); + inlineActCreate(t); + }); + // 提交守卫:还停在「+ 新增活动…」上就拦下来。否则 activity_id 会带着 "__new__" 提交, + // resolveActivity 解析不出整数就当成「无」,这条到访的类型会被静默清掉。 + document.addEventListener('submit', function (e) { + var f = e.target; + if (!f || !f.querySelector) return; + var sel = f.querySelector('select.activity-id-inline'); + if (sel && sel.value === '__new__') { + e.preventDefault(); + alert('类型选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或改回「(无)」。'); + sel.focus(); + } + }, true); + // ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ---------- function activityPost(path, body, onOK) { fetch(path, { diff --git a/internal/web/templates/admin/photos.html b/internal/web/templates/admin/photos.html index b8b5b46..8f72a87 100644 --- a/internal/web/templates/admin/photos.html +++ b/internal/web/templates/admin/photos.html @@ -11,6 +11,7 @@