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(`` + "\n") b.WriteString(`` + "\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, "%s", p.Lat, p.Lon, xmlEscape(p.Name)) if desc != "" { fmt.Fprintf(&b, "%s", xmlEscape(desc)) } if last != "" { fmt.Fprintf(&b, "", last) } b.WriteString("\n") } b.WriteString("\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 } archive, err := s.findSourceArchive(ctx, datasetID) if err != nil { return "", err } if err := rond.WriteBaseline(archive, p); err != nil { return "", fmt.Errorf("重建基线库失败:%w", err) } return p, 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 } 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()) }) if f := pickArchiveBySha(files, sha, size, func(f string) (string, error) { got, _, err := ingest.File(f) return got, err }); 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) } }