feat(api): 完善家谱系统API客户端契约

- 实现家谱管理相关方法,包括创建、详情、概览、我的家谱和选项查询
- 添加家谱加入申请功能,支持申请、审核、取消和待审核列表操作
- 集成通知详情获取方法和通知ID安全验证机制
- 完善功德记录、谱文、相册、视频、祭祀活动的完整CRUD操作契约
- 实现家谱成员管理功能,包含成员列表、更新、移除和转让所有者操作
- 优化路径ID验证逻辑,拒绝不安全的数值ID并提供明确错误提示
- 更新测试用例以验证所有新增API方法的路径和请求体白名单机制
This commit is contained in:
fizzleaf
2026-07-29 16:57:27 +08:00
parent 59a72fb22b
commit fb1743aa2a
74 changed files with 13166 additions and 1320 deletions
+243 -6
View File
@@ -13,33 +13,67 @@ test('世系页只从 URL 读取真实家谱编号', () => {
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
assert.deepEqual(
LineagePages.buildLineagePersonBody({
bindingMode: 'SPECIFIED',
appUserId: '2061000000000000001',
avatarOssId: '2061000000000000002',
name: '李明',
sex: '0',
generation: '3',
generationName: '德',
fatherId: '2061000000000000003',
motherId: '2061000000000000004',
personNo: 'P202607090001',
aliasName: '明远',
birthDate: '1990-01-01',
birthDate: '1990-01-01T08:30',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03T10:45',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
sortOrder: '1',
relationName: '妻',
biography: '人物简介',
remark: '家族备注',
personName: '旧字段',
generationNo: 4,
introduction: '旧字段'
}),
{
bindingMode: 'SPECIFIED',
appUserId: '2061000000000000001',
avatarOssId: '2061000000000000002',
name: '李明',
sex: '0',
generation: 3,
generationName: '德',
fatherId: '2061000000000000003',
motherId: '2061000000000000004',
personNo: 'P202607090001',
aliasName: '明远',
birthDate: '1990-01-01',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
sortOrder: 1,
relationName: '妻',
biography: '人物简介'
biography: '人物简介',
remark: '家族备注'
}
);
assert.deepEqual(LineagePages.buildLineagePersonBody({
bindingMode: 'SELF',
appUserId: '2061000000000000001',
name: '李明'
}), {
bindingMode: 'SELF',
name: '李明'
});
});
test('世系人物要求安全的 ID、姓名和有效世代值', () => {
@@ -60,9 +94,168 @@ test('世系人物要求安全的 ID、姓名和有效世代值', () => {
}
);
assert.equal(LineagePages.normalizeLineagePerson({ personId: Number('2060000000000000000'), name: '李明' }), null);
assert.equal(LineagePages.validateLineagePersonBody({ name: '' }), '请填写成员姓名');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', generation: '1.5' })), '世代序号必须是整数');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
assert.equal(LineagePages.normalizeLineagePerson({
personId: '2060000000000000000',
fatherId: Number('2061000000000000000'),
name: '李明'
}), null);
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '' }), '请填写成员姓名');
assert.equal(LineagePages.validateLineagePersonBody({ name: '李明' }), '请选择账号绑定方式');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'OTHER', name: '李明' }), '账号绑定方式无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'SPECIFIED', name: '李明' }), '指定账号绑定缺少用户选项');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ bindingMode: 'NONE', name: '李明', generation: '1.5' })), '世代序号必须是整数');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ bindingMode: 'NONE', name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', sex: '3' }), '性别选项无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', personStatus: '3' }), '人物状态选项无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', birthLunar: '2' }), '出生历法选项无效');
});
test('世系人物响应保留完整详情字段并拒绝不安全的嵌套 ID', () => {
assert.deepEqual(LineagePages.normalizeLineagePerson({
personId: '2060000000000000000',
genealogyId: '2060000000000000001',
appUserId: '2060000000000000002',
avatarOssId: '2060000000000000003',
fatherId: '2060000000000000004',
motherId: '2060000000000000005',
genealogyName: '李氏家谱',
genealogyNo: 'G20260728001',
appUserNickName: '明远',
personNo: 'P202607090001',
name: '李明',
aliasName: '明远',
sex: '0',
generation: 3,
generationName: '德',
fatherName: '李父',
motherName: '王母',
spouseNames: '张氏',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
biography: '人物简介',
sortOrder: 1,
status: '0',
remark: '家族备注'
}), {
personId: '2060000000000000000',
name: '李明',
genealogyName: '李氏家谱',
genealogyNo: 'G20260728001',
appUserNickName: '明远',
personNo: 'P202607090001',
aliasName: '明远',
sex: '0',
generationName: '德',
fatherName: '李父',
motherName: '王母',
spouseNames: '张氏',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
biography: '人物简介',
status: '0',
remark: '家族备注',
appUserId: '2060000000000000002',
genealogyId: '2060000000000000001',
fatherId: '2060000000000000004',
motherId: '2060000000000000005',
avatarOssId: '2060000000000000003',
generation: 3,
sortOrder: 1
});
});
test('家谱内容权限只控制世系写操作,读取和分页始终可用', () => {
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: false }), {
canEditContent: false
});
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: true }), {
canEditContent: true
});
assert.equal(LineagePages.canUseLineagePagination(false), true);
assert.equal(LineagePages.canUseLineagePagination(true), false);
});
test('世代选项来自正常字辈列表并自动映射序号和字辈', () => {
assert.deepEqual(LineagePages.normalizeGenerationOption({
poemId: '2060000000000000000',
generationNo: 3,
generationText: '德',
status: '0'
}), {
generation: 3,
generationName: '德'
});
assert.equal(LineagePages.normalizeGenerationOption({ generationNo: 0, generationText: '德' }), null);
assert.equal(LineagePages.normalizeGenerationOption({ generationNo: 3, generationText: '' }), null);
});
test('已有账号绑定按当前登录用户区分 SELF 与只读保留的 SPECIFIED', () => {
assert.deepEqual(LineagePages.normalizeLineageBinding({}, '2060000000000000001'), {
bindingMode: 'NONE',
appUserId: ''
});
assert.deepEqual(LineagePages.normalizeLineageBinding({
appUserId: '2060000000000000001'
}, '2060000000000000001'), {
bindingMode: 'SELF',
appUserId: ''
});
assert.deepEqual(LineagePages.normalizeLineageBinding({
appUserId: '2060000000000000002'
}, '2060000000000000001'), {
bindingMode: 'SPECIFIED',
appUserId: '2060000000000000002'
});
});
test('指定账号绑定只使用当前家谱正常且已绑定账号的成员选项', () => {
assert.deepEqual(LineagePages.normalizeGenealogyMemberOption({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '明远',
memberName: '李明',
roleType: 'member',
status: '0'
}), {
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '明远',
memberName: '李明',
roleType: 'member',
status: '0'
});
assert.equal(LineagePages.normalizeGenealogyMemberOption({
memberId: '1',
genealogyId: '2',
appUserId: null,
memberName: '未绑定成员',
status: '0'
}), null);
assert.equal(LineagePages.normalizeGenealogyMemberOption({
memberId: '1',
genealogyId: '2',
appUserId: '3',
memberName: '停用成员',
status: '1'
}), null);
assert.deepEqual(LineagePages.normalizeGenealogyMemberOptions([
{ memberId: '1', genealogyId: '2', appUserId: '3', memberName: '正常成员', status: '0' },
{ memberId: '4', genealogyId: '2', appUserId: null, memberName: '未绑定成员', status: '0' }
]).map((option) => option.appUserId), ['3']);
});
test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
@@ -76,12 +269,56 @@ test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
test('世系页按 Apifox 关键词范围搜索并提供分页入口', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
assert.deepEqual(LineagePages.buildLineagePageQuery({
keyword: ' 李明 ',
generation: '3',
personStatus: '1'
}, 2, 20), {
pageNum: 2,
pageSize: 20,
keyword: '李明',
generation: 3,
personStatus: '1'
});
assert.deepEqual(LineagePages.buildLineagePageQuery({}, 1, 20), {
pageNum: 1,
pageSize: 20
});
assert.match(page, /placeholder="输入姓名、别名或人物编号搜索"/);
assert.match(page, /data-lineage-generation-filter/);
assert.match(page, /data-lineage-status-filter/);
assert.match(page, /data-lineage-pagination/);
assert.match(page, /data-lineage-page-action="previous"/);
assert.match(page, /data-lineage-page-action="next"/);
});
test('世系表单为全部 LineagePersonBody 字段提供真实来源且不允许手填业务 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
[
'bindingMode', 'appUserId', 'avatarOssId', 'personNo', 'name', 'aliasName', 'sex',
'generation', 'generationName', 'fatherId', 'motherId', 'birthDate', 'birthLunar',
'birthPlace', 'deathDate', 'deathLunar', 'deathPlace', 'burialPlace', 'personStatus',
'biography', 'sortOrder', 'remark', 'relationName'
].forEach((name) => assert.match(page, new RegExp('name="' + name + '"')));
assert.match(page, /value="SPECIFIED"(?![^>]+disabled)/);
assert.match(page, /select[^>]+name="appUserId"[^>]+data-lineage-member-options|select[^>]+data-lineage-member-options[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="(?:text|number)"|type="(?:text|number)"[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="hidden"|type="hidden"[^>]+name="appUserId"/);
assert.match(page, /name="avatarOssId"[^>]+type="hidden"|type="hidden"[^>]+name="avatarOssId"/);
assert.match(page, /data-upload-target="#lineage-avatar-oss-id"/);
assert.match(page, /name="generation"[^>]*data-lineage-generation/);
assert.match(page, /name="generationName"[^>]+readonly|readonly[^>]+name="generationName"/);
assert.match(page, /name="fatherId"[^>]*data-lineage-parent-option/);
assert.match(page, /name="motherId"[^>]*data-lineage-parent-option/);
assert.match(page, /data-lineage-management[^>]+hidden/);
assert.match(page, /public\/js\/upload-pages\.js/);
assert.match(styles, /\[data-lineage-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-spouse-field\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-pagination\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('世系管理的 403 不会被当作登录失效', () => {
assert.equal(LineagePages.isForbidden({ status: 403 }), true);
assert.equal(LineagePages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);