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 // 建议的类别 CatLabel 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, custom map[string]string) ([]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].CatLabel = categoryLabel(h.Cat, custom) } 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) catNames := s.categoryLabels(ctx) rows, err := s.fillupRows(ctx, ds.ID, catNames) 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, catNames) 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, catNames), 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, custom map[string]string) ([]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)", categoryLabel(v, custom), n)}) } return out, rows.Err() } // fillupCategories 是类别下拉的选项:数据集里真在用的排最前面,再跟真机用过的 12 种、 // 其余常见 MapKit 类别。同一个值只出现一次——在用的那份信息更准,就顶掉静态表里的说明。 func fillupCategories(inUse []fillupCategory, custom map[string]string) []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, categoryLabel(c, custom) + "(真机用过)"}) } for _, c := range extra { push(fillupCategory{c, categoryLabel(c, custom)}) } 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 } catNames := s.categoryLabels(ctx) rows, err := s.fillupRows(ctx, ds.ID, catNames) 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 }