diff --git a/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs index b63e565..7285493 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/DmsFileService.auto.cs @@ -1698,6 +1698,10 @@ namespace CloudBuilder.Master.Service entity.OwerId = service.UserId; entity.IsLinkIndc = YesNoPolicy.NO; + // 网盘额度闸门:按当前用户类型限额,已用(KB)+本次(KB) 超限直接拒。 + // 放任何 InsertNow 之前,抛异常时主表/上传表/目录关系均未落库,DB 保持一致(仅物理文件可能成为孤儿,属上传前置阶段)。 + EnsureQuotaAllowed(dmsFile.FileSize); + repository.InsertNow(entity); IRepository repositoryUpload = repository.GetRepository>(); @@ -1715,6 +1719,86 @@ namespace CloudBuilder.Master.Service return entity; } + /// + /// 当前登录用户的网盘用量汇总:类型/头像取自 security_user,已用取自 dms_file_usage_view(ower_id=本人 且非目录), + /// 额度按 user_type 从 appsettings:NetDiskQuota 解析。供 dashboard 两张卡片一次拉取。 + /// + [PermissionOperate(name: MasterPermissionPolicy.DmsFile, operate: Operate.Find)] + public StorageUsageData GetMyStorageUsage() + { + IRepository repositoryUser = repository.GetRepository>(); + SecurityUserEntity user = repositoryUser.DetachedEntities.Where(x => x.UserName == service.UserId).FirstOrDefault(); + string userType = user?.UserType; + if (string.IsNullOrWhiteSpace(userType)) userType = SecurityUserTypePolicy.UNAUTHENTICATED_USERS; + + long usedKB = GetUsedKB(service.UserId); + long quotaKB = ServiceSettingsPolicy.GetNetDiskQuotaKB(service, userType); + int percent = quotaKB > 0 ? (int)(usedKB * 100 / quotaKB) : 0; + + return new StorageUsageData + { + UserType = userType, + AvatarUrl = FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.USER_AVATAR, user?.UserIconImgPath) ?? "", + UsedKB = usedKB, + QuotaKB = quotaKB, + Percent = percent + }; + } + + /// + /// 上传并保存当前登录用户头像:复用与成员头像同一套 PackUploadFile 落盘(目录名统一走 UploadFolderPolicy.USER_AVATAR), + /// 末片物理名回写 security_user.user_icon_img_path(与 family_person.avatar_filename 同口径存物理名),返回可访问 URL。 + /// + [PermissionOperate(name: MasterPermissionPolicy.DmsFile, operate: Operate.Insert)] + [UnitOfWork] + public string UploadUserAvatar() + { + var files = httpContextAccessor.HttpContext.Request.Form.Files; + if (files == null || files.Count == 0) return ""; + + IFormFile file = files[0]; + if (file == null || file.Length <= 0) return ""; + + // 落盘:单片三段式名(uni-时间戳-1-1.ext)会在 PackUploadFile 内合并到备份目录 + PackUploadFile(file, UploadFolderPolicy.USER_AVATAR); + + string filename = file.FileName; + + IRepository repositoryUser = repository.GetRepository>(); + SecurityUserEntity user = repositoryUser.DetachedEntities.Where(x => x.UserName == service.UserId).FirstOrDefault(); + if (user != null) + { + user.UserIconImgPath = filename; + repositoryUser.UpdateNow(user); + } + + return FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.USER_AVATAR, filename) ?? ""; + } + + /// 查本人已用容量(KB)。按 dms_file_usage_view 视图实体标准 LINQ 链路取数, + /// 无行(从未上传)记 0;ower_id 源自 JWT 登录态非外部入参。 + private long GetUsedKB(string owerId) + { + IRepository repositoryView = repository.GetRepository>(); + return repositoryView.DetachedEntities.Where(x => x.OwerId == owerId).Select(x => x.UsedKb).FirstOrDefault(); + } + + /// 上传额度校验:额度未配(≤0)视为不限;已用+本次超额度则抛业务异常拒上传。 + private void EnsureQuotaAllowed(int incomingKB) + { + IRepository repositoryUser = repository.GetRepository>(); + string userType = repositoryUser.DetachedEntities.Where(x => x.UserName == service.UserId).FirstOrDefault()?.UserType; + long quotaKB = ServiceSettingsPolicy.GetNetDiskQuotaKB(service, userType); + if (quotaKB <= 0) return; + long usedKB = GetUsedKB(service.UserId); + if (usedKB + incomingKB > quotaKB) + { + long limitGB = quotaKB / (1024 * 1024); + long usedGB = usedKB / (1024 * 1024); + throw new ValidatedException($"网盘容量不足(上限约 {limitGB} GB,已用约 {usedGB} GB),请清理或升级会员后再上传。"); + } + } + [PermissionOperate(name: MasterPermissionPolicy.DmsFile, operate: Operate.Find)] public dynamic DownloadFile(long fileId) { diff --git a/CloudBuilder/CloudBuilder.Master.Service/FamilyPersonService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/FamilyPersonService.auto.cs index 2eb08ed..d929c71 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/FamilyPersonService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/FamilyPersonService.auto.cs @@ -18,9 +18,6 @@ namespace CloudBuilder.Master.Service { public class FamilyPersonService : IFamilyPersonService { - /// 头像上传目录,与前端 FileUpload 的 file-path 保持一致 - private const string AVATAR_FOLDER_NAME = "FamilyPersonAvatar"; - private readonly IApplicationService service; private IRepository repository; @@ -51,7 +48,7 @@ namespace CloudBuilder.Master.Service data.FamilyPerson.IsCanAmend = new FamilyTreePermissionHelper(service).HasPermission(data.FamilyPerson.TreeId, data.FamilyPerson.PersonId); - data.FamilyPerson.AvatarUrl = FileTypePolicy.PackAvatarUrl(service, AVATAR_FOLDER_NAME, data.FamilyPerson.AvatarFilename); + data.FamilyPerson.AvatarUrl = FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.FAMILY_PERSON_AVATAR, data.FamilyPerson.AvatarFilename); return data; } diff --git a/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeService.auto.cs index 82c00f6..451d8ee 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeService.auto.cs @@ -16,13 +16,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal; using Microsoft.Extensions.DependencyInjection; using System.Collections.Generic; +using System.IO; +using System.Linq; namespace CloudBuilder.Master.Service { public class FamilyTreeService : IFamilyTreeService { - /// 头像上传目录,与前端 FileUpload 的 file-path 保持一致 - private const string AVATAR_FOLDER_NAME = "FamilyPersonAvatar"; - private readonly IApplicationService service; private IRepository repository; @@ -36,7 +35,7 @@ namespace CloudBuilder.Master.Service private void FillAvatarUrl(FamilyPersonDisplayData person) { if (person == null) return; - person.AvatarUrl = FileTypePolicy.PackAvatarUrl(service, AVATAR_FOLDER_NAME, person.AvatarFilename); + person.AvatarUrl = FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.FAMILY_PERSON_AVATAR, person.AvatarFilename); } /// 批量填充头像访问地址 @@ -151,17 +150,30 @@ namespace CloudBuilder.Master.Service { currentPerson = d.AncestorPersonId; - data.CurrentPerson.FamilyPerson = new FamilyPersonDisplayData(repositoryFamilyPerson.DetachedEntities.Where(x => x.PersonId == currentPerson).FirstOrDefault()!); + FamilyPersonEntity ancestor0 = repositoryFamilyPerson.DetachedEntities.Where(x => x.PersonId == currentPerson).FirstOrDefault(); + // 始祖行缺失是数据破损,给可读的业务错误而不是 NullReference 服务异常 + if (ancestor0 == null) throw new ValidatedException("该族谱未设置始祖或始祖档案已丢失."); + + data.CurrentPerson.FamilyPerson = new FamilyPersonDisplayData(ancestor0); data.FamilyTree.AncestorPersonGenealogyName = data.CurrentPerson.FamilyPerson.GenealogyName; } else { - data.CurrentPerson.FamilyPerson = new FamilyPersonDisplayData(repositoryFamilyPerson.DetachedEntities.Where(x => x.PersonId == d.AncestorPersonId).FirstOrDefault()!); + FamilyPersonEntity ancestor = repositoryFamilyPerson.DetachedEntities.Where(x => x.PersonId == d.AncestorPersonId).FirstOrDefault(); + if (ancestor != null) + { + data.CurrentPerson.FamilyPerson = new FamilyPersonDisplayData(ancestor); - data.FamilyTree.AncestorPersonGenealogyName = data.CurrentPerson.FamilyPerson.GenealogyName; + data.FamilyTree.AncestorPersonGenealogyName = data.CurrentPerson.FamilyPerson.GenealogyName; + } - data.CurrentPerson.FamilyPerson = new FamilyPersonDisplayData(repositoryFamilyPerson.DetachedEntities.Where(x => x.PersonId == personId).FirstOrDefault()!); + // 旧 personId 失效(如整谱替换/备份还原后全员重编号)回落始祖,不再抛「Source entity is null」服务异常 + FamilyPersonEntity current = repositoryFamilyPerson.DetachedEntities.Where(x => x.PersonId == personId).FirstOrDefault() ?? ancestor; + if (current == null) throw new ValidatedException("该族谱中没有可用的族员."); + currentPerson = current.PersonId; + + data.CurrentPerson.FamilyPerson = new FamilyPersonDisplayData(current); } FillAvatarUrl(data.CurrentPerson.FamilyPerson); @@ -362,6 +374,78 @@ namespace CloudBuilder.Master.Service repositoryPermission.InsertNow(fresh); } + /// + /// 小系替换后的权限行迁移(用户拍板:按新旧编号关联保留绑定权限): + /// 被删后代/入配配偶若能经 idMap(文件旧号→重编新号)关联上则换绑到新号(同谱 round-trip 场景); + /// 关联不上(跨谱嫁接/文件已删掉的人)的悬挂行顺手删除。 + /// 管理员行(ManagerIndc=YES)与 PersonId 空行(申请/未绑)一律不动;返回 moved/dropped 统计进审计。 + /// + private string MigrateSubTreePermissions(string treeId, HashSet deletedIds, Dictionary idMap) + { + IRepository repositoryPermission = repository.GetRepository>(); + FamilyTreePermissionEntity[] rows = repositoryPermission.DetachedEntities.Where(x => x.TreeId == treeId).ToArray(); + int moved = 0; + int dropped = 0; + foreach (FamilyTreePermissionEntity row in rows) + { + if (row.ManagerIndc == YesNoPolicy.YES || string.IsNullOrEmpty(row.PersonId) || !deletedIds.Contains(row.PersonId)) continue; + string newId = idMap.ContainsKey(row.PersonId) ? idMap[row.PersonId] : null; + // 新号已被同名其它行占用(理论上不会)则只删旧行,防复合主键冲突 + if (newId != null && rows.Any(x => x.PersonId == newId && x.UserName == row.UserName)) newId = null; + if (newId == null) + { + repositoryPermission.DeleteNow(row); + dropped++; + continue; + } + RebindFamilyTreePermission(row, newId); + moved++; + } + return $"moved={moved};dropped={dropped}"; + } + + /// + /// 跨谱整谱替换后的权限行清理:全员重编号后本谱引用已删旧号的成员权限行必然悬挂(垃圾行),顺手删除; + /// 管理员行(ManagerIndc=YES,建谱时 PersonId 指向始祖,IsManager 判定不看 PersonId)与 PersonId 空行保留。 + /// 同谱替换编号不变权限行天然无损,不需要走这里。返回删除条数进审计。 + /// + private int CleanDanglingPermissions(string treeId, HashSet aliveIds) + { + IRepository repositoryPermission = repository.GetRepository>(); + List alive = aliveIds.ToList(); + FamilyTreePermissionEntity[] dangling = repositoryPermission.DetachedEntities + .Where(x => x.TreeId == treeId && x.ManagerIndc != YesNoPolicy.YES && x.PersonId != "" && !alive.Contains(x.PersonId)).ToArray(); + if (dangling.Length > 0) repositoryPermission.DeleteNow(dangling); + return dangling.Length; + } + + /// + /// 备份还原的权限回放(绝对闭环):把本谱权限行整体替换为备份快照,PersonId 经 idMap 重映射 + /// (还原恒同谱→编号不变→权限行原样落回);管理员行随快照一并恢复,还原人自身也在快照内故不会自锁。 + /// 仅备份(含权限快照)触发;.fm 导入无权限快照不会走到这里。返回恢复条数进审计。 + /// + private int RestorePermissions(string treeId, FamilyTreePermissionEntity[] snapshot, Dictionary idMap) + { + IRepository repositoryPermission = repository.GetRepository>(); + FamilyTreePermissionEntity[] current = repositoryPermission.DetachedEntities.Where(x => x.TreeId == treeId).ToArray(); + if (current.Length > 0) repositoryPermission.DeleteNow(current); + int restored = 0; + foreach (FamilyTreePermissionEntity row in snapshot) + { + FamilyTreePermissionEntity fresh = new FamilyTreePermissionEntity + { + TreeId = treeId, + UserName = row.UserName, + PersonId = string.IsNullOrEmpty(row.PersonId) || !idMap.ContainsKey(row.PersonId) ? row.PersonId : idMap[row.PersonId], + ManagerIndc = row.ManagerIndc, + ValidIndc = row.ValidIndc, + }; + repositoryPermission.InsertNow(fresh); + restored++; + } + return restored; + } + /// /// 用户中文姓名(security_user.user_local_name);登录账号只是标识,不参与同名比较 /// @@ -718,5 +802,599 @@ namespace CloudBuilder.Master.Service Delete(key); } } + + //=================== 族谱全系/小系 .fm 导入导出(D1-D8 决策) =================== + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Find)] + public string GetExchangeFactor() + { + // D2:前端用「用户密码⊕系统因子」PBKDF2 派生 AES 密钥,因子由后端下发,不进前端包体 + return FamilyTreeExchangePolicy.EXCHANGE_FACTOR; + } + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Find)] + public FamilyTreeExchangeData ExportFamilyTree(string treeId, string scope, string anchorPersonId) + { + FamilyTreeEntity tree = repository.DetachedEntities.Where(x => x.TreeId == treeId).FirstOrDefault(); + if (tree == null) throw new ValidatedException("族谱不存在."); + + bool isSub = scope == FamilyTreeExchangeScope.SUB; + FamilyTreePermissionHelper helper = new FamilyTreePermissionHelper(service); + bool isManager = helper.IsManager(treeId); + // D8 闸门:全系导出仅管理员;小系导出管理员任意锚点、成员仅限自己可编辑家支的锚点 + if (!isManager) + { + if (!helper.IsMember(treeId)) throw new ValidatedException("您没有导出权限."); + if (!isSub) throw new ValidatedException("全系导出需要族谱管理员权限."); + if (!helper.GetAmendablePersonIds(treeId).Contains(anchorPersonId)) throw new ValidatedException("只能导出以自己为锚点的家支."); + } + + IRepository repositoryPerson = repository.GetRepository>(); + IRepository repositorySpouse = repository.GetRepository>(); + IRepository repositoryChild = repository.GetRepository>(); + + FamilyTreeSpouseMapEntity[] spouseMaps = repositorySpouse.DetachedEntities.Where(x => x.TreeId == treeId).OrderBy(x => x.Orderby).ToArray(); + FamilyTreeChildrenMapEntity[] childrenMaps = repositoryChild.DetachedEntities.Where(x => x.TreeId == treeId).OrderBy(x => x.Orderby).ToArray(); + FamilyPersonEntity[] persons = repositoryPerson.DetachedEntities.Where(x => x.TreeId == treeId).ToArray(); + + if (isSub) + { + // 小系子集口径 = 权限 helper 同源家支展开(后代闭包 + 配偶 + 上溯两代及其配偶): + // 管理员拿传入锚点展开;成员闸门已验过锚点,直接用自己可编集合 + List ids; + if (isManager) + { + ids = new List(); + helper.PackPersonIdByChildrenMaps(childrenMaps, spouseMaps, anchorPersonId, ids); + } + else + { + ids = helper.GetAmendablePersonIds(treeId); + } + persons = persons.Where(x => ids.Contains(x.PersonId)).ToArray(); + spouseMaps = spouseMaps.Where(x => ids.Contains(x.PersonId) && ids.Contains(x.SpouseId)).ToArray(); + childrenMaps = childrenMaps.Where(x => ids.Contains(x.PersonId) && ids.Contains(x.ChildrenId)).ToArray(); + } + + string exportFileName = FamilyTreeExchangePolicy.BuildFileName(isSub ? FamilyTreeExchangeScope.SUB : FamilyTreeExchangeScope.ALL, treeId, anchorPersonId); + WriteExchangeLog("OUT", treeId, isSub ? FamilyTreeExchangeScope.SUB : FamilyTreeExchangeScope.ALL, anchorPersonId, exportFileName, persons.Length, "OK", ""); + + return new FamilyTreeExchangeData + { + Scope = isSub ? FamilyTreeExchangeScope.SUB : FamilyTreeExchangeScope.ALL, + FamilyTree = tree, + Persons = persons, + SpouseMaps = spouseMaps, + ChildrenMaps = childrenMaps, + // 二期:小系文件把锚点写进 payload,导入侧据此验明同一人(一期只体在文件名里,旧文件读进来为空串) + AnchorPersonId = isSub ? anchorPersonId : "", + ExportedBy = service.UserId, + ExportedAt = DateTime.Now, + }; + } + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Find)] + public string SaveExchangeFile(string fileName, string contentBase64) + { + if (!FamilyTreeExchangePolicy.IsValidFileName(fileName)) throw new ValidatedException("文件名不合法(须为 A谱号_时间.fm 或 P谱号-人名号_时间.fm)."); + FamilyTreePermissionHelper helper = new FamilyTreePermissionHelper(service); + // 管理员也有生效权限行,IsMember 一并覆盖 + if (!helper.IsMember(FamilyTreeExchangePolicy.ParseFileName(fileName).Item1)) throw new ValidatedException("您不是该族谱成员."); + + byte[] bytes; + try { bytes = Convert.FromBase64String(contentBase64 ?? ""); } + catch { throw new ValidatedException("文件内容不是合法的 base64."); } + if (bytes.Length == 0 || bytes.Length > 50 * 1024 * 1024) throw new ValidatedException("文件大小异常(限 1B~50MB)."); + + string dir = FamilyTreeExchangePolicy.GetExchangeDirectory(service); + Directory.CreateDirectory(dir); + File.WriteAllBytes(Path.Combine(dir, fileName), bytes); + return fileName; + } + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Find)] + public FamilyTreeExchangeFileInfo[] ListExchangeFiles(string treeId) + { + if (string.IsNullOrEmpty(treeId)) throw new ValidatedException("请先选择族谱."); + if (!new FamilyTreePermissionHelper(service).IsMember(treeId)) throw new ValidatedException("您不是该族谱成员."); + + string dir = FamilyTreeExchangePolicy.GetExchangeDirectory(service); + if (!Directory.Exists(dir)) return []; + + List files = new List(); + foreach (string path in Directory.GetFiles(dir, "*" + FamilyTreeExchangePolicy.FILE_EXTENSION)) + { + string name = Path.GetFileName(path); + var parsed = FamilyTreeExchangePolicy.ParseFileName(name); + // 只列本谱的文件(非法文件名一律跳过) + if (parsed == null || parsed.Item1 != treeId) continue; + FileInfo info = new FileInfo(path); + files.Add(new FamilyTreeExchangeFileInfo + { + FileName = name, + Size = info.Length, + LastModified = info.LastWriteTime, + Scope = parsed.Item3, + TreeId = parsed.Item1, + AnchorPersonId = parsed.Item2, + }); + } + // 导入以最新为准:时间降序,前端取第一条即可 + return files.OrderByDescending(x => x.LastModified).ToArray(); + } + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Find)] + public string ReadExchangeFile(string fileName) + { + if (!FamilyTreeExchangePolicy.IsValidFileName(fileName)) throw new ValidatedException("文件名不合法."); + if (!new FamilyTreePermissionHelper(service).IsMember(FamilyTreeExchangePolicy.ParseFileName(fileName).Item1)) throw new ValidatedException("您不是该族谱成员."); + + string path = Path.Combine(FamilyTreeExchangePolicy.GetExchangeDirectory(service), fileName); + if (!File.Exists(path)) throw new ValidatedException("文件不存在或已被清理."); + return Convert.ToBase64String(File.ReadAllBytes(path)); + } + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Update)] + [UnitOfWork] + public int ImportFamilyTree(string targetTreeId, FamilyTreeExchangeData data) + { + FamilyTreeEntity target = repository.DetachedEntities.Where(x => x.TreeId == targetTreeId).FirstOrDefault(); + if (target == null) throw new ValidatedException("目标族谱不存在."); + // D4:整谱替换是谱级破坏性操作,仅目标谱管理员;失败由 ValidatedException 抛回,事务整体回滚 + if (!new FamilyTreePermissionHelper(service).IsManager(targetTreeId)) throw new ValidatedException("只有族谱管理员可以整谱替换导入."); + if (data == null || data.Persons == null || data.Persons.Length == 0) throw new ValidatedException("导入数据为空."); + // 一期只开放全系文件替换;小系 merge 未开(P 文件导进谱会把别人家支的人叠进来,关系锚点不明) + if (data.Scope != FamilyTreeExchangeScope.ALL) throw new ValidatedException("目前仅支持全系(ALL)文件导入,小系合并暂未开放."); + // D3:允许跨谱导入(payload 自带谱信息,主键以目标谱为准),无谱信息的是非法包 + if (data.FamilyTree == null) throw new ValidatedException("导入数据缺少谱信息."); + return ReplaceTreeCore(target, data, "IN"); + } + + /// + /// 替换前整谱 JSON 备份(回滚点):明文存交换目录 Backup 子目录,文件名进审计 message; + /// ListExchangeFiles 只看 *.fm,备份不会被列成可导入文件。 + /// + private string BackupTree(FamilyTreeEntity target, FamilyPersonEntity[] oldPersons, FamilyTreeSpouseMapEntity[] oldSpouses, FamilyTreeChildrenMapEntity[] oldChildren) + { + string backupName = FamilyTreeExchangePolicy.BuildBackupFileName(target.TreeId); + string backupDir = FamilyTreeExchangePolicy.GetBackupDirectory(service); + Directory.CreateDirectory(backupDir); + // 权限行一并入快照(还原时随谱回滚,绝对闭环);整份密文落盘(含账号名隐私) + IRepository repositoryPermission = repository.GetRepository>(); + FamilyTreePermissionEntity[] perms = repositoryPermission.DetachedEntities.Where(x => x.TreeId == target.TreeId).ToArray(); + string plainJson = System.Text.Json.JsonSerializer.Serialize(new FamilyTreeExchangeData + { + Scope = FamilyTreeExchangeScope.ALL, + FamilyTree = target, + Persons = oldPersons, + SpouseMaps = oldSpouses, + ChildrenMaps = oldChildren, + Permissions = perms, + ExportedBy = service.UserId, + }); + File.WriteAllText(Path.Combine(backupDir, backupName), FamilyTreeExchangePolicy.EncryptBackup(plainJson)); + return backupName; + } + + /// + /// 整谱替换本体(ImportFamilyTree 与 RestoreBackup 共用,调用方已开事务): + /// 先备份现谱→关系先行、人后删→插入→关系双端 remap→谱信息行更新→审计。 + /// 同谱(文件谱号==目标谱,备份还原/服务器文件导入恒同谱):编号与绑定原样保留,库与文件 100% 一致,权限行不悬挂; + /// 跨谱嫁接:全员重编号+清绑定(防主键冲突与绑定劫持)。入库前先过完整性闸,坏文件直接拒绝。 + /// 审计动码区分来源:IN=外部文件导入 / RESTORE=备份回滚回放。 + /// + private int ReplaceTreeCore(FamilyTreeEntity target, FamilyTreeExchangeData data, string action) + { + string targetTreeId = target.TreeId; + // 同谱判定:文件谱号与目标谱一致(还原走 R6 闸恒同谱;跨谱全系只能来自本地上传) + bool sameTree = string.Equals(data.FamilyTree?.TreeId ?? "", targetTreeId, StringComparison.OrdinalIgnoreCase); + + // 0) 完整性闸:整谱数据必须内部闭合(人员编号非空不重复、关系行两端都在文件内), + // 否则拒绝——宁可不导也不允许静默丢行,保证落库与文件 100% 相同 + HashSet fileIdSet = new HashSet(); + foreach (FamilyPersonEntity p in data.Persons) + if (string.IsNullOrEmpty(p.PersonId) || !fileIdSet.Add(p.PersonId)) + throw new ValidatedException("导入/还原失败:文件内人员编号为空或重复,数据不完整."); + foreach (FamilyTreeSpouseMapEntity m in data.SpouseMaps ?? []) + if (!fileIdSet.Contains(m.PersonId) || !fileIdSet.Contains(m.SpouseId)) + throw new ValidatedException("导入/还原失败:配偶关系引用了文件外的人员,数据不完整."); + foreach (FamilyTreeChildrenMapEntity m in data.ChildrenMaps ?? []) + if (!fileIdSet.Contains(m.PersonId) || !fileIdSet.Contains(m.ChildrenId)) + throw new ValidatedException("导入/还原失败:父子关系引用了文件外的人员,数据不完整."); + + IRepository repositoryPerson = repository.GetRepository>(); + IRepository repositorySpouse = repository.GetRepository>(); + IRepository repositoryChild = repository.GetRepository>(); + + // 1) 替换前自动备份(回滚点) + FamilyPersonEntity[] oldPersons = repositoryPerson.DetachedEntities.Where(x => x.TreeId == targetTreeId).ToArray(); + FamilyTreeSpouseMapEntity[] oldSpouses = repositorySpouse.DetachedEntities.Where(x => x.TreeId == targetTreeId).ToArray(); + FamilyTreeChildrenMapEntity[] oldChildren = repositoryChild.DetachedEntities.Where(x => x.TreeId == targetTreeId).ToArray(); + string backupName = BackupTree(target, oldPersons, oldSpouses, oldChildren); + + // 2) 清空当前谱:关系先行、人后删(事务内任一环节失败全部回滚,备份文件已落盘不受影响) + if (oldSpouses.Length > 0) repositorySpouse.DeleteNow(oldSpouses); + if (oldChildren.Length > 0) repositoryChild.DeleteNow(oldChildren); + foreach (FamilyPersonEntity old in oldPersons) repositoryPerson.DeleteNow(old); + + // 同谱回放需保留文件里的 Created 印章,而框架 SaveChanges 会对 Added 强制盖 CreatedBy/CreatedDatetime, + // 故插入段临时关闭审计印章(仿 LocaleService 先例;跨谱不开——照旧由框架盖当次操作人/时间); + // 下方手工赋值不受影响(Updated* 均为代码显式设置),末尾 finally 复位防残留 + MasterDbContext dbCtx = repository.GetDbContext() as MasterDbContext; + Dictionary idMap = new Dictionary(); + try + { + if (sameTree && dbCtx != null) dbCtx.SkipAudit = true; + + // 3) 编号策略:同谱保留原编号与绑定(权限行/锚点继续有效,数据 100% 回放); + // 跨谱一律走号段生成器重发 PersonId + 清绑定(防冲突防劫持,进谱后重走审批绑定) + ISecurityGeneratorNumberService generator = service.ServiceProvider.GetService()!; + DateTime now = DateTime.Now; + foreach (FamilyPersonEntity p in data.Persons) + { + idMap[p.PersonId] = sameTree ? p.PersonId : generator.GetNo(MasterPrefixPolicy.FAMILY_PERSON, MasterPrefixPolicy.FAMILY_PERSON_PERSON_ID, MasterPrefixPolicy.FAMILY_PERSON_PREFIX, YesNoPolicy.NO, MasterPrefixPolicy.FAMILY_PERSON_FORMAT); + } + foreach (FamilyPersonEntity p in data.Persons) + { + FamilyPersonEntity fresh = p.Clone(); + fresh.PersonId = idMap[p.PersonId]; + fresh.TreeId = targetTreeId; + fresh.FatherId = string.IsNullOrEmpty(p.FatherId) ? null : (idMap.ContainsKey(p.FatherId) ? idMap[p.FatherId] : null); + fresh.BindUserId = sameTree ? p.BindUserId : null; + fresh.IsBindUser = sameTree ? p.IsBindUser : null; + if (sameTree) + { + fresh.CreatedBy = p.CreatedBy; + fresh.CreatedDatetime = p.CreatedDatetime; + } + else + { + fresh.CreatedBy = service.UserId; + fresh.CreatedDatetime = now; + } + fresh.UpdatedBy = service.UserId; + fresh.UpdatedDatetime = now; + repositoryPerson.InsertNow(fresh); + } + + // 4) 关系行双端 remap(完整性闸已保证两端都在文件内,此处必全量命中); + // 同谱时 Created 印章随 Clone 原样回放,Updated 补当次操作痕迹;跨谱由框架盖当次 + foreach (FamilyTreeSpouseMapEntity m in data.SpouseMaps ?? []) + { + if (!idMap.ContainsKey(m.PersonId) || !idMap.ContainsKey(m.SpouseId)) continue; + FamilyTreeSpouseMapEntity fresh = m.Clone(); + fresh.TreeId = targetTreeId; + fresh.PersonId = idMap[m.PersonId]; + fresh.SpouseId = idMap[m.SpouseId]; + if (sameTree) + { + fresh.UpdatedBy = service.UserId; + fresh.UpdatedDatetime = now; + } + repositorySpouse.InsertNow(fresh); + } + foreach (FamilyTreeChildrenMapEntity m in data.ChildrenMaps ?? []) + { + if (!idMap.ContainsKey(m.PersonId) || !idMap.ContainsKey(m.ChildrenId)) continue; + FamilyTreeChildrenMapEntity fresh = m.Clone(); + fresh.TreeId = targetTreeId; + fresh.PersonId = idMap[m.PersonId]; + fresh.ChildrenId = idMap[m.ChildrenId]; + if (sameTree) + { + fresh.UpdatedBy = service.UserId; + fresh.UpdatedDatetime = now; + } + repositoryChild.InsertNow(fresh); + } + + // 5) 谱信息字段以数据为准(主键 TreeId 保持目标谱;始祖指向重编后的始祖) + target.TreeName = data.FamilyTree.TreeName; + target.Brief = data.FamilyTree.Brief; + target.TreeCoverFilename = data.FamilyTree.TreeCoverFilename; + target.PublicIndc = data.FamilyTree.PublicIndc; + target.AncestorPersonId = idMap.ContainsKey(data.FamilyTree.AncestorPersonId ?? "") ? idMap[data.FamilyTree.AncestorPersonId] : target.AncestorPersonId; + target.UpdatedBy = service.UserId; + target.UpdatedDatetime = now; + repository.UpdateNow(target); + } + finally + { + if (dbCtx != null) dbCtx.SkipAudit = false; + } + + // 权限闭环:备份还原(含权限快照)→ 权限整体回放到备份时刻;跨谱 .fm 导入(无快照)→ 清悬挂行;同谱 .fm 导入→不碰 + string permStat; + if (data.Permissions != null && data.Permissions.Length > 0) + permStat = $"; permRestored={RestorePermissions(targetTreeId, data.Permissions, idMap)}"; + else if (!sameTree) + permStat = $"; permDropped={CleanDanglingPermissions(targetTreeId, new HashSet(idMap.Values))}"; + else + permStat = ""; + + // 6) 审计(事务回滚则审计同滚——失败场景弹错给用户,不留半截日志) + WriteExchangeLog(action, targetTreeId, FamilyTreeExchangeScope.ALL, "", backupName, idMap.Count, "OK", $"backup={backupName}; sameTree={(sameTree ? 1 : 0)}{permStat}"); + return idMap.Count; + } + + /// + /// 支系闭包:锚点以下后代 + 后代的配偶(迭代到不动点,防回头婚配漏拉);锚点本人不进集合。 + /// 目标侧算删除集、文件侧算插入集共用此口径,保证删/插范围对称。 + /// + static HashSet BranchClosure(string anchorId, FamilyTreeChildrenMapEntity[] childrenMaps, FamilyTreeSpouseMapEntity[] spouseMaps) + { + HashSet set = new HashSet(); + bool changed = true; + while (changed) + { + changed = false; + foreach (FamilyTreeChildrenMapEntity m in childrenMaps ?? []) + { + if ((m.PersonId == anchorId || set.Contains(m.PersonId)) && m.ChildrenId != anchorId && set.Add(m.ChildrenId)) changed = true; + } + foreach (FamilyTreeSpouseMapEntity m in spouseMaps ?? []) + { + if (set.Contains(m.PersonId) && m.SpouseId != anchorId && set.Add(m.SpouseId)) changed = true; + if (set.Contains(m.SpouseId) && m.PersonId != anchorId && set.Add(m.PersonId)) changed = true; + } + } + return set; + } + + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Update)] + [UnitOfWork] + public int ImportSubTree(string targetTreeId, string anchorPersonId, FamilyTreeExchangeData data) + { + FamilyTreeEntity target = repository.DetachedEntities.Where(x => x.TreeId == targetTreeId).FirstOrDefault(); + if (target == null) throw new ValidatedException("目标族谱不存在."); + if (data == null || data.Persons == null || data.Persons.Length == 0) throw new ValidatedException("导入数据为空."); + if (data.Scope != FamilyTreeExchangeScope.SUB) throw new ValidatedException("支系导入仅收小系(SUB)文件,全系请走整谱替换."); + if (string.IsNullOrEmpty(data.AnchorPersonId)) throw new ValidatedException("导入文件未标记小系锚点(旧版导出文件),请重新导出."); + if (string.IsNullOrEmpty(anchorPersonId)) throw new ValidatedException("请先选择要替换的族内锚点族人."); + + FamilyTreePermissionHelper helper = new FamilyTreePermissionHelper(service); + // P6 权限闸:管理员任意锚点;成员仅限自己可编家支的锚点(与小系导出同一把闸) + if (!helper.IsManager(targetTreeId)) + { + if (!helper.IsMember(targetTreeId)) throw new ValidatedException("您没有导入权限."); + if (!helper.GetAmendablePersonIds(targetTreeId).Contains(anchorPersonId)) throw new ValidatedException("只能替换自己家支的锚点."); + } + + IRepository repositoryPerson = repository.GetRepository>(); + IRepository repositorySpouse = repository.GetRepository>(); + IRepository repositoryChild = repository.GetRepository>(); + + FamilyPersonEntity targetAnchor = repositoryPerson.DetachedEntities.Where(x => x.TreeId == targetTreeId && x.PersonId == anchorPersonId).FirstOrDefault(); + if (targetAnchor == null) throw new ValidatedException("锚点族人不存在."); + // P2/P3 同一人验证:文件锚点与所选族人 姓+谱名 全量一致才同意,否则拒绝 + FamilyPersonEntity fileAnchor = data.Persons.FirstOrDefault(x => x.PersonId == data.AnchorPersonId); + if (fileAnchor == null) throw new ValidatedException("文件里找不到锚点人."); + string Trim(string s) => (s ?? "").Trim(); + if (Trim(fileAnchor.Surname) != Trim(targetAnchor.Surname) || Trim(fileAnchor.GenealogyName) != Trim(targetAnchor.GenealogyName)) + throw new ValidatedException("文件锚点与所选族人不是同一人(姓/谱名不符),已拒绝导入."); + + FamilyPersonEntity[] oldPersons = repositoryPerson.DetachedEntities.Where(x => x.TreeId == targetTreeId).ToArray(); + FamilyTreeSpouseMapEntity[] oldSpouses = repositorySpouse.DetachedEntities.Where(x => x.TreeId == targetTreeId).ToArray(); + FamilyTreeChildrenMapEntity[] oldChildren = repositoryChild.DetachedEntities.Where(x => x.TreeId == targetTreeId).ToArray(); + + // 1) 替换前整谱 JSON 备份(P7,回滚点,同整谱替换口径) + string backupName = BackupTree(target, oldPersons, oldSpouses, oldChildren); + + // 1.5) 方案 C:锚点本人也纳入替换——资料整体覆盖为文件锚点(备份已先行,可回滚); + // PersonId/TreeId/FatherId/绑定字段/建档信息保留目标侧,防锚点脱谱、防绑定被文件旧值劫持 + DateTime now = DateTime.Now; + FamilyPersonEntity mergedAnchor = targetAnchor.Clone(); + mergedAnchor.CopyFrom(fileAnchor); + mergedAnchor.PersonId = targetAnchor.PersonId; + mergedAnchor.TreeId = targetTreeId; + mergedAnchor.FatherId = targetAnchor.FatherId; + mergedAnchor.BindUserId = targetAnchor.BindUserId; + mergedAnchor.IsBindUser = targetAnchor.IsBindUser; + mergedAnchor.CreatedBy = targetAnchor.CreatedBy; + mergedAnchor.CreatedDatetime = targetAnchor.CreatedDatetime; + mergedAnchor.UpdatedBy = service.UserId; + mergedAnchor.UpdatedDatetime = now; + repositoryPerson.UpdateNow(mergedAnchor); + + // 2) 删除集:目标锚点以下后代闭包 + 锚点配偶(方案 C 扩展:锚点整家替换;锚点本人已在 1.5 步覆盖) + // 配偶仅「纯入配」(无其它婚配/血缘关系、无账号绑定、非始祖)才随删重插;否则只断与锚点的关系行、人行保留防悬挂 + HashSet delSet = BranchClosure(anchorPersonId, oldChildren, oldSpouses); + HashSet delSpousePersons = new HashSet(); + foreach (string sid in oldSpouses.Where(x => x.PersonId == anchorPersonId || x.SpouseId == anchorPersonId) + .Select(x => x.PersonId == anchorPersonId ? x.SpouseId : x.PersonId).Distinct()) + { + if (sid == anchorPersonId || sid == target.AncestorPersonId || delSet.Contains(sid)) continue; + FamilyPersonEntity sp = oldPersons.FirstOrDefault(x => x.PersonId == sid); + if (sp == null) continue; + bool pure = string.IsNullOrEmpty(sp.BindUserId) + && !oldSpouses.Any(x => (x.PersonId == sid || x.SpouseId == sid) && x.PersonId != anchorPersonId && x.SpouseId != anchorPersonId) + && !oldChildren.Any(x => x.PersonId == sid || x.ChildrenId == sid); + if (pure) delSpousePersons.Add(sid); + } + FamilyTreeSpouseMapEntity[] delSpouses = oldSpouses.Where(x => delSet.Contains(x.PersonId) || delSet.Contains(x.SpouseId) || x.PersonId == anchorPersonId || x.SpouseId == anchorPersonId).ToArray(); + FamilyTreeChildrenMapEntity[] delChildren = oldChildren.Where(x => delSet.Contains(x.ChildrenId)).ToArray(); + FamilyPersonEntity[] delPersons = oldPersons.Where(x => delSet.Contains(x.PersonId) || delSpousePersons.Contains(x.PersonId)).ToArray(); + if (delSpouses.Length > 0) repositorySpouse.DeleteNow(delSpouses); + if (delChildren.Length > 0) repositoryChild.DeleteNow(delChildren); + foreach (FamilyPersonEntity old in delPersons) repositoryPerson.DeleteNow(old); + + // 3) 插入集:文件侧同口径闭包(上溯段落不插,P4)+ 文件锚点的配偶(方案 C 扩展,与目标侧删除对称);文件锚点映射到保留的目标锚点,其余重编号 + HashSet insSet = BranchClosure(data.AnchorPersonId, data.ChildrenMaps, data.SpouseMaps); + foreach (string fsid in (data.SpouseMaps ?? []).Where(x => x.PersonId == data.AnchorPersonId || x.SpouseId == data.AnchorPersonId) + .Select(x => x.PersonId == data.AnchorPersonId ? x.SpouseId : x.PersonId).Distinct()) + { + if (fsid != data.AnchorPersonId && !insSet.Contains(fsid) && data.Persons.Any(p => p.PersonId == fsid)) insSet.Add(fsid); + } + ISecurityGeneratorNumberService generator = service.ServiceProvider.GetService()!; + Dictionary idMap = new Dictionary { [data.AnchorPersonId] = anchorPersonId }; + foreach (FamilyPersonEntity p in data.Persons) + { + if (!insSet.Contains(p.PersonId) || idMap.ContainsKey(p.PersonId)) continue; + idMap[p.PersonId] = generator.GetNo(MasterPrefixPolicy.FAMILY_PERSON, MasterPrefixPolicy.FAMILY_PERSON_PERSON_ID, MasterPrefixPolicy.FAMILY_PERSON_PREFIX, YesNoPolicy.NO, MasterPrefixPolicy.FAMILY_PERSON_FORMAT); + } + foreach (FamilyPersonEntity p in data.Persons) + { + if (!insSet.Contains(p.PersonId)) continue; // 锚点以外的文件人(上溯段)一律不插 + FamilyPersonEntity fresh = p.Clone(); + fresh.PersonId = idMap[p.PersonId]; + fresh.TreeId = targetTreeId; + fresh.FatherId = string.IsNullOrEmpty(p.FatherId) ? null : (idMap.ContainsKey(p.FatherId) ? idMap[p.FatherId] : null); + fresh.BindUserId = null; + fresh.IsBindUser = null; + fresh.CreatedBy = service.UserId; + fresh.CreatedDatetime = now; + fresh.UpdatedBy = service.UserId; + fresh.UpdatedDatetime = now; + repositoryPerson.InsertNow(fresh); + } + + // 4) 关系行双端 remap(文件锚点端直接对接保留的目标锚点);引用集合外人的行丢弃防悬挂 + foreach (FamilyTreeSpouseMapEntity m in data.SpouseMaps ?? []) + { + if (!idMap.ContainsKey(m.PersonId) || !idMap.ContainsKey(m.SpouseId)) continue; + if (m.PersonId == data.AnchorPersonId && m.SpouseId == data.AnchorPersonId) continue; + FamilyTreeSpouseMapEntity fresh = m.Clone(); + fresh.TreeId = targetTreeId; + fresh.PersonId = idMap[m.PersonId]; + fresh.SpouseId = idMap[m.SpouseId]; + repositorySpouse.InsertNow(fresh); + } + foreach (FamilyTreeChildrenMapEntity m in data.ChildrenMaps ?? []) + { + if (!idMap.ContainsKey(m.PersonId) || !idMap.ContainsKey(m.ChildrenId)) continue; + FamilyTreeChildrenMapEntity fresh = m.Clone(); + fresh.TreeId = targetTreeId; + fresh.PersonId = idMap[m.PersonId]; + fresh.ChildrenId = idMap[m.ChildrenId]; + repositoryChild.InsertNow(fresh); + } + + // 4.5) 权限行迁移(用户拍板:按新旧编号关联保留绑定权限):被删后代/入配配偶的绑定成员随人换绑到新号, + // 关联不上的悬挂行删除;锚点行(id 未变)与枝外/管理员行不动 + string permStat = MigrateSubTreePermissions(targetTreeId, new HashSet(delPersons.Select(x => x.PersonId)), idMap); + + // 5) 审计 MERGE(P7 独立动作码,统计口径与整谱替换的 IN 分开):Message 记合并报告;人数含锚点本人(方案 C 同步覆盖) + WriteExchangeLog("MERGE", targetTreeId, FamilyTreeExchangeScope.SUB, anchorPersonId, backupName, insSet.Count + 1, "OK", $"backup={backupName}; deleted={delPersons.Length}; inserted={insSet.Count}; anchor=1; anchorSpouse={delSpousePersons.Count}; perm {permStat}"); + return insSet.Count + 1; + } + + /// + /// 备份列表(R1 收紧:备份是谁的操作谁的回滚——成员只见自己创建的,管理员不限): + /// 只列本谱 BK 文件,R5 保留窗口(一年)内,按备份时刻降序;时刻以文件名为准,归属看 JSON 里的 ExportedBy。 + /// + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Find)] + public FamilyTreeExchangeFileInfo[] ListBackups(string treeId) + { + if (string.IsNullOrEmpty(treeId)) throw new ValidatedException("请先选择族谱."); + FamilyTreePermissionHelper helper = new FamilyTreePermissionHelper(service); + if (!helper.IsMember(treeId)) throw new ValidatedException("您不是该族谱成员."); + bool isManager = helper.IsManager(treeId); + + string dir = FamilyTreeExchangePolicy.GetBackupDirectory(service); + if (!Directory.Exists(dir)) return []; + + List files = new List(); + DateTime earliest = DateTime.Now.AddYears(-FamilyTreeExchangePolicy.BACKUP_KEEP_YEARS); + foreach (string path in Directory.GetFiles(dir, $"BK{treeId}_*.json")) + { + string name = Path.GetFileName(path); + var parsed = FamilyTreeExchangePolicy.ParseBackupFileName(name); + // 谱号不符(大小写变体防御)、时刻超出保留窗口、文件名解析失败的一律不列 + if (parsed == null || parsed.Item1 != treeId || parsed.Item2 < earliest) continue; + // 非管理员只列自己创建的备份(读 JSON 取 ExportedBy,大小写不敏感;解析不出归属的旧文件对成员隐藏、管理员可见) + if (!isManager && ReadBackupOwner(path) != service.UserId) continue; + files.Add(new FamilyTreeExchangeFileInfo + { + FileName = name, + Size = new FileInfo(path).Length, + LastModified = parsed.Item2, + Scope = "BK", + TreeId = treeId, + AnchorPersonId = "", + }); + } + return files.OrderByDescending(x => x.LastModified).ToArray(); + } + + /// 从备份 JSON 轻量提取创建人(ExportedBy);密文先解密再读,读不到/损坏返 null + private static string ReadBackupOwner(string path) + { + try + { + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(FamilyTreeExchangePolicy.DecryptBackup(File.ReadAllText(path))); + foreach (System.Text.Json.JsonProperty p in doc.RootElement.EnumerateObject()) + if (string.Equals(p.Name, "ExportedBy", StringComparison.OrdinalIgnoreCase)) return p.Value.GetString(); + } + catch { } + return null; + } + + /// + /// 备份还原(R2:导小系/导全系出事,用替换前自动备份整谱回放退回): + /// R1 成员也可还原;R3 还原前先把现谱再备份一份(还原错了还能退回去); + /// R6 文件名严格 BK 格式且谱号必须等于目标谱(防路径穿越与跨谱灌数据)。 + /// 还原走整谱替换本体:全员重编号、绑定清除,与整谱导入同副作用。 + /// + [PermissionOperate(name: MasterPermissionPolicy.FamilyTree, operate: Operate.Update)] + [UnitOfWork] + public int RestoreBackup(string targetTreeId, string backupFileName) + { + FamilyTreeEntity target = repository.DetachedEntities.Where(x => x.TreeId == targetTreeId).FirstOrDefault(); + if (target == null) throw new ValidatedException("目标族谱不存在."); + if (!new FamilyTreePermissionHelper(service).IsMember(targetTreeId)) throw new ValidatedException("您没有还原权限."); + + // R6:格式闸(含目录成分拒绝)+ 归属闸(只能还原本谱的备份)+ R5 保留窗口闸 + var parsed = FamilyTreeExchangePolicy.ParseBackupFileName(backupFileName ?? ""); + if (parsed == null) throw new ValidatedException("备份文件名不合法."); + if (parsed.Item1 != targetTreeId) throw new ValidatedException("备份文件不属于本族谱,不能跨谱还原."); + if (parsed.Item2 < DateTime.Now.AddYears(-FamilyTreeExchangePolicy.BACKUP_KEEP_YEARS)) throw new ValidatedException("备份已超出保留窗口(一年),不允许还原."); + + string path = Path.Combine(FamilyTreeExchangePolicy.GetBackupDirectory(service), backupFileName); + if (!File.Exists(path)) throw new ValidatedException("备份文件不存在或已被清理."); + FamilyTreeExchangeData data; + try + { + // 备份密文先解密(旧明文备份无 ENC: 前缀直接透传);直读需大小写不敏感,实体属性对不上时会静默空值,故下面验内容完整性 + data = System.Text.Json.JsonSerializer.Deserialize(FamilyTreeExchangePolicy.DecryptBackup(File.ReadAllText(path)), + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + throw new ValidatedException($"备份文件已损坏,无法解析: {ex.Message}"); + } + if (data == null || data.Persons == null || data.Persons.Length == 0 || data.FamilyTree == null) + throw new ValidatedException("备份内容不完整,禁止还原."); + // R1 收紧:成员只能还原自己创建的备份(防绕过列表直调接口);管理员不限 + if (!new FamilyTreePermissionHelper(service).IsManager(targetTreeId) && data.ExportedBy != service.UserId) + throw new ValidatedException("该备份由其他用户创建,成员只能还原自己的备份."); + + return ReplaceTreeCore(target, data, "RESTORE"); + } + + /// D7 审计:写 family_tree_file_log;失败不阻断主流程(日志丢了可以补,导不出去的体验才是大事) + private void WriteExchangeLog(string action, string treeId, string scope, string anchorPersonId, string fileName, int personCount, string result, string message) + { + try + { + IRepository repositoryLog = repository.GetRepository>(); + DateTime now = DateTime.Now; + repositoryLog.InsertNow(new FamilyTreeFileLogEntity + { + CreatedDatetime = now, + CreatedBy = service.UserId, + UpdatedDatetime = now, + UpdatedBy = service.UserId, + TreeId = treeId, + Action = action, + Scope = scope, + AnchorPersonId = string.IsNullOrEmpty(anchorPersonId) ? null : anchorPersonId, + FileName = fileName, + PersonCount = personCount, + Result = result, + Message = message, + }); + } + catch (Exception ex) { Console.WriteLine($"[FamilyTreeExchange] 审计写入失败: {ex.Message}"); } + } } } diff --git a/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeUtility/FamilyTreeExchangePolicy.cs b/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeUtility/FamilyTreeExchangePolicy.cs new file mode 100644 index 0000000..05b248c --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master.Service/FamilyTreeUtility/FamilyTreeExchangePolicy.cs @@ -0,0 +1,122 @@ +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); + } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/FamilyTreeExchangeData.cs b/CloudBuilder/CloudBuilder.Master/Data/FamilyTreeExchangeData.cs new file mode 100644 index 0000000..6d5b4e4 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/FamilyTreeExchangeData.cs @@ -0,0 +1,62 @@ +using CloudBuilder.Master.Entity; +using System; + +namespace CloudBuilder.Master.Data +{ + /// + /// 族谱全系/小系导出的明文数据(按 D1-D8 决策): + /// 后端按权限闸裁剪后下发,前端用「用户密码⊕系统因子」派生密钥加密成 .fm 信封,落盘/上传交换目录 + /// + public class FamilyTreeExchangeData + { + /// ALL=全系 / SUB=小系 + public string Scope { get; set; } = FamilyTreeExchangeScope.ALL; + + public FamilyTreeEntity FamilyTree { get; set; } + + public FamilyPersonEntity[] Persons { get; set; } = []; + + public FamilyTreeSpouseMapEntity[] SpouseMaps { get; set; } = []; + + public FamilyTreeChildrenMapEntity[] ChildrenMaps { get; set; } = []; + + /// + /// 本谱权限行快照(仅备份 BK 写入,用于还原时权限随谱回滚形成绝对闭环; + /// .fm 交换文件恒为空——防成员账号名随导出文件外泄,也防外部文件携权限行劫持本谱) + /// + public FamilyTreePermissionEntity[] Permissions { get; set; } = []; + + public int PersonCount => Persons?.Length ?? 0; + + /// SUB 导出时的锚点人号(二期支系替换导入靠它验明同一人;ALL 为空) + public string AnchorPersonId { get; set; } = ""; + + /// 导出发起人(service.UserId) + public string ExportedBy { get; set; } = ""; + + public DateTime ExportedAt { get; set; } = DateTime.Now; + } + + /// 交换目录下 .fm 密文文件的列表信息(文件名按 D1 规则反解析出 scope/treeId/锚点) + public class FamilyTreeExchangeFileInfo + { + public string FileName { get; set; } = ""; + + public long Size { get; set; } + + public DateTime LastModified { get; set; } + + public string Scope { get; set; } = ""; + + public string TreeId { get; set; } = ""; + + public string AnchorPersonId { get; set; } = ""; + } + + /// 导出范围常量(后端无 CloudBuilder.Master.Service 引用,DTO 与 Policy 各留一份避免工程反向依赖) + public static class FamilyTreeExchangeScope + { + public const string ALL = "ALL"; + public const string SUB = "SUB"; + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/StorageUsageData.cs b/CloudBuilder/CloudBuilder.Master/Data/StorageUsageData.cs new file mode 100644 index 0000000..b800b82 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/StorageUsageData.cs @@ -0,0 +1,33 @@ +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.Service; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Master.Data +{ + /// + /// dashboard 网盘用量卡片数据:当前登录用户的类型、头像、已用/总额度(KB)、百分比。 + /// 用量取自 dms_file_usage_view(ower_id=本人 且非目录),额度按 user_type 从 appsettings:NetDiskQuota 解析。 + /// + [TypeScript] + public class StorageUsageData + { + /// 用户类型码:U=未认证 / N=普通 / V=VIP / S=SVIP(名称由前端 i18n 映射) + public string UserType { get; set; } = ""; + + /// 头像地址(security_user.user_icon_img_path,当前无上传链路多为空,前端占位兜底) + public string AvatarUrl { get; set; } = ""; + + /// 已用容量(KB) + public long UsedKB { get; set; } + + /// 额度总量(KB),0 表示未配置/不限 + public long QuotaKB { get; set; } + + /// 已用百分比(额度为 0 时记 0;可大于 100 表示超额) + public int Percent { get; set; } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs index 694623c..b63ecad 100644 --- a/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs +++ b/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs @@ -114,7 +114,10 @@ builder.Entity(eb => { eb.HasNoKey(); }); -//***END*** + + +builder.Entity(eb => { eb.HasNoKey(); }); + //***END*** } } } diff --git a/CloudBuilder/CloudBuilder.Master/Entity/DmsFileUsageViewEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/DmsFileUsageViewEntity.auto.cs new file mode 100644 index 0000000..44a02c7 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Entity/DmsFileUsageViewEntity.auto.cs @@ -0,0 +1,153 @@ + +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.DatabaseAccessor.Entity; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +namespace CloudBuilder.Master.Entity + { + [TypeScript] + [Table("dms_file_usage_view")] + public class DmsFileUsageViewEntity : ViewEntityBase + { + + public DmsFileUsageViewEntity() + { + } + + [Column("ower_id")] + public string OwerId { get; set; } + [Column("file_count")] + public long FileCount { get; set; } + [Column("used_kb")] + public long UsedKb { get; set; } + + public const string OWER_ID = "OwerId"; + public const string FILE_COUNT = "FileCount"; + public const string USED_KB = "UsedKb"; + + public const string DB_NAME_DMS_FILE_USAGE_VIEW = "dms_file_usage_view"; + public const string DB_NAME_FIELDS="ower_id,file_count,used_kb"; + + public const string DB_OWER_ID = "ower_id"; + public const string DB_FILE_COUNT = "file_count"; + public const string DB_USED_KB = "used_kb"; + + private string ProviderName { get; set; } + + public static DmsFileUsageViewEntity GetInstance(string providerName) + { + return new DmsFileUsageViewEntity() { ProviderName = providerName }; + } + + public virtual void CopyFrom(DmsFileUsageViewEntity source) + { + CopyFrom(source, true); + } + + public virtual void CopyFrom(DmsFileUsageViewEntity source, bool includeSystemFields) + { + if (source == null) + { + throw new NullReferenceException("Source entity is null."); + } + + this.OwerId = source.OwerId; + this.FileCount = source.FileCount; + this.UsedKb = source.UsedKb; + + if (includeSystemFields) + { + } + } + + public override string GetSelectFrom() + { + if (ProviderName.Contains("MySql")) + { + return GetSelectFromByMySql(); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectFromByBySqlServer(); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + public string GetSelectFromByMySql() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_DMS_FILE_USAGE_VIEW} with(nolock) "; + } + public string GetSelectFromByBySqlServer() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_DMS_FILE_USAGE_VIEW} "; + } + + public override string GetSelectCountFrom(string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectCountFromByMySql(sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectCountFromBySqlServer(sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectCountFromByMySql(string sqlWhere) + { + return $" select count(*) from { DB_NAME_DMS_FILE_USAGE_VIEW} where 1=1 {sqlWhere}"; + } + public string GetSelectCountFromBySqlServer(string sqlWhere) + { + return $" select count(*) from { DB_NAME_DMS_FILE_USAGE_VIEW} with(nolock) where 1=1 {sqlWhere}"; + } + + public override string GetSelectTopFrom(int limit, int start, string orderby, string orderbytag, string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectTopFromByMySql(limit, start, orderby, orderbytag, sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectTopFromBySqlServer(limit, start, orderby, orderbytag, sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectTopFromByMySql(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_OWER_ID ; int offset = limit * (start - 1); + return $" select {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_DMS_FILE_USAGE_VIEW} where 1=1 {sqlWhere} ) a where num> {offset} LIMIT {limit} "; + } + + public string GetSelectTopFromBySqlServer(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_OWER_ID ; string top = limit > 0 ? $"top {limit}" : ""; + return $" select {top} {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_DMS_FILE_USAGE_VIEW} with(nolock) where 1=1 {sqlWhere} ) a where num> {limit * (start - 1)}"; + } + + public DmsFileUsageViewEntity Clone() + { + DmsFileUsageViewEntity result = new DmsFileUsageViewEntity(); + result.CopyFrom(this); + + return result; + } + + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Entity/FamilyTreeFileLogEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/FamilyTreeFileLogEntity.auto.cs new file mode 100644 index 0000000..58aeb33 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Entity/FamilyTreeFileLogEntity.auto.cs @@ -0,0 +1,209 @@ + +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.DatabaseAccessor.Entity; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +namespace CloudBuilder.Master.Entity + { + [TypeScript] + [Table("family_tree_file_log")] + public class FamilyTreeFileLogEntity : ViewEntityBase + { + + public FamilyTreeFileLogEntity() + { + } + + [Column("created_datetime")] + public DateTime CreatedDatetime { get; set; } + [Column("created_by")] + public string CreatedBy { get; set; } + [Column("updated_datetime")] + public DateTime UpdatedDatetime { get; set; } + [Column("updated_by")] + public string UpdatedBy { get; set; } + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("log_id")] + public int LogId { get; set; } + [Column("tree_id")] + public string TreeId { get; set; } + [Column("action")] + public string Action { get; set; } + [Column("scope")] + public string Scope { get; set; } + [Column("anchor_person_id")] + public string? AnchorPersonId { get; set; } + [Column("file_name")] + public string? FileName { get; set; } + [Column("person_count")] + public int PersonCount { get; set; } + [Column("result")] + public string Result { get; set; } + [Column("message")] + public string? Message { get; set; } + + public const string CREATED_DATETIME = "CreatedDatetime"; + public const string CREATED_BY = "CreatedBy"; + public const string UPDATED_DATETIME = "UpdatedDatetime"; + public const string UPDATED_BY = "UpdatedBy"; + public const string LOG_ID = "LogId"; + public const string TREE_ID = "TreeId"; + public const string ACTION = "Action"; + public const string SCOPE = "Scope"; + public const string ANCHOR_PERSON_ID = "AnchorPersonId"; + public const string FILE_NAME = "FileName"; + public const string PERSON_COUNT = "PersonCount"; + public const string RESULT = "Result"; + public const string MESSAGE = "Message"; + + public const string DB_NAME_FAMILY_TREE_FILE_LOG = "family_tree_file_log"; + public const string DB_NAME_FIELDS="created_datetime,created_by,updated_datetime,updated_by,log_id,tree_id,action,scope,anchor_person_id,file_name,person_count,result,message"; + + public const string DB_CREATED_DATETIME = "created_datetime"; + public const string DB_CREATED_BY = "created_by"; + public const string DB_UPDATED_DATETIME = "updated_datetime"; + public const string DB_UPDATED_BY = "updated_by"; + public const string DB_LOG_ID = "log_id"; + public const string DB_TREE_ID = "tree_id"; + public const string DB_ACTION = "action"; + public const string DB_SCOPE = "scope"; + public const string DB_ANCHOR_PERSON_ID = "anchor_person_id"; + public const string DB_FILE_NAME = "file_name"; + public const string DB_PERSON_COUNT = "person_count"; + public const string DB_RESULT = "result"; + public const string DB_MESSAGE = "message"; + + private string ProviderName { get; set; } + + public static FamilyTreeFileLogEntity GetInstance(string providerName) + { + return new FamilyTreeFileLogEntity() { ProviderName = providerName }; + } + + public virtual void CopyFrom(FamilyTreeFileLogEntity source) + { + CopyFrom(source, true); + } + + public virtual void CopyFrom(FamilyTreeFileLogEntity source, bool includeSystemFields) + { + if (source == null) + { + throw new NullReferenceException("Source entity is null."); + } + + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + this.LogId = source.LogId; + this.TreeId = source.TreeId; + this.Action = source.Action; + this.Scope = source.Scope; + this.AnchorPersonId = source.AnchorPersonId; + this.FileName = source.FileName; + this.PersonCount = source.PersonCount; + this.Result = source.Result; + this.Message = source.Message; + + if (includeSystemFields) + { + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + } + } + + public override string GetSelectFrom() + { + if (ProviderName.Contains("MySql")) + { + return GetSelectFromByMySql(); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectFromByBySqlServer(); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + public string GetSelectFromByMySql() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_FAMILY_TREE_FILE_LOG} with(nolock) "; + } + public string GetSelectFromByBySqlServer() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_FAMILY_TREE_FILE_LOG} "; + } + + public override string GetSelectCountFrom(string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectCountFromByMySql(sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectCountFromBySqlServer(sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectCountFromByMySql(string sqlWhere) + { + return $" select count(*) from { DB_NAME_FAMILY_TREE_FILE_LOG} where 1=1 {sqlWhere}"; + } + public string GetSelectCountFromBySqlServer(string sqlWhere) + { + return $" select count(*) from { DB_NAME_FAMILY_TREE_FILE_LOG} with(nolock) where 1=1 {sqlWhere}"; + } + + public override string GetSelectTopFrom(int limit, int start, string orderby, string orderbytag, string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectTopFromByMySql(limit, start, orderby, orderbytag, sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectTopFromBySqlServer(limit, start, orderby, orderbytag, sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectTopFromByMySql(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_LOG_ID ; + int offset = limit * (start - 1); + return $" select {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_FAMILY_TREE_FILE_LOG} where 1=1 {sqlWhere} ) a where num> {offset} LIMIT {limit} "; + } + + public string GetSelectTopFromBySqlServer(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_LOG_ID ; + string top = limit > 0 ? $"top {limit}" : ""; + return $" select {top} {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_FAMILY_TREE_FILE_LOG} with(nolock) where 1=1 {sqlWhere} ) a where num> {limit * (start - 1)}"; + } + + public FamilyTreeFileLogEntity Clone() + { + FamilyTreeFileLogEntity result = new FamilyTreeFileLogEntity(); + result.CopyFrom(this); + + return result; + } + + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs b/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs new file mode 100644 index 0000000..3173d85 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs @@ -0,0 +1,22 @@ +using System; + +namespace CloudBuilder.Master.Policy +{ + /// + /// 分片上传落盘目录名统一管理。物理文件落在 {UploadFileDirectory}/{folderName}/{扩展名}/, + /// 末片合并后拷贝到 {BackupFileDirectory}/{folderName}/{扩展名}/;后端拼头像访问地址 + /// (FileTypePolicy.PackAvatarUrl)用的目录名必须与前端 multipart 的 FilePath 头完全一致, + /// 故把这些散落在各服务的字符串收敛到此处,作为前后端共同对齐的单一真源。 + /// + public static class UploadFolderPolicy + { + /// 族谱成员头像目录(family_person.avatar_filename 对应) + public const string FAMILY_PERSON_AVATAR = "FamilyPersonAvatar"; + + /// 登录用户(我的)头像目录(security_user.user_icon_img_path 对应) + public const string USER_AVATAR = "UserAvatar"; + + /// 身份证照片目录(与前端身份证上传 FilePath 头一致,小写历史沿用) + public const string ID_CARD = "idcard"; + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Service/IDmsFileService.auto.cs b/CloudBuilder/CloudBuilder.Master/Service/IDmsFileService.auto.cs index 9be752a..db8899b 100644 --- a/CloudBuilder/CloudBuilder.Master/Service/IDmsFileService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master/Service/IDmsFileService.auto.cs @@ -33,6 +33,12 @@ namespace CloudBuilder.Master.Service void UploadFile(); void UploadDmsFile(); FamilyPersonDisplayData UploadIdCardFile(); + + /// 当前登录用户的网盘用量汇总(类型/头像/已用/额度/百分比),供 dashboard 卡片。 + StorageUsageData GetMyStorageUsage(); + + /// 上传并保存当前登录用户头像(multipart 单图),落盘 UserAvatar 目录并回写 security_user.user_icon_img_path,返回可访问 URL。 + string UploadUserAvatar(); string FindM3u8Path(long fileId); string FindSourceVideoPath(long fileId); diff --git a/CloudBuilder/CloudBuilder.Master/Service/IFamilyTreeService.auto.cs b/CloudBuilder/CloudBuilder.Master/Service/IFamilyTreeService.auto.cs index c3a4d43..33d61e5 100644 --- a/CloudBuilder/CloudBuilder.Master/Service/IFamilyTreeService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master/Service/IFamilyTreeService.auto.cs @@ -21,5 +21,18 @@ namespace CloudBuilder.Master.Service FamilyTreeData BindSelfPerson(string treeId, string personId); FamilyTreeData UnbindSelfPerson(string treeId); + // 族谱全系/小系 .fm 导入导出(D1-D8):Export 出明文数据由前端加密,Save/List/Read 走交换目录 + string GetExchangeFactor(); + FamilyTreeExchangeData ExportFamilyTree(string treeId, string scope, string anchorPersonId); + string SaveExchangeFile(string fileName, string contentBase64); + FamilyTreeExchangeFileInfo[] ListExchangeFiles(string treeId); + string ReadExchangeFile(string fileName); + int ImportFamilyTree(string targetTreeId, FamilyTreeExchangeData data); + + int ImportSubTree(string targetTreeId, string anchorPersonId, FamilyTreeExchangeData data); + + FamilyTreeExchangeFileInfo[] ListBackups(string treeId); + int RestoreBackup(string targetTreeId, string backupFileName); + } } diff --git a/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs index 303b508..abecc59 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs @@ -127,6 +127,14 @@ return Task.Run(() => serviceRequest.PostAsJsonAsync("/DmsFile/FindSou { return Task.Run(() => serviceRequest.PostAsJsonAsync("/DmsFile/FindDeleteLogM3u8Path?fileId=" + fileId.ToString())).GetAwaiter().GetResult(); } + public StorageUsageData GetMyStorageUsage() + { +return Task.Run(() => serviceRequest.PostAsJsonAsync ("/DmsFile/GetMyStorageUsage")).GetAwaiter().GetResult(); } + + public string UploadUserAvatar() + { +return Task.Run(() => serviceRequest.PostAsJsonAsync ("/DmsFile/UploadUserAvatar")).GetAwaiter().GetResult(); } + public dynamic DownloadFile(Int64 fileId) { return Task.Run(() => serviceRequest.PostAsJsonAsync("/DmsFile/DownloadFile?fileId=" + fileId.ToString())).GetAwaiter().GetResult(); } diff --git a/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs index bda491b..fcc4e2d 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs @@ -108,6 +108,74 @@ return Task.Run(() => serviceRequest.PostAsJsonAsync serviceRequest.PostAsJsonAsync("/FamilyTree/Deletes" , keys)).GetAwaiter().GetResult(); } + public string GetExchangeFactor() + { +return Task.Run(() => serviceRequest.PostAsJsonAsync ("/FamilyTree/GetExchangeFactor")).GetAwaiter().GetResult(); } + + + public class ExportFamilyTreeCompositionData3 { + public string treeId { get; set; } + public string scope { get; set; } + public string anchorPersonId { get; set; } + + } + public FamilyTreeExchangeData ExportFamilyTree(string treeId, string scope, string anchorPersonId) + { +var compositionData = new ExportFamilyTreeCompositionData3{ treeId = treeId, scope = scope, anchorPersonId = anchorPersonId }; return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/ExportFamilyTree", compositionData)).GetAwaiter().GetResult(); } + + + public class SaveExchangeFileCompositionData2 { + public string fileName { get; set; } + public string contentBase64 { get; set; } + + } + public string SaveExchangeFile(string fileName, string contentBase64) + { +var compositionData = new SaveExchangeFileCompositionData2{ fileName = fileName, contentBase64 = contentBase64 }; return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/SaveExchangeFile", compositionData)).GetAwaiter().GetResult(); } + + public FamilyTreeExchangeFileInfo[] ListExchangeFiles(string treeId) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/ListExchangeFiles?treeId=" + treeId.ToString())).GetAwaiter().GetResult(); } + + public string ReadExchangeFile(string fileName) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/ReadExchangeFile?fileName=" + fileName.ToString())).GetAwaiter().GetResult(); } + + + public class ImportFamilyTreeCompositionData2 { + public string targetTreeId { get; set; } + public FamilyTreeExchangeData data { get; set; } + + } + public Int32 ImportFamilyTree(string targetTreeId, FamilyTreeExchangeData data) + { +var compositionData = new ImportFamilyTreeCompositionData2{ targetTreeId = targetTreeId, data = data }; return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/ImportFamilyTree", compositionData)).GetAwaiter().GetResult(); } + + + public class ImportSubTreeCompositionData3 { + public string targetTreeId { get; set; } + public string anchorPersonId { get; set; } + public FamilyTreeExchangeData data { get; set; } + + } + public Int32 ImportSubTree(string targetTreeId, string anchorPersonId, FamilyTreeExchangeData data) + { +var compositionData = new ImportSubTreeCompositionData3{ targetTreeId = targetTreeId, anchorPersonId = anchorPersonId, data = data }; return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/ImportSubTree", compositionData)).GetAwaiter().GetResult(); } + + public FamilyTreeExchangeFileInfo[] ListBackups(string treeId) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/ListBackups?treeId=" + treeId.ToString())).GetAwaiter().GetResult(); } + + + public class RestoreBackupCompositionData2 { + public string targetTreeId { get; set; } + public string backupFileName { get; set; } + + } + public Int32 RestoreBackup(string targetTreeId, string backupFileName) + { +var compositionData = new RestoreBackupCompositionData2{ targetTreeId = targetTreeId, backupFileName = backupFileName }; return Task.Run(() => serviceRequest.PostAsJsonAsync("/FamilyTree/RestoreBackup", compositionData)).GetAwaiter().GetResult(); } + } } \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Security/Policy/SecurityUserTypePolicy.cs b/CloudBuilder/CloudBuilder.Security/Policy/SecurityUserTypePolicy.cs index 537b75c..4e7ec0b 100644 --- a/CloudBuilder/CloudBuilder.Security/Policy/SecurityUserTypePolicy.cs +++ b/CloudBuilder/CloudBuilder.Security/Policy/SecurityUserTypePolicy.cs @@ -11,10 +11,12 @@ namespace CloudBuilder.Security.Policy { public class SecurityUserTypePolicy : LocalePolicy, IPolicy { + // 用户类型码(security_user.user_type):未认证 / 普通用户 / VIP / SVIP + // 普通用户沿用历史 "N",存量普通用户零影响;旧审计码 I/S/K 已作废。 + public const string UNAUTHENTICATED_USERS = "U"; public const string NORMAL_USERS = "N"; - public const string ICTI_USERS = "I"; - public const string SEDEX_USERS = "S"; - public const string KOHLS_USERS = "K"; + public const string VIP_USERS = "V"; + public const string SVIP_USERS = "S"; private readonly IContext context; private readonly ILocaleMessageManager localeMessageManager; @@ -28,30 +30,24 @@ namespace CloudBuilder.Security.Policy } + public static bool IsUnauthenticated(string code) + { + return code == UNAUTHENTICATED_USERS; + } + public static bool IsNormalUser(string code) { return code == NORMAL_USERS; } - public static bool IsIctiUser(string code) + public static bool IsVipUser(string code) { - return code == ICTI_USERS; + return code == VIP_USERS; } - public static bool IsSedexUser(string code) + public static bool IsSvipUser(string code) { - return code == SEDEX_USERS; - } - - public static bool IsKohlsUser(string code) - { - return code == KOHLS_USERS; - } - - - public static bool IsAuditor(string code) - { - return IsIctiUser(code) || IsSedexUser(code) || IsKohlsUser(code); + return code == SVIP_USERS; } } } diff --git a/CloudBuilder/CloudBuilder.Security/Policy/ServiceSettingsPolicy.cs b/CloudBuilder/CloudBuilder.Security/Policy/ServiceSettingsPolicy.cs index cbe4114..38c0492 100644 --- a/CloudBuilder/CloudBuilder.Security/Policy/ServiceSettingsPolicy.cs +++ b/CloudBuilder/CloudBuilder.Security/Policy/ServiceSettingsPolicy.cs @@ -24,6 +24,36 @@ namespace CloudBuilder.Security.Policy public const string FfmpegPictrue = "Ffmpeg:Pictrue"; public const string FfmpegArguments = "Ffmpeg:Arguments"; + // 网盘额度(appsettings.json:NetDiskQuota),按 user_type 码取档 + public const string NetDiskQuotaSection = "NetDiskQuota"; + + /// + /// 取当前用户类型的网盘额度(KB)。类型码缺失/未知回退按未认证(U)最小权限档; + /// 未配置或解析不出数值时返回 0(调用方据 0 判定"不限额"还是"不可用")。 + /// + public static long GetNetDiskQuotaKB(IApplicationService service, string userType) + { + var code = string.IsNullOrWhiteSpace(userType) ? SecurityUserTypePolicy.UNAUTHENTICATED_USERS : userType.Trim().ToUpperInvariant(); + var raw = service.Configuration[$"{NetDiskQuotaSection}:{code}"]; + // 未知类型码回退未认证档,避免历史脏码绕过限额 + if (string.IsNullOrWhiteSpace(raw)) + raw = service.Configuration[$"{NetDiskQuotaSection}:{SecurityUserTypePolicy.UNAUTHENTICATED_USERS}"]; + return ParseSizeToKB(raw); + } + + /// 解析 "2G"/"100G"/"500G"/"1T"/"512M"/"2048K"(大小写兼容,缺省按 KB)为 KB。 + private static long ParseSizeToKB(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return 0; + raw = raw.Trim().ToUpperInvariant(); + long unit = 1; // KB + if (raw.EndsWith("T")) { unit = 1024L * 1024 * 1024; raw = raw.Substring(0, raw.Length - 1); } + else if (raw.EndsWith("G")) { unit = 1024L * 1024; raw = raw.Substring(0, raw.Length - 1); } + else if (raw.EndsWith("M")) { unit = 1024L; raw = raw.Substring(0, raw.Length - 1); } + else if (raw.EndsWith("K")) { unit = 1; raw = raw.Substring(0, raw.Length - 1); } + return long.TryParse(raw.Trim(), out var n) && n > 0 ? n * unit : 0; + } + // 身份证 OCR 识别服务(server-to-server 调用,地址来自 appsettings.json,避免写死) public const string IdCardOcrApiUrl = "IdCardOcr:ApiUrl"; public const string IdCardOcrApiKey = "IdCardOcr:ApiKey"; diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs index 3b2ff0a..4bf90cc 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs @@ -555,6 +555,47 @@ this.dmsFileService= dmsFileService; { return new ServiceErrorException(ex); } + } + + [PermissionOperate(name: "DmsFile", operate: "Find")] + [HttpPost] + public dynamic GetMyStorageUsage() + { + try + { + + var result = dmsFileService.GetMyStorageUsage(); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "DmsFile", operate: "Insert")] + [HttpPost] + [UnitOfWork] + public dynamic UploadUserAvatar() + { + try + { + + var result = dmsFileService.UploadUserAvatar(); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } } [PermissionOperate(name: "DmsFile", operate: "Find")] diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs index faf22de..57bb614 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs @@ -331,6 +331,226 @@ this.familyTreeService= familyTreeService; } } + [PermissionOperate(name: "FamilyTree", operate: "Find")] + [HttpPost] + public dynamic GetExchangeFactor() + { + try + { + + var result = familyTreeService.GetExchangeFactor(); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + + public class ExportFamilyTreeCompositionData + { + public string treeId { get; set; } + public string scope { get; set; } + public string anchorPersonId { get; set; } + + } + [PermissionOperate(name: "FamilyTree", operate: "Find")] + [HttpPost] + public dynamic ExportFamilyTree(ExportFamilyTreeCompositionData compositionData) + { + try + { + + var result = familyTreeService.ExportFamilyTree(compositionData.treeId,compositionData.scope,compositionData.anchorPersonId); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + + public class SaveExchangeFileCompositionData + { + public string fileName { get; set; } + public string contentBase64 { get; set; } + + } + [PermissionOperate(name: "FamilyTree", operate: "Find")] + [HttpPost] + public dynamic SaveExchangeFile(SaveExchangeFileCompositionData compositionData) + { + try + { + + var result = familyTreeService.SaveExchangeFile(compositionData.fileName,compositionData.contentBase64); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "FamilyTree", operate: "Find")] + [HttpPost] + public dynamic ListExchangeFiles(string treeId) + { + try + { + + var result = familyTreeService.ListExchangeFiles(treeId); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "FamilyTree", operate: "Find")] + [HttpPost] + public dynamic ReadExchangeFile(string fileName) + { + try + { + + var result = familyTreeService.ReadExchangeFile(fileName); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + + public class ImportFamilyTreeCompositionData + { + public string targetTreeId { get; set; } + public FamilyTreeExchangeData data { get; set; } + + } + [PermissionOperate(name: "FamilyTree", operate: "Update")] + [HttpPost] + [UnitOfWork] + public dynamic ImportFamilyTree(ImportFamilyTreeCompositionData compositionData) + { + try + { + + var result = familyTreeService.ImportFamilyTree(compositionData.targetTreeId,compositionData.data); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + + public class ImportSubTreeCompositionData + { + public string targetTreeId { get; set; } + public string anchorPersonId { get; set; } + public FamilyTreeExchangeData data { get; set; } + + } + [PermissionOperate(name: "FamilyTree", operate: "Update")] + [HttpPost] + [UnitOfWork] + public dynamic ImportSubTree(ImportSubTreeCompositionData compositionData) + { + try + { + + var result = familyTreeService.ImportSubTree(compositionData.targetTreeId,compositionData.anchorPersonId,compositionData.data); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "FamilyTree", operate: "Find")] + [HttpPost] + public dynamic ListBackups(string treeId) + { + try + { + + var result = familyTreeService.ListBackups(treeId); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + + public class RestoreBackupCompositionData + { + public string targetTreeId { get; set; } + public string backupFileName { get; set; } + + } + [PermissionOperate(name: "FamilyTree", operate: "Update")] + [HttpPost] + [UnitOfWork] + public dynamic RestoreBackup(RestoreBackupCompositionData compositionData) + { + try + { + + var result = familyTreeService.RestoreBackup(compositionData.targetTreeId,compositionData.backupFileName); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + } } \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json b/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json index 94e2113..bc50de4 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json +++ b/CloudBuilder/CloudBuilder.Web.Entry/appsettings.json @@ -88,6 +88,13 @@ "DescKey": "052636a8b1469efdf5dc0034a3c5bb34", "DefaultActor": "VIEW_SYSTEM" }, + "NetDiskQuota": { + "//": "网盘额度,按 security_user.user_type 取:U=未认证/N=普通用户/V=VIP/S=SVIP;值支持 T/G/M/K 单位(大小写兼容),解析后换算为 KB(dms_file.file_size 单位即 KB)", + "U": "2G", + "N": "100G", + "V": "500G", + "S": "1T" + }, "IpRateLimiting": { "EnableEndpointRateLimiting": true, //False: globally executed, true: executed for each "StackBlockedRequests": false, //False: Number of rejections should be recorded on another counter diff --git a/CloudBuilder/Data/create_table.sql b/CloudBuilder/Data/create_table.sql index 64a7e21..445faea 100644 --- a/CloudBuilder/Data/create_table.sql +++ b/CloudBuilder/Data/create_table.sql @@ -553,3 +553,16 @@ left join dms_folder_delete_log dd with(nolock) on dms.file_path=dd.folder_name GO +-- ============================================================================================== +-- [备案] dms_file_usage_view —— 按用户(ower_id)分组统计网盘已用容量(dashboard 用量卡片 / 上传额度闸门) +-- 真源在线上 MySQL(nas.youjiala.com:3307/Furion);上方为 SQL Server 建表脚本,此段仅归档留底,勿在此执行。 +-- 口径:ower_id=登录用户(=security_user.user_name),仅计非目录行(is_directory_indc='N'),file_size 单位 KB,SUM 转 SIGNED 防 int 溢出。 +-- MySQL DDL: +-- CREATE OR REPLACE VIEW dms_file_usage_view AS +-- SELECT ower_id, +-- COUNT(*) AS file_count, +-- CAST(SUM(CASE WHEN is_directory_indc='N' THEN file_size ELSE 0 END) AS SIGNED) AS used_kb +-- FROM dms_file GROUP BY ower_id; +-- ============================================================================================== + + diff --git a/uniapp/youjiala/src/admin/admin-types.ts b/uniapp/youjiala/src/admin/admin-types.ts index 028c1bc..88e729d 100644 --- a/uniapp/youjiala/src/admin/admin-types.ts +++ b/uniapp/youjiala/src/admin/admin-types.ts @@ -112,6 +112,7 @@ export const ADMIN_FORM_KIND = { TEXTAREA: "textarea", SWITCH: "switch", DATE: "date", + SELECT: "select", } as const; type FormKinds = typeof ADMIN_FORM_KIND; @@ -134,6 +135,8 @@ export interface AdminFormField { /** 只读模式按日期显示:true 取 yyyy-MM-dd,"datetime" 带时分秒 */ date?: boolean | "datetime"; maxlength?: number; + /** type 为 select 时的候选项(label 展示、value 落库) */ + options?: AdminFilterChoice[]; } /** 详情页的一张卡片(旧 ft-card 的等价物) */ diff --git a/uniapp/youjiala/src/common/userTypeIcons.ts b/uniapp/youjiala/src/common/userTypeIcons.ts new file mode 100644 index 0000000..7eb645e --- /dev/null +++ b/uniapp/youjiala/src/common/userTypeIcons.ts @@ -0,0 +1,74 @@ +/** + * 用户身份徽标字形:登录用户「未认证 / 普通用户 / VIP / SVIP」四类身份的小图标与配色。 + * uview 图标集无皇冠/盾牌等语义图形,故沿用 brand.ts 的内联 SVG data URI 套路(跨端安全)。 + * 图标统一白色描边/填充,压在各自配色的圆角胶囊上;普通用户跟随主题(--fg-gradient 兜底)。 + */ + +/** 未认证:盾牌 + 问号(灰) */ +const unauthGlyph = `data:image/svg+xml,${encodeURIComponent( + [ + "", + "", + "", + "", + "", + ].join(""), +)}`; + +/** 普通用户:人像 + 对勾(跟随主题绿) */ +const normalGlyph = `data:image/svg+xml,${encodeURIComponent( + [ + "", + "", + "", + "", + "", + ].join(""), +)}`; + +/** VIP:三尖皇冠 + 底座(金) */ +const vipGlyph = `data:image/svg+xml,${encodeURIComponent( + [ + "", + "", + "", + "", + ].join(""), +)}`; + +/** SVIP:三尖皇冠 + 顶部四角星(紫,比 VIP 多一颗星以示尊贵) */ +const svipGlyph = `data:image/svg+xml,${encodeURIComponent( + [ + "", + "", + "", + "", + "", + ].join(""), +)}`; + +/** UserType → 徽标字形 data URI(后端 UserType:U/N/V/S) */ +export const USER_TYPE_GLYPHS: Record = { + U: unauthGlyph, + N: normalGlyph, + V: vipGlyph, + S: svipGlyph, +}; + +/** UserType → 胶囊底色(身份卡为统一主题绿实底,四类胶囊统一用深色半透芯片 + 白字白图标,压住深底高对比) */ +export const USER_TYPE_PILL_BG: Record = { + U: "rgba(0, 0, 0, 0.16)", + N: "rgba(0, 0, 0, 0.16)", + V: "rgba(0, 0, 0, 0.16)", + S: "rgba(0, 0, 0, 0.16)", +}; + +/** 取字形 data URI,未知类型回退普通用户 */ +export function userTypeGlyph(type?: string): string { + return USER_TYPE_GLYPHS[type ?? ""] ?? normalGlyph; +} + +/** 取胶囊底色,未知类型回退普通用户 */ +export function userTypePillBg(type?: string): string { + return USER_TYPE_PILL_BG[type ?? ""] ?? USER_TYPE_PILL_BG.N; +} diff --git a/uniapp/youjiala/src/components/admin-form/admin-form.vue b/uniapp/youjiala/src/components/admin-form/admin-form.vue index e1c3201..59052db 100644 --- a/uniapp/youjiala/src/components/admin-form/admin-form.vue +++ b/uniapp/youjiala/src/components/admin-form/admin-form.vue @@ -72,6 +72,9 @@ function displayValue(field: AdminFormField): string { if (field.display) { return field.display(props.model) || empty; } + if (field.type === "select") { + return selectLabel(field) || empty; + } const raw = rawValue(field); if (raw === null || raw === undefined || raw === "") { return empty; @@ -82,6 +85,30 @@ function displayValue(field: AdminFormField): string { return String(raw); } +/** select 候选:无 options 时给空数组,避免模板 range 报错 */ +function selectOptions(field: AdminFormField) { + return field.options ?? []; +} + +/** select 当前值对应的展示文案(找不到返回空) */ +function selectLabel(field: AdminFormField): string { + const cur = String(rawValue(field) ?? ""); + return selectOptions(field).find(o => o.value === cur)?.label ?? ""; +} + +/** select 当前值在候选中的下标(找不到回退 0,picker 需合法 index) */ +function selectIndex(field: AdminFormField): number { + const cur = String(rawValue(field) ?? ""); + const i = selectOptions(field).findIndex(o => o.value === cur); + return i < 0 ? 0 : i; +} + +function onSelectChange(field: AdminFormField, event: any): void { + const idx = Number(event?.detail?.value ?? 0); + const opt = selectOptions(field)[idx]; + setInputValue(field, opt ? opt.value : ""); +} + /** 编辑态控件的值:统一成字符串,switch 例外(u-switch 只吃布尔) */ function inputValue(field: AdminFormField): string { const raw = rawValue(field); @@ -178,6 +205,16 @@ function onTapAction(action: AdminFormAction): void { + + + + {{ selectLabel(field) || field.placeholder || t('AdminPage.notSetTip') }} + + + + (); @@ -74,6 +80,117 @@ const inDetail = ref(false); /** 列表整体收起态:卡区缩成一条窄横条(伸缩钮在列表右缘) */ const listCollapsed = ref(false); +// ===== 顶部两张卡片:登录用户身份 + 网盘用量(列表态,静默拉取,失败不阻断族谱列表) ===== +const loginProfile = useLoginProfileStore(); +const displayName = computed(() => loginProfile.userLocalName || loginProfile.username || ""); +const storage = ref(new StorageUsageData()); +/** 当前用户类型:优先异步用量快照,未就绪时回退登录档案同步值,保证首帧徽标/文字即为真实等级 */ +const currentType = computed(() => storage.value.UserType || loginProfile.userType); +const userTypeText = computed(() => { + const map: Record = { + U: "storage.typeUnauth", + N: "storage.typeNormal", + V: "storage.typeVip", + S: "storage.typeSvip", + }; + return t(map[currentType.value] ?? "storage.typeNormal"); +}); +// 后端 file_size/额度均为 KB,formatFileSize 期望字节 → ×1024 对齐 +const storageUsedText = computed(() => formatFileSize(storage.value.UsedKB * 1024)); +const storageQuotaText = computed(() => storage.value.QuotaKB > 0 ? formatFileSize(storage.value.QuotaKB * 1024) : "-"); +const storageOver = computed(() => storage.value.QuotaKB > 0 && storage.value.UsedKB > storage.value.QuotaKB); +/** 占比在前端按小数重算(后端 Percent 为整数会截断成 0),极小用量也能如实显示 */ +const storagePercentNum = computed(() => (storage.value.QuotaKB > 0 ? (storage.value.UsedKB / storage.value.QuotaKB) * 100 : 0)); +const storagePercentText = computed(() => { + const v = storagePercentNum.value; + if (v <= 0) { + return "0%"; + } + if (v < 0.1) { + return "0.1%"; + } + if (v < 10) { + return `${v.toFixed(1)}%`; + } + return `${Math.round(v)}%`; +}); +/** 进度条宽度:按重算占比取区间;有用量但占比极小时给 2% 最小可见宽度,避免完全空条 */ +const storageBarWidth = computed(() => { + const clamped = Math.min(Math.max(storagePercentNum.value, 0), 100); + const min = storage.value.UsedKB > 0 ? 2 : 0; + return `${Math.max(clamped, min)}%`; +}); +/** 身份徽标字形与胶囊底色:随 UserType(U/N/V/S)切换,未认证灰、普通跟随主题、VIP 金、SVIP 紫 */ +const userTypeIcon = computed(() => userTypeGlyph(currentType.value)); +const userTypeBg = computed(() => userTypePillBg(currentType.value)); +/** 网盘存储卡图标:内联云形 SVG(同 treeGlyph 套路),绿描边置于柔光圆内(存储卡为浅色渐变描边底) */ +const cloudGlyph = `data:image/svg+xml,${encodeURIComponent([ + "", + "", + "", + "", +].join(""))}`; + +/** 未认证底部弹层:单击「未认证」徽标从底部弹出身份证认证,复用 useIdCardVerify(与身份验证页同一套流程) */ +const idPopup = ref(false); +const { + imageUrl: idvImage, + uploading: idvUploading, + isVerified: idvVerified, + buttonText: idvButtonText, + onUploadIdCard, +} = useIdCardVerify(() => { + idPopup.value = false; + void fetchStorage(); +}); +/** 仅未认证(U)可单击弹层去认证,其余身份为只读展示 */ +function onTapUserType(): void { + if (currentType.value === "U") { + idPopup.value = true; + } +} + +async function fetchStorage(): Promise { + try { + const res = await dmsFileService.GetMyStorageUsage(); + if (res) storage.value = res; + } + catch { /* 静默:卡片留占位,不打扰族谱列表主流程 */ } +} + +/** 点头像上传当前用户头像:单图 -> /DmsFile/UploadUserAvatar(后端落盘 UserAvatar 目录并回写 user_icon_img_path)-> 成功后重拉用量刷新卡片 */ +const avatarUploading = ref(false); +function onUploadAvatar(): void { + if (avatarUploading.value) { + return; + } + uni.chooseImage({ + count: 1, + success: async (res: any) => { + const filePath = String(res?.tempFilePaths?.[0] ?? ""); + const fileName = String(res?.tempFiles?.[0]?.name || `avatar-${Date.now()}.png`); + if (!filePath) { + return; + } + try { + avatarUploading.value = true; + await uploadFile({ + url: "/DmsFile/UploadUserAvatar", + filePath, + name: fileName, + fileName: buildChunkFileName(fileName), + }); + await fetchStorage(); + uni.showToast({ title: t("storage.avatarUploadedTip"), icon: "none" }); + } + catch { /* uploadFile 已统一弹错,这里只补一个失败落点 */ } + finally { + avatarUploading.value = false; + } + }, + }); +} + /** 关键字过滤:族谱名称/简介 前端包含匹配(列表量级小,不再走后端筛选链) */ const filteredTrees = computed(() => { const kw = keyword.value.trim().toLowerCase(); @@ -1366,18 +1483,67 @@ defineExpose({ reloadIfList(): void { if (!inDetail.value) { loadTrees(); + void fetchStorage(); } }, });