package web
import (
"context"
"fmt"
"html/template"
"net/http"
"net/url"
"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
// 分页。218 行在手机上堆成一屏一行有五万多像素长,一页 30 行刚好。
PageNo int
PageCount int
}
// fillupPerPage 是补全页每页的行数。
const fillupPerPage = 30
// PageLink 生成带页码的链接,供模板里的上一页/下一页用。
func (d FillupData) PageLink(no int) template.URL {
if no < 1 {
no = 1
}
return template.URL("/admin/fillup?page=" + strconv.Itoa(no))
}
// 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
}
usedCats, err := s.usedCategories(ctx, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
total := len(rows)
hinted := 0
for _, row := range rows {
if row.ActHint != "" || row.CatHint != "" {
hinted++
}
}
pageCount := (total + fillupPerPage - 1) / fillupPerPage
pageNo := clampPage(r.URL.Query().Get("page"), pageCount)
from := (pageNo - 1) * fillupPerPage
to := from + fillupPerPage
if to > total {
to = total
}
rows = rows[from:to]
s.render(w, "admin/fillup", FillupData{
Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(usedCats),
Total: total, WithHint: hinted, PageNo: pageNo, PageCount: pageCount,
})
}
// 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
}
// usedCategories 列出这个数据集里真在用的类别(按地点数从多到少),排在静态表前面。
// 候选得先跟已有数据对得上——否则用户会在这一页挑一个名字相近的静态枚举,
// 跟库里已有的值并存成两种,同一个意思分两处统计。
func (s *Server) usedCategories(ctx context.Context, datasetID int64) ([]fillupCategory, error) {
rows, err := s.db.QueryContext(ctx, `SELECT poi_category, count(*) FROM places
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' GROUP BY 1 ORDER BY 2 DESC, 1`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []fillupCategory
for rows.Next() {
var v string
var n int
if err := rows.Scan(&v, &n); err != nil {
return nil, err
}
// 值可能是 MKPOICategory 枚举,也可能是真机自己写的中文——poiCategoryLabel 两种都认
out = append(out, fillupCategory{v, fmt.Sprintf("%s(在用 %d)", poiCategoryLabel(v), n)})
}
return out, rows.Err()
}
// fillupCategories 是类别下拉的选项:数据集里真在用的排最前面,再跟真机用过的 12 种、
// 其余常见 MapKit 类别。同一个值只出现一次——在用的那份信息更准,就顶掉静态表里的说明。
func fillupCategories(inUse []fillupCategory) []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",
}
seen := make(map[string]bool, len(inUse)+len(known)+len(extra))
out := make([]fillupCategory, 0, len(inUse)+len(known)+len(extra))
push := func(c fillupCategory) {
if c.Value == "" || seen[c.Value] {
return
}
seen[c.Value] = true
out = append(out, c)
}
for _, c := range inUse {
push(c)
}
for _, c := range known {
push(fillupCategory{c, poiName(c) + "(真机用过)"})
}
for _, c := range extra {
push(fillupCategory{c, poiName(c)})
}
return out
}
// adminFillupApply 执行补全。表单里每行两个字段:
//
// act_ = "" 不动
// = 活动 id 设为该活动
// = "__new__:名字" 新建这个活动再设
// cat_ = "" 不动
// = 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 {
// 框里填的是**活动名**:对得上就用现有的,对不上就新建一个。
// 下拉选了「其它(自己写)」时,真正要读的是旁边那个文本框——与类别同一套写法。
actKey := "act_" + strconv.FormatInt(pid, 10)
actName := strings.TrimSpace(r.PostFormValue(actKey))
if actName == "__custom__" {
actName = strings.TrimSpace(r.PostFormValue(actKey + "__custom"))
}
if name := actName; 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)
}
}
key := "cat_" + strconv.FormatInt(pid, 10)
cat := strings.TrimSpace(r.PostFormValue(key))
if cat == "__custom__" {
// 下拉里选了「其它(自己写)」,真正要写的是旁边那个文本框
cat = strings.TrimSpace(r.PostFormValue(key + "__custom"))
}
if 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) {
q := url.Values{}
q.Set(kind, msg)
if pg := strings.TrimSpace(r.PostFormValue("page")); pg != "" {
q.Set("page", pg)
}
http.Redirect(w, r, "/admin/fillup?"+q.Encode(), http.StatusSeeOther)
}
// clampPage 解析页码并夹到有效范围;解析不出来就回第一页。
func clampPage(raw string, pageCount int) int {
no, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || no < 1 {
no = 1
}
if pageCount > 0 && no > pageCount {
no = pageCount
}
return no
}