Files
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

487 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/geo"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// TestPlaceCoordSource 盯住「坐标来源留档」这条链路:建点时就记下来、编辑时不被误改、
// 换算时跟着坐标一起修正。
//
// 为什么要这么细:2026-09 那次照片导入把 WGS-84 的 EXIF 当成 GCJ-02 存了进去,
// 63 个点整体偏 500~670m,而库里没有任何记录能说明「当初选的是什么坐标系」,
// 最后只能逐张翻原图 EXIF 才裁出来。留档就是为了让这种事下一次能一眼看出。
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run CoordSource -v
func TestPlaceCoordSource(t *testing.T) {
f := newMovementFixture(t)
// ---- 1. rond 导入:留档必须自动补上,且原始坐标就是留档里的 ZRAWLATITUDE ----
var n, bad int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*),
count(*) FILTER (WHERE coord_source<>'rond' OR coord_sys<>'wgs84'
OR src_lat IS DISTINCT FROM (SELECT (r.raw->>'ZRAWLATITUDE')::float8 FROM entity_raw r
WHERE r.dataset_id=places.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=places.src_pk))
FROM places WHERE dataset_id=$1`, f.dsID).Scan(&n, &bad); err != nil {
t.Fatalf("统计留档失败: %v", err)
}
if n == 0 {
t.Fatal("数据集里没有地点,导入没成功")
}
if bad != 0 {
t.Fatalf("%d/%d 个地点没补上 rond 留档(或原始坐标与留档对不上)", bad, n)
}
// 挑一个「留档里的原始坐标 != 显示坐标」的点,也就是 rond 正常做过偏移的那种
var pk int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND src_lat IS NOT NULL AND abs(src_lat-lat)>1e-9
ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&pk); err != nil {
t.Fatalf("找不到做过偏移的点: %v", err)
}
type snapshot struct {
lat, lon float64
coordSource, coordSys string
srcLat, srcLon float64
hasSrc bool
name, province, street string
}
load := func() snapshot {
t.Helper()
var s snapshot
var sl, so *float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT lat, lon, COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon, COALESCE(name,''), COALESCE(province,''), COALESCE(thoroughfare,'')
FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).
Scan(&s.lat, &s.lon, &s.coordSource, &s.coordSys, &sl, &so, &s.name, &s.province, &s.street); err != nil {
t.Fatalf("读地点失败: %v", err)
}
if sl != nil && so != nil {
s.srcLat, s.srcLon, s.hasSrc = *sl, *so, true
}
return s
}
before := load()
if !before.hasSrc {
t.Fatal("rond 导入的点应当留下原始坐标")
}
// ---- 2. 只改名称:留档一个字都不能动,街道也不能被没提交的字段清掉 ----
post := func(path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/edit/place/update":
f.srv.editPlaceUpdate(rec, req)
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
return rec
}
// 先给这一行塞一个街道值,验「表单没提交就不该被清空」
f.mustExec(t, `UPDATE places SET thoroughfare='测试街道' WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk)
// 批量换算工具就是这么提交的:只有坐标与地址,没有留档字段
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name}, "category": {""},
"lat": {fmt.Sprintf("%.6f", before.lat)}, "lon": {fmt.Sprintf("%.6f", before.lon)},
"province": {before.province}, "city": {""}, "district": {""},
})
after := load()
if after.coordSource != before.coordSource || after.coordSys != before.coordSys ||
after.srcLat != before.srcLat || after.srcLon != before.srcLon {
t.Fatalf("只改名称却动了留档:%+v -> %+v", before, after)
}
if after.street != "测试街道" {
t.Fatalf("表单没提交街道,却被清成了 %q", after.street)
}
// ---- 3. 走了「坐标系换算」:留档要记成来源坐标系 + 换算前的坐标 ----
// 复刻页面的真实行为:JavaScript 先调 /api/coords 把坐标换算好再提交,
// 同时把「换算前的值 + 它属于哪个坐标系」一起带上(见 edit.html 的 coord-sel)。
// 这里反过来做:把这条 rond 点的显示坐标(GCJ)当 WGS 提交,看留档跟不跟着走。
// 隐藏字段是原值全精度带出来的(不是 %.6f),所以这里也用全精度提交,
// 免得把留档里那份原始坐标截成 6 位。
rawLat, rawLon := before.srcLat, before.srcLon
subLat, subLon := geo.WGS84ToGCJ02(rawLat, rawLon)
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name},
"lat": {fmt.Sprintf("%.6f", subLat)}, "lon": {fmt.Sprintf("%.6f", subLon)},
"province": {""}, "city": {""}, "district": {""},
"coord_source": {"manual"}, "coord_sys": {"wgs84"},
"src_lat": {fmt.Sprintf("%v", rawLat)}, "src_lon": {fmt.Sprintf("%v", rawLon)},
})
after = load()
if after.coordSys != "wgs84" || after.coordSource != "manual" {
t.Fatalf("留档没跟着换算走:%+v", after)
}
if after.srcLat != rawLat || after.srcLon != rawLon {
t.Fatalf("原始坐标没按表单给的记:想要 %v,%v 实际 %v,%v", rawLat, rawLon, after.srcLat, after.srcLon)
}
// 显示坐标 == 按留档换算原始坐标(表单按 6 位小数提交,所以留 1e-6 的余量)
if wlat, wlon := geo.WGS84ToGCJ02(after.srcLat, after.srcLon); abs(wlat-after.lat) > 1e-6 || abs(wlon-after.lon) > 1e-6 {
t.Fatalf("「显示坐标 == 按留档换算原始坐标」不成立:%v,%v vs %v,%v", wlat, wlon, after.lat, after.lon)
}
// ---- 4. 明确选「未记录」:整组留档一起清掉 ----
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name},
"lat": {fmt.Sprintf("%.6f", after.lat)}, "lon": {fmt.Sprintf("%.6f", after.lon)},
"province": {""}, "city": {""}, "district": {""},
"coord_source": {"none"}, "coord_sys": {"none"},
})
after = load()
if after.coordSource != "" || after.coordSys != "" || after.hasSrc {
t.Fatalf("选了「未记录」却没清干净:%+v", after)
}
// ---- 5. 后台新增地点:来源 manual / 坐标系 gcj02(地图点选与高德搜索都是 GCJ) ----
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"留档测试点"}, "lat": {"30.123456"}, "lon": {"120.123456"},
})
var newPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 AND name='留档测试点'`,
f.dsID).Scan(&newPK); err != nil {
t.Fatalf("新增地点没落库: %v", err)
}
var cs, cy string
var sl, so *float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, newPK).
Scan(&cs, &cy, &sl, &so); err != nil {
t.Fatalf("读新增点失败: %v", err)
}
if cs != "manual" || cy != "gcj02" || sl == nil || *sl != 30.123456 || so == nil || *so != 120.123456 {
t.Fatalf("新增地点的留档不对:source=%q sys=%q src=%v,%v", cs, cy, sl, so)
}
// ---- 6. 照片导入:来源 photo,原始坐标是换算前那一份 ----
// 选「已经是 GCJ-02」正是当初出事的选项,这里把它固化下来:一定要记下来。
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"gcj02"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"照片留档测试点"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
var pcs, pcy string
var psl, pso float64
var plat, plon float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT coord_source, coord_sys, src_lat, src_lon, lat, lon
FROM places WHERE dataset_id=$1 AND name='照片留档测试点'`, f.dsID).
Scan(&pcs, &pcy, &psl, &pso, &plat, &plon); err != nil {
t.Fatalf("照片导入没落库: %v", err)
}
if pcs != "photo" || pcy != "gcj02" {
t.Fatalf("照片导入的留档不对:source=%q sys=%q", pcs, pcy)
}
if psl != 22.853216 || pso != 113.253067 {
t.Fatalf("照片导入没记下换算前的原始坐标:%v,%v", psl, pso)
}
if plat != 22.853216 || plon != 113.253067 {
t.Fatalf("选了「已经是 GCJ-02」就不该换算,实际存成了 %v,%v", plat, plon)
}
// 再导一张按 WGS-84 处理的:显示坐标必须是换算后的值,原始坐标保持 EXIF 原样
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"照片留档测试点2"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
if err := f.pg.QueryRowContext(f.ctx, `SELECT coord_source, coord_sys, src_lat, src_lon, lat, lon
FROM places WHERE dataset_id=$1 AND name='照片留档测试点2'`, f.dsID).
Scan(&pcs, &pcy, &psl, &pso, &plat, &plon); err != nil {
t.Fatalf("照片导入(WGS)没落库: %v", err)
}
if pcs != "photo" || pcy != "wgs84" || psl != 22.853216 || pso != 113.253067 {
t.Fatalf("WGS-84 照片导入的留档不对:source=%q sys=%q src=%v,%v", pcs, pcy, psl, pso)
}
if wlat, wlon := geo.WGS84ToGCJ02(psl, pso); abs(wlat-plat) > 1e-9 || abs(wlon-plon) > 1e-9 {
t.Fatalf("显示坐标应当是换算后的值:%v,%v", plat, plon)
}
}
// TestCoordSourceSkipsExportSynthetic 盯住回填判据里最容易踩的那个坑:站点导出的备份会
// 给新建地点**合成** ZLOCATION 行,把 ZRAWLATITUDE 写成 GCJ02ToWGS84(导出那一刻的显示坐标)。
// 这种包被回导之后,「有 ZLOCATION 留档」就不再等于「rond 导入」,早期回填据此把 62 个
// 照片导入的点全标成了 rond。判据必须落在「ZRAW 是否恰好等于 gcj2wgs(该行自己的 ZLAT)」上。
//
// 这里手工造两行留档:一行合成(ZRAW = gcj2wgs(ZLAT)),一行真机(ZRAW 是独立采样值),
// 然后跑回填,断言只有真机那行被认领、合成那行不碰。
func TestCoordSourceSkipsExportSynthetic(t *testing.T) {
f := newMovementFixture(t)
// 造两个站点新建的点:编号取在基线之外,模拟「导出时还不存在」的新建地点
var maxPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(max(src_pk),0) FROM places
WHERE dataset_id=$1`, f.dsID).Scan(&maxPK); err != nil {
t.Fatalf("读编号上限失败: %v", err)
}
synthPK, devicePK := maxPK+1, maxPK+2
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) VALUES
($1,$2,'合成行测试点',30.5,120.5), ($1,$3,'真机行测试点',30.6,120.6)`, f.dsID, synthPK, devicePK)
// 合成行:ZRAW 严格等于 gcj2wgs(ZLAT)——正是 export.go 给新建地点的写法
synLat, synLon := geo.GCJ02ToWGS84(30.5, 120.5)
// 真机行:ZRAW 是独立采样值,显示坐标是 app 自己按 GCJ 偏移算的,两者不会位级吻合
// (真机基线 321 行零命中就是这个原因)。这里按同样的关系构造,但不让往返精确相等。
devRawLat, devRawLon := 30.7, 120.7
devLat, devLon := geo.WGS84ToGCJ02(devRawLat, devRawLon)
devLat, devLon = devLat+1e-9, devLon+1e-9
raw := func(lat, lon, rlat, rlon float64) string {
return fmt.Sprintf(`{"ZLATITUDE":%v,"ZLONGITUDE":%v,"ZRAWLATITUDE":%v,"ZRAWLONGITUDE":%v}`,
lat, lon, rlat, rlon)
}
f.mustExec(t, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped) VALUES
($1,'ZLOCATION',$2,$3::jsonb,false), ($1,'ZLOCATION',$4,$5::jsonb,false)`,
f.dsID, synthPK, raw(30.5, 120.5, synLat, synLon), devicePK, raw(devLat, devLon, devRawLat, devRawLon))
// 先故意把合成行写成早期回填的错误结果,验证回填会把它撤回
f.mustExec(t, `UPDATE places SET coord_source='rond', coord_sys='wgs84', src_lat=$3, src_lon=$4
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, synthPK, synLat, synLon)
if _, err := ingest.BackfillCoordSource(f.ctx, f.pg, f.dsID); err != nil {
t.Fatalf("回填失败: %v", err)
}
var source, sys string
var srcLat, srcLon *float64
load := func(pk int) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).
Scan(&source, &sys, &srcLat, &srcLon); err != nil {
t.Fatalf("读留档失败: %v", err)
}
}
// 合成行必须被撤回:它不是真机留档,来源应当是「未记录」
load(synthPK)
if source != "" || sys != "" || srcLat != nil || srcLon != nil {
t.Fatalf("导出合成行没被撤回:source=%q sys=%q src=%v,%v", source, sys, srcLat, srcLon)
}
// 真机行必须被认领,且原始坐标就是留档里的 ZRAW
load(devicePK)
if source != "rond" || sys != "wgs84" || srcLat == nil || *srcLat != devRawLat || srcLon == nil || *srcLon != devRawLon {
t.Fatalf("真机留档行没被正确认领:source=%q sys=%q src=%v,%v(想要 %v,%v)",
source, sys, srcLat, srcLon, devRawLat, devRawLon)
}
// 再跑一次必须幂等(启动时每次都会跑)
if _, err := ingest.BackfillCoordSource(f.ctx, f.pg, f.dsID); err != nil {
t.Fatalf("二次回填失败: %v", err)
}
load(devicePK)
if source != "rond" || srcLat == nil || *srcLat != devRawLat {
t.Fatalf("回填不幂等:%q %v", source, srcLat)
}
}
// TestExportPhotoImportFlag 盯住导出侧补的 ZISPHOTOIMPORT:rond 靠这一位区分
// 「照片导入」与「手工新增」,站点没有对应字段,只能从坐标来源留档推。
// 真机行(coord_source=rond)必须原样保留,不能被我们改写。
func TestExportPhotoImportFlag(t *testing.T) {
f := newMovementFixture(t)
// 照片导入一个点 → photo;手工新增一个点 → manual
post := func(path string, form url.Values) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
}
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"导出照片标志位测试点"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"导出手工点测试点"}, "lat": {"30.123456"}, "lon": {"120.123456"},
})
var photoPK, manualPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name='导出照片标志位测试点'`, f.dsID).Scan(&photoPK); err != nil {
t.Fatalf("照片导入点没落库: %v", err)
}
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name='导出手工点测试点'`, f.dsID).Scan(&manualPK); err != nil {
t.Fatalf("手工点没落库: %v", err)
}
// 基线里的真机行:留档里记着它自己那份 ZISPHOTOIMPORT,导出后必须一字不改
var devPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND coord_source='rond' ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&devPK); err != nil {
t.Fatalf("找不到真机点: %v", err)
}
srcDB := f.openSource(t)
var devWant int
if err := srcDB.QueryRow(`SELECT ZISPHOTOIMPORT FROM ZLOCATION WHERE Z_PK=?`, devPK).Scan(&devWant); err != nil {
t.Fatalf("读原包 ZISPHOTOIMPORT 失败: %v", err)
}
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
flagOf := func(pk int) int {
t.Helper()
var v sql.NullInt64
if err := edb.QueryRow(`SELECT ZISPHOTOIMPORT FROM ZLOCATION WHERE Z_PK=?`, pk).Scan(&v); err != nil {
t.Fatalf("读导出包 Z_PK=%d 失败: %v", pk, err)
}
if !v.Valid {
return -1
}
return int(v.Int64)
}
if got := flagOf(photoPK); got != 1 {
t.Errorf("照片导入的点 ZISPHOTOIMPORT 应为 1,实际 %d", got)
}
if got := flagOf(manualPK); got != 0 {
t.Errorf("手工新增的点 ZISPHOTOIMPORT 应为 0,实际 %d", got)
}
if got := flagOf(devPK); got != devWant {
t.Errorf("真机点 #%d 的 ZISPHOTOIMPORT 被改写:原有 %d,导出成 %d", devPK, devWant, got)
}
}
// TestInlineSaveKeepsFullPrecisionCoord 盯住「行内保存会把坐标截到 6 位小数」。
// 表单的经纬度框是 %.6f,而真机基线的点带长小数(26.46800422211455 这种)。
// 只改备注/名称时原样回贴,旧实现会把坐标写短——导出与原始包逐位对照就对不上。
// 与时间字段同一套规矩:落在同一个 6 位小数上就沿用库里那份。
func TestInlineSaveKeepsFullPrecisionCoord(t *testing.T) {
f := newMovementFixture(t)
var pk int
var lat0, lon0, srcLat0 float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk, lat, lon, src_lat FROM places
WHERE dataset_id=$1 AND coord_source='rond' AND src_lat IS NOT NULL
AND lat <> round(lat::numeric, 6)::float8 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(
&pk, &lat0, &lon0, &srcLat0); err != nil {
t.Skipf("本地基线的坐标都已经是 6 位,测不出截断: %v", err)
}
// 只改名称:经纬度按表单的 6 位小数原样回贴
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {"改了名字"},
"lat": {fmt.Sprintf("%.6f", lat0)}, "lon": {fmt.Sprintf("%.6f", lon0)},
"province": {""}, "city": {""}, "district": {""},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
f.srv.editPlaceUpdate(httptest.NewRecorder(), req)
var lat1, lon1, srcLat1 float64
var name string
if err := f.pg.QueryRowContext(f.ctx, `SELECT lat, lon, src_lat, name FROM places
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).Scan(&lat1, &lon1, &srcLat1, &name); err != nil {
t.Fatalf("读回失败: %v", err)
}
if name != "改了名字" {
t.Fatalf("名称没改成功: %q", name)
}
if lat1 != lat0 || lon1 != lon0 {
t.Errorf("只改名称却把坐标截短了:%v,%v → %v,%v", lat0, lon0, lat1, lon1)
}
if srcLat1 != srcLat0 {
t.Errorf("原始坐标被动过:%v → %v", srcLat0, srcLat1)
}
}
func (f *movementFixture) mustExec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, q, args...); err != nil {
t.Fatalf("执行 %s 失败: %v", q, err)
}
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
// TestEditPageSurvivesGarbageRawCoord 钉住「留档里的坐标不是数字时,编辑页照样打得开」。
//
// entity_raw 是原样存下来的 JSON,值不一定就是数字。以前那两列直接 `::float8`,
// 一条脏数据就能让整个数据编辑页 500:查询里还有 CASE / 正则等别的条件,表现是
// 「每次打开都 500、别的页面都正常」,很难一眼看出是哪一行哪一列的事。
func TestEditPageSurvivesGarbageRawCoord(t *testing.T) {
f := newMovementFixture(t)
var pk int
var orig []byte
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, r.raw FROM places p
JOIN entity_raw r ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
WHERE p.dataset_id=$1 ORDER BY p.src_pk LIMIT 1`, f.dsID).Scan(&pk, &orig); err != nil {
t.Fatalf("找不到带留档的地点: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `UPDATE entity_raw SET raw=$1::jsonb
WHERE dataset_id=$2 AND entity='ZLOCATION' AND src_pk=$3`, string(orig), f.dsID, pk)
})
get := func() *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx)
rec := httptest.NewRecorder()
f.srv.editPage(rec, req)
return rec
}
if rec := get(); rec.Code != 200 {
t.Fatalf("基线就渲染失败:%d", rec.Code)
}
// 坐标位置塞一个「像数字但不是数字」的字符串:旧那条正则 '^-?[0-9.]+$' 会放行它
if _, err := f.pg.ExecContext(f.ctx,
`UPDATE entity_raw SET raw = jsonb_set(raw, '{ZLATITUDE}', to_jsonb('1.2.3'::text))
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, f.dsID, pk); err != nil {
t.Fatalf("写入脏留档失败: %v", err)
}
rec := get()
if rec.Code != 200 {
t.Fatalf("留档坐标不是数字时编辑页返回 %d,应照常 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "</html>") {
t.Error("页面被截断(没有收尾的 </html>)")
}
}