This commit is contained in:
owenchen committed 2026-10-20 11:11:55 +08:00
1 parent 27284040cc
commit 9b01c4a57a
11 files changed
+1827 -77

No files matched your search

@@ -0,0 +1,288 @@
# 族谱导入导出闭环功能归档
> 适用范围:优嘉拉(youjiala)族谱 `.fm` 交换文件的**导出 / 导入 / 备份 / 还原**全链路。
> 本文是该特性的单一事实来源(single source of truth),涵盖设计决策、数据结构、加密、权限、后端算法、前端编排、测试矩阵与已知边界。
> 涉及工程:后端 `CloudBuilder`(.NET 8 / Furion)、前端 `uniapp youjiala`(Vue3 + crypto-js)。
---
## 1. 决策沿革(一句话版)
| 期次 | 能力 | 关键决策代号 |
|---|---|---|
| 一期 | 全系 `.fm` 导出/导入、备份还原 | D1~D8、R1~R6 |
| 二期 | 小系(支系)导出 + 锚点整家替换导入(方案 C) | P1~P7 |
| 三期 | 权限行随号迁移/清理、备份含权限快照、备份密文落盘 | 绝对闭环 |
核心口径演进:
- **方案 C**:小系导入时锚点本人资料也整体覆盖,锚点配偶"纯入配"随删随插(整家替换)。
- **同谱 100% 完整性**:`treeId` 相同(全系备份还原、服务器同谱文件导入)时,人员编号可保留(用户拍板"编号可以变"→ 实际同谱保留原号、跨谱才重编号),资料/绑定/关系 100% 回放,权限行不悬挂。
- **绝对闭环**:权限行进备份 JSON,还原时随谱回滚;备份整份以服务端 AES 密文落盘。
---
## 2. 名词与数据结构
### 2.1 `FamilyTreeExchangeData`(明文 payload / 备份快照载体)
`CloudBuilder.Master/Data/FamilyTreeExchangeData.cs`
| 字段 | 含义 | 备注 |
|---|---|---|
| `Scope` | `ALL` / `SUB` | 交换范围常量 `FamilyTreeExchangeScope` |
| `FamilyTree` | 谱信息行(含**源 TreeId**) | **同谱判定依据**:`data.FamilyTree.TreeId == targetTreeId` |
| `Persons[]` | 人员实体 | 全系=全谱;小系=家支闭包子集 |
| `SpouseMaps[]` | 夫妻关系行 | 两端都须在集合内 |
| `ChildrenMaps[]` | 父子关系行 | 两端都须在集合内 |
| `Permissions[]` | **权限行快照** | **仅备份 BK 写入**;`.fm` 恒空——防账号名外泄、防外部文件携权限行劫持本谱 |
| `AnchorPersonId` | 小系锚点人号 | ALL 为空;二期起写入 payload |
| `ExportedBy` | 发起人 `service.UserId` | 备份归属隔离依据 |
| `ExportedAt` | 导出时刻 | |
| `PersonCount` | 计算属性 `Persons.Length` | |
### 2.2 权限真源
真正的人员—账号绑定关系存 `family_tree_permission`(复合主键 `PersonId + TreeId + UserName`)。`family_person` 表的 `BindUserId / IsBindUser` 是遗留字段(无业务写入,但同谱回放会原样保留)。
---
## 3. 权限体系
### 3.1 三档身份(`FamilyTreePermissionHelper`)
- **普通用户**:与本谱无生效权限行。
- **成员**:有 `ValidIndc=YES` 且绑定到自己族员的权限行。可小系导出/导入(限自家支锚点)、还原自己的备份。
- **管理员**:有 `ManagerIndc=YES` 行。可全系导出/整谱替换导入、看全部备份、任意锚点小系。
- 判定 `IsManager` 按 `UserName + ManagerIndc`,**不看 PersonId**,故重编号不影响管理员身份。
### 3.2 绑定三重闸(`BindSelfPerson`)
1. 登录已验 `UserIsValidIndc=YES` + 未锁定(`AuthService.Login`);
2. 本谱审批行 `ValidIndc=YES`;
3. 同名闸:族员「姓+谱名」或「常用名」== `UserLocalName`。
### 3.3 家支可编集合 `GetAmendablePersonIds`
口径(`PackPersonIdByChildrenMaps`):本人 + 全部后代 + 配偶 + **向上最多两代(父、祖)及每代配偶**。小系导出/导入的锚点闸门用它。
---
## 4. 加密体系(两套,互不混用)
### 4.1 `.fm` 交换文件信封(前端,用户密码派生)
`src/utils/familyTreeExchange.ts`
- 方案 A / D2:`密钥 = PBKDF2(用户密码 ⊕ 系统因子, 随机盐, 10000 轮, SHA256, 512bit)`;
- 前 256bit → AES-256-CBC(Pkcs7),后 256bit → HMAC-SHA256,**encrypt-then-MAC**;
- MAC 覆盖 `salt+iv+ct`,先验完整性再解密(无 padding oracle);
- 盐与 IV 每次随机(同谱同密码两次导出密文不同);
- 系统因子 `YouJiaLa.FamilyTree.Exchange.v1` 由后端 `GetExchangeFactor` 下发(不进前端包体);
- 信封 JSON:`{V:"FM1", Iter, Salt, Iv, Ct, Mac}`(均 base64);
- **失败不区分**"密码错/文件坏",统一报 `exchange-file-bad`(不给攻击者预言机)。
### 4.2 `BK` 备份文件密文(后端,服务端密钥)
`CloudBuilder.Master.Service/FamilyTreeUtility/FamilyTreeExchangePolicy.cs`
- 备份含权限行(账号名隐私),整份用 `EncryptHelper.AesEncrypt`(AES-128-CBC,内置 Key/IV)落盘;
- 文本前缀魔数 `ENC:` 区分新老格式:**无此前缀=旧明文备份,直接透传**(向后兼容);
- 读取端 `DecryptBackup` / `ReadBackupOwner` / `RestoreBackup` 均先解密再解析;
- ⚠️ 编译陷阱:`DecryptBackup` 须写 `EncryptHelper.AesDecrpt(...)` 静态调用,`.AesDecrpt()` 零参扩展名不匹配(`EncryptHelper` 只有 `(string)` 静态与 `(string,string,string)` 扩展两个重载)。
- 因备份由后端自动生成、后端自回读(不经前端密码),故用服务端密钥而非 `.fm` 的用户密码派生方案。
---
## 5. 文件命名与目录
| 类型 | 规则 | 校验正则 |
|---|---|---|
| 全系 `.fm` | `A{treeId}_{yyyyMMddHHmmss}.fm` | `^AT\w+_\d{14}\.fm$` |
| 小系 `.fm` | `P{treeId}-{personId}_{ts}.fm` | `^PT\w+-P\w+_\d{14}\.fm$` |
| 备份 BK | `BK{treeId}_{ts}.json` | `^BKT\w+_\d{14}\.json$` |
- 目录:与身份证/头像同根(`FileServiceSettings:UploadFileDirectory`),挂 `FamilyTree/` 与 `FamilyTree/Backup/` 子目录,不新增配置节。
- `IsValidFileName` / `IsValidBackupFileName` 拒绝任何目录成分(防 `../` 路径穿越),且 `Path.GetFileName` 双检。
- **TreeId 来源**:交换/备份文件的 TreeId 从**文件名**反解析(`ParseFileName` / `ParseBackupFileName`),不依赖 JSON 内容或文件系统时间。
---
## 6. 导出链路
后端 `ExportFamilyTree`(`FamilyTreeService.auto.cs` L818):
1. D8 闸门:全系仅管理员;小系管理员任意锚点、成员仅限自家支锚点。
2. 全量取本谱 persons/spouseMaps/childrenMaps(关系按 `Orderby` 排序)。
3. 小系子集:`PackPersonIdByChildrenMaps`(管理员用传入锚点)或 `GetAmendablePersonIds`(成员)展开后过滤,关系行**两端都须在 ids 内**。
4. 审计 `OUT`,返回明文 `FamilyTreeExchangeData`(含 `FamilyTree` 谱信息)。
前端 `exportTree`(`useFamilyTreeExchange.ts` L91):
`GetExchangeFactor` → `ExportFamilyTree` → `encryptEnvelope` → **设备落盘**(`saveFileBytes`)和/或 **上传交换目录**(`SaveExchangeFile`,base64 通道,限 1B~50MB)。
---
## 7. 导入链路
### 7.1 全系整谱替换 `ImportFamilyTree` → `ReplaceTreeCore`
`ReplaceTreeCore`(L989,`ImportFamilyTree` 与 `RestoreBackup` 共用,调用方 `@UnitOfWork` 已开事务):
```
0) 完整性闸(宁拒不静默丢行,保落库与文件 100% 相同)
- PersonId 为空或重复 → 拒
- spouse/children 关系行两端不在文件内 → 拒
1) BackupTree 自动备份现谱(整谱快照,先落盘)
2) 清空当前谱:关系先行(spouse→children)、人后删
3) 编号策略 + SkipAudit
- sameTree:idMap[p]=原号(不耗号段)、保留 BindUserId/IsBindUser、回放文件 Created 印章
→ 插入段临时 dbCtx.SkipAudit=true(绕开框架对 Added 强盖 Created*),finally 复位
- 跨谱:generator.GetNo 重发 PersonId + 清绑定 + 框架盖当次 Created
4) 关系行双端 remap(完整性闸保证全量命中);同谱 Updated 补当次痕迹
5) 谱信息字段以数据为准(TreeName/Brief/Cover/PublicIndc;AncestorPersonId 经 idMap remap;主键 TreeId 保持目标谱)
6) 权限闭环(见 §8)→ 审计
```
- 全系导入仅管理员(D4)、仅 ALL scope、要求 `data.FamilyTree` 非空。
- 审计动码:`IN`=外部文件导入 / `RESTORE`=备份回滚回放;message 含 `sameTree=1/0`、`backup=`、权限统计。
### 7.2 小系锚点整家替换 `ImportSubTree`(方案 C)
`ImportSubTree`(L1147):
1. P6 权限闸:管理员任意锚点;成员限自家支锚点。
2. P2/P3 同一人验证:文件锚点与所选族人 **姓+谱名**全量一致才放行。
3. BackupTree 整谱备份(P7,回滚点)。
4. **1.5 方案 C**:锚点本人资料覆盖为文件锚点(`Clone`+`CopyFrom`),回保目标侧 `PersonId/TreeId/FatherId/BindUserId/IsBindUser/Created*`(防脱谱、防绑定劫持)。
5. **删除集**:`BranchClosure(anchor)` 后代闭包 + 锚点配偶"纯入配"(`delSpousePersons`:无绑定、无其它婚配/血缘、非始祖才随删重插),涉及锚点的 spouse 行全删。
6. **插入集**:文件侧同口径闭包(上溯段不插,P4)+ 文件锚点配偶(与目标侧删除对称);文件锚点映射到保留的目标锚点,其余 `generator` 重编号、清绑定。
7. 关系行双端 remap,引用集合外人的行丢弃防悬挂。
8. 权限行迁移 `MigrateSubTreePermissions`(§8)。
9. 审计 `MERGE`:`deleted/inserted/anchor=1/anchorSpouse=N/perm moved;dropped`。返回 `insSet.Count + 1`(含锚点本人)。
**BranchClosure**(L1127):后代 + 配偶迭代到不动点(防回头婚配漏拉),**锚点本人不进集合**;目标侧删除集与文件侧插入集共用此口径保证删/插对称。
### 7.3 前端解密校验 `decryptPayload` / `readAndDecrypt`
`useFamilyTreeExchange.ts` L155
- 服务器回读(`ReadExchangeFile` base64)与本地选文件两路共用解密;
- `expectScope=null`(本地跨谱嫁接)自动采纳文件自身范围;指定模式则文件范围必须同构;
- 小系无 `AnchorPersonId`(旧文件)→ `exchange-legacy-sub`;缺 Persons/FamilyTree → `exchange-file-bad`;
- payload 留前端内存,二次确认后原样交 `importTree` / `importSubTree`。
---
## 8. 权限行三方法(绝对闭环核心)
| 场景 | 方法 | 行为 |
|---|---|---|
| 小系导入 | `MigrateSubTreePermissions`(L386) | 被删后代/入配配偶的权限行经 `idMap`(文件旧号→新号)命中则 `RebindFamilyTreePermission` 删旧插新换绑;命中不了(跨谱/已删)删悬挂;**管理员行、PersonId 空行不动** |
| 跨谱全系导入 | `CleanDanglingPermissions`(L415) | 删本谱引用已删旧号的成员行(`ManagerIndc!=YES && PersonId!="" && 不在存活集`);管理员/空号保留 |
| 备份还原 | `RestorePermissions`(L430) | 本谱权限行整体删除→按快照重建,`PersonId` 经 idMap remap(还原恒同谱→原样落回);仅 `data.Permissions.Length>0` 触发(`.fm` 无快照不会走到) |
- 复合主键 `(PersonId,TreeId,UserName)` 改主键 EF 不可靠 → 统一走"删旧插新"(`RebindFamilyTreePermission` 先例)。
- 建谱时管理员行 `PersonId` 指向始祖且非空 → 清理/迁移必须 `ManagerIndc` 排除,防误杀。
`ReplaceTreeCore` 权限分支优先级:`有快照→Restore` > `跨谱→Clean` > `同谱→不碰`。
---
## 9. 备份与还原链路
### 9.1 备份 `BackupTree`(L960)
- 三条破坏性路径**动手前都先整谱备份**(全系导入 / 小系导入 / 还原),备份范围**恒为整谱**(即使只替换一根枝);
- 采集 persons/spouse/children + **本谱权限行**;
- **无 try-catch**:磁盘满/写失败直接抛出 → 整个事务回滚 = "备份落盘不成功,删除就不执行";
- 整份 `EncryptBackup`(`ENC:` 密文)落 `Backup/` 子目录。
### 9.2 备份列表 `ListBackups`(R1 收紧)(L1288)
- 成员只见自己创建的(`ReadBackupOwner` 解密取 `ExportedBy`),管理员不限;
- R5 保留窗口一年;按备份时刻降序;解析不出归属的旧文件对成员隐藏。
### 9.3 还原 `RestoreBackup`(L1341)
- R1 成员也可还原(走 IsMember);R6 文件名严格 BK + 谱号==目标谱(防跨谱灌数据)+ 超窗口拒;
- 解密反序列化(`PropertyNameCaseInsensitive`)→ 内容完整性校验 → R1 归属校验(成员只能还原自己的);
- 复用 `ReplaceTreeCore(..., "RESTORE")`:**还原前先把当前谱再备份一份**(链式可回退),同谱编号/绑定原样回放、权限随快照闭环。
---
## 10. 同谱 / 跨谱 语义矩阵
| 场景 | 编号 | 绑定 | Created 印章 | 权限行 | 完整性闸 |
|---|---|---|---|---|---|
| 同谱全系导入 / 备份还原 | 保留原号 | 保留 | 回放文件值(SkipAudit) | 无损 / 快照回放 | 拒悬挂/重复 |
| 跨谱全系嫁接 | 重发新号 | 清空 | 当次操作人/时间 | Clean 清悬挂 | 拒悬挂/重复 |
| 小系导入 | 后代重编号 | 锚点保留 / 后代清 | 当次 | Migrate 迁移 | — |
同谱判定:`data.FamilyTree.TreeId == targetTreeId`(还原与服务器同谱文件导入恒同谱;跨谱全系只能来自本地上传)。
---
## 11. 前端 UI / 交互 / 文案
入口在 `components/family-tree-home/family-tree-home.vue`:
- 导入/导出入口 `v-if="canExport"`(`canExport = IsManager || IsMember`),**成员也有导入入口**(不再仅管理员);
- `openExchangeImport`:非管理员默认 scope=`SUB`,`ALL` 选项 chip `v-if IsManager`;
- 二次确认按 **同谱/跨谱** 区分风险文案:同谱文件(含本谱服务器文件)`exchangeImportConfirmSame`(保留编号/绑定,数据 100% 恢复);跨谱 `exchangeImportConfirm`(重编号+解绑需重审批);
- 小系确认 `exchangeImportConfirmSub` 极简版:"将替换当前族员「{anchor}」的全部,自动备份可回滚。确定继续吗?";
- 还原 `exchangeRestoreConfirm` / 警告 `exchangeRestoreWarn`(恒同谱,保留编号绑定 100% 回放);
- **导入/还原弹层列表**用 `<scroll-view scroll-y class="ft-exchange__list">`(`max-height:42vh` + H5 可见 6px 细滚动条),保证密码框/红色警告/按钮永不被长列表挤出。
i18n:`src/locale/lang/zh-CN.json` 与 `en-US.json` 双语同步。
---
## 12. 审计日志 `family_tree_file_log`
`WriteExchangeLog`(L1378):失败不阻断主流程(`try-catch` 吞并 Console 打印)。
| Action | 来源 | Message 关键字 |
|---|---|---|
| `OUT` | 导出 | 文件名、人数 |
| `IN` | 全系导入 | `backup; sameTree; permDropped` |
| `MERGE` | 小系导入 | `backup; deleted; inserted; anchor=1; anchorSpouse=N; perm moved;dropped` |
| `RESTORE` | 备份还原 | `backup; sameTree; permRestored` |
---
## 13. 后端接口清单
| 方法 | 权限 | 入参绑定 | 说明 |
|---|---|---|---|
| `GetExchangeFactor` | Find | — | 下发系统因子 |
| `ExportFamilyTree` | Find | `(treeId,scope,anchorPersonId)` | 返回明文 payload |
| `SaveExchangeFile` | Find | `(fileName,contentBase64)` | 上传交换目录 |
| `ListExchangeFiles` | Find | `?treeId=`(query) | 列 `.fm`,时间降序 |
| `ReadExchangeFile` | Find | `?fileName=`(query) | base64 回读 |
| `ImportFamilyTree` | Update+事务 | body `{targetTreeId,data}` | 整谱替换(仅管理员/ALL) |
| `ImportSubTree` | Update+事务 | body `{targetTreeId,anchorPersonId,data}` | 小系替换 |
| `ListBackups` | Find | `?treeId=`(query) | 列 BK(按归属过滤) |
| `RestoreBackup` | Update+事务 | body `{targetTreeId,backupFileName}` | 备份还原 |
> 简单类型入参走 query string,复杂对象走 JSON body;业务异常统一 HTTP 200 + `ValidatedException` 文本。
---
## 14. 构建链与部署(易踩坑)
- `CloudBuilder.Web.Entry.csproj` 对子项目是 `<Reference>+<HintPath>` 而非 `<ProjectReference>`:**必须先 `dotnet build` 子项目 csproj(更新其 bin dll),再 build Entry**,否则 Entry 输出目录仍是旧 dll,改动不生效。
- 改了 `CloudBuilder.Master`(如 DTO)→ 顺序:`Master → Master.Service → Web.Entry`。
- 构建前需释放文件锁:VS 调试进程(端口 5235)与 PostBuild 的 `CodeGenerator.exe` 会锁 dll;构建后核对 `bin\Debug\net8.0\CloudBuilder.Master.Service.dll` 时间戳。
- `Controller` 与前端 `service/*.ts` 由 CodeGenerator 自动生成,**不可手改**;手写业务逻辑放 `*.auto.cs`(允许)。
---
## 15. 自动化测试矩阵(已实测通过)
| 用例 | 断言要点 | 结果 |
|---|---|---|
| 同谱全系 round-trip | 编号/绑定/字段/关系全等,Created 印章回放 | 12/12 PASS |
| 还原旧明文 BK | 向后兼容透传不炸,生成新密文 BK | PASS |
| 新 BK 密文 | `ENC:` 前缀 + AES-128-CBC 解密回明文 | PASS |
| 权限快照 | 密文含 `Permissions`,条数==库内权限行;含 UserName/ManagerIndc | PASS |
| 小系权限迁移 | 后代绑定随人换绑新号;管理员/空号行不动 | 14/14 PASS |
| 跨谱清理悬挂 | 引用已删旧号的成员行删除,管理员行保留 | PASS |
| 完整性负闸 | 空/重复编号、悬挂关系行拒绝 | PASS |
测试脚本要点(见记忆《CloudBuilder 后端自动化测试登录与接口调用要点》):
- `POST /Auth/Login` 体字段 `UserMobile` + `RsaUserPassword`(RSA: `guid+md5(pwd)`)+ `Guid`,`Culture` 必须**小写 `zh-cn`**(传 `zh-CN` 会让 `ComposeMessage` 抛空 Message 异常);
- token 在 `Token.AccessToken`;
- 结果落盘 `fs.writeFileSync` + Read 工具直读(PowerShell stdout 滞后不可靠)。
---
## 16. 已知边界与遗留
- **备份密钥**:`BK` 用 `EncryptHelper` 内置固定 Key/IV(代码硬编码于 `CloudBuilder.Core`);如需更强隔离可改走配置密钥。
- **father_id 字段 vs children_map 行一致性**:曾发现陈国国 `father_id` 为 null 但 children_map 存在父行(保存链路潜在不一致),属独立 bug,未在本闭环处理。
- **JWT 无状态窗口**:解绑/重绑在 token 有效期内对已登录会话的影响为窗口期问题。
- `.fm` 交换文件的 `Permissions` 恒空:恶意文件携带权限行会被忽略(不回放),符合防劫持设计。
---
*文档生成于 2026-10-20,对应代码版本:Entry 输出 `CloudBuilder.Master.Service.dll` 时间戳 09:14:41(含同谱完整性 + 权限迁移/清理 + 备份权限快照 + 密文落盘全部改动)。*
@@ -0,0 +1,128 @@
# 族谱权限体系(完整流程)
> 定稿 2026-10(v5):三档角色 + 中文姓名同名绑定 + 家支两代上溯 + 逐目标人可编集合。前后端口径一致,后端为最终裁决。
## 一、三档角色定义
| 角色 | 判定条件 | 能力范围 |
|---|---|---|
| **普通用户** | 无生效权限行(或仅有待审行) | 只读:查看公开信息、小/全系图;「申请管理」可用;编辑、编辑资料、顶部公开开关、成员/权限选项卡全部不可见 |
| **成员** | 有 `UserName=自己 且 ValidIndc=Y` 的权限行(`IsMember=true`) | 可见成员选项卡;可编辑**以自己绑定族员为锚点的家支主线**(见第三节);权限选项卡、谱级操作(编辑资料/挂始祖/公开开关/删谱)不可见且后端拦截 |
| **管理员** | 权限行 `ManagerIndc=Y 且 ValidIndc=Y`(`IsManager=true`) | 全部权限:审批/任免、族谱本体修改、任意族员编辑删除(`HasPermission` 内短路为真) |
建谱人(Insert 时 CreatedBy)自动成为管理员。
后端 `FamilyTreeData` 恒定下发三个标志,前端一切显隐只认它们:
- `IsManager`:是否管理员(谱级操作闸门);
- `IsMember`:是否已生效成员(成员 tab 闸门);
- `IsCanAmend`:**当前锚点族员**是否可编(随「选择族员」切换而变),个人编辑/关系维护入口闸门。
## 二、完整流程:从陌生人到成员
```
注册登录 → 浏览族谱(普通用户档)
│
▼
[申请管理](快捷区 / 权限tab常驻卡 / 底部条,三档均可见入口)
│ 后端 RequestFamilyTreePermission:
│ 插入权限行 TreeId+UserName,PersonId=""(不绑任何族员!
│ 旧版把申请锚点——往往是始祖——写入,导致成员误得大支系,已修)
│ 查重按 TreeId+UserName(一人一谱一行)
▼
管理员权限tab「待审批」看到申请行(视图 LEFT JOIN 保证空绑定行可见)
│
▼
[通过](成员档) 后端 ApproveFamilyTreePermission:
│ ValidIndc=Y;不绑始祖;
│ 自动绑定判定:取申请人中文姓名 user_local_name,全谱内
│ 「姓+谱名」组合或常用名与之相同且唯一 → 自动绑该族员;
│ 否则 PersonId 留空,由用户自行绑定
│
├─或→ [设为管理员]:ManagerIndc=Y,不做绑定(管理员全谱短路可编)
│
▼
未自动绑定的成员 → [绑定账号](成员tab「当前族员」卡第4钮)
│ 先用「族员搜索」把当前族员切到目标,再点绑定
│ 后端 BindSelfPerson(treeId, personId):
│ ① 校验自己有 ValidIndc=Y 行,否则「您还未通过审批,不能绑定账号」
│ ② 校验该族员「姓+谱名」组合或常用名 == 登录中文姓名(user_local_name),
│ 否则「只能绑定与您的中文姓名同名的族员」(与审批自动绑定同一把尺;
│ 登录账号 user_name 只是标识,不参与名字比较)
│ ③ 换绑:PersonId 属复合主键 (PersonId,TreeId,UserName),
│ EF 分离实体改主键列不可靠 → 删旧行插新行(RebindFamilyTreePermission)
│ ④ 返回 FindByCurrentPerson(treeId, personId),锚点即切到刚绑族员
│
▼
[解除绑定](同一按钮位:当前族员==自己绑定时显「解除绑定」)
│ 后端 UnbindSelfPerson(treeId):自己 Valid=Y 行 Rebind 到 PersonId="",
│ 锚点退回始祖;解绑后可重新绑定其他同名族员
▼
成员生效:家支主线可编(第三节)
```
驳回(Reject)/移出:按 TreeId+UserName 定位删行(不再依赖 PersonId)。
## 三、成员可编范围:家支主线(两代上溯口径)
`FamilyTreePermissionHelper.HasPermission(treeId, personId)` 判定,
支系集合由绑定族员(锚点)按下列规则展开:
| 关系 | 是否在集合 | 说明 |
|---|---|---|
| 本人(绑定的族员) | ✅ | |
| 全部后代(递归子链) | ✅ | 含后代的配偶 |
| 父(第 1 代上溯)、祖父(第 2 代上溯) | ✅ | **最多两代**,含他们的配偶(母亲/祖母辈) |
| 曾祖及更远直系祖先(含始祖) | ❌ | 超出两代即出支系 |
| 本人配偶 | ✅ | |
| 兄弟姐妹 | ❌ | 旁支 |
| **伯/叔(父亲的兄弟)及其子女** | ❌ | 上溯只收直系父链+每代配偶,不从祖父向下展开 |
环保护:`ps.Contains(father)` 即停,父链成环不会死循环。
**逐目标人可编集合**:详情接口同时下发 `CanAmendPersonIds`(`GetAmendablePersonIds`,与 `HasPermission` 完全同源的家支集合)——因为 `IsCanAmend` 是锚点级单一布尔,盖不住关系行里的旁支(如锚点儿子视角下,父亲的另一子“test叔”是旁支不可编);管理员不限量不下发,前端按 `IsManager` 短路。
## 四、后端守卫矩阵(防绕过前端的直接 API 调用)
| 操作 | 端点 | 守卫 |
|---|---|---|
| 改族员档案 | `FamilyPerson/Update` | `HasPermission(TreeId, PersonId)`(支系内) |
| 新增族员 | `FamilyPerson/Insert` | **`IsManager \|\| IsMember`**(新人无既有 PersonId 可查支系,最小闸门防普通用户直接调 API 污染族谱;挂接关系仍受 Update 的 HasPermission 约束) |
| 删族员 | `FamilyPerson/Delete` | 同上(曾完全无守卫,已补) |
| 改谱字段(谱名/简介/公开开关/挂始祖) | `FamilyTree/Update` | **仅 `IsManager`**;成员支只准保存配偶/子女关系 map,谱字段不入库;成员改 `AncestorPersonId` 抛「您没有挂始祖权限」 |
| 删谱 | `FamilyTree/Delete` | **仅 `IsManager`** |
| 审批/任免/驳回 | `Approve/Reject...` | 管理员函数级权限 |
| 申请 / 绑定账号 | `Request.../BindSelfPerson` | 函数级 Find + 业务校验(见第二节) |
⚠️ 关键教训:**`HasPermission(始祖)` 不能当谱级闸门**——锚点贴近始祖的成员(如绑始祖、或始祖在其上溯两代内)它也为真。谱级操作必须走独立的 `IsManager(treeId)`。
## 五、前端门控(family-tree-home.vue)
| 入口/元素 | 显示条件 |
|---|---|
| 概览快捷区「编辑资料」、顶部公开开关、权限 tab、挂始祖链 | `IsManager` |
| 成员 tab | `IsManager \|\| IsMember` |
| 当前族员「修改」钮、始祖卡「编辑」链 | `IsCanAmend`(锚点级,随锚点变) |
| 关系行(夫妻/子女)行尾铅笔与垃圾桶 | **逐目标人** `canAmendPerson(id)` = `IsManager \|\| CanAmendPersonIds.includes(id)`;旁支(如兄弟)不显示 |
| 关系行 ↑↓ 调序、卡头「新增配偶/添加子女」 | 锚点级 `IsCanAmend`(属锚点关系列表维护) |
| 「申请管理」 | 常驻(管理员点击只提示不重复发) |
| 「绑定账号 / 解除绑定」(成员tab当前族员卡,同一按钮位互斥切换) | `!IsManager`;当前族员 PersonId == `SelfBindPersonId` 时显「解除绑定」,否则显「绑定账号」 |
| 不可编时弹层前置拦截 | `openQuickCreate` 按锚点级 `IsCanAmend`、`openEditPerson` 按目标人级 `canAmendPerson(id)`:不可编直接 toast「您没有该族谱的修改权限」不弹层(防弹完才报错,也防弹层内非公开族人信息泄漏) |
| 不可见 tab 兜底 | `load()` 内:权限 tab 非管理员踢回概览;成员 tab 非成员/管理员踢回概览 |
## 六、数据与视图要点
- 权限表 `family_tree_permission`,复合主键 **(person_id, tree_id, user_name)**;`person_id=""` 表示已过审/待审但未绑定。
- 列表下发走数据库视图 `family_tree_permission_user_view`:对 `family_person` 必须 **LEFT JOIN**(INNER JOIN 会把未绑定行整联丢弃,导致管理员看不到待审申请——已修);对 `security_user` 保持 INNER。
- 非管理员查询时后端过滤 `FamilyTreePermissions` 只留自己的待审行;`IsMember` 必须在过滤**之前**计算。
- 视图定义不在仓库中,只存在于 MySQL;改动需 `CREATE OR REPLACE VIEW`。
## 七、已验证结论(脚本实测)
- 001 管理员:三标志全 true,任意编辑 ✅
- 002 普通用户:三标志全 false,人员/谱级写操作全部「您没有修改权限」 ✅
- 003 成员(绑 test仲,始祖为其父):改伯系后代/旁支被拒、改自己放行、翻公开开关不入库、删谱/删伯系被拒 ✅
- 两代口径:003 绑四世 test我 时,父/祖可编、曾祖(始祖)出支系 `IsCanAmend=false`、伯叔不可编 ✅
- 同名绑定(中文姓名口径):比较基准是 `security_user.user_local_name`;族员侧匹配 `(姓+谱名) == 中文姓名 || 常用名 == 中文姓名`;不同名拒绑、唯一同名审批时自动绑、自助绑定成功 ✅(003/陈国国 可绑 P0000019 陈+国国)
- 逐目标人集合(003 绑陈国国 19 实测):`CanAmendPersonIds = {19本人, 23/24后代, 16父, 17/20父辈配偶, 14/15祖始祖层}` ✅;test叔(旁支)不在集合、行尾无编辑/删除钮;陈国国自己的行可编 ✅
- 构建链教训:`Master.Service`/`Web.Entry` 走 HintPath dll 引用且用 `-o` 临时目录验证编译会漏刷 `bin\Debug`,导致“新 Master + 旧 Service”组合使新字段恒为空数组;交付前必须按 Master → Master.Service → Web.Entry 顺序正常构建(不带 -o)再重启