你要改的那个「海滩」原来根本不在数据里——它是 Go 里硬编码的 map,所以改不动。
更糟的是这样的 map 有**两张**,而且对不上:
MKPOICategoryFoodMarket 菜市场(编辑页) / 食品市场(补全页、GPX)
MKPOICategoryCafe 咖啡馆 / 咖啡厅
MKPOICategoryFitnessCenter 健身房 / 健身中心
外加 ATM / Atm 大小写各写一份、一条永远匹配不上的 MKPOICategoryMarina2
现在合成一张内置表(42 条,冲突处取编辑页那套),并在它上面加了一层站点覆盖:
- 新表 category_labels(value, label),站点级、不挂 dataset——值本身才是稳定键
- 端点 POST /admin/edit/category/label;传空 label = 删掉覆盖行、恢复内置默认名
- 类别台账那行改成两个带标签的输入框:
导出值 [MKPOICategoryBeach] ← 会跟着导出写回手机
显示名 [ ] ← 只影响站内显示,placeholder 写着默认值
是 MKPOICategory 枚举但内置表里没有的,会提示「填一个中文名」,这就是你要的补全入口
**显示名绝不写进 places.poi_category**——中文跟着写进去就把导出数据污染了,
专门加了 TestCategoryLabelNeverTouchesValue 钉这条。显示名会贯穿到所有中文名的地方:
编辑页/照片页/补全页的下拉、补全页「在用」标签、访客地点页、GPX 的 desc。
连带删掉两个绑在旧表上的模板函数 catName / poiname:模板里算不出显示名(要读站点覆盖),
改成在 Go 侧算好塞进数据(FillupRow.CatLabel、PlaceData.CategoryLabel)。
283 lines
11 KiB
Go
283 lines
11 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"rond-with-you/internal/geo"
|
||
)
|
||
|
||
// 照片批量导入:浏览器读 EXIF(static/js/photo-exif.js),只把坐标与时间提交上来。
|
||
//
|
||
// 照片本身默认不上传:手机相册一次几十张、几百 MB,为了几十字节的 EXIF 全传一遍
|
||
// 既慢又占服务器磁盘(早期版本就是这么做的,51 张 334MB 直接把请求撑爆)。
|
||
// 例外是行内勾了「存图」的那几张——那几张会在浏览器里先转成 WebP(长边 2000px、
|
||
// 通常几百 KB)再随表单发过来,作为地点的配图。
|
||
const (
|
||
photoMaxRows = 300
|
||
// photoMaxBodyBytes 是整个导入请求的上限。不勾「存图」时表单只有几十 KB;
|
||
// 勾了之后每张图约 100~250KB(长边 1440px 的 WebP),60MB 够一次带两三百张。
|
||
photoMaxBodyBytes = 60 << 20
|
||
// 默认离开时间 = 到达后 10 分钟(用户可改)
|
||
photoDefaultStay = 10 * time.Minute
|
||
)
|
||
|
||
// PhotoData 是照片导入页的数据。页面只放一个文件选择框,
|
||
// 预览表由前端脚本填,所以这里没有表格数据。
|
||
type PhotoData struct {
|
||
Page
|
||
Err string
|
||
Flash string
|
||
// GeocodeReady 决定前端能否反查地址(没配高德 key 就只能看经纬度)
|
||
GeocodeReady bool
|
||
// 与「新增地点」同一套选项:类别 / 活动 / 标签
|
||
Categories []EditOption
|
||
Activities []EditOption
|
||
Tags []EditOption
|
||
}
|
||
|
||
func (s *Server) adminPhotosPage(w http.ResponseWriter, r *http.Request) {
|
||
p, err := s.pageBase(r, "admin", "照片导入")
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
p.Flatpickr = true
|
||
p.HasMap = true
|
||
d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""}
|
||
catNames := s.categoryLabels(r.Context())
|
||
ds, err := s.datasetFor(r.Context())
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if ds != nil {
|
||
// 与数据编辑页共用同一批选项,保证两边能选的东西一致
|
||
if d.Activities, err = s.editOptions(r.Context(), `SELECT a.id, COALESCE(a.name,'') FROM activities a
|
||
WHERE a.dataset_id=$1 ORDER BY a.name`, ds.ID); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if d.Tags, err = s.editOptions(r.Context(), `SELECT t.id, COALESCE(t.name,'') FROM tags t
|
||
WHERE t.dataset_id=$1 ORDER BY t.name`, ds.ID); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
crows, err := s.db.QueryContext(r.Context(), `SELECT DISTINCT poi_category FROM places
|
||
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' ORDER BY poi_category`, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for crows.Next() {
|
||
var c string
|
||
if err := crows.Scan(&c); err != nil {
|
||
crows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d.Categories = append(d.Categories, EditOption{Label: c, Name: categoryLabel(c, catNames)})
|
||
}
|
||
crows.Close()
|
||
}
|
||
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
|
||
s.render(w, "admin/photos", d)
|
||
}
|
||
|
||
// adminPhotosImport 把前端解析好、用户确认过的行写入 places + visits。
|
||
// 坐标默认按 WGS-84 处理(EXIF 就是 WGS-84),转成站点用的 GCJ-02 再落库。
|
||
func (s *Server) adminPhotosImport(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
u := userFrom(ctx)
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectPhotos(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
// 整个请求体封顶:勾了「存图」时表单是 multipart,最多可能带 300 张小图。
|
||
// 不设上限的话,一个畸形请求就能把这台 1 核机器吃满。
|
||
r.Body = http.MaxBytesReader(w, r.Body, photoMaxBodyBytes)
|
||
// 必须用 ParseMultipartForm 而不是 ParseForm:ParseForm 只认 urlencoded,
|
||
// 会把 PostForm 先填成空的那一份,后面 PostFormValue/FormFile 全是空的。
|
||
// urlencoded 的调用方(脚本、测试)会拿到 ErrNotMultipart —— 那种情况它已经
|
||
// 把普通表单解析好了,放过即可。
|
||
if err := r.ParseMultipartForm(imageUploadMem); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||
redirectPhotos(w, r, "err", "表单太大或读不出来(图片总量请控制在 60MB 以内)")
|
||
return
|
||
}
|
||
n := atoiOr(r.PostFormValue("n"), 0)
|
||
if n <= 0 {
|
||
redirectPhotos(w, r, "err", "没有可导入的照片")
|
||
return
|
||
}
|
||
if n > photoMaxRows {
|
||
redirectPhotos(w, r, "err", fmt.Sprintf("一次最多导入 %d 张,当前 %d 张", photoMaxRows, n))
|
||
return
|
||
}
|
||
// EXIF 标准是 WGS-84,但部分国产手机直接写 GCJ-02——由用户在页面上选择,
|
||
// 只有明确说「已是 GCJ-02」时才不转换
|
||
fromWGS84 := r.PostFormValue("coord") != "gcj02"
|
||
// 这次导入用的坐标系原样留档:以后要复算、要核对,都从这一个字段读,
|
||
// 不用再去猜「当初到底选了什么」(2026-09 那批 63 个点就是栽在没有它)
|
||
coordSys := "wgs84"
|
||
if !fromWGS84 {
|
||
coordSys = "gcj02"
|
||
}
|
||
|
||
// 地点的编号逐条分配(见下面 nextSrcPK);到访的主键由 insertVisit 自己取
|
||
var imported, skipped, withImage int
|
||
var firstErr, imageErr string
|
||
for i := 0; i < n; i++ {
|
||
if r.PostFormValue(fmt.Sprintf("keep_%d", i)) != "on" {
|
||
skipped++
|
||
continue
|
||
}
|
||
lat, ok1 := parseFloat(r.PostFormValue(fmt.Sprintf("lat_%d", i)))
|
||
lon, ok2 := parseFloat(r.PostFormValue(fmt.Sprintf("lon_%d", i)))
|
||
if !ok1 || !ok2 || (lat == 0 && lon == 0) {
|
||
firstErr = fmt.Sprintf("第 %d 行坐标无效,已跳过", i+1)
|
||
skipped++
|
||
continue
|
||
}
|
||
// 换算前的原值先存下来:显示坐标换算错了还能反推回去,也能和原图 EXIF 直接对账
|
||
srcLat, srcLon := lat, lon
|
||
if fromWGS84 {
|
||
lat, lon = geo.WGS84ToGCJ02(lat, lon)
|
||
}
|
||
arrival, ok := parseLocalDT(r.PostFormValue(fmt.Sprintf("arrival_%d", i)))
|
||
if !ok {
|
||
firstErr = fmt.Sprintf("第 %d 行到达时间无效,已跳过", i+1)
|
||
skipped++
|
||
continue
|
||
}
|
||
var departure *time.Time
|
||
if v := strings.TrimSpace(r.PostFormValue(fmt.Sprintf("departure_%d", i))); v != "" {
|
||
if t, ok := parseLocalDT(v); ok && !t.Before(arrival) {
|
||
departure = &t
|
||
}
|
||
}
|
||
if departure == nil {
|
||
d := arrival.Add(photoDefaultStay)
|
||
departure = &d
|
||
}
|
||
name := strings.TrimSpace(r.PostFormValue(fmt.Sprintf("name_%d", i)))
|
||
if name == "" {
|
||
name = fmt.Sprintf("照片地点 %d", i+1)
|
||
}
|
||
|
||
var placeID int64
|
||
// 字段与「新增地点」一致:地址相关字段来自反查(也可能被用户改过),
|
||
// 空串交给 NULLIF 存成 NULL。类别支持下拉里的「自定义…」。
|
||
category := r.PostFormValue(fmt.Sprintf("category_%d", i))
|
||
if category == categoryCustomValue {
|
||
category = r.PostFormValue(fmt.Sprintf("category_custom_%d", i))
|
||
}
|
||
// 地点与到访一个事务:以前分两步走,写到访失败时地点会留下成为孤儿
|
||
//(visit_count=0),导进 rond 就是个永远「无数据」的点。
|
||
// 到访走与「新增到访」同一条路径:活动 / 备注 / 收藏 / 标签都能带上
|
||
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue(fmt.Sprintf("activity_%d", i)))
|
||
var placeSrc int
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
// 编号按「库里 + Core Data + 留档」三处最大值取:留档里存着导入时缺必填
|
||
// 字段的 skipped 行,那些号没有对应实体行却一直占着,只看 MAX(places) 会复用,
|
||
// 导出时新行被当旧行、继承对方的未管理列。
|
||
pk, err := s.nextSrcPK(ctx, ex, ds.ID, placeSeq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := clearStaleRaw(ctx, ex, ds.ID, "ZLOCATION", pk); err != nil {
|
||
return err
|
||
}
|
||
placeSrc = pk
|
||
if err := ex.QueryRowContext(ctx, `INSERT INTO places
|
||
(dataset_id, src_pk, name, poi_category, lat, lon, country_code,
|
||
province, city, district, thoroughfare, timezone,
|
||
coord_source, coord_sys, src_lat, src_lon)
|
||
VALUES ($1,$2,$3,NULLIF($4,''),$5,$6,NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),NULLIF($10,''),NULLIF($11,''),NULLIF($12,''),$13,$14,$15,$16)
|
||
RETURNING id`,
|
||
ds.ID, pk, name, category,
|
||
lat, lon, r.PostFormValue(fmt.Sprintf("country_%d", i)),
|
||
r.PostFormValue(fmt.Sprintf("province_%d", i)),
|
||
r.PostFormValue(fmt.Sprintf("city_%d", i)), r.PostFormValue(fmt.Sprintf("district_%d", i)),
|
||
r.PostFormValue(fmt.Sprintf("street_%d", i)), r.PostFormValue(fmt.Sprintf("timezone_%d", i)),
|
||
"photo", coordSys, srcLat, srcLon).Scan(&placeID); err != nil {
|
||
return fmt.Errorf("写地点: %w", err)
|
||
}
|
||
if _, err := s.insertVisit(ctx, ex, ds.ID, u.ID, placeID, arrival, departure, actID, isHome, isWork,
|
||
r.PostFormValue(fmt.Sprintf("bookmarked_%d", i)) == "on",
|
||
strings.TrimSpace(r.PostFormValue(fmt.Sprintf("remark_%d", i))), "",
|
||
r.PostForm[fmt.Sprintf("tags_%d", i)]); err != nil {
|
||
return fmt.Errorf("写到访: %w", err)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
log.Printf("照片导入:第 %d 行落库失败(已整体回滚)%v", i+1, err)
|
||
firstErr = "部分照片落库失败,已跳过"
|
||
skipped++
|
||
continue
|
||
}
|
||
// 勾了「存图」的行,浏览器已经把这张照片转成 WebP 一起发过来了。
|
||
// 图片存失败只记日志、不回滚地点:地点已经写好,丢一张图不该连点一起丢。
|
||
if n, err := s.attachImportedImage(ctx, r, ds.ID, placeSrc, i); err != nil {
|
||
log.Printf("照片导入:第 %d 行配图失败(地点已保留): %v", i+1, err)
|
||
imageErr = "部分照片的配图没存上(地点已导入)"
|
||
} else if n > 0 {
|
||
withImage += n
|
||
}
|
||
imported++
|
||
}
|
||
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported)
|
||
if withImage > 0 {
|
||
msg += fmt.Sprintf(",配了 %d 张图", withImage)
|
||
}
|
||
if skipped > 0 {
|
||
msg += fmt.Sprintf(",跳过 %d 张", skipped)
|
||
}
|
||
if firstErr != "" {
|
||
msg += "。" + firstErr
|
||
}
|
||
if imageErr != "" {
|
||
msg += "。" + imageErr
|
||
}
|
||
redirectPhotos(w, r, "ok", msg)
|
||
}
|
||
|
||
// attachImportedImage 收下行内附带的 WebP 并挂到刚建好的地点上。
|
||
// 浏览器已经把原图转码过了,这里只做形状校验与转存;没有这一项就返回 0。
|
||
func (s *Server) attachImportedImage(ctx context.Context, r *http.Request, datasetID int64, srcPK, row int) (int, error) {
|
||
f, _, err := r.FormFile(fmt.Sprintf("image_%d", row))
|
||
if err != nil {
|
||
return 0, nil // 这一行没勾「存图」
|
||
}
|
||
defer f.Close()
|
||
data, err := io.ReadAll(io.LimitReader(f, imageMaxBytes+1))
|
||
if err != nil {
|
||
return 0, fmt.Errorf("读图失败: %w", err)
|
||
}
|
||
if len(data) == 0 {
|
||
return 0, nil
|
||
}
|
||
if len(data) > imageMaxBytes {
|
||
return 0, fmt.Errorf("图片超过 %dMB", imageMaxBytes>>20)
|
||
}
|
||
if _, _, err := s.savePlaceImage(ctx, datasetID, srcPK, data,
|
||
atoiOr(r.PostFormValue(fmt.Sprintf("image_w_%d", row)), 0),
|
||
atoiOr(r.PostFormValue(fmt.Sprintf("image_h_%d", row)), 0)); err != nil {
|
||
return 0, err
|
||
}
|
||
return 1, nil
|
||
}
|
||
|
||
// redirectPhotos 把结果带回照片导入页。
|
||
func redirectPhotos(w http.ResponseWriter, r *http.Request, kind, msg string) {
|
||
http.Redirect(w, r, "/admin/photos?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
|
||
}
|