Files
rond-with-you/internal/web/fillup.go
T
tamakyi 5aeded2d51 补全页恢复手工填写,把「不动」变成真正的默认
上一版改成 select 之后有两个问题:活动和类别都只能从列表里挑,填不进去的写不了;
而且建议被强制预选,用户选「不动」再点「用高德分析」(会整页重载)就白选了,
看起来像被锁死。

现在:活动是可输入的输入框,带已有类型提示,填新名字会新建类型;
类别下拉末尾加「其它(自己写)…」,选中后露出文本框,写什么存什么——
真机自己也存中文类别,写中文一样认得,不必强记 MKPOICategory 那串枚举名。

「不动」改成真正的默认:什么都不预选,想批量采纳就点「采纳本页建议」。
点「用高德分析」前会把本页填过的存进 sessionStorage,回来接着填,不会白改。
2026-09-19 20:13:06 +08:00

512 lines
16 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"
"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
}