上一版改成 select 之后有两个问题:活动和类别都只能从列表里挑,填不进去的写不了; 而且建议被强制预选,用户选「不动」再点「用高德分析」(会整页重载)就白选了, 看起来像被锁死。 现在:活动是可输入的输入框,带已有类型提示,填新名字会新建类型; 类别下拉末尾加「其它(自己写)…」,选中后露出文本框,写什么存什么—— 真机自己也存中文类别,写中文一样认得,不必强记 MKPOICategory 那串枚举名。 「不动」改成真正的默认:什么都不预选,想批量采纳就点「采纳本页建议」。 点「用高德分析」前会把本页填过的存进 sessionStorage,回来接着填,不会白改。
512 lines
16 KiB
Go
512 lines
16 KiB
Go
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
|
||
}
|
||
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(),
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
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
|
||
}
|