Files
wpywmail/server-native-v2/StorageMigration.cs
T

322 lines
15 KiB
C#
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.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace WpywMail.Native;
/// <summary>
/// 存储后端之间的数据搬迁。
///
/// 正向(JSON → SQLite):把 users/messages/queue/sessions 全部导入数据库,
/// 报文原文与附件读文件后进 blobs 表(gzip + 内容去重),并**逐封校验**
/// 「从数据库读回来的字节」与「原文件字节」SHA256 完全一致,之后才允许删除源文件。
///
/// 反向(SQLite → JSON):把 blobs 还原成 raw/ 与 attachments/ 文件并重写 JSON,
/// 用于一键回滚。
/// </summary>
public static class StorageMigration
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private static T? ReadJson<T>(string path)
{
if (!File.Exists(path)) return default;
try { return JsonSerializer.Deserialize<T>(File.ReadAllText(path), Json); }
catch (Exception ex) { AppLog.Error($"[迁移] 读取 {Path.GetFileName(path)} 失败:{ex.Message}"); return default; }
}
private static long DirectoryBytes(string path) =>
Directory.Exists(path) ? Directory.GetFiles(path, "*", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length) : 0;
private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
// ------------------------------------------------------------------ 正向
public static int ToSqlite(AppConfig config, bool deleteSourceFiles)
{
var dataDir = config.DataDirectory;
var usersPath = Path.Combine(dataDir, "users.json");
var messagesPath = Path.Combine(dataDir, "messages.json");
var queuePath = Path.Combine(dataDir, "queue.json");
var sessionsPath = Path.Combine(dataDir, "sessions.json");
var users = ReadJson<List<MailUser>>(usersPath) ?? [];
var messages = ReadJson<List<MailMessage>>(messagesPath) ?? [];
var queue = ReadJson<List<QueueItem>>(queuePath) ?? [];
var sessions = ReadJson<List<SessionRecord>>(sessionsPath) ?? [];
if (messages.Count == 0 && users.Count == 0)
{
Console.WriteLine("没有可迁移的数据(users.json / messages.json 为空或不存在)。");
return 1;
}
var beforeRaw = DirectoryBytes(Path.Combine(dataDir, "raw"));
var beforeAttachments = DirectoryBytes(Path.Combine(dataDir, "attachments"));
var beforeJson = new[] { usersPath, messagesPath, queuePath, sessionsPath }
.Where(File.Exists).Sum(p => new FileInfo(p).Length);
Console.WriteLine($"源数据:用户 {users.Count},邮件 {messages.Count},队列 {queue.Count},会话 {sessions.Count}");
Console.WriteLine($"源占用:raw {beforeRaw:N0} B + attachments {beforeAttachments:N0} B + JSON {beforeJson:N0} B");
using var store = new SqliteStore(config);
var existing = (int)store.StorageReport().Messages;
if (existing > 0)
{
Console.WriteLine($"数据库里已有 {existing} 封邮件,将按 id 覆盖导入(幂等)。");
}
foreach (var user in users) store.ImportUser(user);
// UID 唯一性修复:数据库上有 (owner, folder, uid) 唯一索引
var used = new HashSet<(string, string, int)>();
var nextUid = new Dictionary<(string, string), int>();
var verified = new List<(MailMessage Message, string OriginalSha, bool HadFile)>();
var imported = 0;
var missingRaw = 0;
foreach (var message in messages.OrderBy(m => m.Date))
{
message.OwnerEmail = (message.OwnerEmail ?? "").ToLowerInvariant();
message.Folder = (message.Folder ?? "inbox").ToLowerInvariant();
var key = (message.OwnerEmail, message.Folder);
if (!nextUid.TryGetValue(key, out var next)) next = 1;
var uid = message.Uid;
if (uid <= 0 || !used.Add((key.Item1, key.Item2, uid)))
{
while (used.Contains((key.Item1, key.Item2, next))) next++;
uid = next;
used.Add((key.Item1, key.Item2, uid));
}
message.Uid = uid;
next = Math.Max(next, uid + 1);
nextUid[key] = next;
// 报文原文:文件 → blobs
var originalSha = "";
var hadFile = false;
if (!string.IsNullOrWhiteSpace(message.RawPath) && !message.RawPath.StartsWith("db:", StringComparison.OrdinalIgnoreCase))
{
var full = Path.Combine(dataDir, message.RawPath);
if (File.Exists(full))
{
var bytes = File.ReadAllBytes(full);
originalSha = Sha(bytes);
hadFile = true;
message.RawPath = store.SaveRaw(bytes);
message.Size = bytes.Length;
}
else
{
missingRaw++;
message.RawPath = "";
}
}
// 附件:文件 → blobs
foreach (var attachment in message.Attachments)
{
if (string.IsNullOrWhiteSpace(attachment.StoredAs) ||
attachment.StoredAs.StartsWith("db:", StringComparison.OrdinalIgnoreCase)) continue;
var full = Path.Combine(dataDir, attachment.StoredAs);
if (File.Exists(full)) attachment.StoredAs = store.SaveAttachment(File.ReadAllBytes(full), attachment.FileName);
}
store.SaveMessage(message);
verified.Add((message, originalSha, hadFile));
imported++;
}
foreach (var item in queue) store.ImportQueueItem(item);
var now = DateTimeOffset.UtcNow;
foreach (var session in sessions.Where(s => s.Expires > now)) store.ImportSession(session);
// ---- 校验:从数据库读回来的字节必须与原文件逐字节一致 ----
Console.WriteLine("校验中(逐封比对 SHA256)……");
var bad = 0;
foreach (var (message, originalSha, hadFile) in verified)
{
if (!hadFile || message.RawPath.Length == 0) continue;
try
{
var back = store.ReadRaw(message.RawPath);
if (Sha(back) != originalSha)
{
bad++;
Console.Error.WriteLine($" [不一致] {message.Id} {message.Subject}");
}
}
catch (Exception ex)
{
bad++;
Console.Error.WriteLine($" [读取失败] {message.Id}:{ex.Message}");
}
}
var report = store.StorageReport();
Console.WriteLine();
Console.WriteLine($"导入完成:邮件 {imported} 封,队列 {queue.Count} 条,会话 {sessions.Count} 个" +
(missingRaw > 0 ? $",{missingRaw} 封缺少原始报文文件" : ""));
Console.WriteLine($"入库大对象:原始 {report.BlobRawBytes:N0} B → 存储 {report.BlobStoredBytes:N0} B" +
(report.BlobRawBytes > 0 ? $"({(100.0 * report.BlobStoredBytes / report.BlobRawBytes):F0}%,{report.BlobGzipped} 个压缩)" : ""));
Console.WriteLine($"数据库文件:{report.DbBytes:N0} B + WAL {report.WalBytes:N0} B");
Console.WriteLine($"校验结果:SHA256 不一致 {bad} 封");
if (bad > 0)
{
Console.Error.WriteLine("存在校验失败,已保留源文件,请勿删除。");
return 2;
}
if (!deleteSourceFiles)
{
Console.WriteLine("源文件已保留(想释放空间请加 --delete-source 重跑)。");
return 0;
}
// ---- 删源文件(校验已全部通过才走到这里)----
var deleted = 0;
long freed = 0;
foreach (var file in Directory.GetFiles(Path.Combine(dataDir, "raw"), "*.eml", SearchOption.TopDirectoryOnly))
{
freed += new FileInfo(file).Length;
File.Delete(file);
deleted++;
}
var attachmentDir = Path.Combine(dataDir, "attachments");
foreach (var file in Directory.GetFiles(attachmentDir, "*", SearchOption.TopDirectoryOnly))
{
freed += new FileInfo(file).Length;
File.Delete(file);
deleted++;
}
Console.WriteLine($"已删除源文件 {deleted} 个,释放 {freed:N0} B");
Console.WriteLine($"提示:JSON 索引文件(users/messages/queue/sessions.json)保留未删,便于随时回滚。");
return 0;
}
// ------------------------------------------------------------------ 反向
public static int ToJson(AppConfig config)
{
var dataDir = config.DataDirectory;
using var store = new SqliteStore(config);
var messages = store.AllMessages().ToList();
var users = store.AllUsers().ToList();
var queue = store.AllQueueItems().ToList();
var sessions = store.AllSessions().ToList();
var rawDir = Path.Combine(dataDir, "raw");
var attachmentDir = Path.Combine(dataDir, "attachments");
Directory.CreateDirectory(rawDir);
Directory.CreateDirectory(attachmentDir);
var exported = new Dictionary<long, string>();
foreach (var (id, data) in store.ExportBlobs())
{
// 同一个 blob 可能被多封邮件/附件引用,这里各导出一份文件(回滚场景不追求去重)
var name = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{id}{GuessExtension(data)}";
var relative = Path.Combine("raw", name);
File.WriteAllBytes(Path.Combine(dataDir, relative), data);
exported[id] = relative;
}
static string GuessExtension(byte[] data) =>
data.Length >= 4 && data[0] == 0x50 && data[1] == 0x4B ? ".zip" : ".eml";
foreach (var message in messages)
{
if (message.RawPath.StartsWith("db:", StringComparison.OrdinalIgnoreCase) &&
long.TryParse(message.RawPath.AsSpan(3), out var blobId) && exported.TryGetValue(blobId, out var path))
message.RawPath = path;
foreach (var attachment in message.Attachments)
{
if (attachment.StoredAs.StartsWith("db:", StringComparison.OrdinalIgnoreCase) &&
long.TryParse(attachment.StoredAs.AsSpan(3), out var attId) && exported.TryGetValue(attId, out var attPath))
{
var target = Path.Combine("attachments", Path.GetFileName(attPath));
File.Copy(Path.Combine(dataDir, attPath), Path.Combine(dataDir, target), true);
attachment.StoredAs = target;
}
}
}
File.WriteAllText(Path.Combine(dataDir, "messages.json"), JsonSerializer.Serialize(messages, Json), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(dataDir, "users.json"), JsonSerializer.Serialize(users, Json), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(dataDir, "queue.json"), JsonSerializer.Serialize(queue, Json), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(dataDir, "sessions.json"), JsonSerializer.Serialize(sessions, Json), new UTF8Encoding(false));
Console.WriteLine($"已导出:邮件 {messages.Count} 封,用户 {users.Count},队列 {queue.Count},会话 {sessions.Count}");
Console.WriteLine($"报文文件 {exported.Count} 个 → {rawDir}");
Console.WriteLine("把 appsettings.json 的 Storage.Provider 改回 json 即可用这套数据启动。");
return 0;
}
// ------------------------------------------------------------------ 状态
public static int Status(AppConfig config)
{
var dataDir = config.DataDirectory;
var rawBytes = DirectoryBytes(Path.Combine(dataDir, "raw"));
var attachmentBytes = DirectoryBytes(Path.Combine(dataDir, "attachments"));
var jsonBytes = new[] { "users.json", "messages.json", "queue.json", "sessions.json" }
.Select(f => Path.Combine(dataDir, f)).Where(File.Exists).Sum(p => new FileInfo(p).Length);
Console.WriteLine($"当前配置的后端:{config.Storage.Provider}");
Console.WriteLine($"raw/ {rawBytes,12:N0} B ({Directory.GetFiles(Path.Combine(dataDir, "raw"), "*").Length} 个文件)");
Console.WriteLine($"attachments/{attachmentBytes,12:N0} B ({Directory.GetFiles(Path.Combine(dataDir, "attachments"), "*").Length} 个文件)");
Console.WriteLine($"JSON 索引 {jsonBytes,12:N0} B");
var dbPath = string.IsNullOrWhiteSpace(config.Storage.DatabasePath)
? Path.Combine(dataDir, "wpywmail.db") : config.Storage.DatabasePath;
if (!File.Exists(dbPath))
{
Console.WriteLine("SQLite 数据库:尚未创建");
return 0;
}
using var store = new SqliteStore(config);
var report = store.StorageReport();
Console.WriteLine($"SQLite {report.DbBytes + report.WalBytes,12:N0} B ({dbPath})");
Console.WriteLine($" 邮件 {report.Messages} 封,大对象 {report.Blobs} 个" +
$"(原始 {report.BlobRawBytes:N0} B → 存储 {report.BlobStoredBytes:N0} B," +
$"其中 {report.BlobGzipped} 个启用了压缩)");
Console.WriteLine($" 正文列合计 {report.TextBytes:N0} B(最大一封 {report.LargestTextBytes:N0} B:{Trim(report.LargestTextSubject)})");
Console.WriteLine($" 页统计:{report.PageCount} 页 × {report.PageSize} B = {report.TotalBytes:N0} B," +
$"其中空闲 {report.FreeBytes:N0} B" +
(report.FreeBytes > 0 ? "(用 --vacuum 回收;注意 VACUUM 需要约 2 倍临时空间,且应在服务停止时做)" : ""));
Console.WriteLine($" 孤儿大对象 {report.Orphans} 个(用 --compact 清理)");
return 0;
}
private static string Trim(string text) =>
string.IsNullOrEmpty(text) ? "(无)" : text.Length <= 24 ? text : text[..24] + "…";
public static int Compact(AppConfig config)
{
using var store = new SqliteStore(config);
var removed = store.Compact();
Console.WriteLine($"已清理孤儿大对象 {removed} 个。");
return Status(config);
}
/// <summary>合并 WAL 并回收空闲页,把数据库文件压到实际大小。</summary>
public static int Vacuum(AppConfig config)
{
using var store = new SqliteStore(config);
var before = store.StorageReport();
Console.WriteLine($"整理前:主库 {before.DbBytes:N0} B + WAL {before.WalBytes:N0} B,空闲页 {before.FreeBytes:N0} B");
store.Vacuum(); // VACUUM:回收空闲页
store.Persist(); // 再把 VACUUM 期间产生的 WAL 归并回主库并截断
var after = store.StorageReport();
Console.WriteLine($"整理后:主库 {after.DbBytes:N0} B + WAL {after.WalBytes:N0} B,空闲页 {after.FreeBytes:N0} B");
Console.WriteLine($"实际占用:{(before.DbBytes + before.WalBytes):N0} B → {(after.DbBytes + after.WalBytes):N0} B" +
$"(释放 {(before.DbBytes + before.WalBytes) - (after.DbBytes + after.WalBytes):N0} B)");
return 0;
}
}