feat(B批收尾): B1门禁可信/B7-1菜单环防护/B2空值守卫/B7-2分页/B7-3字段清洗/B3空态/B4索引key/B5-3 comment三集合选A新建schema(漂移清零)/B6清理+admin配置+changelog

This commit is contained in:
12914
2026-09-12 14:14:42 +08:00
parent e408cd0a3e
commit c80c5977f7
79 changed files with 18538 additions and 16777 deletions
+257
View File
@@ -0,0 +1,257 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
const reportDir = path.join(root, 'reports')
const excluded = new Set(['.git', 'node_modules', 'unpackage', '.zcode'])
function walk(dir) {
if (!fs.existsSync(dir)) return []
const result = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory() && excluded.has(entry.name)) continue
const file = path.join(dir, entry.name)
if (entry.isDirectory()) result.push(...walk(file))
else result.push(file)
}
return result
}
function rel(file) {
return path.relative(root, file).replace(/\\/g, '/')
}
function parseJson(file) {
try {
return JSON.parse(stripJsonComments(fs.readFileSync(file, 'utf8')))
} catch (error) {
return { __error: error.message }
}
}
// B1: schema 允许 // 行注释与 /* 块注释头(uniCloud JSONC),比较前剥离
function stripJsonComments(text) {
let out = ''
let i = 0
const n = text.length
let inStr = false
while (i < n) {
const c = text[i]
if (inStr) {
out += c
if (c === '\\') { out += text[i + 1] || ''; i += 2; continue }
if (c === '"') inStr = false
i++
continue
}
if (c === '"') { inStr = true; out += c; i++; continue }
if (c === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i)
const stop = end === -1 ? n : end
out += ' '.repeat(stop - i)
i = stop
continue
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
const stop = end === -1 ? n : end + 2
const skipped = text.slice(i, stop)
out += skipped.replace(/[^\n]/g, ' ')
i = stop
continue
}
out += c
i++
}
return out
}
function schemaFiles() {
const localSchemas = walk(path.join(root, 'uniCloud-alipay', 'database'))
.filter(file => file.endsWith('.schema.json'))
const moduleSchemas = walk(path.join(root, 'uni_modules'))
.filter(file => file.endsWith('.schema.json'))
return [...localSchemas, ...moduleSchemas]
}
function sourceFiles() {
return [
...walk(path.join(root, 'pages')),
...walk(path.join(root, 'components')),
...walk(path.join(root, 'cloudfunctions'))
].filter(file => /\.(vue|js)$/.test(file))
}
function getSchemas() {
const result = new Map()
for (const file of schemaFiles()) {
result.set(path.basename(file, '.schema.json'), { file: rel(file), schema: parseJson(file) })
}
return result
}
function addMap(map, key, value) {
if (!map.has(key)) map.set(key, [])
map.get(key).push(value)
}
function collectCollections(sources) {
const collections = new Map()
const patterns = [
/collectionList\s*:\s*["']([^"']+)["']/g,
/collection\s*:\s*["']([^"']+)["']/g,
/collection\(["']([^"']+)["']\)/g
]
for (const file of sources) {
const text = fs.readFileSync(file, 'utf8')
for (const pattern of patterns) {
let match
while ((match = pattern.exec(text))) addMap(collections, match[1], rel(file))
}
}
return collections
}
function collectFields(sources, collections) {
const fields = new Map()
// B1-1: 前置边界 [\s"] 排除 group-field/groupField;只认属性 field=(不认 .field( JS 调用)
const fieldPattern = /[\s"]field\s*=\s*["']([^"']+)["']/g
// 主集合判定:<unicloud-db ...> 标签内的 collection 绑定
const udbRe = /<unicloud-db\b[^>]*>/g
const udbCollectionRe = /collectionList\s*:\s*["']([^"']+)["']|collection\s*:\s*["']([^"']+)["']/
for (const file of sources.filter(file => file.endsWith('.vue'))) {
const text = fs.readFileSync(file, 'utf8')
// script 区 collectionList: "x" 字面量(:collection="collectionList" 绑定的解析目标)
const scriptCollections = []
const scriptRe = /collectionList\s*:\s*["']([^"']+)["']/g
let sm
while ((sm = scriptRe.exec(text))) scriptCollections.push(sm[1])
const arrayRe = /collectionList\s*:\s*\[([^\]]*)\]/g
let am
while ((am = arrayRe.exec(text))) {
const inner = am[1]
const strRe = /db\.collection\(\s*["']([^"']+)["']\s*\)/g
let im
while ((im = strRe.exec(inner))) scriptCollections.push(im[1])
}
let um
let foundMain = false
const pendingTags = []
while ((um = udbRe.exec(text))) {
const tag = um[0]
const cm = udbCollectionRe.exec(tag)
if (!cm) {
if (/[:\s]collection\s*=/.test(tag)) pendingTags.push(tag)
continue
}
const main = cm[1] || cm[2]
foundMain = true
collectTagFields(tag, main, file, fields)
}
if (pendingTags.length && scriptCollections.length) {
// :collection="collectionList" 绑定的 unicloud-db 标签:只收该标签自身的 field,
// 不收 uni-data-select / uni-data-checkbox 等辅助组件的 field
foundMain = true
const main = scriptCollections[0]
for (const tag of pendingTags) collectTagFields(tag, main, file, fields)
}
}
return fields
}
function collectTagFields(tag, main, file, fields) {
const fieldRe = /[\s"]field\s*=\s*["']([^"']+)["']/g
let fm
while ((fm = fieldRe.exec(tag))) addFieldEntries(main, fm[1], file, fields)
}
function addFieldEntries(main, raw, file, fields) {
for (const field of raw.split(',')) {
// B1-1: 去 as 别名,去表前缀;聚合表达式(含括号)直接丢弃
const name = field.trim().split(/\s+as\s+/i)[0].split('.')[0].trim()
if (!name || name.includes('(') || name.includes(')')) continue
addMap(fields, `${main}:${name}`, rel(file))
}
}
function buildReport() {
const schemas = getSchemas()
const sources = sourceFiles()
const collections = collectCollections(sources)
const fields = collectFields(sources, collections)
const missingSchemas = []
for (const [collection, files] of collections) {
if (!schemas.has(collection)) missingSchemas.push({ collection, files: [...new Set(files)] })
}
const fieldDrift = []
for (const [key, files] of fields) {
const [collection, field] = key.split(':')
const schema = schemas.get(collection)
if (!schema || schema.schema.__error) {
fieldDrift.push({ collection, field, files: [...new Set(files)], schema: schema ? schema.file : null, driftKind: 'missing-in-schema' })
} else if (!schema.schema.properties || !Object.prototype.hasOwnProperty.call(schema.schema.properties, field)) {
fieldDrift.push({ collection, field, files: [...new Set(files)], schema: schema.file, driftKind: 'page-field-typo' })
}
}
const knownMigrations = [
{
source: 'opendb-news-articles',
target: 'uni-cms-articles',
status: 'requires-review',
mapping: { avatar: 'thumbnail[]', content: 'content Delta/object', category_id: 'uni-cms-categories._id' }
},
{
source: 'opendb-news-comments',
target: 'comment',
status: 'missing-target-schema',
mapping: { comment_content: 'content', comment_date: 'create_time', comment_ip: 'ip_location' }
},
{
source: 'opendb-news-favorite',
target: 'comment_like',
status: 'requires-review',
mapping: { create_date: 'create_time', article_id: 'comment_id (semantic mismatch)' }
}
]
const report = {
generatedAt: new Date().toISOString(),
schemas: [...schemas.entries()].map(([name, value]) => ({ name, file: value.file })),
collections: [...collections.entries()].map(([name, files]) => ({ name, files: [...new Set(files)] })),
missingSchemas,
fieldDrift,
knownMigrations,
summary: {
schemaCount: schemas.size,
collectionCount: collections.size,
missingSchemaCount: missingSchemas.length,
fieldDriftCount: fieldDrift.length
}
}
fs.mkdirSync(reportDir, { recursive: true })
fs.writeFileSync(path.join(reportDir, 'data-contract-report.json'), JSON.stringify(report, null, 2) + '\n')
const lines = [
'# Data contract report',
'',
`Generated: ${report.generatedAt}`,
'',
`- Schemas: ${report.summary.schemaCount}`,
`- Referenced collections: ${report.summary.collectionCount}`,
`- Missing schemas: ${report.summary.missingSchemaCount}`,
`- Field drift entries: ${report.summary.fieldDriftCount}`,
'',
'## Missing schemas',
''
]
if (!missingSchemas.length) lines.push('None', '')
for (const item of missingSchemas) lines.push(`- **${item.collection}** — ${item.files.join(', ')}`)
lines.push('', '## Field drift', '')
if (!fieldDrift.length) lines.push('None', '')
for (const item of fieldDrift.slice(0, 200)) lines.push(`- **${item.collection}.${item.field}** [\`${item.driftKind}\`] — ${item.files.join(', ')}`)
lines.push('', '## Migration review queue', '')
for (const item of knownMigrations) lines.push(`- **${item.source} → ${item.target}** (${item.status}) — ${JSON.stringify(item.mapping)}`)
fs.writeFileSync(path.join(reportDir, 'data-contract-report.md'), lines.join('\n') + '\n')
return report
}
const report = buildReport()
console.log(JSON.stringify(report.summary))
@@ -0,0 +1,17 @@
[
{
"_id": "demo-article-1",
"title": "示例文章",
"excerpt": "用于迁移器和空数据回归测试",
"content": "这是旧文章内容。",
"avatar": "cloud://demo-cover.jpg",
"category_id": "demo-category",
"user_id": "demo-user",
"article_status": 1,
"view_count": 12,
"like_count": 2,
"comment_status": 1,
"comment_count": 0,
"create_date": 1710000000000
}
]
+77
View File
@@ -0,0 +1,77 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
const fixtureDir = path.join(root, 'scripts', 'fixtures')
const reportDir = path.join(root, 'reports')
const sourceFile = process.env.MIGRATION_SOURCE || path.join(fixtureDir, 'opendb-news-articles.json')
const outputFile = path.join(reportDir, 'cms-migration-dry-run.json')
function readRows(file) {
if (!fs.existsSync(file)) return []
const value = JSON.parse(fs.readFileSync(file, 'utf8'))
if (!Array.isArray(value)) throw new Error(`fixture must be an array: ${file}`)
return value
}
function toDelta(content) {
if (content && typeof content === 'object' && Array.isArray(content.ops)) return content
if (typeof content !== 'string' || !content) return { ops: [] }
return { ops: [{ insert: content }, { insert: '\n' }] }
}
function mapArticle(row) {
const thumbnail = Array.isArray(row.thumbnail)
? row.thumbnail
: row.avatar ? [row.avatar] : []
return {
_id: row._id,
title: row.title || '',
excerpt: row.excerpt || '',
content: toDelta(row.content),
category_id: row.category_id || '',
user_id: row.user_id || '',
thumbnail,
article_status: Number.isInteger(row.article_status) ? row.article_status : 0,
view_count: Number(row.view_count || 0),
like_count: Number(row.like_count || 0),
comment_status: Number(row.comment_status || 0),
comment_count: Number(row.comment_count || 0),
publish_date: row.publish_date || row.create_date || null,
source_id: row._id,
migration_source: 'opendb-news-articles'
}
}
function main() {
const source = readRows(sourceFile)
const seen = new Set()
const rows = []
const conflicts = []
for (const row of source) {
if (!row || !row._id) {
conflicts.push({ reason: 'missing _id', row })
continue
}
if (seen.has(row._id)) {
conflicts.push({ reason: 'duplicate _id', id: row._id })
continue
}
seen.add(row._id)
rows.push(mapArticle(row))
}
const report = {
mode: 'dry-run',
source: path.relative(root, sourceFile).replace(/\\/g, '/'),
target: 'uni-cms-articles',
generatedAt: new Date().toISOString(),
summary: { source: source.length, mapped: rows.length, conflicts: conflicts.length },
conflicts,
rows
}
fs.mkdirSync(reportDir, { recursive: true })
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2) + '\n')
console.log(JSON.stringify(report.summary))
}
main()
+232
View File
@@ -0,0 +1,232 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
const reportDir = path.join(root, 'reports')
const sourceRoots = ['pages', 'components', 'windows', 'js_sdk', 'App.vue', 'main.js']
const excludedDirs = new Set(['.git', 'node_modules', 'unpackage', '.zcode'])
const sourceExtensions = new Set(['.vue', '.js', '.json'])
function walk(target) {
const absolute = path.join(root, target)
if (!fs.existsSync(absolute)) return []
const stat = fs.statSync(absolute)
if (stat.isFile()) return [absolute]
const files = []
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
if (entry.isDirectory() && excludedDirs.has(entry.name)) continue
const child = path.join(absolute, entry.name)
if (entry.isDirectory()) files.push(...walk(path.relative(root, child)))
else if (sourceExtensions.has(path.extname(entry.name))) files.push(child)
}
return files
}
function relative(file) {
return path.relative(root, file).replace(/\\/g, '/')
}
function lineOf(text, index) {
return text.slice(0, index).split(/\r?\n/).length
}
function addFinding(list, severity, file, line, message, evidence = '') {
list.push({ severity, file: relative(file), line, message, evidence })
}
function readJson(file) {
try {
return JSON.parse(stripJsonComments(fs.readFileSync(file, 'utf8')))
} catch (error) {
return { __parseError: error.message }
}
}
// B1-2: schema 允许 uniCloud JSONC 注释头,比较前剥离
function stripJsonComments(text) {
let out = ''
let i = 0
const n = text.length
let inStr = false
while (i < n) {
const c = text[i]
if (inStr) {
out += c
if (c === '\\') { out += text[i + 1] || ''; i += 2; continue }
if (c === '"') inStr = false
i++
continue
}
if (c === '"') { inStr = true; out += c; i++; continue }
if (c === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i)
const stop = end === -1 ? n : end
out += ' '.repeat(stop - i)
i = stop
continue
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
const stop = end === -1 ? n : end + 2
const skipped = text.slice(i, stop)
out += skipped.replace(/[^\n]/g, ' ')
i = stop
continue
}
out += c
i++
}
return out
}
function collectSchemas() {
const schemaFiles = []
for (const base of ['uniCloud-alipay/database', 'uni_modules']) {
schemaFiles.push(...walk(base).filter(file => file.endsWith('.schema.json')))
}
const schemas = new Map()
for (const file of schemaFiles) {
const name = path.basename(file, '.schema.json')
schemas.set(name, { file: relative(file), schema: readJson(file) })
}
return schemas
}
function collectFiles() {
return sourceRoots.flatMap(walk).filter(file => !file.endsWith('.schema.json'))
}
function checkVue3(files, findings) {
for (const file of files.filter(file => file.endsWith('.vue'))) {
const text = fs.readFileSync(file, 'utf8')
for (const pattern of [
{ re: /\s(slot|show-iconn)=/g, message: '发现 Vue3 兼容性标记或拼写错误' },
{ re: /<[^>]+\sslot="[^" está]+"/g, message: '发现旧版具名插槽语法' }
]) {
let match
while ((match = pattern.re.exec(text))) {
addFinding(findings, 'error', file, lineOf(text, match.index), pattern.message, match[0].trim())
}
}
}
}
function checkSensitiveFields(files, findings) {
const sensitive = /\b(password|token|password_secret|secret_key|private_key)\b/i
for (const file of files.filter(file => file.endsWith('.vue'))) {
const text = fs.readFileSync(file, 'utf8')
const isList = /<unicloud-db|download-excel/.test(text) && /list\.vue$/.test(file)
if (!isList) continue
const lines = text.split(/\r?\n/)
lines.forEach((line, index) => {
if (sensitive.test(line)) {
addFinding(findings, 'error', file, index + 1, '列表页面包含敏感字段,禁止直接查询、展示或导出', line.trim())
}
})
}
}
function checkCollectionContracts(files, schemas, findings, warnings) {
// B1-2: 只认 <unicloud-db> 主标签的字面量 collection;辅助集合(uni-data-select 等)
// 只登记不比对,避免噪音 warning。:collection="变量" 绑定由 script 区首个定义解析。
const udbRe = /<unicloud-db\b[^>]*>/g
const udbCollectionRe = /collectionList\s*:\s*["']([^"']+)["']|collection\s*:\s*["']([^"']+)["']/
const scriptRe = /collectionList\s*:\s*["']([^"']+)["']/
for (const file of files.filter(file => file.endsWith('.vue') || file.endsWith('.js'))) {
const text = fs.readFileSync(file, 'utf8')
const mains = new Set()
let um
while ((um = udbRe.exec(text))) {
const cm = udbCollectionRe.exec(um[0])
if (cm) mains.add(cm[1] || cm[2])
}
if (!mains.size) {
const sm = scriptRe.exec(text)
if (sm) mains.add(sm[1])
}
for (const name of mains) {
if (name.startsWith('uni-') || name.startsWith('opendb-') || name.includes('comment') || name === 'cms-temp') {
if (!schemas.has(name)) {
addFinding(warnings, 'warning', file, 1, '页面引用的主集合未在本地 schema 中发现', name)
}
}
}
}
}
function checkKnownPatterns(files, findings) {
const patterns = [
{ re: /if\s*\(this\.where\._id\)/, message: '使用不存在的 where._id 判断加载条件' },
{ re: /const\s+queryRe\s*=\s*new\s+RegExp\(query,\s*['"]i['"]\)/, message: '用户搜索输入未转义正则特殊字符' },
{ re: /field\.hasOwnProperty\(['"]value['"]\)/, message: '疑似使用未定义变量 field' },
{ re: /uploadSuccessTaskNames\.push\(name\)/, message: '疑似使用未定义变量 name' },
{ re: /:emptyText="error\.message\s*\|\|\s*loading\s*\?/, message: 'emptyText 条件优先级可能错误' }
]
for (const file of files) {
const text = fs.readFileSync(file, 'utf8')
for (const { re, message } of patterns) {
const match = re.exec(text)
if (match) addFinding(findings, 'error', file, lineOf(text, match.index), message, match[0])
}
}
}
function checkJsonParse(files, findings) {
for (const file of files.filter(file => file.endsWith('.json'))) {
const parsed = readJson(file)
if (parsed.__parseError) addFinding(findings, 'error', file, 1, 'JSON 解析失败', parsed.__parseError)
}
}
function makeReport(findings, warnings, schemas) {
const report = {
generatedAt: new Date().toISOString(),
project: 'uni-admin 2.5.1',
schemaCount: schemas.size,
errors: findings,
warnings,
summary: {
errors: findings.length,
warnings: warnings.length,
status: findings.length ? 'failed' : 'passed'
}
}
fs.mkdirSync(reportDir, { recursive: true })
fs.writeFileSync(path.join(reportDir, 'quality-report.json'), JSON.stringify(report, null, 2) + '\n')
const lines = [
'# Quality report',
'',
`Generated: ${report.generatedAt}`,
'',
`- Status: **${report.summary.status}**`,
`- Errors: ${report.summary.errors}`,
`- Warnings: ${report.summary.warnings}`,
`- Schemas indexed: ${report.schemaCount}`,
''
]
for (const [title, list] of [['Errors', findings], ['Warnings', warnings]]) {
lines.push(`## ${title}`, '')
if (!list.length) lines.push('None', '')
for (const item of list) lines.push(`- **${item.file}:${item.line}** ${item.message}${item.evidence ? ` — \`${item.evidence}\`` : ''}`)
lines.push('')
}
fs.writeFileSync(path.join(reportDir, 'quality-report.md'), lines.join('\n'))
return report
}
function main() {
const files = collectFiles()
const schemas = collectSchemas()
const findings = []
const warnings = []
checkJsonParse(files, findings)
checkVue3(files, findings)
checkSensitiveFields(files, findings)
checkKnownPatterns(files, findings)
checkCollectionContracts(files, schemas, findings, warnings)
const report = makeReport(findings, warnings, schemas)
console.log(JSON.stringify(report.summary))
if (findings.length) process.exitCode = 1
}
main()
+27
View File
@@ -0,0 +1,27 @@
const assert = require('assert')
const path = require('path')
const buildTemplateData = require(path.join(__dirname, '..', 'uniCloud-alipay', 'cloudfunctions', 'uni-sms-co', 'build-template-data.js'))
const { compare } = require(path.join(__dirname, '..', 'uniCloud-alipay', 'cloudfunctions', 'uni-upgrade-center', 'checkVersion', 'index.js'))
function run() {
const user = { nickname: '', score: 0, enabled: false }
const result = buildTemplateData([
{ field: 'nickname', value: '{uni-id-users.nickname}' },
{ field: 'score', value: '{uni-id-users.score}' },
{ field: 'enabled', value: '{uni-id-users.enabled}' },
{ field: 'fallback', value: '{uni-id-users.missing}' }
], user)
assert.strictEqual(result.nickname, '')
assert.strictEqual(result.score, 0)
assert.strictEqual(result.enabled, false)
assert.strictEqual(result.fallback, '{uni-id-users.missing}')
assert.strictEqual(compare('1.2.0', '1.1.9'), 1)
assert.strictEqual(compare('1.2', '1.2.0'), 0)
assert.strictEqual(compare('2.0', '10.0'), -1)
console.log(JSON.stringify({ status: 'passed', checks: 7 }))
}
run()