专题显示区改为一组圆(后端与查询层)
把专题的「一个圆」换成「一组圆」(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:
+177
-28
@@ -9,10 +9,10 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/stats"
|
||||
)
|
||||
|
||||
@@ -41,6 +41,131 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ---------- 显示区(一组圆) ----------
|
||||
|
||||
const (
|
||||
// maxTopicAreas 限制显示区个数:每个圆在 SQL 里占 3 个占位符、在 clip-path 里是一段子路径,
|
||||
// 都不是能无限堆的东西;后台地图编辑器用同一个上限。
|
||||
maxTopicAreas = 20
|
||||
// 半径 50 米 ~ 2000 公里:再小在地图上拖不出来,再大没有意义。
|
||||
topicAreaMinRadiusKm = 0.05
|
||||
topicAreaMaxRadiusKm = 2000.0
|
||||
)
|
||||
|
||||
// parseTopicAreas 解析 areas 列。坏数据要上抛而不是吞掉——
|
||||
// 显示区决定了专题「有哪些地点」,静默退化成「全都要」比报错危险得多。
|
||||
func parseTopicAreas(raw []byte) ([]TopicArea, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var areas []TopicArea
|
||||
if err := json.Unmarshal(raw, &areas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return areas, nil
|
||||
}
|
||||
|
||||
// normalizeTopicAreas 校验并收敛显示区。crs 是**点选时所处底图**的坐标系:
|
||||
// 天地图那类 WGS-84 底图上点的圆心必须先换算成 GCJ-02 再存 ——
|
||||
// 地点过滤是拿它跟 places 的 p.lat/p.lon(GCJ-02)直接比球面距离的,不换算会整体偏几百米。
|
||||
func normalizeTopicAreas(areas []TopicArea, crs string) ([]TopicArea, error) {
|
||||
if len(areas) > maxTopicAreas {
|
||||
return nil, fmt.Errorf("显示区最多 %d 个", maxTopicAreas)
|
||||
}
|
||||
out := make([]TopicArea, 0, len(areas))
|
||||
for _, a := range areas {
|
||||
if math.IsNaN(a.Lat) || math.IsNaN(a.Lon) || math.IsNaN(a.RadiusKm) ||
|
||||
math.IsInf(a.Lat, 0) || math.IsInf(a.Lon, 0) || math.IsInf(a.RadiusKm, 0) {
|
||||
return nil, fmt.Errorf("显示区里有非法数值")
|
||||
}
|
||||
if math.Abs(a.Lat) > 90 || math.Abs(a.Lon) > 180 {
|
||||
return nil, fmt.Errorf("显示区的圆心超出经纬度范围")
|
||||
}
|
||||
if a.RadiusKm < topicAreaMinRadiusKm || a.RadiusKm > topicAreaMaxRadiusKm {
|
||||
return nil, fmt.Errorf("显示区的半径要在 %g ~ %g 公里之间", topicAreaMinRadiusKm, topicAreaMaxRadiusKm)
|
||||
}
|
||||
if crs == "wgs84" {
|
||||
a.Lat, a.Lon = geo.WGS84ToGCJ02(a.Lat, a.Lon)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// topicAreasColumn 是写进 areas 列的值。**空必须是 `[]` 而不是 `null`**:
|
||||
// 列上有 NOT NULL,而 json.Marshal(nil slice) 给出来的正好是 "null"。
|
||||
func topicAreasColumn(areas []TopicArea) []byte {
|
||||
if len(areas) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
b, err := json.Marshal(areas)
|
||||
if err != nil {
|
||||
return []byte("[]")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// topicAreasFromForm 从表单取显示区:优先读地图编辑器提交的 areas(JSON);
|
||||
// 没给才退回老的 center_lat / center_lon / radius_km 三字段(合成一个圆)。
|
||||
// 两条路走同一套校验与换算,免得「新入口有校验、老入口没有」。
|
||||
func topicAreasFromForm(r *http.Request) ([]TopicArea, error) {
|
||||
crs := strings.TrimSpace(r.PostFormValue("crs"))
|
||||
raw := strings.TrimSpace(r.PostFormValue("areas"))
|
||||
if raw == "" {
|
||||
lat, ok1 := parseFloat(r.PostFormValue("center_lat"))
|
||||
lon, ok2 := parseFloat(r.PostFormValue("center_lon"))
|
||||
rkm, ok3 := parseFloat(r.PostFormValue("radius_km"))
|
||||
// 老表单里「不限区域」就是留空或 0,不是错误
|
||||
if !ok1 || !ok2 || !ok3 || rkm <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if lat == 0 && lon == 0 {
|
||||
return nil, fmt.Errorf("限定区域时要在图上点选圆心")
|
||||
}
|
||||
return normalizeTopicAreas([]TopicArea{{Lat: lat, Lon: lon, RadiusKm: rkm}}, crs)
|
||||
}
|
||||
var areas []TopicArea
|
||||
if err := json.Unmarshal([]byte(raw), &areas); err != nil {
|
||||
return nil, fmt.Errorf("显示区数据无法解析")
|
||||
}
|
||||
return normalizeTopicAreas(areas, crs)
|
||||
}
|
||||
|
||||
// topicAreasBounds 求所有圆的并集外接矩形(GCJ-02 度)。
|
||||
// 只有一个圆时它就是这个圆的外接正方形,与原来 fitCircle 那条路(toBounds(2r km))等价,
|
||||
// 所以单圆专题的视野不变;多圆只有外接矩形才有意义。
|
||||
func topicAreasBounds(areas []TopicArea) (minLat, maxLat, minLon, maxLon float64, ok bool) {
|
||||
if len(areas) == 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
minLat, maxLat = math.MaxFloat64, -math.MaxFloat64
|
||||
minLon, maxLon = math.MaxFloat64, -math.MaxFloat64
|
||||
for _, a := range areas {
|
||||
dLat := a.RadiusKm / 111.32
|
||||
// 经度方向 1 度比纬度短,按纬度余弦放大;高纬处夹一下,避免除到极大
|
||||
cos := math.Cos(a.Lat * math.Pi / 180)
|
||||
if cos < 0.01 {
|
||||
cos = 0.01
|
||||
}
|
||||
dLon := a.RadiusKm / (111.32 * cos)
|
||||
minLat, maxLat = math.Min(minLat, a.Lat-dLat), math.Max(maxLat, a.Lat+dLat)
|
||||
minLon, maxLon = math.Min(minLon, a.Lon-dLon), math.Max(maxLon, a.Lon+dLon)
|
||||
}
|
||||
if minLat < -90 {
|
||||
minLat = -90
|
||||
}
|
||||
if maxLat > 90 {
|
||||
maxLat = 90
|
||||
}
|
||||
if minLon < -180 {
|
||||
minLon = -180
|
||||
}
|
||||
if maxLon > 180 {
|
||||
maxLon = 180
|
||||
}
|
||||
return minLat, maxLat, minLon, maxLon, true
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
|
||||
@@ -50,7 +175,7 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
|
||||
func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) {
|
||||
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
|
||||
radius_km, show_fog, enabled, sort_order
|
||||
radius_km, show_fog, enabled, sort_order, areas
|
||||
FROM topics WHERE user_id=$1`
|
||||
if onlyEnabled {
|
||||
q += ` AND enabled`
|
||||
@@ -64,10 +189,15 @@ func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool
|
||||
var out []Topic
|
||||
for rows.Next() {
|
||||
var t Topic
|
||||
var rawAreas []byte
|
||||
if err := rows.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); err != nil {
|
||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder,
|
||||
&rawAreas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||
return nil, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err)
|
||||
}
|
||||
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
||||
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
||||
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
|
||||
@@ -100,16 +230,23 @@ const topicHaversineSQL = `6371 * 2 * asin(sqrt(
|
||||
power(sin(radians((%s - p.lat) / 2)), 2) +
|
||||
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
||||
|
||||
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
|
||||
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
|
||||
// topicAreaCond 追加显示区的参数并返回区域条件:落在**任一**圆内就算在区域内(并集)。
|
||||
// 批量统计要重复拼这段,抽出来保证两条路径的占位符编号逐字一致 ——
|
||||
// 这里少算一个 $n,表现就是「专题内容全变 0」。每个圆占 3 个参数(纬度、经度、半径)。
|
||||
func topicAreaCond(t *Topic, args *[]any) string {
|
||||
if !t.HasArea() {
|
||||
areas := t.AreaList()
|
||||
if len(areas) == 0 {
|
||||
return ""
|
||||
}
|
||||
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||||
n := len(*args)
|
||||
return fmt.Sprintf(" AND %s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||||
conds := make([]string, 0, len(areas))
|
||||
for _, a := range areas {
|
||||
*args = append(*args, a.Lat, a.Lon, a.RadiusKm)
|
||||
n := len(*args)
|
||||
// 球面距离表达式的三个 %s 依次是 中心纬度 / 中心纬度 / 中心经度,最后再比半径
|
||||
conds = append(conds, fmt.Sprintf("%s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n))
|
||||
}
|
||||
return " AND (" + strings.Join(conds, " OR ") + ")"
|
||||
}
|
||||
|
||||
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
||||
@@ -258,14 +395,15 @@ func parseTopicForm(r *http.Request) (Topic, error) {
|
||||
return t, fmt.Errorf("结束时间不能早于开始时间")
|
||||
}
|
||||
t.StartAt, t.EndAt = start, end
|
||||
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
|
||||
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
|
||||
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
|
||||
if t.RadiusKm < 0 {
|
||||
t.RadiusKm = 0
|
||||
areas, err := topicAreasFromForm(r)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
|
||||
return t, fmt.Errorf("限定区域时要填中心点坐标")
|
||||
t.Areas = areas
|
||||
// 镜像第一个圆进那三列废弃列:万一回滚旧二进制,退化成「第一个圆」总比「没有区域」强。
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm = 0, 0, 0
|
||||
if len(areas) > 0 {
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm = areas[0].Lat, areas[0].Lon, areas[0].RadiusKm
|
||||
}
|
||||
t.ShowFog = r.PostFormValue("show_fog") == "on"
|
||||
t.Enabled = r.PostFormValue("enabled") == "on"
|
||||
@@ -345,10 +483,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
if id > 0 {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
|
||||
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
|
||||
show_fog=$11, enabled=$12, sort_order=$13
|
||||
show_fog=$11, enabled=$12, sort_order=$13, areas=$14::jsonb
|
||||
WHERE id=$1 AND user_id=$2`,
|
||||
id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder,
|
||||
topicAreasColumn(t.Areas)); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -360,10 +499,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
|
||||
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
||||
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb) RETURNING id`,
|
||||
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder).Scan(&newID); err != nil {
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder,
|
||||
topicAreasColumn(t.Areas)).Scan(&newID); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -443,11 +583,13 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var t Topic
|
||||
var rawAreas []byte
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas
|
||||
FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID).
|
||||
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,
|
||||
&rawAreas)
|
||||
if err == sql.ErrNoRows {
|
||||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||
return
|
||||
@@ -456,6 +598,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||
s.serverError(w, r, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err))
|
||||
return
|
||||
}
|
||||
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
||||
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
||||
if !t.Enabled && !p.IsAdmin() {
|
||||
@@ -512,14 +658,17 @@ func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points
|
||||
}
|
||||
}
|
||||
|
||||
// fitParams 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
|
||||
// fitParams 生成把地图视野框到专题范围的脚本参数:限定显示区时用**所有圆的并集外接矩形**,
|
||||
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
|
||||
//
|
||||
// 多圆之后不再有「圆心 + 半径」可言(fitCircle 只表达得了一个圆),统一走 fitBBox:
|
||||
// 单圆时外接矩形就是这个圆的外接正方形,与原来 fitCircle 那条路等价。
|
||||
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
|
||||
if t.HasArea() {
|
||||
if minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(t.AreaList()); ok {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
|
||||
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
||||
})
|
||||
return template.JS(b), template.JS("null")
|
||||
return template.JS("null"), template.JS(b)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return template.JS("null"), template.JS("null")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+48
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user