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
+316
View File
@@ -0,0 +1,316 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(root);
return;
}
root.CeremonyPages = factory(root);
if (root.document) {
root.document.addEventListener('DOMContentLoaded', function () {
root.CeremonyPages.init();
});
}
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
'use strict';
var documentRef = root.document;
var invitationsById = Object.create(null);
var selectedInvitationId = '';
var writePending = false;
var invitationStatuses = ['PENDING', 'ACCEPTED', 'DECLINED', 'CANCELED'];
var responseStatuses = ['ACCEPTED', 'DECLINED'];
var statusLabels = {
PENDING: '待响应',
ACCEPTED: '已接受',
DECLINED: '已拒绝',
CANCELED: '已取消'
};
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function query(selector, rootNode) {
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
}
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
function redirectUnauthorized(api, error) {
if (!shouldRedirectToLogin(api, error)) return false;
if (api && api.clearToken) api.clearToken();
if (!root.location) return true;
if (typeof root.location.replace === 'function') {
root.location.replace('login.html');
return true;
}
root.location.href = 'login.html';
return true;
}
function normalizeId(value) {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function optionalInteger(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isSafeInteger(number) ? number : undefined;
}
function optionalNumber(value) {
var number;
if (value === undefined || value === null || value === '') return undefined;
number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function normalizeInvitation(item) {
var invitationId = normalizeId(item && item.invitationId);
var genealogyId = normalizeId(item && item.genealogyId);
var ceremonyId = normalizeId(item && item.ceremonyId);
var inviteeUserId = normalizeId(item && item.inviteeUserId);
var inviteStatus = stringValue(item && item.inviteStatus);
var invitation;
var inviteVersion;
var longitude;
var latitude;
if (!invitationId || !genealogyId || !ceremonyId || invitationStatuses.indexOf(inviteStatus) < 0) {
return null;
}
invitation = {
invitationId: invitationId,
genealogyId: genealogyId,
ceremonyId: ceremonyId,
inviteStatus: inviteStatus,
deliveredTime: stringValue(item.deliveredTime),
readTime: stringValue(item.readTime),
responseTime: stringValue(item.responseTime),
ceremonyTitle: stringValue(item.ceremonyTitle),
ceremonyTime: stringValue(item.ceremonyTime),
location: stringValue(item.location),
locationAddress: stringValue(item.locationAddress)
};
if (inviteeUserId) invitation.inviteeUserId = inviteeUserId;
inviteVersion = optionalInteger(item.inviteVersion);
longitude = optionalNumber(item.longitude);
latitude = optionalNumber(item.latitude);
if (inviteVersion !== undefined) invitation.inviteVersion = inviteVersion;
if (longitude !== undefined) invitation.longitude = longitude;
if (latitude !== undefined) invitation.latitude = latitude;
return invitation;
}
function normalizeList(data) {
return Array.isArray(data) ? data : [];
}
function buildInvitationResponseBody(values) {
return {
inviteStatus: stringValue(values && values.inviteStatus)
};
}
function validateInvitationResponseBody(body) {
return !body || responseStatuses.indexOf(body.inviteStatus) < 0 ? '邀请只能选择接受或拒绝' : '';
}
function canRespondToInvitation(invitation) {
return Boolean(invitation && invitation.inviteStatus === 'PENDING');
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function invitationTitle(invitation) {
return invitation.ceremonyTitle || '未命名贺礼活动';
}
function renderInvitationRow(invitation) {
var actions = '<button class="pill" type="button" data-invitation-detail="' +
escapeHtml(invitation.invitationId) + '">查看详情</button>';
if (canRespondToInvitation(invitation)) {
actions += '<button class="pill" type="button" data-invitation-response="ACCEPTED" data-invitation-id="' +
escapeHtml(invitation.invitationId) + '">接受邀请</button>' +
'<button class="pill is-danger" type="button" data-invitation-response="DECLINED" data-invitation-id="' +
escapeHtml(invitation.invitationId) + '">拒绝邀请</button>';
}
return '<article class="module-row ceremony-invitation-row" data-invitation-row="' +
escapeHtml(invitation.invitationId) + '"><div><h3>' + escapeHtml(invitationTitle(invitation)) +
'</h3><p>' + escapeHtml(statusLabels[invitation.inviteStatus]) +
(invitation.ceremonyTime ? ' · ' + escapeHtml(invitation.ceremonyTime) : '') +
(invitation.location ? ' · ' + escapeHtml(invitation.location) : '') +
'</p></div><div class="row-actions">' + actions + '</div></article>';
}
function renderDetailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderInvitationDetail(invitation) {
var mapLink = '';
if (!invitation) return '<div class="api-empty">请选择一条邀请查看详情</div>';
if (invitation.longitude !== undefined && invitation.latitude !== undefined) {
mapLink = ' <a class="pill" target="_blank" rel="noopener noreferrer" href="https://uri.amap.com/marker?position=' +
encodeURIComponent(invitation.longitude + ',' + invitation.latitude) + '&amp;name=' +
encodeURIComponent(invitation.location || invitation.locationAddress || invitationTitle(invitation)) +
'">打开地图</a>';
}
return '<div class="form-like invitation-detail">' +
renderDetailValue('活动标题', invitationTitle(invitation)) +
renderDetailValue('邀请状态', statusLabels[invitation.inviteStatus]) +
renderDetailValue('活动时间', invitation.ceremonyTime) +
renderDetailValue('地点', invitation.location) +
'<p><span>详细地址</span><b>' + escapeHtml(invitation.locationAddress || '未提供') + mapLink + '</b></p>' +
renderDetailValue('投递时间', invitation.deliveredTime) +
renderDetailValue('阅读时间', invitation.readTime) +
renderDetailValue('响应时间', invitation.responseTime) +
'</div>';
}
function renderDetail(invitation) {
var container = query('[data-my-invitation-detail]');
selectedInvitationId = invitation ? invitation.invitationId : '';
if (container) container.innerHTML = renderInvitationDetail(invitation);
}
function renderInvitations(data) {
var container = query('[data-my-invitation-list]');
var normalized = normalizeList(data).map(normalizeInvitation);
var invalidCount = normalized.filter(function (item) { return !item; }).length;
var invitations = normalized.filter(Boolean);
invitationsById = Object.create(null);
invitations.forEach(function (invitation) {
invitationsById[invitation.invitationId] = invitation;
});
if (!container) return invitations;
if (invalidCount) {
container.innerHTML = '<div class="api-empty">邀请响应缺少稳定 ID 或合法状态,请联系后端核对 DTO。</div>';
renderDetail(null);
return [];
}
if (!invitations.length) {
container.innerHTML = '<div class="api-empty">暂无贺礼邀请</div>';
renderDetail(null);
return [];
}
container.innerHTML = invitations.map(renderInvitationRow).join('');
renderDetail(invitationsById[selectedInvitationId] || invitations[0]);
return invitations;
}
function setWritePending(pending) {
var buttons;
writePending = pending;
if (!documentRef) return;
buttons = documentRef.querySelectorAll('[data-invitation-response]');
Array.prototype.forEach.call(buttons, function (button) {
button.disabled = pending;
});
}
function setListState(message) {
var container = query('[data-my-invitation-list]');
if (container) container.innerHTML = '<div class="api-empty">' + escapeHtml(message) + '</div>';
}
async function loadMyInvitations() {
var api = getApi();
if (redirectUnauthorized(api)) return;
setListState('正在加载我的邀请…');
try {
renderInvitations(await api.myCeremonyInvitations());
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权查看邀请' : (error.message || '邀请加载失败'));
renderDetail(null);
}
}
async function respondToInvitation(invitationId, inviteStatus) {
var api = getApi();
var invitation = invitationsById[normalizeId(invitationId)];
var body = buildInvitationResponseBody({ inviteStatus: inviteStatus });
var validation = validateInvitationResponseBody(body);
if (writePending || redirectUnauthorized(api) || !invitation || !canRespondToInvitation(invitation)) return;
if (validation) return;
setWritePending(true);
try {
await api.respondCeremonyInvitation(invitation.genealogyId, invitation.ceremonyId, body);
selectedInvitationId = invitation.invitationId;
await loadMyInvitations();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权响应该邀请' : (error.message || '邀请响应失败'));
} finally {
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-invitation-detail]');
var responseButton = event.target.closest('[data-invitation-response]');
if (detailButton) {
event.preventDefault();
renderDetail(invitationsById[detailButton.getAttribute('data-invitation-detail')] || null);
return;
}
if (!responseButton) return;
event.preventDefault();
respondToInvitation(
responseButton.getAttribute('data-invitation-id'),
responseButton.getAttribute('data-invitation-response')
);
});
}
function init() {
if (!documentRef || !query('[data-ceremony-page]')) return;
bindActions();
loadMyInvitations();
}
return {
normalizeInvitation: normalizeInvitation,
normalizeList: normalizeList,
buildInvitationResponseBody: buildInvitationResponseBody,
validateInvitationResponseBody: validateInvitationResponseBody,
canRespondToInvitation: canRespondToInvitation,
renderInvitationRow: renderInvitationRow,
renderInvitationDetail: renderInvitationDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
loadMyInvitations: loadMyInvitations,
init: init
};
});