package web import ( "context" "database/sql" "errors" "fmt" "io" "log" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "rond-with-you/internal/ingest" "rond-with-you/internal/rond" ) // 「换基线」:把当前数据集的底座库(导出时铺数据的底稿)换成另一份手机导出的包。 // // 为什么必须能换:导出只是以底座为底、覆盖我们托管的那七张实体表,其余内容 // (Core Data 元数据、索引、持久化历史、ZRAWVISIT 采样点、ZTRIP* 等)全部从底座原样带出来。 // 所以底座**必须是手机导出的包**——拿本站导出的包当底座,编号空间与真机对不上, // 导出会越导越失真。而底座一旦定下就跟着数据集走;手机那边再导出新包时, // 以前唯一的办法是整包重新导入成新数据集,站点侧补录的行、主题、备注都得重新收拾。 // 这个动作就是给「同一份数据的底座换代」用的:站点数据一行不动,只换底稿。 // // 与「上传备份」的区别:上传是重建数据(新数据集),换基线不动数据、只换底稿。 // baselineBackupKeep 是旧底座留档的保留份数(最近 N 份)。替换前一律留档, // 换错了能一键回滚——底座是导出的命根子,写坏了整个数据集就导不出去了。 const baselineBackupKeep = 3 // minOverlapVisits 是「重合率」判据生效所需的最小样本量。站点侧带 UUID 的到访太少时 // (比如数据集本来就是站点手工建的),重合率没有统计意义,不拦。 const minOverlapVisits = 20 // baselineStatus 是底座的现状画像。 type baselineStatus struct { Path string Exists bool Size int64 ModTime *time.Time // Name / SHA / At 是「换基线」记档:这份底座是从哪份包换过来的。 // SHA 为空表示从未换过,来源就是数据集最初上传的那份原始包。 Name string SHA string At *time.Time // Backups 是留档的旧底座(新的在前)。 Backups []baselineBackup } type baselineBackup struct { Name string Size int64 At time.Time } // baselineCount 是底座里某张表的行数(页面上的「底稿画像」)。 type baselineCount struct { Table string Label string N int } // baselineTableLabels 是画像要列的表,顺序按对导出的重要程度。 var baselineTableLabels = []struct{ Table, Label string }{ {"ZVISIT", "到访"}, {"ZLOCATION", "地点"}, {"ZMOVEMENT", "位移"}, {"ZHOURLYWEATHER", "天气"}, {"ZRAWVISIT", "原始定位采样"}, {"ZACTIVITY", "活动类型"}, {"ZTAG", "标签"}, {"ZTRANSPORT", "交通方式"}, } // openBaselineRO 只读打开底座库(Core Data 单文件)。 // // 驱动只认 _pragma / _time_format 这几个 DSN 参数,没有 mode=ro;底座库是 rollback journal // 模式(VACUUM INTO 的产物),只做 SELECT 不会落下任何旁写文件,再加 query_only 兜住 // 「误写底座库」这条路——底座是导出的底稿,写坏了整个数据集就导不出去了。 func openBaselineRO(path string) (*sql.DB, error) { // 先确认文件在:sql.Open 默认是 READWRITE|CREATE,路径不存在时会**建一个空库**, // 于是查表报「no such table」而不是「没有底座」——那种错误很难查。 if _, err := os.Stat(path); err != nil { return nil, err } sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?_pragma=query_only(1)&_pragma=busy_timeout(5000)") if err != nil { return nil, err } sdb.SetMaxOpenConns(1) return sdb, nil } // baselineStatusOf 读出底座的现状:文件、记档、留档列表。 func (s *Server) baselineStatusOf(ctx context.Context, datasetID int64) (*baselineStatus, error) { st := &baselineStatus{Path: s.baselinePath(datasetID)} if fi, err := os.Stat(st.Path); err == nil { st.Exists = true st.Size = fi.Size() t := fi.ModTime() st.ModTime = &t } if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,''), baseline_at FROM datasets WHERE id=$1`, datasetID).Scan(&st.Name, &st.SHA, &st.At); err != nil { return nil, err } baks, _ := filepath.Glob(st.Path + ".bak-*") for _, b := range baks { fi, err := os.Stat(b) if err != nil { continue } st.Backups = append(st.Backups, baselineBackup{Name: filepath.Base(b), Size: fi.Size(), At: fi.ModTime()}) } // 新的在前:回滚要的是最近一份,页面也要按时间倒序列 sort.Slice(st.Backups, func(i, j int) bool { return st.Backups[i].At.After(st.Backups[j].At) }) return st, nil } // baselineCounts 数底座里各表的行数。表不存在(老包缺某张表)就跳过,不报错—— // 画像只是给人看的,缺一行不该拦住换底座。 func baselineCounts(ctx context.Context, path string) ([]baselineCount, error) { sdb, err := openBaselineRO(path) if err != nil { return nil, err } defer sdb.Close() rows, err := sdb.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table'`) if err != nil { return nil, err } have := map[string]bool{} for rows.Next() { var n string if err := rows.Scan(&n); err != nil { rows.Close() return nil, err } have[strings.ToUpper(n)] = true } rows.Close() if err := rows.Err(); err != nil { return nil, err } var out []baselineCount for _, t := range baselineTableLabels { if !have[t.Table] { continue } var n int if err := sdb.QueryRowContext(ctx, `SELECT count(*) FROM `+t.Table).Scan(&n); err != nil { continue } out = append(out, baselineCount{Table: t.Table, Label: t.Label, N: n}) } return out, nil } // visitUUIDOverlap 数「站点里带 UUID 的到访」有多少条能在这份底座里找到。 // // 这是唯一能挡住「换错包」的闸。干跑导出验证的是**结构**(表齐、实体编号齐), // 一份完全不相干的包照样能导出成功——但换下去以后,导出的包就不是这份数据了: // 站点每一行都会在底座里找不到身份,于是全都按「新行」插到最大编号之上, // 真机上表现为「这些到访全变成新的、老记录还在」。 // // 站点补录/新建的行(没有 UUID)不计入分母:真机包里本来就没有它们。 func (s *Server) visitUUIDOverlap(ctx context.Context, datasetID int64, baselinePath string) (matched, withUUID int, err error) { base, err := readDupBaseline(ctx, baselinePath) if err != nil { return 0, 0, err } site, err := s.loadStoredVisits(ctx, datasetID) if err != nil { return 0, 0, err } for _, sv := range site { if sv.UUID == "" { continue } withUUID++ if base.uuids[sv.UUID] { matched++ } } return matched, withUUID, nil } // baselineBackupName 生成一个不与已有文件冲突的留档名。 // // **必须防重名**:替换与回滚可能落在同一秒里,秒级时间戳直接当文件名的话, // 后一次 os.Rename 会把前一次的留档**覆盖掉**——而被覆盖的那一份正是要恢复的旧底座。 // 实测「换底座紧接着回滚」就踩过:回滚把新底座改名成 dst.bak-<同一秒>, // 恰好压掉刚生成的旧底座留档,于是「回滚」回了个寂寞。 func baselineBackupName(dst string, now time.Time) string { base := fmt.Sprintf("%s.bak-%s", dst, now.Format("20060102-150405")) p := base for i := 1; ; i++ { if _, err := os.Stat(p); err != nil { return p } p = fmt.Sprintf("%s-%d", base, i) } } // errBaselineMismatch 表示这份包与当前数据集对不上,需要站长显式确认才换。 type errBaselineMismatch struct { matched, withUUID int zero bool // 一条都对不上:连「强制」都不给过 } func (e *errBaselineMismatch) Error() string { if e.zero { return fmt.Sprintf("这份备份跟当前数据集的 %d 条到访一条都对不上,几乎可以肯定不是同一份数据的手机包,"+ "换下去会让导出包整个错位,已放弃", e.withUUID) } return fmt.Sprintf("这份备份与当前数据集只对得上 %d/%d 条到访,可能不是同一批数据。确认无误请勾选「强制替换」再试", e.matched, e.withUUID) } // baselineReport 是一次换底座的结果,用来给页面/提示提供具体数字。 type baselineReport struct { Name string SHA string Size int64 Matched int WithUUID int Counts []baselineCount Replaced bool // 是否真的换掉了(false = 原本没有底座,是首次建立) Backup string SiteVisits int // 站内到访条数,用来提示「新底座里多出来的那些不会进包」 ElapsedMs int64 } // swapBaseline 把当前数据集的底座换成 archive 那份包。 // // 顺序是有讲究的,三步都不能挪: // 1. 先在新底座上**干跑一次导出**。导出失败(缺表、缺实体编号、写入报错)在这里就挡住, // 绝不会出现「底座已经换了、才发现导不出、而原来那份已经不知道去哪了」。 // 2. 旧底座改名留档,新的原子改名就位。中途失败要把旧的换回来,至少不能让数据集没底座。 // 3. 记档(来源文件名 + sha)。底座文件以后丢了,ensureBaseline 要按它找同一份包重建; // 只认 datasets.sha256 的话会把底座悄悄退回最初那份旧包。 // // 站点数据(places/visits/…)一行都不动:换的只是导出的底稿。 func (s *Server) swapBaseline(ctx context.Context, datasetID int64, archive, name, sha string, force bool) (baselineReport, error) { start := time.Now() rep := baselineReport{Name: name, SHA: sha} dst := s.baselinePath(datasetID) tmp := dst + ".new" _ = os.Remove(tmp) if err := rond.WriteBaseline(archive, tmp); err != nil { return rep, fmt.Errorf("读取这份备份生成底座失败:%w", err) } // 成功路径会把 tmp 改名走,这里只兜住失败路径(改名后 Remove 失败无害) defer func() { _ = os.Remove(tmp) }() if fi, err := os.Stat(tmp); err == nil { rep.Size = fi.Size() } // 判据一:这份包得跟当前数据集是同一批数据 matched, withUUID, err := s.visitUUIDOverlap(ctx, datasetID, tmp) if err != nil { return rep, fmt.Errorf("比对这份备份与当前数据集失败:%w", err) } rep.Matched, rep.WithUUID = matched, withUUID if withUUID >= minOverlapVisits && matched == 0 { return rep, &errBaselineMismatch{matched: matched, withUUID: withUUID, zero: true} } if !force && withUUID >= minOverlapVisits && matched*2 < withUUID { return rep, &errBaselineMismatch{matched: matched, withUUID: withUUID} } // 判据二:这份底座得真的能导出(结构完整) if err := rond.ExportRondbackup(ctx, s.db, datasetID, tmp, io.Discard); err != nil { return rep, fmt.Errorf("用这份备份当底座导不出包,已放弃替换:%w", err) } if counts, err := baselineCounts(ctx, tmp); err == nil { rep.Counts = counts } // 站内到访条数:新底座里多出来的那些不会进导出包(导出的行集合始终等于站点数据), // 这点反直觉,要在提示里说出来。 _ = s.db.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, datasetID). Scan(&rep.SiteVisits) if _, err := os.Stat(dst); err == nil { rep.Replaced = true rep.Backup = baselineBackupName(dst, time.Now()) if err := os.Rename(dst, rep.Backup); err != nil { return rep, fmt.Errorf("留档旧底座失败:%w", err) } } if err := os.Rename(tmp, dst); err != nil { // 尽力把旧的换回来:宁可换基线失败,也不能让数据集没有底座 if rep.Backup != "" { if rerr := os.Rename(rep.Backup, dst); rerr != nil { log.Printf("换基线失败且旧底座恢复不回去(%s): %v", rep.Backup, rerr) } } return rep, fmt.Errorf("替换底座失败:%w", err) } rep.ElapsedMs = time.Since(start).Milliseconds() if _, err := s.db.ExecContext(ctx, `UPDATE datasets SET baseline_name=$2, baseline_sha256=$3, baseline_at=now() WHERE id=$1`, datasetID, name, sha); err != nil { // 底座已经换好了,导出照常;只是丢了「来源是谁」的记档,不值得回滚 log.Printf("换基线记档失败(数据集 %d): %v", datasetID, err) } s.pruneBaselineBackups(dst) return rep, nil } // pruneBaselineBackups 只留最近 N 份旧底座。底座库几十 MB,不留上限会慢慢堆满磁盘。 func (s *Server) pruneBaselineBackups(dst string) { baks, _ := filepath.Glob(dst + ".bak-*") if len(baks) <= baselineBackupKeep { return } sort.Slice(baks, func(i, j int) bool { fi, ei := os.Stat(baks[i]) fj, ej := os.Stat(baks[j]) if ei != nil || ej != nil { return false } return fi.ModTime().After(fj.ModTime()) }) for _, b := range baks[baselineBackupKeep:] { if err := os.Remove(b); err != nil { log.Printf("清理旧底座留档失败(%s): %v", b, err) } } } // rollbackBaseline 把最近一份留档的旧底座换回来。 // // 记档要一并清空:留档恰好就是「换之前的那份底座」,而换之前的那份多半是从最初上传的 // 原始包重建的。留着新那份的 sha 会让 ensureBaseline 在文件丢失时重建出错的那一份。 // 清空后退回「按 datasets.sha256 找原始包」的旧行为,与留档的实际来源一致。 func (s *Server) rollbackBaseline(ctx context.Context, datasetID int64) (string, error) { dst := s.baselinePath(datasetID) st, err := s.baselineStatusOf(ctx, datasetID) if err != nil { return "", err } if len(st.Backups) == 0 { return "", errors.New("还没有可回滚的旧底座") } newest := st.Backups[0] consumed := filepath.Join(filepath.Dir(dst), newest.Name) // 先把当前这份也留档,回滚本身也要可逆(万一回滚错了还能再滚回来) if st.Exists { if err := os.Rename(dst, baselineBackupName(dst, time.Now())); err != nil { return "", fmt.Errorf("留档当前底座失败:%w", err) } } if err := os.Rename(consumed, dst); err != nil { return "", fmt.Errorf("恢复旧底座失败:%w", err) } if _, err := s.db.ExecContext(ctx, `UPDATE datasets SET baseline_name=NULL, baseline_sha256=NULL, baseline_at=NULL WHERE id=$1`, datasetID); err != nil { return "", fmt.Errorf("记档清理失败:%w", err) } s.pruneBaselineBackups(dst) return newest.Name, nil } // ---------- 页面与动作 ---------- // baselineFlash 回「换基线」页并带上提示(extra 是额外要带的查询参数,如待确认的 sha)。 func (s *Server) baselineFlash(w http.ResponseWriter, r *http.Request, kind, msg string, extra ...string) { sep := "?" u := "/admin/baseline" for _, e := range append([]string{kind + "=" + urlQueryEscape(msg)}, extra...) { if e == "" { continue } u += sep + e sep = "&" } http.Redirect(w, r, u, http.StatusSeeOther) } // baselinePendingPrefix 标记「这份包要强制替换」的查询参数。带上它时页面多渲染一个确认按钮, // 不必让人把几十 MB 的包再传一遍。 const baselinePendingPrefix = "pending=" // adminBaselinePage 渲染「换基线」页。 func (s *Server) adminBaselinePage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() p, err := s.pageBase(r, "admin", "换基线") if err != nil { s.serverError(w, r, err) return } p.SetFlashFromQuery(r) d := BaselineData{Page: p, MaxSize: maxUploadBytes} ds, err := s.datasetFor(ctx) if err != nil { s.serverError(w, r, err) return } if ds == nil { s.render(w, "admin/baseline", d) return } d.Dataset = ds st, err := s.baselineStatusOf(ctx, ds.ID) if err != nil { s.serverError(w, r, err) return } d.Status = st if st.Exists { counts, err := baselineCounts(ctx, st.Path) if err != nil { d.CountsErr = "读不出底座里的行数:" + err.Error() } else { d.Counts = counts } } // 数据集最初上传的那份包,用来判断「当前底座是否就是它」 if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(original_name,''), COALESCE(sha256,'') FROM datasets WHERE id=$1`, ds.ID).Scan(&d.OrigName, &d.OrigSHA); err != nil { s.serverError(w, r, err) return } d.FromOriginal = st.SHA == "" || st.SHA == d.OrigSHA // 待强制确认的那份包(刚上传、判据没过,但内容已经收进 uploads 了) if sha := r.URL.Query().Get("pending"); sha != "" { if _, err := s.findUploadBySha(sha, 0); err == nil { q := r.URL.Query() d.PendingSHA = sha d.PendingName = q.Get("pname") if d.PendingName == "" { d.PendingName = "刚上传的那份备份" } d.PendingMatched, _ = strconv.Atoi(q.Get("pm")) d.PendingWith, _ = strconv.Atoi(q.Get("pw")) } } s.render(w, "admin/baseline", d) } // adminBaselineUpload 收下作为新底座的备份并立即尝试替换。 // // 判据没过时**不报错就算了**:包已经收进 uploads,页面给一个「强制替换」按钮, // 让站长确认后直接用它,不必为了勾一下复选框把几十 MB 再传一遍。 func (s *Server) adminBaselineUpload(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ds, err := s.datasetFor(ctx) if err != nil { s.serverError(w, r, err) return } if ds == nil { s.baselineFlash(w, r, "err", "还没有可换底座的数据集,请先导入一份备份") return } r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20) if err := r.ParseMultipartForm(32 << 20); err != nil { s.baselineFlash(w, r, "err", "读取上传内容失败:"+err.Error()) return } file, hdr, err := r.FormFile("backup") if err != nil { s.baselineFlash(w, r, "err", "请选择要作为底座的 .rondbackup 文件") return } defer file.Close() name := filepath.Base(hdr.Filename) lower := strings.ToLower(name) if !strings.HasSuffix(lower, ".rondbackup") && !strings.HasSuffix(lower, ".zip") { s.baselineFlash(w, r, "err", "文件类型不正确,请上传 rond 导出的 .rondbackup 备份") return } // 先落到临时目录算摘要(不能在 uploads 里算:那一份会把自己认成「已存在」) tmpDir, err := ingest.TempDir(s.cfg.DataDir, "baseline") if err != nil { s.serverError(w, r, err) return } defer os.RemoveAll(tmpDir) incoming := filepath.Join(tmpDir, sanitize(name)) out, err := os.Create(incoming) if err != nil { s.serverError(w, r, err) return } n, cerr := io.Copy(out, io.LimitReader(file, maxUploadBytes+1)) if err := out.Close(); err != nil && cerr == nil { cerr = err } if cerr != nil { s.baselineFlash(w, r, "err", "读取文件失败:"+cerr.Error()) return } if n > maxUploadBytes { s.baselineFlash(w, r, "err", "备份文件过大,疑似不是 rond 备份") return } sha, size, err := ingest.File(incoming) if err != nil { s.baselineFlash(w, r, "err", "读取备份失败:"+err.Error()) return } stored, err := s.stashUpload(incoming, name, sha, size) if err != nil { s.serverError(w, r, err) return } rep, err := s.swapBaseline(ctx, ds.ID, stored, name, sha, r.FormValue("force") != "") if err != nil { var mism *errBaselineMismatch if errors.As(err, &mism) && !mism.zero { // 包已经收好了,让站长在页面上点一下确认即可,不必重传 s.baselineFlash(w, r, "err", err.Error(), baselinePendingPrefix+sha, "pname="+urlQueryEscape(name), fmt.Sprintf("pm=%d", mism.matched), fmt.Sprintf("pw=%d", mism.withUUID)) return } s.baselineFlash(w, r, "err", err.Error()) return } s.baselineFlash(w, r, "ok", baselineSwapMessage(rep, ds.Name)) } // adminBaselineConfirm 用页面上的「强制替换」按钮落地一份已经收好的包(不重传)。 func (s *Server) adminBaselineConfirm(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ds, err := s.datasetFor(ctx) if err != nil { s.serverError(w, r, err) return } if ds == nil { s.baselineFlash(w, r, "err", "还没有可换底座的数据集") return } if err := r.ParseForm(); err != nil { s.baselineFlash(w, r, "err", "读取表单失败:"+err.Error()) return } sha := strings.TrimSpace(r.FormValue("sha")) if len(sha) != 64 { s.baselineFlash(w, r, "err", "要替换的备份已失效,请重新上传") return } stored, err := s.findUploadBySha(sha, 0) if err != nil { s.baselineFlash(w, r, "err", "找不到那份备份了,请重新上传") return } name := filepath.Base(stored) if n := strings.TrimSpace(r.FormValue("name")); n != "" { name = filepath.Base(n) } rep, err := s.swapBaseline(ctx, ds.ID, stored, name, sha, true) if err != nil { s.baselineFlash(w, r, "err", err.Error()) return } s.baselineFlash(w, r, "ok", "已按确认强制替换。"+baselineSwapMessage(rep, ds.Name)) } // adminBaselineRollback 回滚到上一份底座。 func (s *Server) adminBaselineRollback(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ds, err := s.datasetFor(ctx) if err != nil { s.serverError(w, r, err) return } if ds == nil { s.baselineFlash(w, r, "err", "还没有可换底座的数据集") return } name, err := s.rollbackBaseline(ctx, ds.ID) if err != nil { s.baselineFlash(w, r, "err", "回滚失败:"+err.Error()) return } s.baselineFlash(w, r, "ok", "已回滚到旧底座("+name+")。当前底座记录的来源已清空,导出照常可用。") } // adminBaselineRebuild 按记档(或最初上传的包)重建底座文件。 // 底座库被误删、或换了机器没带 baselines 目录时用得上——导出时其实也会自动重建, // 这里给一个能立刻看到结果、并把「重建不出来」说清楚的入口。 func (s *Server) adminBaselineRebuild(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ds, err := s.datasetFor(ctx) if err != nil { s.serverError(w, r, err) return } if ds == nil { s.baselineFlash(w, r, "err", "还没有可换底座的数据集") return } path := s.baselinePath(ds.ID) tmp := path + ".new" _ = os.Remove(tmp) // 先写临时文件、成功了再改名就位:重建失败时不能让数据集连底座都没有 if err := s.ensureBaselineTo(ctx, ds.ID, tmp); err != nil { _ = os.Remove(tmp) s.baselineFlash(w, r, "err", "重建底座失败:"+err.Error()) return } if err := os.Rename(tmp, path); err != nil { _ = os.Remove(tmp) s.baselineFlash(w, r, "err", "就位底座失败:"+err.Error()) return } fi, _ := os.Stat(path) size := int64(0) if fi != nil { size = fi.Size() } s.baselineFlash(w, r, "ok", fmt.Sprintf("已重建底座(%s)。", fmtSize(size))) } // stashUpload 把一份上传件收进 uploads,供以后按摘要找回(换基线记档要用)。 // 内容已经在 uploads 里就不再存第二份:底座库几十 MB,重复堆几份纯属浪费。 func (s *Server) stashUpload(incoming, name, sha string, size int64) (string, error) { if exist, err := s.findUploadBySha(sha, size); err == nil && exist != "" { return exist, nil } if err := os.MkdirAll(s.cfg.UploadDir, 0o755); err != nil { return "", err } dst := filepath.Join(s.cfg.UploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), sanitize(name))) if err := os.Rename(incoming, dst); err != nil { // 跨卷(临时目录与 uploads 不在同一个卷)时退回复制 if cerr := copyFileShallow(incoming, dst); cerr != nil { return "", fmt.Errorf("保存备份失败:%w", cerr) } } return dst, nil } // copyFileShallow 是给 stashUpload 用的兜底拷贝(rename 跨卷失败时)。 func copyFileShallow(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } return out.Close() } // baselineSwapMessage 拼一条说清楚「换了什么」的提示:替换前后底座的行数对比。 func baselineSwapMessage(rep baselineReport, datasetName string) string { var b strings.Builder if rep.Replaced { fmt.Fprintf(&b, "已换底座:%s(%s)。旧底座已留档,可回滚。", rep.Name, fmtSize(rep.Size)) } else { fmt.Fprintf(&b, "已为数据集 #%s 建立底座:%s(%s)。", datasetName, rep.Name, fmtSize(rep.Size)) } if rep.WithUUID > 0 { fmt.Fprintf(&b, " 这份包与站内 %d/%d 条带 UUID 的到访对得上。", rep.Matched, rep.WithUUID) } for _, c := range rep.Counts { if c.Table == "ZVISIT" || c.Table == "ZLOCATION" { fmt.Fprintf(&b, " %s %d;", c.Label, c.N) } } b.WriteString(" 站点数据一行没动,导出即刻以新底座为准。") // 反直觉的一条:底座里比站内多出来的到访会被导出清掉(导出的行集合以站点数据为准)。 // 不说清楚的话,站长会以为「换了更新的手机包 ⇒ 导出的包里就有手机上的新记录」。 if extra := baselineCountValue(rep.Counts, "ZVISIT") - rep.SiteVisits; extra > 0 { fmt.Fprintf(&b, " ⚠️ 新底座里有 %d 条到访是站内还没有的,它们不会进导出包"+ "(导出的行集合以站点数据为准):想让它们进包,先用「分析差异」把这份备份合并进来,再来换底座。", extra) } return b.String() } // baselineCountValue 取画像里某张表的行数(没有这张表就是 0)。 func baselineCountValue(counts []baselineCount, table string) int { for _, c := range counts { if c.Table == table { return c.N } } return 0 }