Files
rond-with-you/internal/web/handlers_photos.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

283 lines
11 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"
"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++
}
logAggFailure("数据集计数", s.refreshDatasetAgg(ctx, s.db, 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)
}