地点补全:按地名与坐标猜活动与类别,只填空
后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来, 给出建议、由站长确认后再写。判断分三层: - 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水) - 高德周边搜索:按坐标问「这里是什么 POI」,比按名字搜准; 拿到的类型再映射成活动与 MapKit 类别 - 人工:都猜不出的留空,站长自己填;填了列表里没有的名字会自动建成新活动类型 两条底线:**只填空**(已有活动或类别的一律不动);**活动按地点批量设**, 一次把它名下所有没设活动的到访一起填上,否则同一个地点会在统计里分裂成两种活动。 类别写的是 Apple MapKit 的枚举名(真机确认用过 12 种),不是高德分类。 规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则 「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先 (否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。
This commit is contained in:
@@ -0,0 +1,436 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 地点补全:把「活动为空 / 类别为空」的地点捞出来,给一条建议,由站长确认后再写。
|
||||||
|
//
|
||||||
|
// 两条底线贯穿这个文件:
|
||||||
|
// - **只填空**:SQL 里都带 `AND activity_id IS NULL` / `AND poi_category=''`,
|
||||||
|
// 已经有值的一律不动,改错了也只影响原本空着的地方;
|
||||||
|
// - **按地点批量**:填活动是把该地点名下**所有**没活动的到访一起设,
|
||||||
|
// 而不是只挑一条——否则同一个地点会在统计里分裂成两种活动。
|
||||||
|
|
||||||
|
// fillupHint 是一次高德查询给出的建议(按地点 id 缓存在内存里,页面重载仍能看到)。
|
||||||
|
type fillupHint struct {
|
||||||
|
Act string
|
||||||
|
Cat string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
fillupMu sync.Mutex
|
||||||
|
fillupCache = map[int64]fillupHint{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func fillupCachePut(placeID int64, h fillupHint) {
|
||||||
|
fillupMu.Lock()
|
||||||
|
defer fillupMu.Unlock()
|
||||||
|
fillupCache[placeID] = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillupHintOf(placeID int64) (fillupHint, bool) {
|
||||||
|
fillupMu.Lock()
|
||||||
|
defer fillupMu.Unlock()
|
||||||
|
h, ok := fillupCache[placeID]
|
||||||
|
return h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillupRow 是补全页上的一行。
|
||||||
|
type FillupRow struct {
|
||||||
|
PlaceID int64
|
||||||
|
SrcPK int
|
||||||
|
Name string
|
||||||
|
City string
|
||||||
|
Visits int
|
||||||
|
NoAct int // 还没设活动的到访条数
|
||||||
|
NoCat bool // 类别为空
|
||||||
|
HasCoord bool
|
||||||
|
Lat, Lon float64
|
||||||
|
ActHint string // 建议的活动名(空 = 没建议)
|
||||||
|
ActID int64 // 建议命中现有活动时的 id;0 表示得新建
|
||||||
|
CatHint string // 建议的类别
|
||||||
|
Source string // 建议从哪来
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r FillupRow) Missing() string {
|
||||||
|
switch {
|
||||||
|
case r.NoAct > 0 && r.NoCat:
|
||||||
|
return "活动 + 类别"
|
||||||
|
case r.NoAct > 0:
|
||||||
|
return "活动"
|
||||||
|
default:
|
||||||
|
return "类别"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillupData 是补全页的数据。
|
||||||
|
type FillupData struct {
|
||||||
|
Page
|
||||||
|
Rows []FillupRow
|
||||||
|
Activities []activityOption // 下拉选项
|
||||||
|
Categories []string // 类别下拉选项
|
||||||
|
Total int
|
||||||
|
WithHint int
|
||||||
|
}
|
||||||
|
|
||||||
|
// activityOption 只是给下拉用的(活动 id + 名字)。
|
||||||
|
type activityOption struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillupRows 捞出需要补的地点。判定口径:
|
||||||
|
// - 缺活动 = 该地点名下还有 activity_id 为空的到访(含「一条都没设」和「只设了一部分」);
|
||||||
|
// - 缺类别 = poi_category 为空。
|
||||||
|
//
|
||||||
|
// 零到访的地点只要缺类别也会进来——它们没有到访可挂活动,补类别仍然有意义。
|
||||||
|
func (s *Server) fillupRows(ctx context.Context, datasetID int64) ([]FillupRow, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.city,''),
|
||||||
|
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.poi_category,''),
|
||||||
|
(SELECT count(*) FROM visits v WHERE v.place_id = p.id AND v.activity_id IS NULL)
|
||||||
|
FROM places p
|
||||||
|
WHERE p.dataset_id = $1
|
||||||
|
AND (COALESCE(p.poi_category,'') = ''
|
||||||
|
OR EXISTS (SELECT 1 FROM visits v WHERE v.place_id = p.id AND v.activity_id IS NULL))
|
||||||
|
ORDER BY p.visit_count DESC, p.id`, datasetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
// 活动名 -> id:用来判断建议是「选中现有的」还是「需要新建一个」
|
||||||
|
actByName := map[string]int64{}
|
||||||
|
arows, err := s.db.QueryContext(ctx, `SELECT id, name FROM activities WHERE dataset_id=$1`, datasetID)
|
||||||
|
if err == nil {
|
||||||
|
for arows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name string
|
||||||
|
if arows.Scan(&id, &name) == nil {
|
||||||
|
actByName[name] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arows.Close()
|
||||||
|
}
|
||||||
|
var out []FillupRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r FillupRow
|
||||||
|
var lat, lon float64
|
||||||
|
var cat string
|
||||||
|
if err := rows.Scan(&r.PlaceID, &r.SrcPK, &r.Name, &r.City, &lat, &lon, &cat, &r.NoAct); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.NoCat = cat == ""
|
||||||
|
r.Lat, r.Lon = lat, lon
|
||||||
|
r.HasCoord = lat != 0 || lon != 0
|
||||||
|
// 建议先走本地词表:不花钱、不联网、结果稳定
|
||||||
|
if r.NoAct > 0 {
|
||||||
|
if a := guessActivityByName(r.Name); a != "" {
|
||||||
|
r.ActHint, r.Source, r.ActID = a, "词表", actByName[a]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 补上高德那一轮的结果(如果有缓存),并把没建议的排后面
|
||||||
|
for i := range out {
|
||||||
|
if h, ok := fillupHintOf(out[i].PlaceID); ok {
|
||||||
|
if out[i].ActHint == "" && h.Act != "" {
|
||||||
|
out[i].ActHint, out[i].Source, out[i].ActID = h.Act, "高德", actByName[h.Act]
|
||||||
|
}
|
||||||
|
if out[i].NoCat && h.Cat != "" {
|
||||||
|
out[i].CatHint = h.Cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
return (out[i].ActHint != "" || out[i].CatHint != "") && (out[j].ActHint == "" && out[j].CatHint == "")
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
ds, err := s.datasetFor(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ds == nil {
|
||||||
|
s.fillupFlash(w, r, "err", "还没有可补全的数据集")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := s.pageBase(r, "admin", "地点补全")
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.SetFlashFromQuery(r)
|
||||||
|
rows, err := s.fillupRows(ctx, ds.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acts, err := s.activityOptions(ctx, ds.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := FillupData{Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(), Total: len(rows)}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ActHint != "" || row.CatHint != "" {
|
||||||
|
d.WithHint++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.render(w, "admin/fillup", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// activityOptions 列出该数据集的活动,供下拉选择。
|
||||||
|
func (s *Server) activityOptions(ctx context.Context, datasetID int64) ([]activityOption, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name FROM activities
|
||||||
|
WHERE dataset_id=$1 ORDER BY name`, datasetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []activityOption
|
||||||
|
for rows.Next() {
|
||||||
|
var a activityOption
|
||||||
|
if err := rows.Scan(&a.ID, &a.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillupCategories 是类别下拉的选项:真机用过的 12 种在前,其余常见 MapKit 类别在后。
|
||||||
|
func fillupCategories() []string {
|
||||||
|
known := []string{
|
||||||
|
"MKPOICategoryRestaurant", "MKPOICategoryStore", "MKPOICategoryPublicTransport",
|
||||||
|
"MKPOICategoryHotel", "MKPOICategoryLandmark", "MKPOICategoryFoodMarket",
|
||||||
|
"MKPOICategoryBeach", "MKPOICategoryMuseum", "MKPOICategoryCampground",
|
||||||
|
"MKPOICategoryZoo", "MKPOICategoryStadium", "MKPOICategoryPark",
|
||||||
|
}
|
||||||
|
extra := []string{
|
||||||
|
"MKPOICategoryCafe", "MKPOICategoryBakery", "MKPOICategoryAirport",
|
||||||
|
"MKPOICategoryHospital", "MKPOICategoryPharmacy", "MKPOICategorySchool",
|
||||||
|
"MKPOICategoryUniversity", "MKPOICategoryLibrary", "MKPOICategoryParking",
|
||||||
|
"MKPOICategoryBank", "MKPOICategoryMovieTheater", "MKPOICategoryFitnessCenter",
|
||||||
|
"MKPOICategoryNationalPark", "MKPOICategoryAquarium", "MKPOICategoryAmusementPark",
|
||||||
|
"MKPOICategoryGasStation", "MKPOICategoryCarRental", "MKPOICategoryMarina",
|
||||||
|
}
|
||||||
|
return append(known, extra...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminFillupApply 执行补全。表单里每行两个字段:
|
||||||
|
//
|
||||||
|
// act_<placeID> = "" 不动
|
||||||
|
// = 活动 id 设为该活动
|
||||||
|
// = "__new__:名字" 新建这个活动再设
|
||||||
|
// cat_<placeID> = "" 不动
|
||||||
|
// = MKPOICategoryX 写进地点类别
|
||||||
|
//
|
||||||
|
// 全部改动在一个事务里,失败整体回滚。
|
||||||
|
func (s *Server) adminFillupApply(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
ds, err := s.datasetFor(ctx)
|
||||||
|
if err != nil || ds == nil {
|
||||||
|
s.fillupFlash(w, r, "err", "还没有可补全的数据集")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var acts, cats, newActs int
|
||||||
|
err = s.withTx(ctx, func(ex execer) error {
|
||||||
|
ids, err := s.fillupTargets(ctx, ex, ds.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 活动:先把要新建的建出来,再统一分配,避免同一批里重复建
|
||||||
|
actByName := map[string]int64{}
|
||||||
|
actByID := map[int64]string{}
|
||||||
|
for _, a := range mustActivityOptions(ctx, ex, ds.ID) {
|
||||||
|
actByName[a.Name], actByID[a.ID] = a.ID, a.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pid := range ids {
|
||||||
|
// 框里填的是**活动名**:对得上就用现有的,对不上就新建一个
|
||||||
|
if name := strings.TrimSpace(r.PostFormValue("act_" + strconv.FormatInt(pid, 10))); name != "" {
|
||||||
|
actID, ok := actByName[name]
|
||||||
|
if !ok {
|
||||||
|
pk, err := s.nextSrcPK(ctx, ex, ds.ID, activitySeq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ex.QueryRowContext(ctx, `INSERT INTO activities
|
||||||
|
(dataset_id, src_pk, name, color, icon, is_home, is_work, is_excluded, is_archived)
|
||||||
|
VALUES ($1,$2,$3,'','',false,false,false,false) RETURNING id`,
|
||||||
|
ds.ID, pk, name).Scan(&actID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actByName[name] = actID
|
||||||
|
newActs++
|
||||||
|
}
|
||||||
|
isHome, isWork := s.activityFlags(ctx, ex, actID)
|
||||||
|
res, err := ex.ExecContext(ctx, `UPDATE visits v SET activity_id=$1, is_home=$2, is_work=$3
|
||||||
|
WHERE v.dataset_id=$4 AND v.activity_id IS NULL
|
||||||
|
AND v.place_id = (SELECT p.id FROM places p WHERE p.dataset_id=$4 AND p.id=$5)`,
|
||||||
|
actID, isHome, isWork, ds.ID, pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
acts += int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cat := strings.TrimSpace(r.PostFormValue("cat_" + strconv.FormatInt(pid, 10))); cat != "" {
|
||||||
|
res, err := ex.ExecContext(ctx, `UPDATE places SET poi_category=$1
|
||||||
|
WHERE dataset_id=$2 AND id=$3 AND COALESCE(poi_category,'')=''`, cat, ds.ID, pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
cats += int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.fillupFlash(w, r, "err", "补全失败,已整体回滚:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acts+cats == 0 {
|
||||||
|
s.fillupFlash(w, r, "ok", "没有选中任何要补的内容,数据未变")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "补全完成"
|
||||||
|
if acts > 0 {
|
||||||
|
msg += ":为 " + strconv.Itoa(acts) + " 条到访设了活动"
|
||||||
|
}
|
||||||
|
if cats > 0 {
|
||||||
|
msg += ":为 " + strconv.Itoa(cats) + " 个地点补了类别"
|
||||||
|
}
|
||||||
|
if newActs > 0 {
|
||||||
|
msg += "(新建 " + strconv.Itoa(newActs) + " 个活动类型)"
|
||||||
|
}
|
||||||
|
s.fillupFlash(w, r, "ok", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillupTargets 返回本次提交涉及的地点 id(只认本数据集的,防止越权改别处)。
|
||||||
|
func (s *Server) fillupTargets(ctx context.Context, ex execer, datasetID int64) ([]int64, error) {
|
||||||
|
rows, err := ex.QueryContext(ctx, `SELECT id FROM places WHERE dataset_id=$1`, datasetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool) {
|
||||||
|
ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustActivityOptions(ctx context.Context, ex execer, datasetID int64) []activityOption {
|
||||||
|
rows, err := ex.QueryContext(ctx, `SELECT id, name FROM activities WHERE dataset_id=$1`, datasetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []activityOption
|
||||||
|
for rows.Next() {
|
||||||
|
var a activityOption
|
||||||
|
if err := rows.Scan(&a.ID, &a.Name); err == nil {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillupAmapBatch 是一次「用高德分析」最多处理的条数。
|
||||||
|
// 高德限流 10 QPS、每条一次请求,一批 40 条约 4 秒——再长就该担心请求超时了。
|
||||||
|
const fillupAmapBatch = 40
|
||||||
|
|
||||||
|
// adminFillupAmap 对「还没有建议、有坐标、还没查过」的地点跑一次高德周边搜索,
|
||||||
|
// 把猜出来的活动与类别放进内存缓存,页面重载就能看到。
|
||||||
|
//
|
||||||
|
// 用周边的坐标搜而不是按名字搜:地点名可能是后来反查补全出来的,
|
||||||
|
// 而坐标是它真实所在的位置,「这个坐标上是什么 POI」比「这个名字叫什么」准得多。
|
||||||
|
func (s *Server) adminFillupAmap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
ds, err := s.datasetFor(ctx)
|
||||||
|
if err != nil || ds == nil {
|
||||||
|
s.fillupFlash(w, r, "err", "还没有可补全的数据集")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cfg.GeocodeKey == "" {
|
||||||
|
s.fillupFlash(w, r, "err", "conf/app.ini 里没配 geocode_key,用不了高德")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.fillupRows(ctx, ds.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var todo []FillupRow
|
||||||
|
for _, row := range rows {
|
||||||
|
if !row.HasCoord || row.ActHint != "" {
|
||||||
|
continue // 已经有建议、或者没坐标搜不了
|
||||||
|
}
|
||||||
|
if _, done := fillupHintOf(row.PlaceID); done {
|
||||||
|
continue // 查过了(哪怕是没查着),别反复重试
|
||||||
|
}
|
||||||
|
todo = append(todo, row)
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
s.fillupFlash(w, r, "ok", "没有需要再查的地点")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, hits := 0, 0
|
||||||
|
for _, row := range todo {
|
||||||
|
if n >= fillupAmapBatch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_, typ, _, ok := s.amapAround(ctx, row.Lat, row.Lon)
|
||||||
|
var h fillupHint
|
||||||
|
if ok {
|
||||||
|
h.Act = guessActivityFromAmapType(typ)
|
||||||
|
h.Cat = guessCategoryFromAmapType(typ)
|
||||||
|
if h.Act != "" || h.Cat != "" {
|
||||||
|
hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fillupCachePut(row.PlaceID, h)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("已用高德分析 %d 个,其中 %d 个猜出了结果", n, hits)
|
||||||
|
if left := len(todo) - n; left > 0 {
|
||||||
|
msg += fmt.Sprintf(";还剩 %d 个,再点一次继续", left)
|
||||||
|
}
|
||||||
|
s.fillupFlash(w, r, "ok", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillupFlash 把提示带回补全页本身(adminFlash 是跳后台首页的)。
|
||||||
|
func (s *Server) fillupFlash(w http.ResponseWriter, r *http.Request, kind, msg string) {
|
||||||
|
http.Redirect(w, r, "/admin/fillup?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// 地点补全的判定规则:地名怎么猜活动、高德 POI 类型怎么猜活动和类别。
|
||||||
|
//
|
||||||
|
// 两条原则:
|
||||||
|
// - **只填空**:调用方只在原值为空时才用这里的建议;
|
||||||
|
// - **拿不准就别猜**:宁可标成「待确认」让人看一眼,也不要瞎填一个看起来像样的值,
|
||||||
|
// 错的活动会直接污染统计与筛选,比空着更难发现。
|
||||||
|
//
|
||||||
|
// 词表按**定义顺序**匹配,先命中的赢——所以具体的词要排在笼统的词前面。
|
||||||
|
|
||||||
|
// actHint 是一条「关键词 → 活动类型」的映射,关键词出现在地名里就算命中。
|
||||||
|
type actHint struct{ kw, val string }
|
||||||
|
|
||||||
|
// nameActHints 从地名猜活动。活动名必须与站点里已有的 31 个完全一致,
|
||||||
|
// 否则调用方会把它当成「需要新建的类型」。
|
||||||
|
var nameActHints = []actHint{
|
||||||
|
{"咖啡", "奶茶和甜甜水"}, {"奶茶", "奶茶和甜甜水"}, {"茶艺", "奶茶和甜甜水"},
|
||||||
|
{"书店", "学习"}, {"图书馆", "学习"}, {"学校", "学习"}, {"大学", "学习"},
|
||||||
|
{"博物馆", "纪念馆"}, {"纪念馆", "纪念馆"}, {"陈列馆", "纪念馆"},
|
||||||
|
{"动物园", "动物园"}, {"海洋馆", "动物园"},
|
||||||
|
{"沙滩", "沙滩"}, {"海滨", "沙滩"}, {"海滩", "沙滩"},
|
||||||
|
{"度假区", "旅游景点"}, {"古镇", "旅游景点"}, {"景区", "旅游景点"},
|
||||||
|
{"游客中心", "旅游景点"}, {"索道", "旅游景点"},
|
||||||
|
{"公园", "散步"}, {"绿道", "散步"}, {"步道", "散步"}, {"广场", "散步"}, {"栈道", "散步"},
|
||||||
|
{"步行街", "步行街"}, {"老街", "步行街"},
|
||||||
|
{"小吃", "美食街"}, {"美食", "美食街"}, {"夜市", "美食街"}, {"大排档", "美食街"},
|
||||||
|
{"餐厅", "餐厅"}, {"饭店", "餐厅"}, {"酒楼", "餐厅"}, {"食府", "餐厅"}, {"火锅", "餐厅"},
|
||||||
|
{"菜市场", "市场"}, {"农贸", "市场"}, {"市场", "市场"},
|
||||||
|
{"超市", "小商店"}, {"便利店", "小商店"}, {"小卖部", "小商店"}, {"商店", "小商店"},
|
||||||
|
{"商场", "商场"}, {"购物", "商场"}, {"百货", "商场"},
|
||||||
|
{"公交", "公共交通"}, {"地铁", "公共交通"}, {"车站", "公共交通"}, {"机场", "公共交通"},
|
||||||
|
{"高铁", "公共交通"}, {"码头", "公共交通"}, {"客运", "公共交通"},
|
||||||
|
{"民宿", "酒店"}, {"客栈", "酒店"}, {"宾馆", "酒店"}, {"酒店", "酒店"}, {"度假", "酒店"},
|
||||||
|
{"医院", "一般公共场所"}, {"诊所", "一般公共场所"}, {"政务", "一般公共场所"}, {"服务中心", "一般公共场所"},
|
||||||
|
{"体育", "户外活动"}, {"运动", "户外活动"}, {"健身", "户外活动"}, {"露营", "户外活动"},
|
||||||
|
{"骑行", "骑行目的地"}, {"自行车", "骑行目的地"},
|
||||||
|
{"租车", "租车"}, {"汽车租赁", "租车"},
|
||||||
|
{"彩票", "彩票购买点"},
|
||||||
|
{"4S", "车辆维修"}, {"维修", "车辆维修"}, {"汽修", "车辆维修"},
|
||||||
|
{"停车", "暂且休息"}, {"服务区", "暂且休息"}, {"休息区", "暂且休息"},
|
||||||
|
{"公司", "工作"}, {"办公", "工作"}, {"产业园", "工作"}, {"工厂", "工作"},
|
||||||
|
{"塔", "地标"}, {"大桥", "地标"}, {"地标", "地标"}, {"牌坊", "地标"},
|
||||||
|
{"乡镇", "乡镇"}, {"村委会", "乡镇"}, {"街道办", "乡镇"},
|
||||||
|
{"山峰", "山"}, {"峡谷", "山"}, {"雪山", "山"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// amapActHints 从高德 POI 类型猜活动(高德给的是「大类;小类」形式,按子串匹配)。
|
||||||
|
var amapActHints = []actHint{
|
||||||
|
{"咖啡厅", "奶茶和甜甜水"}, {"茶艺馆", "奶茶和甜甜水"}, {"冷饮店", "奶茶和甜甜水"},
|
||||||
|
{"图书馆", "学习"}, {"学校", "学习"}, {"高等院校", "学习"}, {"中学", "学习"}, {"小学", "学习"},
|
||||||
|
{"博物馆", "纪念馆"}, {"展览馆", "纪念馆"}, {"科教文化", "纪念馆"},
|
||||||
|
{"动物园", "动物园"}, {"水族馆", "动物园"},
|
||||||
|
{"海滩", "沙滩"}, {"海滨浴场", "沙滩"},
|
||||||
|
{"风景名胜", "旅游景点"}, {"公园广场", "旅游景点"}, {"寺庙道观", "旅游景点"},
|
||||||
|
{"公园", "散步"}, {"城市广场", "散步"},
|
||||||
|
{"特色街区", "步行街"},
|
||||||
|
{"小吃快餐", "美食街"}, {"美食街", "美食街"}, {"餐饮相关", "美食街"},
|
||||||
|
{"中餐厅", "餐厅"}, {"外国餐厅", "餐厅"}, {"餐厅", "餐厅"}, {"快餐厅", "餐厅"},
|
||||||
|
{"综合市场", "市场"}, {"农副产品市场", "市场"}, {"菜市场", "市场"},
|
||||||
|
{"便利店", "小商店"}, {"超市", "小商店"}, {"专卖店", "小商店"}, {"小商品市场", "小商店"},
|
||||||
|
{"商场", "商场"}, {"购物中心", "商场"}, {"百货商场", "商场"},
|
||||||
|
{"地铁站", "公共交通"}, {"公交车站", "公共交通"}, {"火车站", "公共交通"},
|
||||||
|
{"机场", "公共交通"}, {"港口码头", "公共交通"}, {"长途汽车站", "公共交通"},
|
||||||
|
{"宾馆酒店", "酒店"}, {"旅馆招待所", "酒店"}, {"度假村", "酒店"},
|
||||||
|
{"综合医院", "一般公共场所"}, {"专科医院", "一般公共场所"}, {"诊所", "一般公共场所"},
|
||||||
|
{"政府机关", "一般公共场所"}, {"社会团体", "一般公共场所"},
|
||||||
|
{"运动场馆", "户外活动"}, {"健身中心", "户外活动"}, {"露营地", "户外活动"},
|
||||||
|
{"汽车租赁", "租车"}, {"汽车维修", "车辆维修"}, {"汽车服务", "车辆维修"},
|
||||||
|
{"彩票", "彩票购买点"},
|
||||||
|
{"停车场", "暂且休息"}, {"服务区", "暂且休息"},
|
||||||
|
{"公司", "工作"}, {"写字楼", "工作"}, {"产业园区", "工作"},
|
||||||
|
{"旅游景点", "旅游景点"},
|
||||||
|
{"商务住宅", "家"}, {"住宅区", "家"},
|
||||||
|
|
||||||
|
// 一级大类兜底
|
||||||
|
{"餐饮服务", "餐厅"},
|
||||||
|
{"购物服务", "商场"},
|
||||||
|
{"住宿服务", "酒店"},
|
||||||
|
{"交通设施服务", "公共交通"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// mkHints 从高德 POI 类型猜 MapKit 类别。
|
||||||
|
//
|
||||||
|
// 值必须是真机能认的枚举名(`MKPOICategory` + 驼峰)。已确认真机用过的是这 12 种:
|
||||||
|
// Restaurant / Store / PublicTransport / Hotel / Landmark / FoodMarket / Beach /
|
||||||
|
// Museum / Campground / Zoo / Stadium / Park。其余取 MapKit 的公开枚举名,
|
||||||
|
// 万一写错,rond 那边只会显示成原始串,不会损坏数据。
|
||||||
|
var mkHints = []actHint{
|
||||||
|
{"咖啡厅", "MKPOICategoryCafe"}, {"茶艺馆", "MKPOICategoryCafe"}, {"冷饮店", "MKPOICategoryCafe"},
|
||||||
|
{"糕饼店", "MKPOICategoryBakery"}, {"面包", "MKPOICategoryBakery"},
|
||||||
|
{"图书馆", "MKPOICategoryLibrary"},
|
||||||
|
{"学校", "MKPOICategorySchool"}, {"中学", "MKPOICategorySchool"}, {"小学", "MKPOICategorySchool"},
|
||||||
|
{"高等院校", "MKPOICategoryUniversity"},
|
||||||
|
{"博物馆", "MKPOICategoryMuseum"}, {"展览馆", "MKPOICategoryMuseum"},
|
||||||
|
{"动物园", "MKPOICategoryZoo"}, {"水族馆", "MKPOICategoryAquarium"},
|
||||||
|
{"海滩", "MKPOICategoryBeach"}, {"海滨浴场", "MKPOICategoryBeach"},
|
||||||
|
{"风景名胜", "MKPOICategoryLandmark"}, {"寺庙道观", "MKPOICategoryLandmark"},
|
||||||
|
{"公园", "MKPOICategoryPark"}, {"城市广场", "MKPOICategoryLandmark"},
|
||||||
|
{"露营地", "MKPOICategoryCampground"},
|
||||||
|
{"综合市场", "MKPOICategoryFoodMarket"}, {"农副产品市场", "MKPOICategoryFoodMarket"},
|
||||||
|
{"便利店", "MKPOICategoryStore"}, {"超市", "MKPOICategoryStore"},
|
||||||
|
{"专卖店", "MKPOICategoryStore"}, {"商场", "MKPOICategoryStore"}, {"购物中心", "MKPOICategoryStore"},
|
||||||
|
{"地铁站", "MKPOICategoryPublicTransport"}, {"公交车站", "MKPOICategoryPublicTransport"},
|
||||||
|
{"火车站", "MKPOICategoryPublicTransport"}, {"港口码头", "MKPOICategoryPublicTransport"},
|
||||||
|
{"长途汽车站", "MKPOICategoryPublicTransport"},
|
||||||
|
{"机场", "MKPOICategoryAirport"},
|
||||||
|
{"宾馆酒店", "MKPOICategoryHotel"}, {"旅馆招待所", "MKPOICategoryHotel"}, {"度假村", "MKPOICategoryHotel"},
|
||||||
|
{"综合医院", "MKPOICategoryHospital"}, {"专科医院", "MKPOICategoryHospital"},
|
||||||
|
{"诊所", "MKPOICategoryHospital"}, {"药房", "MKPOICategoryPharmacy"},
|
||||||
|
{"运动场馆", "MKPOICategoryStadium"}, {"健身中心", "MKPOICategoryFitnessCenter"},
|
||||||
|
{"电影院", "MKPOICategoryMovieTheater"}, {"剧场", "MKPOICategoryTheater"},
|
||||||
|
{"汽车租赁", "MKPOICategoryCarRental"}, {"加油站", "MKPOICategoryGasStation"},
|
||||||
|
{"停车场", "MKPOICategoryParking"},
|
||||||
|
{"银行", "MKPOICategoryBank"}, {"自动取款机", "MKPOICategoryAtm"},
|
||||||
|
{"邮局", "MKPOICategoryPostOffice"}, {"派出所", "MKPOICategoryPolice"},
|
||||||
|
{"中餐厅", "MKPOICategoryRestaurant"}, {"外国餐厅", "MKPOICategoryRestaurant"},
|
||||||
|
{"快餐厅", "MKPOICategoryRestaurant"}, {"餐厅", "MKPOICategoryRestaurant"},
|
||||||
|
|
||||||
|
// 高德的一级大类兜底:小类不认识时至少落到大类上
|
||||||
|
{"餐饮服务", "MKPOICategoryRestaurant"},
|
||||||
|
{"购物服务", "MKPOICategoryStore"},
|
||||||
|
{"住宿服务", "MKPOICategoryHotel"},
|
||||||
|
{"体育休闲服务", "MKPOICategoryStadium"},
|
||||||
|
{"医疗保健服务", "MKPOICategoryHospital"},
|
||||||
|
{"风景名胜", "MKPOICategoryLandmark"},
|
||||||
|
{"交通设施服务", "MKPOICategoryPublicTransport"},
|
||||||
|
{"金融保险服务", "MKPOICategoryBank"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimParens 只看括号外的主体:「尚优居客栈(大理古城步行街店)」是个客栈,
|
||||||
|
// 括号里那串只是地址,不该拿它来判断。
|
||||||
|
func trimParens(s string) string {
|
||||||
|
if i := strings.IndexAny(s, "(([【"); i > 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchHint 取**最长的**命中词——越长的词越具体。
|
||||||
|
//
|
||||||
|
// 不用「定义顺序」也不用「出现位置」:前者要求把词表排成一件容易出错的事,
|
||||||
|
// 后者会让「马鞍山公园」命中「山」而不是「公园」。长度相同则取先定义的。
|
||||||
|
func matchHint(hints []actHint, s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
best, bestLen := "", 0
|
||||||
|
for _, h := range hints {
|
||||||
|
if len(h.kw) > bestLen && strings.Contains(s, h.kw) {
|
||||||
|
best, bestLen = h.val, len(h.kw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchNameHint 是地名专用的入口:先掐掉括号里的地址再匹配。
|
||||||
|
func matchNameHint(hints []actHint, s string) string {
|
||||||
|
return matchHint(hints, trimParens(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// guessActivityByName 从地名猜活动,命中表示置信度高。
|
||||||
|
func guessActivityByName(name string) string { return matchNameHint(nameActHints, name) }
|
||||||
|
|
||||||
|
// matchAmapType 在高德类型串里找映射。高德给的是「大类;小类」,
|
||||||
|
// **小类更具体**(「风景名胜;公园」该按公园算,不是风景名胜),所以先试小类再退回大类。
|
||||||
|
func matchAmapType(hints []actHint, t string) string {
|
||||||
|
if t == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.LastIndexByte(t, ';'); i >= 0 {
|
||||||
|
if v := matchHint(hints, t[i+1:]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return matchHint(hints, t[:i])
|
||||||
|
}
|
||||||
|
return matchHint(hints, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func guessActivityFromAmapType(t string) string { return matchAmapType(amapActHints, t) }
|
||||||
|
|
||||||
|
func guessCategoryFromAmapType(t string) string { return matchAmapType(mkHints, t) }
|
||||||
|
|
||||||
|
// knownMKCategories 是真机确认用过的类别,界面上标一下,方便一眼看出哪些是「新猜的」。
|
||||||
|
var knownMKCategories = map[string]bool{
|
||||||
|
"MKPOICategoryRestaurant": true,
|
||||||
|
"MKPOICategoryStore": true,
|
||||||
|
"MKPOICategoryPublicTransport": true,
|
||||||
|
"MKPOICategoryHotel": true,
|
||||||
|
"MKPOICategoryLandmark": true,
|
||||||
|
"MKPOICategoryFoodMarket": true,
|
||||||
|
"MKPOICategoryBeach": true,
|
||||||
|
"MKPOICategoryMuseum": true,
|
||||||
|
"MKPOICategoryCampground": true,
|
||||||
|
"MKPOICategoryZoo": true,
|
||||||
|
"MKPOICategoryStadium": true,
|
||||||
|
"MKPOICategoryPark": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// mkCategoryKnown 报告这个类别真机是否用过。
|
||||||
|
func mkCategoryKnown(c string) bool { return knownMKCategories[c] }
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 用例直接用站点里真实存在的地名,规则改坏了这里立刻会响。
|
||||||
|
func TestGuessActivityByName(t *testing.T) {
|
||||||
|
cases := []struct{ name, want string }{
|
||||||
|
{"星巴克咖啡", "奶茶和甜甜水"},
|
||||||
|
{"后谷咖啡", "奶茶和甜甜水"},
|
||||||
|
{"花溪十里河滩绿道", "散步"},
|
||||||
|
{"马鞍山公园", "散步"},
|
||||||
|
{"河池市体育馆", "户外活动"},
|
||||||
|
{"喜洲古镇洱海环境监测站", "旅游景点"},
|
||||||
|
{"十里河滩旅游度假区-孔子圣像", "旅游景点"},
|
||||||
|
{"尚优居客栈(大理古城步行街店)", "酒店"}, // 括号里是地址,主体才是客栈
|
||||||
|
{"鑫圣餐厅", "餐厅"},
|
||||||
|
{"彭大花·白族小花园餐厅(广武路店)", "餐厅"},
|
||||||
|
{"大昌盛超市", "小商店"},
|
||||||
|
{"中国福利彩票", "彩票购买点"},
|
||||||
|
|
||||||
|
// 猜不出来的必须留空,交给高德或者人工——宁可空着也别瞎填
|
||||||
|
{"蜜雪冰城", ""},
|
||||||
|
{"昆明站", ""}, // 单字「站」误命中太多,交给高德
|
||||||
|
{"独山路28-17号", ""}, // 路名里有「山」但不是山
|
||||||
|
{"银滩镇", ""},
|
||||||
|
{"傣思蜜清真手抓饭", ""},
|
||||||
|
{"何孃孃小锅米线", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := guessActivityByName(c.name); got != c.want {
|
||||||
|
t.Errorf("guessActivityByName(%q) = %q,期望 %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuessCategoryFromAmapType(t *testing.T) {
|
||||||
|
cases := []struct{ typ, want string }{
|
||||||
|
{"餐饮服务;咖啡厅", "MKPOICategoryCafe"},
|
||||||
|
{"餐饮服务;中餐厅", "MKPOICategoryRestaurant"},
|
||||||
|
{"餐饮服务;快餐厅", "MKPOICategoryRestaurant"},
|
||||||
|
{"购物服务;购物中心", "MKPOICategoryStore"},
|
||||||
|
{"购物服务;便利店", "MKPOICategoryStore"},
|
||||||
|
// 小类优先:「风景名胜」是大类,这地方其实是个公园
|
||||||
|
{"风景名胜;公园", "MKPOICategoryPark"},
|
||||||
|
{"住宿服务;宾馆酒店", "MKPOICategoryHotel"},
|
||||||
|
{"交通设施服务;地铁站", "MKPOICategoryPublicTransport"},
|
||||||
|
{"交通设施服务;机场", "MKPOICategoryAirport"},
|
||||||
|
{"体育休闲服务;运动场馆", "MKPOICategoryStadium"},
|
||||||
|
{"医疗保健服务;综合医院", "MKPOICategoryHospital"},
|
||||||
|
|
||||||
|
// 大类命中、小类不认识时退回大类
|
||||||
|
{"购物服务;从未见过的子类", "MKPOICategoryStore"},
|
||||||
|
{"", ""},
|
||||||
|
{"未知;未知", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := guessCategoryFromAmapType(c.typ); got != c.want {
|
||||||
|
t.Errorf("guessCategoryFromAmapType(%q) = %q,期望 %q", c.typ, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuessActivityFromAmapType(t *testing.T) {
|
||||||
|
cases := []struct{ typ, want string }{
|
||||||
|
{"餐饮服务;咖啡厅", "奶茶和甜甜水"},
|
||||||
|
{"餐饮服务;小吃快餐", "美食街"},
|
||||||
|
{"购物服务;便利店", "小商店"},
|
||||||
|
{"住宿服务;宾馆酒店", "酒店"},
|
||||||
|
{"风景名胜;公园", "散步"},
|
||||||
|
{"体育休闲服务;运动场馆", "户外活动"},
|
||||||
|
{"未知;未知", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := guessActivityFromAmapType(c.typ); got != c.want {
|
||||||
|
t.Errorf("guessActivityFromAmapType(%q) = %q,期望 %q", c.typ, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类别值必须是真机能认的枚举名,别写出别的形状。
|
||||||
|
func TestKnownMKCategoriesShape(t *testing.T) {
|
||||||
|
for c := range knownMKCategories {
|
||||||
|
if len(c) <= len("MKPOICategory") || c[:13] != "MKPOICategory" {
|
||||||
|
t.Errorf("类别 %q 不符合 MKPOICategory<名字> 的形状", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mkCategoryKnown("MKPOICategoryRestaurant") {
|
||||||
|
t.Error("真机用过的类别应被认得")
|
||||||
|
}
|
||||||
|
if mkCategoryKnown("MKPOICategoryCafe") {
|
||||||
|
t.Error("这一次新猜的类别不该被算作「真机用过」")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrimParens(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"尚优居客栈(大理古城步行街店)", "尚优居客栈"},
|
||||||
|
{"三哥·思茅菜(北区店)", "三哥·思茅菜"},
|
||||||
|
{"没有括号的名字", "没有括号的名字"},
|
||||||
|
{"开头就是括号的", "开头就是括号的"}, // 整串都是括号开头时不截断,避免截成空串
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := trimParens(c.in); got != c.want {
|
||||||
|
t.Errorf("trimParens(%q) = %q,期望 %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+11
-3
@@ -351,6 +351,13 @@ func (s *Server) apiRegeo(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 半径给 200 米:再大容易匹配到马路边上不相干的店;取不到就返回 ok=false,
|
// 半径给 200 米:再大容易匹配到马路边上不相干的店;取不到就返回 ok=false,
|
||||||
// 界面上只显示地址。
|
// 界面上只显示地址。
|
||||||
func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name string, dist float64, ok bool) {
|
func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name string, dist float64, ok bool) {
|
||||||
|
name, _, dist, ok = s.amapAround(ctx, lat, lon)
|
||||||
|
return name, dist, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// amapAround 周边搜索:拿最近那个 POI 的名字、类型与距离。
|
||||||
|
// typ 是高德的分类串(形如「餐饮服务;咖啡厅」),地点补全靠它猜活动与类别。
|
||||||
|
func (s *Server) amapAround(ctx context.Context, lat, lon float64) (name, typ string, dist float64, ok bool) {
|
||||||
v := url.Values{}
|
v := url.Values{}
|
||||||
v.Set("key", s.cfg.GeocodeKey)
|
v.Set("key", s.cfg.GeocodeKey)
|
||||||
v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat))
|
v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat))
|
||||||
@@ -359,20 +366,21 @@ func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name str
|
|||||||
v.Set("sortrule", "distance") // 按距离排序,第一条就是最近的
|
v.Set("sortrule", "distance") // 按距离排序,第一条就是最近的
|
||||||
body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode())
|
body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, false
|
return "", "", 0, false
|
||||||
}
|
}
|
||||||
var raw struct {
|
var raw struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Pois []struct {
|
Pois []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
Distance string `json:"distance"`
|
Distance string `json:"distance"`
|
||||||
} `json:"pois"`
|
} `json:"pois"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" || len(raw.Pois) == 0 {
|
if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" || len(raw.Pois) == 0 {
|
||||||
return "", 0, false
|
return "", "", 0, false
|
||||||
}
|
}
|
||||||
d, _ := strconv.ParseFloat(raw.Pois[0].Distance, 64)
|
d, _ := strconv.ParseFloat(raw.Pois[0].Distance, 64)
|
||||||
return raw.Pois[0].Name, d, true
|
return raw.Pois[0].Name, raw.Pois[0].Type, d, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// amapRegeo 调一次高德的逆地理编码。失败返回 ok=false,调用方留空即可。
|
// amapRegeo 调一次高德的逆地理编码。失败返回 ok=false,调用方留空即可。
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ var pageFiles = map[string]struct {
|
|||||||
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
|
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
|
||||||
"admin/fog-preview": {[]string{"templates/layout.html", "templates/admin/fog-preview.html"}, "layout"},
|
"admin/fog-preview": {[]string{"templates/layout.html", "templates/admin/fog-preview.html"}, "layout"},
|
||||||
"admin/backup-preview": {[]string{"templates/layout.html", "templates/admin/backup-preview.html"}, "layout"},
|
"admin/backup-preview": {[]string{"templates/layout.html", "templates/admin/backup-preview.html"}, "layout"},
|
||||||
|
"admin/fillup": {[]string{"templates/layout.html", "templates/admin/fillup.html"}, "layout"},
|
||||||
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
|
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
|
||||||
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
|
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
|
||||||
"admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"},
|
"admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"},
|
||||||
@@ -279,6 +280,9 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON))
|
s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON))
|
||||||
s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX))
|
s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX))
|
||||||
s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup))
|
s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup))
|
||||||
|
s.mux.HandleFunc("GET /admin/fillup", s.requireAdmin(s.adminFillupPage))
|
||||||
|
s.mux.HandleFunc("POST /admin/fillup/apply", s.requireAdmin(s.adminFillupApply))
|
||||||
|
s.mux.HandleFunc("POST /admin/fillup/amap", s.requireAdmin(s.adminFillupAmap))
|
||||||
s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage))
|
s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage))
|
||||||
s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
|
s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
|
||||||
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
|
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>地点补全</h1>
|
||||||
|
<div class="sub">
|
||||||
|
共 <strong>{{num .Total}}</strong> 个地点缺活动或类别,其中 <strong>{{num .WithHint}}</strong> 个能给出建议。
|
||||||
|
<strong>只填空</strong>——已经有活动或类别的一律不动。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
|
||||||
|
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .Rows}}
|
||||||
|
<div class="card">
|
||||||
|
<h2>待补全({{num .Total}})</h2>
|
||||||
|
<div class="card-sub">
|
||||||
|
建议已经预填在框里,<strong>不满意的直接改或清空</strong>即可,留空就是不动。
|
||||||
|
活动按<strong>地点</strong>补:一次把该地点名下所有没设活动的到访一起填上,不会只改一条。
|
||||||
|
填了现有列表之外的名字,会自动建成新的活动类型。<br>
|
||||||
|
「用高德分析剩下的」每次处理 40 个、约 4 秒,结果会填进上面还没建议的行里,点多几次即可。
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/fillup/apply">
|
||||||
|
<div class="tw">
|
||||||
|
<table class="tb tb-stack">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>地点</th><th>城市</th><th class="num">待设到访</th><th>缺什么</th>
|
||||||
|
<th>活动</th><th>类别</th><th>建议来源</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $row := .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td>{{if $row.Name}}{{$row.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
|
||||||
|
<td data-label="城市">{{if $row.City}}{{$row.City}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
<td class="num" data-label="待设到访">{{if $row.NoAct}}{{$row.NoAct}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
<td data-label="缺什么">{{$row.Missing}}</td>
|
||||||
|
<td data-label="活动">
|
||||||
|
{{if $row.NoAct}}
|
||||||
|
<input list="fill-act" name="act_{{$row.PlaceID}}" value="{{$row.ActHint}}"
|
||||||
|
placeholder="(不动)" style="min-width:8em">
|
||||||
|
{{else}}<span class="muted">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td data-label="类别">
|
||||||
|
{{if $row.NoCat}}
|
||||||
|
<input list="fill-cat" name="cat_{{$row.PlaceID}}" value="{{$row.CatHint}}"
|
||||||
|
placeholder="(不动)" style="min-width:13em">
|
||||||
|
{{else}}<span class="muted">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td data-label="建议来源">{{if $row.Source}}{{$row.Source}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<datalist id="fill-act">
|
||||||
|
{{range .Activities}}<option value="{{.Name}}"></option>{{end}}
|
||||||
|
</datalist>
|
||||||
|
<datalist id="fill-cat">
|
||||||
|
{{range .Categories}}<option value="{{.}}"></option>{{end}}
|
||||||
|
</datalist>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button class="btn primary" type="submit">执行补全</button>
|
||||||
|
<button class="btn" type="submit" formaction="/admin/fillup/amap"
|
||||||
|
title="用高德按坐标搜一遍,给还没建议的地点补上参考">用高德分析剩下的</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="alert ok">所有地点的活动和类别都齐了,没什么要补的。</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<a class="btn" href="/admin/settings">站点设置</a>
|
<a class="btn" href="/admin/settings">站点设置</a>
|
||||||
<a class="btn" href="/admin/twofa">两步验证</a>
|
<a class="btn" href="/admin/twofa">两步验证</a>
|
||||||
<a class="btn" href="/admin/notes">地点备注</a>
|
<a class="btn" href="/admin/notes">地点备注</a>
|
||||||
|
<a class="btn" href="/admin/fillup">地点补全</a>
|
||||||
<a class="btn" href="/admin/password">账号设置</a>
|
<a class="btn" href="/admin/password">账号设置</a>
|
||||||
<a class="btn" href="/" target="_blank">查看站点</a>
|
<a class="btn" href="/" target="_blank">查看站点</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user