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
+308 -35
View File
@@ -22,8 +22,9 @@
var currentPageNum = 1;
var currentPageSize = 20;
var currentPageTotal = null;
var managementAccess = true;
var managementAccess = false;
var writePending = false;
var currentUserId = '';
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
@@ -101,8 +102,7 @@
return Number.isInteger(number) && Number.isSafeInteger(number) ? number : undefined;
}
function getPersonId(item) {
var value = item && item.personId;
function normalizeId(value) {
var text;
if (value === undefined || value === null || value === '') return '';
@@ -111,22 +111,52 @@
return /^\d+$/.test(text) ? text : '';
}
function getPersonId(item) {
return normalizeId(item && item.personId);
}
function formatLineageDate(value) {
var text = trimOrUndefined(value);
if (!text) return undefined;
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ') + ':00';
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(text)) return text.replace('T', ' ');
return text;
}
function buildLineagePersonBody(values) {
var source = values || {};
var body = { name: trimOrUndefined(source.name) };
var bindingMode = trimOrUndefined(source.bindingMode);
var body = {
bindingMode: bindingMode,
name: trimOrUndefined(source.name)
};
var optionalFields = [
'personNo', 'aliasName', 'sex', 'generationName', 'birthDate', 'birthLunar', 'birthPlace',
'deathDate', 'deathLunar', 'deathPlace', 'burialPlace', 'personStatus', 'biography',
'personNo', 'aliasName', 'sex', 'generationName', 'birthLunar', 'birthPlace',
'deathLunar', 'deathPlace', 'burialPlace', 'personStatus', 'biography',
'remark', 'relationName'
];
var idFields = ['avatarOssId', 'fatherId', 'motherId'];
var generation = trimOrUndefined(source.generation);
var sortOrder = trimOrUndefined(source.sortOrder);
var birthDate = formatLineageDate(source.birthDate);
var deathDate = formatLineageDate(source.deathDate);
optionalFields.forEach(function (field) {
var value = trimOrUndefined(source[field]);
if (value !== undefined) body[field] = value;
});
idFields.forEach(function (field) {
var value = normalizeId(source[field]);
if (value) body[field] = value;
});
if (bindingMode === 'SPECIFIED') {
body.appUserId = normalizeId(source.appUserId) || trimOrUndefined(source.appUserId);
}
if (birthDate !== undefined) body.birthDate = birthDate;
if (deathDate !== undefined) body.deathDate = deathDate;
if (generation !== undefined) body.generation = Number(generation);
if (sortOrder !== undefined) body.sortOrder = Number(sortOrder);
return body;
@@ -134,6 +164,17 @@
function validateLineagePersonBody(body) {
if (!body || !body.name) return '请填写成员姓名';
if (!body.bindingMode) return '请选择账号绑定方式';
if (['NONE', 'SELF', 'SPECIFIED'].indexOf(body.bindingMode) === -1) return '账号绑定方式无效';
if (body.bindingMode === 'SPECIFIED' && !normalizeId(body.appUserId)) return '指定账号绑定缺少用户选项';
if (body.bindingMode !== 'SPECIFIED' && body.appUserId !== undefined) return '当前绑定方式不能提交指定用户';
if (body.sex !== undefined && ['0', '1', '2'].indexOf(body.sex) === -1) return '性别选项无效';
if (body.personStatus !== undefined && ['0', '1', '2'].indexOf(body.personStatus) === -1) return '人物状态选项无效';
if (body.birthLunar !== undefined && ['0', '1'].indexOf(body.birthLunar) === -1) return '出生历法选项无效';
if (body.deathLunar !== undefined && ['0', '1'].indexOf(body.deathLunar) === -1) return '逝世历法选项无效';
if (['avatarOssId', 'fatherId', 'motherId'].some(function (field) {
return body[field] !== undefined && !normalizeId(body[field]);
})) return '人物关联字段无效';
if (body.generation !== undefined && (!Number.isInteger(body.generation) || !Number.isSafeInteger(body.generation))) {
return '世代序号必须是整数';
}
@@ -147,6 +188,7 @@
var personId = getPersonId(item);
var name = trimOrUndefined(item && item.name);
var person;
var invalidId = false;
if (!personId || !name) return null;
person = { personId: personId, name: name };
@@ -160,15 +202,70 @@
});
['appUserId', 'genealogyId', 'fatherId', 'motherId', 'avatarOssId'].forEach(function (field) {
if (item && item[field] !== undefined && item[field] !== null) {
if (typeof item[field] === 'number' && !Number.isSafeInteger(item[field])) return;
person[field] = String(item[field]);
var value = normalizeId(item[field]);
if (!value) {
invalidId = true;
return;
}
person[field] = value;
}
});
if (invalidId) return null;
if (toIntegerOrUndefined(item && item.generation) !== undefined) person.generation = toIntegerOrUndefined(item.generation);
if (toIntegerOrUndefined(item && item.sortOrder) !== undefined) person.sortOrder = toIntegerOrUndefined(item.sortOrder);
return person;
}
function normalizeLineageAccess(genealogy) {
return {
canEditContent: Boolean(genealogy && genealogy.canEditContent === true)
};
}
function normalizeGenerationOption(item) {
var generation = toIntegerOrUndefined(item && item.generationNo);
var generationName = trimOrUndefined(item && item.generationText);
if (!generation || !generationName) return null;
return {
generation: generation,
generationName: generationName
};
}
function normalizeGenealogyMemberOption(item) {
var memberId = normalizeId(item && item.memberId);
var genealogyId = normalizeId(item && item.genealogyId);
var appUserId = normalizeId(item && item.appUserId);
var status = trimOrUndefined(item && item.status);
if (!memberId || !genealogyId || !appUserId || status !== '0') return null;
return {
memberId: memberId,
genealogyId: genealogyId,
appUserId: appUserId,
appUserNickName: String(item.appUserNickName === undefined || item.appUserNickName === null ? '' : item.appUserNickName),
memberName: String(item.memberName === undefined || item.memberName === null ? '' : item.memberName),
roleType: String(item.roleType === undefined || item.roleType === null ? '' : item.roleType),
status: status
};
}
function normalizeGenealogyMemberOptions(data) {
if (!Array.isArray(data)) return [];
return data.map(normalizeGenealogyMemberOption).filter(Boolean);
}
function normalizeLineageBinding(person, userId) {
var appUserId = normalizeId(person && person.appUserId);
var normalizedUserId = normalizeId(userId);
if (!appUserId) return { bindingMode: 'NONE', appUserId: '' };
if (normalizedUserId && appUserId === normalizedUserId) return { bindingMode: 'SELF', appUserId: '' };
return { bindingMode: 'SPECIFIED', appUserId: appUserId };
}
function normalizeList(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.rows)) return data.rows;
@@ -259,12 +356,68 @@
function renderOptions(data) {
var select = query('[data-lineage-person-options]');
var people = normalizeList(data).map(normalizeLineagePerson).filter(Boolean);
if (!select) return;
select.innerHTML = '<option value="">请选择当前成员</option>' + people.map(function (person) {
var options = people.map(function (person) {
return '<option value="' + escapeHtml(person.personId) + '">' + escapeHtml(person.name + (person.generationName ? ' · ' + person.generationName : '')) + '</option>';
}).join('');
if (!select) return;
select.innerHTML = '<option value="">请选择当前成员</option>' + options;
select.value = selectedPersonId;
queryAll('[data-lineage-parent-option]').forEach(function (parentSelect) {
var emptyLabel = parentSelect.getAttribute('data-empty-label') || '请选择人物';
parentSelect.innerHTML = '<option value="">' + escapeHtml(emptyLabel) + '</option>' + options;
});
}
function renderGenerationOptions(data) {
var select = query('[data-lineage-generation]');
var filter = query('[data-lineage-generation-filter]');
var selectedGeneration = select ? select.value : '';
var selectedFilter = filter ? filter.value : '';
var options = normalizeList(data).map(normalizeGenerationOption).filter(Boolean);
var optionHtml = options.map(function (option) {
return '<option value="' + escapeHtml(option.generation) + '" data-generation-name="' + escapeHtml(option.generationName) + '">' +
escapeHtml('第 ' + option.generation + ' 世 · ' + option.generationName) + '</option>';
}).join('');
if (select) {
select.innerHTML = '<option value="">请选择世代</option>' + optionHtml;
select.value = selectedGeneration;
}
if (filter) {
filter.innerHTML = '<option value="">全部世代</option>' + optionHtml;
filter.value = selectedFilter;
}
}
function updateBindingFields() {
var bindingMode = query('[name="bindingMode"]');
var memberSelect = query('[data-lineage-member-options]');
var specified = Boolean(bindingMode && bindingMode.value === 'SPECIFIED');
if (!memberSelect) return;
memberSelect.hidden = !specified;
memberSelect.required = specified;
if (!specified) memberSelect.value = '';
}
function renderGenealogyMemberOptions(data) {
var select = query('[data-lineage-member-options]');
var selectedValue = select ? select.value : '';
var options = normalizeGenealogyMemberOptions(data).filter(function (option) {
return option.appUserId !== currentUserId;
});
if (!select) return options;
select.innerHTML = '<option value="">请选择已绑定账号的家谱成员</option>' + options.map(function (option) {
var label = option.memberName || option.appUserNickName || '家谱成员';
return '<option value="' + escapeHtml(option.appUserId) + '">' + escapeHtml(label) + '</option>';
}).join('');
select.value = selectedValue;
updateBindingFields();
return options;
}
function renderListSummary(data) {
@@ -281,7 +434,7 @@
var previous = query('[data-lineage-page-action="previous"]');
var next = query('[data-lineage-page-action="next"]');
var status = query('[data-lineage-page-status]');
var disabled = !managementAccess || writePending;
var disabled = writePending;
if (!container) return;
if (currentPageTotal === null) {
@@ -294,6 +447,10 @@
if (status) status.textContent = '第 ' + currentPageNum + ' 页,共 ' + currentPageTotal + ' 人';
}
function canUseLineagePagination(pending) {
return !Boolean(pending);
}
function renderPagination(data) {
var total = data && Number(data.total);
@@ -305,6 +462,7 @@
var container = query('[data-lineage-detail]');
var person = normalizeLineagePerson(data);
var values;
var actions = '';
if (!container) return;
if (!person) {
@@ -314,17 +472,30 @@
selectedPersonId = person.personId;
peopleById[person.personId] = person;
values = [
person.sex ? '性别:' + person.sex : '',
person.sex !== undefined ? '性别:' + ({ '0': '男', '1': '女', '2': '未知' }[person.sex] || person.sex) : '',
person.generation !== undefined ? '世代:' + person.generation : '',
person.generationName ? '字辈:' + person.generationName : '',
person.personNo ? '人物编号:' + person.personNo : '',
person.aliasName ? '别名:' + person.aliasName : '',
person.fatherName ? '父亲:' + person.fatherName : '',
person.motherName ? '母亲:' + person.motherName : '',
person.spouseNames ? '配偶:' + person.spouseNames : '',
person.appUserNickName ? '绑定账号:' + person.appUserNickName : '',
person.birthDate ? '出生:' + person.birthDate : '',
person.birthPlace ? '出生地:' + person.birthPlace : '',
person.deathDate ? '逝世:' + person.deathDate : '',
person.biography ? '简介' + person.biography : ''
person.deathPlace ? '逝世地' + person.deathPlace : '',
person.burialPlace ? '安葬地:' + person.burialPlace : '',
person.personStatus !== undefined ? '人物状态:' + ({ '0': '健在', '1': '已故', '2': '未知' }[person.personStatus] || person.personStatus) : '',
person.biography ? '简介:' + person.biography : '',
person.remark ? '备注:' + person.remark : ''
].filter(Boolean);
if (managementAccess) {
actions = '<div class="row-actions"><button class="pill" type="button" data-lineage-edit="' + escapeHtml(person.personId) + '">编辑</button>' +
'<button class="pill is-danger" type="button" data-lineage-disable="' + escapeHtml(person.personId) + '">停用</button></div>';
}
container.innerHTML = '<div class="module-row"><div><h3>' + escapeHtml(person.name) + '</h3><p>' + escapeHtml(values.join(' · ') || '暂无更多资料') + '</p></div>' +
'<div class="row-actions"><button class="pill" type="button" data-lineage-edit="' + escapeHtml(person.personId) + '">编辑</button>' +
'<button class="pill is-danger" type="button" data-lineage-disable="' + escapeHtml(person.personId) + '">停用</button></div></div>';
actions + '</div>';
renderLineageList(Object.keys(peopleById).map(function (id) { return peopleById[id]; }));
if (query('[data-lineage-person-options]')) query('[data-lineage-person-options]').value = person.personId;
refreshControls();
@@ -377,6 +548,7 @@
});
if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = '';
currentPageTotal = null;
setLineageEnabled(false);
updatePaginationControls();
}
@@ -391,10 +563,16 @@
}
function refreshControls() {
var disabled = !managementAccess || writePending;
var writeDisabled = !managementAccess || writePending;
queryAll('[data-lineage-relation], [data-lineage-search], [data-lineage-form] button, [data-lineage-person-options], [data-lineage-edit], [data-lineage-disable]').forEach(function (control) {
control.disabled = disabled;
queryAll('[data-lineage-management]').forEach(function (element) {
element.hidden = !managementAccess;
});
queryAll('[data-lineage-relation], [data-lineage-form] button, [data-lineage-form] input, [data-lineage-form] select, [data-lineage-form] textarea, [data-lineage-edit], [data-lineage-disable]').forEach(function (control) {
control.disabled = writeDisabled;
});
queryAll('[data-lineage-search], [data-lineage-person-options]').forEach(function (control) {
control.disabled = writePending;
});
updatePaginationControls();
}
@@ -427,10 +605,38 @@
return input ? input.value : '';
}
function buildLineagePageQuery(values, pageNum, pageSize) {
var source = values || {};
var queryValues = {
pageNum: Number(pageNum),
pageSize: Number(pageSize)
};
var keyword = trimOrUndefined(source.keyword);
var generation = toIntegerOrUndefined(source.generation);
var personStatus = trimOrUndefined(source.personStatus);
if (keyword !== undefined) queryValues.keyword = keyword;
if (generation !== undefined && generation > 0) queryValues.generation = generation;
if (['0', '1', '2'].indexOf(personStatus) !== -1) queryValues.personStatus = personStatus;
return queryValues;
}
function getCurrentFilters(keyword) {
var generation = query('[data-lineage-generation-filter]');
var status = query('[data-lineage-status-filter]');
return {
keyword: keyword,
generation: generation ? generation.value : '',
personStatus: status ? status.value : ''
};
}
async function loadLineage(keyword, pageNum) {
var api = getApi();
var genealogyId;
var results;
var access;
var searchKeyword = trimOrUndefined(keyword);
var requestedPage = Number(pageNum);
@@ -441,27 +647,41 @@
syncGenealogyLinks(genealogyId);
try {
results = await Promise.all([
api.genealogyDetail(genealogyId),
api.currentProfile(),
api.lineageTree(genealogyId),
api.lineagePersons(genealogyId),
api.lineagePersonsPage(genealogyId, {
pageNum: currentPageNum,
pageSize: currentPageSize,
keyword: searchKeyword
}),
api.lineagePersonOptions(genealogyId)
api.lineagePersonsPage(genealogyId, buildLineagePageQuery(
getCurrentFilters(searchKeyword),
currentPageNum,
currentPageSize
)),
api.lineagePersonOptions(genealogyId),
api.generationPoems(genealogyId)
]);
renderTree(results[0]);
renderListSummary(results[1]);
renderLineageList(results[2]);
renderPagination(results[2]);
renderOptions(results[3]);
setLineageEnabled(true);
access = normalizeLineageAccess(results[0]);
currentUserId = normalizeId(results[1] && results[1].userId);
setLineageEnabled(access.canEditContent);
renderTree(results[2]);
renderListSummary(results[3]);
renderLineageList(results[4]);
renderPagination(results[4]);
renderOptions(results[5]);
renderGenerationOptions(results[6]);
if (access.canEditContent) {
renderGenealogyMemberOptions(await api.genealogyMemberOptions(genealogyId));
}
} catch (error) {
if (redirectUnauthorized(api, error)) return;
if (isForbidden(error)) {
renderForbiddenState();
return;
}
['[data-lineage-tree]', '[data-lineage-list]'].forEach(function (selector) {
var container = query(selector);
if (container) container.innerHTML = '<div class="api-empty">世系人物加载失败,请稍后重试</div>';
});
showMessage(error.message || '世系人物加载失败');
}
}
@@ -496,34 +716,63 @@
}
relationMode = mode;
editingPersonId = '';
setRelationFieldVisibility(mode === 'spouses');
if (submitButton) submitButton.textContent = '新增' + relationLabel(mode);
setFormStatus('当前将为“' + person.name + '”新增' + relationLabel(mode));
}
function toLocalDateTime(value) {
var text = trimOrUndefined(value);
return text ? text.slice(0, 16).replace(' ', 'T') : '';
}
function setRelationFieldVisibility(visible) {
var field = query('[data-lineage-spouse-field]');
if (field) field.hidden = !visible;
}
function fillForm(person) {
var form = query('[data-lineage-form]');
var values;
var submitButton;
var binding;
if (!form || !person) return;
binding = normalizeLineageBinding(person, currentUserId);
values = {
bindingMode: binding.bindingMode,
appUserId: binding.appUserId,
avatarOssId: person.avatarOssId,
name: person.name,
sex: person.sex,
generation: person.generation,
generationName: person.generationName,
fatherId: person.fatherId,
motherId: person.motherId,
personNo: person.personNo,
aliasName: person.aliasName,
birthDate: person.birthDate ? person.birthDate.slice(0, 10) : '',
deathDate: person.deathDate ? person.deathDate.slice(0, 10) : '',
birthDate: toLocalDateTime(person.birthDate),
birthLunar: person.birthLunar,
birthPlace: person.birthPlace,
deathDate: toLocalDateTime(person.deathDate),
deathLunar: person.deathLunar,
deathPlace: person.deathPlace,
burialPlace: person.burialPlace,
personStatus: person.personStatus,
sortOrder: person.sortOrder,
relationName: person.relationName,
biography: person.biography
biography: person.biography,
remark: person.remark
};
queryAll('[name]', form).forEach(function (field) {
field.value = values[field.name] === undefined || values[field.name] === null ? '' : values[field.name];
});
updateBindingFields();
relationMode = '';
editingPersonId = person.personId;
setRelationFieldVisibility(false);
submitButton = query('[data-lineage-form] button[type="submit"]');
if (submitButton) submitButton.textContent = '保存修改';
setFormStatus('正在编辑“' + person.name + '”');
@@ -534,8 +783,10 @@
relationMode = '';
editingPersonId = '';
setRelationFieldVisibility(false);
if (submitButton) submitButton.textContent = '保存成员';
setFormStatus('');
if (root.setTimeout) root.setTimeout(updateBindingFields, 0);
}
async function submitPerson(form) {
@@ -627,8 +878,23 @@
});
documentRef.addEventListener('change', function (event) {
var select = event.target.closest('[data-lineage-person-options]');
var generationSelect = event.target.closest('[data-lineage-generation]');
var bindingSelect = event.target.closest('[name="bindingMode"]');
var selectedOption;
var generationName;
var appUserId;
if (select && select.value) loadPersonDetail(select.value);
if (generationSelect) {
selectedOption = generationSelect.options[generationSelect.selectedIndex];
generationName = query('[name="generationName"]', generationSelect.form);
if (generationName) generationName.value = selectedOption ? selectedOption.getAttribute('data-generation-name') || '' : '';
}
if (bindingSelect) {
appUserId = query('[name="appUserId"]', bindingSelect.form);
if (bindingSelect.value !== 'SPECIFIED' && appUserId) appUserId.value = '';
updateBindingFields();
}
});
documentRef.addEventListener('click', function (event) {
var target = event.target;
@@ -645,7 +911,7 @@
}
if (pageButton) {
event.preventDefault();
if (!managementAccess || writePending) return;
if (!canUseLineagePagination(writePending)) return;
if (pageButton.getAttribute('data-lineage-page-action') === 'previous' && currentPageNum > 1) {
loadLineage(getCurrentKeyword(), currentPageNum - 1);
}
@@ -688,6 +954,13 @@
buildLineagePersonBody: buildLineagePersonBody,
validateLineagePersonBody: validateLineagePersonBody,
normalizeLineagePerson: normalizeLineagePerson,
normalizeLineageAccess: normalizeLineageAccess,
canUseLineagePagination: canUseLineagePagination,
normalizeGenerationOption: normalizeGenerationOption,
normalizeGenealogyMemberOption: normalizeGenealogyMemberOption,
normalizeGenealogyMemberOptions: normalizeGenealogyMemberOptions,
normalizeLineageBinding: normalizeLineageBinding,
buildLineagePageQuery: buildLineagePageQuery,
normalizeList: normalizeList,
relationLabel: relationLabel,
shouldRedirectToLogin: shouldRedirectToLogin,