using CloudBuilder.Core.DatabaseAccessor.Entity; using CloudBuilder.Master.Data; using CloudBuilder.Security.Policy; using System; using System.IO; using System.Text.RegularExpressions; namespace CloudBuilder.Master.Service.FamilyTreeUtility { /// /// 族谱全系/小系 .fm 交换文件的规则:目录、文件名、系统因子。 /// 不新增配置节——目录与身份证/头像上传同根(FileServiceSettings:UploadFileDirectory),固定挂在 FamilyTree 子目录下 /// public static class FamilyTreeExchangePolicy { /// 交换目录名(身份证用 FamilyPersonAvatar/IdCard,这里同款机制) public const string EXCHANGE_FOLDER_NAME = "FamilyTree"; public const string FILE_EXTENSION = ".fm"; /// D2 系统因子:前端 PBKDF2(用户密码⊕因子) 派生 AES 密钥,防「只漏密码」离线破解 public const string EXCHANGE_FACTOR = "YouJiaLa.FamilyTree.Exchange.v1"; // D1 文件名:全系 A{treeId}_{ts}.fm;小系 P{treeId}-{personId}_{ts}.fm(ts=yyyyMMddHHmmss) private static readonly Regex ALL_FILE = new Regex(@"^AT\w+_\d{14}\.fm$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex SUB_FILE = new Regex(@"^PT\w+-P\w+_\d{14}\.fm$", RegexOptions.IgnoreCase | RegexOptions.Compiled); // R6 备份文件名:BK{treeId}_{ts}.json(替换前自动落盘的整谱回滚点,存 Backup 子目录,不进交换文件白名单) private static readonly Regex BACKUP_FILE = new Regex(@"^BKT\w+_\d{14}\.json$", RegexOptions.IgnoreCase | RegexOptions.Compiled); /// 备份目录名(挂在交换目录下) public const string BACKUP_FOLDER_NAME = "Backup"; /// R5 备份保留窗口:列表与还原都只认一年内 public static readonly int BACKUP_KEEP_YEARS = 1; /// /// 备份密文魔数前缀:备份含成员权限行(账号名等隐私),统一用服务端 AES 密钥加密落盘; /// 前缀区分新老格式(无此前缀视为旧明文库,向后兼容),不新增配置节(复用 EncryptHelper 内置密钥) /// public const string BACKUP_ENCRYPT_PREFIX = "ENC:"; /// 备份明文 JSON → 密文文本(写盘前调用) public static string EncryptBackup(string plainJson) { return BACKUP_ENCRYPT_PREFIX + EncryptHelper.AesEncrypt(plainJson); } /// 备份文件文本 → 明文 JSON(带 ENC: 前缀解密;旧明文备份直接透传) public static string DecryptBackup(string fileContent) { if (string.IsNullOrEmpty(fileContent)) return fileContent; return fileContent.StartsWith(BACKUP_ENCRYPT_PREFIX) ? EncryptHelper.AesDecrpt(fileContent.Substring(BACKUP_ENCRYPT_PREFIX.Length)) : fileContent; } public static string GetExchangeDirectory(IApplicationService service) { return Path.Combine(ServiceSettingsPolicy.GetUploadFileDirectory(service), EXCHANGE_FOLDER_NAME); } /// 生成 D1 规范文件名 public static string BuildFileName(string scope, string treeId, string anchorPersonId) { string ts = DateTime.Now.ToString("yyyyMMddHHmmss"); return scope == FamilyTreeExchangeScope.SUB ? $"P{treeId}-{anchorPersonId}_{ts}{FILE_EXTENSION}" : $"A{treeId}_{ts}{FILE_EXTENSION}"; } /// 校验文件名:只准 D1 两种格式,且拒绝任何目录成分(防 ../ 路径穿越) public static bool IsValidFileName(string fileName) { if (string.IsNullOrEmpty(fileName)) return false; if (fileName != Path.GetFileName(fileName)) return false; return ALL_FILE.IsMatch(fileName) || SUB_FILE.IsMatch(fileName); } /// 从合法文件名反解析 (treeId, anchorPersonId, scope);非法返回 null public static Tuple ParseFileName(string fileName) { if (!IsValidFileName(fileName)) return null; // 去掉前缀字母与时间戳,取中间的 id 段 string body = fileName.Substring(1, fileName.Length - 1 - FILE_EXTENSION.Length).Split('_')[0]; if (fileName[0].ToString().Equals("A", StringComparison.OrdinalIgnoreCase)) return Tuple.Create(body, "", FamilyTreeExchangeScope.ALL); string[] parts = body.Split('-'); return Tuple.Create(parts[0], parts[1], FamilyTreeExchangeScope.SUB); } public static string GetBackupDirectory(IApplicationService service) { return Path.Combine(GetExchangeDirectory(service), BACKUP_FOLDER_NAME); } /// 生成备份文件名(谱号+秒级时间戳,同谱同秒重名由覆盖兼容:秒内连点两次导入的极小概率场景) public static string BuildBackupFileName(string treeId) { return $"BK{treeId}_{DateTime.Now:yyyyMMddHHmmss}.json"; } /// 校验备份文件名:只准 BK 格式,拒绝任何目录成分(还原入口防路径穿越/拿 *.fm 灌数据) public static bool IsValidBackupFileName(string fileName) { if (string.IsNullOrEmpty(fileName)) return false; if (fileName != Path.GetFileName(fileName)) return false; return BACKUP_FILE.IsMatch(fileName); } /// 从合法备份文件名反解析 (treeId, 备份时刻);非法返回 null。时刻以文件名为准(不依赖文件系统时间戳) public static Tuple ParseBackupFileName(string fileName) { if (!IsValidBackupFileName(fileName)) return null; string body = fileName.Substring(2, fileName.Length - 2 - ".json".Length); string[] parts = body.Split('_'); if (!DateTime.TryParseExact(parts[1], "yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime ts)) return null; return Tuple.Create(parts[0], ts); } } }