地点补全:按地名与坐标猜活动与类别,只填空
后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来, 给出建议、由站长确认后再写。判断分三层: - 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水) - 高德周边搜索:按坐标问「这里是什么 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)
|
||||
}
|
||||
Reference in New Issue
Block a user