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
+287 -45
View File
@@ -14,118 +14,360 @@
'use strict';
var documentRef = root.document;
var notificationsById = Object.create(null);
var selectedNotificationId = '';
var writePending = false;
function getApi() {
return root.GenealogyApi && root.GenealogyApi.defaultClient;
}
function normalizeNotifications(data) {
return Array.isArray(data) ? data : [];
function query(selector, node) {
return documentRef ? (node || documentRef).querySelector(selector) : null;
}
function formatNotification(data) {
return JSON.stringify(data || {}, null, 2);
function queryAll(selector, node) {
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
}
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 normalizeNullableId(value) {
var id;
if (value === undefined || value === null || value === '') return null;
id = normalizeId(value);
return id || undefined;
}
function stringValue(value) {
return value === undefined || value === null ? '' : String(value);
}
function buildNotificationQuery(readStatus) {
return readStatus === '0' || readStatus === '1' ? { readStatus: readStatus } : {};
}
function normalizeNotification(item) {
var notificationId = normalizeId(item && item.notificationId);
var genealogyId = normalizeNullableId(item && item.genealogyId);
var senderUserId = normalizeNullableId(item && item.senderUserId);
var bizId = normalizeNullableId(item && item.bizId);
var readStatus = stringValue(item && item.readStatus);
var status = stringValue(item && item.status);
if (!notificationId || genealogyId === undefined || senderUserId === undefined || bizId === undefined) return null;
if ((readStatus !== '0' && readStatus !== '1') || (status !== '0' && status !== '1')) return null;
return {
notificationId: notificationId,
genealogyId: genealogyId,
genealogyNo: stringValue(item.genealogyNo),
genealogyName: stringValue(item.genealogyName),
senderUserId: senderUserId,
senderNickName: stringValue(item.senderNickName),
senderPhone: stringValue(item.senderPhone),
noticeType: stringValue(item.noticeType),
noticeTitle: stringValue(item.noticeTitle),
noticeContent: stringValue(item.noticeContent),
bizType: stringValue(item.bizType),
bizId: bizId,
bizSummary: stringValue(item.bizSummary),
publishTime: stringValue(item.publishTime),
readStatus: readStatus,
readTime: item.readTime === undefined || item.readTime === null ? null : String(item.readTime),
status: status,
remark: stringValue(item.remark)
};
}
function normalizeNotifications(data) {
var normalized;
if (!Array.isArray(data)) return [];
normalized = data.map(normalizeNotification);
return normalized.some(function (item) { return !item; }) ? [] : normalized;
}
function getUnreadCount(data) {
var count = Number(data);
return Number.isFinite(count) && count >= 0 ? count : 0;
return Number.isSafeInteger(count) && count >= 0 ? count : 0;
}
function query(selector, rootNode) {
return (rootNode || documentRef).querySelector(selector);
function matchesNotification(item, expectedNotificationId) {
var notification = normalizeNotification(item);
return Boolean(notification && notification.notificationId === normalizeId(expectedNotificationId));
}
function canMarkRead(notification) {
return Boolean(notification && notification.status === '0' && notification.readStatus === '0');
}
function escapeHtml(value) {
return stringValue(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderNotificationRow(notification) {
var statusText = notification.readStatus === '0' ? '未读' : '已读';
var action = canMarkRead(notification)
? '<button class="pill" type="button" data-notification-read-id="' + escapeHtml(notification.notificationId) + '">标记已读</button>'
: '';
return '<article class="module-row notification-row' + (notification.readStatus === '0' ? ' is-unread' : '') + '">' +
'<div><h3>' + escapeHtml(notification.noticeTitle || '通知') + '</h3><p>' +
escapeHtml(notification.noticeContent || '暂无通知内容') + '</p><p>' +
escapeHtml([notification.publishTime, statusText].filter(Boolean).join(' · ')) +
'</p></div><div class="row-actions"><button class="pill" type="button" data-notification-detail-id="' +
escapeHtml(notification.notificationId) + '">查看详情</button>' + action + '</div></article>';
}
function detailValue(label, value) {
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
}
function renderNotificationDetail(notification) {
var action;
if (!notification) return '<div class="api-empty">请选择一条通知查看详情</div>';
action = canMarkRead(notification)
? '<div class="bottom-actions"><button class="btn ghost" type="button" data-notification-read-id="' +
escapeHtml(notification.notificationId) + '">标记为已读</button></div>'
: '';
return '<div class="form-like notification-detail">' +
detailValue('通知标题', notification.noticeTitle) +
detailValue('通知内容', notification.noticeContent) +
detailValue('通知类型', notification.noticeType) +
detailValue('家谱', notification.genealogyName) +
detailValue('发送人', notification.senderNickName) +
detailValue('业务类型', notification.bizType) +
detailValue('业务摘要', notification.bizSummary) +
detailValue('发布时间', notification.publishTime) +
detailValue('阅读状态', notification.readStatus === '0' ? '未读' : '已读') +
detailValue('阅读时间', notification.readTime) +
detailValue('备注', notification.remark) +
'</div>' + action;
}
function showMessage(message) {
if (root.layui && root.layui.layer) {
root.layui.layer.msg(message);
return;
}
if (root.alert) root.alert(message);
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
else if (root.alert) root.alert(message);
}
function renderNotifications(container, notifications) {
if (!container) return;
container.innerHTML = '';
function shouldRedirectToLogin(api, error) {
var status = error && (error.status || error.code);
if (!notifications.length) {
container.textContent = '暂无通知';
return;
}
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
}
notifications.forEach(function (notification) {
var row = documentRef.createElement('pre');
row.className = 'notification-raw';
row.textContent = formatNotification(notification);
container.appendChild(row);
});
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');
else root.location.href = 'login.html';
return true;
}
function updateStatus(message) {
var status = query('[data-notification-status]');
if (status) status.textContent = message;
if (status) status.textContent = message || '';
}
function setWritePending(value) {
writePending = Boolean(value);
queryAll('[data-notification-read-id], [data-notification-read-all], [data-notification-refresh], [data-notification-filter]').forEach(function (control) {
control.disabled = writePending;
});
}
function renderNotifications(data) {
var container = query('[data-notification-list]');
var normalized = Array.isArray(data) ? data.map(normalizeNotification) : [];
var invalidCount = normalized.filter(function (item) { return !item; }).length;
var notifications = normalized.filter(Boolean);
notificationsById = Object.create(null);
notifications.forEach(function (notification) {
notificationsById[notification.notificationId] = notification;
});
if (!container) return notifications;
if (!Array.isArray(data) || invalidCount) {
container.innerHTML = '<div class="api-empty">通知列表响应缺少稳定 NotificationView 字段,请联系后端核对。</div>';
return [];
}
if (!notifications.length) {
container.innerHTML = '<div class="api-empty">暂无通知</div>';
return [];
}
container.innerHTML = notifications.map(renderNotificationRow).join('');
return notifications;
}
function renderDetail(notification) {
var container = query('[data-notification-detail]');
if (container) container.innerHTML = renderNotificationDetail(notification);
}
async function loadNotificationDetail(notificationId) {
var api = getApi();
var notification;
if (!normalizeId(notificationId) || redirectUnauthorized(api)) return null;
try {
notification = normalizeNotification(await api.notificationDetail(notificationId));
if (!notification || !matchesNotification(notification, notificationId)) {
throw new Error('通知详情未返回同一条稳定 NotificationView');
}
selectedNotificationId = notification.notificationId;
notificationsById[notification.notificationId] = notification;
renderDetail(notification);
return notification;
} catch (error) {
if (redirectUnauthorized(api, error)) return null;
renderDetail(null);
updateStatus(error.message || '通知详情加载失败');
return null;
}
}
function currentFilter() {
var filter = query('[data-notification-filter]');
return filter ? filter.value : '';
}
async function loadNotifications() {
var api = getApi();
var container = query('[data-notification-list]');
var countTarget = query('[data-notification-unread-count]');
var result;
var notifications;
var nextSelectedId;
if (!api || !api.notifications || !api.unreadNotificationCount) {
if (redirectUnauthorized(api)) return;
if (!api.notifications || !api.notificationDetail || !api.unreadNotificationCount) {
updateStatus('消息接口初始化失败,请刷新后重试');
return;
}
updateStatus('正在同步消息…');
try {
result = await Promise.all([api.notifications(), api.unreadNotificationCount()]);
renderNotifications(container, normalizeNotifications(result[0]));
result = await Promise.all([
api.notifications(buildNotificationQuery(currentFilter())),
api.unreadNotificationCount()
]);
notifications = renderNotifications(result[0]);
if (countTarget) countTarget.textContent = String(getUnreadCount(result[1]));
updateStatus('已同步消息。列表元素 DTO 尚未在 PC 契约展开,以下保留服务端原始记录。');
nextSelectedId = selectedNotificationId && notificationsById[selectedNotificationId]
? selectedNotificationId
: (notifications[0] && notifications[0].notificationId);
if (nextSelectedId) await loadNotificationDetail(nextSelectedId);
else renderDetail(null);
updateStatus('消息已同步');
} catch (error) {
if (redirectUnauthorized(api, error)) return;
renderNotifications(null);
renderDetail(null);
updateStatus(error.message || '消息同步失败');
}
}
async function markAllRead(button) {
async function markNotificationRead(notificationId) {
var api = getApi();
var notification = notificationsById[notificationId];
if (writePending || !normalizeId(notificationId) || !canMarkRead(notification) || redirectUnauthorized(api)) return;
setWritePending(true);
try {
await api.markNotificationRead(notificationId);
selectedNotificationId = notificationId;
showMessage('通知已标记为已读');
await loadNotifications();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
updateStatus(error.message || '标记通知已读失败');
} finally {
setWritePending(false);
}
}
async function markAllRead() {
var api = getApi();
if (!api || !api.markAllNotificationsRead) {
if (writePending || redirectUnauthorized(api)) return;
if (!api.markAllNotificationsRead) {
updateStatus('消息接口初始化失败,请刷新后重试');
return;
}
button.disabled = true;
setWritePending(true);
try {
await api.markAllNotificationsRead();
showMessage('已全部标记为已读');
await loadNotifications();
} catch (error) {
if (redirectUnauthorized(api, error)) return;
updateStatus(error.message || '全部标记已读失败');
} finally {
button.disabled = false;
setWritePending(false);
}
}
function bindActions() {
if (!documentRef) return;
documentRef.addEventListener('click', function (event) {
var detailButton = event.target.closest('[data-notification-detail-id]');
var readButton = event.target.closest('[data-notification-read-id]');
if (detailButton) {
event.preventDefault();
loadNotificationDetail(detailButton.getAttribute('data-notification-detail-id'));
return;
}
if (readButton) {
event.preventDefault();
markNotificationRead(readButton.getAttribute('data-notification-read-id'));
}
});
if (query('[data-notification-read-all]')) {
query('[data-notification-read-all]').addEventListener('click', markAllRead);
}
if (query('[data-notification-refresh]')) {
query('[data-notification-refresh]').addEventListener('click', loadNotifications);
}
if (query('[data-notification-filter]')) {
query('[data-notification-filter]').addEventListener('change', function () {
selectedNotificationId = '';
loadNotifications();
});
}
}
function init() {
var readAll = query('[data-notification-read-all]');
var refresh = query('[data-notification-refresh]');
if (!documentRef || !query('[data-notification-list]')) return;
if (readAll) readAll.addEventListener('click', function () { markAllRead(readAll); });
if (refresh) refresh.addEventListener('click', loadNotifications);
bindActions();
loadNotifications();
}
return {
buildNotificationQuery: buildNotificationQuery,
normalizeNotification: normalizeNotification,
normalizeNotifications: normalizeNotifications,
formatNotification: formatNotification,
getUnreadCount: getUnreadCount,
matchesNotification: matchesNotification,
canMarkRead: canMarkRead,
renderNotificationRow: renderNotificationRow,
renderNotificationDetail: renderNotificationDetail,
shouldRedirectToLogin: shouldRedirectToLogin,
loadNotifications: loadNotifications,
init: init
};