专题显示区改为一组圆(后端与查询层)

把专题的「一个圆」换成「一组圆」(areas jsonb 数组):落在任一圆内即算在
区域内(SQL 里的 OR)。这一批只动后端与查询层,前台渲染与后台地图编辑器
分别跟进;后台表单暂时沿用原来的三个坐标输入框,保存时合成一个圆写进
areas,所以现有交互不受影响。

- topics.areas JSONB + 存量回填:老的单圆搬进去,之后 areas 是唯一权威。
  废弃的 center_lat/center_lon/radius_km 保留(写入时镜像第一个圆),
  一是回滚旧二进制时还能退化显示成第一个圆,二是回填的输入。
- Topic.AreaList() 归一:Areas 为空时退回老的三列,未回填的老行与直接构造
  Topic 的调用方都继续工作。
- topicAreaCond 改成 N 个圆的 OR;每个圆占 3 个占位符,编号纪律照旧
  (写错就是「专题内容全变 0」那种难查的事故)。
- fitParams 改用所有圆的并集外接矩形:多圆之后「圆心+半径」表达不了,
  统一走 fitBBox;单圆时外接矩形就是这个圆的外接正方形,与原路径等价。
- 坐标系:编辑器提交的圆心若是 WGS-84 底图上点的,存前换算成 GCJ-02
  (地点过滤要拿它跟 places 的 GCJ-02 坐标比距离)。

测试:
- 表单解析(两个入口同一套校验)、wgs84 换算、半径为 0/空 = 不限区域、
  坏 JSON/越界必须报错、上限 20 个。
- 占位符纪律:本段引用的 $n 必须恰好是 [prefix+1, len(args)] 连续区间,
  最大值必须等于参数个数。
- 多圆并集语义(DB 门控):两圆专题的地点集合必须恰好等于两个单圆专题的
  并集 —— 定义性不变量,变异验证过(只认第一个圆 ⇒ 两个用例同时红)。
This commit is contained in:
tamakyi
2026-10-12 16:29:46 +08:00
parent cc37c9d1c9
commit 1bb35dbbc9
5 changed files with 725 additions and 39 deletions
+391
View File
@@ -0,0 +1,391 @@
package web
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
"rond-with-you/internal/geo"
)
func closeTo(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
// 显示区(一组圆)的数据层不变量。这些用例都是纯函数级的,不需要数据库。
// formPost 造一个已经 ParseForm 过的 POST 请求,供 topicAreasFromForm 读字段。
func formPost(t *testing.T, values url.Values) *http.Request {
t.Helper()
r := httptest.NewRequest("POST", "/admin/topics/save", strings.NewReader(values.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
return r
}
// ---------- 表单解析:两个入口必须走同一套校验 ----------
func TestTopicAreasFromForm(t *testing.T) {
type want struct {
areas int
lat float64
lon float64
km float64
}
cases := []struct {
name string
values url.Values
want want
wantErr string
}{
{
name: "老表单三字段 → 合成一个圆",
values: url.Values{"center_lat": {"25.6"}, "center_lon": {"100.2"}, "radius_km": {"30"}},
want: want{areas: 1, lat: 25.6, lon: 100.2, km: 30},
},
{
name: "老表单全空 → 不限区域(不是错误)",
values: url.Values{"center_lat": {""}, "center_lon": {""}, "radius_km": {""}},
want: want{areas: 0},
},
{
name: "老表单半径 0 → 不限区域",
values: url.Values{"center_lat": {"25.6"}, "center_lon": {"100.2"}, "radius_km": {"0"}},
want: want{areas: 0},
},
{
name: "老表单有半径但圆心是 0,0 → 报错(不能静默变成「圈在几内亚湾」)",
values: url.Values{"center_lat": {"0"}, "center_lon": {"0"}, "radius_km": {"30"}},
wantErr: "点选圆心",
},
{
name: "编辑器 JSON → 多个圆",
values: url.Values{"areas": {`[{"lat":25,"lon":100,"radiusKm":3},{"lat":26,"lon":101,"radiusKm":5}]`}},
want: want{areas: 2, lat: 25, lon: 100, km: 3},
},
{
name: "编辑器 JSON 空数组 → 不限区域",
values: url.Values{"areas": {"[]"}},
want: want{areas: 0},
},
{
name: "坏 JSON → 报错而不是当成「不限区域」",
values: url.Values{"areas": {"[{lat:25]"}},
wantErr: "无法解析",
},
{
name: "半径越界 → 报错",
values: url.Values{"areas": {`[{"lat":25,"lon":100,"radiusKm":0.001}]`}},
wantErr: "半径",
},
{
name: "纬度越界 → 报错",
values: url.Values{"areas": {`[{"lat":91,"lon":100,"radiusKm":3}]`}},
wantErr: "超出经纬度",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := topicAreasFromForm(formPost(t, tc.values))
if tc.wantErr != "" {
if err == nil {
t.Fatalf("期望报错(含 %q),却通过了:%+v", tc.wantErr, got)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("错误信息里应含 %q,实得 %q", tc.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("不该报错: %v", err)
}
if len(got) != tc.want.areas {
t.Fatalf("圆的个数 = %d,期望 %d(%+v)", len(got), tc.want.areas, got)
}
if tc.want.areas > 0 {
if got[0].Lat != tc.want.lat || got[0].Lon != tc.want.lon || got[0].RadiusKm != tc.want.km {
t.Fatalf("第一个圆 = %+v,期望 {%v %v %v}", got[0], tc.want.lat, tc.want.lon, tc.want.km)
}
}
})
}
}
// wgs84 底图上点的圆心必须换算成 GCJ-02 再存:地点过滤是拿它跟 places 的 GCJ-02 坐标
// 直接比球面距离的,不换算会整体偏几百米(历史上照片导入那次就是这么偏掉的)。
func TestTopicAreasFromFormConvertsWGS84(t *testing.T) {
const wgsLat, wgsLon = 25.6, 100.2
gcjLat, gcjLon := geo.WGS84ToGCJ02(wgsLat, wgsLon)
args := []url.Values{
// 编辑器路径
{"areas": {fmt.Sprintf(`[{"lat":%v,"lon":%v,"radiusKm":3}]`, wgsLat, wgsLon)}, "crs": {"wgs84"}},
// 老表单路径
{"center_lat": {fmt.Sprint(wgsLat)}, "center_lon": {fmt.Sprint(wgsLon)},
"radius_km": {"3"}, "crs": {"wgs84"}},
}
for i, v := range args {
got, err := topicAreasFromForm(formPost(t, v))
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
if len(got) != 1 {
t.Fatalf("[%d] 应得 1 个圆,实得 %d", i, len(got))
}
if !closeTo(got[0].Lat, gcjLat) || !closeTo(got[0].Lon, gcjLon) {
t.Errorf("[%d] 没换算成 GCJ-02:得 %.6f,%.6f 期望 %.6f,%.6f", i, got[0].Lat, got[0].Lon, gcjLat, gcjLon)
}
if d := math.Hypot(got[0].Lat-wgsLat, got[0].Lon-wgsLon); d < 1e-4 {
t.Errorf("[%d] 换算前后竟然一样(说明根本没换算)", i)
}
}
// crs 不是 wgs84 时保持原值
got, err := topicAreasFromForm(formPost(t, url.Values{
"areas": {fmt.Sprintf(`[{"lat":%v,"lon":%v,"radiusKm":3}]`, wgsLat, wgsLon)}, "crs": {"gcj02"}}))
if err != nil {
t.Fatal(err)
}
if got[0].Lat != wgsLat || got[0].Lon != wgsLon {
t.Errorf("gcj02 底图不该换算,实得 %+v", got[0])
}
}
// ---------- SQL 占位符纪律 ----------
var placeholderRe = regexp.MustCompile(`\$(\d+)`)
// topicAreaCond 拼进 SQL 里的 $n 必须**恰好**是 [prefix+1, len(args)] 这个连续区间:
// 不跳号也不越界。这正是「专题内容全变 0」那类事故的判据 —— 少算一个就是 pgx
// 「expected N arguments, got M」,而列表页会把错误吞掉,只表现为数字变 0。
//
// 注意断言的是「本段自己用的那 3n 个编号」:前缀那几个(列表页批量的 dataset/start/end)
// 由查询里的别处引用,不该出现在区域条件里。
func TestTopicAreaCondPlaceholderDiscipline(t *testing.T) {
for _, n := range []int{1, 2, 3, 7} {
for _, prefix := range []int{3, 0} { // 3 = 列表页批量(dataset/start/end 先占位)
areas := make([]TopicArea, 0, n)
for i := 0; i < n; i++ {
areas = append(areas, TopicArea{
Lat: 20 + float64(i), Lon: 100 + float64(i), RadiusKm: 3})
}
topic := &Topic{Areas: areas}
args := make([]any, 0, prefix+3*n)
for i := 0; i < prefix; i++ {
args = append(args, i)
}
cond := topicAreaCond(topic, &args)
if len(args) != prefix+3*n {
t.Fatalf("n=%d prefix=%d:参数个数 = %d,期望 %d", n, prefix, len(args), prefix+3*n)
}
seen := map[int]int{}
maxIdx, count := 0, 0
for _, m := range placeholderRe.FindAllStringSubmatch(cond, -1) {
idx, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("解析占位符 %q 失败", m[1])
}
if seen[idx] == 0 {
count++
}
seen[idx]++
if idx > maxIdx {
maxIdx = idx
}
}
// 最大值必须正好是最后一个参数 —— 否则下一个条件接着编号就会错位
if maxIdx != len(args) {
t.Errorf("n=%d prefix=%d:最大编号 = $%d,应正好是 $%d(否则后续条件会错位)\n%s",
n, prefix, maxIdx, len(args), cond)
}
// 且必须是 prefix+1 起、3n 个互不相同的连续编号
if count != 3*n {
t.Errorf("n=%d prefix=%d:引用了 %d 个不同编号,期望 %d\n%s", n, prefix, count, 3*n, cond)
}
for idx := prefix + 1; idx <= len(args); idx++ {
if seen[idx] == 0 {
t.Errorf("n=%d prefix=%d:$%d 没被引用(跳号)\n%s", n, prefix, idx, cond)
}
}
for idx := 1; idx <= prefix; idx++ {
if seen[idx] != 0 {
t.Errorf("n=%d prefix=%d:不该引用前缀的 $%d(那是查询别处的参数)", n, prefix, idx)
}
}
// OR 的个数 = 圆的个数 - 1(并集语义:落在任一圆内即可)
if got := strings.Count(cond, " OR "); got != n-1 {
t.Errorf("n=%d:OR 个数 = %d,期望 %d\n%s", n, got, n-1, cond)
}
}
}
// 不限区域时既不加参数也不加条件
args := []any{1, 2, 3}
if cond := topicAreaCond(&Topic{}, &args); cond != "" || len(args) != 3 {
t.Errorf("不限区域时应空手而归,实得 cond=%q args=%d", cond, len(args))
}
}
// 老行(areas 还没回填)与测试里直接构造 Topic 的写法都得继续工作。
func TestTopicAreaListFallsBackToLegacyCircle(t *testing.T) {
legacy := Topic{CenterLat: 25, CenterLon: 100, RadiusKm: 30}
if !legacy.HasArea() {
t.Error("只有老的三列时 HasArea 应为真")
}
if got := legacy.AreaList(); len(got) != 1 || got[0].RadiusKm != 30 {
t.Errorf("应退回单圆,实得 %+v", got)
}
// Areas 非空时以 Areas 为准,老的列不再参与
both := Topic{CenterLat: 1, CenterLon: 1, RadiusKm: 1,
Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}, {Lat: 26, Lon: 101, RadiusKm: 4}}}
if got := both.AreaList(); len(got) != 2 || got[0].Lat != 25 {
t.Errorf("应以 Areas 为准,实得 %+v", got)
}
// 半径 0 / 无坐标 = 不限区域
if (Topic{}).HasArea() || (Topic{CenterLat: 25, CenterLon: 100}).HasArea() {
t.Error("没有圆时 HasArea 应为假")
}
}
// 出库值不能是 JSON null:areas 列有 NOT NULL,而 json.Marshal(nil slice) 恰好给 "null"。
func TestTopicAreasColumnNeverNull(t *testing.T) {
for _, in := range [][]TopicArea{nil, {}, {{Lat: 1, Lon: 2, RadiusKm: 3}}} {
got := string(topicAreasColumn(in))
if got == "null" {
t.Fatalf("输入 %+v 时写成了 null(列上有 NOT NULL)", in)
}
var back []TopicArea
if err := json.Unmarshal([]byte(got), &back); err != nil {
t.Fatalf("%s 不是合法 JSON: %v", got, err)
}
if len(back) != len(in) {
t.Errorf("往返后圆的个数 %d ≠ %d", len(back), len(in))
}
}
}
// ---------- 视野贴合 ----------
// 外接矩形必须把**每一个**圆都包住(漏一个圆,那个圆里的迷雾就会被挤到视野外)。
func TestTopicAreasBoundsCoversEveryCircle(t *testing.T) {
areas := []TopicArea{
{Lat: 25.0, Lon: 100.0, RadiusKm: 3},
{Lat: 30.5, Lon: 110.25, RadiusKm: 10},
{Lat: 20.0, Lon: 95.0, RadiusKm: 50},
}
minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(areas)
if !ok {
t.Fatal("应返回 ok")
}
for _, a := range areas {
dLat := a.RadiusKm / 111.32
cos := math.Cos(a.Lat * math.Pi / 180)
dLon := a.RadiusKm / (111.32 * cos)
if a.Lat-dLat < minLat-1e-9 || a.Lat+dLat > maxLat+1e-9 ||
a.Lon-dLon < minLon-1e-9 || a.Lon+dLon > maxLon+1e-9 {
t.Errorf("圆 %+v 没被外接矩形包住:纬度[%v,%v] 经度[%v,%v]",
a, minLat, maxLat, minLon, maxLon)
}
}
// 单圆时与「圆心 ± r/111.32」等价(原来 fitCircle 用 toBounds(2r) 给的就是这个正方形)
one := []TopicArea{{Lat: 25.0, Lon: 100.0, RadiusKm: 30}}
minLat, maxLat, minLon, maxLon, _ = topicAreasBounds(one)
if !closeTo(minLat, 25-30/111.32) || !closeTo(maxLat, 25+30/111.32) {
t.Errorf("单圆纬度区间 = [%v,%v]", minLat, maxLat)
}
wantDLon := 30 / (111.32 * math.Cos(25*math.Pi/180))
if !closeTo(minLon, 100-wantDLon) || !closeTo(maxLon, 100+wantDLon) {
t.Errorf("单圆经度区间 = [%v,%v],期望 ±%v", minLon, maxLon, wantDLon)
}
// 空 → 没有矩形
if _, _, _, _, ok := topicAreasBounds(nil); ok {
t.Error("没有圆时不该返回矩形")
}
}
// 贴合参数:有显示区就走 bbox、不再给 fitCircle(多圆表达不了圆心+半径)。
func TestFitParamsUsesBBoxForAreas(t *testing.T) {
circle, bbox := fitParams(Topic{Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}}}, nil)
if string(circle) != "null" {
t.Errorf("限定显示区时不该再给 fitCircle,实得 %s", circle)
}
var b struct{ MinLat, MaxLat, MinLon, MaxLon float64 }
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("fitBBox 不是合法 JSON: %s (%v)", bbox, err)
}
if !(b.MinLat < 25 && b.MaxLat > 25 && b.MinLon < 100 && b.MaxLon > 100) {
t.Errorf("fitBBox 应包住圆心:%+v", b)
}
// 不限区域时仍退回点集包围盒
circle, bbox = fitParams(Topic{}, []MapPoint{{Lat: 21, Lon: 99}, {Lat: 23, Lon: 103}})
if string(circle) != "null" {
t.Errorf("点集路径不该给 fitCircle,实得 %s", circle)
}
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("点集 fitBBox 不是合法 JSON: %s", bbox)
}
if b.MinLat != 21 || b.MaxLat != 23 || b.MinLon != 99 || b.MaxLon != 103 {
t.Errorf("点集包围盒 = %+v", b)
}
// 两者都没有 → 两个 null
circle, bbox = fitParams(Topic{}, nil)
if string(circle) != "null" || string(bbox) != "null" {
t.Errorf("既无显示区又无点时应给两个 null,实得 %s / %s", circle, bbox)
}
}
// 经纬度夹取:极地 / 超大半径不至于算出 ±inf 或越界值喂给 fitBounds。
func TestTopicAreasBoundsClampsToGlobe(t *testing.T) {
minLat, maxLat, minLon, maxLon, ok := topicAreasBounds([]TopicArea{{Lat: 89.9, Lon: 179.9, RadiusKm: 2000}})
if !ok {
t.Fatal("应返回 ok")
}
for _, v := range []float64{minLat, maxLat} {
if v < -90 || v > 90 || math.IsInf(v, 0) || math.IsNaN(v) {
t.Errorf("纬度夹取后仍是 %v", v)
}
}
for _, v := range []float64{minLon, maxLon} {
if v < -180 || v > 180 || math.IsInf(v, 0) || math.IsNaN(v) {
t.Errorf("经度夹取后仍是 %v", v)
}
}
}
// ---------- 个数上限 ----------
func TestNormalizeTopicAreasLimits(t *testing.T) {
ok := make([]TopicArea, maxTopicAreas)
for i := range ok {
ok[i] = TopicArea{Lat: 20 + float64(i)/100, Lon: 100, RadiusKm: 3}
}
if _, err := normalizeTopicAreas(ok, ""); err != nil {
t.Errorf("%d 个圆应当允许:%v", maxTopicAreas, err)
}
tooMany := append(append([]TopicArea(nil), ok...), TopicArea{Lat: 25, Lon: 100, RadiusKm: 3})
if _, err := normalizeTopicAreas(tooMany, ""); err == nil {
t.Errorf("%d 个圆应当拒绝", len(tooMany))
}
for _, bad := range []TopicArea{
{Lat: math.NaN(), Lon: 100, RadiusKm: 3},
{Lat: 25, Lon: math.Inf(1), RadiusKm: 3},
{Lat: 25, Lon: 100, RadiusKm: math.NaN()},
{Lat: 25, Lon: 100, RadiusKm: topicAreaMaxRadiusKm + 1},
{Lat: 25, Lon: 100, RadiusKm: 0},
} {
if _, err := normalizeTopicAreas([]TopicArea{bad}, ""); err == nil {
t.Errorf("%+v 应当被拒绝", bad)
}
}
}