diff --git a/internal/db/schema.sql b/internal/db/schema.sql index fc9656c..9c993e6 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -383,7 +383,7 @@ BEGIN END $$; -- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。 --- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。 +-- 「区域」是**一组圆**(areas,见下),空数组表示不限区域(时间范围内的地点画到哪算哪)。 -- -- ⚠️ dataset_id **故意不带外键**(早期版本带 ON DELETE CASCADE)。 -- 专题是「一段时间 + 一块区域」的**定义**,不是备份里的数据:换数据集之后它照旧成立 @@ -409,6 +409,22 @@ CREATE TABLE IF NOT EXISTS topics ( ); CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id); +-- 专题的显示区:**一组圆**,jsonb 数组,元素形如 {"lat":..,"lon":..,"radiusKm":..}。 +-- 坐标是 GCJ-02(与 places 同系);前台由 rond-map.js 的 project() 换算到底图坐标系。 +-- 落在**任一**圆内就算在区域内(并集)——地点过滤是 SQL 里的 OR,迷雾裁剪是 clip-path: path() +-- 的多段子路径(同绕向 ⇒ nonzero 填充规则下自然取并集)。 +-- +-- 为什么用 jsonb 而不是单开一张表:这些圆是专题定义的组成部分、跟专题一起读写、没有独立 +-- 生命周期,也就免了「能不能从备份重导」那道归属口径(topics 本来就只归人、不挂数据集)。 +-- +-- ⚠️ center_lat / center_lon / radius_km 三列**已废弃**(早期只支持一个圆),保留只为两件事: +-- 一是回滚旧二进制时还能退化显示成「第一个圆」,二是下面这段存量回填的输入。 +-- 新代码一律只读 areas;写入时把第一个圆镜像进这三列。 +ALTER TABLE topics ADD COLUMN IF NOT EXISTS areas JSONB NOT NULL DEFAULT '[]'::jsonb; +UPDATE topics SET areas = jsonb_build_array( + jsonb_build_object('lat', center_lat, 'lon', center_lon, 'radiusKm', radius_km)) + WHERE radius_km > 0 AND areas = '[]'::jsonb; + -- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。 -- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。 diff --git a/internal/web/handlers_topics.go b/internal/web/handlers_topics.go index dfd8fd6..bb02749 100644 --- a/internal/web/handlers_topics.go +++ b/internal/web/handlers_topics.go @@ -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") diff --git a/internal/web/topic_areas_test.go b/internal/web/topic_areas_test.go new file mode 100644 index 0000000..5da7c15 --- /dev/null +++ b/internal/web/topic_areas_test.go @@ -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) + } + } +} diff --git a/internal/web/topic_stats_test.go b/internal/web/topic_stats_test.go index c2b8619..eb3d8af 100644 --- a/internal/web/topic_stats_test.go +++ b/internal/web/topic_stats_test.go @@ -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 } diff --git a/internal/web/view.go b/internal/web/view.go index 3aa5f1f..20e86f1 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -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 {