备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」: - 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上 用绿/灰/红标出会新增 / 不变 / 会丢的区域 - 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人), 新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行; 合入的行写回 entity_raw,保证再传同一份备份时差异归零 - 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的 到访全变成假冲突 - 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联; 访客票没有 dataset_id,回填按活跃数据集认领 - 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释 导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
302 lines
11 KiB
Go
302 lines
11 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"rond-with-you/internal/setting"
|
||
)
|
||
|
||
// 地点图片的几条硬约束。字节由浏览器转好 WebP 才发过来,服务端只做形状校验,
|
||
// 不重新编码(1 核机器扛不住解码大图)。
|
||
const (
|
||
imageMaxBytes = 6 << 20 // 单张上限 6MB:长边 1440px、按 220KB 目标降质的 WebP 通常不到 300KB
|
||
imageMaxPer = 12 // 单个地点最多留几张,防止误拖一堆进来
|
||
imageUploadMem = 8 << 20 // multipart 解析用的内存上限
|
||
)
|
||
|
||
// placeKeyExpr 把 places p 那一行的坐标换算成 place_key,图片、备注、结论共用这个键。
|
||
// **必须与 rond.PlaceCoordKey 以及 notes/votes 包里的同款表达式逐位一致**:
|
||
// fmt.Sprintf("%.6f|%.6f") ↔ to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')
|
||
const placeKeyExpr = `to_char(p.lat,'FM999990.000000')||'|'||to_char(p.lon,'FM999990.000000')`
|
||
|
||
// PlaceImage 是模板里用的地点图片。
|
||
type PlaceImage struct {
|
||
ID int64
|
||
URL string
|
||
Width int
|
||
Height int
|
||
Bytes int64
|
||
}
|
||
|
||
// savePlaceImage 把一段已经转好的 WebP 字节存到当前后端,落一行记录,并返回可用的地址。
|
||
// 上传接口与照片导入共用这一条路径,省得两处各写一遍 key 规则。
|
||
// url 为空表示「存是存进去了,但没配 s3_public_base,浏览器取不到」——调用方要据此提示。
|
||
func (s *Server) savePlaceImage(ctx context.Context, datasetID int64, srcPK int, data []byte, w, h int) (int64, string, error) {
|
||
if !isWebP(data) {
|
||
return 0, "", fmt.Errorf("不是 WebP 数据")
|
||
}
|
||
backend, err := s.imageBackend(ctx)
|
||
if err != nil {
|
||
return 0, "", err
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
|
||
// 按月份分目录:一次出行传几十张,按月归堆最好找、也最好整月备份/迁移;
|
||
// 若按地点分目录,几百个地点会摊出几百个大多只有零张图的空夹。
|
||
// 文件名带地点号:在对象存储控制台里一眼看出这张是谁的,不用回库查。
|
||
// 带内容哈希:同一张图重复上传落到同一个 key,天然去重。
|
||
key := fmt.Sprintf("%d/%s/%d-%s.webp", datasetID, time.Now().Format("200601"),
|
||
srcPK, hex.EncodeToString(sum[:])[:16])
|
||
if err := s.media.Save(ctx, backend, key, data, "image/webp"); err != nil {
|
||
return 0, "", err
|
||
}
|
||
pkey, err := s.placeKeyOf(ctx, datasetID, srcPK)
|
||
if err != nil {
|
||
return 0, "", err
|
||
}
|
||
var id int64
|
||
if err := s.db.QueryRowContext(ctx, `INSERT INTO place_images
|
||
(dataset_id, src_pk, place_key, storage, object_key, width, height, bytes, position)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||
COALESCE((SELECT max(position)+1 FROM place_images WHERE place_key=$3), 0))
|
||
ON CONFLICT (dataset_id, src_pk, object_key) DO UPDATE SET bytes=EXCLUDED.bytes
|
||
RETURNING id`,
|
||
datasetID, srcPK, pkey, backend, key, w, h, int64(len(data))).Scan(&id); err != nil {
|
||
return 0, "", err
|
||
}
|
||
return id, s.media.PublicURL(backend, key), nil
|
||
}
|
||
|
||
// adminPlaceImageUpload 收下浏览器转好的 WebP 并落库。
|
||
// 存哪边由后台设置决定;对象存储的凭据只在服务端,前端拿到的永远是最终 URL。
|
||
func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
|
||
return
|
||
}
|
||
if err := r.ParseMultipartForm(imageUploadMem); err != nil {
|
||
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
|
||
return
|
||
}
|
||
src, err := strconv.Atoi(strings.TrimSpace(r.FormValue("src_pk")))
|
||
if err != nil {
|
||
s.json(w, map[string]any{"error": "地点标识无效"})
|
||
return
|
||
}
|
||
var exists bool
|
||
if err := s.db.QueryRowContext(ctx, `SELECT true FROM places
|
||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&exists); err != nil {
|
||
s.json(w, map[string]any{"error": "地点不存在"})
|
||
return
|
||
}
|
||
|
||
pkey, err := s.placeKeyOf(ctx, ds.ID, src)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
var count int
|
||
// 按坐标键数:换数据集后图片仍跟着同一个地点,配额也要跟着
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM place_images
|
||
WHERE place_key=$1`, pkey).Scan(&count); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if count >= imageMaxPer {
|
||
s.json(w, map[string]any{"error": fmt.Sprintf("一个地点最多 %d 张,先删几张再传", imageMaxPer)})
|
||
return
|
||
}
|
||
|
||
file, hdr, err := r.FormFile("file")
|
||
if err != nil {
|
||
s.json(w, map[string]any{"error": "没有收到图片"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
data, err := io.ReadAll(io.LimitReader(file, imageMaxBytes+1))
|
||
if err != nil {
|
||
s.json(w, map[string]any{"error": "读取上传内容失败"})
|
||
return
|
||
}
|
||
if len(data) > imageMaxBytes {
|
||
s.json(w, map[string]any{"error": fmt.Sprintf("图片超过 %dMB", imageMaxBytes>>20)})
|
||
return
|
||
}
|
||
if !isWebP(data) {
|
||
// 前端没转成功(老浏览器不支持 canvas.toBlob('image/webp'))时给出明确提示
|
||
s.json(w, map[string]any{"error": "只接受 WebP:请用较新的浏览器,或在转换完成后再保存"})
|
||
return
|
||
}
|
||
|
||
id, url, err := s.savePlaceImage(ctx, ds.ID, src, data,
|
||
atoiOr(r.FormValue("w"), 0), atoiOr(r.FormValue("h"), 0))
|
||
if err != nil {
|
||
log.Printf("地点图片写入失败(src_pk=%d): %v", src, err)
|
||
s.json(w, map[string]any{"error": "保存图片失败:" + err.Error()})
|
||
return
|
||
}
|
||
backend, _ := s.imageBackend(ctx)
|
||
out := map[string]any{"ok": true, "id": id, "url": url, "bytes": len(data),
|
||
"name": hdr.Filename, "storage": backend}
|
||
if url == "" {
|
||
out["error"] = "图片已存进对象存储,但没配 s3_public_base,浏览器取不到;" +
|
||
"请在 conf/app.ini 里补上公开域名后重新上传"
|
||
}
|
||
s.json(w, out)
|
||
}
|
||
|
||
// adminPlaceImageDelete 删掉一张图:先删对象再删行(对象删失败也不留孤儿行)。
|
||
func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
|
||
return
|
||
}
|
||
// 前端是用 FormData 提交的(multipart),这里**不要**先 r.ParseForm():
|
||
// 它会把 r.Form 先填成「不含 multipart 字段」的那一份,之后再怎么读都是空的
|
||
// (FormValue 只在 r.Form == nil 时才去解析)。直接用 FormValue,它自己会挑对解析方式。
|
||
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||
if err != nil {
|
||
s.json(w, map[string]any{"error": "图片标识无效"})
|
||
return
|
||
}
|
||
var backend, key string
|
||
if err := s.db.QueryRowContext(ctx, `SELECT storage, object_key FROM place_images
|
||
WHERE id=$1 AND dataset_id=$2`, id, ds.ID).Scan(&backend, &key); err != nil {
|
||
s.json(w, map[string]any{"error": "图片不存在"})
|
||
return
|
||
}
|
||
if err := s.media.Delete(ctx, backend, key); err != nil {
|
||
// 对象删不掉就留着行,否则文件永远无人回收
|
||
log.Printf("删除图片对象失败(%s %s): %v", backend, key, err)
|
||
s.json(w, map[string]any{"error": "删除文件失败:" + err.Error()})
|
||
return
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1 AND dataset_id=$2`,
|
||
id, ds.ID); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.json(w, map[string]any{"ok": true})
|
||
}
|
||
|
||
// imageBackend 取当前设置的存储后端;选了对象存储但凭据不全时明确报出来,
|
||
// 不要静默退回本地(那样用户以为存到 R2 了,其实落在服务器磁盘上)。
|
||
func (s *Server) imageBackend(ctx context.Context) (string, error) {
|
||
st, err := s.sets.Load(ctx)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if st.MediaBackend == setting.MediaBackendS3 {
|
||
if !s.media.S3Ready() {
|
||
return "", fmt.Errorf("后台选了对象存储,但 conf/app.ini 里的 s3_* 没配齐")
|
||
}
|
||
return setting.MediaBackendS3, nil
|
||
}
|
||
return setting.MediaBackendLocal, nil
|
||
}
|
||
|
||
// placeKeyOf 把某个数据集里的地点编号换算成坐标键。
|
||
// 换数据集之后仍靠它认出「还是同一个地点」,所以图片、备注、结论都挂在这上面。
|
||
func (s *Server) placeKeyOf(ctx context.Context, datasetID int64, srcPK int) (string, error) {
|
||
var key string
|
||
err := s.db.QueryRowContext(ctx, `SELECT `+placeKeyExpr+` FROM places p
|
||
WHERE p.dataset_id=$1 AND p.src_pk=$2 AND p.lat IS NOT NULL AND p.lon IS NOT NULL`,
|
||
datasetID, srcPK).Scan(&key)
|
||
if err == sql.ErrNoRows {
|
||
return "", fmt.Errorf("地点不存在或没有坐标")
|
||
}
|
||
return key, err
|
||
}
|
||
|
||
// placeImagesOf 读某个地点的图片,按 position 排序。
|
||
// 按坐标键查而不是 (dataset_id, src_pk):换一份备份之后图片还跟着同一个地点。
|
||
func (s *Server) placeImagesOf(ctx context.Context, datasetID int64, srcPK int) ([]PlaceImage, error) {
|
||
pkey, err := s.placeKeyOf(ctx, datasetID, srcPK)
|
||
if err != nil {
|
||
return nil, nil // 地点没有坐标就当作没有图
|
||
}
|
||
rows, err := s.db.QueryContext(ctx, `SELECT id, storage, object_key, width, height, bytes
|
||
FROM place_images WHERE place_key=$1 ORDER BY position, id`, pkey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []PlaceImage
|
||
for rows.Next() {
|
||
var img PlaceImage
|
||
var backend, key string
|
||
if err := rows.Scan(&img.ID, &backend, &key, &img.Width, &img.Height, &img.Bytes); err != nil {
|
||
return nil, err
|
||
}
|
||
url := s.media.PublicURL(backend, key)
|
||
if url == "" {
|
||
continue // 存储配置被撤掉后取不到址,跳过而不是给出破图
|
||
}
|
||
img.URL = url
|
||
out = append(out, img)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// serveMedia 提供本地存储的图片。key 是内容寻址的(带 sha1 前缀),
|
||
// 内容永不变化,所以可以长期强缓存。
|
||
func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) {
|
||
rel := strings.TrimPrefix(r.URL.Path, "/media/")
|
||
full, err := s.media.LocalPath(rel)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
f, err := os.Open(full)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
fi, err := f.Stat()
|
||
if err != nil || fi.IsDir() {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", mimeOfExt(filepath.Ext(full)))
|
||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||
http.ServeContent(w, r, path.Base(full), fi.ModTime(), f)
|
||
}
|
||
|
||
func mimeOfExt(ext string) string {
|
||
switch strings.ToLower(ext) {
|
||
case ".webp":
|
||
return "image/webp"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".gif":
|
||
return "image/gif"
|
||
default:
|
||
return "application/octet-stream"
|
||
}
|
||
}
|
||
|
||
// isWebP 校验 RIFF....WEBP 头。前端已经转过一道,这里是防御性的最后一道。
|
||
func isWebP(b []byte) bool {
|
||
return len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP"
|
||
}
|