first release
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
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 != ""}
|
||||
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: poiCategoryLabel(c)})
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user