feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
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 register = read('pages/auth/register.vue')
|
||||
const routes = read('utils/navigation/routes.js')
|
||||
const genealogyHome = read('pages/genealogy/my-genealogies.vue')
|
||||
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 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 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-rank.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 lifeRecordService = read('services/api/life-record-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(
|
||||
/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(
|
||||
/members:\s*\(\)\s*=>\s*openPage\("G13"/.test(genealogyHome),
|
||||
'家谱首页“成员”没有直达成员列表',
|
||||
)
|
||||
const platformVideosRoute = routes.match(/F11: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
|
||||
expect(platformVideosRoute.includes('"G01"'), '宣传视频路由不允许从家谱首页进入')
|
||||
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'), '家谱首页没有宣传视频封面预览')
|
||||
expect(
|
||||
genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
|
||||
'家谱首页没有读取首页推荐视频投放位',
|
||||
)
|
||||
expect(
|
||||
platformVideosPage.includes('video-card__cover-button') &&
|
||||
platformVideosPage.includes('requestedVideoId'),
|
||||
'宣传视频列表没有按封面进入指定视频播放',
|
||||
)
|
||||
expect(
|
||||
genealogyHome.includes('genealogyListError') &&
|
||||
genealogyHome.includes('getRequestErrorMessage'),
|
||||
'家谱首页仍会隐藏家谱列表的真实失败类型',
|
||||
)
|
||||
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(
|
||||
/\.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(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('createDiscardConfirmation'), '成员排行页缺少未保存修改确认')
|
||||
expect(memberRankPage.includes('dirty: isDirty.value'), '成员排行页返回守卫未检查排序修改')
|
||||
expect(memberRankPage.includes('sortOrderBaseline.value = normalizedSortOrder'), '成员排行保存成功后没有更新草稿基准')
|
||||
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(
|
||||
/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(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}`)
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
const referenceRoot = path.resolve(workspaceRoot, '..', 'Jiapu-App')
|
||||
const referencePagesPath = path.join(referenceRoot, 'pages.json')
|
||||
const parityDocumentPath = path.join(
|
||||
workspaceRoot,
|
||||
'docs',
|
||||
'frontend-reference-parity-2026-08-17.md'
|
||||
)
|
||||
|
||||
const stripJsonLineComments = (source) =>
|
||||
source
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => {
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let index = 0; index < line.length - 1; index += 1) {
|
||||
const character = line[index]
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (character === '\\' && inString) {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (character === '"') {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if (!inString && character === '/' && line[index + 1] === '/') {
|
||||
return line.slice(0, index)
|
||||
}
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const fail = (message) => {
|
||||
console.error(`FRONTEND PARITY CHECK FAIL: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(referencePagesPath)) {
|
||||
fail(`参考项目不存在:${referencePagesPath}`)
|
||||
}
|
||||
if (!fs.existsSync(parityDocumentPath)) {
|
||||
fail(`对比总表不存在:${parityDocumentPath}`)
|
||||
}
|
||||
|
||||
const referenceConfig = JSON.parse(
|
||||
stripJsonLineComments(fs.readFileSync(referencePagesPath, 'utf8'))
|
||||
)
|
||||
const referenceRoutes = referenceConfig.pages.map(({ path: routePath }) => routePath)
|
||||
if (referenceRoutes.length !== 78) {
|
||||
fail(`参考项目活动路由应为 78 条,实际为 ${referenceRoutes.length} 条`)
|
||||
}
|
||||
if (new Set(referenceRoutes).size !== referenceRoutes.length) {
|
||||
fail('参考项目 pages.json 存在重复活动路由')
|
||||
}
|
||||
|
||||
for (const routePath of referenceRoutes) {
|
||||
const vuePath = path.join(referenceRoot, `${routePath}.vue`)
|
||||
const nvuePath = path.join(referenceRoot, `${routePath}.nvue`)
|
||||
if (!fs.existsSync(vuePath) && !fs.existsSync(nvuePath)) {
|
||||
fail(`参考路由没有 .vue 或 .nvue 页面文件:${routePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const parityDocument = fs.readFileSync(parityDocumentPath, 'utf8')
|
||||
const documentedRouteRows = parityDocument
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => /^\| `[^`]+` \|/u.test(line))
|
||||
.map((line) => line.split('|').slice(1, -1).map((column) => column.trim()))
|
||||
const documentedRoutes = documentedRouteRows.map(([routeCell]) => routeCell.slice(1, -1))
|
||||
const allowedStatuses = new Set([
|
||||
'覆盖',
|
||||
'合并覆盖',
|
||||
'升级替代',
|
||||
'非产品页',
|
||||
'本轮补齐',
|
||||
'部分覆盖',
|
||||
'部分合并',
|
||||
'部分升级',
|
||||
'前端待后端'
|
||||
])
|
||||
|
||||
for (const columns of documentedRouteRows) {
|
||||
if (columns.length !== 5 || columns.some((column) => !column)) {
|
||||
fail(`对比总表路由行字段不完整:${columns[0] || '未知路由'}`)
|
||||
}
|
||||
if (!allowedStatuses.has(columns[3])) {
|
||||
fail(`对比总表路由状态无效:${columns[0]} -> ${columns[3]}`)
|
||||
}
|
||||
}
|
||||
|
||||
const documentedRouteCounts = new Map()
|
||||
for (const routePath of documentedRoutes) {
|
||||
documentedRouteCounts.set(
|
||||
routePath,
|
||||
(documentedRouteCounts.get(routePath) || 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
const missingRoutes = referenceRoutes.filter(
|
||||
(routePath) => !documentedRouteCounts.has(routePath)
|
||||
)
|
||||
const duplicateRoutes = [...documentedRouteCounts.entries()]
|
||||
.filter(([, count]) => count !== 1)
|
||||
.map(([routePath]) => routePath)
|
||||
const unknownRoutes = documentedRoutes.filter(
|
||||
(routePath) => !referenceRoutes.includes(routePath)
|
||||
)
|
||||
|
||||
if (missingRoutes.length) fail(`对比总表遗漏路由:${missingRoutes.join(', ')}`)
|
||||
if (duplicateRoutes.length) fail(`对比总表重复路由:${duplicateRoutes.join(', ')}`)
|
||||
if (unknownRoutes.length) fail(`对比总表包含未知路由:${unknownRoutes.join(', ')}`)
|
||||
|
||||
console.log(
|
||||
`FRONTEND PARITY CHECK PASS referenceRoutes=${referenceRoutes.length} documentedRoutes=${documentedRoutes.length}`
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const toModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
|
||||
|
||||
const routesSource = fs.readFileSync('utils/navigation/routes.js', 'utf8')
|
||||
const gatewaySource = fs.readFileSync('utils/navigation/gateway.js', 'utf8')
|
||||
.replace('"./routes.js"', JSON.stringify(toModuleUrl(routesSource)))
|
||||
|
||||
let currentPage = { route: 'pages/profile/home' }
|
||||
const relaunchCalls = []
|
||||
globalThis.getCurrentPages = () => [currentPage]
|
||||
globalThis.uni = {
|
||||
reLaunch(options) {
|
||||
relaunchCalls.push(options)
|
||||
},
|
||||
}
|
||||
|
||||
const { goRoot, recoverToAuthRoot } = await import(toModuleUrl(gatewaySource))
|
||||
|
||||
const ordinaryNavigation = goRoot('G01')
|
||||
const sessionRecovery = recoverToAuthRoot()
|
||||
|
||||
assert.equal(relaunchCalls.length, 1, '登录恢复应等待已有转场完成')
|
||||
assert.equal(relaunchCalls[0].url, '/pages/genealogy/my-genealogies')
|
||||
|
||||
currentPage = { route: 'pages/genealogy/my-genealogies' }
|
||||
relaunchCalls[0].success()
|
||||
await ordinaryNavigation
|
||||
await Promise.resolve()
|
||||
|
||||
assert.equal(relaunchCalls.length, 2, '已有转场完成后必须继续登录恢复')
|
||||
assert.equal(relaunchCalls[1].url, '/pages/auth/sign-in')
|
||||
|
||||
currentPage = { route: 'pages/auth/sign-in' }
|
||||
relaunchCalls[1].success()
|
||||
assert.equal(await sessionRecovery, true)
|
||||
|
||||
console.log('NAVIGATION RECOVERY CHECK PASS')
|
||||
+145
-2
@@ -40,6 +40,26 @@ parseJson('manifest.json')
|
||||
parseJson('package.json')
|
||||
parseJson('package-lock.json')
|
||||
|
||||
const runtimeConfigSource = readText('utils/runtime-config.js')
|
||||
if (runtimeConfigSource.includes('import.meta.env')) {
|
||||
fail('运行时配置直接读取 import.meta.env,会把构建机环境写入生产资源')
|
||||
}
|
||||
if (/\bmock\b|isMockMode|resolveRuntimeMode/.test(runtimeConfigSource)) {
|
||||
fail('正式运行时配置仍保留不可达的 mock 双轨逻辑')
|
||||
}
|
||||
for (const productionValue of [
|
||||
"const configuredBaseUrl = 'https://backend-api.ddxcjp.cn'",
|
||||
"const configuredClientId = '428a8310cd442757ae699df5d894f051'",
|
||||
"const configuredTenantId = '000000'",
|
||||
]) {
|
||||
if (!runtimeConfigSource.includes(productionValue)) {
|
||||
fail(`运行时正式配置不完整:${productionValue}`)
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(path.join(workspace, 'data', 'preview'))) {
|
||||
fail('正式项目仍保留不可达的本地预览数据目录 data/preview')
|
||||
}
|
||||
|
||||
const routeSource = readText('utils/navigation/routes.js')
|
||||
const routeEntries = [...routeSource.matchAll(/^\s{2}([A-Z]\d{2}): defineRoute\(\{[\s\S]*?^\s{2}\}\),/gm)]
|
||||
const routeKeys = routeEntries.map((match) => match[1])
|
||||
@@ -60,9 +80,17 @@ const sourceFiles = listFiles(
|
||||
['App.vue', 'main.js', 'pages', 'components', 'composables', 'services', 'utils'],
|
||||
new Set(['.js', '.vue']),
|
||||
)
|
||||
if (/hasRemoteConfig|REMOTE_(?:READ|WRITE)_REQUIRED|WRITE_UNAVAILABLE|本地预览/.test(
|
||||
sourceFiles.map((filePath) => readText(filePath)).join('\n')
|
||||
)) {
|
||||
fail('正式源码仍保留已经不可达的本地预览守卫或错误码')
|
||||
}
|
||||
const importPattern = /(?:from\s*|import\s*)["']([^"']+)["']/g
|
||||
for (const filePath of sourceFiles) {
|
||||
const source = readText(filePath)
|
||||
if (source.includes('@/data/preview')) {
|
||||
fail(`${filePath} 仍引用本地预览数据`)
|
||||
}
|
||||
if (filePath.endsWith('.vue') && !/<(?:template|script)(?:\s|>)/.test(source)) {
|
||||
fail(`${filePath} 缺少 <template> 或 <script>`)
|
||||
}
|
||||
@@ -101,27 +129,142 @@ if (fs.existsSync(path.join(workspace, '家谱.openapi.json'))) {
|
||||
fail('检测到旧 OpenAPI:家谱.openapi.json;genealogy-app-openapi.yaml 必须是唯一所有者')
|
||||
}
|
||||
|
||||
const resolveLocalOpenApiRef = (reference) => {
|
||||
if (!openApi || typeof reference !== 'string' || !reference.startsWith('#/')) return undefined
|
||||
return reference
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'))
|
||||
.reduce((value, segment) => value?.[segment], openApi)
|
||||
}
|
||||
const visitOpenApiNode = (value, location = '$') => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry, index) => visitOpenApiNode(entry, `${location}[${index}]`))
|
||||
return
|
||||
}
|
||||
if (!value || typeof value !== 'object') return
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === '$ref' && typeof child === 'string' && child.startsWith('#/') && resolveLocalOpenApiRef(child) === undefined) {
|
||||
fail(`OpenAPI 引用了不存在的本地定义:${child}(${location})`)
|
||||
continue
|
||||
}
|
||||
visitOpenApiNode(child, `${location}.${key}`)
|
||||
}
|
||||
}
|
||||
visitOpenApiNode(openApi)
|
||||
|
||||
if (openApi?.servers?.[0]?.url !== 'https://backend-api.ddxcjp.cn') {
|
||||
fail('OpenAPI 首选服务地址必须与正式运行时 HTTPS 接口一致')
|
||||
}
|
||||
|
||||
for (const [endpointPath, pathItem] of Object.entries(openApi?.paths || {})) {
|
||||
const requiredPathParams = [...endpointPath.matchAll(/\{([^}]+)\}/g)].map((match) => match[1])
|
||||
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
|
||||
const operation = pathItem[method]
|
||||
if (!operation) continue
|
||||
const declaredPathParams = [...(pathItem.parameters || []), ...(operation.parameters || [])]
|
||||
.map((parameter) => parameter?.$ref ? resolveLocalOpenApiRef(parameter.$ref) : parameter)
|
||||
.filter((parameter) => parameter?.in === 'path')
|
||||
.map((parameter) => parameter.name)
|
||||
for (const name of requiredPathParams) {
|
||||
if (!declaredPathParams.includes(name)) {
|
||||
fail(`OpenAPI 路径参数未声明:${method.toUpperCase()} ${endpointPath} 缺少 ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const compliancePath of [
|
||||
'/genealogy/app/compliance/documents/{documentKey}',
|
||||
'/genealogy/app/compliance/documents/{documentKey}/versions/{versionNo}',
|
||||
]) {
|
||||
const security = openApi?.paths?.[compliancePath]?.get?.security
|
||||
if (!Array.isArray(security) || security.length !== 0) {
|
||||
fail(`OpenAPI 合规文档必须允许登录前读取:GET ${compliancePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const contentProtectionPath of [
|
||||
'/genealogy/app/genealogies/{genealogyId}/articles/{articleId}/content-protection',
|
||||
'/genealogy/app/genealogies/{genealogyId}/growth-records/{recordId}/content-protection',
|
||||
]) {
|
||||
for (const method of ['put', 'delete']) {
|
||||
const schemaRef = openApi?.paths?.[contentProtectionPath]?.[method]
|
||||
?.responses?.['200']?.content?.['application/json']?.schema?.$ref
|
||||
if (schemaRef !== '#/components/schemas/RVoid') {
|
||||
fail(`OpenAPI 内容密码写操作必须返回 RVoid:${method.toUpperCase()} ${contentProtectionPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [schemaName, expectedGrantType] of [
|
||||
['PasswordRegisterBody', 'password'],
|
||||
['PasswordLoginBody', 'password'],
|
||||
['SmsLoginBody', 'sms'],
|
||||
['SmsCodeBody', 'sms'],
|
||||
['PasswordResetBody', 'password'],
|
||||
]) {
|
||||
const grantType = openApi?.components?.schemas?.[schemaName]?.properties?.grantType
|
||||
if (!Array.isArray(grantType?.enum) || grantType.enum.length !== 1 || grantType.enum[0] !== expectedGrantType) {
|
||||
fail(`OpenAPI ${schemaName}.grantType 必须固定为 ${expectedGrantType}`)
|
||||
}
|
||||
}
|
||||
if (openApi?.components?.schemas?.PasswordChangeBody?.additionalProperties !== false) {
|
||||
fail('OpenAPI PasswordChangeBody 必须拒绝未知字段')
|
||||
}
|
||||
const loginSchema = openApi?.components?.schemas?.LoginVo
|
||||
if (
|
||||
loginSchema?.additionalProperties !== false ||
|
||||
!Array.isArray(loginSchema?.required) ||
|
||||
loginSchema.required.length !== 1 ||
|
||||
loginSchema.required[0] !== 'access_token' ||
|
||||
!loginSchema?.properties?.access_token ||
|
||||
['token', 'accessToken', 'tokenValue'].some((field) => loginSchema?.properties?.[field])
|
||||
) {
|
||||
fail('OpenAPI LoginVo 必须只以 access_token 作为会话令牌契约')
|
||||
}
|
||||
|
||||
const normalizeEndpointPath = (endpointPath) => endpointPath
|
||||
.split('?')[0]
|
||||
.replace(/\$\{[^}]+\}/g, '{}')
|
||||
.replace(/\{[^}]+\}/g, '{}')
|
||||
const documentedPaths = new Set(Object.keys(openApi?.paths || {}).map(normalizeEndpointPath))
|
||||
const documentedOperations = new Set(
|
||||
Object.entries(openApi?.paths || {}).flatMap(([endpointPath, pathItem]) =>
|
||||
['get', 'post', 'put', 'patch', 'delete']
|
||||
.filter((method) => pathItem?.[method])
|
||||
.map((method) => `${method.toUpperCase()} ${normalizeEndpointPath(endpointPath)}`),
|
||||
),
|
||||
)
|
||||
const dynamicEndpointBuilders = new Set([
|
||||
'/genealogy/app/genealogies/{}/{}',
|
||||
'/genealogy/app/genealogies/{}/lineage/persons/{}/{}',
|
||||
])
|
||||
const servicePathBuilders = new Set([
|
||||
'/genealogy/app/genealogies/{}/content-password-recovery/{}/{}',
|
||||
])
|
||||
const apiFiles = listFiles(['services/api'], new Set(['.js']))
|
||||
.filter((filePath) => filePath.endsWith('-service.js') || filePath.endsWith('request-client.js'))
|
||||
const endpointPattern = /([`'"])(\/(?:genealogy|captcha|auth)[\s\S]*?)\1/g
|
||||
const serviceOperationPattern = /url:\s*([`'"])(\/(?:genealogy|captcha|auth)[^`'"\r\n]*)\1,\s*method:\s*(['"])(GET|POST|PUT|PATCH|DELETE)\3/g
|
||||
for (const filePath of apiFiles) {
|
||||
for (const match of readText(filePath).matchAll(endpointPattern)) {
|
||||
const source = readText(filePath)
|
||||
for (const match of source.matchAll(endpointPattern)) {
|
||||
const endpoint = match[2]
|
||||
if (endpoint.includes('\n')) continue
|
||||
const normalizedPath = normalizeEndpointPath(endpoint)
|
||||
if (!documentedPaths.has(normalizedPath) && !dynamicEndpointBuilders.has(normalizedPath)) {
|
||||
if (!documentedPaths.has(normalizedPath) && !dynamicEndpointBuilders.has(normalizedPath) && !servicePathBuilders.has(normalizedPath)) {
|
||||
fail(`${filePath} 使用了 OpenAPI 未声明的路径:${endpoint}`)
|
||||
}
|
||||
}
|
||||
for (const match of source.matchAll(serviceOperationPattern)) {
|
||||
const endpoint = normalizeEndpointPath(match[2])
|
||||
if (dynamicEndpointBuilders.has(endpoint)) continue
|
||||
const operation = `${match[4]} ${endpoint}`
|
||||
if (!documentedOperations.has(operation)) {
|
||||
fail(`${filePath} 使用了 OpenAPI 未声明的操作:${operation}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user