package store import ( "fmt" "os" "syscall" "time" ) // GCReport gc 结果报告(dry-run 与 commit 共用)。 type GCReport struct { ExpiredNotes []ExpiredNote `json:"expired_notes"` OrphanImages []Image `json:"orphan_images"` ExpiredSessons int64 `json:"expired_sessions"` Committed bool `json:"committed"` } // ExpiredNote 待物理删除的回收站笔记。 type ExpiredNote struct { ID int64 `json:"id"` Slug string `json:"slug"` DeletedAt int64 `json:"deleted_at"` } // TrashTTL 回收站保留期(§6.3:30 天)。 const TrashTTL = 30 * 24 * time.Hour // OrphanGrace 孤儿图片宽限期(§6.3:7 天,防误删编辑中刚上传的图)。 const OrphanGrace = 7 * 24 * time.Hour // GC 清理:回收站过期笔记物理删除、孤儿图片清除、过期会话清理。 // dryRun=true 时只输出计划不改数据。 func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) { rep := &GCReport{Committed: !dryRun} noteCutoff := now.Add(-TrashTTL).Unix() rows, err := s.db.Query( `SELECT id, slug, deleted_at FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ? ORDER BY deleted_at`, noteCutoff) if err != nil { return nil, err } for rows.Next() { var en ExpiredNote if err := rows.Scan(&en.ID, &en.Slug, &en.DeletedAt); err != nil { rows.Close() return nil, err } rep.ExpiredNotes = append(rep.ExpiredNotes, en) } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() // 孤儿图片:0 引用且超过宽限期 imageCutoff := now.Add(-OrphanGrace).Unix() orphanRows, err := s.db.Query(` SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id) ORDER BY i.created_at`, imageCutoff) if err != nil { return nil, err } for orphanRows.Next() { var img Image if err := orphanRows.Scan(&img.ID, &img.SHA256, &img.MIME, &img.Size, &img.CreatedAt); err != nil { orphanRows.Close() return nil, err } rep.OrphanImages = append(rep.OrphanImages, img) } if err := orphanRows.Err(); err != nil { orphanRows.Close() return nil, err } orphanRows.Close() if dryRun { // 会话计数(不改数据) var cnt int64 if err := s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE expires_at < ?`, now.Unix()).Scan(&cnt); err != nil { return nil, err } rep.ExpiredSessons = cnt return rep, nil } // 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被 // 重新引用时,不删活数据;评审 round2 P2-6) for _, en := range rep.ExpiredNotes { res, err := s.db.Exec( `DELETE FROM notes WHERE id=? AND deleted_at IS NOT NULL AND deleted_at < ?`, en.ID, noteCutoff) if err != nil { return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err) } if rows, _ := res.RowsAffected(); rows == 0 { continue // 期间已被恢复,跳过 } } for _, img := range rep.OrphanImages { res, err := s.db.Exec( `DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`, img.ID, img.ID) if err != nil { return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err) } if rows, _ := res.RowsAffected(); rows == 0 { continue // 期间已被引用,跳过 } } n, err := s.DeleteExpiredSessions(now.Unix()) if err != nil { return nil, err } rep.ExpiredSessons = n return rep, nil } // Backup 在线备份:VACUUM INTO 一致快照(WAL 下与 serve 并发安全,§10.3)。 func (s *Store) Backup(destPath string) error { if _, err := os.Stat(destPath); err == nil { return fmt.Errorf("目标文件已存在: %s", destPath) } // 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600 // (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。 // backup 为 CLI 单线程进程,进程级 umask 无并发副作用。 old := syscall.Umask(0o077) // VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。 _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)) syscall.Umask(old) if err != nil { return err } // 双保险:显式收紧(幂等) if err := os.Chmod(destPath, 0o600); err != nil { return err } return nil } func escapeSQLString(s string) string { out := make([]byte, 0, len(s)+2) out = append(out, '\'') for i := 0; i < len(s); i++ { if s[i] == '\'' { out = append(out, '\'', '\'') } else { out = append(out, s[i]) } } return string(append(out, '\'')) }