导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /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。
352 lines
13 KiB
Go
352 lines
13 KiB
Go
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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||
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)
|
||
}
|
||
}
|