import { readFileSync, readdirSync, statSync } from 'node:fs' import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' const scriptDir = dirname(fileURLToPath(import.meta.url)) export const repositoryRoot = resolve(scriptDir, '..') function filesBelow(root, predicate) { if (!statSafe(root)?.isDirectory()) return [] const result = [] for (const entry of readdirSync(root, { withFileTypes: true })) { if (entry.name === 'target' || entry.name === '.git') continue const path = join(root, entry.name) if (entry.isDirectory()) result.push(...filesBelow(path, predicate)) else if (predicate(path)) result.push(path) } return result } function statSafe(path) { try { return statSync(path) } catch { return null } } function packageName(source) { return source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || '' } function mapperInterfaces(javaFiles) { return javaFiles.flatMap((path) => { const source = readFileSync(path, 'utf8') const names = [...source.matchAll(/\b(?:public\s+)?interface\s+(\w*Mapper)\b/g)].map((match) => match[1]) return names.map((name) => ({ path, source, packageName: packageName(source), name })) }) } function mapperScanAnnotation(source) { const start = source.indexOf('@MapperScan') if (start < 0) return '' const end = source.indexOf('\n', start) if (source[start + '@MapperScan'.length] !== '(') return source.slice(start, end < 0 ? undefined : end) let depth = 0 for (let index = start + '@MapperScan'.length; index < source.length; index += 1) { if (source[index] === '(') depth += 1 else if (source[index] === ')' && --depth === 0) return source.slice(start, index + 1) } return source.slice(start) } function moduleNames(root) { const pom = readFileSync(join(root, 'pom.xml'), 'utf8') return [...pom.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) } export function auditMapperScanning(root = repositoryRoot) { const failures = [] let mapperCount = 0 let persistenceModuleCount = 0 for (const moduleName of moduleNames(root)) { const moduleRoot = join(root, moduleName) const javaRoot = join(moduleRoot, 'src', 'main', 'java') const javaFiles = filesBelow(javaRoot, (path) => path.endsWith('.java')) const mappers = mapperInterfaces(javaFiles) if (!mappers.length) continue persistenceModuleCount += 1 mapperCount += mappers.length const mapperNames = new Map() for (const mapper of mappers) { const previous = mapperNames.get(mapper.name) if (previous) { failures.push(`${moduleName}: 根包扫描下 Mapper Bean 名称重复 ${mapper.name}(${relative(root, previous)}、${relative(root, mapper.path)})`) } else mapperNames.set(mapper.name, mapper.path) if (!/@Mapper\b/.test(mapper.source)) { failures.push(`${moduleName}: ${mapper.packageName}.${mapper.name} 缺少 @Mapper(${relative(root, mapper.path)})`) } } const applications = javaFiles.filter((path) => /@SpringBootApplication\b/.test(readFileSync(path, 'utf8'))) if (applications.length !== 1) { failures.push(`${moduleName}: 期望且只能有一个 @SpringBootApplication,实际 ${applications.length} 个`) continue } const applicationPath = applications[0] const applicationSource = readFileSync(applicationPath, 'utf8') const rootPackage = packageName(applicationSource) const scan = mapperScanAnnotation(applicationSource) if (!scan) failures.push(`${moduleName}: 启动类缺少统一 @MapperScan`) else { if (!scan.includes(`"${rootPackage}"`)) failures.push(`${moduleName}: @MapperScan 必须覆盖服务根包 ${rootPackage}`) if (!/annotationClass\s*=\s*Mapper\.class/.test(scan)) failures.push(`${moduleName}: @MapperScan 必须使用 annotationClass = Mapper.class`) } for (const path of javaFiles) { if (path === applicationPath) continue if (/@MapperScan\b/.test(readFileSync(path, 'utf8'))) { failures.push(`${moduleName}: 禁止分散维护 MapperScan(${relative(root, path)})`) } } for (const mapper of mappers) { if (!(mapper.packageName === rootPackage || mapper.packageName.startsWith(`${rootPackage}.`))) { failures.push(`${moduleName}: ${mapper.packageName}.${mapper.name} 不在服务扫描根包 ${rootPackage} 内`) } } } return { failures, mapperCount, persistenceModuleCount } } function main() { const result = auditMapperScanning(repositoryRoot) if (result.failures.length) { console.error(`Mapper 扫描合同失败,共 ${result.failures.length} 项:`) result.failures.forEach((failure) => console.error(`- ${failure}`)) process.exitCode = 1 return } console.log(`Mapper 扫描合同通过:${result.persistenceModuleCount} 个持久化模块、${result.mapperCount} 个 Mapper 均使用 @Mapper,并由服务根包注解过滤扫描。`) } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()