修复图片上传分类无法新增、潜在movementWindow容差问题、404页面未生效问题

This commit is contained in:
tamakyi
2026-09-18 17:06:22 +08:00
parent f8e1076296
commit ad2531c113
9 changed files with 749 additions and 23 deletions
+81
View File
@@ -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)
}
}
}
+84
View File
@@ -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
}
+3 -3
View File
@@ -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) {
+5 -4
View File
@@ -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)
+17
View File
@@ -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)
}
+8
View File
@@ -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, "", "")
})
}
// ---------- 中间件 ----------
+102 -15
View File
@@ -270,12 +270,21 @@
<form method="post" action="/admin/edit/visit/update" style="margin-top:8px;min-width:300px">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<label class="f"><span>类型</span>
<select name="activity_id">
<select name="activity_id" class="activity-id-inline">
<option value="">(无)</option>
{{$cur := .ActivityID}}
{{range $.Activities}}<option value="{{.ID}}" {{if eq .ID $cur}}selected{{end}}>{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</label>
{{/* 选到「+ 新增活动…」就地展开。输入框不写 name:它不该随表单提交,
选中的活动由 activity_id 带过去。建完由脚本挂到页面所有类型下拉上。 */}}
<label class="f visit-act-new" style="display:none"><span>新建活动</span>
<div class="input-btn">
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</label>
<div class="row">
<label class="f" style="flex:1 1 190px"><span>到达时间<b class="req">*</b></span><input type="datetime-local" name="arrival" value="{{.ArrivalStr}}" required></label>
<label class="f" style="flex:1 1 190px"><span>离开时间</span><input type="datetime-local" name="departure" value="{{.DepartureStr}}"></label>
@@ -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, {
+81 -1
View File
@@ -11,6 +11,7 @@
<select id="activity-options" hidden>
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
<select id="tag-options" hidden>
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
@@ -198,6 +199,51 @@
}
return sel;
}
// 活动下拉里的「+ 新增活动…」:选中后就地展开一个新建框,建完把新选项挂到**所有**
// 活动下拉上(含隐藏的选项源,之后才解析出来的行也才有它),并在当前这行选中。
// 名字已经存在就直接选中,不再新建——/admin/edit/activity 不做去重,同名会真的建两行。
function addActivityOption(id, name) {
var src = document.getElementById('activity-options');
var sels = [].slice.call(document.querySelectorAll('select[name^="activity_"]'));
if (src) sels.unshift(src);
sels.forEach(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(sel, input, wrap) {
var name = input.value.trim();
if (!name) { input.focus(); return; }
var src = document.getElementById('activity-options');
var exist = '';
if (src) {
[].forEach.call(src.options, function (o) {
if (o.value !== '__new__' && o.textContent === name) exist = o.value;
});
}
if (exist) {
sel.value = exist;
input.value = '';
wrap.style.display = 'none';
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; }
addActivityOption(d.id, d.name);
sel.value = String(d.id);
input.value = '';
wrap.style.display = 'none';
}).catch(function (e) { alert('创建活动失败:' + e); });
}
function setVal(name, v) {
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
if (el && v) el.value = v;
@@ -343,7 +389,30 @@
var cap2 = groupCap('到访属性');
cap2.style.marginTop = '14px';
td2.appendChild(cap2);
row2.appendChild(fieldLabel('活动', makeSelect('activity_' + index, 'activity-options'), 'flex:0 1 170px'));
var actSel = makeSelect('activity_' + index, 'activity-options');
// 新建活动的输入框不给 name:它不是要随表单提交的字段,选中的活动由 activity_<i> 带过去
var actNewInput = tInput('', '', '新活动名称', '');
var actNewBtn = document.createElement('button');
actNewBtn.type = 'button';
actNewBtn.className = 'btn';
actNewBtn.textContent = '添加';
var actNewBox = document.createElement('div');
actNewBox.className = 'input-btn';
actNewBox.appendChild(actNewInput);
actNewBox.appendChild(actNewBtn);
var actNewWrap = fieldLabel('新建活动', actNewBox, 'flex:0 1 190px');
actNewWrap.style.display = 'none';
actSel.addEventListener('change', function () {
var on = actSel.value === '__new__';
actNewWrap.style.display = on ? '' : 'none';
if (on) actNewInput.focus();
});
actNewBtn.addEventListener('click', function () { createActivity(actSel, actNewInput, actNewWrap); });
actNewInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); createActivity(actSel, actNewInput, actNewWrap); }
});
row2.appendChild(fieldLabel('活动', actSel, 'flex:0 1 170px'));
row2.appendChild(actNewWrap);
// 备注不设 flex-grow:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
var favWrap = document.createElement('label');
@@ -684,6 +753,17 @@
var submitBtn = form.querySelector('button[type=submit]');
form.addEventListener('submit', function (e) {
if (form.dataset.ready === '1') return; // 放行那一趟
// 选了「+ 新增活动…」却没点「添加」的:拦下来。否则 activity_<i> 会带着 "__new__"
// 提交,服务端解析不出整数就当成「未指定」,这行活动会静默丢掉。
var unresolved = [].slice.call(rowsBox.querySelectorAll('select[name^="activity_"]'))
.filter(function (s) { return s.value === '__new__'; });
if (unresolved.length) {
e.preventDefault();
alert('有 ' + unresolved.length + ' 行的活动选了「+ 新增活动…」但还没点「添加」:'
+ '填个名字再点添加,或者把它改回「未指定」。');
unresolved[0].focus();
return;
}
var picked = [];
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
if (!cb.checked) return;