feat(api): 添加帮助中心反馈系统和VIP服务功能

- 在ApiClient中新增submitFeedback、myFeedback、helpArticles、helpArticleDetail、
  siteArticles、promotions、vipPackages、createVipOrder、vipOrders等方法
- 添加帮助文章和站点资讯的参数验证逻辑
- 更新测试文件添加新的API方法测试用例
- 在HTML页面中添加反馈、帮助和VIP服务相关页面的脚本引用
- 更新加入家谱页面为完整的申请流程界面
- 修改资讯详情页面为站点资讯展示页面
- 更新AxiosRequestUtil中认证处理逻辑
- 添加世系树渲染的HTML生成函数用于页面复用
- 更新文档中的API契约说明和页面规划
This commit is contained in:
fizzleaf
2026-07-30 16:04:48 +08:00
parent fb1743aa2a
commit 309598bfa6
53 changed files with 6740 additions and 282 deletions
+174
View File
@@ -32,6 +32,10 @@ test('API client exposes latest document-defined PC operations', () => {
'genealogiesMine', 'genealogyOptions', 'genealogyDetail', 'genealogyOverview', 'genealogyQuota',
'createGenealogy', 'applyToGenealogy', 'myGenealogyJoinApplies',
'pendingGenealogyJoinApplies', 'auditGenealogyJoinApply', 'cancelGenealogyJoinApply',
'submitFeedback', 'myFeedback',
'helpArticles', 'helpArticleDetail', 'siteArticles',
'promotions',
'vipPackages', 'createVipOrder', 'vipOrders',
'notifications', 'notificationDetail', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
@@ -506,6 +510,110 @@ test('genealogy quota uses the current PC path without manufacturing a genealogy
await client.genealogyQuota();
});
test('help article methods use authenticated PC paths and whitelist the optional category', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.helpArticles({ helpCategory: ' account ', keyword: 'must-drop' });
await client.helpArticleDetail('2062179707935264769');
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.params,
config.headers.Authorization
]), [
['get', '/genealogy/pc/help-articles', { helpCategory: ' account ' }, 'Bearer access-token'],
['get', '/genealogy/pc/help-articles/2062179707935264769', undefined, 'Bearer access-token']
]);
});
test('promotion method uses the authenticated PC path and whitelists platform query', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.promotions({
platform: 'pc',
keyword: 'must-drop'
});
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.params,
config.headers.Authorization
]), [
['get', '/genealogy/pc/promotions', { platform: 'pc' }, 'Bearer access-token']
]);
assert.throws(
() => client.promotions({ platform: 'desktop' }),
/不支持的推广平台/
);
});
test('site article method uses the public PC path and validates SQL query values', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: {
getItem() { return 'access-token'; },
setItem() {},
removeItem() {}
},
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.siteArticles({
articleType: 'notice',
limit: 100,
keyword: 'must-drop'
});
await client.siteArticles({ limit: 100 });
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.params,
config.headers.Authorization
]), [
['get', '/genealogy/pc/site/articles', { articleType: 'notice', limit: 100 }, undefined],
['get', '/genealogy/pc/site/articles', { limit: 100 }, undefined]
]);
assert.throws(
() => client.siteArticles({ articleType: 'culture', limit: 100 }),
/不支持的资讯类型/
);
[0, -1, 1.5, 101, '100'].forEach((limit) => {
assert.throws(
() => client.siteArticles({ limit }),
/资讯数量限制/
);
});
});
test('genealogy context methods obtain IDs from the real PC list and detail paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -613,6 +721,72 @@ test('genealogy lifecycle methods use PC paths and strict request bodies', async
);
});
test('feedback methods use the PC collection path and AppFeedbackBody whitelist', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.submitFeedback({
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
});
await client.myFeedback();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['post', '/genealogy/pc/feedback', {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
}],
['get', '/genealogy/pc/feedback', undefined]
]);
});
test('VIP methods use PC package and order paths with AppVipOrderBody whitelist', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.vipPackages();
await client.createVipOrder({
packageId: '2062179707935264769',
genealogyId: '2062179707935264770',
payType: 'wechat',
appUserId: 'must-drop',
payStatus: 'must-drop'
});
await client.vipOrders();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/vip/packages', undefined],
['post', '/genealogy/pc/vip/orders', {
packageId: '2062179707935264769',
genealogyId: '2062179707935264770',
payType: 'wechat'
}],
['get', '/genealogy/pc/vip/orders', undefined]
]);
});
test('notification methods use the current PC list, unread and read paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
+136
View File
@@ -0,0 +1,136 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const AppPromotionPages = require('../public/js/app-promotion-pages.js');
const appPage = fs.readFileSync(path.resolve(__dirname, '../app.html'), 'utf8');
const promotionId = '2062179707935264769';
function promotionFixture(overrides) {
return Object.assign({
promotionId,
promotionKey: 'app-download',
promotionTitle: '下载移动端',
promotionDesc: '随时查看家谱内容',
coverOssId: '2062179707935264701',
targetUrl: 'https://example.com/download',
platform: 'all',
sortOrder: 10,
status: '0',
remark: 'internal-only'
}, overrides);
}
test('推广响应只保留 AppPromotionVo 的安全展示字段并保持长 ID 字符串', () => {
assert.deepEqual(AppPromotionPages.normalizePromotion(promotionFixture()), {
promotionId,
promotionTitle: '下载移动端',
promotionDesc: '随时查看家谱内容',
targetUrl: 'https://example.com/download'
});
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({
promotionId: Number.MAX_SAFE_INTEGER + 1
})), null);
});
test('推广响应拒绝停用记录和空标题', () => {
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({ status: '1' })), null);
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({ promotionTitle: ' ' })), null);
});
test('推广列表只接受直接数组且任一非法元素使整批失败', () => {
assert.equal(AppPromotionPages.normalizePromotionList([promotionFixture()]).length, 1);
assert.deepEqual(AppPromotionPages.normalizePromotionList([]), []);
assert.deepEqual(AppPromotionPages.normalizePromotionList({
rows: [promotionFixture()]
}), []);
assert.deepEqual(AppPromotionPages.normalizePromotionList([
promotionFixture(),
promotionFixture({ promotionId: '' })
]), []);
});
test('推广链接只允许绝对 HTTP 或 HTTPS 地址', () => {
assert.equal(
AppPromotionPages.normalizeTargetUrl(' https://example.com/download '),
'https://example.com/download'
);
assert.equal(AppPromotionPages.normalizeTargetUrl('http://example.com'), 'http://example.com/');
['javascript:alert(1)', 'data:text/html,test', '/download', 'download.html', ''].forEach((value) => {
assert.equal(AppPromotionPages.normalizeTargetUrl(value), '');
});
});
test('推广卡片转义响应并隐藏 OSS ID、后台键、排序、状态和备注', () => {
const html = AppPromotionPages.renderPromotionList([promotionFixture({
promotionTitle: '<script>alert(1)</script>',
promotionDesc: '<img src=x>',
coverOssId: 'secret-oss',
promotionKey: 'secret-key',
remark: 'secret-remark'
})]);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /&lt;img src=x&gt;/);
assert.match(html, /target="_blank"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.doesNotMatch(
html,
/<script|<img|secret-oss|secret-key|secret-remark|coverOssId|promotionKey|sortOrder|status|remark/
);
});
test('危险或缺失的推广链接渲染为不可点击卡片', () => {
const html = AppPromotionPages.renderPromotionList([
promotionFixture({ targetUrl: 'javascript:alert(1)' })
]);
assert.match(html, /^<article class="promotion-card"/);
assert.doesNotMatch(html, /<a(?:\s|>)|href=|target=|rel=/);
});
test('PC 推广列表只请求 SQL 字典确认的 pc 平台', async () => {
const calls = [];
const api = {
async promotions() {
calls.push(Array.from(arguments));
return [promotionFixture()];
}
};
assert.equal((await AppPromotionPages.loadPromotions(api)).length, 1);
assert.deepEqual(calls, [[{ platform: 'pc' }]]);
await assert.rejects(
AppPromotionPages.loadPromotions({
async promotions() {
return { rows: [promotionFixture()] };
}
}),
/推广列表响应无效/
);
});
test('推广区域只把 401 视为登录失效', () => {
assert.equal(AppPromotionPages.isUnauthorized({ status: 401 }), true);
assert.equal(AppPromotionPages.isUnauthorized({ code: '401' }), true);
assert.equal(AppPromotionPages.isUnauthorized({ status: 403 }), false);
assert.equal(AppPromotionPages.isUnauthorized(new Error('网络失败')), false);
});
test('推广区域提供明确的未登录和空列表状态', () => {
assert.match(
AppPromotionPages.renderPromotionLoginRequired(),
/<a href="login\.html">立即登录<\/a>/
);
assert.match(AppPromotionPages.renderPromotionList([]), /当前暂无应用推广/);
});
test('应用下载页只通过页面标记和新推广模块接入,不暴露业务 ID 输入', () => {
assert.match(appPage, /<body[^>]*data-promotion-page/);
assert.match(appPage, /data-promotion-list/);
assert.match(appPage, /src="public\/js\/app-promotion-pages\.js"/);
assert.doesNotMatch(appPage, /src="public\/js\/promotion-pages\.js"/);
assert.doesNotMatch(appPage, /name="(?:promotionId|coverOssId)"/);
});
+186
View File
@@ -0,0 +1,186 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const FamilyHomePages = require('../public/js/family-home-pages.js');
const genealogyId = '2062179707935264769';
const root = path.resolve(__dirname, '..');
function overviewFixture(overrides) {
return Object.assign({
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
ancestralHall: '南阳堂',
originPlace: '四川成都',
regionCode: '510100',
regionName: '成都市',
regionFullName: '四川省 成都市',
addressDetail: '高新区',
coverOssId: '2062179707935264700',
intro: '叶氏源流',
ownerUserId: '2062179707935264701',
visibility: '1',
joinMode: '1',
memberCount: 12,
personCount: 36,
status: '0',
roleType: 'owner',
canManage: true,
canEditContent: true,
memberStatus: '0',
joinTime: '2026-07-29 12:00:00'
}, overrides);
}
test('家谱主页只保留 AppGenealogyVo 的安全展示字段并匹配当前上下文', () => {
assert.deepEqual(FamilyHomePages.normalizeFamilyOverview(
overviewFixture(),
genealogyId
), {
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
ancestralHall: '南阳堂',
originPlace: '四川成都',
regionFullName: '四川省 成都市',
memberCount: 12,
personCount: 36,
canManage: true,
canEditContent: true
});
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ genealogyId: '2062179707935264770' }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ genealogyId: Number.MAX_SAFE_INTEGER + 1 }),
genealogyId
), null);
});
test('家谱主页拒绝停用、空名称和非法计数响应', () => {
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ status: '1' }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ genealogyName: '' }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ memberCount: -1 }),
genealogyId
), null);
assert.equal(FamilyHomePages.normalizeFamilyOverview(
overviewFixture({ personCount: Number.MAX_SAFE_INTEGER + 1 }),
genealogyId
), null);
});
test('家谱概览渲染转义文本并隐藏内部用户与文件编号', () => {
const overview = FamilyHomePages.normalizeFamilyOverview(
overviewFixture({
genealogyName: '<叶氏家谱>',
ancestralHall: '<南阳堂>'
}),
genealogyId
);
const html = FamilyHomePages.renderFamilyOverview(overview);
assert.match(html, /&lt;叶氏家谱&gt;/);
assert.match(html, /&lt;南阳堂&gt;/);
assert.match(html, /成员 12 人/);
assert.match(html, /世系人物 36 人/);
assert.doesNotMatch(html, /ownerUserId|coverOssId|206217970793526470[01]/);
});
test('家谱管理入口只接受后端布尔能力字段', () => {
assert.equal(FamilyHomePages.shouldShowFamilyManagement(
FamilyHomePages.normalizeFamilyOverview(overviewFixture({ canManage: true }), genealogyId)
), true);
assert.equal(FamilyHomePages.shouldShowFamilyManagement(
FamilyHomePages.normalizeFamilyOverview(overviewFixture({ canManage: 'true' }), genealogyId)
), false);
});
test('家谱主页只把缺失登录态和 401 视为登录失效', () => {
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return 'token'; }
}, { status: 403 }), false);
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return 'token'; }
}, { status: 401 }), true);
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return ''; }
}), true);
});
test('家谱主页只读取当前家谱概览和世系树', async () => {
const calls = [];
const tree = [{
personId: '2062179707935264770',
genealogyId,
name: '始祖',
spouses: [],
children: []
}];
const result = await FamilyHomePages.loadFamilyHome({
genealogyOverview(receivedId) {
calls.push(['overview', receivedId]);
return Promise.resolve(overviewFixture());
},
lineageTree(receivedId) {
calls.push(['tree', receivedId]);
return Promise.resolve(tree);
}
}, genealogyId);
assert.deepEqual(calls, [
['overview', genealogyId],
['tree', genealogyId]
]);
assert.deepEqual(result.overview, FamilyHomePages.normalizeFamilyOverview(
overviewFixture(),
genealogyId
));
assert.deepEqual(result.tree, tree);
});
test('家谱主页开放真实只读概览且不制造邀请或聚合统计入口', () => {
const source = fs.readFileSync(path.join(root, 'profile-family-home.html'), 'utf8');
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.doesNotMatch(source, /四川武胜汤氏族/);
assert.doesNotMatch(source, /href="profile-invite\.html"/);
assert.match(source, /PC 暂未开放邀请/);
assert.match(source, /data-family-home-title/);
assert.match(source, /data-family-home-overview/);
assert.match(source, /data-family-home-member-count/);
assert.match(source, /data-family-home-person-count/);
assert.match(source, /data-family-home-management/);
assert.match(source, /data-lineage-home-tree/);
assert.match(source, /src="public\/js\/lineage-pages\.js"/);
assert.match(source, /src="public\/js\/family-home-pages\.js"/);
[
'profile-feed.html',
'profile-tree.html',
'profile-article.html',
'profile-album.html',
'profile-video.html',
'profile-merit.html',
'profile-ceremony.html'
].forEach((href) => {
assert.match(
source,
new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'),
`${href} 缺少家谱上下文`
);
});
});
+169
View File
@@ -0,0 +1,169 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const FeedbackPages = require('../public/js/feedback-pages.js');
const feedbackId = '2062179707935264769';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
function feedbackFixture(overrides) {
return Object.assign({
feedbackId,
appUserId: '2062179707935264701',
appUserNickName: '叶子',
appUserPhone: '19100000000',
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
handleStatus: '2',
handleResult: '已经修复',
handlerId: '2062179707935264702',
handleTime: '2026-07-29 13:00:00',
status: '0',
remark: '感谢反馈'
}, overrides);
}
test('反馈请求只构造 AppFeedbackBody 并裁剪文本', () => {
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: ' bug ',
feedbackContent: ' 页面按钮无响应 ',
contactInfo: ' 19181970173 ',
feedbackTitle: 'must-drop',
appUserId: 'must-drop',
handlerId: 'must-drop'
}), {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
});
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: '',
feedbackContent: ' 建议增加导出 ',
contactInfo: ' '
}), {
feedbackContent: '建议增加导出'
});
});
test('反馈内容必填且类型只允许后端确认的四种值', () => {
assert.equal(FeedbackPages.validateFeedbackBody({ feedbackContent: '建议增加导出' }), '');
assert.equal(FeedbackPages.validateFeedbackBody({
feedbackType: 'advice',
feedbackContent: '建议增加导出'
}), '');
assert.match(FeedbackPages.validateFeedbackBody({ feedbackContent: '' }), /反馈内容/);
assert.match(FeedbackPages.validateFeedbackBody({
feedbackType: 'suggestion',
feedbackContent: '建议增加导出'
}), /反馈类型/);
});
test('FeedbackVo 只保留用户可见字段并保持长 ID 字符串', () => {
assert.deepEqual(FeedbackPages.normalizeFeedback(feedbackFixture()), {
feedbackId,
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
handleStatus: '2',
handleResult: '已经修复',
handleTime: '2026-07-29 13:00:00',
status: '0',
remark: '感谢反馈'
});
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
feedbackId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
feedbackContent: ''
})), null);
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
handleStatus: '9'
})), null);
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
status: '9'
})), null);
});
test('反馈数组包含非法记录时整批拒绝', () => {
assert.equal(FeedbackPages.normalizeFeedbackList([feedbackFixture()]).length, 1);
assert.deepEqual(FeedbackPages.normalizeFeedbackList([
feedbackFixture(),
feedbackFixture({ feedbackId: '' })
]), []);
assert.deepEqual(FeedbackPages.normalizeFeedbackList({ rows: [feedbackFixture()] }), []);
});
test('详情只精确匹配 URL 中的反馈 ID 且不存在时不回退', () => {
const secondId = '2062179707935264770';
const list = [feedbackFixture(), feedbackFixture({ feedbackId: secondId, feedbackContent: '第二条' })];
assert.equal(FeedbackPages.getFeedbackId('?feedbackId=' + secondId), secondId);
assert.equal(FeedbackPages.getFeedbackId('?feedbackId=unsafe'), '');
assert.equal(FeedbackPages.findFeedbackById(list, secondId).feedbackContent, '第二条');
assert.equal(FeedbackPages.findFeedbackById(list, '2062179707935264999'), null);
assert.equal(
FeedbackPages.buildFeedbackDetailUrl(secondId),
'ticket-detail.html?feedbackId=2062179707935264770'
);
});
test('反馈列表和详情转义文本且不暴露内部账号信息', () => {
const item = feedbackFixture({
feedbackContent: '<script>alert(1)</script>',
handleResult: '<img src=x>',
appUserId: 'secret-user',
handlerId: 'secret-handler',
appUserPhone: 'secret-account-phone'
});
const listHtml = FeedbackPages.renderFeedbackList([item], {
detailPage: 'ticket-detail.html'
});
const detailHtml = FeedbackPages.renderFeedbackDetail(item);
assert.match(listHtml, /&lt;script&gt;/);
assert.match(detailHtml, /&lt;img src=x&gt;/);
assert.doesNotMatch(listHtml + detailHtml, /<script|<img|secret-user|secret-handler|secret-account-phone|appUserId|handlerId/);
});
test('两个提交页只提供 AppFeedbackBody 字段并加载真实脚本', () => {
['profile-feedback.html', 'submit-ticket.html'].forEach((page) => {
const source = read(page);
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /data-feedback-form/);
assert.match(source, /name="feedbackType"/);
assert.match(source, /name="feedbackContent"/);
assert.match(source, /name="contactInfo"/);
assert.match(source, /public\/js\/feedback-pages\.js/);
assert.doesNotMatch(source, /name="(?:feedbackTitle|feedbackId|appUserId|handlerId)"/);
});
});
test('我的工单和详情页读取同一反馈集合且不制造处理动作', () => {
const listPage = read('my-tickets.html');
const detailPage = read('ticket-detail.html');
[listPage, detailPage].forEach((source) => {
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /public\/js\/feedback-pages\.js/);
});
assert.match(listPage, /data-feedback-list/);
assert.match(listPage, /data-feedback-refresh/);
assert.match(detailPage, /data-feedback-detail/);
assert.doesNotMatch(detailPage, /回复|追问|删除|关闭工单|name="feedbackId"/);
});
test('反馈运行时提交后重读同一 ID,详情不存在时不回退', () => {
const source = read('public/js/feedback-pages.js');
assert.match(source, /await api\.submitFeedback\(/);
assert.match(source, /await api\.myFeedback\(\)/);
assert.match(source, /提交后无法读取同一反馈记录/);
assert.match(source, /反馈记录不存在或无权查看/);
});
+116
View File
@@ -0,0 +1,116 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const HelpPages = require('../public/js/help-pages.js');
const helpId = '2062179707935264769';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
function helpFixture(overrides) {
return Object.assign({
helpId,
helpCategory: '账号与安全',
helpTitle: '如何修改登录密码?',
helpContent: '进入账号安全页,完成验证后修改密码。',
coverOssId: '2062179707935264701',
sortOrder: 10,
viewCount: 12,
status: '0',
remark: 'internal-only'
}, overrides);
}
test('帮助文章只保留 HelpArticleVo 的安全展示字段并保持长 ID 字符串', () => {
assert.deepEqual(HelpPages.normalizeHelpArticle(helpFixture()), {
helpId,
helpCategory: '账号与安全',
helpTitle: '如何修改登录密码?',
helpContent: '进入账号安全页,完成验证后修改密码。',
viewCount: '12'
});
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({
helpId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({
viewCount: '9007199254740993123'
})).viewCount, '9007199254740993123');
});
test('帮助文章拒绝停用、空标题、空正文和非法浏览量', () => {
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ status: '1' })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ helpTitle: ' ' })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ helpContent: '' })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ viewCount: -1 })), null);
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ viewCount: '1.5' })), null);
});
test('帮助文章列表只接受直接数组且任一非法元素使整批失败', () => {
assert.equal(HelpPages.normalizeHelpArticleList([helpFixture()]).length, 1);
assert.deepEqual(HelpPages.normalizeHelpArticleList([]), []);
assert.deepEqual(HelpPages.normalizeHelpArticleList({
rows: [helpFixture()]
}), []);
assert.deepEqual(HelpPages.normalizeHelpArticleList([
helpFixture(),
helpFixture({ helpId: '' })
]), []);
});
test('帮助文章列表和详情转义响应且不暴露 OSS ID、排序、状态和备注', () => {
const article = helpFixture({
helpTitle: '<script>alert(1)</script>',
helpContent: '<img src=x onerror=alert(1)>\n第二行',
helpCategory: '<b>分类</b>',
coverOssId: 'secret-oss',
remark: 'secret-remark'
});
const listHtml = HelpPages.renderHelpArticleList([article]);
const detailHtml = HelpPages.renderHelpArticleDetail(article);
assert.match(listHtml, /&lt;script&gt;/);
assert.match(detailHtml, /&lt;img src=x onerror=alert\(1\)&gt;<br \/>第二行/);
assert.doesNotMatch(listHtml + detailHtml, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
});
test('帮助文章详情必须与列表中点击的真实 helpId 一致', async () => {
const calls = [];
const api = {
async helpArticleDetail(id) {
calls.push(id);
return helpFixture();
}
};
assert.equal((await HelpPages.loadHelpArticleDetail(api, helpId)).helpId, helpId);
assert.deepEqual(calls, [helpId]);
await assert.rejects(
HelpPages.loadHelpArticleDetail({
async helpArticleDetail() {
return helpFixture({ helpId: '2062179707935264770' });
}
}, helpId),
/详情响应与请求编号不一致/
);
});
test('帮助中心只把缺失登录态和 401 视为登录失效', () => {
assert.equal(HelpPages.shouldRedirectToLogin(null), true);
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return ''; } }), true);
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 401 }), true);
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
});
test('帮助中心使用真实 PC 列表和详情,不再展示硬编码问答', () => {
const page = read('help.html');
assert.match(page, /data-help-list/);
assert.match(page, /data-help-status/);
assert.match(page, /public\/js\/help-pages\.js/);
assert.doesNotMatch(page, /如何创建第一本家谱|如何邀请亲人一起维护/);
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
});
+189
View File
@@ -0,0 +1,189 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const JoinPages = require('../public/js/join-pages.js');
const genealogyId = '2062179707935264769';
const applyId = '2062179707935264770';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
test('加入申请只构造 AppGenealogyJoinApplyBody 并裁剪可选文本', () => {
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' 叶子 ',
phone: ' 19181970173 ',
relationDesc: ' 族亲 ',
applyReason: ' 申请加入 ',
inviterUserId: 'must-drop',
genealogyId: 'must-drop'
}), {
applicantName: '叶子',
phone: '19181970173',
relationDesc: '族亲',
applyReason: '申请加入'
});
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' ',
phone: '',
relationDesc: null
}), {});
});
test('加入申请按后端长度约束校验四个可选字段', () => {
assert.equal(JoinPages.validateJoinApplyBody({ applicantName: '叶'.repeat(50) }), '');
assert.match(JoinPages.validateJoinApplyBody({ applicantName: '叶'.repeat(51) }), /50/);
assert.equal(JoinPages.validateJoinApplyBody({ phone: '1'.repeat(30) }), '');
assert.match(JoinPages.validateJoinApplyBody({ phone: '1'.repeat(31) }), /30/);
assert.equal(JoinPages.validateJoinApplyBody({ relationDesc: '亲'.repeat(100) }), '');
assert.match(JoinPages.validateJoinApplyBody({ relationDesc: '亲'.repeat(101) }), /100/);
assert.equal(JoinPages.validateJoinApplyBody({ applyReason: '因'.repeat(500) }), '');
assert.match(JoinPages.validateJoinApplyBody({ applyReason: '因'.repeat(501) }), /500/);
});
test('可申请家谱必须提供稳定字符串 ID、谱名和姓氏,并拒绝停用家谱', () => {
assert.deepEqual(JoinPages.normalizeJoinGenealogy({
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
regionFullName: '四川省',
memberCount: 12,
joinMode: '0',
status: '0',
ownerUserId: 'must-drop'
}), {
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
regionFullName: '四川省',
memberCount: 12,
joinMode: '0'
});
assert.equal(JoinPages.normalizeJoinGenealogy({
genealogyId: Number.MAX_SAFE_INTEGER + 1,
genealogyName: '叶氏家谱',
surname: '叶',
status: '0'
}), null);
assert.equal(JoinPages.normalizeJoinGenealogy({
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
status: '1'
}), null);
});
test('我的申请只接收稳定 ID 和 0/1/2/3 状态', () => {
assert.deepEqual(JoinPages.normalizeJoinApply({
applyId,
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
relationDesc: '族亲',
applyReason: '申请加入',
auditRemark: '已核实',
auditTime: '2026-07-29 12:00:00',
status: '1',
appUserId: 'must-drop',
inviterUserId: 'must-drop',
auditUserId: 'must-drop',
appUserPhone: 'must-drop'
}), {
applyId,
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
relationDesc: '族亲',
applyReason: '申请加入',
auditRemark: '已核实',
auditTime: '2026-07-29 12:00:00',
status: '1'
});
assert.equal(JoinPages.normalizeJoinApply({
applyId: Number.MAX_SAFE_INTEGER + 1,
genealogyId,
genealogyName: '叶氏家谱',
status: '0'
}), null);
assert.equal(JoinPages.normalizeJoinApply({
applyId,
genealogyId,
genealogyName: '叶氏家谱',
status: '9'
}), null);
});
test('申请数组只要包含非法元素就整批拒绝', () => {
const valid = { applyId, genealogyId, genealogyName: '叶氏家谱', status: '0' };
assert.equal(JoinPages.normalizeJoinApplies([valid]).length, 1);
assert.deepEqual(JoinPages.normalizeJoinApplies([valid, { status: '0' }]), []);
assert.deepEqual(JoinPages.normalizeJoinApplies({ rows: [valid] }), []);
});
test('普通用户列表隐藏内部字段且只有待审核申请可以撤销', () => {
const html = JoinPages.renderMyJoinApplies([{
applyId,
genealogyId,
genealogyName: '<叶氏家谱>',
surname: '叶',
relationDesc: '族亲',
applyReason: '申请加入',
status: '0',
appUserId: 'secret-user',
inviterUserId: 'secret-inviter',
auditUserId: 'secret-auditor',
appUserPhone: 'secret-phone'
}]);
const approvedHtml = JoinPages.renderMyJoinApplies([{
applyId,
genealogyId,
genealogyName: '叶氏家谱',
status: '1'
}]);
assert.match(html, /&lt;叶氏家谱&gt;/);
assert.match(html, new RegExp('data-join-cancel-id="' + applyId + '"'));
assert.doesNotMatch(approvedHtml, /data-join-cancel-id/);
assert.doesNotMatch(html, /secret-user|secret-inviter|secret-auditor|secret-phone|must-drop/);
});
test('申请加入页由后端家谱选项驱动且不允许手填业务 ID 或邀请码', () => {
const source = read('join-genealogy.html');
assert.match(source, /data-join-apply-page/);
assert.match(source, /data-join-genealogy-search/);
assert.match(source, /data-join-genealogy-options/);
assert.match(source, /data-join-apply-form/);
assert.match(source, /name="applicantName"/);
assert.match(source, /name="phone"/);
assert.match(source, /name="relationDesc"/);
assert.match(source, /name="applyReason"/);
assert.match(source, /public\/js\/join-pages\.js/);
assert.doesNotMatch(source, /name="(?:genealogyId|applyId|inviterUserId|inviteCode)"/);
assert.doesNotMatch(source, /邀请码|分享码|手工.*ID|输入.*ID/);
});
test('我的加入申请页加载真实列表和刷新入口', () => {
const source = read('profile-join-family.html');
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /data-my-join-applies-page/);
assert.match(source, /data-join-apply-list="mine"/);
assert.match(source, /data-join-apply-refresh/);
assert.match(source, /public\/js\/join-pages\.js/);
assert.doesNotMatch(source, /邀请码|分享码|输入.*ID/);
});
test('加入申请运行时提交与撤销后都重新读取我的申请', () => {
const source = read('public/js/join-pages.js');
assert.match(source, /await api\.applyToGenealogy\(/);
assert.match(source, /await api\.myGenealogyJoinApplies\(\)/);
assert.match(source, /await api\.cancelGenealogyJoinApply\(/);
assert.match(source, /提交后无法读取同一申请/);
assert.match(source, /撤销后申请仍处于待审核状态/);
});
+82
View File
@@ -0,0 +1,82 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const JoinReviewPages = require('../public/js/join-review-pages.js');
const genealogyId = '2062179707935264769';
const applyId = '2062179707935264770';
function read(relativePath) {
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
}
test('审核页只从当前家谱上下文读取稳定字符串 ID', () => {
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=' + genealogyId), genealogyId);
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=unsafe'), '');
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=1e21'), '');
});
test('审核请求只构造 status 和可选审核说明', () => {
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({
status: '2',
auditRemark: ' 资料不一致 ',
applyId: 'must-drop',
genealogyId: 'must-drop'
}), {
status: '2',
auditRemark: '资料不一致'
});
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({ status: '1', auditRemark: ' ' }), {
status: '1'
});
assert.equal(JoinReviewPages.validateJoinAuditBody({ status: '1' }), '');
assert.equal(JoinReviewPages.validateJoinAuditBody({ status: '2', auditRemark: '因'.repeat(500) }), '');
assert.match(JoinReviewPages.validateJoinAuditBody({ status: '0' }), /审核状态/);
assert.match(JoinReviewPages.validateJoinAuditBody({ status: '2', auditRemark: '因'.repeat(501) }), /500/);
});
test('待审核列表隐藏内部 ID 和账号手机号,只有管理者可见操作', () => {
const data = [{
applyId,
genealogyId,
genealogyName: '叶氏家谱',
applicantName: '<叶子>',
phone: '19181970173',
relationDesc: '族亲',
applyReason: '申请加入',
status: '0',
appUserId: 'secret-user',
appUserPhone: 'secret-account-phone',
inviterUserId: 'secret-inviter',
auditUserId: 'secret-auditor'
}];
const managerHtml = JoinReviewPages.renderPendingJoinApplies(data, true);
const memberHtml = JoinReviewPages.renderPendingJoinApplies(data, false);
assert.match(managerHtml, /&lt;叶子&gt;/);
assert.match(managerHtml, /19181970173/);
assert.match(managerHtml, new RegExp('data-join-audit-id="' + applyId + '"'));
assert.match(managerHtml, /data-join-audit-status="1"/);
assert.match(managerHtml, /data-join-audit-status="2"/);
assert.doesNotMatch(memberHtml, /data-join-audit-id|data-join-audit-status/);
assert.doesNotMatch(managerHtml, /secret-user|secret-account-phone|secret-inviter|secret-auditor/);
});
test('审核页开放真实 PC 管理行为且不允许手填业务 ID', () => {
const page = read('profile-join-review.html');
const script = read('public/js/join-review-pages.js');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
assert.match(page, /data-join-review-page/);
assert.match(page, /data-join-apply-list="pending"/);
assert.match(page, /data-join-review-refresh/);
assert.match(page, /public\/js\/join-review-pages\.js/);
assert.doesNotMatch(page, /name="(?:genealogyId|applyId|appUserId|inviterUserId|auditUserId)"/);
assert.match(script, /await api\.genealogyDetail\(/);
assert.match(script, /detail\.canManage === true/);
assert.match(script, /await api\.auditGenealogyJoinApply\(/);
assert.match(script, /await api\.pendingGenealogyJoinApplies\(/);
assert.match(script, /审核后申请仍在待审核列表/);
});
+16
View File
@@ -10,6 +10,22 @@ test('世系页只从 URL 读取真实家谱编号', () => {
assert.equal(LineagePages.getCurrentGenealogyId(''), '');
});
test('世系管理页和家谱主页复用同一棵安全树的 HTML', () => {
const html = LineagePages.renderLineageTreeHtml([{
personId: '2062179707935264770',
genealogyId: '2062179707935264769',
name: '<始祖>',
generationName: '始',
spouses: [],
children: []
}]);
assert.match(html, /&lt;始祖&gt;/);
assert.match(html, /data-lineage-person="2062179707935264770"/);
assert.doesNotMatch(html, /2062179707935264769/);
assert.match(LineagePages.renderLineageTreeHtml([]), /暂无世系树/);
});
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
assert.deepEqual(
LineagePages.buildLineagePersonBody({
+15 -3
View File
@@ -36,9 +36,9 @@ test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
test('pages do not load scripts for unavailable business APIs', () => {
const removedScripts = [
'feedback-pages.js', 'genealogy-pages.js',
'help-pages.js', 'join-apply-pages.js',
'promotion-pages.js', 'vip-pages.js'
'genealogy-pages.js',
'join-apply-pages.js',
'promotion-pages.js'
];
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
@@ -53,6 +53,17 @@ test('pages do not load scripts for unavailable business APIs', () => {
assert.equal(read(page).includes('src="public/js/feed-pages.js"'), true, `${page} must load feed-pages.js`);
});
assert.equal(read('profile-generation.html').includes('src="public/js/generation-pages.js"'), true, 'profile-generation.html must load generation-pages.js');
assert.equal(read('join-genealogy.html').includes('src="public/js/join-pages.js"'), true, 'join-genealogy.html must load join-pages.js');
assert.equal(read('profile-join-family.html').includes('src="public/js/join-pages.js"'), true, 'profile-join-family.html must load join-pages.js');
assert.equal(read('profile-join-review.html').includes('src="public/js/join-review-pages.js"'), true, 'profile-join-review.html must load join-review-pages.js');
['profile-feedback.html', 'submit-ticket.html', 'my-tickets.html', 'ticket-detail.html'].forEach((page) => {
assert.equal(read(page).includes('src="public/js/feedback-pages.js"'), true, `${page} must load feedback-pages.js`);
});
assert.equal(read('help.html').includes('src="public/js/help-pages.js"'), true, 'help.html must load help-pages.js');
assert.equal(read('app.html').includes('src="public/js/app-promotion-pages.js"'), true, 'app.html must load app-promotion-pages.js');
['news.html', 'article-detail.html'].forEach((page) => {
assert.equal(read(page).includes('src="public/js/site-news-pages.js"'), true, `${page} must load site-news-pages.js`);
});
assert.equal(read('profile-tree.html').includes('src="public/js/lineage-pages.js"'), true, 'profile-tree.html must load lineage-pages.js');
assert.equal(read('profile-family-admin.html').includes('src="public/js/member-admin-pages.js"'), true, 'profile-family-admin.html must load member-admin-pages.js');
assert.equal(read('profile-video.html').includes('src="public/js/video-pages.js"'), true, 'profile-video.html must load video-pages.js');
@@ -60,4 +71,5 @@ test('pages do not load scripts for unavailable business APIs', () => {
assert.equal(read('profile-ceremony.html').includes('src="public/js/ceremony-admin-pages.js"'), true, 'profile-ceremony.html must load ceremony-admin-pages.js');
assert.equal(read('profile-relative.html').includes('src="public/js/relative-pages.js"'), true, 'profile-relative.html must load relative-pages.js');
assert.equal(read('profile-memo.html').includes('src="public/js/memo-pages.js"'), true, 'profile-memo.html must load memo-pages.js');
assert.equal(read('profile-services.html').includes('src="public/js/vip-pages.js"'), true, 'profile-services.html must load vip-pages.js');
});
+8 -8
View File
@@ -7,21 +7,16 @@ const PageAvailability = require('../public/js/pending-pages.js');
const root = path.resolve(__dirname, '..');
const familyPendingPages = [
'profile-join-family.html',
'profile-join-review.html',
'profile-invite.html',
];
const contentPendingPages = [
'profile-feedback.html',
'profile-admin-permissions.html',
'profile-data-reminders.html'
];
const residualPendingPages = [
'profile-family-home.html',
'profile-share.html',
'profile-services.html'
'profile-share.html'
];
const pendingPages = familyPendingPages.concat(contentPendingPages, residualPendingPages);
@@ -53,12 +48,17 @@ test('待开发页面显式加载状态脚本', () => {
});
test('已接入的家谱业务页面保持真实功能状态', () => {
['profile-families.html', 'profile-create-family.html', 'profile-family-admin.html', 'profile-content.html', 'profile-feed.html', 'profile-feed-edit.html', 'profile-feed-detail.html', 'profile-generation.html', 'profile-tree.html', 'profile-article.html', 'profile-article-edit.html', 'profile-album.html', 'profile-album-edit.html', 'profile-album-detail.html', 'profile-video.html', 'profile-video-edit.html', 'profile-gift.html', 'profile-gift-edit.html', 'profile-ceremony.html', 'profile-ceremony-detail.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html', 'profile-merit-edit.html', 'profile-messages.html'].forEach((page) => {
['profile-families.html', 'profile-create-family.html', 'profile-join-family.html', 'profile-join-review.html', 'profile-family-home.html', 'profile-family-admin.html', 'profile-feedback.html', 'profile-services.html', 'profile-content.html', 'profile-feed.html', 'profile-feed-edit.html', 'profile-feed-detail.html', 'profile-generation.html', 'profile-tree.html', 'profile-article.html', 'profile-article-edit.html', 'profile-album.html', 'profile-album-edit.html', 'profile-album-detail.html', 'profile-video.html', 'profile-video-edit.html', 'profile-gift.html', 'profile-gift-edit.html', 'profile-ceremony.html', 'profile-ceremony-detail.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html', 'profile-merit-edit.html', 'profile-messages.html'].forEach((page) => {
assert.doesNotMatch(read(page), /data-feature-status="pending"/, `${page} 不应标记为待开发`);
});
assert.match(read('profile-generation.html'), /src="public\/js\/generation-pages\.js"/);
assert.match(read('profile-families.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-create-family.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-join-family.html'), /src="public\/js\/join-pages\.js"/);
assert.match(read('profile-join-review.html'), /src="public\/js\/join-review-pages\.js"/);
assert.match(read('profile-feedback.html'), /src="public\/js\/feedback-pages\.js"/);
assert.match(read('profile-services.html'), /src="public\/js\/vip-pages\.js"/);
assert.match(read('profile-family-home.html'), /src="public\/js\/family-home-pages\.js"/);
assert.match(read('profile-tree.html'), /src="public\/js\/lineage-pages\.js"/);
assert.match(read('profile-family-admin.html'), /src="public\/js\/member-admin-pages\.js"/);
assert.match(read('profile-article.html'), /src="public\/js\/article-pages\.js"/);
@@ -93,7 +93,7 @@ test('待开发的家谱管理页明确放行已接入的字辈入口并透传
});
test('待开发页面仅放行显式标记的可用功能入口', () => {
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feedback.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-share.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feed.html', 'data-feature-link': 'available' })), false);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
});
+11 -9
View File
@@ -37,14 +37,16 @@ test('官网静态信息页面均有独立入口', () => {
});
});
test('新闻页提供分类与现有详情跳转', () => {
test('新闻页提供真实 PC 文章分类与详情入口', () => {
if (!exists('news.html')) return;
const source = read('news.html');
assert.match(source, /href="#platform"/);
assert.match(source, /href="#culture"/);
assert.match(source, /href="notice-detail\.html"/);
assert.match(source, /href="article-detail\.html"/);
assert.match(source, /href="news\.html"/);
assert.match(source, /href="news\.html\?articleType=news"/);
assert.match(source, /href="news\.html\?articleType=notice"/);
assert.match(source, /href="news\.html\?articleType=download"/);
assert.match(source, /data-site-news-list/);
assert.match(source, /src="public\/js\/site-news-pages\.js"/);
});
test('法律页面明确等待法务确认', () => {
@@ -54,14 +56,14 @@ test('法律页面明确等待法务确认', () => {
});
});
test('工单功能入口显示待开发状态并保留帮助中心跳转', () => {
test('工单功能入口开放真实反馈记录并保留帮助中心跳转', () => {
['submit-ticket.html', 'my-tickets.html', 'ticket-detail.html'].forEach((page) => {
if (!exists(page)) return;
const source = read(page);
assert.match(source, /data-feature-status="pending"/);
assert.match(source, /src="public\/js\/pending-pages\.js"/);
assert.match(source, /href="help\.html"\s+data-feature-link="available"/);
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /src="public\/js\/feedback-pages\.js"/);
assert.match(source, /href="help\.html"/);
});
});
+62
View File
@@ -2,6 +2,7 @@ const assert = require('node:assert/strict');
const test = require('node:test');
const GenealogyApi = require('../utils/ApiClient.js');
const AxiosRequestUtil = require('../utils/AxiosRequestUtil.js');
function createFailingClient(status) {
let storedToken = 'access-token';
@@ -96,3 +97,64 @@ test('business response code 401 clears the stored login token', async () => {
assert.equal(client.getToken(), '');
});
test('public HTTP 401 neither sends nor clears the stored login token', async () => {
let storedToken = 'access-token';
let seenRequest;
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'web-pc',
getToken() {
return storedToken;
},
onUnauthorized() {
storedToken = '';
},
axiosInstance: {
request(config) {
seenRequest = config;
return Promise.reject({
message: 'Request failed',
response: {
status: 401,
data: {
code: 401,
msg: '认证失败'
}
}
});
}
}
});
await assert.rejects(requester('GET', '/public', { auth: false }));
assert.equal(seenRequest.headers.Authorization, undefined);
assert.equal(storedToken, 'access-token');
});
test('public business 401 preserves the stored login token', async () => {
let storedToken = 'access-token';
const requester = AxiosRequestUtil.createRequester({
baseUrl: 'https://api.example.test',
clientId: 'web-pc',
getToken() {
return storedToken;
},
onUnauthorized() {
storedToken = '';
},
axiosInstance: {
request() {
return Promise.resolve({
data: {
code: 401,
msg: '认证失败'
}
});
}
}
});
await assert.rejects(requester('GET', '/public', { auth: false }));
assert.equal(storedToken, 'access-token');
});
+179
View File
@@ -0,0 +1,179 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const SiteNewsPages = require('../public/js/site-news-pages.js');
const newsPage = fs.readFileSync(path.resolve(__dirname, '../news.html'), 'utf8');
const detailPage = fs.readFileSync(path.resolve(__dirname, '../article-detail.html'), 'utf8');
const articleId = '2062179707935264769';
function articleFixture(overrides) {
return Object.assign({
articleId,
articleType: 'notice',
articleTitle: '平台公告',
articleSummary: '公告摘要',
articleContent: '第一行\n第二行',
coverOssId: '2062179707935264701',
externalUrl: 'https://example.com/notice',
publishTime: '2026-07-30 09:00:00',
sortOrder: 1,
status: '0',
remark: 'internal-only'
}, overrides);
}
test('站点文章只保留完整 SiteArticleVo 的安全展示字段和字符串长 ID', () => {
assert.deepEqual(SiteNewsPages.normalizeArticle(articleFixture()), {
articleId,
articleType: 'notice',
articleTitle: '平台公告',
articleSummary: '公告摘要',
articleContent: '第一行\n第二行',
externalUrl: 'https://example.com/notice',
publishTime: '2026-07-30 09:00:00'
});
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({
articleId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleId: '0' })), null);
});
test('站点文章拒绝 SQL 字典外类型、停用记录和空标题', () => {
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleType: 'culture' })), null);
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ status: '1' })), null);
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleTitle: ' ' })), null);
assert.equal(SiteNewsPages.normalizeArticleType('news'), 'news');
assert.equal(SiteNewsPages.normalizeArticleType('notice'), 'notice');
assert.equal(SiteNewsPages.normalizeArticleType('download'), 'download');
assert.equal(SiteNewsPages.normalizeArticleType('culture'), '');
});
test('站点文章列表只接受直接数组且任一非法元素使整批失败', () => {
assert.equal(SiteNewsPages.normalizeArticleList([articleFixture()]).length, 1);
assert.deepEqual(SiteNewsPages.normalizeArticleList([]), []);
assert.deepEqual(SiteNewsPages.normalizeArticleList({ rows: [articleFixture()] }), []);
assert.deepEqual(SiteNewsPages.normalizeArticleList([
articleFixture(),
articleFixture({ articleId: '' })
]), []);
});
test('站点资讯列表使用响应 ID 生成详情链接并隐藏内部字段', () => {
const html = SiteNewsPages.renderArticleList([articleFixture({
articleTitle: '<script>alert(1)</script>',
articleSummary: '<img src=x>',
coverOssId: 'secret-oss',
remark: 'secret-remark'
})]);
assert.match(html, /article-detail\.html\?articleId=2062179707935264769/);
assert.match(html, /公告/);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /&lt;img src=x&gt;/);
assert.doesNotMatch(html, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
assert.match(SiteNewsPages.renderArticleList([]), /当前暂无资讯/);
});
test('站点资讯详情转义正文、保留换行并只开放安全外链', () => {
const html = SiteNewsPages.renderArticleDetail(articleFixture({
articleContent: '<script>alert(1)</script>\n第二行'
}));
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;<br \/>第二行/);
assert.match(html, /href="https:\/\/example\.com\/notice"/);
assert.match(html, /target="_blank"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.doesNotMatch(html, /<script|coverOssId|secret-remark/);
['javascript:alert(1)', 'data:text/html,test', '/notice', 'notice.html', ''].forEach((value) => {
const unsafeHtml = SiteNewsPages.renderArticleDetail(articleFixture({ externalUrl: value }));
assert.doesNotMatch(unsafeHtml, /data-external-link/);
});
});
test('站点文章外链只接受绝对 HTTP 或 HTTPS', () => {
assert.equal(
SiteNewsPages.normalizeExternalUrl(' https://example.com/notice '),
'https://example.com/notice'
);
assert.equal(SiteNewsPages.normalizeExternalUrl('http://example.com'), 'http://example.com/');
assert.equal(SiteNewsPages.normalizeExternalUrl('javascript:alert(1)'), '');
assert.equal(SiteNewsPages.normalizeExternalUrl('/notice'), '');
});
test('资讯列表只提交已确认分类和系统 limit', async () => {
const calls = [];
const api = {
async siteArticles(query) {
calls.push(query);
return [articleFixture()];
}
};
assert.equal((await SiteNewsPages.loadArticles(api, 'notice')).length, 1);
assert.equal((await SiteNewsPages.loadArticles(api, '')).length, 1);
assert.deepEqual(calls, [
{ articleType: 'notice', limit: 100 },
{ limit: 100 }
]);
await assert.rejects(SiteNewsPages.loadArticles(api, 'culture'), /资讯分类无效/);
await assert.rejects(
SiteNewsPages.loadArticles({
async siteArticles() {
return { rows: [articleFixture()] };
}
}, ''),
/资讯列表响应无效/
);
});
test('资讯详情只精确匹配 URL 中由列表产生的文章 ID', async () => {
const api = {
async siteArticles(query) {
assert.deepEqual(query, { limit: 100 });
return [
articleFixture(),
articleFixture({ articleId: '2062179707935264770', articleTitle: '另一条' })
];
}
};
assert.equal((await SiteNewsPages.loadArticleDetail(api, articleId)).articleTitle, '平台公告');
await assert.rejects(
SiteNewsPages.loadArticleDetail(api, '2062179707935264771'),
/资讯不存在或已下线/
);
await assert.rejects(
SiteNewsPages.loadArticleDetail(api, Number.MAX_SAFE_INTEGER + 1),
/资讯编号无效/
);
});
test('详情 URL 只读取安全字符串文章 ID', () => {
assert.equal(SiteNewsPages.readArticleId('?articleId=' + articleId), articleId);
assert.equal(SiteNewsPages.readArticleId('?articleId=0'), '');
assert.equal(SiteNewsPages.readArticleId('?articleId=unsafe'), '');
assert.equal(SiteNewsPages.readArticleId(''), '');
});
test('官网资讯列表与详情页加载同一个公开内容 owner 且不暴露业务 ID', () => {
assert.match(newsPage, /<body[^>]*data-site-news-page/);
assert.match(newsPage, /data-site-news-list/);
assert.match(newsPage, /data-site-news-status/);
assert.match(newsPage, /news\.html\?articleType=news/);
assert.match(newsPage, /news\.html\?articleType=notice/);
assert.match(newsPage, /news\.html\?articleType=download/);
assert.match(newsPage, /src="public\/js\/site-news-pages\.js"/);
assert.match(detailPage, /<body[^>]*data-site-article-page/);
assert.match(detailPage, /<title>资讯详情 - 代代相传<\/title>/);
assert.match(detailPage, /data-site-article-detail/);
assert.match(detailPage, /data-site-article-status/);
assert.match(detailPage, /src="public\/js\/site-news-pages\.js"/);
assert.doesNotMatch(detailPage, /src="public\/js\/article-pages\.js"/);
assert.doesNotMatch(detailPage, /href="article-detail\.html"/);
assert.doesNotMatch(newsPage + detailPage, /name="(?:articleId|coverOssId)"/);
});
+21
View File
@@ -33,3 +33,24 @@ test('个人中心功能入口没有把家谱业务页当作无上下文普通
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少个人中心上下文入口`);
});
});
test('加入申请与管理员审核入口使用真实页面和家谱上下文', () => {
const families = read('profile-families.html');
const familyAdmin = read('profile-family-admin.html');
const profile = read('profile.html');
assert.match(families, /href="profile-join-family\.html"/);
assert.doesNotMatch(families, /邀请码|分享码|手填.*ID/);
assert.match(familyAdmin, /href="profile-join-review\.html"[^>]*data-genealogy-context-link/);
assert.match(profile, /href="profile-join-review\.html"[^>]*data-genealogy-context-link/);
});
test('帮助中心和服务中心开放同一反馈记录闭环', () => {
const help = read('help.html');
const services = read('profile-services.html');
assert.match(help, /href="submit-ticket\.html"/);
assert.match(help, /href="my-tickets\.html"/);
assert.match(services, /href="profile-feedback\.html"[^>]*data-feature-link="available"/);
assert.doesNotMatch(help + services, /手填.*feedbackId|独立.*ticket.*接口/i);
});
+231
View File
@@ -0,0 +1,231 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const VipPages = require('../public/js/vip-pages.js');
const root = path.resolve(__dirname, '..');
const packageId = '2062179707935264769';
const genealogyId = '2062179707935264770';
const orderId = '2062179707935264771';
function packageFixture(overrides) {
return Object.assign({
packageId,
packageName: '家谱年度会员',
packageType: 'vip',
packageDesc: '适合持续维护家谱',
price: '99.00',
originalPrice: '129.00',
durationValue: 1,
durationUnit: 'year',
genealogyLimit: 3,
memberLimit: 500,
storageLimitMb: 10240,
featureJson: '{"export":true}',
sortOrder: 1,
status: '0',
remark: '年度套餐'
}, overrides);
}
function orderFixture(overrides) {
return Object.assign({
orderId,
orderNo: 'VIP202607291200001234',
packageId,
packageName: '家谱年度会员',
appUserId: '2062179707935264701',
appUserNickName: '叶子',
appUserPhone: '19100000000',
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
orderAmount: '99.00',
payAmount: '99.00',
payType: 'wechat',
payStatus: '0',
payTime: null,
expireTime: null,
status: '0',
remark: ''
}, overrides);
}
test('VIP 套餐只保留可展示字段并保持金额和长 ID 字符串', () => {
assert.deepEqual(VipPages.normalizeVipPackage(packageFixture()), {
packageId,
packageName: '家谱年度会员',
packageType: 'vip',
packageDesc: '适合持续维护家谱',
price: '99.00',
originalPrice: '129.00',
durationValue: 1,
durationUnit: 'year',
genealogyLimit: 3,
memberLimit: 500,
storageLimitMb: 10240,
remark: '年度套餐'
});
assert.equal(VipPages.normalizeVipPackage(packageFixture({
packageId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
packageType: 'unknown'
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
durationUnit: 'week'
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
price: '-1.00'
})), null);
assert.equal(VipPages.normalizeVipPackage(packageFixture({
status: '1'
})), null);
});
test('套餐列表过滤停用或非法记录', () => {
assert.equal(VipPages.normalizeVipPackages([
packageFixture(),
packageFixture({ packageId: '2062179707935264772', status: '1' })
]).length, 1);
assert.deepEqual(VipPages.normalizeVipPackages({ rows: [packageFixture()] }), []);
});
test('家谱选择项只接受响应中的稳定 ID 和名称', () => {
assert.deepEqual(VipPages.normalizeGenealogyOption({
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶',
ownerUserId: 'must-drop'
}), {
genealogyId,
genealogyName: '叶氏家谱',
surname: '叶'
});
assert.equal(VipPages.normalizeGenealogyOption({
genealogyId: Number.MAX_SAFE_INTEGER + 1,
genealogyName: '叶氏家谱'
}), null);
assert.equal(VipPages.normalizeGenealogyOption({
genealogyId,
genealogyName: ''
}), null);
});
test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式', () => {
assert.deepEqual(VipPages.buildVipOrderBody({
packageId: ' ' + packageId + ' ',
genealogyId: ' ' + genealogyId + ' ',
payType: '',
orderId: 'must-drop',
appUserId: 'must-drop',
payStatus: 'must-drop'
}), {
packageId,
genealogyId
});
assert.deepEqual(VipPages.buildVipOrderBody({
packageId,
payType: ' wechat '
}), {
packageId,
payType: 'wechat'
});
});
test('订单请求要求安全套餐 ID,并只接受已确认的可选值', () => {
assert.equal(VipPages.validateVipOrderBody({ packageId }), '');
assert.equal(VipPages.validateVipOrderBody({ packageId, genealogyId }), '');
assert.match(VipPages.validateVipOrderBody({ packageId: '' }), /套餐/);
assert.match(VipPages.validateVipOrderBody({
packageId,
genealogyId: 'unsafe'
}), /家谱/);
assert.match(VipPages.validateVipOrderBody({
packageId,
payType: 'alipay'
}), /支付方式/);
});
test('VIP 订单隐藏账号字段并校验金额和状态', () => {
assert.deepEqual(VipPages.normalizeVipOrder(orderFixture()), {
orderId,
orderNo: 'VIP202607291200001234',
packageId,
packageName: '家谱年度会员',
genealogyId,
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
orderAmount: '99.00',
payAmount: '99.00',
payType: 'wechat',
payStatus: '0',
payTime: '',
expireTime: '',
status: '0',
remark: ''
});
assert.equal(VipPages.normalizeVipOrder(orderFixture({
orderId: Number.MAX_SAFE_INTEGER + 1
})), null);
assert.equal(VipPages.normalizeVipOrder(orderFixture({
payAmount: '-1'
})), null);
assert.equal(VipPages.normalizeVipOrder(orderFixture({
payStatus: '9'
})), null);
assert.equal(VipPages.normalizeVipOrder(orderFixture({
status: '9'
})), null);
});
test('套餐和订单渲染不出现内部账号信息或伪支付操作', () => {
const packagesHtml = VipPages.renderVipPackages([packageFixture({
packageName: '<年度会员>'
})], packageId);
const ordersHtml = VipPages.renderVipOrders([orderFixture({
appUserId: 'secret-user',
appUserPhone: 'secret-phone'
})]);
const combined = packagesHtml + ordersHtml;
assert.match(packagesHtml, /&lt;年度会员&gt;/);
assert.match(packagesHtml, new RegExp('data-vip-package-id="' + packageId + '"'));
assert.match(ordersHtml, /待支付/);
assert.doesNotMatch(combined, /secret-user|secret-phone|appUserId|appUserPhone|立即支付|模拟|取消订单|退款/);
});
test('套餐渲染接受初始化流程已经规范化的数据', () => {
const normalized = VipPages.normalizeVipPackages([packageFixture()]);
const html = VipPages.renderVipPackages(normalized, packageId);
assert.match(html, /家谱年度会员/);
assert.match(html, /data-vip-package-id=/);
assert.doesNotMatch(html, /当前没有可用会员套餐/);
});
test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 ID 或未开放支付操作', () => {
const source = fs.readFileSync(path.join(root, 'profile-services.html'), 'utf8');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.match(source, /src="public\/js\/vip-pages\.js"/);
assert.match(source, /data-vip-package-list/);
assert.match(source, /data-vip-selected-package/);
assert.match(source, /<select[^>]+data-vip-genealogy-options/);
assert.match(source, /data-vip-order-refresh/);
assert.match(source, /data-vip-order-list/);
assert.doesNotMatch(source, /<input[^>]+name="(?:packageId|genealogyId|orderId)"/);
assert.doesNotMatch(source, /name="payType"/);
assert.doesNotMatch(source, /立即支付|模拟支付|取消订单|退款/);
});
test('会员服务运行时并行读取套餐、我的家谱和订单,并在创建后重新读取核验', () => {
const source = fs.readFileSync(path.join(root, 'public/js/vip-pages.js'), 'utf8');
assert.match(source, /Promise\.all\(\[api\.vipPackages\(\), api\.genealogiesMine\(\), api\.vipOrders\(\)\]\)/);
assert.match(source, /created = normalizeVipOrder\(await api\.createVipOrder\(body\)\)/);
assert.match(source, /refreshed = normalizeVipOrders\(await api\.vipOrders\(\)\)/);
assert.match(source, /item\.orderId === created\.orderId/);
});