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

把专题的「一个圆」换成「一组圆」(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
+92 -2
View File
@@ -34,6 +34,41 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
_ = f.srv.db.QueryRowContext(ctx, `SELECT city FROM places WHERE dataset_id=$1
AND COALESCE(city,'')<>'' GROUP BY city ORDER BY count(*) DESC LIMIT 1`, ds).Scan(&city)
// 挑两个**离得最远**的地点做圆心,好让两个圆尽量不相交 —— 相交的话
// 「并集 = 两个单圆之和」这个判据就退化成「两个圆其实差不多」,抓不住 OR 只算一半的错。
type pt struct {
lat, lon float64
}
var cand []pt
rows, err := f.srv.db.QueryContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL
AND COALESCE(lat,0)<>0 AND COALESCE(lon,0)<>0
ORDER BY visit_count DESC LIMIT 200`, ds)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var p pt
if err := rows.Scan(&p.lat, &p.lon); err != nil {
t.Fatal(err)
}
cand = append(cand, p)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
rows.Close()
if len(cand) < 2 {
t.Skip("生效数据集里不足两个带坐标的地点,无法构造多圆用例")
}
a0, a1, far := cand[0], cand[1], 0.0
for _, p := range cand[1:] {
d := (p.lat-a0.lat)*(p.lat-a0.lat) + (p.lon-a0.lon)*(p.lon-a0.lon)
if d > far {
far, a1 = d, p
}
}
mid := minAt.Time.Add(maxAt.Time.Sub(minAt.Time) / 2)
base := []Topic{
{ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time},
@@ -43,6 +78,14 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
CenterLat: lat, CenterLon: lon, RadiusKm: 30},
{ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time,
CenterLat: 1, CenterLon: 1, RadiusKm: 1},
// 6/7/8 是多圆用例:8 必须恰好等于 6 与 7 的并集(下面有专门断言)
{ID: 6, Title: "圆A", StartAt: minAt.Time, EndAt: maxAt.Time,
Areas: []TopicArea{{Lat: a0.lat, Lon: a0.lon, RadiusKm: 5}}},
{ID: 7, Title: "圆B", StartAt: minAt.Time, EndAt: maxAt.Time,
Areas: []TopicArea{{Lat: a1.lat, Lon: a1.lon, RadiusKm: 5}}},
{ID: 8, Title: "A+B(并集)", StartAt: minAt.Time, EndAt: maxAt.Time,
Areas: []TopicArea{{Lat: a0.lat, Lon: a0.lon, RadiusKm: 5},
{Lat: a1.lat, Lon: a1.lon, RadiusKm: 5}}},
}
type round struct {
@@ -59,19 +102,28 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
)
}
// 多圆并集语义至少要被真正测到一轮(两个圆都非空),否则形同虚设
unionRounds := 0
for _, rd := range rounds {
// 详情页路径:逐专题查询
want := append([]Topic(nil), base...)
placeSets := map[int64]map[int64]bool{}
for i := range want {
points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude)
if err != nil {
t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err)
}
want[i].PlaceCount, want[i].VisitCount = len(points), visits
want[i].DayCount, want[i].CityCount = days, cities
ids := map[int64]bool{}
for _, p := range points {
ids[p.ID] = true
want[i].DwellMin += p.Dwell
}
placeSets[want[i].ID] = ids
want[i].PlaceCount, want[i].VisitCount = len(points), visits
want[i].DayCount, want[i].CityCount = days, cities
}
if checkAreaUnionSemantics(t, rd.name, placeSets) {
unionRounds++
}
// 列表页路径:一条 UNION ALL
@@ -104,4 +156,42 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
}
t.Logf("[%s] %d 个专题逐个比对通过(有内容 %d 个)", rd.name, len(got), nonZero)
}
if unionRounds == 0 {
t.Error("多圆并集语义一轮都没真正测到(两个圆里总有一个是空的)—— 用例形同虚设")
}
}
// checkAreaUnionSemantics 校验多圆语义:主题 8(两个圆)的地点集合必须**恰好**等于
// 主题 6 与 7(各一个圆)的并集。这是定义性不变量,不是相对计数:
// - 只认第一个圆 ⇒ 退化成 6 的子集,7 那些地点全部缺失;
// - OR 写成 AND ⇒ 退化成 6 与 7 的交集(两圆不相交时直接空)。
//
// 返回值报告这一轮是否「真的测到了」:两个圆都非空才有区分力。
// 带区域白/黑名单时其中一个圆可能整个被过滤掉(圆心在外地),那是正常数据行为,
// 所以不在这里硬失败,改由调用方要求「至少有一轮测到」。
func checkAreaUnionSemantics(t *testing.T, round string, sets map[int64]map[int64]bool) bool {
t.Helper()
a, b, both := sets[6], sets[7], sets[8]
if a == nil || b == nil || both == nil {
t.Fatalf("[%s] 多圆用例没跑起来(sets 里缺 6/7/8)", round)
}
union := map[int64]bool{}
for id := range a {
union[id] = true
}
for id := range b {
union[id] = true
}
for id := range union {
if !both[id] {
t.Errorf("[%s] 并集里的地点 %d 没出现在两圆专题里(少算了某个圆?)", round, id)
}
}
for id := range both {
if !union[id] {
t.Errorf("[%s] 两圆专题多出地点 %d(不属于任何一个圆)", round, id)
}
}
t.Logf("[%s] 多圆并集:A=%d B=%d 并集=%d 两圆专题=%d", round, len(a), len(b), len(union), len(both))
return len(a) > 0 && len(b) > 0
}