地点补全:按地名与坐标猜活动与类别,只填空
后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来, 给出建议、由站长确认后再写。判断分三层: - 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水) - 高德周边搜索:按坐标问「这里是什么 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,
|
||||
// 界面上只显示地址。
|
||||
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.Set("key", s.cfg.GeocodeKey)
|
||||
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") // 按距离排序,第一条就是最近的
|
||||
body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode())
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
return "", "", 0, false
|
||||
}
|
||||
var raw struct {
|
||||
Status string `json:"status"`
|
||||
Pois []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Distance string `json:"distance"`
|
||||
} `json:"pois"`
|
||||
}
|
||||
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)
|
||||
return raw.Pois[0].Name, d, true
|
||||
return raw.Pois[0].Name, raw.Pois[0].Type, d, true
|
||||
}
|
||||
|
||||
// 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-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/fillup": {[]string{"templates/layout.html", "templates/admin/fillup.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/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.gpx", s.requireAdmin(s.exportGPX))
|
||||
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("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
|
||||
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/twofa">两步验证</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="/" target="_blank">查看站点</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user