Files
rond-with-you/internal/web/fillup.go
T
tamakyi 6c061a0548 地点补全:按地名与坐标猜活动与类别,只填空
后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来,
给出建议、由站长确认后再写。判断分三层:

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

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

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

规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则
「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先
(否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。
2026-09-19 16:31:01 +08:00

437 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}