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

把专题的「一个圆」换成「一组圆」(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
+48 -8
View File
@@ -1572,6 +1572,15 @@ func dict(kv ...any) map[string]any {
// ---------- 专题 ----------
// TopicArea 是专题的一个圆形显示区(圆心 + 半径,半径单位公里)。
// 坐标是 **GCJ-02**(与 places 同系):地点过滤是拿它和 p.lat/p.lon 直接比球面距离的,
// 前台地图侧再由 rond-map.js 的 project() 换算到底图坐标系。存成底图坐标就会整体偏几百米。
type TopicArea struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
RadiusKm float64 `json:"radiusKm"`
}
// Topic 是一个行程专题:某段时间、某个区域内的足迹打包展示。
type Topic struct {
ID int64
@@ -1585,12 +1594,16 @@ type Topic struct {
EndDate string
// RangeLabel 是展示用的时间范围文案
RangeLabel string
CenterLat float64
CenterLon float64
RadiusKm float64
ShowFog bool
Enabled bool
SortOrder int
// Areas 是显示区(一组圆),空表示不限区域。
Areas []TopicArea
// CenterLat / CenterLon / RadiusKm **已废弃**:早期只支持一个圆时的那三列。
// 保留只为兼容「还没回填的老行」与「测试里直接构造 Topic 的调用方」——新代码一律走 Areas。
CenterLat float64
CenterLon float64
RadiusKm float64
ShowFog bool
Enabled bool
SortOrder int
// PlaceCount / VisitCount / DayCount / CityCount / DwellMin 由内容统计得出
PlaceCount int
VisitCount int
@@ -1599,8 +1612,35 @@ type Topic struct {
DwellMin int64
}
// HasArea 报告专题是否限定了区域(中心点 + 半径)。
func (t Topic) HasArea() bool { return t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) }
// AreaList 归一显示区:Areas 为空时退回老的单圆。
// 这样「还没回填的老行」和「测试里直接构造的 Topic」都继续工作,
// 不用要求每个调用方自己记着处理两套字段。
func (t Topic) AreaList() []TopicArea {
if len(t.Areas) > 0 {
return t.Areas
}
if t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) {
return []TopicArea{{Lat: t.CenterLat, Lon: t.CenterLon, RadiusKm: t.RadiusKm}}
}
return nil
}
// HasArea 报告专题是否限定了显示区。
func (t Topic) HasArea() bool { return len(t.AreaList()) > 0 }
// AreasJSON 把显示区序列化成内联给地图脚本的 JSON(不限区域时是 `[]`,前端按 length 判)。
// 走 AreaList() 而不是裸 Areas:保证「HasArea 为真」与「脚本拿得到圆」永远同一份判据。
func (t Topic) AreasJSON() template.JS {
areas := t.AreaList()
if len(areas) == 0 {
return template.JS("[]")
}
b, err := json.Marshal(areas)
if err != nil {
return template.JS("[]")
}
return template.JS(b)
}
// TopicAdminData 是后台专题管理页的数据。
type TopicAdminData struct {