备份恢复与归档都改变数据生命周期,但目标不同:备份恢复保护整个数据库;活跃归档把租户热表中的历史行迁移到归档表。两者当前都绑定 SqlSugar + SQL Server 能力,不是多 ORM 等价功能。
1. 两条边界
备份文件不带租户边界;恢复覆盖当前数据库。归档必须有可信 TenantId,手写租户入口永远不会调用全租户执行。
2. 当前备份 Provider
唯一生产实现是 SqlSugarDatabaseBackupService。它直接使用 Microsoft.Data.SqlClient 执行 SQL Server T-SQL:
BACKUP DATABASEFull;BACKUP DATABASE ... WITH DIFFERENTIAL;BACKUP LOG;RESTORE VERIFYONLY;RESTORE DATABASE ... WITH REPLACE, RECOVERY。
EF Core 或其他数据库 Provider 没有等价实现。缺少服务时 API Host 注册 UnavailableBackgroundJobPorts 失败关闭。
3. 创建备份
CreateBackup.Command.Type 通过大小写不敏感 Enum 解析,只接受 Full、Differential、Log。无效值返回 Validation。
Production/Staging 根据审批 gate 校验 Incident;非强制环境放行。JobHost 定时备份直接调用 IDatabaseBackupService,不经过这个 HTTP 审批 Handler。
4. Log 备份的 skipped 成功
SQL Server 为 SIMPLE recovery model 时,Log 备份返回成功结果,但 Skipped=true、文件名为空、大小为零并附原因。
监控不能只统计 Result.IsSuccess;必须单独统计 skipped,否则会把长期没有日志备份误报为绿色。
5. 文件命名与目录
目录来自 DatabaseBackup:BackupDirectory,空值回退到应用目录下 Backups。数据库名优先配置,否则从连接串解析。
文件名形如 {database}_{full|diff|log}_{yyyyMMdd_HHmmss}.bak。同一秒同类型并发创建会计算相同文件名,并使用 WITH INIT,当前没有互斥锁。
6. 创建调用示例
// ① 发起日志备份;审批票据是否必需由当前 Host 环境决定。var result = await mediator.Send( new CreateBackup.Command(Type: "Log", ApprovalTicket: ticket), cancellationToken);
if (result.IsFailure) return BackupDecision.Failed(result.Error.Code);
var backup = result.Value!;// ② SIMPLE recovery 会返回成功但 skipped,不能当作已经生成文件。if (backup.Skipped) return BackupDecision.Skipped(backup.SkipReason!);
return BackupDecision.Created(backup.FileName, backup.SizeBytes);7. 列表不暴露绝对路径
ListBackups 列举目录下所有 .bak,按最后修改时间倒序,公开 DTO 只含文件名、大小、时间和从文件名推断的类型。
类型推断顺序先 full/diff/log,再 prerestore;未知命名显示 unknown。列表没有分页,备份很多时会一次读取全部文件元数据。
8. 文件名路径防护
验证和恢复调用 ValidateBackupFileName:拒绝空值、..、路径分隔符、驱动器号,并在 GetFullPath 后确认仍位于备份根目录。
这可以阻断常见路径穿越。符号链接安全取决于部署文件系统和根目录治理;备份目录应由专用账户控制。
9. VERIFYONLY 的返回语义
文件不存在或路径非法返回失败。SQL Server 执行异常则返回成功 Result + IsValid=false + 异常消息。
Verify Handler 随后直接写活动审计;审计 sink 抛错会让请求失败,即使数据库验证已经完成。Create/Restore 的审计则被 try/catch 包裹,不会覆盖主结果,三条路径并不一致。
10. 恢复的门控顺序
Confirm 必须精确大小写匹配。审批实际只在 Production/Staging 强制;非强制环境 ValidateAsync 直接 true。
11. 职责分离会降级
只有审批 gate 能返回 Incident.CreatedBy,且当前用户 EffectiveUserId 可用时,才拒绝同一人创建工单并执行恢复。
无 OpsExtension Store、非强制环境、工单读取失败或身份缺失时,creator 为 null,SoD 放行。商业环境不能把这条 best-effort 校验描述为强制双人复核。
12. 恢复不会自动先验证
恢复服务确认文件存在后,直接创建当前数据库的 pre-restore 全量快照,然后执行 RESTORE ... WITH REPLACE, RECOVERY。
它没有先调用 RESTORE VERIFYONLY,也没有核对备份数据库名、链完整性、加密、版本兼容或 restore rehearsal。单独的 verify 端点由操作者自行调用,且结果没有绑定到后续恢复。
13. pre-restore 快照
快照名为 {database}_prerestore_{timestamp}.bak,创建失败就不会执行 restore。成功响应只公开快照文件名,避免泄露绝对路径。
快照与 restore 不在一个可回滚事务内;如果快照成功而恢复失败,快照保留。当前保留作业对 pre-restore 文件固定保留三天。
14. 备份保留策略
PruneExpiredBackupsAsync:
- recent full 按 DailyRetentionDays;
- 周日 full 按 WeeklyRetentionWeeks;
- 每月 1 日 full 按 MonthlyRetentionMonths×30 天;
- diff/log 简化为日窗口;
- pre-restore 固定三天;
- 单文件删除失败只写 warning,整体继续成功。
这是按文件时间与命名的近似策略,没有备份链图、legal hold、不可变存储或异地复制证明。
15. 活跃归档的两个固定策略
默认 registry 只有:
| Resource | Online | Archive | Action | Archive table |
|---|---|---|---|---|
| NotificationInbox | 365 天 | 1095 天 | ColdStorage | SysNotification_Archive |
| LoginLog | 180 天 | 730 天 | Delete | SysLoginLog_Archive |
策略是代码内不可变列表,不是租户可配置规则。
16. 归档事务语义
SqlSugar 执行器仅支持 SQL Server。每个租户开启独立事务,以 1000 行为批次执行 DELETE TOP ... OUTPUT DELETED ... INTO archive,然后在同一事务写 SysArchiveBatch。
这样可保证单租户热表删除、归档表插入和批次记录共同提交。全租户 Job 是逐租户事务,不保证所有租户一起成功或回滚。
17. 租户边界与手动归档的 fail-closed 审计
手动执行从 ICurrentUser.User.TenantId 取租户;空或 "0" 一律拒绝(Archiving.Tenant.Required),即 Host 身份不能触发租户归档。批次查询也按当前租户过滤;PageIndex≤0 归一为 1,PageSize≤0 归一为 20,PageSize>1000 截断为 1000。PageWindow 的默认最大 offset 是 100000,超过后当前 Handler 返回空页(TotalCount=0、PageIndex=1、PageSize=20),这一回退会丢失请求页元数据,客户端不能把它当作真实无数据。
手动归档的守卫按固定顺序执行,审计现在是 fail-closed,不再是 best-effort:
- 租户校验(
tenantId空或"0"→Archiving.Tenant.Required); - 资源名、审批工单、原因长度校验;
- 确认令牌校验(必须精确匹配
"ARCHIVE"),在审批与任何副作用之前; - 审批 gate 校验;
- 策略存在性,且策略
Action必须是Archive(如ColdStorage会以Operations.Archive.ManualActionUnsupported拒绝,同样在审计与执行之前); - 意图审计:写
DataRetention.Archive.Intent。写失败返回Operations.Archive.AuditUnavailable,归档不执行; - 执行
ArchiveTenantAsync; - 完成审计:写
DataRetention.Archive.Completed。执行成功但审计写失败时返回Operations.Archive.OutcomeUnknown,而不是成功。
换种说法:归档执行器不再”完成后 best-effort 写审计、失败只告警”。意图审计写不进去就根本不会动数据;数据动了但完成审计写不进去,调用方收到的是失败结果而不是成功。这样审计记录与数据迁移在失败情况下保持一致——要么两者都成,要么两者都没有,不会出现”数据已经迁走却查不到操作记录”的灰色地带。批次表本身仍保存 ExecutedBy、执行时间、行数和时间范围,是更具体的操作证据。
18. ColdStorage 仍未落地
NotificationInbox 策略的 Action 是 ColdStorage,但 MoveToColdStorageAsync 固定返回 ColdStorageUnavailable。手动入口也会在执行前以 Operations.Archive.ManualActionUnsupported 拒绝非 Archive 的策略。活跃归档只先迁入数据库 archive table;冷存储阶段尚未接入对象存储。
不能把策略枚举值等同于已完成端到端冷存储、校验和、加密、legal hold 与删除证明。
19. HTTP 可达性
归档有手写路由并有集成边界测试。备份四个用例只有嵌套 [GenerateEndpoint] 声明;当前生成器遍历限制意味着仍需修复映射并增加真实 HTTP 合同测试。
20. 灾备验收清单
- 在独立环境验证 Full/Diff/Log 链;
- 恢复前强制 VERIFYONLY 并绑定验证 hash;
- 演练恢复到隔离数据库,而不只原地 WITH REPLACE;
- 验证 RPO/RTO、加密、访问控制和异地复制;
- 并发备份不会覆盖同秒文件;
- 审计不可用时有告警和补偿证据;
- 清理基于备份链与 legal hold,而不只文件年龄;
- 归档、冷存储、延迟删除和查询路径都完成演练。
21. 源码核查
# SQL Server 备份/恢复与归档实现。rg -n "BACKUP DATABASE|RESTORE VERIFYONLY|WITH REPLACE|DELETE TOP" \ src/Framework/BitzOrcas.Infrastructure.SqlSugar -g '*.cs'
# 备份与归档边界测试。dotnet test tests/BitzOrcas.Application.Tests \ --filter FullyQualifiedName~ArchiveTenantBoundaryTests