地点补全:按地名与坐标猜活动与类别,只填空

后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来,
给出建议、由站长确认后再写。判断分三层:

- 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水)
- 高德周边搜索:按坐标问「这里是什么 POI」,比按名字搜准;
  拿到的类型再映射成活动与 MapKit 类别
- 人工:都猜不出的留空,站长自己填;填了列表里没有的名字会自动建成新活动类型

两条底线:**只填空**(已有活动或类别的一律不动);**活动按地点批量设**,
一次把它名下所有没设活动的到访一起填上,否则同一个地点会在统计里分裂成两种活动。

类别写的是 Apple MapKit 的枚举名(真机确认用过 12 种),不是高德分类。

规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则
「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先
(否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。
This commit is contained in:
tamakyi
2026-09-19 16:31:01 +08:00
parent 30c502ce7b
commit 6c061a0548
8 changed files with 1012 additions and 3 deletions
+170
View File
@@ -0,0 +1,170 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// fillupPlace 在测试数据集里造一个「没类别、名下到访全都没活动」的地点。
func fillupPlace(t *testing.T, f *movementFixture) (placeID int64, placePK int) {
t.Helper()
const pk = 670001
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
VALUES ($1,$2,'测试补全点',31.1,121.1) RETURNING id`, f.dsID, pk).Scan(&placeID); err != nil {
t.Fatalf("建地点失败: %v", err)
}
for _, vpk := range []int{670002, 670003} {
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits
(dataset_id, src_pk, place_id, arrival, departure)
VALUES ($1,$2,$3,'2026-01-01 10:00:00+08','2026-01-01 11:00:00+08')`,
f.dsID, vpk, placeID); err != nil {
t.Fatalf("建到访失败: %v", err)
}
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM visits WHERE dataset_id=$1 AND place_id=$2`, f.dsID, placeID)
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE id=$1`, placeID)
})
return placeID, pk
}
func applyFillup(t *testing.T, f *movementFixture, form url.Values) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/fillup/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.srv.adminFillupApply(rec, req.WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("补全应跳回页面: code=%d body=%s", rec.Code, rec.Body.String())
}
return rec.Header().Get("Location")
}
// 补活动是按**地点**批量做的:一次把它名下所有没活动的到访一起设,不是只改一条。
func TestFillupSetsActivityForWholePlace(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
acts, err := f.srv.activityOptions(f.ctx, f.dsID)
if err != nil || len(acts) == 0 {
t.Fatalf("取活动失败: %v", err)
}
target := acts[0].Name
loc := applyFillup(t, f, url.Values{
"act_" + strconv.FormatInt(placeID, 10): {target},
})
if strings.Contains(loc, "err=") {
t.Fatalf("补全失败: %s", loc)
}
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id=$2
AND activity_id IS NULL`, f.dsID, placeID); n != 0 {
t.Fatalf("该地点两条到访都该设上活动,还剩 %d 条没设", n)
}
var got string
if err := f.pg.QueryRowContext(f.ctx, `SELECT a.name FROM visits v JOIN activities a ON a.id=v.activity_id
WHERE v.dataset_id=$1 AND v.place_id=$2 LIMIT 1`, f.dsID, placeID).Scan(&got); err != nil {
t.Fatalf("读活动失败: %v", err)
}
if got != target {
t.Fatalf("活动应为 %q,实际 %q", target, got)
}
}
// 「只填空」:已经有值的一律不动,哪怕表单里填了别的。
func TestFillupNeverOverwrites(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
// 先给一条到访设好活动,给地点设好类别
acts, _ := f.srv.activityOptions(f.ctx, f.dsID)
first := acts[0].Name
second := "补全测试活动"
if len(acts) > 1 {
second = acts[1].Name
}
if _, err := f.pg.ExecContext(f.ctx, `UPDATE visits SET activity_id=$1
WHERE dataset_id=$2 AND place_id=$3 AND src_pk=670002`,
acts[0].ID, f.dsID, placeID); err != nil {
t.Fatalf("预置活动失败: %v", err)
}
if _, err := f.pg.ExecContext(f.ctx, `UPDATE places SET poi_category='MKPOICategoryPark'
WHERE id=$1`, placeID); err != nil {
t.Fatalf("预置类别失败: %v", err)
}
applyFillup(t, f, url.Values{
"act_" + strconv.FormatInt(placeID, 10): {second},
"cat_" + strconv.FormatInt(placeID, 10): {"MKPOICategoryZoo"},
})
// 原来那条不能被改掉
var got string
if err := f.pg.QueryRowContext(f.ctx, `SELECT a.name FROM visits v JOIN activities a ON a.id=v.activity_id
WHERE v.dataset_id=$1 AND v.place_id=$2 AND v.src_pk=670002`, f.dsID, placeID).Scan(&got); err != nil {
t.Fatalf("读活动失败: %v", err)
}
if got != first {
t.Fatalf("已有活动的到访不该被改:期望 %q,实际 %q", first, got)
}
// 已经填过的类别也不能被覆盖
var cat string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(poi_category,'') FROM places WHERE id=$1`, placeID).Scan(&cat); err != nil {
t.Fatalf("读类别失败: %v", err)
}
if cat != "MKPOICategoryPark" {
t.Fatalf("已有类别不该被覆盖,实际 %q", cat)
}
// 但另一条原本没活动的到访应当补上:这个地点不该再有无活动的到访
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id=$2
AND activity_id IS NULL`, f.dsID, placeID); n != 0 {
t.Fatalf("原本没活动的那条应当补上,还剩 %d 条没设", n)
}
}
// 填一个现有列表里没有的名字,应当自动建成新的活动类型(用户要的「匹配不上就新增」)。
func TestFillupCreatesUnknownActivity(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
before := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID)
applyFillup(t, f, url.Values{"act_" + strconv.FormatInt(placeID, 10): {"洗碗机测试活动"}})
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND name=$2`,
f.dsID, "洗碗机测试活动"); n != 1 {
t.Fatalf("该新建一个活动类型,实际 %d 个", n)
}
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID); n != before+1 {
t.Fatalf("活动总数应 +1,实际 %d(原 %d)", n, before)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE dataset_id=$1 AND name=$2`, f.dsID, "洗碗机测试活动")
})
}
func itoa(v int64) string {
return strings.TrimSpace(strings.ReplaceAll(strings.Trim(strings.Join([]string{}, ""), " "), " ", "")) + intToString(v)
}
func intToString(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var b []byte
for v > 0 {
b = append([]byte{byte('0' + v%10)}, b...)
v /= 10
}
if neg {
return "-" + string(b)
}
return string(b)
}