277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
|
||
import YAML from 'yaml'
|
||
|
||
const workspace = process.cwd()
|
||
const failures = []
|
||
|
||
const fail = (message) => failures.push(message)
|
||
const readText = (filePath) => fs.readFileSync(path.join(workspace, filePath), 'utf8')
|
||
const listFiles = (entryPaths, extensions) => {
|
||
const files = []
|
||
const visit = (entryPath) => {
|
||
const absolutePath = path.join(workspace, entryPath)
|
||
if (!fs.existsSync(absolutePath)) return
|
||
const stat = fs.statSync(absolutePath)
|
||
if (stat.isDirectory()) {
|
||
for (const name of fs.readdirSync(absolutePath)) {
|
||
visit(path.join(entryPath, name))
|
||
}
|
||
return
|
||
}
|
||
if (extensions.has(path.extname(entryPath))) files.push(entryPath)
|
||
}
|
||
entryPaths.forEach(visit)
|
||
return files
|
||
}
|
||
|
||
const parseJson = (filePath) => {
|
||
try {
|
||
return JSON.parse(readText(filePath))
|
||
} catch (error) {
|
||
fail(`${filePath} 不是有效 JSON:${error.message}`)
|
||
return null
|
||
}
|
||
}
|
||
|
||
const pagesConfig = parseJson('pages.json')
|
||
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])
|
||
const routePaths = routeEntries.map((match) => match[0].match(/path: "([^"]+)"/)?.[1]?.replace(/^\//, ''))
|
||
const configuredPages = pagesConfig?.pages?.map((page) => page.path) || []
|
||
|
||
if (new Set(routeKeys).size !== routeKeys.length) fail('路由键存在重复')
|
||
if (routePaths.some((routePath) => !routePath)) fail('存在没有静态 path 的路由')
|
||
for (const pagePath of configuredPages) {
|
||
if (!fs.existsSync(path.join(workspace, `${pagePath}.vue`))) fail(`页面文件缺失:${pagePath}.vue`)
|
||
if (!routePaths.includes(pagePath)) fail(`页面没有路由语义:${pagePath}`)
|
||
}
|
||
for (const routePath of routePaths) {
|
||
if (!configuredPages.includes(routePath)) fail(`路由没有在 pages.json 注册:${routePath}`)
|
||
}
|
||
|
||
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>`)
|
||
}
|
||
for (const match of source.matchAll(importPattern)) {
|
||
const specifier = match[1]
|
||
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue
|
||
const basePath = specifier.startsWith('@/')
|
||
? path.join(workspace, specifier.slice(2))
|
||
: path.resolve(workspace, path.dirname(filePath), specifier)
|
||
const candidates = [basePath, `${basePath}.js`, `${basePath}.vue`, path.join(basePath, 'index.js')]
|
||
if (!candidates.some((candidate) => fs.existsSync(candidate))) {
|
||
fail(`${filePath} 导入不存在:${specifier}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
const visualFiles = listFiles(
|
||
['App.vue', 'uni.scss', 'pages', 'components', 'styles'],
|
||
new Set(['.vue', '.scss']),
|
||
)
|
||
const assetPattern = /["'(]((?:\/@?static\/|@\/static\/)[^"')\s]+)["')]/g
|
||
for (const filePath of visualFiles) {
|
||
for (const match of readText(filePath).matchAll(assetPattern)) {
|
||
const assetPath = match[1].replace(/^@?\//, '')
|
||
if (!fs.existsSync(path.join(workspace, assetPath))) fail(`${filePath} 引用不存在的资源:${match[1]}`)
|
||
}
|
||
}
|
||
|
||
let openApi = null
|
||
try {
|
||
openApi = YAML.parse(readText('genealogy-app-openapi.yaml'))
|
||
} catch (error) {
|
||
fail(`genealogy-app-openapi.yaml 无法解析:${error.message}`)
|
||
}
|
||
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) {
|
||
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) && !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) {
|
||
console.error(`PROJECT CHECK FAILED (${failures.length})`)
|
||
failures.forEach((message) => console.error(`- ${message}`))
|
||
process.exit(1)
|
||
}
|
||
|
||
console.log(`PROJECT CHECK PASS pages=${configuredPages.length} routes=${routeKeys.length} sources=${sourceFiles.length}`)
|