Files
rond-with-you/internal/web/fillup.go
T
tamakyi 9c7e152361 补全页显示高德找到的 POI 与类型,类别标出真机用过的
原先没映射出建议的行就是一片空白,可高德其实早就说了「这里是什么」——
「中国邮政 / 生活服务;邮局」「沙县小吃 / 餐饮服务;中餐厅」,
这种信息看着就能判断该填什么。现在多一列「高德参考」把它显示出来。

类别下拉带上中文,并标出哪几种是**真机确实用过**的:
ZCATEGORY_ 那列存的就是这些 MKPOICategory 枚举串,那 12 种导回 rond 一定认得,
其余是 MapKit 支持但你的设备还没用过的,写错只会显示不出类别、不会坏数据。
2026-09-19 16:46:34 +08:00

458 lines
14 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
Type string // 高德给的原始类型(「餐饮服务;咖啡厅」),没映射上时给站长看
Poi string // 最近的 POI 名字,同样只作参考
}
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 // 建议从哪来
AmapType string // 高德给出的类型原文,供人工判断(可能没映射出建议)
AmapPoi string // 高德找到的最近 POI 名字
}
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 []fillupCategory // 类别下拉选项
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
}
out[i].AmapType, out[i].AmapPoi = h.Type, h.Poi
}
}
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()
}
// fillupCategory 是类别下拉的一项。Label 用中文,并标出这一种真机到底用过没有——
// ZCATEGORY_ 那列真机存的就是这些 MKPOICategory 枚举串,写错名字不会损坏数据,
// 但会显示不出类别,所以优先选「真机用过」的那几种。
type fillupCategory struct {
Value string
Label string
}
// fillupCategories 是类别下拉的选项:真机用过的 12 种在前,其余常见 MapKit 类别在后。
func fillupCategories() []fillupCategory {
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",
}
out := make([]fillupCategory, 0, len(known)+len(extra))
for _, c := range known {
out = append(out, fillupCategory{c, poiName(c) + "(真机用过)"})
}
for _, c := range extra {
out = append(out, fillupCategory{c, poiName(c)})
}
return out
}
// 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
}
poi, typ, _, ok := s.amapAround(ctx, row.Lat, row.Lon)
var h fillupHint
if ok {
h.Type, h.Poi = typ, poi
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)
}