修复图片上传分类无法新增、潜在movementWindow容差问题、404页面未生效问题
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1037,6 +1037,11 @@ func (s *Server) editVisitUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
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"])
|
s.saveVisitTags(ctx, s.db, ds.ID, src, r.PostForm["tags"])
|
||||||
if oldPlace > 0 {
|
if oldPlace > 0 {
|
||||||
s.refreshPlaceAgg(ctx, s.db, oldPlace)
|
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), ""
|
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 比的是本地时区下的「哪一分钟」:表单值是按本地时区解析出来的,
|
// sameMinute 比的是本地时区下的「哪一分钟」:表单值是按本地时区解析出来的,
|
||||||
// 而库里取回的 timestamptz 常在 UTC,先统一到本地再比。
|
// 而库里取回的 timestamptz 常在 UTC,先统一到本地再比。
|
||||||
func sameMinute(a, b time.Time) bool {
|
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 {
|
type execer interface {
|
||||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
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
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -497,17 +497,17 @@ func (s *Server) placePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
p.HasMap = true
|
p.HasMap = true
|
||||||
if p.Dataset == nil {
|
if p.Dataset == nil {
|
||||||
http.NotFound(w, r)
|
s.notFoundPage(w, "这个地点不存在", "站点还没有导入任何数据,这一页自然也没有内容。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
s.notFoundPage(w, "这个地址不对", "地点的编号应该是数字,链接可能被截断了。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
place, err := s.q.Place(r.Context(), p.Dataset.ID, id)
|
place, err := s.q.Place(r.Context(), p.Dataset.ID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
s.notFoundPage(w, "这个地点不存在", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if regionHidden(p.Settings, p.IsAdmin(), place) {
|
if regionHidden(p.Settings, p.IsAdmin(), place) {
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
p.HasMap = true
|
p.HasMap = true
|
||||||
id := atoiOr(r.PathValue("id"), 0)
|
id := atoiOr(r.PathValue("id"), 0)
|
||||||
if id <= 0 || p.Dataset == nil {
|
if id <= 0 || p.Dataset == nil {
|
||||||
http.NotFound(w, r)
|
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var t Topic
|
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,
|
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)
|
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
http.NotFound(w, r)
|
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 未对外展示的专题只有站长能看
|
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
||||||
|
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
||||||
if !t.Enabled && !p.IsAdmin() {
|
if !t.Enabled && !p.IsAdmin() {
|
||||||
http.NotFound(w, r)
|
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
||||||
|
|||||||
@@ -47,3 +47,20 @@ func (s *Server) placeHiddenPage(w http.ResponseWriter) {
|
|||||||
s.pausePage(w, http.StatusForbidden, "🔒", "这个地点没有对访客开放",
|
s.pausePage(w, http.StatusForbidden, "🔒", "这个地点没有对访客开放",
|
||||||
"站长按区域设置隐藏了这个地点的记录。带你去别处看看。", 4)
|
"站长按区域设置隐藏了这个地点的记录。带你去别处看看。", 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,14 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
|
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/password", s.requireAdmin(s.passwordSubmit))
|
||||||
s.mux.HandleFunc("POST /admin/username", s.requireAdmin(s.usernameSubmit))
|
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, "", "")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 中间件 ----------
|
// ---------- 中间件 ----------
|
||||||
|
|||||||
@@ -270,12 +270,21 @@
|
|||||||
<form method="post" action="/admin/edit/visit/update" style="margin-top:8px;min-width:300px">
|
<form method="post" action="/admin/edit/visit/update" style="margin-top:8px;min-width:300px">
|
||||||
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
|
||||||
<label class="f"><span>类型</span>
|
<label class="f"><span>类型</span>
|
||||||
<select name="activity_id">
|
<select name="activity_id" class="activity-id-inline">
|
||||||
<option value="">(无)</option>
|
<option value="">(无)</option>
|
||||||
{{$cur := .ActivityID}}
|
{{$cur := .ActivityID}}
|
||||||
{{range $.Activities}}<option value="{{.ID}}" {{if eq .ID $cur}}selected{{end}}>{{.Name}}</option>{{end}}
|
{{range $.Activities}}<option value="{{.ID}}" {{if eq .ID $cur}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||||
|
<option value="__new__">+ 新增活动…</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<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>到达时间<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>
|
<label class="f" style="flex:1 1 190px"><span>离开时间</span><input type="datetime-local" name="departure" value="{{.DepartureStr}}"></label>
|
||||||
@@ -884,34 +893,112 @@
|
|||||||
upd();
|
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;
|
if (!name) return;
|
||||||
|
var exist = findActivityByName(name);
|
||||||
|
if (exist) { onDone(exist); return; }
|
||||||
fetch('/admin/edit/activity', {
|
fetch('/admin/edit/activity', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: 'name=' + encodeURIComponent(name)
|
body: 'name=' + encodeURIComponent(name)
|
||||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { alert(d.error); return; }
|
||||||
var sel = document.getElementById('activity-select');
|
addActivityOption(d.id, d.name);
|
||||||
var opt = document.createElement('option');
|
onDone(String(d.id));
|
||||||
opt.value = d.id;
|
|
||||||
opt.textContent = d.name;
|
|
||||||
opt.selected = true;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
input.value = '';
|
|
||||||
}).catch(function (e) { alert('创建活动失败:' + e); });
|
}).catch(function (e) { alert('创建活动失败:' + e); });
|
||||||
}
|
}
|
||||||
|
|
||||||
var actAdd = document.getElementById('activity-add');
|
var actAdd = document.getElementById('activity-add');
|
||||||
if (actAdd) {
|
if (actAdd) {
|
||||||
actAdd.addEventListener('click', addActivity);
|
var newActInput = document.getElementById('new-activity-name');
|
||||||
document.getElementById('new-activity-name').addEventListener('keydown', function (e) {
|
var addActivityTop = function () {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); addActivity(); }
|
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) {
|
function activityPost(path, body, onOK) {
|
||||||
fetch(path, {
|
fetch(path, {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<select id="activity-options" hidden>
|
<select id="activity-options" hidden>
|
||||||
<option value="">未指定</option>
|
<option value="">未指定</option>
|
||||||
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
|
<option value="__new__">+ 新增活动…</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="tag-options" hidden>
|
<select id="tag-options" hidden>
|
||||||
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
@@ -198,6 +199,51 @@
|
|||||||
}
|
}
|
||||||
return sel;
|
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) {
|
function setVal(name, v) {
|
||||||
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
|
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
|
||||||
if (el && v) el.value = v;
|
if (el && v) el.value = v;
|
||||||
@@ -343,7 +389,30 @@
|
|||||||
var cap2 = groupCap('到访属性');
|
var cap2 = groupCap('到访属性');
|
||||||
cap2.style.marginTop = '14px';
|
cap2.style.marginTop = '14px';
|
||||||
td2.appendChild(cap2);
|
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:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
|
// 备注不设 flex-grow:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
|
||||||
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
|
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
|
||||||
var favWrap = document.createElement('label');
|
var favWrap = document.createElement('label');
|
||||||
@@ -684,6 +753,17 @@
|
|||||||
var submitBtn = form.querySelector('button[type=submit]');
|
var submitBtn = form.querySelector('button[type=submit]');
|
||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
if (form.dataset.ready === '1') return; // 放行那一趟
|
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 = [];
|
var picked = [];
|
||||||
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
|
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
|
||||||
if (!cb.checked) return;
|
if (!cb.checked) return;
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""结构级对比:导出的 .rondbackup / .fwss 与真机原版,判断「能不能被软件读回去」。
|
||||||
|
|
||||||
|
与 tools/verify_rondbackup.py 的分工:那个比**字段取值**(MANAGED 表逐列),
|
||||||
|
这个比**结构**——zip 条目、表/索引/建表语句、格式不变量、自洽性、确定性。
|
||||||
|
两者都用「未改动的往返包」时应当全过;用真库的导出包时,字段级差异是正常的
|
||||||
|
(站点新建的行),但**结构不变量与自洽性必须仍然全过**。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python tools/verify_export_structure.py all <原版.rondbackup> <导出.rondbackup> \
|
||||||
|
<原版.fwss> <导出.fwss>
|
||||||
|
python tools/verify_export_structure.py rondbackup <原版> <导出> [<第二次导出>]
|
||||||
|
python tools/verify_export_structure.py fwss <原版> <导出> [<第二次导出>]
|
||||||
|
|
||||||
|
两点必须知道的前提:
|
||||||
|
* 原版 rondbackup 是**带 WAL 的活快照**(主库小、-wal 大)。必须三个文件一起解出来
|
||||||
|
让 SQLite 重放 WAL,否则行数全 0、比出来全是「缺行」的假象。
|
||||||
|
* 原版 fwss 是某一天的快照,块数据与当前库**本来就该不同**,所以内容不比,
|
||||||
|
只比命名可逆、槽位自洽、块尾 24 位数的定义这些格式不变量。
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
# internal/fog/fog.go 的常量与掩码(改那边记得同步这里)
|
||||||
|
MAP_TILES, TILE_BLOCKS = 512, 128
|
||||||
|
HEADER_LEN, HEADER_SIZE = TILE_BLOCKS * TILE_BLOCKS, 32768
|
||||||
|
BITMAP, BLOCK = 512, 515
|
||||||
|
MASK1, MASK2 = "olhwjsktri", "eizxdwknmo"
|
||||||
|
SENTINEL = -63114076800 # ZLASTARRIVALDATE_ 的「无数据」哨兵
|
||||||
|
|
||||||
|
OUT, FAILED, WARNS = [], [False], []
|
||||||
|
|
||||||
|
|
||||||
|
def say(s=""):
|
||||||
|
OUT.append(s)
|
||||||
|
|
||||||
|
|
||||||
|
def head(s):
|
||||||
|
say()
|
||||||
|
say("=" * 78)
|
||||||
|
say(s)
|
||||||
|
say("=" * 78)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
FAILED[0] = True
|
||||||
|
say(" !! " + msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ fwss 命名编码
|
||||||
|
def tile_name(tid):
|
||||||
|
s = str(tid)
|
||||||
|
b = hashlib.md5(s.encode()).hexdigest()[:4]
|
||||||
|
for ch in s:
|
||||||
|
b += MASK1[int(ch)]
|
||||||
|
for ch in s[-2:]:
|
||||||
|
b += MASK2[int(ch)]
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def name_reversible(name):
|
||||||
|
if len(name) < 7:
|
||||||
|
return False
|
||||||
|
digits = ""
|
||||||
|
for c in name[4:len(name) - 2]:
|
||||||
|
if c not in MASK1:
|
||||||
|
return False
|
||||||
|
digits += str(MASK1.index(c))
|
||||||
|
return tile_name(int(digits)) == name
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- rondbackup
|
||||||
|
def compare_rondbackup(orig, new, new2=None):
|
||||||
|
head("一、rondbackup 结构对比")
|
||||||
|
say("原版: %s (%.2f MB)" % (os.path.basename(orig), os.path.getsize(orig) / 1048576))
|
||||||
|
say("导出: %s (%.2f MB)" % (os.path.basename(new), os.path.getsize(new) / 1048576))
|
||||||
|
zo, zn = zipfile.ZipFile(orig), zipfile.ZipFile(new)
|
||||||
|
oi = {i.filename: i for i in zo.infolist()}
|
||||||
|
ni = {i.filename: i for i in zn.infolist()}
|
||||||
|
say("zip 条目: 原版 %d / 导出 %d" % (len(oi), len(ni)))
|
||||||
|
if set(oi) != set(ni):
|
||||||
|
fail("条目集合不同:缺 %s,多 %s" % (sorted(set(oi) - set(ni)), sorted(set(ni) - set(oi))))
|
||||||
|
else:
|
||||||
|
say(" 条目名完全一致 ✓(%s)" % ", ".join(sorted(oi)))
|
||||||
|
say(" %-20s %14s %14s" % ("条目", "原版", "导出"))
|
||||||
|
for n in sorted(oi):
|
||||||
|
say(" %-20s %14d %14d" % (n, oi[n].file_size, ni[n].file_size))
|
||||||
|
|
||||||
|
def extract(z):
|
||||||
|
d = tempfile.mkdtemp(prefix="rbstruct-")
|
||||||
|
for i in z.infolist():
|
||||||
|
with open(os.path.join(d, i.filename), "wb") as f:
|
||||||
|
f.write(z.read(i.filename))
|
||||||
|
return d
|
||||||
|
|
||||||
|
co = sqlite3.connect(os.path.join(extract(zo), "LifeEasy.sqlite"))
|
||||||
|
cn = sqlite3.connect(os.path.join(extract(zn), "LifeEasy.sqlite"))
|
||||||
|
|
||||||
|
def schema(db):
|
||||||
|
t, idx = {}, {}
|
||||||
|
for typ, name, tbl, sql in db.execute("SELECT type,name,tbl_name,sql FROM sqlite_master"):
|
||||||
|
(t if typ == "table" else idx)[name] = sql
|
||||||
|
return t, idx
|
||||||
|
|
||||||
|
to, io_ = schema(co)
|
||||||
|
tn, in_ = schema(cn)
|
||||||
|
head("1.1 表 / 索引 / 建表语句 / 列")
|
||||||
|
say("表: 原版 %d / 导出 %d;索引: 原版 %d / 导出 %d" % (len(to), len(tn), len(io_), len(in_)))
|
||||||
|
for label, a, b in (("表", set(to), set(tn)), ("索引", set(io_), set(in_))):
|
||||||
|
if a == b:
|
||||||
|
say(" %s集合一致 ✓" % label)
|
||||||
|
else:
|
||||||
|
fail("%s集合不同:缺 %s,多 %s" % (label, sorted(a - b), sorted(b - a)))
|
||||||
|
bad_sql = [t for t in set(to) & set(tn) if to[t] != tn[t]]
|
||||||
|
if bad_sql:
|
||||||
|
fail("CREATE TABLE 语句不同: %s" % bad_sql)
|
||||||
|
else:
|
||||||
|
say(" %d 张表的 CREATE TABLE 语句逐字符一致 ✓" % len(to))
|
||||||
|
say()
|
||||||
|
say("%-24s %9s %9s %s" % ("表", "原版行数", "导出行数", "列定义"))
|
||||||
|
say("-" * 78)
|
||||||
|
for tbl in sorted(set(to) & set(tn)):
|
||||||
|
ca = [r[1] for r in co.execute('PRAGMA table_info("%s")' % tbl)]
|
||||||
|
cb = [r[1] for r in cn.execute('PRAGMA table_info("%s")' % tbl)]
|
||||||
|
ra = co.execute('SELECT count(*) FROM "%s"' % tbl).fetchone()[0]
|
||||||
|
rb = cn.execute('SELECT count(*) FROM "%s"' % tbl).fetchone()[0]
|
||||||
|
if ca != cb:
|
||||||
|
fail("%s 列定义不同" % tbl)
|
||||||
|
say(" %-24s %9d %9d !! 原=%s 新=%s" % (tbl, ra, rb, ca, cb))
|
||||||
|
else:
|
||||||
|
say(" %-24s %9d %9d 一致" % (tbl, ra, rb))
|
||||||
|
say()
|
||||||
|
say(" 行数差异说明:用真库导出时,站点新建的行(ZVISIT/ZLOCATION/ZACTIVITY/ZTRANSPORT)")
|
||||||
|
say(" 会比原版多,这是预期;用「未改动的往返包」时应当全部相等。")
|
||||||
|
|
||||||
|
head("1.2 自洽性(决定能不能被读回去)")
|
||||||
|
for label, db in (("原版", co), ("导出", cn)):
|
||||||
|
ic = db.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
fk = len(db.execute("PRAGMA foreign_key_check").fetchall())
|
||||||
|
say("%s: integrity_check=%s foreign_key_check 违例=%d" % (label, ic, fk))
|
||||||
|
if label == "导出" and (ic != "ok" or fk):
|
||||||
|
fail("导出包自洽性不通过")
|
||||||
|
wal = os.path.getsize(os.path.join(extract(zn), "LifeEasy.sqlite-wal"))
|
||||||
|
say("导出的 -wal = %d 字节(0 = 打开即完整数据,无需重放)" % wal)
|
||||||
|
md = cn.execute("SELECT Z_PLIST FROM Z_METADATA LIMIT 1").fetchone()
|
||||||
|
say("Z_METADATA: %d 行,Z_PLIST %d 字节(Core Data 模型版本,缺了 rond 认不出模型)"
|
||||||
|
% (cn.execute("SELECT count(*) FROM Z_METADATA").fetchone()[0], len(md[0] or b"") if md else 0))
|
||||||
|
say("Z_PRIMARYKEY 行数 = %d(实体编号表)"
|
||||||
|
% cn.execute("SELECT count(*) FROM Z_PRIMARYKEY").fetchone()[0])
|
||||||
|
|
||||||
|
head("1.3 关键不变量(导出侧)")
|
||||||
|
checks = [
|
||||||
|
("ZMOVEMENT.ZTYPE_ 为空(真机 791/791 有值)", "SELECT count(*) FROM ZMOVEMENT WHERE ZTYPE_ IS NULL"),
|
||||||
|
("ZMOVEMENT 缺 ZSTART_", "SELECT count(*) FROM ZMOVEMENT WHERE ZSTART_ IS NULL"),
|
||||||
|
("ZMOVEMENT 缺 ZEND_", "SELECT count(*) FROM ZMOVEMENT WHERE ZEND_ IS NULL"),
|
||||||
|
("ZMOVEMENT ZSTART_ 早于起点离开",
|
||||||
|
"SELECT count(*) FROM ZMOVEMENT m JOIN ZVISIT vf ON vf.Z_PK=m.ZVISITFROM_"
|
||||||
|
" WHERE vf.ZDEPARTUREDATE_ IS NOT NULL AND m.ZSTART_ < vf.ZDEPARTUREDATE_"),
|
||||||
|
("ZACTIVITY 缺 ZUID_", "SELECT count(*) FROM ZACTIVITY WHERE ZUID_ IS NULL OR ZUID_=''"),
|
||||||
|
("新建的 ZVISIT 缺 ZIDENTIFIER_",
|
||||||
|
"SELECT count(*) FROM ZVISIT WHERE ZUSERADDED=1 AND (ZIDENTIFIER_ IS NULL OR ZIDENTIFIER_='')"),
|
||||||
|
("ZLOCATION 缺 ZRAWLATITUDE", "SELECT count(*) FROM ZLOCATION WHERE ZRAWLATITUDE IS NULL"),
|
||||||
|
]
|
||||||
|
for label, sql in checks:
|
||||||
|
v = cn.execute(sql).fetchone()[0]
|
||||||
|
if v:
|
||||||
|
fail("%s = %d(期望 0)" % (label, v))
|
||||||
|
else:
|
||||||
|
say(" %-42s = 0 ✓" % label)
|
||||||
|
|
||||||
|
# 只提示、不判失败:导出只能如实反映库里的东西。用真库导出时,基线里有、库里没有的行
|
||||||
|
# 会在这里露出来(正常情况是「一条全空的 ZVISIT」,早期导入路径跳过过它)。
|
||||||
|
miss_rows = {}
|
||||||
|
for tbl, col in (("ZVISIT", "Z_PK"), ("ZLOCATION", "Z_PK"), ("ZMOVEMENT", "Z_PK")):
|
||||||
|
sa = {r[0] for r in co.execute("SELECT %s FROM %s" % (col, tbl))}
|
||||||
|
sb = {r[0] for r in cn.execute("SELECT %s FROM %s" % (col, tbl))}
|
||||||
|
if sa - sb:
|
||||||
|
miss_rows[tbl] = sorted(sa - sb)
|
||||||
|
say(" %-42s %s" % ("基线有、导出没有的行",
|
||||||
|
"无 ✓" if not miss_rows else str(miss_rows)))
|
||||||
|
if miss_rows:
|
||||||
|
WARNS.append(
|
||||||
|
"基线里这些行不在导出中:%s。导出只反映库里现有的数据,所以这不是导出缺陷 ——"
|
||||||
|
"用「未改动的往返包」时不出现,说明是当年那次导入(9-14)用的是更早的代码,"
|
||||||
|
"或这些行被删过。想对齐就重导一次原包。"
|
||||||
|
% ", ".join("%s %s" % (k, v) for k, v in miss_rows.items()))
|
||||||
|
|
||||||
|
nbad = cn.execute(
|
||||||
|
"SELECT count(*) FROM ZMOVEMENT m JOIN ZVISIT vt ON vt.Z_PK=m.ZVISITTO_"
|
||||||
|
" WHERE vt.ZARRIVALDATE_ IS NOT NULL AND m.ZEND_ > vt.ZARRIVALDATE_").fetchone()[0]
|
||||||
|
say(" %-42s = %d%s" % ("ZMOVEMENT ZEND_ 晚于终点到达(容差内允许)", nbad,
|
||||||
|
"" if not nbad else " ← 见下方说明"))
|
||||||
|
if nbad:
|
||||||
|
for r in cn.execute(
|
||||||
|
"SELECT m.Z_PK, m.ZVISITTO_, m.ZEND_-vt.ZARRIVALDATE_ FROM ZMOVEMENT m"
|
||||||
|
" JOIN ZVISIT vt ON vt.Z_PK=m.ZVISITTO_"
|
||||||
|
" WHERE vt.ZARRIVALDATE_ IS NOT NULL AND m.ZEND_ > vt.ZARRIVALDATE_"):
|
||||||
|
say(" movement %d → visit %d:超出 %.1f 秒" % (r[0], r[1], r[2]))
|
||||||
|
WARNS.append(
|
||||||
|
"ZEND_ 晚于终点到达的有 %d 条(均在 1 分钟内)。成因:站点允许手填时间有 1 分钟"
|
||||||
|
"容差,而**改到访时间时不会回头校验引用它的行程** —— 到访的到达时间被改早之后,"
|
||||||
|
"原先合法的行程就变成越界。不属格式问题,rond 读的是同一种数据形状。" % nbad)
|
||||||
|
|
||||||
|
# 派生缓存列:导出的值应当等于按 ZVISIT 实算的 MAX(到达)
|
||||||
|
head("1.4 派生缓存列 ZLASTARRIVALDATE_ 是否按到访重算")
|
||||||
|
diff_rows = []
|
||||||
|
for pk, ch in cn.execute("SELECT Z_PK, ZLASTARRIVALDATE_ FROM ZLOCATION"):
|
||||||
|
r = cn.execute("SELECT max(ZARRIVALDATE_) FROM ZVISIT WHERE ZLOCATION=?", (pk,)).fetchone()[0]
|
||||||
|
if r is not None and ch != r:
|
||||||
|
diff_rows.append((pk, ch, r))
|
||||||
|
say("导出里「缓存 != MAX(到访到达)」的地点: %d" % len(diff_rows))
|
||||||
|
if diff_rows:
|
||||||
|
fail("导出有 %d 个地点的派生缓存没有按到访重算" % len(diff_rows))
|
||||||
|
else:
|
||||||
|
say(" 全部按 ZVISIT 重算一致 ✓(这个列不写会让 rond 显示「无数据」)")
|
||||||
|
stale = 0
|
||||||
|
for pk, ch in co.execute("SELECT Z_PK, ZLASTARRIVALDATE_ FROM ZLOCATION"):
|
||||||
|
r = co.execute("SELECT max(ZARRIVALDATE_) FROM ZVISIT WHERE ZLOCATION=?", (pk,)).fetchone()[0]
|
||||||
|
if r is not None and ch != r:
|
||||||
|
stale += 1
|
||||||
|
say("对照:原版自身「缓存 != MAX(到访到达)」的地点 %d 个(原版缓存陈旧,导出是修正)" % stale)
|
||||||
|
|
||||||
|
head("1.5 确定性(同一份数据导出两次是否一致)")
|
||||||
|
if new2 and os.path.exists(new2):
|
||||||
|
def dig(p):
|
||||||
|
d = extract(zipfile.ZipFile(p))
|
||||||
|
return hashlib.sha256(open(os.path.join(d, "LifeEasy.sqlite"), "rb").read()).hexdigest()
|
||||||
|
a, b = dig(new), dig(new2)
|
||||||
|
say(" LifeEasy.sqlite sha256: %s" % ("一致 ✓" if a == b else "不同 ×"))
|
||||||
|
say(" #1 %s" % a)
|
||||||
|
say(" #2 %s" % b)
|
||||||
|
if a != b:
|
||||||
|
fail("两次导出不一致:ZIDENTIFIER_/ZUID_ 之类若是随机的,rond 会把整条当新记录")
|
||||||
|
else:
|
||||||
|
say(" 未提供第二次导出,跳过(真担心就再导一次,比 sha256 即可)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- fwss
|
||||||
|
def compare_fwss(orig, new, new2=None):
|
||||||
|
head("二、fwss 迷雾快照结构对比")
|
||||||
|
say("原版: %s (%.1f KB)" % (os.path.basename(orig), os.path.getsize(orig) / 1024))
|
||||||
|
say("导出: %s (%.1f KB)" % (os.path.basename(new), os.path.getsize(new) / 1024))
|
||||||
|
fo, fn = zipfile.ZipFile(orig), zipfile.ZipFile(new)
|
||||||
|
fon, fnn = set(fo.namelist()), set(fn.namelist())
|
||||||
|
say()
|
||||||
|
say("%-14s %8s %8s %6s" % ("前缀", "原版", "导出", "差"))
|
||||||
|
for pref in ("Model/*/", "Model/~/", "Model/#/"):
|
||||||
|
a = len([n for n in fon if n.startswith(pref)])
|
||||||
|
b = len([n for n in fnn if n.startswith(pref)])
|
||||||
|
say("%-14s %8d %8d %6d" % (pref, a, b, b - a))
|
||||||
|
other = sorted(n for n in fnn if not n.startswith("Model/"))
|
||||||
|
if other:
|
||||||
|
say("非 Model/ 条目(原版没有 = 格式异常): %s" % other)
|
||||||
|
fail("导出里出现了 Model/ 之外的条目")
|
||||||
|
miss = sorted(fon - fnn)
|
||||||
|
say("缺少条目: %d %s" % (len(miss), miss[:5]))
|
||||||
|
if miss:
|
||||||
|
fail("导出缺少原版有的 ~/# 图层条目(快照会残缺)")
|
||||||
|
extra = sorted(fnn - fon)
|
||||||
|
say("多出条目: %d 个(Model/*/ 新瓦片 = 库里探索数据比快照多,属正常增长)" % len(extra))
|
||||||
|
if any(n.startswith("Model/~/") or n.startswith("Model/#/") for n in extra):
|
||||||
|
fail("多出的条目落在 ~/# 图层里(残留图层条目)")
|
||||||
|
|
||||||
|
bad_name, bad_len, bad_slot, bad_tail = [], [], [], []
|
||||||
|
blocks = cells = 0
|
||||||
|
star = sorted(n for n in fnn if n.startswith("Model/*/"))
|
||||||
|
for n in star:
|
||||||
|
base = n.split("/")[-1]
|
||||||
|
if not name_reversible(base):
|
||||||
|
bad_name.append(n)
|
||||||
|
continue
|
||||||
|
data = zlib.decompress(fn.read(n))
|
||||||
|
if len(data) < HEADER_SIZE or (len(data) - HEADER_SIZE) % BLOCK != 0:
|
||||||
|
bad_len.append((n, len(data)))
|
||||||
|
continue
|
||||||
|
nb = (len(data) - HEADER_SIZE) // BLOCK
|
||||||
|
blocks += nb
|
||||||
|
slots = [data[2 * i] | (data[2 * i + 1] << 8) for i in range(HEADER_LEN)]
|
||||||
|
used = [s for s in slots if s]
|
||||||
|
if sorted(used) != list(range(1, nb + 1)):
|
||||||
|
bad_slot.append((n, nb, len(used)))
|
||||||
|
for k in range(nb):
|
||||||
|
blk = data[HEADER_SIZE + k * BLOCK: HEADER_SIZE + (k + 1) * BLOCK]
|
||||||
|
pc = sum(bin(x).count("1") for x in blk[:BITMAP])
|
||||||
|
v = (blk[512] << 16) | (blk[513] << 8) | blk[514]
|
||||||
|
if v != pc * 2 + 1 or blk[512] != 0:
|
||||||
|
bad_tail.append(n)
|
||||||
|
break
|
||||||
|
cells += pc
|
||||||
|
say()
|
||||||
|
say("Model/*/: %d 个瓦片、%d 个块、%d 个已探索格" % (len(star), blocks, cells))
|
||||||
|
for label, bad, hint in (
|
||||||
|
("文件名可逆(md5(十进制id)[:4] + 两段掩码)", bad_name, "名字编错 fog of world 找不到瓦片"),
|
||||||
|
("长度 = 32768 + 515n(头 + 每块 515B)", bad_len, "头或块体长度不对"),
|
||||||
|
("槽位表自洽(非零槽 = 1..n 的排列)", bad_slot, "块体索引越界或重复引用"),
|
||||||
|
("块尾 24 位大端 = 置位数×2+1", bad_tail, "元信息与真机格式不符")):
|
||||||
|
if bad:
|
||||||
|
fail("%s:%d 处异常 %s(%s)" % (label, len(bad), bad[:3], hint))
|
||||||
|
else:
|
||||||
|
say(" %s ✓" % label)
|
||||||
|
|
||||||
|
say()
|
||||||
|
say("图层 ~/# 是不解析、原样写回的,应与原版逐字节一致:")
|
||||||
|
for pref in ("Model/~/", "Model/#/"):
|
||||||
|
a = sorted(n for n in fon if n.startswith(pref))
|
||||||
|
b = sorted(n for n in fnn if n.startswith(pref))
|
||||||
|
same = a == b and all(fo.read(n) == fn.read(n) for n in a)
|
||||||
|
say(" %-12s 原版 %d / 导出 %d 名字+字节一致=%s" % (pref, len(a), len(b), "是 ✓" if same else "否"))
|
||||||
|
if not same:
|
||||||
|
fail("%s 与原版不一致(少了快照会残缺,内容变了说明被解析过)" % pref)
|
||||||
|
|
||||||
|
head("2.1 确定性(同一份数据导出两次)")
|
||||||
|
if new2 and os.path.exists(new2):
|
||||||
|
a, b = zipfile.ZipFile(new), zipfile.ZipFile(new2)
|
||||||
|
an, bn = set(a.namelist()), set(b.namelist())
|
||||||
|
diff = [n for n in an & bn if a.read(n) != b.read(n)]
|
||||||
|
if an != bn or diff:
|
||||||
|
fail("两次导出条目不同:%d 个条目不匹配 %s" % (len(diff), diff[:3]))
|
||||||
|
else:
|
||||||
|
say(" 解压后逐条目字节一致 ✓(%d 个条目)" % len(an))
|
||||||
|
say(" zip 内条目顺序: %s(Go map 遍历顺序随机,顺序不保证,fog 靠文件名找)"
|
||||||
|
% ("相同" if a.namelist() == b.namelist() else "不同,不影响读取"))
|
||||||
|
else:
|
||||||
|
say(" 未提供第二次导出,跳过")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
mode = sys.argv[1]
|
||||||
|
args = sys.argv[2:]
|
||||||
|
if mode == "all":
|
||||||
|
if len(args) < 4:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
compare_rondbackup(args[0], args[1])
|
||||||
|
compare_fwss(args[2], args[3])
|
||||||
|
elif mode == "rondbackup":
|
||||||
|
if len(args) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
compare_rondbackup(args[0], args[1], args[2] if len(args) > 2 else None)
|
||||||
|
elif mode == "fwss":
|
||||||
|
if len(args) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
compare_fwss(args[0], args[1], args[2] if len(args) > 2 else None)
|
||||||
|
else:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
head("结论")
|
||||||
|
for w in WARNS:
|
||||||
|
say("注意: %s" % w)
|
||||||
|
say()
|
||||||
|
say("结构一致性: %s" % ("通过 ✓" if not FAILED[0] else "有差异(见上面带 !! 的行)"))
|
||||||
|
print("\n".join(OUT))
|
||||||
|
return 0 if not FAILED[0] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user