Files
jiapuapp/scripts/check-audit-regressions.mjs
T
2026-09-13 17:45:52 +08:00

639 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs'
import path from 'node:path'
const workspace = process.cwd()
const failures = []
const read = (filePath) => fs.readFileSync(path.join(workspace, filePath), 'utf8')
const expect = (condition, message) => {
if (!condition) failures.push(message)
}
const listPageSources = (directory = 'pages') => fs.readdirSync(path.join(workspace, directory), { withFileTypes: true })
.flatMap((entry) => {
const childPath = path.join(directory, entry.name)
return entry.isDirectory()
? listPageSources(childPath)
: entry.isFile() && entry.name.endsWith('.vue') ? [childPath] : []
})
const signIn = read('pages/auth/sign-in.vue')
const loginAdvertisement = read('components/auth/LoginAdvertisement.vue')
const register = read('pages/auth/register.vue')
const routes = read('utils/navigation/routes.js')
const genealogyHome = read('pages/genealogy/my-genealogies.vue')
const profileHomePage = read('pages/profile/home.vue')
const homeAdvertisementPanel = read('components/genealogy/HomeAdvertisementPanel.vue')
const genealogyOverview = read('pages/genealogy/overview.vue')
const genealogyCard = read('components/genealogy/Card.vue')
const familyFeedPage = read('pages/family/feed.vue')
const siteHomePage = read('pages/family/site-home.vue')
const siteContentContract = read('services/api/site-content-contract.js')
const invitationManager = read('components/genealogy/InvitationManager.vue')
const pedigree = read('pages/tree/pedigree.vue')
const treeOverview = read('pages/tree/overview.vue')
const moduleBackground = read('components/ModulePageBackground.vue')
const genealogyBackground = read('components/genealogy/PageBackground.vue')
const vipPage = read('pages/profile/vip.vue')
const promotionsPage = read('pages/profile/promotions.vue')
const referralQrCode = read('components/ReferralQrCode.vue')
const helpPage = read('pages/profile/help.vue')
const editProfilePage = read('pages/profile/edit-profile.vue')
const genealogySettingsPage = read('pages/genealogy/settings.vue')
const genealogyCreatePage = read('pages/genealogy/create.vue')
const familyVideosPage = read('pages/family/videos.vue')
const articleEditorPage = read('pages/family/article-editor.vue')
const ceremonyEditorPage = read('pages/records/ceremony-editor.vue')
const ceremonyListPage = read('pages/records/ceremonies.vue')
const articleListPage = read('pages/family/articles.vue')
const personDocumentsPage = read('pages/records/person-documents.vue')
const personDocumentDialog = read('components/tree/PersonDocumentDialog.vue')
const earningsPage = read('pages/profile/earnings.vue')
const relativeRecordsPage = read('pages/records/relative-records.vue')
const growthJournalPage = read('pages/records/growth-journal.vue')
const messageCenterPage = read('pages/notification/message-center.vue')
const changePhonePage = read('pages/profile/change-phone.vue')
const changePasswordPage = read('pages/profile/change-password.vue')
const platformVideosPage = read('pages/family/platform-videos.vue')
const familyMediaContract = read('services/api/family-media-contract.js')
const batchDeletionComposable = read('composables/use-batch-deletion.js')
const batchManagementBar = read('components/BatchManagementBar.vue')
const batchSelectionMark = read('components/BatchSelectionMark.vue')
const familyArticleService = read('services/api/family-article-service.js')
const familyArticleContract = read('services/api/family-article-contract.js')
const lifeRecordService = read('services/api/life-record-service.js')
const dialogPages = [
'pages/tree/pedigree.vue',
'pages/family/album-detail.vue',
'pages/records/relative-records.vue',
'pages/genealogy/search.vue',
'pages/family/article-detail.vue',
'pages/notification/message-detail.vue',
'pages/notification/message-center.vue',
'pages/profile/earnings.vue',
]
const vipService = read('services/api/vip-service.js')
const siteContentService = read('services/api/site-content-service.js')
const requestClient = read('services/api/request-client.js')
const requestErrorMessage = read('services/api/request-error-message.js')
const memberDirectoryPage = read('pages/tree/member-directory.vue')
const peoplePage = read('pages/records/people.vue')
const idValidatedPages = [
'pages/tree/add-relative.vue',
'pages/tree/member-profile.vue',
'pages/tree/member-states.vue',
'pages/genealogy/generation-poems.vue',
'pages/genealogy/overview.vue',
'pages/records/person-detail.vue',
]
const memberRankPage = read('pages/tree/member-rank.vue')
const meritRecordsPage = read('pages/records/merit-records.vue')
const memoPage = read('pages/records/memos.vue')
const lifeRecordContract = read('services/api/life-record-contract.js')
const ceremonyDetailPage = read('pages/records/ceremony-detail.vue')
const relativeRecordEditorPage = read('pages/records/relative-record-editor.vue')
const requestNormalizers = read('services/api/request-normalizers.js')
const profileContract = read('services/api/profile-contract.js')
const genealogyContext = read('utils/genealogy/context.js')
const editMemberPage = read('pages/tree/edit-member.vue')
const addRelativePage = read('pages/tree/add-relative.vue')
const securityPage = read('pages/profile/security.vue')
const lineageService = read('services/api/lineage-service.js')
const lineageWriteContract = read('services/api/lineage-write-contract.js')
const lineagePersonContract = read('services/api/lineage-person-contract.js')
const feedDetailPage = read('pages/family/feed-detail.vue')
const articleDetailPage = read('pages/family/article-detail.vue')
const familyVideosPageSource = read('pages/family/videos.vue')
const genealogyContract = read('services/api/genealogy-contract.js')
const genealogySearchPage = read('pages/genealogy/search.vue')
const generationPoemService = read('services/api/generation-poem-service.js')
const genealogyMemberService = read('services/api/genealogy-member-service.js')
const personDocumentService = read('services/api/person-document-service.js')
const mediaUpload = read('utils/media-upload.js')
const businessFileContract = read('services/api/business-file-contract.js')
const authContract = read('services/api/auth-contract.js')
const authService = read('services/api/auth-service.js')
const contentRecoveryContract = read('services/api/content-password-recovery-contract.js')
const contentRecoveryService = read('services/api/content-password-recovery-service.js')
const referralContract = read('services/api/referral-contract.js')
const permissionContract = read('services/api/genealogy-permission-contract.js')
const genealogyCapabilityService = read('services/api/genealogy-capability-service.js')
const businessDictionaryContract = read('services/api/business-dictionary-contract.js')
const vipContract = read('services/api/vip-contract.js')
const tacLibrary = read('static/tac/js/tac.min.js')
const openApi = read('genealogy-app-openapi.yaml')
const manifest = JSON.parse(read('manifest.json'))
for (const pagePath of listPageSources()) {
expect(!/暂不支持|开发中|敬请期待|准备中/.test(read(pagePath)), `${pagePath} 仍展示占位功能提示`)
}
expect(manifest.name === '代代相传家谱', 'manifest 应用名称与正式产品名称不一致')
expect(!signIn.includes('协议页面准备中'), '登录页仍使用协议占位提示')
expect(!register.includes('协议页面准备中'), '注册页仍使用协议占位提示')
expect(signIn.includes('openComplianceDocument'), '登录页没有接入协议正文导航')
expect(register.includes('openComplianceDocument'), '注册页没有接入协议正文导航')
expect(
signIn.includes('<LoginAdvertisement') &&
loginAdvertisement.includes('placement: "home_banner"') &&
loginAdvertisement.includes('promotion.coverFile.accessUrl') &&
loginAdvertisement.includes('home-ad-heritage-hall.png') &&
!loginAdvertisement.includes('点击查看更多内容'),
'登录页没有在注册入口上方显示单张祠堂山水图,或仍带有查看更多文案',
)
expect(
signIn.includes('justify-content: flex-start') &&
signIn.includes('color: #5d493b') &&
signIn.includes('color: #766c63'),
'登录页仍使用自动撑开的旧布局,或次要文字颜色没有加深',
)
expect(
/\.login-page\s*\{[^}]*height:\s*var\(--app-viewport-height\)[^}]*max-height:\s*var\(--app-viewport-height\)[^}]*overflow:\s*hidden/.test(signIn) &&
/\.login-page\s+:deep\(\.auth-shell__paper\)\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/.test(signIn),
'登录页没有锁定动态 100vh,新增广告后仍可能产生页面级滚动',
)
expect(
/getPromotions[\s\S]*?authenticated:\s*false/.test(siteContentService),
'公开广告接口仍会携带登录态,登录页无法安全读取 home_banner',
)
expect(
/getComplianceDocument[\s\S]*?authenticated:\s*false/.test(siteContentService),
'协议正文请求仍依赖登录状态',
)
const complianceRoute = routes.match(/M13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(complianceRoute.includes('"A01"'), '协议正文路由不允许从登录页进入')
expect(complianceRoute.includes('"A04"'), '协议正文路由不允许从注册页进入')
const membersRoute = routes.match(/G13: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(!membersRoute.includes('"G01"'), '成员页仍允许从家谱首页旧入口进入')
expect(
genealogyOverview.includes('openPage("G13"') &&
!genealogyHome.includes('shortcut-grid') &&
!genealogyHome.includes('openShortcut'),
'家谱快捷导航没有完整迁移到家谱详情页',
)
const platformVideosRoute = routes.match(/F11: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(!platformVideosRoute.includes('"G01"') && platformVideosRoute.includes('"F10"'), '宣传视频没有迁回原视频导航')
expect(platformVideosRoute.includes('"videoId"'), '宣传视频路由不能携带指定视频 ID')
expect(
familyMediaContract.includes("HOME_FEATURED: 'home_featured'") &&
familyMediaContract.includes("VIDEO_CENTER: 'video_center'") &&
familyMediaContract.includes("PROFILE_FEATURED: 'profile_featured'"),
'平台视频契约没有完整覆盖后端三个投放位',
)
expect(
!genealogyHome.includes('featured-media-grid') &&
!genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
'家谱首页仍保留已经迁出的宣传视频',
)
expect(
!genealogyHome.includes('genealogy-create-button') &&
!genealogyHome.includes('新建家谱') &&
genealogyHome.includes('class="create-action"') &&
genealogyHome.includes('添加家谱'),
'家谱首页仍在顶部显示误加的新建按钮,或列表末尾缺少原有添加入口',
)
expect(
genealogyHome.indexOf('class="genealogy-list-scroll"') <
genealogyHome.indexOf('class="genealogy-list-toolbar"') &&
!genealogyHome.includes('genealogy-fixed-zone'),
'家谱分组标题仍被固定在第 7 条标注的滚动区域之外',
)
expect(
siteContentContract.includes("news: '资讯'") &&
siteContentContract.includes("notice: '公告'") &&
siteHomePage.includes('article.typeLabel'),
'官网文章类型仍直接暴露后端英文编码',
)
expect(
genealogyHome.includes('<HomeAdvertisementPanel') &&
homeAdvertisementPanel.includes('home-ad-heritage-hall.png') &&
homeAdvertisementPanel.includes('home-ad-family-tree.png') &&
homeAdvertisementPanel.includes('点击查看更多内容') &&
!genealogyHome.includes('home-news-card') &&
!genealogyHome.includes('<AppPromotionStrip'),
'首页底部固定区域没有按参考图显示两张并排图片,或仍使用错误的文字/横向广告卡',
)
expect(
genealogyCard.includes('创建于 {{ formatGenealogyDate(genealogy.createTime) }}') &&
!genealogyCard.includes('加入于 {{ formatGenealogyDate(genealogy.joinTime) }}'),
'家谱卡片没有按后端真实 createTime 显示创建日期',
)
expect(
genealogyOverview.includes('familyContentGroups') &&
genealogyOverview.includes('openFamilySection') &&
!familyFeedPage.includes('feed-shortcuts'),
'家族内容导航没有迁入家谱详情并完成归类',
)
expect(
siteHomePage.includes('siteContentApi.getSiteArticles') &&
siteHomePage.includes('<AppTabbar v-if="!subpageMode" active="family"') &&
!familyFeedPage.includes('<AppTabbar'),
'消息根页没有继续承载公共新闻资讯,或家谱动态仍占用根导航',
)
expect(read('components/AppTabbar.vue').includes('label: "消息"'), '底部中间导航没有改为消息')
expect(
fs.existsSync(path.join(workspace, 'pages/family/site-article-list.vue')) &&
read('pages/family/site-article-list.vue').includes('subpage-mode'),
'公共资讯子列表没有与消息根页复用同一实现',
)
expect(routes.includes('path: "/pages/family/site-home"') && !routes.includes('path: "/pages/notification/message-home"') && !routes.includes('path: "/pages/profile/message-home"'), '消息根页没有指向原新闻资讯页面')
expect(!profileHomePage.includes('isMessagePortal'), '我的页面仍混入消息页变体逻辑')
expect(!profileHomePage.includes('class="profile-metadata"'), '我的页面仍显示应删除的性别、生日和邮箱摘要卡')
expect(profileHomePage.includes('家谱新闻列表(图文)') && profileHomePage.includes('家谱系列文章(图文)'), '我的菜单没有按标注图补齐')
expect(
profileHomePage.includes('<HomeAdvertisementPanel') &&
profileHomePage.includes('class="profile-home-advertisements"') &&
!profileHomePage.includes('广告位列表') &&
!routes.includes('F14: defineRoute'),
'我的页面底部没有直接复用首页图片广告,或仍保留错误的广告位列表入口',
)
expect(
platformVideosPage.includes('video-card__cover-button') &&
platformVideosPage.includes('requestedVideoId'),
'宣传视频列表没有按封面进入指定视频播放',
)
expect(
genealogyHome.includes('genealogyListError') &&
genealogyHome.includes('getRequestErrorMessage'),
'家谱首页仍会隐藏家谱列表的真实失败类型',
)
expect(
/import\s*\{[^}]*normalizeOptionalNumericId[^}]*\}\s*from '\.\/response-normalizers\.js'/.test(genealogyContract),
'家谱响应契约使用 normalizeOptionalNumericId 时没有从响应归一化模块导入',
)
expect(
articleListPage.includes('articleCategoryError') &&
articleListPage.includes('getRequestErrorMessage'),
'谱文分类读取失败仍被静默伪装为空分类',
)
expect(
!/loadArticleCategories[\s\S]*?catch\s*\([^)]*\)[\s\S]*?return\s+\[\]/.test(articleListPage),
'谱文分类读取失败仍直接返回空数组',
)
expect(
invitationManager.includes('.invitation-manager__row .app-button'),
'邀请记录按钮没有独立收缩规则',
)
expect(
!/invitation-manager__row text:first-child\s*\{[^}]*overflow-wrap:\s*anywhere/.test(invitationManager),
'邀请记录名称仍允许逐字断行',
)
expect(pedigree.includes('isVerticalPedigreeText'), '世系表格没有区分中英文排版')
expect(pedigree.includes('member-node__name--horizontal'), '世系表格缺少英文姓名横排样式')
expect(!pedigree.includes('\\p{Script=Han}'), '世系表格仍使用 Android WebView 不兼容的正则')
expect(!treeOverview.includes('邀请绑定暂未开放'), '世系操作仍展示没有实现的占位入口')
expect(moduleBackground.includes('mode="aspectFill"'), '通用长背景仍按原图高度绘制')
expect(genealogyBackground.includes('mode="aspectFill"'), '家谱长背景仍按原图高度绘制')
expect(vipService.includes('async createVipOrder'), 'VIP service 缺少创建订单接口')
expect(vipService.includes('async getVipPaymentStatus'), 'VIP service 缺少支付状态查询接口')
expect(vipService.includes('async closeVipPayment'), 'VIP service 缺少关闭支付接口')
expect(vipPage.includes('uni.requestPayment'), 'VIP 页面没有接入 App 支付')
expect(vipPage.includes('createVipOrder'), 'VIP 页面没有调用创建订单接口')
expect(vipPage.includes('createNonIdempotentWriteGuard'), 'VIP 创建订单缺少重复下单保护')
expect(vipPage.includes('orderCreationGuard.recordFailure'), 'VIP 下单结果未知时仍允许重复购买')
expect(Boolean(manifest['app-plus']?.modules?.Payment), 'manifest 未启用 Payment 模块')
expect(Boolean(manifest['app-plus']?.modules?.Share), 'manifest 未启用推广页所需的 Share 模块')
expect(
/saveGenerationPoemBatch[\s\S]*?requireData:\s*false/.test(generationPoemService),
'字辈批量保存仍把 VoidResult 当作必须包含 data 的响应',
)
expect(
/transferGenealogyOwner[\s\S]*?requireData:\s*false/.test(genealogyMemberService),
'谱主转让仍把 VoidResult 当作必须包含 data 的响应',
)
expect(
/setGrowthRecordPassword[\s\S]*?requireData:\s*false/.test(lifeRecordService),
'成长记录密码设置仍把无 data 的成功响应当作失败',
)
expect(
/setPersonDocumentPassword[\s\S]*?requireData:\s*false/.test(personDocumentService),
'证件密码设置仍把 VoidResult 当作必须包含 data 的响应',
)
expect(mediaUpload.includes('IMAGE_TYPE_INVALID'), '图片上传缺少选择结果类型复核')
expect(mediaUpload.includes('readNativeFile(await pickNativeVideo(), "视频")'), '视频读取错误仍误称为图片错误')
expect(
businessFileContract.includes("!/^https:\\/\\/[^\\s]+$/.test(accessUrl)"),
'业务文件访问地址没有限制为 HTTPS',
)
expect(
authContract.includes("typeof token !== 'string' || !token || token.trim() !== token"),
'登录响应没有严格校验 access_token',
)
expect(!tacLibrary.includes('res.code'), '行为验证码 SDK 异常分支仍引用未定义的 res.code')
expect(!tacLibrary.includes('validFail(res'), '行为验证码 SDK 异常分支仍传递未定义的 res')
expect(
manifest['app-plus']?.distribute?.android?.permissions?.some((permission) =>
permission.includes('android.permission.READ_MEDIA_VIDEO')),
'Android 视频相册选择缺少 READ_MEDIA_VIDEO 权限',
)
expect(
manifest['app-plus']?.distribute?.android?.permissions?.some((permission) =>
permission.includes('android.permission.READ_EXTERNAL_STORAGE') &&
permission.includes('android:maxSdkVersion="32"')),
'Android 12L 及以下相册选择缺少受限的存储读取权限',
)
expect(
manifest['app-plus']?.distribute?.ios?.privacyDescription?.NSPhotoLibraryUsageDescription,
'iOS 相册选择缺少 NSPhotoLibraryUsageDescription 用途说明',
)
expect(
/promotion-card__actions[^>]*@click\.stop/.test(promotionsPage),
'推广操作按钮会继续触发卡片跳转',
)
expect(
promotionsPage.includes('<ReferralQrCode :value="referralProfile.shareUrl"') &&
referralQrCode.includes('class="referral-qr-code"'),
'推广页没有根据后端 shareUrl 展示推荐二维码',
)
expect(
/\.help-article-list\s*\{[^}]*margin-top:\s*18rpx/.test(helpPage),
'帮助中心分类栏与首条问答之间缺少间距',
)
expect(
/\.help-feedback-card text\s*\{[^}]*display:\s*block/.test(helpPage),
'帮助中心反馈标题和说明仍挤在同一行',
)
expect(editProfilePage.includes('createDiscardConfirmation'), '编辑资料页缺少未保存修改确认')
expect(editProfilePage.includes('onBackPress'), '编辑资料页系统返回未接入返回守卫')
expect(editProfilePage.includes('dirty: isDirty.value'), '编辑资料页返回守卫未检查完整资料快照')
expect(genealogySettingsPage.includes('createDiscardConfirmation'), '家谱设置页缺少未保存修改确认')
expect(genealogySettingsPage.includes('onBackPress'), '家谱设置页系统返回未接入返回守卫')
expect(genealogySettingsPage.includes('if (isDirty.value'), '家谱设置页返回守卫未检查设置快照')
expect(familyVideosPage.includes('createDiscardConfirmation'), '家族视频表单缺少未保存修改确认')
expect(familyVideosPage.includes('onBackPress'), '家族视频页系统返回未接入返回守卫')
expect(familyVideosPage.includes('formSnapshot.value !== formBaseline.value'), '家族视频表单返回未检查视频草稿')
expect(!familyVideosPage.includes('video-card__player'), '家族视频列表仍直接铺设播放器')
expect(familyVideosPage.includes('video-card__cover-action'), '家族视频列表没有封面点击播放入口')
expect(ceremonyListPage.includes('item.coverFile?.accessUrl'), '礼仪列表没有消费封面字段')
expect(ceremonyDetailPage.includes('detail.coverFile?.accessUrl'), '礼仪详情没有显示封面')
expect(articleListPage.includes('item.coverFile?.accessUrl'), '谱文列表没有消费封面字段')
expect(articleDetailPage.includes('article.coverFile?.accessUrl'), '谱文详情没有显示封面')
expect(routes.includes('path: "/pages/records/person-documents"'), '缺少家谱级重要证件路由')
expect(personDocumentsPage.includes('<PersonDocumentDialog'), '家谱级重要证件页没有复用证件工作流')
expect(personDocumentDialog.includes('const documentQuery = props.personId'), '重要证件弹窗不支持家谱级聚合查询')
expect(vipContract.includes("orderNo: normalizeVipText(item.orderNo, 'orderNo')"), 'VIP 订单契约没有保留订单号')
expect(vipPage.includes('订单号:{{ item.orderNo }}'), 'VIP 订单页没有显示订单号')
expect(vipPage.includes('支付时间:{{ item.paidAt }}') && vipPage.includes('到期时间:{{ item.expiresAt }}'), 'VIP 订单页没有区分支付和到期时间')
for (const field of ['withdrawalNo', 'auditRemark', 'payoutReference', 'paidAt']) {
expect(earningsPage.includes(`withdrawal.${field}`), `提现记录没有显示 ${field}`)
}
expect(relativeRecordsPage.includes('item.mediaFiles?.[0]?.accessUrl'), '贺礼簿列表没有显示首图')
expect(meritRecordsPage.includes('item.mediaFiles?.[0]?.accessUrl'), '功德记录列表没有显示首图')
expect(memoPage.includes('item.mediaFiles?.[0]?.accessUrl'), '备忘录和恩人录列表没有显示首图')
expect(
platformVideosPage.includes('video-card__cover-placeholder') &&
!/<video\s+v-else[\s\S]*?class="video-card__player"/.test(platformVideosPage),
'宣传视频列表无封面时仍直接铺设播放器',
)
expect(growthJournalPage.includes('field !== "lineagePersonId"'), '成长记录仍把自动带入人物直接判为用户修改')
const joinApplicationRoute = routes.match(/G08: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
expect(joinApplicationRoute.includes('"genealogyName"'), '公开家谱申请页路由仍拒绝谱名参数')
expect(messageCenterPage.includes(':active="sourceTab"') && messageCenterPage.includes('returnToSource'), '消息中心没有按真实来源恢复导航')
expect(changePhonePage.includes('请先获取新手机号的验证码'), '换绑手机号仍错误提示当前手机号验证码')
expect(!changePhonePage.includes('请先获取当前手机号的验证码'), '换绑手机号保留了错误的验证码归属文案')
expect(changePhonePage.includes('role="alert"') && changePhonePage.includes(':aria-describedby'), '换绑手机号字段错误缺少无障碍关联')
expect(changePasswordPage.includes('<button') && changePasswordPage.includes(':aria-pressed'), '密码显示开关缺少按钮和状态语义')
expect(changePasswordPage.includes('role="alert"') && changePasswordPage.includes(':aria-describedby'), '修改密码字段错误缺少无障碍关联')
expect(
genealogySettingsPage.indexOf('pageState.value = "form"') < genealogySettingsPage.indexOf('getPermanentDeletionCapability'),
'家谱设置仍被永久注销资格读取阻断',
)
for (const dialogPagePath of dialogPages) {
expect(read(dialogPagePath).includes('onBackPress'), `${dialogPagePath} 的弹窗未接入系统返回`)
}
const iconPaths = []
const collectStrings = (value) => {
if (typeof value === 'string') iconPaths.push(value)
else if (value && typeof value === 'object') Object.values(value).forEach(collectStrings)
}
collectStrings(manifest['app-plus']?.distribute?.icons)
const uniqueIconPaths = [...new Set(iconPaths)]
expect(uniqueIconPaths.length === 17, `App 图标路径数量异常:${uniqueIconPaths.length}`)
for (const iconPath of uniqueIconPaths) {
expect(fs.existsSync(path.join(workspace, iconPath)), `App 图标不存在:${iconPath}`)
}
const privacyPath = path.join(workspace, 'androidPrivacy.json')
expect(fs.existsSync(privacyPath), '缺少 androidPrivacy.json')
if (fs.existsSync(privacyPath)) {
const privacy = JSON.parse(fs.readFileSync(privacyPath, 'utf8'))
expect(privacy.prompt === 'template', 'Android 原生隐私提示未使用 template 模式')
expect(/https:\/\//.test(privacy.message || ''), 'Android 原生隐私提示没有 HTTPS 协议链接')
expect(typeof privacy.backToExit === 'boolean', 'Android 原生隐私提示 backToExit 必须是布尔值')
}
expect(!read('App.vue').includes("console.log('家谱 App 已启动')"), 'App.vue 仍保留启动调试日志')
expect(
/HTTP_ERROR'[\s\S]*?httpStatus\) === 401/.test(requestClient),
'请求层没有把 HTTP 401 识别为会话失效',
)
expect(
/statusCode < 200[\s\S]*?recoverExpiredAuthenticatedSession/.test(requestClient),
'普通请求收到 HTTP 401 时没有恢复登录状态',
)
expect(
/expectedStatus !== null[\s\S]*?recoverExpiredAuthenticatedSession/.test(requestClient),
'严格请求收到 HTTP 401 时没有恢复登录状态',
)
expect(requestClient.includes('const FILE_UPLOAD_TIMEOUT_MS = 120000'), '文件上传没有独立的超时配置')
expect(
(requestClient.match(/timeout: FILE_UPLOAD_TIMEOUT_MS|}, FILE_UPLOAD_TIMEOUT_MS\)/g) || []).length === 2,
'原生和浏览器文件上传没有统一使用独立超时配置',
)
expect(
requestClient.includes('rejectAuthenticatedUploadStatus'),
'文件上传收到 HTTP 401 时没有恢复登录状态',
)
expect(
/unwrapAuthenticatedUploadResponse[\s\S]*?isAuthenticatedSessionRejected[\s\S]*?recoverExpiredAuthenticatedSession/.test(requestClient),
'文件上传收到业务 401 时没有恢复登录状态',
)
expect(
/HTTP_ERROR'[\s\S]*?httpStatus\) === 403/.test(requestErrorMessage),
'HTTP 403 没有使用无权限提示',
)
for (const [pageName, pageSource] of [
['成员目录', memberDirectoryPage],
['人物列表', peoplePage],
]) {
expect(pageSource.includes('const requestedPage = append ? pageNum.value + 1 : 1'), `${pageName} 没有延迟提交分页页码`)
expect(!pageSource.includes('pageNum.value += 1'), `${pageName} 仍会在请求成功前递增页码`)
expect(pageSource.includes('if (append) loadMoreError.value = true'), `${pageName} 加载更多失败会破坏现有列表`)
expect(pageSource.includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pageName} 没有校验家谱参数`)
}
for (const pagePath of idValidatedPages) {
expect(read(pagePath).includes('/^[1-9]\\d*$/.test(genealogyId.value)'), `${pagePath} 没有校验家谱 ID`)
}
expect(memberRankPage.includes('returnTo("T05", { genealogyId, personId })'), '旧成员排行页没有统一转入成员编辑页')
expect(!memberRankPage.includes('lineageApi.updatePerson'), '旧成员排行页仍保留独立保存逻辑')
expect(!memberRankPage.includes('<text>排序值</text>'), '独立排行页仍要求普通用户手填排序值')
expect(!memberRankPage.includes('updatePersonSortOrder'), '独立排行页仍会提交手工排序值')
expect(editProfilePage.includes('original[field] = payload[field]'), '编辑资料保存成功后没有更新草稿基准')
expect(meritRecordsPage.includes('/^(?:0|[1-9]\\d{0,9})(?:\\.\\d{1,2})?$/'), '功德记录页面没有限制金额精度和范围')
expect(lifeRecordContract.includes("normalizeOptionalCurrencyNumber(payload.amount, '功德金额')"), '功德记录 API 契约没有限制金额精度和范围')
expect(requestNormalizers.includes('normalizeOptionalCurrencyNumber'), '请求契约缺少统一金额校验')
expect(ceremonyDetailPage.includes('献礼金额应为 0 至 9999999999.99'), '礼仪献礼页面没有限制金额精度和范围')
expect(relativeRecordEditorPage.includes('礼金金额应为 0 至 9999999999.99'), '亲友往来页面没有限制金额精度和范围')
expect(profileContract.includes('const isCalendarDate'), '个人资料契约没有验证真实日期')
expect(profileContract.includes("birthday && !isCalendarDate(birthday.slice(0, 10))"), '个人资料响应没有验证生日')
expect(genealogyContext.includes("/^[1-9]\\d*$/.test(genealogyId)"), '家谱上下文允许缓存无效家谱 ID')
expect(
editMemberPage.match(/\/\^\[1-9\]\\d\*\$\/.test/g)?.length >= 4,
'修改成员页没有完整校验家谱和人物 ID',
)
expect(editMemberPage.includes('lineageApi.getRankOptions'), '修改成员页没有读取排行称谓选项')
expect(editMemberPage.includes('rankId: editForm.rankId'), '修改成员页没有提交所选排行称谓')
expect(editMemberPage.includes('暂无可选排行'), '修改成员页没有展示排行配置缺失状态')
expect(!editMemberPage.includes('<text>排序值</text>'), '修改成员页仍要求普通用户手填排序值')
expect(addRelativePage.includes('lineageApi.getRankOptions'), '新增亲属页没有读取排行称谓选项')
expect(addRelativePage.includes('{ rankId: addForm.rankId }'), '新增亲属页没有提交所选排行称谓')
expect(!addRelativePage.includes('<text>排序值</text>'), '新增亲属页仍要求普通用户手填排序值')
expect(addRelativePage.includes('MemberDatePickerSheet'), '新增亲属页没有复用成员日期选择器')
expect(!addRelativePage.includes('mode="date"'), '新增亲属页仍在使用可选择未来年份的系统日期控件')
expect(addRelativePage.includes('不能晚于今天'), '新增亲属页没有在提交前拦截未来日期')
expect(!treeOverview.includes('writing-mode: vertical-rl'), '树状图世代信息仍被强制逐字竖排')
expect(treeOverview.includes('-webkit-line-clamp: 2'), '树状图长姓名仍会被单行省略号截断')
expect(!editMemberPage.includes('`${name}${personOptionId}`'), '修改成员页仍向用户暴露人物内部 ID')
expect(editMemberPage.includes('sortOrder: editForm.sortOrder'), '隐藏排序值后修改成员会把原顺序重置为零')
expect(!treeOverview.includes('@click="toRank"') && !treeOverview.includes('ADJUST_RANK'), '树状图仍保留重复的独立排行入口')
expect(
/v-if="feed\.canDelete"[\s\S]*?label="删除动态"/.test(feedDetailPage),
'动态详情向无删除权限用户显示删除按钮',
)
expect(
articleDetailPage.includes('article.canEdit || article.canDelete || article.canManageProtection'),
'谱文仅有内容密码管理权限时不显示密码操作',
)
expect(
/v-if="video\.canDelete"[\s\S]*?:label="deletingVideoId/.test(familyVideosPageSource),
'视频列表向无删除权限用户显示删除按钮',
)
expect(genealogyContract.includes('hasMembership: item.canManage === true || Boolean(memberStatus)'), '公开家谱契约用管理权限代替成员关系')
expect(genealogySearchPage.includes(':disabled="item.hasMembership"'), '公开家谱对普通成员仍显示申请加入')
expect(treeOverview.includes('/^[1-9]\\d*$/.test(genealogyId.value)'), '树状图没有校验家谱 ID')
expect(pedigree.includes('/^[1-9]\\d*$/.test(genealogyId.value)'), '世系谱没有校验家谱 ID')
const articleCategoryContract = openApi.slice(
openApi.indexOf(' /genealogy/app/genealogies/{genealogyId}/article-categories:'),
openApi.indexOf(' /genealogy/app/genealogies/{genealogyId}/articles:'),
)
expect(articleCategoryContract.includes('AppArticleCategoryResult'), '谱文分类写接口没有返回明确的分类对象')
expect(!articleCategoryContract.includes('PaymentOrderVo'), '谱文分类接口仍错误引用支付下单模型')
expect(/loginWithWechat[\s\S]*?data:\s*\{\s*code:/.test(authService), '微信登录没有按最新后端契约只提交一次性 code')
expect(authService.includes('ACCOUNT_BINDING_REQUIRED'), '微信登录没有处理后端要求的账号绑定状态')
expect(securityPage.includes('authApi.bindWechat'), '账号与安全页没有提供微信绑定入口')
expect(contentRecoveryService.includes('/capability`'), '内容密码找回能力仍调用旧路径')
expect(/resetPassword[\s\S]*?method:\s*'POST'/.test(contentRecoveryService), '内容密码重置仍使用旧 HTTP 方法')
expect(/sendCode[\s\S]*?requireData:\s*false/.test(contentRecoveryService), '内容密码验证码发送仍要求后端返回旧版 delivery 对象')
expect(contentRecoveryContract.includes('value.available'), '内容密码找回能力仍读取旧版 enabled 字段')
expect(contentRecoveryContract.includes('value.mobileMasked'), '内容密码找回能力仍读取旧版 maskedPhone 字段')
expect(referralContract.includes('referredUserCount'), '推广资料没有接入后端返回的推荐人数')
expect(referralContract.includes('value.shareUrl'), '推广资料没有读取后端提供的 HTTPS 分享链接')
expect(promotionsPage.includes('profile.shareUrl'), '推广中心分享内容没有使用后端提供的分享链接')
expect(profileContract.includes('currentPassword: payload.currentPasswordHash.toLowerCase()'), '手机号换绑没有提交当前密码摘要')
expect(changePhonePage.includes('calcMD5(currentPassword.value)'), '手机号换绑没有按现有认证契约处理当前密码')
expect(changePhonePage.includes('session.clear()') && changePhonePage.includes('goRoot("A01")'), '手机号换绑成功后没有清理登录态并返回登录页')
expect(changePasswordPage.includes('session.clear()') && changePasswordPage.includes('goRoot("A01")'), '密码修改成功后没有清理已失效登录态')
expect(genealogyContract.includes("'requestId'") && genealogyContract.includes("'ownerIsFirstAncestor'"), '建谱契约缺少幂等请求号或谱主始迁祖标记')
expect(genealogyCreatePage.includes('genealogyCreateRequestId') && genealogyCreatePage.includes('ownerIsFirstAncestor'), '建谱页面没有提交稳定请求号或谱主始迁祖选择')
expect(genealogyContract.includes('normalizedPayload.coverOssId = null'), '家谱封面契约不能发送 null 清空')
for (const [pageSource, pageName] of [[genealogySettingsPage, '家谱'], [articleEditorPage, '谱文'], [ceremonyEditorPage, '礼仪'], [familyVideosPage, '视频']]) {
expect(pageSource.includes('移除封面'), `${pageName}编辑页没有提供移除封面操作`)
}
expect(lifeRecordContract.includes("BENEFACTOR: 'benefactor'"), '备忘契约缺少家族恩人类型')
expect(memoPage.includes('memoType: memoType.value'), '家族恩人页面没有提交 benefactor 类型')
expect(memoPage.includes('memo.memoType === memoType.value'), '家族恩人列表没有按类型过滤')
for (const contractSource of [lifeRecordContract, familyMediaContract, read('services/api/ceremony-contract.js')]) {
expect(contractSource.includes('createTime:'), '资源响应契约没有保留后端 createTime')
}
expect(batchDeletionComposable.includes('for (const id of pendingIds)') && batchDeletionComposable.includes('break'), '批量删除没有按顺序执行或遇到失败后停止')
expect(batchDeletionComposable.includes('selectedIds.value = selectedIds.value.filter'), '批量删除成功后没有移除已完成选择')
expect(batchManagementBar.includes('仅可选择有删除权限的内容'), '批量管理没有说明权限边界')
expect(batchSelectionMark.includes('role="checkbox"') && batchSelectionMark.includes(':aria-checked="selected"'), '批量选择控件缺少复选语义')
for (const [pageSource, resourceName] of [
[articleListPage, '谱文'],
[familyVideosPage, '视频'],
[ceremonyListPage, '礼仪活动'],
[meritRecordsPage, '功德记录'],
[relativeRecordsPage, '往来记录'],
[memoPage, '家族备忘/恩人'],
[growthJournalPage, '成长记录'],
[personDocumentDialog, '重要证件'],
]) {
expect(pageSource.includes('useBatchDeletion'), `${resourceName}没有接入统一批量删除状态`)
expect(pageSource.includes('BatchManagementBar') && pageSource.includes('BatchSelectionMark'), `${resourceName}缺少批量管理入口或选择控件`)
}
expect(/createArticle[\s\S]*?normalizeAppArticle\(article, normalizedGenealogyId\)/.test(familyArticleService), '谱文创建响应没有规范化,无法可靠继续设置密码')
expect(/createGrowthRecord[\s\S]*?normalizeAppGrowthRecord\(growthRecord, normalizedGenealogyId\)/.test(lifeRecordService), '成长记录创建响应没有规范化,无法可靠继续设置密码')
expect(/genealogyNo:\s*normalizeGenealogyResponseText\(item\.genealogyNo/.test(genealogyContract), '我的家谱响应没有保留后端返回的稳定家谱编号')
expect(genealogyContract.includes("createTime: normalizeGenealogyResponseText(item.createTime, 'createTime')"), '我的家谱响应没有保留后端真实创建时间')
expect(genealogyContract.includes('createTime: genealogy.createTime'), '我的家谱列表规范化时丢弃了真实创建时间')
expect(familyArticleContract.includes("'mediaOssIds'") && familyArticleContract.includes('normalizedPayload.mediaOssIds'), '谱文请求契约没有提交正文图片标识')
expect(familyArticleContract.includes('mediaFiles: normalizeBusinessFileAccessRows'), '谱文响应契约没有保留正文图片')
expect(articleEditorPage.includes('mediaReceipts') && articleEditorPage.includes('mediaOssIds') && articleEditorPage.includes('removeArticleMedia'), '谱文编辑页没有完整支持正文多图新增、回显和移除')
expect(articleDetailPage.includes(':files="article.mediaFiles"'), '谱文详情没有按后端顺序显示正文图片')
expect(/createArticle[\s\S]*?setArticlePassword/.test(articleEditorPage), '新建谱文没有在创建成功后继续设置内容密码')
expect(/createGrowthRecord[\s\S]*?setGrowthRecordPassword/.test(growthJournalPage), '新建成长记录没有在创建成功后继续设置内容密码')
expect(/createPersonDocument[\s\S]*?setPersonDocumentPassword/.test(personDocumentDialog), '新建重要证件没有在创建成功后继续设置内容密码')
for (const protectedCreateSource of [articleEditorPage, growthJournalPage, personDocumentDialog]) {
expect(protectedCreateSource.includes('可能尚未受到密码保护'), '两阶段创建没有明确提示密码设置失败后的未保护风险')
}
expect(permissionContract.includes('item.code'), '权限目录仍读取旧版 permissionCode 字段')
expect(permissionContract.includes('item.groupName'), '权限目录没有接入后端权限分组')
expect(genealogyCapabilityService.includes('/comments/page`'), '家族视频根评论仍调用旧列表路径')
expect(genealogyCapabilityService.includes('/replies/page`'), '家族视频回复仍调用旧列表路径')
expect(/getVideoComments[\s\S]*?assertPage/.test(genealogyCapabilityService), '家族视频评论没有读取后端分页响应')
expect(genealogyContract.includes('genealogyName: payload.confirmationName.trim()'), '家谱永久删除仍提交旧版 confirmationName 字段')
expect(/getPermanentDeletionCapability[\s\S]*?permanent-deletion\/capability/.test(vipService + genealogyCapabilityService + read('services/api/genealogy-service.js')), '家谱设置没有接入永久删除能力接口')
expect(vipContract.includes('item.method'), 'VIP 支付能力没有读取后端 VO 的 method 字段')
expect(!vipContract.includes('item.paymentMethod'), 'VIP 支付能力仍读取与后端 VO 不一致的 paymentMethod 字段')
expect(vipContract.includes('value.orderString'), '支付宝下单仍读取旧版 alipayOrderInfo 字段')
expect(vipContract.includes('value.completed !== true'), '余额支付没有校验后端 completed 字段')
expect(/createVipOrder[\s\S]*?requestId/.test(vipService), 'VIP 下单没有提交后端必填幂等 requestId')
for (const dictionaryType of [
'gen_parent_relationship_variant',
'gen_education_type',
'gen_death_expression',
'gen_spouse_relationship_variant',
'gen_ceremony_type',
'gen_person_document_type',
'gen_growth_record_type',
'gen_merit_type',
'gen_feedback_type',
'gen_zodiac',
]) {
expect(businessDictionaryContract.includes(`'${dictionaryType}'`), `前端业务字典白名单缺少 ${dictionaryType}`)
}
for (const latestLineageField of ['zodiacCode', 'educationCode', 'deathExpressionCode', 'relationVariantCode']) {
expect(lineageWriteContract.includes(`'${latestLineageField}'`), `成员写契约缺少最新字段 ${latestLineageField}`)
}
expect(!lineageWriteContract.includes("'hereditaryMedicalHistory'"), '成员主档仍混入遗传病史敏感字段')
expect(lineageService.includes('/sensitive-profile`'), '成员敏感健康资料没有接入独立接口')
expect(lineagePersonContract.includes('value.zodiacCode'), '成员详情仍读取旧版生肖字段')
expect(lineagePersonContract.includes('value.educationCode'), '成员详情仍读取旧版学历字段')
expect(lineagePersonContract.includes('value.deathExpressionCode'), '成员详情仍读取旧版逝世表述字段')
for (const pageSource of [addRelativePage, editMemberPage]) {
expect(pageSource.includes('gen_zodiac'), '成员表单没有读取生肖动态字典')
expect(pageSource.includes('gen_education_type'), '成员表单没有读取学历动态字典')
expect(pageSource.includes('gen_death_expression'), '成员表单没有读取逝世表述动态字典')
expect(pageSource.includes('dictionaryRequestControllers'), '成员表单的并发字典请求仍共用一个取消控制器')
}
expect(
genealogySettingsPage.includes('deletionCapabilityReadRequestController'),
'家谱设置和永久删除能力的并发读取仍共用一个取消控制器',
)
if (failures.length) {
console.error(`AUDIT REGRESSION CHECK FAILED (${failures.length})`)
failures.forEach((failure) => console.error(`- ${failure}`))
process.exit(1)
}
console.log(`AUDIT REGRESSION CHECK PASS icons=${uniqueIconPaths.length}`)