Files
rond-with-you/internal/web/handlers_export.go
T
tamakyi 6ff6f944a1 换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /admin/baseline(后台首页加入口)就是给同一份数据的底座换代用的。

三道闸,顺序不能挪:
1. 重合判据:新包里的到访 UUID 与站点侧带 UUID 的到访比。一条都对不上直接拒
   (连 force 也不给过)——干跑只验结构,一份不相干的包照样导得出,但换下去站点
   每行都在新底座里认不出身份,全按新行插到最大编号之后,真机上就是「这些到访
   全变成新的」。重合率不到一半要显式确认;判据没过不当场报错了事:包已经收进
   uploads,页面给一个「我确认,强制替换」按钮,不必重传几十 MB。
2. 干跑导出:底座缺表/缺实体编号在这步挡住,不会出现「换完了才发现导不出、
   原来那份已经不知去哪」。
3. 旧底座留档 + 记档,换错了可一键回滚。

两个坑:
- 留档名不能只用秒级时间戳。「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把
  刚生成的留档覆盖掉,于是回滚回了个寂寞(TestRollbackBaselineRestoresPrevious 当场红)。
  改用 baselineBackupName():20060102-150405 + 冲突自增。
- ensureBaseline 必须先看记档。只认 datasets.sha256(= 最初那份包)的话,底座一丢就静默
  重建回旧包,上一次换基线白做且毫无提示。抽出 ensureBaselineTo(ctx, dsID, dst) 共用;
  「重建」入口写临时文件再 rename,不做破坏性删除。

回归用例 7 条(internal/web/baseline_swap_test.go):成功路径断言「换底座前后导出包里
ZVISIT 的 UUID 集合逐条一致」——编号会换、身份不该换,谁在底座里没对上立刻亮;
记档优先重建带反证(清掉记档后同一条路必须走不通);部分重合需确认、完全对不上 force
也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、页面渲染到底。

验收:./scripts/verify.sh 全量 PASS=318 FAIL=0 SKIP=0。
2026-09-30 19:00:18 +08:00

352 lines
13 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 (
"bytes"
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
"rond-with-you/internal/stats"
)
// 这两个导出面向站长自己的数据迁移,因此不做「家 / 工作」过滤——
// 能进后台的人本来就拥有全部数据的查看权。
func (s *Server) exportPlaces(w http.ResponseWriter, r *http.Request) []stats.Place {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return nil
}
// 直接按 dataset 全量取,绕过筛选与隐私开关
rows, err := s.db.QueryContext(ctx, `SELECT `+stats.PlaceCols+`
FROM places p WHERE p.dataset_id=$1 ORDER BY p.id`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return nil
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var p stats.Place
if err := stats.ScanPlace(rows, &p); err != nil {
s.serverError(w, r, err)
return nil
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
s.serverError(w, r, err)
return nil
}
return out
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
b.WriteString(`{"type":"FeatureCollection","features":[`)
for i, p := range places {
if i > 0 {
b.WriteByte(',')
}
var first, last string
if p.FirstAt != nil {
first = p.FirstAt.UTC().Format("2006-01-02")
}
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02")
}
// GeoJSON 坐标是 [lon, lat];库里的坐标本身是 GCJ-02,原样导出并在说明里注明
fmt.Fprintf(&b, `{"type":"Feature","geometry":{"type":"Point","coordinates":[%v,%v]},`+
`"properties":{"name":%q,"city":%q,"province":%q,"category":%q,`+
`"visits":%d,"dwell_min":%d,"first":%q,"last":%q,"crs":"gcj02"}}`,
p.Lon, p.Lat, p.Name, p.City, p.Province, p.POICategory,
p.VisitCount, p.DwellMinutes, first, last)
}
b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
w.Header().Set("Content-Disposition", downloadDisposition("places.geojson"))
w.Write([]byte(b.String()))
}
func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
catNames := s.categoryLabels(r.Context())
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<gpx version="1.1" creator="rond-with-you" xmlns="http://www.topografix.com/GPX/1/1">` + "\n")
for _, p := range places {
var parts []string
if p.City != "" {
parts = append(parts, p.Province, p.City)
}
if p.POICategory != "" {
parts = append(parts, categoryLabel(p.POICategory, catNames))
}
desc := strings.Join(strings.Fields(strings.Join(parts, " · ")), " ")
var last string
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02T15:04:05Z")
}
fmt.Fprintf(&b, "<wpt lat=\"%v\" lon=\"%v\"><name>%s</name>", p.Lat, p.Lon, xmlEscape(p.Name))
if desc != "" {
fmt.Fprintf(&b, "<desc>%s</desc>", xmlEscape(desc))
}
if last != "" {
fmt.Fprintf(&b, "<time>%s</time>", last)
}
b.WriteString("</wpt>\n")
}
b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition",
downloadDisposition("places-"+time.Now().Format("20060102")+".gpx"))
w.Write([]byte(b.String()))
}
// downloadDisposition 拼 Content-Disposition。
//
// 导出包的名字里带着 `datasets.original_name`,那是**上传时的文件名**——含引号、斜杠、
// 控制字符都正常,甚至可能整段 `"; filename*=…` 之类的内容。原样塞进 `filename="…"`
// 就能提前闭合引号、往这个头里塞参数(net/http 只把 CR/LF 换成空格,管不了引号)。
// 所以给两个值:
// - `filename=` 只留 ASCII 安全字符,老浏览器兜底;
// - `filename*=`(RFC 5987)按 UTF-8 百分号编码带真实名字,现代浏览器优先取它,
// 中文名不会像以前那样被当成 Latin-1 乱码。
func downloadDisposition(name string) string {
if strings.TrimSpace(name) == "" {
name = "download"
}
ext := filepath.Ext(name)
stem := asciiSafe(strings.TrimSuffix(name, ext))
// 纯中文名的词干映射完就是一串下划线,给个能读的兜底名;但不能把扩展名一起丢掉,
// 否则用户下到手里是个没法双击打开的「download」。
if !hasAlnum(stem) {
stem = "download"
}
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", stem+asciiSafe(ext), rfc5987Escape(name))
}
// asciiSafe 把不能安全写进 `filename="…"` 的字符换成下划线:
// 引号能提前闭合这个参数,斜杠/反斜杠会在落盘时被当成路径,`;` `=` 能伪造后续参数。
func asciiSafe(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e {
return '_'
}
switch r {
case '"', '\\', '/', ';', ':', '=', '*', '?', '<', '>', '|':
return '_'
}
return r
}, s)
}
func hasAlnum(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
})
}
// rfc5987Escape 按 RFC 5987 的 attr-char 编码:ASCII 字母数字与 `. _ - ~` 原样,
// 其余**逐字节**百分号编码(UTF-8 的每个字节各编一次,这正是规范要求的)。
// 故意只放行这个窄集合:`;` `=` 这类在 attr-char 之外,漏掉就能截断参数。
func rfc5987Escape(s string) string {
const hexDigits = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
return b.String()
}
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
func (s *Server) baselinePath(datasetID int64) string {
return filepath.Join(s.cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", datasetID))
}
// ensureBaseline 确保基线库存在;缺失时按记档或当初上传的原始备份包重建。
func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, error) {
p := s.baselinePath(datasetID)
if _, err := os.Stat(p); err == nil {
return p, nil
}
if err := s.ensureBaselineTo(ctx, datasetID, p); err != nil {
return "", err
}
return p, nil
}
// ensureBaselineTo 把底座库写到 dst(不论 dst 是否已存在)。调用方要先想清楚覆盖是否安全:
// 导出路径只会在底座缺失时调它,而「换基线」页要先写临时文件、成功了再改名就位。
func (s *Server) ensureBaselineTo(ctx context.Context, datasetID int64, dst string) error {
// 换过底座的数据集必须先看记档:datasets.sha256 指的是**最初**那份包,
// 拿它重建会把底座悄悄退回旧包——站长上一次换基线就白做了,而且没有任何提示。
var name, baseSHA string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,'')
FROM datasets WHERE id=$1`, datasetID).Scan(&name, &baseSHA); err != nil {
return err
}
if baseSHA != "" {
archive, err := s.findUploadBySha(baseSHA, 0)
if err != nil {
return fmt.Errorf("当前底座来自 %s,但 uploads 里已找不到那份包:请到「换基线」页重新上传", name)
}
if err := rond.WriteBaseline(archive, dst); err != nil {
return fmt.Errorf("重建基线库失败:%w", err)
}
return nil
}
archive, err := s.findSourceArchive(ctx, datasetID)
if err != nil {
return err
}
if err := rond.WriteBaseline(archive, dst); err != nil {
return fmt.Errorf("重建基线库失败:%w", err)
}
return nil
}
// uploadsNewestFirst 列出 uploads 目录里的候选包,新的在前(命中即止的查找靠这个顺序)。
func (s *Server) uploadsNewestFirst() []string {
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
sort.Slice(files, func(i, j int) bool {
fi, ei := os.Stat(files[i])
fj, ej := os.Stat(files[j])
if ei != nil || ej != nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
return files
}
func hashUpload(f string) (string, error) {
got, _, err := ingest.File(f)
return got, err
}
// findUploadBySha 按内容摘要找一份包(用于「换基线」记档的源包)。
// size > 0 时先按字节数筛一道再算摘要:uploads 里可能堆着几十份几十 MB 的包,
// 每份都完整读一遍只为比摘要太亏(sha 相同必然字节数相同)。
func (s *Server) findUploadBySha(sha string, size int64) (string, error) {
f := pickArchiveBySha(s.uploadsNewestFirst(), sha, size, hashUpload)
if f == "" {
return "", fmt.Errorf("找不到摘要为 %.8s… 的备份包", sha)
}
return f, nil
}
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
var size int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,''), COALESCE(size_bytes,0) FROM datasets WHERE id=$1`, datasetID).Scan(&sha, &size); err != nil {
return "", err
}
// 新的在前,命中即止
if f := pickArchiveBySha(s.uploadsNewestFirst(), sha, size, hashUpload); f != "" {
return f, nil
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
}
// pickArchiveBySha 从候选文件里挑出摘要匹配的那一份(没有则返回空串)。
//
// 先按字节数筛一道再算摘要:uploads 里可能堆着几十份几百 MB 的备份,每份都得完整读一遍
// 才比得出 sha256,而 sha256 相同就一定字节数相同——大小对不上的根本不必读。
// 没有 sha 可比时(老库或退化数据)不做筛选,保持「取最新的那份」的旧行为。
//
// hash 做成参数是为了让用例能数「到底算了几次摘要」:只看最终返回哪个文件,
// 带不带这道筛选完全一样,区分不出来。
func pickArchiveBySha(files []string, sha string, size int64, hash func(string) (string, error)) string {
for _, f := range files {
fi, err := os.Stat(f)
if err != nil || fi.IsDir() {
continue
}
if sha != "" && size > 0 && fi.Size() != size {
continue
}
got, err := hash(f)
if err != nil {
continue
}
if sha == "" || got == sha {
return f
}
}
return ""
}
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
// 坐标是 GCJ-02,与 rond 同系,原样写回;新增/编辑的记录随数据集一并导出。
func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return
}
base, err := s.ensureBaseline(ctx, ds.ID)
if err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
// 先构建到缓冲,成功后再写响应头,避免半截输出破坏下载文件
var buf bytes.Buffer
if err := rond.ExportRondbackup(ctx, s.db, ds.ID, base, &buf); err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
stem := strings.TrimSuffix(filepath.Base(ds.Name), filepath.Ext(ds.Name))
if stem == "" {
stem = "LifeEasy"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
downloadDisposition(stem+"-export-"+time.Now().Format("20060102")+".rondbackup"))
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
// 整包先落内存是为了绝不写出半截文件,代价是整个下载都得在写死线内完成。
// 全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够——只放宽这一条路由。
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
log.Printf("导出:无法延长写超时,仍按全站限制走: %v", err)
}
w.WriteHeader(http.StatusOK)
if _, err := buf.WriteTo(w); err != nil {
log.Printf("导出:下载写出中断: %v", err)
}
}