Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
319
src/server/agents/code-analyzer.ts
Normal file
319
src/server/agents/code-analyzer.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { createAnthropic } from '@ai-sdk/anthropic'
|
||||
import { generateText } from 'ai'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
import { analyzeCodeFile } from '@/server/utils/ast-helper'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: process.env.PKUAI_API_KEY,
|
||||
baseURL: process.env.PKUAI_API_BASE + 'api/anthropic/v1',
|
||||
})
|
||||
|
||||
const FILE_MAX_LENGTH = 50 * 1024
|
||||
|
||||
/**
|
||||
* 文件分析结果类型
|
||||
*/
|
||||
export interface FileAnalysisResult {
|
||||
fileTypeId: string
|
||||
summary: string
|
||||
description: string
|
||||
exportedMembers: Array<{ name: string; type: string }>
|
||||
dependencies: Array<{ path: string; usage: string }>
|
||||
pkgDependencies: Array<{ packageName: string; usage: string }>
|
||||
tags: string[]
|
||||
content: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型定义
|
||||
*/
|
||||
export interface FileType {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查文件是否为二进制文件
|
||||
*/
|
||||
export async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath)
|
||||
const buffer = await fs.readFile(fullPath)
|
||||
|
||||
// 检查是否包含NULL字节
|
||||
const checkLength = Math.min(buffer.length, FILE_MAX_LENGTH)
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
if (buffer[i] === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error(`检查文件 ${filePath} 是否为二进制文件失败:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析单个文件
|
||||
*/
|
||||
export async function analyzeFile(
|
||||
filePath: string,
|
||||
fileTypes: FileType[]
|
||||
): Promise<FileAnalysisResult> {
|
||||
const fullPath = path.join(process.cwd(), filePath)
|
||||
const ext = path.extname(filePath)
|
||||
|
||||
// 检查是否为二进制文件
|
||||
const isBinary = await isBinaryFile(filePath)
|
||||
if (isBinary) {
|
||||
return {
|
||||
fileTypeId: 'ASSET',
|
||||
summary: `二进制文件 (${ext})`,
|
||||
description: `这是一个二进制格式的资源文件`,
|
||||
exportedMembers: [],
|
||||
dependencies: [],
|
||||
pkgDependencies: [],
|
||||
tags: ['arch-frontend', 'func-ui'],
|
||||
content: null,
|
||||
}
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
|
||||
// 判断是否存储文件内容(不超过100K且非二进制)
|
||||
const fileSize = Buffer.byteLength(content, 'utf-8')
|
||||
const shouldStoreContent = fileSize <= 100 * 1024 // 100KB
|
||||
const storedContent = shouldStoreContent ? content : null
|
||||
|
||||
// 对于 JS/JSX/TS/TSX 文件,使用 TypeScript Compiler API 分析
|
||||
const isCodeFile = ['.js', '.jsx', '.ts', '.tsx'].includes(ext)
|
||||
let codeAnalysis: {
|
||||
exportedMembers: Array<{ name: string; type: string }>
|
||||
dependencies: string[]
|
||||
pkgDependencies: string[]
|
||||
} | null = null
|
||||
|
||||
if (isCodeFile) {
|
||||
codeAnalysis = await analyzeCodeFile(filePath)
|
||||
}
|
||||
|
||||
// 对于非代码文件,简单处理
|
||||
if (['.json', '.md', '.txt', '.yml', '.yaml', '.svg'].includes(ext)) {
|
||||
return {
|
||||
fileTypeId: ext === '.json' || ext === '.yml' || ext === '.yaml' ? 'CONFIG' : 'ASSET',
|
||||
summary: `${ext}文件`,
|
||||
description: `这是一个${ext}格式的文件`,
|
||||
exportedMembers: [],
|
||||
dependencies: [],
|
||||
pkgDependencies: [],
|
||||
tags: [],
|
||||
content: storedContent,
|
||||
}
|
||||
}
|
||||
|
||||
// 构建文件类型选项说明
|
||||
const fileTypeOptions = fileTypes
|
||||
.map(ft => `- ${ft.id} (${ft.name}: ${ft.description})`)
|
||||
.join('\n')
|
||||
|
||||
// 查询依赖文件的summary信息
|
||||
let dependencySummaries: Record<string, string> = {}
|
||||
if (codeAnalysis?.dependencies && codeAnalysis.dependencies.length > 0) {
|
||||
try {
|
||||
// 查询所有依赖文件最近一次分析的summary
|
||||
const analyzedDeps = await db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
path: {
|
||||
in: codeAnalysis.dependencies
|
||||
}
|
||||
},
|
||||
select: {
|
||||
path: true,
|
||||
summary: true,
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
distinct: ['path'], // 每个path只取最新的一条记录
|
||||
})
|
||||
|
||||
// 构建依赖文件路径到summary的映射
|
||||
dependencySummaries = Object.fromEntries(
|
||||
analyzedDeps.map(dep => [dep.path, dep.summary])
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('查询依赖文件summary失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用LLM分析代码文件(最多重试3次)
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
// 构建依赖示例和依赖文件信息
|
||||
let dependencyUsagesExample = '{}'
|
||||
let dependencyContextInfo = ''
|
||||
|
||||
if (codeAnalysis?.dependencies && codeAnalysis.dependencies.length > 0) {
|
||||
dependencyUsagesExample = `{\n${codeAnalysis.dependencies.map(dep => ` "${dep}": "描述该依赖**在当前文件中**的用途(不超过30字)"`).join(',\n')}\n }`
|
||||
|
||||
// 如果有依赖文件的summary信息,添加到提示词中
|
||||
const depsWithSummary = codeAnalysis.dependencies
|
||||
.filter(dep => dependencySummaries[dep])
|
||||
.map(dep => ` - ${dep}: ${dependencySummaries[dep]}`)
|
||||
|
||||
if (depsWithSummary.length > 0) {
|
||||
dependencyContextInfo = `\n\n依赖文件本身的功能说明(帮助你理解这些依赖的作用):\n${depsWithSummary.join('\n')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 构建包依赖示例
|
||||
const pkgDependencyUsagesExample = codeAnalysis?.pkgDependencies && codeAnalysis.pkgDependencies.length > 0
|
||||
? `{\n${codeAnalysis.pkgDependencies.map(pkg => ` "${pkg}": "描述该包**在当前文件中**的用途和使用的功能(不超过50字)"`).join(',\n')}\n }`
|
||||
: '{}'
|
||||
|
||||
const prompt = `你是一个代码分析专家,正在分析一个NextJS项目中的文件,请分析返回JSON格式的分析结果。
|
||||
|
||||
文件路径: ${filePath}
|
||||
文件内容:
|
||||
\`\`\`
|
||||
${content.substring(0, FILE_MAX_LENGTH)}
|
||||
\`\`\`
|
||||
${dependencyContextInfo}
|
||||
|
||||
请分析并返回以下JSON格式(必须是有效的JSON,不要包含任何其他文本):
|
||||
{
|
||||
"fileType": "文件类型ID,从以下选项中选择一个: ${fileTypes.map(ft => ft.id).join(', ')}",
|
||||
"summary": "一句话总结文件的主要功能(不超过50字)",
|
||||
"description": "详细描述文件的功能和作用(100-200字)",
|
||||
"tags": ["标签1", "标签2", "标签3"],
|
||||
"dependencyUsages": ${dependencyUsagesExample},
|
||||
"pkgDependencyUsages": ${pkgDependencyUsagesExample}
|
||||
}
|
||||
|
||||
文件类型:
|
||||
${fileTypeOptions}
|
||||
|
||||
标签规则(类型-关键字),例如:
|
||||
1. 架构分层 (arch): arch-frontend, arch-backend, arch-database, arch-shared
|
||||
2. 功能/职责 (func): func-auth, func-data, func-state, func-ui, func-form, func-table, func-cache, func-job, func-dashboard, func-beautify
|
||||
3. 技术栈 (tech): tech-trpc, tech-recharts, tech-prisma, tech-zod, tech-bullmq, tech-redis
|
||||
4. 业务领域 (busi): busi-userManage, busi-dataManage
|
||||
|
||||
${codeAnalysis?.dependencies && codeAnalysis.dependencies.length > 0 ? `
|
||||
项目内部依赖用途说明要求:
|
||||
- dependencyUsages 必须是一个对象,键为依赖文件路径,值为**在当前文件中**的用途描述
|
||||
- 必须为每一个依赖文件都提供用途描述
|
||||
- 示例:"导入数据库客户端进行数据查询"、"使用工具函数进行数据格式化"、"导入UI组件用于页面渲染"、"提供xx类型定义供组件使用"
|
||||
` : ''}
|
||||
|
||||
${codeAnalysis?.pkgDependencies && codeAnalysis.pkgDependencies.length > 0 ? `
|
||||
包依赖用途说明要求:
|
||||
- pkgDependencyUsages 必须是一个对象,键为包名,值为**在当前文件中**的用途和功能描述
|
||||
- 必须为每一个包都提供用途描述
|
||||
- 示例:"使用 React hooks 进行状态管理和副作用处理"、"使用 Prisma Client 进行数据库 CRUD 操作"、"使用 zod 进行表单数据验证和类型推断"
|
||||
` : ''}
|
||||
|
||||
注意:
|
||||
- 标签应该包含多个维度,至少1-4个标签
|
||||
- 确保返回的是有效的JSON格式
|
||||
`
|
||||
|
||||
const result = await generateText({
|
||||
model: anthropic(attempt > 1 ? 'claude-sonnet-4-5-20250929' : 'claude-haiku-4-5-20251001'),
|
||||
prompt,
|
||||
temperature: 0.3,
|
||||
})
|
||||
|
||||
// 提取JSON内容
|
||||
let jsonText = result.text.trim()
|
||||
|
||||
// 移除可能的markdown代码块标记
|
||||
jsonText = jsonText.replace(/^```json\s*\n?/i, '').replace(/\n?```\s*$/i, '')
|
||||
|
||||
const analysis = JSON.parse(jsonText)
|
||||
|
||||
// 验证依赖用途描述
|
||||
const dependencyUsages = analysis.dependencyUsages || {}
|
||||
const dependencies: Array<{ path: string; usage: string }> = []
|
||||
|
||||
if (codeAnalysis?.dependencies && codeAnalysis.dependencies.length > 0) {
|
||||
// 检查是否所有依赖都有用途描述
|
||||
const missingUsages = codeAnalysis.dependencies.filter(dep => !dependencyUsages[dep])
|
||||
|
||||
if (missingUsages.length > 0) {
|
||||
throw new Error(`缺少以下依赖的用途描述: ${missingUsages.join(', ')}\n===== 提示词 =====\n${prompt}\n\n===== 输出 =====\n${JSON.stringify(analysis, null, 2)}\n`)
|
||||
}
|
||||
|
||||
// 构建依赖数组
|
||||
codeAnalysis.dependencies.forEach(dep => {
|
||||
dependencies.push({
|
||||
path: dep,
|
||||
usage: dependencyUsages[dep] || '',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 验证包依赖用途描述
|
||||
const pkgDependencyUsages = analysis.pkgDependencyUsages || {}
|
||||
const pkgDependencies: Array<{ packageName: string; usage: string }> = []
|
||||
|
||||
if (codeAnalysis?.pkgDependencies && codeAnalysis.pkgDependencies.length > 0) {
|
||||
// 检查是否所有包都有用途描述
|
||||
const missingPkgUsages = codeAnalysis.pkgDependencies.filter(pkg => !pkgDependencyUsages[pkg])
|
||||
|
||||
if (missingPkgUsages.length > 0) {
|
||||
throw new Error(`缺少以下包的用途描述: ${missingPkgUsages.join(', ')}\n===== 提示词 =====\n${prompt}\n\n===== 输出 =====\n${JSON.stringify(analysis, null, 2)}\n`)
|
||||
}
|
||||
|
||||
// 构建包依赖数组
|
||||
codeAnalysis.pkgDependencies.forEach(pkg => {
|
||||
pkgDependencies.push({
|
||||
packageName: pkg,
|
||||
usage: pkgDependencyUsages[pkg] || '',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
fileTypeId: analysis.fileType || 'OTHER',
|
||||
summary: analysis.summary || '',
|
||||
description: analysis.description || '',
|
||||
exportedMembers: codeAnalysis?.exportedMembers || [],
|
||||
dependencies,
|
||||
pkgDependencies,
|
||||
tags: analysis.tags || [],
|
||||
content: storedContent,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
console.error(`分析文件 ${filePath} 失败 (尝试 ${attempt}/3):`, error)
|
||||
|
||||
// 如果不是最后一次尝试,继续重试
|
||||
if (attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt)) // 递增延迟
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败,返回默认值
|
||||
console.error(`分析文件 ${filePath} 最终失败:`, lastError)
|
||||
return {
|
||||
fileTypeId: 'OTHER',
|
||||
summary: '分析失败',
|
||||
description: '无法分析此文件',
|
||||
exportedMembers: [],
|
||||
dependencies: codeAnalysis?.dependencies?.map(dep => ({ path: dep, usage: '' })) || [],
|
||||
pkgDependencies: codeAnalysis?.pkgDependencies?.map(pkg => ({ packageName: pkg, usage: '' })) || [],
|
||||
tags: [],
|
||||
content: storedContent,
|
||||
}
|
||||
}
|
||||
110
src/server/agents/folder-analyzer.ts
Normal file
110
src/server/agents/folder-analyzer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { generateText } from 'ai'
|
||||
import { createAnthropic } from '@ai-sdk/anthropic'
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: process.env.PKUAI_API_KEY,
|
||||
baseURL: process.env.PKUAI_API_BASE + 'api/anthropic/v1',
|
||||
})
|
||||
|
||||
/**
|
||||
* 文件夹分析结果类型
|
||||
*/
|
||||
export interface FolderAnalysisResult {
|
||||
summary: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用AI分析文件夹
|
||||
* @param folderPath 文件夹路径
|
||||
* @param folderName 文件夹名称
|
||||
* @param subFolderSummaries 子文件夹的摘要信息
|
||||
* @param fileSummaries 文件的摘要信息(最多20个)
|
||||
* @returns 文件夹分析结果
|
||||
*/
|
||||
export async function analyzeFolder(
|
||||
folderPath: string,
|
||||
folderName: string,
|
||||
subFolderSummaries: Array<{ path: string; summary: string; description: string }>,
|
||||
fileSummaries: Array<{ path: string; summary: string; description: string }>
|
||||
): Promise<FolderAnalysisResult> {
|
||||
// 构建子文件夹信息
|
||||
const subFoldersText = subFolderSummaries.length > 0
|
||||
? subFolderSummaries
|
||||
.map(sf => ` - ${sf.path}\n 摘要: ${sf.summary}\n 描述: ${sf.description}`)
|
||||
.join('\n\n')
|
||||
: ' (无子文件夹)'
|
||||
|
||||
// 构建文件信息
|
||||
const filesText = fileSummaries.length > 0
|
||||
? fileSummaries
|
||||
.map(f => ` - ${f.path}\n 摘要: ${f.summary}\n 描述: ${f.description}`)
|
||||
.join('\n\n')
|
||||
: ' (无文件)'
|
||||
|
||||
// 使用LLM分析(最多重试3次)
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const prompt = `你是一个代码架构分析专家,正在分析一个NextJS项目中的文件夹结构。
|
||||
|
||||
文件夹路径: ${folderPath}
|
||||
文件夹名称: ${folderName}
|
||||
|
||||
子文件夹信息(共 ${subFolderSummaries.length} 个):
|
||||
${subFoldersText}
|
||||
|
||||
文件信息(共 ${fileSummaries.length} 个):
|
||||
${filesText}
|
||||
|
||||
请分析并返回以下JSON格式(必须是有效的JSON,不要包含任何其他文本):
|
||||
{
|
||||
"summary": "一句话总结该文件夹的主要功能和职责(不超过50字)",
|
||||
"description": "详细描述该文件夹的功能和在项目中的作用(150-300字)"
|
||||
}
|
||||
|
||||
分析要求:
|
||||
1. summary 要简洁明了,突出该文件夹的核心职责
|
||||
2. description 要综合考虑子文件夹和文件的功能,说明该文件夹在项目架构中的位置和作用
|
||||
3. 如果有子文件夹,要说明它们之间的关系和组织方式
|
||||
4. 如果有文件,要概括它们的共同特点和功能分类
|
||||
5. 确保返回的是有效的JSON格式`
|
||||
|
||||
const result = await generateText({
|
||||
model: anthropic(attempt > 1 ? 'claude-sonnet-4-5-20250929' : 'claude-haiku-4-5-20251001'),
|
||||
prompt,
|
||||
temperature: 0.3,
|
||||
})
|
||||
|
||||
// 提取JSON内容
|
||||
let jsonText = result.text.trim()
|
||||
jsonText = jsonText.replace(/^```json\s*\n?/i, '').replace(/\n?```\s*$/i, '')
|
||||
|
||||
const analysis = JSON.parse(jsonText)
|
||||
|
||||
if (!analysis.summary || !analysis.description) {
|
||||
throw new Error(`分析结果缺少必需字段\n===== 提示词 =====\n${prompt}\n\n===== 输出 =====\n${JSON.stringify(analysis, null, 2)}\n`)
|
||||
}
|
||||
|
||||
return {
|
||||
summary: analysis.summary,
|
||||
description: analysis.description,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
console.error(`分析文件夹 ${folderPath} 失败 (尝试 ${attempt}/3):`, error)
|
||||
|
||||
if (attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
console.error(`分析文件夹 ${folderPath} 最终失败:`, lastError)
|
||||
return {
|
||||
summary: '分析失败',
|
||||
description: '无法分析此文件夹',
|
||||
}
|
||||
}
|
||||
135
src/server/agents/package-analyzer.ts
Normal file
135
src/server/agents/package-analyzer.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { generateText } from 'ai'
|
||||
import { createAnthropic } from '@ai-sdk/anthropic'
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: process.env.PKUAI_API_KEY,
|
||||
baseURL: process.env.PKUAI_API_BASE + 'api/anthropic/v1',
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 包类型定义
|
||||
*/
|
||||
export interface PkgType {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 包分析结果类型
|
||||
*/
|
||||
export interface PackageAnalysisResult {
|
||||
pkgTypeId: string
|
||||
projectRoleSummary: string
|
||||
primaryUsagePattern: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析单个依赖包
|
||||
* @param packageName 包名
|
||||
* @param packageDescription 包的官方描述
|
||||
* @param usageExamples 项目中使用该包的示例
|
||||
* @param level1Info 一级引用文件及其被二级文件引用的关系说明
|
||||
* @param pkgTypes 所有包类型列表
|
||||
* @returns 包分析结果
|
||||
*/
|
||||
export async function analyzePackage(
|
||||
packageName: string,
|
||||
packageDescription: string,
|
||||
usageExamples: Array<{ filePath: string; usage: string }>,
|
||||
level1Info: string,
|
||||
pkgTypes: PkgType[]
|
||||
): Promise<PackageAnalysisResult> {
|
||||
// 构建包类型选项说明
|
||||
const pkgTypeOptions = pkgTypes
|
||||
.map(pt => `- ${pt.id} (${pt.name}: ${pt.description})`)
|
||||
.join('\n')
|
||||
|
||||
// 构建使用示例说明
|
||||
const usageExamplesText = usageExamples.length > 0
|
||||
? usageExamples
|
||||
.map(ex => ` - ${ex.filePath}: ${ex.usage}`)
|
||||
.join('\n')
|
||||
: ' (暂无使用示例)'
|
||||
|
||||
// 使用LLM分析包(最多重试3次)
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const prompt = `你是一个依赖包分析专家,正在分析一个 NextJS 项目中使用的依赖包。
|
||||
|
||||
包名: ${packageName}
|
||||
官方描述: ${packageDescription}
|
||||
|
||||
项目中的直接使用情况(一级引用):
|
||||
${usageExamplesText}
|
||||
|
||||
一级引用文件及其被二级文件引用的关系:
|
||||
${level1Info}
|
||||
|
||||
请分析并返回以下JSON格式(必须是有效的JSON,不要包含任何其他文本):
|
||||
{
|
||||
"pkgType": "包类型ID,从以下选项中选择一个: ${pkgTypes.map(pt => pt.id).join(', ')}",
|
||||
"projectRoleSummary": "该包在本项目中的核心功能(50-100字)",
|
||||
"primaryUsagePattern": "主要使用模式,分成2-5点描述,每点用换行符分隔,200字以内"
|
||||
}
|
||||
|
||||
包类型选项:
|
||||
${pkgTypeOptions}
|
||||
|
||||
注意:
|
||||
- pkgType 必须从给定的选项中选择最合适的一个
|
||||
- projectRoleSummary 要结合项目实际使用情况(包括一级和二级引用关系),说明该包在项目中的核心功能和影响范围
|
||||
- primaryUsagePattern 要具体描述在项目中如何使用该包,每点描述一个主要的使用场景或模式,可以参考一级引用文件的用途和二级引用的传播路径
|
||||
- 确保返回的是有效的JSON格式`
|
||||
|
||||
const result = await generateText({
|
||||
model: anthropic(attempt > 1 ? 'claude-sonnet-4-5-20250929' : 'claude-haiku-4-5-20251001'),
|
||||
prompt,
|
||||
temperature: 0.3,
|
||||
})
|
||||
|
||||
// 提取JSON内容
|
||||
let jsonText = result.text.trim()
|
||||
|
||||
// 移除可能的markdown代码块标记
|
||||
jsonText = jsonText.replace(/^```json\s*\n?/i, '').replace(/\n?```\s*$/i, '')
|
||||
|
||||
const analysis = JSON.parse(jsonText)
|
||||
|
||||
// 验证必需字段
|
||||
if (!analysis.pkgType || !analysis.projectRoleSummary || !analysis.primaryUsagePattern) {
|
||||
throw new Error(`分析结果缺少必需字段\n===== 提示词 =====\n${prompt}\n\n===== 输出 =====\n${JSON.stringify(analysis, null, 2)}\n`)
|
||||
}
|
||||
|
||||
// 验证 pkgType 是否有效
|
||||
if (!pkgTypes.some(pt => pt.id === analysis.pkgType)) {
|
||||
throw new Error(`无效的包类型: ${analysis.pkgType}\n===== 提示词 =====\n${prompt}\n\n===== 输出 =====\n${JSON.stringify(analysis, null, 2)}\n`)
|
||||
}
|
||||
|
||||
return {
|
||||
pkgTypeId: analysis.pkgType,
|
||||
projectRoleSummary: analysis.projectRoleSummary,
|
||||
primaryUsagePattern: analysis.primaryUsagePattern,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
console.error(`分析包 ${packageName} 失败 (尝试 ${attempt}/3):`, error)
|
||||
|
||||
// 如果不是最后一次尝试,继续重试
|
||||
if (attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt)) // 递增延迟
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败,返回默认值
|
||||
console.error(`分析包 ${packageName} 最终失败:`, lastError)
|
||||
return {
|
||||
pkgTypeId: 'OTHER',
|
||||
projectRoleSummary: '分析失败',
|
||||
primaryUsagePattern: '无法分析此包的使用模式',
|
||||
}
|
||||
}
|
||||
118
src/server/agents/ui-demo-generator.ts
Normal file
118
src/server/agents/ui-demo-generator.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { generateText } from 'ai'
|
||||
import { createAnthropic } from '@ai-sdk/anthropic'
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: process.env.PKUAI_API_KEY,
|
||||
baseURL: process.env.PKUAI_API_BASE + 'api/anthropic/v1',
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件信息类型
|
||||
*/
|
||||
export interface ComponentInfo {
|
||||
path: string
|
||||
fileName: string
|
||||
content: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片信息类型
|
||||
*/
|
||||
export interface ImageInfo {
|
||||
url: string // base64编码的图片数据
|
||||
mediaType: string // 图片MIME类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成UI组件演示代码
|
||||
*/
|
||||
export async function generateUIComponentDemo(
|
||||
components: ComponentInfo[],
|
||||
userPrompt: string,
|
||||
images?: ImageInfo[]
|
||||
): Promise<string> {
|
||||
// 构建系统提示词
|
||||
const systemPrompt = `你是一个React组件演示代码生成专家。用户会提供一些UI组件的源代码和需求描述,你需要生成一个适合react-live运行的演示代码,用户的UI组件库基于shadcn。
|
||||
|
||||
重要要求:
|
||||
1. **不要写任何import语句**,所有组件都已经在scope中提供,直接使用组件名称,例如:<Button>点击</Button>
|
||||
2. 只返回代码,不要有任何解释文字
|
||||
3. 可以直接使用React hooks(useState, useEffect等)、useForm、useDataTable(与data-table组件结合),以及shadcn的cn函数
|
||||
4. 可以定义内部函数和变量
|
||||
5. 在最后面调用render函数完成组件的渲染
|
||||
|
||||
代码格式示例:
|
||||
\`\`\`
|
||||
type Props = {
|
||||
label: string;
|
||||
}
|
||||
const Counter = (props: Props) => {
|
||||
const [count, setCount] =
|
||||
React.useState<number>(0)
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{
|
||||
background: 'darkslateblue',
|
||||
color: 'white',
|
||||
padding: 8,
|
||||
borderRadius: 4
|
||||
}}>
|
||||
{props.label}: {count}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCount(c => c + 1)
|
||||
}>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Counter label="Counter" />)
|
||||
\`\`\`
|
||||
|
||||
可用的UI组件:
|
||||
${components.length ? components.map(c => `- ${c.fileName} (${c.path}): ${c.summary}`).join('\n') : '无'}
|
||||
|
||||
组件源代码参考:
|
||||
${components.length ? components.map(c => `
|
||||
// ${c.path}
|
||||
${c.content}
|
||||
`).join('\n---\n') : '无'}
|
||||
|
||||
请根据用户需求生成演示代码。记住:不要写import语句,直接使用组件名称!`
|
||||
|
||||
// 构建消息内容
|
||||
const messageContent: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mimeType?: string }> = [
|
||||
{ type: 'text' as const, text: userPrompt }
|
||||
];
|
||||
|
||||
// 如果有图片,添加到消息中
|
||||
if (images && images.length > 0) {
|
||||
for (const image of images) {
|
||||
messageContent.push({
|
||||
type: 'image' as const,
|
||||
image: image.url,
|
||||
mimeType: image.mediaType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 调用AI生成代码
|
||||
const result = await generateText({
|
||||
model: anthropic('claude-sonnet-4-5-20250929'),
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: messageContent,
|
||||
}
|
||||
],
|
||||
temperature: 1.0,
|
||||
topP: 0.8
|
||||
})
|
||||
// 移除可能的代码块标记
|
||||
const code = result.text.replace(/^```[a-z]*\s*\n?/i, '').replace(/\n?```\s*$/i, '')
|
||||
return code
|
||||
}
|
||||
123
src/server/auth.ts
Normal file
123
src/server/auth.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'server-only'
|
||||
import { NextAuthOptions } from "next-auth"
|
||||
import type { User as NextAuthUser } from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { db } from "./db"
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
id: { label: "用户ID", type: "text" },
|
||||
password: { label: "密码", type: "password" }
|
||||
},
|
||||
async authorize(credentials, req): Promise<NextAuthUser | null> {
|
||||
if (!credentials?.id || !credentials?.password) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// 查找用户
|
||||
const user = await db.user.findUnique({
|
||||
where: {
|
||||
id: credentials.id
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: { permissions: true }
|
||||
},
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 更新最近登录时间
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() }
|
||||
})
|
||||
|
||||
// 返回用户信息、角色和权限
|
||||
const roles = user.roles.map((r) => r.name)
|
||||
const permissions = Array.from(
|
||||
new Set(user.roles.flatMap((r) =>
|
||||
r.permissions.map((p) => p.name)
|
||||
))
|
||||
)
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
status: user.status,
|
||||
deptCode: user.deptCode,
|
||||
roles,
|
||||
permissions,
|
||||
isSuperAdmin: user.isSuperAdmin
|
||||
} as any
|
||||
} catch (error) {
|
||||
console.error("Auth error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
// 初次登录时,将用户信息保存到JWT token中
|
||||
if (user) {
|
||||
const u = user as any
|
||||
token = {
|
||||
...token,
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
deptCode: u.deptCode,
|
||||
roles: u.roles,
|
||||
permissions: u.permissions,
|
||||
isSuperAdmin: u.isSuperAdmin,
|
||||
}
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// 将JWT token中的信息传递给session
|
||||
if (session.user) {
|
||||
const t = token as any
|
||||
session.user = {
|
||||
...session.user,
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
status: t.status,
|
||||
deptCode: t.deptCode,
|
||||
roles: t.roles,
|
||||
permissions: t.permissions,
|
||||
isSuperAdmin: t.isSuperAdmin,
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
}
|
||||
35
src/server/cron.ts
Normal file
35
src/server/cron.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 定时任务配置
|
||||
*
|
||||
* 使用 BullMQ 的 Repeatable Jobs 功能实现定时任务
|
||||
*/
|
||||
import 'server-only'
|
||||
|
||||
/**
|
||||
* 初始化所有定时任务
|
||||
*/
|
||||
export async function initCronJobs() {
|
||||
console.log('[Cron] 初始化定时任务...')
|
||||
|
||||
try {
|
||||
|
||||
console.log('[Cron] 定时任务初始化完成')
|
||||
} catch (error) {
|
||||
console.error('[Cron] 定时任务初始化失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有定时任务
|
||||
*/
|
||||
export async function cleanupCronJobs() {
|
||||
console.log('[Cron] 清理定时任务...')
|
||||
|
||||
try {
|
||||
|
||||
console.log('[Cron] 定时任务清理完成')
|
||||
} catch (error) {
|
||||
console.error('[Cron] 定时任务清理失败:', error)
|
||||
}
|
||||
}
|
||||
15
src/server/db.ts
Normal file
15
src/server/db.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'server-only'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
// 避免热重载的时候重新创建PrismaClient
|
||||
export const db = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: [
|
||||
// 'query', // 是否打印执行的SQL
|
||||
]
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
|
||||
351
src/server/minio.ts
Normal file
351
src/server/minio.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import 'server-only'
|
||||
import { Client } from 'minio';
|
||||
|
||||
// 初始化 MinIO 客户端
|
||||
export const minioClient = new Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_API_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ROOT_USER || '',
|
||||
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
|
||||
});
|
||||
|
||||
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
|
||||
|
||||
// 桶初始化标志
|
||||
let bucketInitialized = false;
|
||||
|
||||
/**
|
||||
* 检查并初始化存储桶
|
||||
* 如果桶不存在则创建,并设置为公开读取策略(可根据需求调整)
|
||||
*/
|
||||
export async function ensureBucketExists(): Promise<void> {
|
||||
if (bucketInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(BUCKET_NAME);
|
||||
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(BUCKET_NAME, 'cn-beijing-1');
|
||||
console.log(`MinIO bucket '${BUCKET_NAME}' created successfully`);
|
||||
|
||||
// 设置桶策略:默认私有,但 /public 路径下的文件公开可读
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET_NAME}/public/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
|
||||
console.log(`MinIO bucket '${BUCKET_NAME}' policy set: private by default, public read for /public path`);
|
||||
}
|
||||
|
||||
bucketInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure bucket exists:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 POST 策略配置
|
||||
*/
|
||||
export interface PresignedPostPolicyOptions {
|
||||
/** 对象名称前缀(不含文件名) public开头是可以公开GetObject */
|
||||
prefix: string;
|
||||
/** 文件名(可选,如果指定则只允许上传该文件名) */
|
||||
fileName?: string;
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 最大文件大小(字节),默认 100MB */
|
||||
maxSize?: number;
|
||||
/** 允许的文件类型(MIME类型数组),默认允许所有类型 */
|
||||
allowedContentType?: string;
|
||||
/** 是否允许上传原始文件名元信息,默认 true */
|
||||
allowOriginalFilename?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 POST 策略结果
|
||||
*/
|
||||
export interface PresignedPostPolicyResult {
|
||||
/** POST 请求的 URL */
|
||||
postURL: string;
|
||||
/** POST 请求需要的表单字段 */
|
||||
formData: Record<string, string>;
|
||||
/** 对象名称(完整路径) */
|
||||
objectName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成预签名的 POST 策略(用于客户端直传)
|
||||
* 支持单文件上传(指定fileName)或批量上传(不指定fileName,只校验前缀)
|
||||
* 如果客户端无法上传,通过 mc admin trace <ALIAS> --verbose --all 排查minio存储桶那边的详细日志
|
||||
*/
|
||||
export async function generatePresignedPostPolicy(
|
||||
options: PresignedPostPolicyOptions
|
||||
): Promise<PresignedPostPolicyResult> {
|
||||
await ensureBucketExists();
|
||||
|
||||
const {
|
||||
prefix,
|
||||
fileName,
|
||||
expirySeconds = 3600,
|
||||
maxSize = 100 * 1024 * 1024, // 默认 100MB
|
||||
allowedContentType,
|
||||
allowOriginalFilename = true
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const policy = minioClient.newPostPolicy();
|
||||
|
||||
// 设置过期时间
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setSeconds(expiryDate.getSeconds() + expirySeconds);
|
||||
policy.setExpires(expiryDate);
|
||||
|
||||
// 设置存储桶
|
||||
policy.setBucket(BUCKET_NAME);
|
||||
|
||||
// 构建完整的对象名称
|
||||
const objectName = fileName ? `${prefix}/${fileName}` : prefix;
|
||||
|
||||
// 如果指定了文件名,则精确匹配;否则只匹配前缀
|
||||
if (fileName) {
|
||||
policy.setKey(objectName);
|
||||
} else {
|
||||
policy.setKeyStartsWith(prefix);
|
||||
}
|
||||
|
||||
// 设置文件大小限制
|
||||
policy.setContentLengthRange(1, maxSize);
|
||||
|
||||
// 设置允许的内容类型(支持精确匹配和通配符)
|
||||
if (allowedContentType) {
|
||||
// 判断是否为通配符模式(如 'image/*')
|
||||
if (allowedContentType.endsWith('/*')) {
|
||||
// 将通配符模式转换为前缀(如 'image/*' -> 'image/')
|
||||
const prefix = allowedContentType.slice(0, -1);
|
||||
policy.setContentTypeStartsWith(prefix);
|
||||
} else {
|
||||
// 精确匹配
|
||||
policy.setContentType(allowedContentType);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowOriginalFilename) {
|
||||
// 允许客户端设置 x-amz-meta-original-filename 元信息
|
||||
policy.policy.conditions.push(['starts-with', '$x-amz-meta-original-filename', ''])
|
||||
}
|
||||
|
||||
// 生成预签名 POST 数据
|
||||
const presignedData = await minioClient.presignedPostPolicy(policy);
|
||||
|
||||
return {
|
||||
postURL: presignedData.postURL,
|
||||
formData: presignedData.formData,
|
||||
objectName,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned POST policy:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 GET 对象配置
|
||||
*/
|
||||
export interface PresignedGetObjectOptions {
|
||||
/** 对象名称(文件路径) */
|
||||
objectName: string;
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 自定义响应头 */
|
||||
responseHeaders?: {
|
||||
/** 下载时的文件名,默认使用对象的 x-amz-meta-original-filename 元信息 */
|
||||
'response-content-disposition'?: string;
|
||||
/** 内容类型 */
|
||||
'response-content-type'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名 GET 对象结果
|
||||
*/
|
||||
export interface PresignedGetObjectResult {
|
||||
/** 预签名的 GET URL */
|
||||
url: string;
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成预签名的 GET URL(用于下载对象)
|
||||
* 文件名默认使用对象的 x-amz-meta-original-filename 元信息
|
||||
*/
|
||||
export async function generatePresignedGetObject(
|
||||
options: PresignedGetObjectOptions
|
||||
): Promise<PresignedGetObjectResult> {
|
||||
await ensureBucketExists();
|
||||
|
||||
const {
|
||||
objectName,
|
||||
expirySeconds = 3600,
|
||||
responseHeaders = {},
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// 如果没有指定响应头,尝试从对象元数据中获取
|
||||
if (!responseHeaders['response-content-disposition'] || !responseHeaders['response-content-type']) {
|
||||
try {
|
||||
const metadata = await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
|
||||
// 设置 Content-Disposition(文件名)
|
||||
if (!responseHeaders['response-content-disposition']) {
|
||||
const originalFilename = metadata.metaData?.['original-filename'];
|
||||
if (originalFilename) {
|
||||
// 使用 attachment 强制下载,并设置文件名
|
||||
responseHeaders['response-content-disposition'] =
|
||||
`attachment; filename*=UTF-8''${originalFilename}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 Content-Type
|
||||
if (!responseHeaders['response-content-type'] && metadata.metaData?.['content-type']) {
|
||||
responseHeaders['response-content-type'] = metadata.metaData['content-type'];
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果获取元数据失败,继续使用默认行为
|
||||
console.warn('Failed to get object metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成预签名 URL
|
||||
const url = await minioClient.presignedGetObject(
|
||||
BUCKET_NAME,
|
||||
objectName,
|
||||
expirySeconds,
|
||||
responseHeaders
|
||||
);
|
||||
|
||||
return {
|
||||
url,
|
||||
expiresIn: expirySeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned GET URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对象
|
||||
* @param objectName 对象名称(文件路径)
|
||||
*/
|
||||
export async function deleteObject(objectName: string): Promise<void> {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET_NAME, objectName);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete object:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除对象
|
||||
* @param objectNames 对象名称数组
|
||||
*/
|
||||
export async function deleteBatchObjects(objectNames: string[]): Promise<void> {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
await minioClient.removeObjects(BUCKET_NAME, objectNames);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete batch objects:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否存在
|
||||
* @param objectName 对象名称(文件路径)
|
||||
* @returns 是否存在
|
||||
*/
|
||||
export async function objectExists(objectName: string): Promise<boolean> {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查对象是否存在
|
||||
* @param objectNames 对象名称数组
|
||||
* @returns 对象存在状态的映射表 { objectName: boolean }
|
||||
*/
|
||||
export async function batchObjectExists(
|
||||
objectNames: string[]
|
||||
): Promise<Record<string, boolean>> {
|
||||
await ensureBucketExists();
|
||||
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
// 并发检查所有对象
|
||||
await Promise.all(
|
||||
objectNames.map(async (objectName) => {
|
||||
try {
|
||||
await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
results[objectName] = true;
|
||||
} catch (error) {
|
||||
results[objectName] = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取对象元数据
|
||||
* @param objectName 对象名称(文件路径)
|
||||
* @returns 对象元数据
|
||||
*/
|
||||
export async function getObjectMetadata(objectName: string) {
|
||||
await ensureBucketExists();
|
||||
|
||||
try {
|
||||
const stat = await minioClient.statObject(BUCKET_NAME, objectName);
|
||||
return stat;
|
||||
} catch (error) {
|
||||
console.error('Failed to get object metadata:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成公开访问的 URL(需要桶策略支持公开读取)
|
||||
* @param objectName 对象名称(文件路径)
|
||||
* @returns 公开访问 URL
|
||||
*/
|
||||
export function getPublicUrl(objectName: string): string {
|
||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
||||
const port = process.env.MINIO_API_PORT || '9000';
|
||||
|
||||
return `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${objectName}`;
|
||||
}
|
||||
51
src/server/queues/analyze-files.queue.ts
Normal file
51
src/server/queues/analyze-files.queue.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Queue, QueueEvents } from 'bullmq'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
|
||||
/**
|
||||
* 文件分析任务的输入数据
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface AnalyzeFilesJobData {
|
||||
// 空对象,不需要输入参数
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分析任务的进度数据
|
||||
*/
|
||||
export interface AnalyzeFilesProgress {
|
||||
totalFiles?: number
|
||||
analyzedFiles?: number
|
||||
currentFile?: string
|
||||
canceled?: boolean
|
||||
/** 失败的文件数量 */
|
||||
failedFiles?: number
|
||||
/** 跳过的文件数量(文件未修改且从上次分析到现在都没有修改) */
|
||||
skippedFiles?: number
|
||||
/** 最近的错误信息列表(最多保留最近10条) */
|
||||
recentErrors?: Array<{
|
||||
filePath: string
|
||||
error: string
|
||||
timestamp: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分析队列
|
||||
*/
|
||||
export const analyzeFilesQueue = new Queue<AnalyzeFilesJobData, void, string>('analyze-files', {
|
||||
connection: getRedisClient(),
|
||||
defaultJobOptions: {
|
||||
attempts: 1, // 不重试,失败就失败
|
||||
removeOnComplete: {
|
||||
age: 3600, // 完成后保留1小时
|
||||
count: 100, // 最多保留100个已完成的任务
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 7200, // 失败后保留2小时
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const analyzeFilesQueueEvents = new QueueEvents('analyze-files', {
|
||||
connection: getRedisClient(),
|
||||
})
|
||||
336
src/server/queues/analyze-files.worker.ts
Normal file
336
src/server/queues/analyze-files.worker.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { Worker, Job } from 'bullmq'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
import { db } from '@/server/db'
|
||||
import { AnalyzeFilesJobData, AnalyzeFilesProgress, analyzeFilesQueue } from './analyze-files.queue'
|
||||
import { analyzeFile, type FileType } from '@/server/agents/code-analyzer'
|
||||
import { getProjectFiles, getFileCommitInfo, hasFileChangedBetweenCommits } from '@/server/utils/git-helper'
|
||||
import { analyzeCodeFile } from '@/server/utils/ast-helper'
|
||||
import { topologicalSort, defaultCycleBreaker, type AdjacencyList } from '@/lib/algorithm'
|
||||
import * as path from 'path'
|
||||
import { promises as fs } from 'fs'
|
||||
|
||||
/**
|
||||
* 构建文件依赖图并进行拓扑排序
|
||||
* @param files 所有文件路径列表
|
||||
* @returns 排序后的文件列表(删除的文件在最前面,其余按依赖关系排序)
|
||||
*/
|
||||
async function sortFilesByDependency(files: string[]): Promise<string[]> {
|
||||
// 第一步:扫描所有文件,分离删除的文件和存在的文件
|
||||
const deletedFiles: string[] = []
|
||||
const existingFiles: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(process.cwd(), file)
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
existingFiles.push(file)
|
||||
} catch {
|
||||
deletedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:为存在的文件构建依赖图(邻接表)
|
||||
// 邻接表的含义:key -> [依赖key的文件列表]
|
||||
// 即:被依赖的文件 -> [依赖它的文件]
|
||||
const adjacencyList: AdjacencyList<string> = new Map()
|
||||
for (const file of existingFiles) {
|
||||
adjacencyList.set(file, [])
|
||||
}
|
||||
|
||||
for (const file of existingFiles) {
|
||||
try {
|
||||
const analysis = await analyzeCodeFile(file)
|
||||
for (const dependency of analysis.dependencies) {
|
||||
if (adjacencyList.has(dependency)) {
|
||||
const dependents = adjacencyList.get(dependency)!
|
||||
if (!dependents.includes(file)) {
|
||||
dependents.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`分析文件 ${file} 的依赖关系失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:使用拓扑排序对存在的文件进行排序
|
||||
const sortResult = topologicalSort(adjacencyList, defaultCycleBreaker)
|
||||
|
||||
const sortedFiles = [...deletedFiles, ...sortResult.sorted]
|
||||
if (sortedFiles.length !== files.length) {
|
||||
throw new Error(
|
||||
`文件排序后数量不匹配!原始: ${files.length}, 排序后: ${sortedFiles.length}`
|
||||
)
|
||||
}
|
||||
|
||||
return sortedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分析 Worker
|
||||
*/
|
||||
export const analyzeFilesWorker = new Worker<AnalyzeFilesJobData, void, string>(
|
||||
'analyze-files',
|
||||
async (job: Job<AnalyzeFilesJobData, void, string>) => {
|
||||
console.log(`[AnalyzeFiles] Job ${job.id} start`)
|
||||
|
||||
// 错误记录数组(最多保留10条)
|
||||
const recentErrors: Array<{ filePath: string; error: string; timestamp: number }> = []
|
||||
let failedFiles = 0
|
||||
let skippedFiles = 0
|
||||
|
||||
// 辅助函数:添加错误记录
|
||||
const addError = (filePath: string, error: string) => {
|
||||
failedFiles++
|
||||
recentErrors.push({
|
||||
filePath,
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// 只保留最近10条错误
|
||||
if (recentErrors.length > 10) {
|
||||
recentErrors.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:检查任务是否被取消
|
||||
const checkCanceled = async () => {
|
||||
const updatedJob = await analyzeFilesQueue.getJob(job.id as string)
|
||||
if ((updatedJob?.progress as AnalyzeFilesProgress)?.canceled) {
|
||||
await job.log('任务已被取消')
|
||||
throw new Error('任务已被用户取消')
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:更新进度
|
||||
const updateProgress = async (progress: Partial<AnalyzeFilesProgress>, logMessage?: string) => {
|
||||
await checkCanceled()
|
||||
await job.updateProgress({
|
||||
...progress,
|
||||
canceled: false,
|
||||
failedFiles,
|
||||
skippedFiles,
|
||||
recentErrors: recentErrors.length > 0 ? [...recentErrors] : undefined,
|
||||
} as AnalyzeFilesProgress)
|
||||
if (logMessage) await job.log(logMessage)
|
||||
}
|
||||
|
||||
// 从数据库获取文件类型列表
|
||||
await updateProgress({ totalFiles: 0, analyzedFiles: 0 }, '正在获取文件类型列表...')
|
||||
const fileTypes = await db.devFileType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (fileTypes.length === 0) {
|
||||
throw new Error('数据库中没有文件类型定义,请先初始化文件类型数据')
|
||||
}
|
||||
|
||||
await updateProgress({ totalFiles: 0, analyzedFiles: 0 }, `已加载 ${fileTypes.length} 个文件类型`)
|
||||
|
||||
// 获取所有需要分析的文件
|
||||
await updateProgress({ totalFiles: 0, analyzedFiles: 0 }, '正在获取文件列表...')
|
||||
const allFiles = await getProjectFiles()
|
||||
|
||||
// 对文件进行排序(删除的文件在前,其余按依赖关系排序)
|
||||
await updateProgress({ totalFiles: 0, analyzedFiles: 0 }, '正在分析文件依赖关系并排序...')
|
||||
const files = await sortFilesByDependency(allFiles)
|
||||
const totalFiles = files.length
|
||||
|
||||
await updateProgress({ totalFiles, analyzedFiles: 0 }, `共 ${totalFiles} 个文件,正在查询历史分析记录...`)
|
||||
|
||||
// 查询所有不同文件的最近一次不带*的commitId
|
||||
const lastAnalyzedCommits = await db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
commitId: {
|
||||
not: {
|
||||
contains: '*'
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
path: true,
|
||||
commitId: true,
|
||||
},
|
||||
distinct: ['path']
|
||||
})
|
||||
const lastCommitMap = new Map(lastAnalyzedCommits.map(item => [item.path, item.commitId]))
|
||||
|
||||
await updateProgress({ totalFiles, analyzedFiles: 0 }, `已加载 ${lastCommitMap.size} 个文件的历史记录,开始分析...`)
|
||||
|
||||
// 逐个分析文件
|
||||
let analyzedFiles = 0
|
||||
for (const filePath of files) {
|
||||
const fileName = path.basename(filePath)
|
||||
await updateProgress({ totalFiles, analyzedFiles, currentFile: filePath }, `正在分析: ${filePath}`)
|
||||
|
||||
try {
|
||||
// 获取文件的 git 状态信息
|
||||
const { commitId, isDeleted } = await getFileCommitInfo(filePath)
|
||||
|
||||
// 如果文件已被删除,跳过分析
|
||||
if (isDeleted) {
|
||||
skippedFiles++
|
||||
analyzedFiles++
|
||||
await updateProgress({ totalFiles, analyzedFiles, currentFile: filePath }, `跳过已删除文件: ${filePath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否可以跳过分析
|
||||
const isFileModified = commitId.endsWith('*')
|
||||
const lastCommitId = lastCommitMap.get(filePath)
|
||||
|
||||
if (!isFileModified && lastCommitId) {
|
||||
// 条件1:文件本次未修改(commitId不带*)
|
||||
// 数据库中有该文件的历史记录
|
||||
// 从上次分析到现在文件都没有修改
|
||||
const currentCommitId = commitId // 当前commit(不带*)
|
||||
const hasChanged = await hasFileChangedBetweenCommits(filePath, lastCommitId, currentCommitId)
|
||||
|
||||
if (!hasChanged) {
|
||||
// 文件从上次分析到现在都没有修改,跳过分析
|
||||
skippedFiles++
|
||||
analyzedFiles++
|
||||
await updateProgress({ totalFiles, analyzedFiles, currentFile: filePath }, `跳过未修改文件: ${filePath} (commit: ${lastCommitId} -> ${currentCommitId})`)
|
||||
continue
|
||||
}
|
||||
} else if (isFileModified) {
|
||||
// 条件2:文件本次已修改(commitId带*)
|
||||
// 数据库中存在相同commitId的记录
|
||||
// 该记录的content字段与当前文件内容一致
|
||||
const existingRecord = await db.devAnalyzedFile.findUnique({
|
||||
where: {
|
||||
uidx_path_commit: {
|
||||
path: filePath,
|
||||
commitId: commitId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
content: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingRecord) {
|
||||
// 读取当前文件内容进行比对
|
||||
const currentContent = await fs.readFile(path.join(process.cwd(), filePath), 'utf-8')
|
||||
if (existingRecord.content === currentContent) {
|
||||
// 内容一致,跳过分析
|
||||
skippedFiles++
|
||||
analyzedFiles++
|
||||
await updateProgress({ totalFiles, analyzedFiles, currentFile: filePath }, `跳过内容未变化文件: ${filePath} (commit: ${commitId})`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastAnalyzedAt = new Date()
|
||||
const analysis = await analyzeFile(filePath, fileTypes as FileType[])
|
||||
|
||||
// 使用 upsert 保存到数据库(如果 path + commitId 已存在则更新,否则创建)
|
||||
const savedFile = await db.devAnalyzedFile.upsert({
|
||||
where: {
|
||||
uidx_path_commit: {
|
||||
path: filePath,
|
||||
commitId: commitId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
fileName,
|
||||
fileTypeId: analysis.fileTypeId,
|
||||
summary: analysis.summary,
|
||||
description: analysis.description,
|
||||
exportedMembers: analysis.exportedMembers,
|
||||
tags: analysis.tags,
|
||||
content: analysis.content,
|
||||
lastAnalyzedAt,
|
||||
},
|
||||
create: {
|
||||
path: filePath,
|
||||
fileName,
|
||||
commitId,
|
||||
content: analysis.content,
|
||||
fileTypeId: analysis.fileTypeId,
|
||||
summary: analysis.summary,
|
||||
description: analysis.description,
|
||||
exportedMembers: analysis.exportedMembers,
|
||||
tags: analysis.tags,
|
||||
lastAnalyzedAt,
|
||||
},
|
||||
})
|
||||
|
||||
// 删除旧的文件依赖关系记录
|
||||
await db.devFileDependency.deleteMany({
|
||||
where: {
|
||||
sourceFileId: savedFile.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 保存新的文件依赖关系
|
||||
if (analysis.dependencies.length > 0) {
|
||||
await db.devFileDependency.createMany({
|
||||
data: analysis.dependencies.map(({path, usage})=> ({
|
||||
sourceFileId: savedFile.id,
|
||||
targetFilePath: path,
|
||||
usageDescription: usage
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// 删除旧的包依赖关系记录
|
||||
await db.devFilePkgDependency.deleteMany({
|
||||
where: {
|
||||
sourceFileId: savedFile.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 保存新的包依赖关系
|
||||
if (analysis.pkgDependencies.length > 0) {
|
||||
await db.devFilePkgDependency.createMany({
|
||||
data: analysis.pkgDependencies.map(({packageName, usage})=> ({
|
||||
sourceFileId: savedFile.id,
|
||||
packageName: packageName,
|
||||
usageDescription: usage
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
analyzedFiles++
|
||||
await updateProgress({ totalFiles, analyzedFiles, currentFile: filePath }, `已完成: ${filePath} (commit: ${commitId || '无提交'})`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`处理文件 ${filePath} 失败:`, error)
|
||||
addError(filePath, errorMessage)
|
||||
await updateProgress({ totalFiles, analyzedFiles, currentFile: filePath }, `跳过文件 ${filePath}: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = analyzedFiles - failedFiles
|
||||
await updateProgress(
|
||||
{ totalFiles, analyzedFiles, currentFile: '' },
|
||||
`分析完成!成功 ${successCount} 个,失败 ${failedFiles} 个,跳过 ${skippedFiles} 个,共 ${totalFiles} 个文件`
|
||||
)
|
||||
},
|
||||
{
|
||||
connection: getRedisClient(),
|
||||
concurrency: 1, // 同时只处理一个分析任务
|
||||
}
|
||||
)
|
||||
|
||||
// Worker 事件监听
|
||||
analyzeFilesWorker.on('completed', (job) => {
|
||||
console.log(`[AnalyzeFiles] Job ${job.id} completed`)
|
||||
})
|
||||
|
||||
analyzeFilesWorker.on('failed', (job, err) => {
|
||||
console.error(`[AnalyzeFiles] Job ${job?.id} failed:`, err)
|
||||
})
|
||||
|
||||
analyzeFilesWorker.on('error', (err) => {
|
||||
console.error('[AnalyzeFiles] Worker error:', err)
|
||||
})
|
||||
51
src/server/queues/analyze-folders.queue.ts
Normal file
51
src/server/queues/analyze-folders.queue.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Queue, QueueEvents } from 'bullmq'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
|
||||
/**
|
||||
* 文件夹分析任务的输入数据
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface AnalyzeFoldersJobData {
|
||||
// 空对象,不需要输入参数
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹分析任务的进度数据
|
||||
*/
|
||||
export interface AnalyzeFoldersProgress {
|
||||
totalFolders?: number
|
||||
analyzedFolders?: number
|
||||
currentFolder?: string
|
||||
canceled?: boolean
|
||||
/** 失败的文件夹数量 */
|
||||
failedFolders?: number
|
||||
/** 跳过的文件夹数量 */
|
||||
skippedFolders?: number
|
||||
/** 最近的错误信息列表(最多保留最近10条) */
|
||||
recentErrors?: Array<{
|
||||
folderPath: string
|
||||
error: string
|
||||
timestamp: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹分析队列
|
||||
*/
|
||||
export const analyzeFoldersQueue = new Queue<AnalyzeFoldersJobData, void, string>('analyze-folders', {
|
||||
connection: getRedisClient(),
|
||||
defaultJobOptions: {
|
||||
attempts: 1, // 不重试,失败就失败
|
||||
removeOnComplete: {
|
||||
age: 3600, // 完成后保留1小时
|
||||
count: 100, // 最多保留100个已完成的任务
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 7200, // 失败后保留2小时
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const analyzeFoldersQueueEvents = new QueueEvents('analyze-folders', {
|
||||
connection: getRedisClient(),
|
||||
})
|
||||
273
src/server/queues/analyze-folders.worker.ts
Normal file
273
src/server/queues/analyze-folders.worker.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Worker, Job } from 'bullmq'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
import { db } from '@/server/db'
|
||||
import { AnalyzeFoldersJobData, AnalyzeFoldersProgress, analyzeFoldersQueue } from './analyze-folders.queue'
|
||||
import { getProjectFiles } from '@/server/utils/git-helper'
|
||||
import { analyzeFolder } from '@/server/agents/folder-analyzer'
|
||||
import { shuffle } from '@/lib/algorithm'
|
||||
import * as path from 'path'
|
||||
|
||||
/**
|
||||
* 文件夹信息
|
||||
*/
|
||||
interface FolderInfo {
|
||||
path: string
|
||||
name: string
|
||||
depth: number
|
||||
subFolders: string[]
|
||||
files: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件列表中提取所有文件夹
|
||||
*/
|
||||
function extractFolders(files: string[]): FolderInfo[] {
|
||||
const folderMap = new Map<string, FolderInfo>()
|
||||
|
||||
// 遍历所有文件,提取文件夹路径
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file)
|
||||
if (dir === '.') continue // 跳过根目录文件
|
||||
|
||||
// 分解路径,确保所有父文件夹都被记录
|
||||
const parts = dir.split(path.sep)
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const folderPath = parts.slice(0, i).join(path.sep)
|
||||
if (!folderMap.has(folderPath)) {
|
||||
folderMap.set(folderPath, {
|
||||
path: folderPath,
|
||||
name: parts[i - 1]!,
|
||||
depth: i,
|
||||
subFolders: [],
|
||||
files: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二次遍历,填充子文件夹和文件信息
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file)
|
||||
if (dir === '.') continue
|
||||
|
||||
const folderInfo = folderMap.get(dir)
|
||||
if (folderInfo) {
|
||||
folderInfo.files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 填充子文件夹信息
|
||||
for (const [folderPath, folderInfo] of folderMap) {
|
||||
const parts = folderPath.split(path.sep)
|
||||
if (parts.length > 1) {
|
||||
const parentPath = parts.slice(0, -1).join(path.sep)
|
||||
const parentInfo = folderMap.get(parentPath)
|
||||
if (parentInfo && !parentInfo.subFolders.includes(folderPath)) {
|
||||
parentInfo.subFolders.push(folderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(folderMap.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* 按深度排序文件夹(从深到浅)
|
||||
*/
|
||||
function sortFoldersByDepth(folders: FolderInfo[]): FolderInfo[] {
|
||||
return folders.sort((a, b) => b.depth - a.depth)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹分析 Worker
|
||||
*/
|
||||
export const analyzeFoldersWorker = new Worker<AnalyzeFoldersJobData, void, string>(
|
||||
'analyze-folders',
|
||||
async (job: Job<AnalyzeFoldersJobData, void, string>) => {
|
||||
console.log(`[AnalyzeFolders] Job ${job.id} start`)
|
||||
|
||||
// 错误记录数组(最多保留10条)
|
||||
const recentErrors: Array<{ folderPath: string; error: string; timestamp: number }> = []
|
||||
let failedFolders = 0
|
||||
let skippedFolders = 0
|
||||
|
||||
// 辅助函数:添加错误记录
|
||||
const addError = (folderPath: string, error: string) => {
|
||||
failedFolders++
|
||||
recentErrors.push({
|
||||
folderPath,
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
if (recentErrors.length > 10) {
|
||||
recentErrors.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:检查任务是否被取消
|
||||
const checkCanceled = async () => {
|
||||
const updatedJob = await analyzeFoldersQueue.getJob(job.id as string)
|
||||
if ((updatedJob?.progress as AnalyzeFoldersProgress)?.canceled) {
|
||||
await job.log('任务已被取消')
|
||||
throw new Error('任务已被用户取消')
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:更新进度
|
||||
const updateProgress = async (progress: Partial<AnalyzeFoldersProgress>, logMessage?: string) => {
|
||||
await checkCanceled()
|
||||
await job.updateProgress({
|
||||
...progress,
|
||||
canceled: false,
|
||||
failedFolders,
|
||||
skippedFolders,
|
||||
recentErrors: recentErrors.length > 0 ? [...recentErrors] : undefined,
|
||||
} as AnalyzeFoldersProgress)
|
||||
if (logMessage) await job.log(logMessage)
|
||||
}
|
||||
|
||||
// 获取所有文件
|
||||
await updateProgress({ totalFolders: 0, analyzedFolders: 0 }, '正在获取文件列表...')
|
||||
const allFiles = await getProjectFiles()
|
||||
|
||||
// 提取所有文件夹
|
||||
await updateProgress({ totalFolders: 0, analyzedFolders: 0 }, '正在分析文件夹结构...')
|
||||
const folders = extractFolders(allFiles)
|
||||
|
||||
// 按深度排序(从深到浅)
|
||||
const sortedFolders = sortFoldersByDepth(folders)
|
||||
const totalFolders = sortedFolders.length
|
||||
|
||||
await updateProgress({ totalFolders, analyzedFolders: 0 }, `共 ${totalFolders} 个文件夹,开始分析...`)
|
||||
|
||||
// 存储已分析的文件夹结果(用于上层文件夹分析)
|
||||
const analyzedFolderResults = new Map<string, { summary: string; description: string }>()
|
||||
|
||||
// 逐个分析文件夹(从叶子节点开始)
|
||||
let analyzedFolders = 0
|
||||
for (const folderInfo of sortedFolders) {
|
||||
await updateProgress({ totalFolders, analyzedFolders, currentFolder: folderInfo.path }, `正在分析: ${folderInfo.path}`)
|
||||
|
||||
try {
|
||||
// 获取子文件夹的分析结果
|
||||
const subFolderSummaries: Array<{ path: string; summary: string; description: string }> = []
|
||||
for (const subFolderPath of folderInfo.subFolders) {
|
||||
const result = analyzedFolderResults.get(subFolderPath)
|
||||
if (result) {
|
||||
subFolderSummaries.push({
|
||||
path: subFolderPath,
|
||||
summary: result.summary,
|
||||
description: result.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件的分析结果
|
||||
const fileSummaries: Array<{ path: string; summary: string; description: string }> = []
|
||||
if (folderInfo.files.length > 0) {
|
||||
const analyzedFiles = await db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
path: {
|
||||
in: folderInfo.files,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
path: true,
|
||||
summary: true,
|
||||
description: true,
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc',
|
||||
},
|
||||
distinct: ['path'],
|
||||
})
|
||||
|
||||
fileSummaries.push(...analyzedFiles.map(f => ({
|
||||
path: f.path,
|
||||
summary: f.summary,
|
||||
description: f.description,
|
||||
})))
|
||||
}
|
||||
|
||||
// 如果没有子文件夹和文件信息,跳过分析
|
||||
if (subFolderSummaries.length === 0 && fileSummaries.length === 0) {
|
||||
skippedFolders++
|
||||
analyzedFolders++
|
||||
await updateProgress({ totalFolders, analyzedFolders, currentFolder: folderInfo.path }, `跳过空文件夹: ${folderInfo.path}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 随机抽取最多100个文件
|
||||
const selectedFiles = fileSummaries.length > 100
|
||||
? shuffle([...fileSummaries]).slice(0, 100)
|
||||
: fileSummaries
|
||||
|
||||
// 使用AI分析文件夹
|
||||
const analysis = await analyzeFolder(
|
||||
folderInfo.path,
|
||||
folderInfo.name,
|
||||
subFolderSummaries,
|
||||
selectedFiles
|
||||
)
|
||||
|
||||
// 保存到数据库
|
||||
await db.devAnalyzedFolder.upsert({
|
||||
where: {
|
||||
path: folderInfo.path,
|
||||
},
|
||||
update: {
|
||||
name: folderInfo.name,
|
||||
summary: analysis.summary,
|
||||
description: analysis.description,
|
||||
lastAnalyzedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
path: folderInfo.path,
|
||||
name: folderInfo.name,
|
||||
summary: analysis.summary,
|
||||
description: analysis.description,
|
||||
},
|
||||
})
|
||||
|
||||
// 保存结果供上层文件夹使用
|
||||
analyzedFolderResults.set(folderInfo.path, {
|
||||
summary: analysis.summary,
|
||||
description: analysis.description,
|
||||
})
|
||||
|
||||
analyzedFolders++
|
||||
await updateProgress({ totalFolders, analyzedFolders, currentFolder: folderInfo.path }, `已完成: ${folderInfo.path}`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`处理文件夹 ${folderInfo.path} 失败:`, error)
|
||||
addError(folderInfo.path, errorMessage)
|
||||
await updateProgress({ totalFolders, analyzedFolders, currentFolder: folderInfo.path }, `跳过文件夹 ${folderInfo.path}: ${errorMessage}`)
|
||||
analyzedFolders++
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = analyzedFolders - failedFolders
|
||||
await updateProgress(
|
||||
{ totalFolders, analyzedFolders, currentFolder: '' },
|
||||
`分析完成!成功 ${successCount} 个,失败 ${failedFolders} 个,跳过 ${skippedFolders} 个,共 ${totalFolders} 个文件夹`
|
||||
)
|
||||
},
|
||||
{
|
||||
connection: getRedisClient(),
|
||||
concurrency: 1, // 同时只处理一个分析任务
|
||||
}
|
||||
)
|
||||
|
||||
// Worker 事件监听
|
||||
analyzeFoldersWorker.on('completed', (job) => {
|
||||
console.log(`[AnalyzeFolders] Job ${job.id} completed`)
|
||||
})
|
||||
|
||||
analyzeFoldersWorker.on('failed', (job, err) => {
|
||||
console.error(`[AnalyzeFolders] Job ${job?.id} failed:`, err)
|
||||
})
|
||||
|
||||
analyzeFoldersWorker.on('error', (err) => {
|
||||
console.error('[AnalyzeFolders] Worker error:', err)
|
||||
})
|
||||
51
src/server/queues/analyze-packages.queue.ts
Normal file
51
src/server/queues/analyze-packages.queue.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Queue, QueueEvents } from 'bullmq'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
|
||||
/**
|
||||
* 依赖包分析任务的输入数据
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface AnalyzePackagesJobData {
|
||||
// 空对象,不需要输入参数
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖包分析任务的进度数据
|
||||
*/
|
||||
export interface AnalyzePackagesProgress {
|
||||
totalPackages?: number
|
||||
analyzedPackages?: number
|
||||
currentPackage?: string
|
||||
canceled?: boolean
|
||||
/** 失败的包数量 */
|
||||
failedPackages?: number
|
||||
/** 跳过的包数量(内置模块或不在 package.json 中) */
|
||||
skippedPackages?: number
|
||||
/** 最近的错误信息列表(最多保留最近10条) */
|
||||
recentErrors?: Array<{
|
||||
packageName: string
|
||||
error: string
|
||||
timestamp: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖包分析队列
|
||||
*/
|
||||
export const analyzePackagesQueue = new Queue<AnalyzePackagesJobData, void, string>('analyze-packages', {
|
||||
connection: getRedisClient(),
|
||||
defaultJobOptions: {
|
||||
attempts: 1, // 不重试,失败就失败
|
||||
removeOnComplete: {
|
||||
age: 3600, // 完成后保留1小时
|
||||
count: 100, // 最多保留100个已完成的任务
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 7200, // 失败后保留2小时
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const analyzePackagesQueueEvents = new QueueEvents('analyze-packages', {
|
||||
connection: getRedisClient(),
|
||||
})
|
||||
322
src/server/queues/analyze-packages.worker.ts
Normal file
322
src/server/queues/analyze-packages.worker.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { Worker, Job } from 'bullmq'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
import { db } from '@/server/db'
|
||||
import { AnalyzePackagesJobData, AnalyzePackagesProgress, analyzePackagesQueue } from './analyze-packages.queue'
|
||||
import { analyzePackage, type PkgType } from '@/server/agents/package-analyzer'
|
||||
import { isBuiltinModule, getPackageInfo } from '@/server/utils/node-helper'
|
||||
import { shuffle } from '@/lib/algorithm'
|
||||
import packageJson from '@/../package.json'
|
||||
import { maxBy, groupBy } from 'lodash'
|
||||
|
||||
/**
|
||||
* 获取与指定包关联的文件信息(二级以内)
|
||||
* @param packageName 包名
|
||||
* @returns 包含关联文件路径、使用示例和引用关系的对象
|
||||
*/
|
||||
async function getRelatedFiles(packageName: string): Promise<{
|
||||
relatedFilePaths: string[]
|
||||
usageExamples: Array<{ filePath: string; usage: string }>
|
||||
level1Info: string
|
||||
}> {
|
||||
// 1. 获取直接依赖该包的文件(一级)及其使用描述
|
||||
const rawLevel1Files = await db.devFilePkgDependency.findMany({
|
||||
where: { packageName },
|
||||
select: {
|
||||
sourceFileId: true,
|
||||
usageDescription: true,
|
||||
sourceFile: {
|
||||
select: {
|
||||
path: true,
|
||||
lastAnalyzedAt: true
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 按 path 分组去重,取 lastAnalyzedAt 最大的记录
|
||||
const allLevel1Files = Object.values(
|
||||
groupBy(rawLevel1Files, f => f.sourceFile.path)
|
||||
).map(group => maxBy(group, f => f.sourceFile.lastAnalyzedAt)!)
|
||||
|
||||
// 2. 获取依赖一级文件的文件(二级)
|
||||
const alllevel1FilePaths = allLevel1Files.map(f => f.sourceFile.path)
|
||||
|
||||
const rawLevel2Dependencies = await db.devFileDependency.findMany({
|
||||
where: {
|
||||
targetFilePath: {
|
||||
in: alllevel1FilePaths,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
sourceFileId: true,
|
||||
targetFilePath: true,
|
||||
usageDescription: true,
|
||||
sourceFile: {
|
||||
select: {
|
||||
path: true,
|
||||
lastAnalyzedAt: true
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 按 path 分组去重,取 lastAnalyzedAt 最大的记录
|
||||
const level2Dependencies = Object.values(
|
||||
groupBy(rawLevel2Dependencies, f => f.sourceFile.path)
|
||||
).map(group => maxBy(group, f => f.sourceFile.lastAnalyzedAt)!)
|
||||
|
||||
const level2FilePaths = level2Dependencies
|
||||
.map(f => f.sourceFile.path)
|
||||
.filter(path => !alllevel1FilePaths.includes(path)) // 排除已在一级中的文件
|
||||
|
||||
const relatedFilePaths = [...alllevel1FilePaths, ...level2FilePaths]
|
||||
|
||||
// 3. 构建使用示例和一级文件的引用关系说明
|
||||
|
||||
// 如果一级文件超过100个,随机选取100个
|
||||
const level1Files = allLevel1Files.length > 100
|
||||
? shuffle([...allLevel1Files]).slice(0, 100)
|
||||
: allLevel1Files
|
||||
|
||||
// 构建使用示例(从一级文件中获取)
|
||||
const usageExamples = level1Files
|
||||
.filter(ex => ex.usageDescription) // 过滤掉没有描述的
|
||||
.map(ex => ({
|
||||
filePath: ex.sourceFile.path,
|
||||
usage: ex.usageDescription || '',
|
||||
}))
|
||||
|
||||
const level1InfoParts: string[] = []
|
||||
|
||||
for (const level1File of level1Files) {
|
||||
const level1Path = level1File.sourceFile.path
|
||||
|
||||
// 找出引用该一级文件的二级文件
|
||||
const allReferencingLevel2 = level2Dependencies
|
||||
.filter(dep => dep.targetFilePath === level1Path)
|
||||
|
||||
// 如果引用的二级文件超过10个,随机选取10个
|
||||
const referencingLevel2 = allReferencingLevel2.length > 10
|
||||
? shuffle([...allReferencingLevel2]).slice(0, 10)
|
||||
: allReferencingLevel2
|
||||
|
||||
if (referencingLevel2.length > 0) {
|
||||
const references = referencingLevel2
|
||||
.map(dep => {
|
||||
const usageDesc = dep.usageDescription ? ` (${dep.usageDescription})` : ''
|
||||
return ` - ${dep.sourceFile.path}${usageDesc}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
level1InfoParts.push(` - ${level1Path}\n 被以下文件引用:\n${references}`)
|
||||
} else {
|
||||
level1InfoParts.push(` - ${level1Path}\n 未被其他文件使用`)
|
||||
}
|
||||
}
|
||||
|
||||
const level1Info = level1InfoParts.length > 0
|
||||
? level1InfoParts.join('\n\n')
|
||||
: ' (暂无一级引用文件)'
|
||||
|
||||
return {
|
||||
relatedFilePaths,
|
||||
usageExamples,
|
||||
level1Info,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖包分析 Worker
|
||||
*/
|
||||
export const analyzePackagesWorker = new Worker<AnalyzePackagesJobData, void, string>(
|
||||
'analyze-packages',
|
||||
async (job: Job<AnalyzePackagesJobData, void, string>) => {
|
||||
console.log(`[AnalyzePackages] Job ${job.id} start`)
|
||||
|
||||
// 错误记录数组(最多保留10条)
|
||||
const recentErrors: Array<{ packageName: string; error: string; timestamp: number }> = []
|
||||
let failedPackages = 0
|
||||
let skippedPackages = 0
|
||||
|
||||
// 辅助函数:添加错误记录
|
||||
const addError = (packageName: string, error: string) => {
|
||||
failedPackages++
|
||||
recentErrors.push({
|
||||
packageName,
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// 只保留最近10条错误
|
||||
if (recentErrors.length > 10) {
|
||||
recentErrors.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:检查任务是否被取消
|
||||
const checkCanceled = async () => {
|
||||
const updatedJob = await analyzePackagesQueue.getJob(job.id as string)
|
||||
if ((updatedJob?.progress as AnalyzePackagesProgress)?.canceled) {
|
||||
await job.log('任务已被取消')
|
||||
throw new Error('任务已被用户取消')
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:更新进度
|
||||
const updateProgress = async (progress: Partial<AnalyzePackagesProgress>, logMessage?: string) => {
|
||||
await checkCanceled()
|
||||
await job.updateProgress({
|
||||
...progress,
|
||||
canceled: false,
|
||||
failedPackages,
|
||||
skippedPackages,
|
||||
recentErrors: recentErrors.length > 0 ? [...recentErrors] : undefined,
|
||||
} as AnalyzePackagesProgress)
|
||||
if (logMessage) await job.log(logMessage)
|
||||
}
|
||||
|
||||
// 从数据库获取包类型列表
|
||||
await updateProgress({ totalPackages: 0, analyzedPackages: 0 }, '正在获取包类型列表...')
|
||||
const pkgTypes = await db.devPkgType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (pkgTypes.length === 0) {
|
||||
throw new Error('数据库中没有包类型定义,请先初始化包类型数据')
|
||||
}
|
||||
|
||||
await updateProgress({ totalPackages: 0, analyzedPackages: 0 }, `已加载 ${pkgTypes.length} 个包类型`)
|
||||
|
||||
// 获取 package.json 中的依赖
|
||||
const dependencies = Object.keys(packageJson.dependencies || {})
|
||||
const devDependencies = Object.keys(packageJson.devDependencies || {})
|
||||
const allDependencies = [...dependencies, ...devDependencies]
|
||||
|
||||
await updateProgress({ totalPackages: 0, analyzedPackages: 0 }, `package.json 中共有 ${allDependencies.length} 个依赖`)
|
||||
|
||||
// 从 DevFilePkgDependency 中获取所有被使用的包
|
||||
await updateProgress({ totalPackages: 0, analyzedPackages: 0 }, '正在查询项目中使用的包...')
|
||||
const usedPackages = await db.devFilePkgDependency.findMany({
|
||||
select: {
|
||||
packageName: true,
|
||||
},
|
||||
distinct: ['packageName'],
|
||||
})
|
||||
|
||||
const usedPackageNames = new Set(usedPackages.map(p => p.packageName))
|
||||
await updateProgress({ totalPackages: 0, analyzedPackages: 0 }, `项目中使用了 ${usedPackageNames.size} 个不同的包`)
|
||||
|
||||
// 过滤出需要分析的包:
|
||||
// 1. 是内置模块
|
||||
// 2. 在 package.json 的 dependencies 或 devDependencies 中
|
||||
const packagesToAnalyze = Array.from(usedPackageNames).filter(pkgName => {
|
||||
if (isBuiltinModule(pkgName)) {
|
||||
return true
|
||||
}
|
||||
if (allDependencies.includes(pkgName)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const totalPackages = packagesToAnalyze.length
|
||||
await updateProgress({ totalPackages, analyzedPackages: 0 }, `共需分析 ${totalPackages} 个包`)
|
||||
|
||||
// 逐个分析包
|
||||
let analyzedPackages = 0
|
||||
for (const packageName of packagesToAnalyze) {
|
||||
await updateProgress({ totalPackages, analyzedPackages, currentPackage: packageName }, `正在分析: ${packageName}`)
|
||||
|
||||
try {
|
||||
// 获取包的基本信息
|
||||
const packageInfo = await getPackageInfo(packageName)
|
||||
if (!packageInfo) {
|
||||
throw new Error('无法获取包信息')
|
||||
}
|
||||
|
||||
// 获取关联文件信息(包含使用示例和引用关系)
|
||||
const relatedInfo = await getRelatedFiles(packageName)
|
||||
|
||||
// 使用 AI 分析包
|
||||
const analysis = await analyzePackage(
|
||||
packageName,
|
||||
packageInfo.description,
|
||||
relatedInfo.usageExamples,
|
||||
relatedInfo.level1Info,
|
||||
pkgTypes as PkgType[]
|
||||
)
|
||||
|
||||
// 保存到数据库(使用 upsert)
|
||||
await db.devAnalyzedPkg.upsert({
|
||||
where: {
|
||||
name: packageName,
|
||||
},
|
||||
update: {
|
||||
version: packageInfo.version,
|
||||
modifiedAt: packageInfo.modifiedAt,
|
||||
description: packageInfo.description,
|
||||
homepage: packageInfo.homepage,
|
||||
repositoryUrl: packageInfo.repositoryUrl,
|
||||
pkgTypeId: analysis.pkgTypeId,
|
||||
projectRoleSummary: analysis.projectRoleSummary,
|
||||
primaryUsagePattern: analysis.primaryUsagePattern,
|
||||
relatedFiles: relatedInfo.relatedFilePaths,
|
||||
relatedFileCount: relatedInfo.relatedFilePaths.length,
|
||||
lastAnalyzedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
name: packageName,
|
||||
version: packageInfo.version,
|
||||
modifiedAt: packageInfo.modifiedAt,
|
||||
description: packageInfo.description,
|
||||
homepage: packageInfo.homepage,
|
||||
repositoryUrl: packageInfo.repositoryUrl,
|
||||
pkgTypeId: analysis.pkgTypeId,
|
||||
projectRoleSummary: analysis.projectRoleSummary,
|
||||
primaryUsagePattern: analysis.primaryUsagePattern,
|
||||
relatedFiles: relatedInfo.relatedFilePaths,
|
||||
relatedFileCount: relatedInfo.relatedFilePaths.length,
|
||||
},
|
||||
})
|
||||
|
||||
analyzedPackages++
|
||||
await updateProgress(
|
||||
{ totalPackages, analyzedPackages, currentPackage: packageName },
|
||||
`已完成: ${packageName} (v${packageInfo.version}, 关联 ${relatedInfo.relatedFilePaths.length} 个文件)`
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`处理包 ${packageName} 失败:`, error)
|
||||
addError(packageName, errorMessage)
|
||||
await updateProgress({ totalPackages, analyzedPackages, currentPackage: packageName }, `跳过包 ${packageName}: ${errorMessage}`)
|
||||
analyzedPackages++
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = analyzedPackages - failedPackages
|
||||
await updateProgress(
|
||||
{ totalPackages, analyzedPackages, currentPackage: '' },
|
||||
`分析完成!成功 ${successCount} 个,失败 ${failedPackages} 个,跳过 ${skippedPackages} 个,共 ${totalPackages} 个包`
|
||||
)
|
||||
},
|
||||
{
|
||||
connection: getRedisClient(),
|
||||
concurrency: 1, // 同时只处理一个分析任务
|
||||
}
|
||||
)
|
||||
|
||||
// Worker 事件监听
|
||||
analyzePackagesWorker.on('completed', (job) => {
|
||||
console.log(`[AnalyzePackages] Job ${job.id} completed`)
|
||||
})
|
||||
|
||||
analyzePackagesWorker.on('failed', (job, err) => {
|
||||
console.error(`[AnalyzePackages] Job ${job?.id} failed:`, err)
|
||||
})
|
||||
|
||||
analyzePackagesWorker.on('error', (err) => {
|
||||
console.error('[AnalyzePackages] Worker error:', err)
|
||||
})
|
||||
16
src/server/queues/index.ts
Normal file
16
src/server/queues/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 队列和 Worker 入口文件
|
||||
*
|
||||
* 导出所有队列和 worker 实例
|
||||
*/
|
||||
export { analyzeFilesQueue, analyzeFilesQueueEvents } from './analyze-files.queue'
|
||||
export { analyzeFilesWorker } from './analyze-files.worker'
|
||||
export type { AnalyzeFilesJobData, AnalyzeFilesProgress } from './analyze-files.queue'
|
||||
|
||||
export { analyzePackagesQueue, analyzePackagesQueueEvents } from './analyze-packages.queue'
|
||||
export { analyzePackagesWorker } from './analyze-packages.worker'
|
||||
export type { AnalyzePackagesJobData, AnalyzePackagesProgress } from './analyze-packages.queue'
|
||||
|
||||
export { analyzeFoldersQueue, analyzeFoldersQueueEvents } from './analyze-folders.queue'
|
||||
export { analyzeFoldersWorker } from './analyze-folders.worker'
|
||||
export type { AnalyzeFoldersJobData, AnalyzeFoldersProgress } from './analyze-folders.queue'
|
||||
28
src/server/redis.ts
Normal file
28
src/server/redis.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'server-only'
|
||||
import Redis from 'ioredis'
|
||||
|
||||
// Redis 客户端单例
|
||||
let redisClient: Redis | null = null
|
||||
|
||||
/**
|
||||
* 获取共享的 Redis 客户端实例
|
||||
*/
|
||||
export function getRedisClient() {
|
||||
if (!redisClient) {
|
||||
const redisPort = process.env.REDIS_PORT || '6379'
|
||||
const redisPassword = process.env.REDIS_PASSWORD
|
||||
|
||||
redisClient = new Redis({
|
||||
host: 'localhost',
|
||||
port: parseInt(redisPort, 10),
|
||||
password: redisPassword,
|
||||
maxRetriesPerRequest: null, // BullMQ 推荐设置
|
||||
})
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
console.error('Redis Client Error:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return redisClient
|
||||
}
|
||||
30
src/server/routers/_app.ts
Normal file
30
src/server/routers/_app.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createTRPCRouter } from '@/server/trpc'
|
||||
import { usersRouter } from './users'
|
||||
import { selectionRouter } from './selection'
|
||||
import { uploadRouter } from './upload'
|
||||
import { globalRouter } from './global'
|
||||
import { devFileRouter } from './dev/file'
|
||||
import { devFrontendDesignRouter } from './dev/frontend-design'
|
||||
import { devArchRouter } from './dev/arch'
|
||||
import { jobsRouter } from './jobs'
|
||||
import { devPanelRouter } from './dev/panel'
|
||||
import { commonRouter } from './common'
|
||||
|
||||
// 这是根路由
|
||||
export const appRouter = createTRPCRouter({
|
||||
common: commonRouter,
|
||||
users: usersRouter,
|
||||
selection: selectionRouter,
|
||||
upload: uploadRouter,
|
||||
global: globalRouter,
|
||||
jobs: jobsRouter,
|
||||
|
||||
...(process.env.NODE_ENV === 'development' ? {
|
||||
devFile: devFileRouter,
|
||||
devFrontendDesign: devFrontendDesignRouter,
|
||||
devArch: devArchRouter,
|
||||
devPanel: devPanelRouter
|
||||
} : {})
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
8
src/server/routers/common.ts
Normal file
8
src/server/routers/common.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 通用接口,与特定业务关联性不强,需要在不同的地方反复使用
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
|
||||
export const commonRouter = createTRPCRouter({
|
||||
getDepts: permissionRequiredProcedure('').query(({ ctx }) =>
|
||||
ctx.db.dept.findMany({ orderBy: { code: 'asc' } })
|
||||
),
|
||||
})
|
||||
68
src/server/routers/dev/arch.ts
Normal file
68
src/server/routers/dev/arch.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { analyzePackagesQueue } from '@/server/queues'
|
||||
import type { AnalyzePackagesProgress } from '@/server/queues'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
export const devArchRouter = createTRPCRouter({
|
||||
// 获取所有包类型
|
||||
getAllPkgTypes: permissionRequiredProcedure('SUPER_ADMIN_ONLY').query(async ({ ctx }) => {
|
||||
const pkgTypes = await ctx.db.devPkgType.findMany({
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
})
|
||||
return pkgTypes
|
||||
}),
|
||||
|
||||
// 获取所有依赖包数据(按类型分组)
|
||||
getAllPackages: permissionRequiredProcedure('SUPER_ADMIN_ONLY').query(async ({ ctx }) => {
|
||||
// 获取所有依赖包,包含类型信息
|
||||
const packages = await ctx.db.devAnalyzedPkg.findMany({
|
||||
include: {
|
||||
pkgType: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ pkgTypeId: 'asc' },
|
||||
{ relatedFileCount: 'desc' },
|
||||
],
|
||||
})
|
||||
|
||||
// 按类型分组
|
||||
const packagesByType: Record<string, typeof packages> = {}
|
||||
|
||||
packages.forEach((pkg) => {
|
||||
const typeId = pkg.pkgTypeId
|
||||
if (!packagesByType[typeId]) {
|
||||
packagesByType[typeId] = []
|
||||
}
|
||||
packagesByType[typeId].push(pkg)
|
||||
})
|
||||
|
||||
return packagesByType
|
||||
}),
|
||||
|
||||
// 启动依赖包分析任务
|
||||
startAnalyzePackages: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.mutation(async () => {
|
||||
const job = await analyzePackagesQueue.add('analyze-packages', {})
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
|
||||
// 取消依赖包分析任务
|
||||
cancelAnalyzePackagesJob: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const job = await analyzePackagesQueue.getJob(input.jobId)
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '任务不存在' })
|
||||
}
|
||||
|
||||
// 更新进度标记为已取消
|
||||
await job.updateProgress({ ...(job.progress as AnalyzePackagesProgress), canceled: true })
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevArchRouter = typeof devArchRouter;
|
||||
export type PackageData = inferProcedureOutput<DevArchRouter['getAllPackages']>[string][number];
|
||||
602
src/server/routers/dev/file.ts
Normal file
602
src/server/routers/dev/file.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
|
||||
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import { analyzeFilesQueue, analyzeFoldersQueue } from '@/server/queues'
|
||||
import type { AnalyzeFilesProgress, AnalyzeFoldersProgress } from '@/server/queues'
|
||||
import { getProjectFiles, getFileGitHistory, getFileContentAtCommit, getFileDiffBetweenCommits } from '@/server/utils/git-helper'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
export const devFileRouter = createTRPCRouter({
|
||||
// 获取最近分析时间
|
||||
getLatestAnalyzedTime: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
const latestFile = await ctx.db.devAnalyzedFile.findFirst({
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return latestFile?.lastAnalyzedAt || null
|
||||
}),
|
||||
|
||||
// 获取 Commit ID 统计(包含每个 commit 的文件数量和最小分析时间)
|
||||
getCommitIdStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 聚合统计每个 commitId 的文件数量和最小 lastAnalyzedAt
|
||||
const stats = await ctx.db.devAnalyzedFile.groupBy({
|
||||
by: ['commitId'],
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
_min: {
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ _min: { lastAnalyzedAt: 'desc'} },
|
||||
{ commitId: 'desc' }
|
||||
]
|
||||
})
|
||||
|
||||
return stats
|
||||
.map(stat => ({
|
||||
id: stat.commitId,
|
||||
name: stat.commitId,
|
||||
count: stat._count.id,
|
||||
minAnalyzedAt: stat._min.lastAnalyzedAt,
|
||||
}))
|
||||
}),
|
||||
|
||||
getTagsStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
const data = await ctx.db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
tags: {
|
||||
isEmpty: false
|
||||
}
|
||||
},
|
||||
select: { tags: true }
|
||||
});
|
||||
const tagCounts = data.reduce((acc, data) => {
|
||||
data.tags.forEach(value => {
|
||||
acc[value] = (acc[value] || 0) + 1;
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return Object.entries(tagCounts)
|
||||
.map(([name, count]) => ({
|
||||
id: name,
|
||||
name: name,
|
||||
count: count
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}),
|
||||
|
||||
// 获取包依赖统计
|
||||
getPkgDependencyStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 统计每个包名的使用次数(按不同的源文件计数)
|
||||
const stats = await ctx.db.devFilePkgDependency.groupBy({
|
||||
by: ['packageName'],
|
||||
_count: {
|
||||
sourceFileId: true,
|
||||
},
|
||||
orderBy: {
|
||||
packageName: 'asc',
|
||||
},
|
||||
})
|
||||
|
||||
return stats.map(stat => ({
|
||||
id: stat.packageName,
|
||||
name: stat.packageName,
|
||||
count: stat._count.sourceFileId,
|
||||
}))
|
||||
}),
|
||||
|
||||
|
||||
// 获取文件类型统计(包含每种类型的文件数量)
|
||||
getFileTypeStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取所有文件类型
|
||||
const fileTypes = await ctx.db.devFileType.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
// 统计每种文件类型的数量
|
||||
const stats = await ctx.db.devAnalyzedFile.groupBy({
|
||||
by: ['fileTypeId'],
|
||||
_count: {
|
||||
fileTypeId: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建统计映射
|
||||
const countMap = new Map(
|
||||
stats.map(stat => [stat.fileTypeId, stat._count.fileTypeId])
|
||||
)
|
||||
|
||||
// 返回带有数量的文件类型列表
|
||||
return fileTypes.map(fileType => ({
|
||||
id: fileType.id,
|
||||
name: fileType.name,
|
||||
count: countMap.get(fileType.id) || 0,
|
||||
}))
|
||||
}),
|
||||
|
||||
// 获取已分析文件列表
|
||||
listAnalyzedFiles: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(dataTableQueryParamsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
|
||||
model: 'DevAnalyzedFile',
|
||||
columns: {
|
||||
path: { field: 'path', sortable: true },
|
||||
fileName: { field: 'fileName', sortable: true },
|
||||
commitId: { field: 'commitId', variant: 'select', sortable: true },
|
||||
fileTypeId: { field: 'fileTypeId', variant: 'select', sortable: true },
|
||||
summary: { field: 'summary', sortable: true },
|
||||
pkgDependencies: { field: 'pkgDependencies.some.packageName', variant: 'multiSelect' },
|
||||
createdAt: { field: 'createdAt', variant: 'dateRange', sortable: true },
|
||||
lastAnalyzedAt: { field: 'lastAnalyzedAt', variant: 'dateRange', sortable: true },
|
||||
},
|
||||
})
|
||||
|
||||
// 如果对 commitId 排序,则转换为对 lastAnalyzedAt 排序
|
||||
const commitIdSortIndex = input.sorting.findIndex(s => s.id === 'commitId')
|
||||
if (commitIdSortIndex !== -1) {
|
||||
const commitIdSort = input.sorting[commitIdSortIndex]!
|
||||
// 移除 commitId 排序,添加 lastAnalyzedAt 排序
|
||||
orderBy.splice(commitIdSortIndex, 0, { lastAnalyzedAt: commitIdSort.desc ? 'desc' : 'asc' })
|
||||
}
|
||||
|
||||
const conditions = []
|
||||
|
||||
const fileNameFilter = input.filters.find(f => f.id === 'fileName')
|
||||
if (fileNameFilter?.value) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ path: { contains: fileNameFilter.value, mode: 'insensitive' } },
|
||||
{ fileName: { contains: fileNameFilter.value, mode: 'insensitive' } },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const summaryFilter = input.filters.find(f => f.id === 'summary')
|
||||
if (summaryFilter?.value) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ summary: { contains: summaryFilter.value, mode: 'insensitive' } },
|
||||
{ description: { contains: summaryFilter.value, mode: 'insensitive' } },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const tagsFilter = input.filters.find(f => f.id === 'tags')
|
||||
if (tagsFilter && Array.isArray(tagsFilter.value) && tagsFilter.value.length > 0) {
|
||||
conditions.push({ tags: { hasSome: tagsFilter.value } })
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
where.AND = conditions
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.db.devAnalyzedFile.findMany({
|
||||
where,
|
||||
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
fileTypeId: true,
|
||||
summary: true,
|
||||
description: true,
|
||||
exportedMembers: true,
|
||||
tags: true,
|
||||
commitId: true,
|
||||
lastAnalyzedAt: true,
|
||||
createdAt: true,
|
||||
fileType: true,
|
||||
dependencies: {
|
||||
select: {
|
||||
targetFilePath: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
pkgDependencies: {
|
||||
select: {
|
||||
packageName: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
// 排除 content 字段以提升性能
|
||||
},
|
||||
}),
|
||||
ctx.db.devAnalyzedFile.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}),
|
||||
|
||||
// 启动文件分析任务
|
||||
startAnalyzeFiles: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.mutation(async () => {
|
||||
const job = await analyzeFilesQueue.add('analyze-files', {})
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
|
||||
// 取消文件分析任务
|
||||
cancelAnalyzeFilesJob: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const job = await analyzeFilesQueue.getJob(input.jobId)
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '任务不存在' })
|
||||
}
|
||||
|
||||
// 更新进度标记为已取消
|
||||
await job.updateProgress({ ...(job.progress as AnalyzeFilesProgress), canceled: true })
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// 启动文件夹分析任务
|
||||
startAnalyzeFolders: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.mutation(async () => {
|
||||
const job = await analyzeFoldersQueue.add('analyze-folders', {})
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
|
||||
// 取消文件夹分析任务
|
||||
cancelAnalyzeFoldersJob: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const job = await analyzeFoldersQueue.getJob(input.jobId)
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '任务不存在' })
|
||||
}
|
||||
|
||||
// 更新进度标记为已取消
|
||||
await job.updateProgress({ ...(job.progress as AnalyzeFoldersProgress), canceled: true })
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// 获取文件详情
|
||||
getFileById: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
fileType: true,
|
||||
dependencies: {
|
||||
select: {
|
||||
targetFilePath: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
pkgDependencies: {
|
||||
select: {
|
||||
packageName: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
return file
|
||||
}),
|
||||
|
||||
// 获取文件内容
|
||||
getFileContent: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { content: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
return file.content
|
||||
}),
|
||||
|
||||
// 获取文件依赖图数据
|
||||
getDependencyGraph: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取项目中当前存在的文件
|
||||
const currentFiles = new Set(await getProjectFiles())
|
||||
|
||||
// 获取所有文件最近一次分析记录
|
||||
const files = await ctx.db.devAnalyzedFile.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
fileTypeId: true,
|
||||
summary: true,
|
||||
dependencies: {
|
||||
select: {
|
||||
targetFilePath: true,
|
||||
},
|
||||
},
|
||||
fileType: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
distinct: ['path'], // 当存在多条记录拥有相同的distinct字段组合时,Prisma会返回这些记录中的第一条
|
||||
})
|
||||
|
||||
// 构建路径到ID的映射
|
||||
const pathToIdMap = new Map<string, number>()
|
||||
files.forEach(file => {
|
||||
pathToIdMap.set(file.path, file.id)
|
||||
})
|
||||
|
||||
// 构建节点数据,标记已删除的文件
|
||||
const nodes = files.map(file => ({
|
||||
id: String(file.id),
|
||||
path: file.path,
|
||||
fileName: file.fileName,
|
||||
fileTypeId: file.fileTypeId,
|
||||
fileTypeName: file.fileType?.name || file.fileTypeId,
|
||||
summary: file.summary,
|
||||
dependencyCount: file.dependencies?.length || 0,
|
||||
isDeleted: !currentFiles.has(file.path), // 标记文件是否已删除
|
||||
}))
|
||||
|
||||
// 构建边数据
|
||||
const edges: Array<{ source: string; target: string; label?: string }> = []
|
||||
files.forEach(file => {
|
||||
if (file.dependencies && file.dependencies.length > 0) {
|
||||
file.dependencies.forEach(dep => {
|
||||
const targetId = pathToIdMap.get(dep.targetFilePath)
|
||||
if (targetId) {
|
||||
edges.push({
|
||||
source: String(file.id),
|
||||
target: String(targetId),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}),
|
||||
|
||||
// 获取文件的Git变更历史
|
||||
getFileGitHistory: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 先获取文件信息
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { path: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
// 获取Git历史
|
||||
try {
|
||||
const history = await getFileGitHistory(file.path)
|
||||
return history
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取Git历史失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取指定commit时文件的内容
|
||||
getFileContentAtCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
commitId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 先获取文件信息
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { path: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
// 获取指定commit的文件内容
|
||||
try {
|
||||
const content = await getFileContentAtCommit(file.path, input.commitId)
|
||||
return content
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取文件内容失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取文件在两个commit之间的差异
|
||||
getFileDiffBetweenCommits: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
oldCommitId: z.string(),
|
||||
newCommitId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 先获取文件信息
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { path: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
// 获取文件差异
|
||||
try {
|
||||
const diff = await getFileDiffBetweenCommits(file.path, input.oldCommitId, input.newCommitId)
|
||||
return diff
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取文件差异失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取目录树结构(包含文件和文件夹的summary)
|
||||
getDirectoryTree: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取所有已分析的文件(最新版本),只保留当前存在的文件
|
||||
const files = await ctx.db.devAnalyzedFile.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
summary: true,
|
||||
},
|
||||
orderBy: { lastAnalyzedAt: 'desc' },
|
||||
distinct: ['path'],
|
||||
})
|
||||
|
||||
// 获取所有已分析的文件夹
|
||||
const folders = await ctx.db.devAnalyzedFolder.findMany({
|
||||
select: {
|
||||
path: true,
|
||||
summary: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 构建路径到summary和id的映射
|
||||
const pathSummaryMap = new Map<string, string>()
|
||||
const pathIdMap = new Map<string, number>()
|
||||
files.forEach(file => {
|
||||
pathSummaryMap.set(file.path, file.summary)
|
||||
pathIdMap.set(file.path, file.id)
|
||||
})
|
||||
folders.forEach(folder => pathSummaryMap.set(folder.path, folder.summary))
|
||||
|
||||
// 构建文件树结构
|
||||
interface TreeNode {
|
||||
name: string
|
||||
path: string
|
||||
isFolder: boolean
|
||||
summary?: string
|
||||
fileId?: number
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
const root: TreeNode = {
|
||||
name: '',
|
||||
path: '',
|
||||
isFolder: true,
|
||||
summary: pathSummaryMap.get('/') || '项目根目录',
|
||||
children: [],
|
||||
}
|
||||
|
||||
// 收集所有需要的路径(文件及其父目录)
|
||||
const allPaths = new Set<string>()
|
||||
files.forEach(file => {
|
||||
allPaths.add(file.path)
|
||||
// 添加所有父目录
|
||||
const parts = file.path.split('/').filter(Boolean)
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
allPaths.add(parts.slice(0, i).join('/'))
|
||||
}
|
||||
})
|
||||
|
||||
// 添加已分析的文件夹路径
|
||||
folders.forEach(folder => {
|
||||
allPaths.add(folder.path)
|
||||
})
|
||||
|
||||
// 按路径深度排序
|
||||
const sortedPaths = Array.from(allPaths).sort((a, b) => {
|
||||
const depthDiff = a.split('/').length - b.split('/').length
|
||||
return depthDiff !== 0 ? depthDiff : a.localeCompare(b)
|
||||
})
|
||||
|
||||
// 路径到节点的映射
|
||||
const pathToNode = new Map<string, TreeNode>()
|
||||
pathToNode.set(root.path, root)
|
||||
|
||||
// 构建树结构
|
||||
const filePathSet = new Set(files.map(f => f.path))
|
||||
sortedPaths.forEach(path => {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const name = parts[parts.length - 1]!
|
||||
const parentPath = parts.length === 1 ? '' : parts.slice(0, -1).join('/')
|
||||
const isFile = filePathSet.has(path)
|
||||
|
||||
const node: TreeNode = {
|
||||
name,
|
||||
path,
|
||||
isFolder: !isFile,
|
||||
summary: pathSummaryMap.get(path),
|
||||
fileId: isFile ? pathIdMap.get(path) : undefined,
|
||||
children: isFile ? undefined : [],
|
||||
}
|
||||
|
||||
pathToNode.set(path, node)
|
||||
|
||||
// 添加到父节点
|
||||
const parentNode = pathToNode.get(parentPath)
|
||||
if (parentNode?.children) {
|
||||
parentNode.children.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// 递归排序:文件夹在前,然后按字母表顺序
|
||||
const sortChildren = (node: TreeNode) => {
|
||||
if (node.children?.length) {
|
||||
node.children.sort((a, b) =>
|
||||
a.isFolder !== b.isFolder ? (a.isFolder ? -1 : 1) : a.name.localeCompare(b.name)
|
||||
)
|
||||
node.children.forEach(sortChildren)
|
||||
}
|
||||
}
|
||||
|
||||
sortChildren(root)
|
||||
|
||||
return root
|
||||
}),
|
||||
|
||||
// 获取文件夹详情
|
||||
getFolderDetail: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const folder = await ctx.db.devAnalyzedFolder.findUnique({
|
||||
where: { path: input.path },
|
||||
})
|
||||
|
||||
if (!folder) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件夹不存在' })
|
||||
}
|
||||
|
||||
return folder
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevFileRouter = typeof devFileRouter;
|
||||
export type DevAnalyzedFile = inferProcedureOutput<DevFileRouter['listAnalyzedFiles']>['data'][number];
|
||||
export type FileTreeItem = inferProcedureOutput<DevFileRouter['getDirectoryTree']>;
|
||||
221
src/server/routers/dev/frontend-design.ts
Normal file
221
src/server/routers/dev/frontend-design.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { generateUIComponentDemo } from '@/server/agents/ui-demo-generator'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export const devFrontendDesignRouter = createTRPCRouter({
|
||||
// 获取UI组件列表
|
||||
getUIComponents: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
const components = await ctx.db.devAnalyzedFile.findMany({
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
summary: true,
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
distinct: ['path'], // 当存在多条记录拥有相同的distinct字段组合时,Prisma会返回这些记录中的第一条
|
||||
});
|
||||
|
||||
// 检查文件是否存在,过滤掉不存在的文件
|
||||
const validComponents = await Promise.all(
|
||||
components.map(async (component) => {
|
||||
if (!component.path.startsWith("src/components/")) {
|
||||
return null;
|
||||
}
|
||||
const fullPath = path.join(process.cwd(), component.path)
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
return component
|
||||
} catch {
|
||||
// 文件不存在,返回null
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return validComponents.filter(Boolean) as typeof components
|
||||
}),
|
||||
|
||||
// 生成UI组件演示代码
|
||||
generateComponentDemo: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
componentPaths: z.array(z.string()),
|
||||
prompt: z.string(),
|
||||
images: z.array(z.object({
|
||||
url: z.string(), // base64编码的图片数据
|
||||
mediaType: z.string(), // 图片MIME类型,如 image/png
|
||||
})).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 获取选中组件的源代码和导出信息
|
||||
const components = await ctx.db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
path: {
|
||||
in: input.componentPaths
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
path: true,
|
||||
fileName: true,
|
||||
content: true,
|
||||
summary: true,
|
||||
exportedMembers: true,
|
||||
},
|
||||
distinct: ['path']
|
||||
})
|
||||
|
||||
// 过滤掉没有内容的组件
|
||||
const validComponents = components.filter(c => c.content)
|
||||
|
||||
// 调用AI生成代码
|
||||
const generatedCode = await generateUIComponentDemo(
|
||||
validComponents.map(c => ({
|
||||
path: c.path,
|
||||
fileName: c.fileName,
|
||||
content: c.content!,
|
||||
summary: c.summary,
|
||||
})),
|
||||
input.prompt,
|
||||
input.images
|
||||
)
|
||||
|
||||
// 返回组件信息,包括导出的符号
|
||||
return {
|
||||
code: generatedCode,
|
||||
components: components.map(c => ({
|
||||
path: c.path,
|
||||
fileName: c.fileName,
|
||||
exportedMembers: c.exportedMembers as { name: string, type: string }[],
|
||||
}))
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取可用的registry列表
|
||||
getRegistries: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
const componentsJsonPath = path.join(process.cwd(), 'components.json')
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(componentsJsonPath, 'utf-8')
|
||||
const config = JSON.parse(content)
|
||||
|
||||
if (!config.registries || typeof config.registries !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
// 将registries对象转换为数组,并提取网站链接
|
||||
return Object.entries(config.registries).map(([name, url]) => {
|
||||
const urlStr = url as string
|
||||
// 提取网站根路径
|
||||
let websiteUrl = ''
|
||||
try {
|
||||
const urlObj = new URL(urlStr.replace('{name}.json', ''))
|
||||
websiteUrl = urlObj.origin
|
||||
// 如果域名以registry.开头,去掉这个前缀
|
||||
if (urlObj.hostname.startsWith('registry.')) {
|
||||
const newHostname = urlObj.hostname.replace(/^registry\./, '')
|
||||
websiteUrl = `${urlObj.protocol}//${newHostname}`
|
||||
}
|
||||
} catch {
|
||||
// URL解析失败,使用空字符串
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
url: urlStr,
|
||||
websiteUrl,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('读取components.json失败:', error)
|
||||
return []
|
||||
}
|
||||
}),
|
||||
|
||||
// 搜索组件
|
||||
searchComponents: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
registries: z.array(z.string()), // registry名称数组,如 ['@ai-elements', '@basecn']
|
||||
query: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
// 并行执行所有registry的搜索
|
||||
const results = await Promise.all(
|
||||
input.registries.map(async (registry) => {
|
||||
try {
|
||||
const command = `npx shadcn@latest search ${registry} --query "${input.query}" --limit 5`
|
||||
const { stdout } = await execAsync(command, {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000, // 30秒超时
|
||||
})
|
||||
|
||||
// 解析JSON格式的输出
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
|
||||
// 检查是否有items字段
|
||||
if (parsed.items && Array.isArray(parsed.items)) {
|
||||
const items = parsed.items.map((item: any) => ({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
addCommandArgument: item.addCommandArgument,
|
||||
}))
|
||||
|
||||
return { registry, items }
|
||||
} else {
|
||||
// 如果没有items字段,返回空数组
|
||||
return { registry, items: [] }
|
||||
}
|
||||
} catch (parseError) {
|
||||
// JSON解析失败
|
||||
return { registry, items: [], error: 'JSON解析失败' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 命令执行失败,记录错误但继续处理其他registry
|
||||
return { registry, items: [], error: error.message || '搜索失败' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}),
|
||||
|
||||
// 获取组件详细信息
|
||||
viewComponent: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
componentName: z.string(), // 组件名称,如 @shadcn/tabs
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const command = `npx shadcn@latest view ${input.componentName}`
|
||||
const { stdout } = await execAsync(command, {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000, // 30秒超时
|
||||
})
|
||||
|
||||
// 解析JSON格式的输出
|
||||
const parsed = JSON.parse(stdout)
|
||||
|
||||
// 返回解析后的数据
|
||||
return parsed
|
||||
} catch (error: any) {
|
||||
throw new Error(`获取组件详情失败: ${error.message}`)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevFrontendDesignRouter = typeof devFrontendDesignRouter;
|
||||
229
src/server/routers/dev/panel.ts
Normal file
229
src/server/routers/dev/panel.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { sendTmuxCommand } from '@/server/service/dev/terminal'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import {
|
||||
getBranches,
|
||||
getCommitHistory,
|
||||
createCommit,
|
||||
checkoutCommit,
|
||||
checkoutBranch,
|
||||
revertCommit,
|
||||
resetToCommit,
|
||||
getCurrentBranch,
|
||||
hasUncommittedChanges,
|
||||
} from '@/server/utils/git-helper'
|
||||
|
||||
|
||||
export const devPanelRouter = createTRPCRouter({
|
||||
/**
|
||||
* 发送tmux命令到终端
|
||||
*/
|
||||
sendTmuxCommand: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
command: z.string().min(1, '命令不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// 发送命令到tmux session,没必要验证输入,因为本来就是给开发者在开发模式下用的
|
||||
const result = await sendTmuxCommand(input.command)
|
||||
|
||||
return { success: true, output: result }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `执行命令失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取所有分支列表
|
||||
*/
|
||||
getBranches: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
try {
|
||||
const branches = await getBranches()
|
||||
return branches
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取分支列表失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取当前分支
|
||||
*/
|
||||
getCurrentBranch: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
try {
|
||||
const branch = await getCurrentBranch()
|
||||
return { branch }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取当前分支失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取提交历史
|
||||
*/
|
||||
getCommitHistory: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
limit: z.number().min(1).max(200).default(50),
|
||||
branchName: z.string().optional(), // 可选的分支名称,用于查看指定分支的历史
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const commits = await getCommitHistory(input.limit, input.branchName)
|
||||
return commits
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取提交历史失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 创建新提交
|
||||
*/
|
||||
createCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
message: z.string().min(1, '提交信息不能为空'),
|
||||
amend: z.boolean().optional().default(false),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const commitId = await createCommit(input.message, input.amend)
|
||||
return { success: true, commitId, message: input.amend ? '修订提交成功' : '提交成功' }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `创建提交失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 切换到指定提交
|
||||
*/
|
||||
checkoutCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
commitId: z.string().min(1, '提交ID不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// 检查是否有未提交的更改
|
||||
const hasChanges = await hasUncommittedChanges()
|
||||
if (hasChanges) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '有未提交的更改,请先提交或暂存更改',
|
||||
})
|
||||
}
|
||||
|
||||
await checkoutCommit(input.commitId)
|
||||
return { success: true, message: `已切换到提交: ${input.commitId}` }
|
||||
} catch (error: any) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `切换提交失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 切换到指定分支
|
||||
*/
|
||||
checkoutBranch: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
branchName: z.string().min(1, '分支名称不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// 检查是否有未提交的更改
|
||||
const hasChanges = await hasUncommittedChanges()
|
||||
if (hasChanges) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '有未提交的更改,请先提交或暂存更改',
|
||||
})
|
||||
}
|
||||
|
||||
await checkoutBranch(input.branchName)
|
||||
return { success: true, message: `已切换到分支: ${input.branchName}` }
|
||||
} catch (error: any) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `切换分支失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 反转提交(git revert)
|
||||
*/
|
||||
revertCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
commitId: z.string().min(1, '提交ID不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const newCommitId = await revertCommit(input.commitId)
|
||||
return { success: true, newCommitId, message: '已创建反转提交' }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `反转提交失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 强制回滚到指定提交(git reset --hard)
|
||||
*/
|
||||
resetToCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
commitId: z.string().min(1, '提交ID不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await resetToCommit(input.commitId)
|
||||
return { success: true, message: `已强制回滚到提交: ${input.commitId}` }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `强制回滚失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 检查是否有未提交的更改
|
||||
*/
|
||||
hasUncommittedChanges: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
try {
|
||||
const hasChanges = await hasUncommittedChanges()
|
||||
return { hasChanges }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `检查未提交更改失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevPanelRouter = typeof devPanelRouter
|
||||
37
src/server/routers/global.ts
Normal file
37
src/server/routers/global.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createTRPCRouter, publicProcedure } from "@/server/trpc";
|
||||
|
||||
/**
|
||||
* 用于处理系统全局配置和与业务关联不大的API
|
||||
*/
|
||||
export const globalRouter = createTRPCRouter({
|
||||
/**
|
||||
* 检查是否已显示过欢迎对话框
|
||||
*/
|
||||
checkWelcomeShown: publicProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const config = await ctx.db.kVConfig.findUnique({
|
||||
where: { key: 'welcome_shown' },
|
||||
});
|
||||
return { shown: !!config };
|
||||
}),
|
||||
|
||||
/**
|
||||
* 标记欢迎对话框已显示
|
||||
*/
|
||||
markWelcomeShown: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
await ctx.db.kVConfig.upsert({
|
||||
where: { key: 'welcome_shown' },
|
||||
create: {
|
||||
key: 'welcome_shown',
|
||||
value: 'true',
|
||||
},
|
||||
update: {
|
||||
value: 'true',
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
export type GlobalRouter = typeof globalRouter;
|
||||
205
src/server/routers/jobs.ts
Normal file
205
src/server/routers/jobs.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { tracked } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import type { Queue, QueueEvents } from 'bullmq'
|
||||
import {
|
||||
AnalyzeFilesProgress,
|
||||
analyzeFilesQueue,
|
||||
analyzeFilesQueueEvents,
|
||||
AnalyzePackagesProgress,
|
||||
analyzePackagesQueue,
|
||||
analyzePackagesQueueEvents,
|
||||
AnalyzeFoldersProgress,
|
||||
analyzeFoldersQueue,
|
||||
analyzeFoldersQueueEvents,
|
||||
} from '../queues'
|
||||
import { AsyncQueue } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* 任务状态类型
|
||||
*/
|
||||
export type TaskState = 'waiting' | 'active' | 'completed' | 'failed'
|
||||
|
||||
/**
|
||||
* 基础任务进度接口
|
||||
*/
|
||||
export interface BaseTaskProgress {
|
||||
jobId: string
|
||||
state: TaskState
|
||||
progressPercent: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 队列配置接口
|
||||
*/
|
||||
interface QueueConfig<TProgress> {
|
||||
queue: Queue
|
||||
events: QueueEvents
|
||||
calculateProgress: (progress: TProgress) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务进度订阅的工厂函数
|
||||
*/
|
||||
function createProgressSubscription<TProgress>(
|
||||
queueConfig: QueueConfig<TProgress>
|
||||
) {
|
||||
return permissionRequiredProcedure('')
|
||||
.input(
|
||||
z.object({
|
||||
jobId: z.string(),
|
||||
lastEventId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.subscription(async function* (opts) {
|
||||
const { jobId } = opts.input
|
||||
const { queue, events, calculateProgress } = queueConfig
|
||||
|
||||
// 创建异步队列用于推送数据
|
||||
const dataQueue = new AsyncQueue<BaseTaskProgress & TProgress>()
|
||||
|
||||
// 获取任务当前状态
|
||||
const job = await queue.getJob(jobId)
|
||||
if (!job) {
|
||||
yield tracked(jobId, {
|
||||
jobId,
|
||||
state: 'failed' as TaskState,
|
||||
progressPercent: 0,
|
||||
error: '任务不存在'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 发送初始状态
|
||||
const state = await job.getState()
|
||||
const progress = job.progress as TProgress
|
||||
|
||||
yield tracked(jobId, {
|
||||
jobId,
|
||||
state: state as TaskState,
|
||||
progressPercent: progress ? calculateProgress(progress) : 0,
|
||||
...(progress),
|
||||
...(job.failedReason && { error: job.failedReason })
|
||||
})
|
||||
|
||||
// 如果任务已经完成或失败,直接返回
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
return
|
||||
}
|
||||
|
||||
// 监听进度更新
|
||||
const progressListener = ({ jobId: eventJobId, data }: any) => {
|
||||
if (eventJobId === jobId) {
|
||||
const progressData = data as TProgress
|
||||
dataQueue.push({
|
||||
jobId,
|
||||
state: 'active' as TaskState,
|
||||
progressPercent: calculateProgress(progressData),
|
||||
...(progressData)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听完成
|
||||
const completedListener = async ({ jobId: eventJobId }: any) => {
|
||||
if (eventJobId === jobId) {
|
||||
const completedJob = await queue.getJob(jobId)
|
||||
if (completedJob) {
|
||||
const completedProgress = completedJob.progress as TProgress
|
||||
dataQueue.push({
|
||||
jobId,
|
||||
state: 'completed' as TaskState,
|
||||
progressPercent: 100,
|
||||
...(completedProgress)
|
||||
})
|
||||
}
|
||||
dataQueue.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听失败
|
||||
const failedListener = async ({ jobId: eventJobId, failedReason }: any) => {
|
||||
if (eventJobId === jobId) {
|
||||
const failedJob = await queue.getJob(jobId)
|
||||
const failedProgress = failedJob?.progress as TProgress
|
||||
dataQueue.push({
|
||||
jobId,
|
||||
state: 'failed' as TaskState,
|
||||
progressPercent: failedProgress ? calculateProgress(failedProgress) : 0,
|
||||
...(failedProgress),
|
||||
error: failedReason
|
||||
})
|
||||
dataQueue.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// 注册事件监听器
|
||||
events.on('progress', progressListener)
|
||||
events.on('completed', completedListener)
|
||||
events.on('failed', failedListener)
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
events.off('progress', progressListener)
|
||||
events.off('completed', completedListener)
|
||||
events.off('failed', failedListener)
|
||||
dataQueue.finish()
|
||||
}
|
||||
|
||||
// 监听客户端断开连接
|
||||
opts.signal?.addEventListener('abort', cleanup)
|
||||
|
||||
try {
|
||||
// 持续yield数据直到队列结束
|
||||
for await (const data of dataQueue) {
|
||||
yield tracked(jobId, data)
|
||||
}
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 队列配置映射
|
||||
*/
|
||||
const queueConfigs = {
|
||||
analyzeFiles: {
|
||||
queue: analyzeFilesQueue,
|
||||
events: analyzeFilesQueueEvents,
|
||||
calculateProgress: (progress: AnalyzeFilesProgress) => {
|
||||
return (progress.analyzedFiles !== undefined && progress.totalFiles)
|
||||
? Math.round(((progress.analyzedFiles + (progress.failedFiles ?? 0)) / progress.totalFiles) * 100)
|
||||
: 0
|
||||
}
|
||||
},
|
||||
analyzePackages: {
|
||||
queue: analyzePackagesQueue,
|
||||
events: analyzePackagesQueueEvents,
|
||||
calculateProgress: (progress: AnalyzePackagesProgress) => {
|
||||
return (progress.analyzedPackages !== undefined && progress.totalPackages)
|
||||
? Math.round(((progress.analyzedPackages + (progress.failedPackages ?? 0)) / progress.totalPackages) * 100)
|
||||
: 0
|
||||
}
|
||||
},
|
||||
analyzeFolders: {
|
||||
queue: analyzeFoldersQueue,
|
||||
events: analyzeFoldersQueueEvents,
|
||||
calculateProgress: (progress: AnalyzeFoldersProgress) => {
|
||||
return (progress.analyzedFolders !== undefined && progress.totalFolders)
|
||||
? Math.round(((progress.analyzedFolders + (progress.failedFolders ?? 0)) / progress.totalFolders) * 100)
|
||||
: 0
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 任务路由
|
||||
*/
|
||||
export const jobsRouter = createTRPCRouter({
|
||||
// 为每个队列创建订阅
|
||||
subscribeAnalyzeFilesProgress: createProgressSubscription(queueConfigs.analyzeFiles),
|
||||
subscribeAnalyzePackagesProgress: createProgressSubscription(queueConfigs.analyzePackages),
|
||||
subscribeAnalyzeFoldersProgress: createProgressSubscription(queueConfigs.analyzeFolders),
|
||||
})
|
||||
60
src/server/routers/selection.ts
Normal file
60
src/server/routers/selection.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "@/server/trpc";
|
||||
|
||||
export const selectionRouter = createTRPCRouter({
|
||||
// 记录用户选择
|
||||
logSelection: permissionRequiredProcedure('')
|
||||
.input(z.object({
|
||||
context: z.string(),
|
||||
optionId: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.selectionLog.create({
|
||||
data: {
|
||||
userId: ctx.session!.user.id,
|
||||
context: input.context,
|
||||
optionId: input.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// 获取选项优先级
|
||||
getOptionPriorities: permissionRequiredProcedure('')
|
||||
.input(z.object({
|
||||
context: z.string(),
|
||||
// 'personal' 代表当前用户, 'global' 代表所有用户
|
||||
scope: z.enum(['personal', 'global']),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const whereCondition = {
|
||||
context: input.context,
|
||||
// 如果是 personal,则只查询当前用户
|
||||
...(input.scope === 'personal' && { userId: ctx.session!.user.id }),
|
||||
};
|
||||
|
||||
const frequencies = await ctx.db.selectionLog.groupBy({
|
||||
by: ['optionId'],
|
||||
where: whereCondition,
|
||||
_count: {
|
||||
optionId: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
optionId: 'desc',
|
||||
},
|
||||
},
|
||||
take: 20, // 限制返回最常用的20个,避免数据量过大
|
||||
});
|
||||
|
||||
// 返回一个易于查找的Map,key为optionId,value为评估的选项优先级(暂时用频率)
|
||||
const frequencyMap = new Map<string, number>();
|
||||
for (const item of frequencies) {
|
||||
frequencyMap.set(item.optionId, item._count.optionId);
|
||||
}
|
||||
return frequencyMap;
|
||||
}),
|
||||
});
|
||||
|
||||
export type SelectionRouter = typeof selectionRouter;
|
||||
255
src/server/routers/upload.ts
Normal file
255
src/server/routers/upload.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc';
|
||||
import {
|
||||
generatePresignedPostPolicy,
|
||||
generatePresignedGetObject,
|
||||
type PresignedPostPolicyOptions,
|
||||
type PresignedGetObjectOptions
|
||||
} from '@/server/minio';
|
||||
import { TRPCError, inferProcedureInput, inferProcedureOutput } from '@trpc/server';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Permissions } from '@/constants/permissions';
|
||||
|
||||
|
||||
interface UploadConfig {
|
||||
/** 业务类型(category) public/开头是可以公开GetObject的 */
|
||||
category: string;
|
||||
/** 最大文件大小(字节),默认 100MB */
|
||||
maxSize?: number;
|
||||
/** 允许的文件类型(MIME类型,支持精确匹配和通配符如 'image/*'),默认允许所有类型 */
|
||||
allowedContentType?: string;
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 所需权限,默认为空字符串(需要登录但无特定权限要求) */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface SingleUploadConfig extends UploadConfig {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface BatchUploadConfig extends UploadConfig {}
|
||||
|
||||
interface DownloadConfig {
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 所需权限,默认为空字符串(需要登录但无特定权限要求) */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机哈希字符串
|
||||
*/
|
||||
function generateHash(length: number = 7): string {
|
||||
return randomBytes(Math.ceil(length / 2))
|
||||
.toString('hex')
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单文件上传接口的工厂函数
|
||||
*
|
||||
* 生成的对象路径格式:${category}/${userId}/${hash}_${fileName}
|
||||
*
|
||||
* @param config 单文件上传配置
|
||||
* @returns tRPC mutation procedure
|
||||
*/
|
||||
export function createSingleUploadProcedure(config: SingleUploadConfig) {
|
||||
const {
|
||||
category,
|
||||
maxSize = 100 * 1024 * 1024, // 默认 100MB
|
||||
allowedContentType,
|
||||
expirySeconds = 3600,
|
||||
permission = '',
|
||||
} = config;
|
||||
|
||||
return permissionRequiredProcedure(permission)
|
||||
.input(
|
||||
z.object({
|
||||
fileName: z.string().min(1, '文件名不能为空'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { fileName } = input;
|
||||
const userId = ctx.session!.user.id;
|
||||
const hash = generateHash();
|
||||
|
||||
// 单文件上传指定完整的文件路径
|
||||
const prefix = `${category}/${userId}`;
|
||||
const fullFileName = `${hash}_${fileName}`;
|
||||
|
||||
try {
|
||||
const policyOptions: PresignedPostPolicyOptions = {
|
||||
prefix,
|
||||
fileName: fullFileName,
|
||||
expirySeconds: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
|
||||
const result = await generatePresignedPostPolicy(policyOptions);
|
||||
|
||||
return {
|
||||
postURL: result.postURL,
|
||||
formData: result.formData,
|
||||
objectName: result.objectName,
|
||||
expiresIn: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('生成单文件上传策略失败:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '生成上传策略失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量上传接口的工厂函数
|
||||
*
|
||||
* 生成的对象路径格式:${category}/${userId}/${hash}/(前缀,客户端上传时需要在此前缀下)
|
||||
*
|
||||
* @param config 批量上传配置
|
||||
* @returns tRPC mutation procedure
|
||||
*/
|
||||
export function createBatchUploadProcedure(config: BatchUploadConfig) {
|
||||
const {
|
||||
category,
|
||||
maxSize = 100 * 1024 * 1024, // 默认 100MB
|
||||
allowedContentType,
|
||||
expirySeconds = 3600,
|
||||
permission = '',
|
||||
} = config;
|
||||
|
||||
return permissionRequiredProcedure(permission)
|
||||
.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.session!.user.id;
|
||||
const hash = generateHash();
|
||||
|
||||
// 批量上传只校验前缀,不指定具体文件名
|
||||
const prefix = `${category}/${userId}/${hash}`;
|
||||
|
||||
try {
|
||||
const policyOptions: PresignedPostPolicyOptions = {
|
||||
prefix,
|
||||
expirySeconds: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
|
||||
const result = await generatePresignedPostPolicy(policyOptions);
|
||||
|
||||
return {
|
||||
postURL: result.postURL,
|
||||
formData: result.formData,
|
||||
prefix: result.objectName,
|
||||
expiresIn: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('生成批量上传策略失败:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '生成上传策略失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建下载接口的工厂函数
|
||||
*
|
||||
* 生成预签名的 GET URL,用于下载对象
|
||||
* 文件名默认使用对象的 x-amz-meta-original-filename 元信息
|
||||
*
|
||||
* @param config 下载配置
|
||||
* @returns tRPC mutation procedure
|
||||
*/
|
||||
export function createDownloadProcedure(config: DownloadConfig) {
|
||||
const {
|
||||
expirySeconds = 3600,
|
||||
permission = '',
|
||||
} = config;
|
||||
|
||||
return permissionRequiredProcedure(permission)
|
||||
.input(
|
||||
z.object({
|
||||
objectName: z.string().min(1, '对象名称不能为空'),
|
||||
fileName: z.string().optional(),
|
||||
contentType: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const { objectName, fileName, contentType } = input;
|
||||
|
||||
try {
|
||||
const options: PresignedGetObjectOptions = {
|
||||
objectName,
|
||||
expirySeconds,
|
||||
responseHeaders: {},
|
||||
};
|
||||
|
||||
// 如果指定了自定义文件名,覆盖默认行为
|
||||
if (fileName) {
|
||||
options.responseHeaders!['response-content-disposition'] =
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
// 如果指定了自定义 Content-Type,覆盖默认行为
|
||||
if (contentType) {
|
||||
options.responseHeaders!['response-content-type'] = contentType;
|
||||
}
|
||||
|
||||
const result = await generatePresignedGetObject(options);
|
||||
|
||||
return {
|
||||
url: result.url,
|
||||
expiresIn: result.expiresIn,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('生成下载链接失败:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '生成下载链接失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传路由
|
||||
* 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口
|
||||
*/
|
||||
export const uploadRouter = createTRPCRouter({
|
||||
// 供给照片批量上传接口
|
||||
supplyPhotos: createBatchUploadProcedure({
|
||||
category: 'transfer/supply',
|
||||
maxSize: 1 * 1024 * 1024, // 1MB
|
||||
allowedContentType: 'image/*',
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
}),
|
||||
supplyPdfs: createBatchUploadProcedure({
|
||||
category: 'transfer/supply',
|
||||
maxSize: 1 * 1024 * 1024, // 1MB
|
||||
allowedContentType: 'application/pdf',
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
}),
|
||||
// 已上传的供给照片下载
|
||||
downloadSupplyPhotos: createDownloadProcedure({
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
})
|
||||
});
|
||||
|
||||
export type SingleUploadProcedureInput = inferProcedureInput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口调用参数
|
||||
export type SingleUploadProcedureOutput = inferProcedureOutput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口返回值
|
||||
export type BatchUploadProcedureInput = inferProcedureInput<ReturnType<typeof createBatchUploadProcedure>>; // createBatchUploadProcedure 创建的接口调用参数
|
||||
export type BatchUploadProcedureOutput = inferProcedureOutput<ReturnType<typeof createBatchUploadProcedure>>; // createBatchUploadProcedure 创建的接口返回值
|
||||
export type DownloadProcedureInput = inferProcedureInput<ReturnType<typeof createDownloadProcedure>>; // createDownloadProcedure 创建的接口调用参数
|
||||
export type DownloadProcedureOutput = inferProcedureOutput<ReturnType<typeof createDownloadProcedure>>; // createDownloadProcedure 创建的接口返回值
|
||||
380
src/server/routers/users.ts
Normal file
380
src/server/routers/users.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { Permissions } from '@/constants/permissions'
|
||||
import { createUserSchema, updateUserSchema, changePasswordSchema } from '@/lib/schema/user'
|
||||
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
|
||||
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
export const usersRouter = createTRPCRouter({
|
||||
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(dataTableQueryParamsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
|
||||
model: 'User',
|
||||
columns: {
|
||||
id: { field: 'id', variant: 'text', sortable: true },
|
||||
name: { field: 'name', variant: 'text', sortable: true },
|
||||
status: { field: 'status', variant: 'select', sortable: true },
|
||||
dept: { field: 'deptCode', variant: 'multiSelect', sortable: true },
|
||||
roles: { field: 'roles.some.id', variant: 'select', transform: Number },
|
||||
permissions: { field: 'roles.some.permissions.some.id', variant: 'select', transform: Number },
|
||||
lastLoginAt: { field: 'lastLoginAt', sortable: true },
|
||||
createdAt: { field: 'createdAt', sortable: true }
|
||||
},
|
||||
})
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.db.user.findMany({
|
||||
where,
|
||||
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
|
||||
skip,
|
||||
take,
|
||||
include: { roles: { include: { permissions: true } }, dept: true },
|
||||
}),
|
||||
ctx.db.user.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}),
|
||||
|
||||
getRoles: permissionRequiredProcedure(Permissions.USER_MANAGE).query(({ ctx }) =>
|
||||
ctx.db.role.findMany({ orderBy: { name: 'asc' } })
|
||||
),
|
||||
|
||||
getPermissions: permissionRequiredProcedure(Permissions.USER_MANAGE).query(({ ctx }) =>
|
||||
ctx.db.permission.findMany({ orderBy: { name: 'asc' } })
|
||||
),
|
||||
|
||||
// 角色管理相关API
|
||||
getRolesWithStats: permissionRequiredProcedure(Permissions.USER_MANAGE).query(async ({ ctx }) => {
|
||||
const roles = await ctx.db.role.findMany({
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
},
|
||||
orderBy: { id: 'asc' }
|
||||
})
|
||||
|
||||
return roles.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
userCount: role._count.users,
|
||||
permissions: role.permissions
|
||||
}))
|
||||
}),
|
||||
|
||||
createRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({
|
||||
name: z.string().min(1, '角色名称不能为空'),
|
||||
permissionIds: z.array(z.number())
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { name, permissionIds } = input
|
||||
|
||||
// 检查角色名是否已存在
|
||||
const existingRole = await ctx.db.role.findUnique({ where: { name } })
|
||||
if (existingRole) throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已存在' })
|
||||
|
||||
return ctx.db.role.create({
|
||||
data: {
|
||||
name,
|
||||
permissions: { connect: permissionIds.map(id => ({ id })) }
|
||||
},
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
updateRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().min(1, '角色名称不能为空'),
|
||||
permissionIds: z.array(z.number())
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, name, permissionIds } = input
|
||||
|
||||
// 检查角色是否存在
|
||||
const existingRole = await ctx.db.role.findUnique({ where: { id } })
|
||||
if (!existingRole) throw new TRPCError({ code: 'NOT_FOUND', message: '角色不存在' })
|
||||
|
||||
// 检查角色名是否被其他角色占用
|
||||
const roleWithSameName = await ctx.db.role.findUnique({ where: { name } })
|
||||
if (roleWithSameName && roleWithSameName.id !== id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
|
||||
}
|
||||
|
||||
return ctx.db.role.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
permissions: { set: permissionIds.map(permissionId => ({ id: permissionId })) }
|
||||
},
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input
|
||||
|
||||
// 检查角色是否存在
|
||||
const existingRole = await ctx.db.role.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { users: true } } }
|
||||
})
|
||||
if (!existingRole) throw new TRPCError({ code: 'NOT_FOUND', message: '角色不存在' })
|
||||
|
||||
// 检查是否有用户使用该角色
|
||||
if (existingRole._count.users > 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '该角色还有用户在使用,无法删除' })
|
||||
}
|
||||
|
||||
return ctx.db.role.delete({
|
||||
where: { id },
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
batchUpdateRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({
|
||||
roleId: z.number(),
|
||||
deptCodes: z.array(z.string()).optional(),
|
||||
action: z.enum(['grant', 'revoke'])
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roleId, deptCodes, action } = input
|
||||
|
||||
// 检查角色是否存在
|
||||
const role = await ctx.db.role.findUnique({ where: { id: roleId } })
|
||||
if (!role) throw new TRPCError({ code: 'NOT_FOUND', message: '角色不存在' })
|
||||
|
||||
// 构建查询条件
|
||||
const where: any = {}
|
||||
if (deptCodes && deptCodes.length > 0) {
|
||||
where.deptCode = { in: deptCodes }
|
||||
}
|
||||
|
||||
// 获取符合条件的用户
|
||||
const users = await ctx.db.user.findMany({ where, select: { id: true } })
|
||||
|
||||
// 分批处理,每批1000个用户
|
||||
const batchSize = 1000
|
||||
let processedCount = 0
|
||||
|
||||
for (let i = 0; i < users.length; i += batchSize) {
|
||||
const batch = users.slice(i, i + batchSize)
|
||||
|
||||
await Promise.all(
|
||||
batch.map(user =>
|
||||
ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
roles: action === 'grant'
|
||||
? { connect: { id: roleId } }
|
||||
: { disconnect: { id: roleId } }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
processedCount += batch.length
|
||||
}
|
||||
|
||||
return { count: processedCount }
|
||||
}),
|
||||
|
||||
create: permissionRequiredProcedure(Permissions.USER_MANAGE).input(createUserSchema).mutation(async ({ ctx, input }) => {
|
||||
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
|
||||
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (existingUser) throw new TRPCError({ code: 'BAD_REQUEST', message: '用户ID已存在' })
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12)
|
||||
|
||||
return ctx.db.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: name?.trim() || '',
|
||||
status: status?.trim() || null,
|
||||
deptCode: deptCode?.trim() || null,
|
||||
password: hashedPassword,
|
||||
isSuperAdmin,
|
||||
roles: { connect: roleIds.map(id => ({ id })) }
|
||||
},
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
update: permissionRequiredProcedure(Permissions.USER_MANAGE).input(updateUserSchema).mutation(async ({ ctx, input }) => {
|
||||
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
|
||||
|
||||
// 检查用户是否存在
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
|
||||
// 准备更新数据
|
||||
const updateData: any = {
|
||||
name: name?.trim() || '',
|
||||
status: status?.trim() || null,
|
||||
deptCode: deptCode?.trim() || null,
|
||||
isSuperAdmin,
|
||||
roles: { set: roleIds.map(roleId => ({ id: roleId })) }
|
||||
}
|
||||
|
||||
// 如果提供了密码,则更新密码
|
||||
if (password && password.trim()) {
|
||||
updateData.password = await bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
return ctx.db.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
return user
|
||||
}),
|
||||
|
||||
delete: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const { id } = input
|
||||
|
||||
// 检查用户是否存在
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
|
||||
// 防止用户删除自己
|
||||
if (ctx.session?.user?.id === id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
return ctx.db.user.delete({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
changePassword: permissionRequiredProcedure('')
|
||||
.input(changePasswordSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { oldPassword, newPassword } = input
|
||||
|
||||
// 获取当前用户ID
|
||||
const userId = ctx.session?.user?.id
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未登录' })
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await ctx.db.user.findUnique({ where: { id: userId } })
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isValidPassword = await bcrypt.compare(oldPassword, user.password)
|
||||
if (!isValidPassword) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '旧密码错误' })
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12)
|
||||
await ctx.db.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword }
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// 获取当前用户的完整资料
|
||||
getCurrentUserProfile: permissionRequiredProcedure('')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取当前用户ID
|
||||
const userId = ctx.session?.user?.id
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未登录' })
|
||||
}
|
||||
|
||||
// 获取用户完整信息
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
status: user.status,
|
||||
deptCode: user.deptCode,
|
||||
deptName: user.dept?.name || null,
|
||||
deptFullName: user.dept?.fullName || null,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
roles: user.roles.map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
})),
|
||||
permissions: Array.from(
|
||||
new Set(user.roles.flatMap(role =>
|
||||
role.permissions.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}))
|
||||
))
|
||||
).sort((a, b) => a.id - b.id),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export type UsersRouter = typeof usersRouter;
|
||||
export type User = inferProcedureOutput<UsersRouter['list']>['data'][number];
|
||||
export type UserProfile = inferProcedureOutput<UsersRouter['getCurrentUserProfile']>;
|
||||
175
src/server/service/dev/terminal.ts
Normal file
175
src/server/service/dev/terminal.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// 开发模式下自动启动的终端服务
|
||||
import { spawn, ChildProcess, exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
// 使用globalThis避免HMR导致重复启动
|
||||
const globalForTerminal = globalThis as unknown as {
|
||||
terminalProcess?: ChildProcess | null
|
||||
isStarting?: boolean
|
||||
}
|
||||
|
||||
if (!globalForTerminal.terminalProcess) {
|
||||
globalForTerminal.terminalProcess = null
|
||||
}
|
||||
if (globalForTerminal.isStarting === undefined) {
|
||||
globalForTerminal.isStarting = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动ttyd终端服务
|
||||
* 仅在开发环境下运行
|
||||
*/
|
||||
export function startTerminalService() {
|
||||
|
||||
// 只在开发环境启动
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return
|
||||
}
|
||||
|
||||
// 防止重复启动
|
||||
if (globalForTerminal.terminalProcess || globalForTerminal.isStarting) {
|
||||
console.log('[Terminal] 终端服务已在运行或正在启动中')
|
||||
return
|
||||
}
|
||||
globalForTerminal.isStarting = true
|
||||
|
||||
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
|
||||
const projectRoot = process.cwd()
|
||||
|
||||
console.log(`[Terminal] 正在启动终端服务,端口: ${port}`)
|
||||
|
||||
try {
|
||||
// 启动ttyd
|
||||
globalForTerminal.terminalProcess = spawn('ttyd', [
|
||||
'-p', port,
|
||||
'-t', 'titleFixed=开发终端',
|
||||
'-t', 'fontSize=14',
|
||||
'-i', '127.0.0.1',
|
||||
'--writable',
|
||||
'tmux', 'new', '-A',
|
||||
'-s', process.env.DEV_TERMINAL || 'nextdev',
|
||||
'-c', projectRoot,
|
||||
'zsh'
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
detached: false
|
||||
})
|
||||
|
||||
globalForTerminal.terminalProcess.stdout?.on('data', (data) => {
|
||||
console.log(`[Terminal] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
globalForTerminal.terminalProcess.stderr?.on('data', (data) => {
|
||||
const message = data.toString().trim()
|
||||
// ttyd的正常输出也会到stderr,所以不全是错误
|
||||
if (message.includes('error') || message.includes('Error')) {
|
||||
console.error(`[Terminal] 错误: ${message}`)
|
||||
} else {
|
||||
console.log(`[Terminal] ${message}`)
|
||||
}
|
||||
})
|
||||
|
||||
globalForTerminal.terminalProcess.on('error', (error) => {
|
||||
console.error('[Terminal] 启动失败:', error.message)
|
||||
if (error.message.includes('ENOENT')) {
|
||||
console.error('[Terminal] 请确保已安装ttyd: https://github.com/tsl0922/ttyd')
|
||||
}
|
||||
globalForTerminal.terminalProcess = null
|
||||
globalForTerminal.isStarting = false
|
||||
})
|
||||
|
||||
globalForTerminal.terminalProcess.on('exit', (code, signal) => {
|
||||
if (code !== null) {
|
||||
console.log(`[Terminal] 终端服务已退出,退出码: ${code}`)
|
||||
} else if (signal !== null) {
|
||||
console.log(`[Terminal] 终端服务被信号终止: ${signal}`)
|
||||
}
|
||||
globalForTerminal.terminalProcess = null
|
||||
globalForTerminal.isStarting = false
|
||||
})
|
||||
|
||||
// 启动成功
|
||||
setTimeout(() => {
|
||||
globalForTerminal.isStarting = false
|
||||
console.log(`[Terminal] 终端服务已启动: http://localhost:${port}`)
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Terminal] 启动异常:', error)
|
||||
globalForTerminal.terminalProcess = null
|
||||
globalForTerminal.isStarting = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送tmux命令到开发终端
|
||||
* @param command tmux命令(不包含tmux前缀)
|
||||
* @returns 命令执行结果
|
||||
*/
|
||||
export async function sendTmuxCommand(
|
||||
command: string
|
||||
): Promise<string> {
|
||||
const session = process.env.DEV_TERMINAL || 'nextdev'
|
||||
|
||||
try {
|
||||
// 将-t参数插入到command的第一个单词之后
|
||||
const parts = command.trim().split(/\s+/)
|
||||
const firstWord = parts[0]
|
||||
const restCommand = parts.slice(1).join(' ')
|
||||
const fullCommand = restCommand
|
||||
? `tmux ${firstWord} -t ${session} ${restCommand}`
|
||||
: `tmux ${firstWord} -t ${session}`
|
||||
|
||||
const { stdout, stderr } = await execAsync(fullCommand, {
|
||||
timeout: 5000, // 5秒超时
|
||||
})
|
||||
|
||||
if (stderr && !stderr.includes('no server running')) {
|
||||
console.warn(`[Terminal] 命令执行警告: ${stderr}`)
|
||||
}
|
||||
|
||||
return stdout.trim()
|
||||
} catch (error: any) {
|
||||
console.error(`[Terminal] 命令执行失败: ${error.message}`)
|
||||
throw new Error(`执行tmux命令失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止终端服务
|
||||
*/
|
||||
export function stopTerminalService() {
|
||||
if (globalForTerminal.terminalProcess) {
|
||||
console.log('[Terminal] 正在停止终端服务...')
|
||||
globalForTerminal.terminalProcess.kill('SIGTERM')
|
||||
globalForTerminal.terminalProcess = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取终端服务状态
|
||||
*/
|
||||
export function getTerminalStatus() {
|
||||
return {
|
||||
isRunning: globalForTerminal.terminalProcess !== null,
|
||||
isStarting: globalForTerminal.isStarting || false,
|
||||
port: process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
|
||||
}
|
||||
}
|
||||
|
||||
// 进程退出时清理
|
||||
process.on('exit', () => {
|
||||
stopTerminalService()
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
stopTerminalService()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
stopTerminalService()
|
||||
process.exit(0)
|
||||
})
|
||||
63
src/server/trpc.ts
Normal file
63
src/server/trpc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'server-only'
|
||||
import { initTRPC, TRPCError } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
import { db } from '@/server/db'
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/server/auth"
|
||||
import { evaluatePermissionExpression } from '@/constants/permissions'
|
||||
|
||||
// 创建上下文
|
||||
export const createTRPCContext = async () => {
|
||||
const session = await getServerSession(authOptions)
|
||||
return {
|
||||
db,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createTRPCContext>>
|
||||
|
||||
// 初始化tRPC,配置SSE支持
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape }) {
|
||||
return shape
|
||||
},
|
||||
// SSE配置
|
||||
sse: {
|
||||
ping: {
|
||||
enabled: true,
|
||||
intervalMs: 2000, // 每2秒发送一次ping保持连接
|
||||
},
|
||||
client: {
|
||||
reconnectAfterInactivityMs: 5000, // 5秒无消息后重连
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 导出路由创建器和过程创建器
|
||||
export const createTRPCRouter = t.router
|
||||
// 公开的 Procedure,不需要任何权限
|
||||
export const publicProcedure = t.procedure
|
||||
|
||||
|
||||
// 权限校验中间件,根据权限表达式判断用户是否有权限,可以用&、|、()组合多个权限字符串
|
||||
const requirePermission = (permission: string) =>
|
||||
t.middleware(async ({ ctx, next }) => {
|
||||
const session = ctx.session
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" })
|
||||
}
|
||||
const userPermissions = session.user.permissions as string[]
|
||||
if (
|
||||
!session.user.isSuperAdmin &&
|
||||
!evaluatePermissionExpression(permission, userPermissions)
|
||||
) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "没有权限" })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
// 按权限创建的 Procedure,接收权限表达式
|
||||
export const permissionRequiredProcedure = (permission: string) =>
|
||||
t.procedure.use(requirePermission(permission))
|
||||
292
src/server/utils/ast-helper.ts
Normal file
292
src/server/utils/ast-helper.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as ts from 'typescript'
|
||||
|
||||
/**
|
||||
* 分析 TypeScript/JavaScript 文件的导出成员和依赖项
|
||||
*/
|
||||
export interface CodeAnalysisResult {
|
||||
exportedMembers: Array<{ name: string; type: string }>
|
||||
dependencies: string[]
|
||||
pkgDependencies: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析导入路径为项目根目录的绝对路径
|
||||
*/
|
||||
async function resolveImportPath(importPath: string, currentFilePath: string): Promise<string | null> {
|
||||
// 只处理项目内部导入(以 @/、./、../ 开头)
|
||||
if (!importPath.startsWith('@/') && !importPath.startsWith('./') && !importPath.startsWith('../')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd()
|
||||
const currentDir = path.dirname(path.join(projectRoot, currentFilePath))
|
||||
|
||||
let resolvedPath: string
|
||||
|
||||
if (importPath.startsWith('@/')) {
|
||||
// @/ 别名指向 src 目录
|
||||
resolvedPath = path.join(projectRoot, 'src', importPath.slice(2))
|
||||
} else {
|
||||
// 相对路径
|
||||
resolvedPath = path.resolve(currentDir, importPath)
|
||||
}
|
||||
|
||||
// 转换为相对于项目根目录的路径
|
||||
let relativePath = path.relative(projectRoot, resolvedPath)
|
||||
|
||||
// 尝试添加常见的文件扩展名
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.d.ts']
|
||||
|
||||
// 检查是否已经有扩展名
|
||||
if (path.extname(relativePath)) {
|
||||
try {
|
||||
await fs.access(path.join(projectRoot, relativePath))
|
||||
return relativePath
|
||||
} catch {
|
||||
// 文件不存在,继续尝试其他扩展名
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试添加扩展名
|
||||
for (const ext of extensions) {
|
||||
const pathWithExt = relativePath + ext
|
||||
try {
|
||||
await fs.access(path.join(projectRoot, pathWithExt))
|
||||
return pathWithExt
|
||||
} catch {
|
||||
// 文件不存在,继续尝试
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 index 文件
|
||||
for (const ext of extensions) {
|
||||
const indexPath = path.join(relativePath, `index${ext}`)
|
||||
try {
|
||||
await fs.access(path.join(projectRoot, indexPath))
|
||||
return indexPath
|
||||
} catch {
|
||||
// 文件不存在,继续尝试
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都找不到,返回原始相对路径
|
||||
return relativePath
|
||||
}
|
||||
|
||||
/**
|
||||
* 从导入路径中提取包名
|
||||
* 支持普通包名(如 'react')和作用域包名(如 '@tanstack/react-table')
|
||||
*/
|
||||
function extractPackageName(importPath: string): string | null {
|
||||
// 排除项目内部导入
|
||||
if (importPath.startsWith('@/') || importPath.startsWith('./') || importPath.startsWith('../')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 作用域包名:@scope/package 或 @scope/package/subpath
|
||||
if (importPath.startsWith('@')) {
|
||||
const match = importPath.match(/^(@[^/]+\/[^/]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// 普通包名:package 或 package/subpath
|
||||
const match = importPath.match(/^([^/]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否有 export 修饰符
|
||||
*/
|
||||
function hasExportModifier(node: ts.Node): boolean {
|
||||
const modifiersNode = node as ts.Node & { modifiers?: ts.NodeArray<ts.ModifierLike> }
|
||||
if (!modifiersNode.modifiers) return false
|
||||
return modifiersNode.modifiers.some((modifier: ts.ModifierLike) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 TypeScript Compiler API 分析代码文件
|
||||
*/
|
||||
export async function analyzeCodeFile(filePath: string): Promise<CodeAnalysisResult> {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
|
||||
// 只处理 JS/JSX/TS/TSX 文件
|
||||
if (!['.js', '.jsx', '.ts', '.tsx'].includes(ext)) {
|
||||
return {
|
||||
exportedMembers: [],
|
||||
dependencies: [],
|
||||
pkgDependencies: [],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath)
|
||||
const sourceCode = await fs.readFile(fullPath, 'utf-8')
|
||||
|
||||
// 创建 SourceFile
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceCode,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
)
|
||||
|
||||
const exportedMembers: Array<{ name: string; type: string }> = []
|
||||
const dependencies = new Set<string>()
|
||||
const pkgDependencies = new Set<string>()
|
||||
|
||||
// 遍历 AST 节点
|
||||
async function visit(node: ts.Node): Promise<void> {
|
||||
// 分析导出
|
||||
if (ts.isExportDeclaration(node)) {
|
||||
// export { foo, bar } from './module'
|
||||
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
||||
node.exportClause.elements.forEach(element => {
|
||||
exportedMembers.push({
|
||||
name: element.name.text,
|
||||
type: 'other',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 from 子句
|
||||
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const importPath = node.moduleSpecifier.text
|
||||
const resolved = await resolveImportPath(importPath, filePath)
|
||||
if (resolved) {
|
||||
dependencies.add(resolved)
|
||||
}
|
||||
}
|
||||
} else if (ts.isExportAssignment(node)) {
|
||||
// export default ...
|
||||
exportedMembers.push({
|
||||
name: 'default',
|
||||
type: 'other',
|
||||
})
|
||||
} else if (ts.isFunctionDeclaration(node) && hasExportModifier(node)) {
|
||||
// export function foo() {}
|
||||
if (node.name) {
|
||||
exportedMembers.push({
|
||||
name: node.name.text,
|
||||
type: 'function',
|
||||
})
|
||||
}
|
||||
} else if (ts.isClassDeclaration(node) && hasExportModifier(node)) {
|
||||
// export class Foo {}
|
||||
if (node.name) {
|
||||
exportedMembers.push({
|
||||
name: node.name.text,
|
||||
type: 'component',
|
||||
})
|
||||
}
|
||||
} else if (ts.isVariableStatement(node) && hasExportModifier(node)) {
|
||||
// export const foo = ...
|
||||
node.declarationList.declarations.forEach(decl => {
|
||||
if (ts.isIdentifier(decl.name)) {
|
||||
// 判断是否为 React 组件
|
||||
let type = 'constant'
|
||||
if (decl.initializer) {
|
||||
if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
|
||||
type = 'function'
|
||||
// 检查是否返回 JSX
|
||||
const returnType = decl.initializer.body
|
||||
if (returnType && (ts.isJsxElement(returnType) || ts.isJsxFragment(returnType) || ts.isJsxSelfClosingElement(returnType))) {
|
||||
type = 'component'
|
||||
}
|
||||
}
|
||||
}
|
||||
exportedMembers.push({
|
||||
name: decl.name.text,
|
||||
type,
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (ts.isInterfaceDeclaration(node) && hasExportModifier(node)) {
|
||||
// export interface Foo {}
|
||||
exportedMembers.push({
|
||||
name: node.name.text,
|
||||
type: 'type',
|
||||
})
|
||||
} else if (ts.isTypeAliasDeclaration(node) && hasExportModifier(node)) {
|
||||
// export type Foo = ...
|
||||
exportedMembers.push({
|
||||
name: node.name.text,
|
||||
type: 'type',
|
||||
})
|
||||
} else if (ts.isEnumDeclaration(node) && hasExportModifier(node)) {
|
||||
// export enum Foo {}
|
||||
exportedMembers.push({
|
||||
name: node.name.text,
|
||||
type: 'constant',
|
||||
})
|
||||
}
|
||||
|
||||
// 分析导入依赖
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const importPath = node.moduleSpecifier.text
|
||||
const resolved = await resolveImportPath(importPath, filePath)
|
||||
if (resolved) {
|
||||
dependencies.add(resolved)
|
||||
} else {
|
||||
// 如果不是项目内部导入,则为包依赖
|
||||
// 提取包名(支持普通包名和作用域包名)
|
||||
const pkgName = extractPackageName(importPath)
|
||||
if (pkgName) {
|
||||
pkgDependencies.add(pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 分析动态导入 import()
|
||||
if (ts.isCallExpression(node)) {
|
||||
// 检查是否为 import() 调用
|
||||
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
// import() 的第一个参数应该是模块路径
|
||||
if (node.arguments.length > 0) {
|
||||
const arg = node.arguments[0]
|
||||
// 只处理字符串字面量参数
|
||||
if (ts.isStringLiteral(arg)) {
|
||||
const importPath = arg.text
|
||||
const resolved = await resolveImportPath(importPath, filePath)
|
||||
if (resolved) {
|
||||
dependencies.add(resolved)
|
||||
} else {
|
||||
// 如果不是项目内部导入,则为包依赖
|
||||
const pkgName = extractPackageName(importPath)
|
||||
if (pkgName) {
|
||||
pkgDependencies.add(pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 递归遍历子节点
|
||||
const promises: Promise<void>[] = []
|
||||
ts.forEachChild(node, (child) => {
|
||||
promises.push(visit(child))
|
||||
})
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
// 开始遍历
|
||||
await visit(sourceFile)
|
||||
|
||||
return {
|
||||
exportedMembers,
|
||||
dependencies: Array.from(dependencies),
|
||||
pkgDependencies: Array.from(pkgDependencies),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`分析文件 ${filePath} 失败:`, error)
|
||||
return {
|
||||
exportedMembers: [],
|
||||
dependencies: [],
|
||||
pkgDependencies: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/server/utils/data-table-helper.ts
Normal file
168
src/server/utils/data-table-helper.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { DataTableQueryParams } from "@/lib/schema/data-table";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { startOfDay, endOfDay } from "date-fns";
|
||||
import type { FilterVariant } from "@/constants/data-table";
|
||||
|
||||
// 定义列的配置接口
|
||||
export interface ColumnConfig<T extends Prisma.ModelName> {
|
||||
// 该列对应的 Prisma 模型字段名,支持通过点号表示关联关系,例如 "user.name"
|
||||
field: string;
|
||||
// 该列的过滤器类型
|
||||
variant?: FilterVariant;
|
||||
// 对该列的值进行转换,例如将字符串转换为枚举类型
|
||||
transform?: (value: any) => any;
|
||||
// 是否可排序,必须显式设置为 true 的列才能进行排序
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
// 数据表格的完整配置
|
||||
export type DataTablePrismaConfig<T extends Prisma.ModelName> = {
|
||||
// Prisma 模型名称,用于类型提示
|
||||
model: T;
|
||||
// 各列的配置
|
||||
columns: Record<string, ColumnConfig<T>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 DataTableQueryParams 转换为 Prisma 查询参数
|
||||
* @param params DataTable 查询参数
|
||||
* @param config 列定义配置
|
||||
* @returns Prisma 查询参数
|
||||
*/
|
||||
export function transformDataTableQueryParams<T extends Prisma.ModelName>(
|
||||
params: DataTableQueryParams,
|
||||
config: DataTablePrismaConfig<T>,
|
||||
) {
|
||||
const { page, pageSize, sorting, filters } = params;
|
||||
|
||||
// 1. 处理分页
|
||||
const skip = (page - 1) * pageSize;
|
||||
const take = pageSize;
|
||||
|
||||
// 2. 处理排序
|
||||
const orderBy = sorting
|
||||
.filter(({ id }) => config.columns[id]?.sortable === true)
|
||||
.map(({ id, desc }) => {
|
||||
const fieldPath = config.columns[id]?.field ?? id;
|
||||
const path = fieldPath.split(".");
|
||||
let curr: any = { [path.pop()!]: desc ? "desc" : "asc" };
|
||||
while (path.length) {
|
||||
curr = { [path.pop()!]: curr };
|
||||
}
|
||||
return curr;
|
||||
});
|
||||
|
||||
// 3. 处理过滤
|
||||
const where: Record<string, any> = {};
|
||||
|
||||
for (const filter of filters) {
|
||||
const { id, value } = filter;
|
||||
const columnConfig = config.columns[id];
|
||||
|
||||
if (!columnConfig?.variant) continue;
|
||||
|
||||
const { field, variant, transform } = columnConfig;
|
||||
const fieldPath = field.split(".");
|
||||
const finalField = fieldPath[fieldPath.length - 1]!;
|
||||
|
||||
// 用于存储最终字段的查询条件
|
||||
let fieldCondition: Record<string, any> | undefined;
|
||||
|
||||
// 根据不同的过滤器类型生成查询条件
|
||||
switch (variant) {
|
||||
case "text":
|
||||
if (typeof value === "string" && value) {
|
||||
fieldCondition = { contains: value, mode: "insensitive" };
|
||||
}
|
||||
break;
|
||||
|
||||
case "number": {
|
||||
const num = Number(value);
|
||||
if (!Number.isNaN(num)) {
|
||||
fieldCondition = { equals: transform ? transform(num) : num };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "range":
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
const [min, max] = value as [number, number];
|
||||
fieldCondition = { gte: min, lte: max };
|
||||
}
|
||||
break;
|
||||
|
||||
case "date": {
|
||||
if (value instanceof Date) {
|
||||
fieldCondition = {
|
||||
gte: startOfDay(value),
|
||||
lte: endOfDay(value),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "dateRange":
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
const [from, to] = value as [Date | undefined, Date | undefined];
|
||||
const rangeCondition: { gte?: Date; lte?: Date } = {};
|
||||
if (from instanceof Date) rangeCondition.gte = startOfDay(from);
|
||||
if (to instanceof Date) rangeCondition.lte = endOfDay(to);
|
||||
if (Object.keys(rangeCondition).length > 0) {
|
||||
fieldCondition = rangeCondition;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "select":
|
||||
if (Array.isArray(value) && value.length === 1 && value[0] !== undefined) {
|
||||
const singleValue = value[0];
|
||||
fieldCondition = {
|
||||
equals: transform ? transform(singleValue) : singleValue,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case "multiSelect":
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const transformedValue = transform ? value.map(transform) : value;
|
||||
fieldCondition = { in: transformedValue };
|
||||
}
|
||||
break;
|
||||
|
||||
case "boolean":
|
||||
if (Array.isArray(value) && value.length === 1 && value[0] !== undefined) {
|
||||
fieldCondition = { equals: value[0] === "true" };
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// 只有在有有效的查询条件时才构建嵌套路径
|
||||
if (fieldCondition) {
|
||||
const condition: Record<string, any> = {};
|
||||
let currentWhere = condition;
|
||||
|
||||
// 支持嵌套字段的查询条件构造
|
||||
for (let i = 0; i < fieldPath.length - 1; i++) {
|
||||
const part = fieldPath[i]!;
|
||||
currentWhere[part] = {};
|
||||
currentWhere = currentWhere[part];
|
||||
}
|
||||
|
||||
// 设置最终字段的查询条件
|
||||
currentWhere[finalField] = fieldCondition;
|
||||
|
||||
// 合并查询条件
|
||||
Object.assign(where, condition);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
};
|
||||
}
|
||||
634
src/server/utils/git-helper.ts
Normal file
634
src/server/utils/git-helper.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* 获取项目中所有需要分析的文件
|
||||
*/
|
||||
export async function getProjectFiles(): Promise<string[]> {
|
||||
try {
|
||||
// 使用git ls-files获取所有被git管理的文件
|
||||
const { stdout } = await execAsync('git -c core.quotePath=false ls-files;git -c core.quotePath=false ls-files --others --exclude-standard', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
const allFiles = stdout.trim().split('\n').filter(Boolean)
|
||||
// 过滤规则
|
||||
const excludePatterns = [
|
||||
/^\./, // 以.开头的文件和文件夹
|
||||
/^public\//, // public目录
|
||||
/^node_modules\//, // node_modules目录
|
||||
/^package-lock\.json$/, // package-lock.json
|
||||
/^AGENTS\.md$/, // AGENTS.md
|
||||
/^README\.md$/, // README.md
|
||||
/^TASKS\.md$/, // TASKS.md
|
||||
]
|
||||
|
||||
return allFiles
|
||||
.filter((file: string) => {
|
||||
return !excludePatterns.some(pattern => pattern.test(file))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error)
|
||||
throw new Error('无法获取项目文件列表')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近一次提交的 commit ID(前7位),没有返回空字符串
|
||||
*/
|
||||
export async function getLatestCommitId(): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --short=7 HEAD', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim() || ''
|
||||
} catch (error) {
|
||||
console.error('获取最近提交ID失败:', error)
|
||||
return '' // 如果没有提交历史,返回空字符串
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git提交信息
|
||||
*/
|
||||
export interface FileCommitInfo {
|
||||
commitId: string
|
||||
isDeleted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 git 状态和对应的 commitId
|
||||
* @returns { commitId: string, isDeleted: boolean }
|
||||
*/
|
||||
export async function getFileCommitInfo(filePath: string): Promise<FileCommitInfo> {
|
||||
try {
|
||||
const latestCommit = await getLatestCommitId()
|
||||
|
||||
// 检查文件是否被删除(不在工作区中)
|
||||
const fullPath = path.join(process.cwd(), filePath)
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
} catch {
|
||||
return { commitId: '', isDeleted: true }
|
||||
}
|
||||
|
||||
// 使用 git status --porcelain 检查文件状态
|
||||
const { stdout } = await execAsync(`git status --porcelain "${filePath}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
const statusOutput = stdout.trim()
|
||||
|
||||
// 如果文件状态为空,说明文件未修改
|
||||
if (!statusOutput) {
|
||||
return { commitId: latestCommit, isDeleted: false }
|
||||
}
|
||||
|
||||
// 检查状态码
|
||||
const statusCode = statusOutput.substring(0, 2)
|
||||
|
||||
// D 开头表示文件被删除
|
||||
if (statusCode.includes('D')) {
|
||||
return { commitId: '', isDeleted: true }
|
||||
}
|
||||
|
||||
// 文件已修改或未跟踪,commitId 后面加 *
|
||||
return { commitId: `${latestCommit}*`, isDeleted: false }
|
||||
} catch (error) {
|
||||
throw new Error(`获取文件 ${filePath} 的 git 状态失败:${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件在两个commit之间是否有修改
|
||||
* @param filePath 文件路径
|
||||
* @param oldCommitId 旧的commit ID(不带*)
|
||||
* @param newCommitId 新的commit ID(不带*)
|
||||
* @returns true表示文件有修改,false表示无修改
|
||||
*/
|
||||
export async function hasFileChangedBetweenCommits(filePath: string, oldCommitId: string, newCommitId: string): Promise<boolean> {
|
||||
try {
|
||||
if (oldCommitId === newCommitId) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果旧commit为空,说明是新文件
|
||||
if (!oldCommitId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 使用 git diff 检查文件在两个commit之间是否有变化
|
||||
// --quiet 选项:如果有差异则返回1,无差异返回0
|
||||
await execAsync(
|
||||
`git diff --quiet "${oldCommitId}" "${newCommitId}" -- "${filePath}"`,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
|
||||
// 如果命令成功执行(返回0),说明文件无变化
|
||||
return false
|
||||
} catch (error: any) {
|
||||
// git diff --quiet 在有差异时会抛出错误(退出码1)
|
||||
// 这是正常情况,表示文件有变化
|
||||
if (error.code === 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 其他错误(如commit不存在等)
|
||||
console.error(`检查文件 ${filePath} 在commit ${oldCommitId}..${newCommitId} 之间的变化失败:`, error)
|
||||
// 出错时保守处理,认为文件有变化,需要重新分析
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git文件变更记录
|
||||
*/
|
||||
export interface FileGitHistory {
|
||||
commitId: string
|
||||
timestamp: Date
|
||||
action: 'added' | 'modified' | 'renamed' | 'deleted'
|
||||
oldPath?: string // 重命名时的旧路径
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的Git变更历史
|
||||
* @param filePath 文件路径
|
||||
* @returns 文件的所有变更记录,按时间倒序排列
|
||||
*/
|
||||
export async function getFileGitHistory(filePath: string): Promise<FileGitHistory[]> {
|
||||
try {
|
||||
// 使用 git log 获取文件的所有提交历史
|
||||
// --follow: 跟踪文件重命名
|
||||
// --name-status: 显示文件状态(A=新增, M=修改, R=重命名, D=删除)
|
||||
// --format='%H|%aI': 输出格式为 commitId|时间戳
|
||||
const { stdout } = await execAsync(
|
||||
`git log --follow --name-status --format='%H|%aI' -- "${filePath}"`,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n')
|
||||
const history: FileGitHistory[] = []
|
||||
let currentCommit: { id: string; timestamp: Date } | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
// 解析提交信息行(格式:commitId|timestamp)
|
||||
if (line.includes('|')) {
|
||||
const [commitId, timestamp] = line.split('|')
|
||||
if (commitId && timestamp) {
|
||||
currentCommit = {
|
||||
id: commitId.substring(0, 7), // 取前7位
|
||||
timestamp: new Date(timestamp),
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析文件状态行
|
||||
if (currentCommit) {
|
||||
const parts = line.split('\t')
|
||||
const status = parts[0]?.trim()
|
||||
|
||||
if (!status) continue
|
||||
|
||||
// 处理不同的状态
|
||||
if (status === 'A') {
|
||||
// 新增
|
||||
history.push({
|
||||
commitId: currentCommit.id,
|
||||
timestamp: currentCommit.timestamp,
|
||||
action: 'added',
|
||||
})
|
||||
} else if (status === 'M') {
|
||||
// 修改
|
||||
history.push({
|
||||
commitId: currentCommit.id,
|
||||
timestamp: currentCommit.timestamp,
|
||||
action: 'modified',
|
||||
})
|
||||
} else if (status === 'D') {
|
||||
// 删除
|
||||
history.push({
|
||||
commitId: currentCommit.id,
|
||||
timestamp: currentCommit.timestamp,
|
||||
action: 'deleted',
|
||||
})
|
||||
} else if (status.startsWith('R')) {
|
||||
// 重命名(格式:R100 oldPath newPath)
|
||||
const oldPath = parts[1]
|
||||
history.push({
|
||||
commitId: currentCommit.id,
|
||||
timestamp: currentCommit.timestamp,
|
||||
action: 'renamed',
|
||||
oldPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return history
|
||||
} catch (error) {
|
||||
console.error(`获取文件 ${filePath} 的Git历史失败:`, error)
|
||||
throw new Error(`无法获取文件Git历史: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定commit时文件的内容
|
||||
* @param filePath 文件路径
|
||||
* @param commitId commit ID
|
||||
* @returns 文件内容,如果文件不存在则返回null
|
||||
*/
|
||||
export async function getFileContentAtCommit(filePath: string, commitId: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git show "${commitId}:${filePath}"`,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
return stdout
|
||||
} catch (error: any) {
|
||||
// 如果文件在该commit不存在,git show会返回错误
|
||||
if (error.code === 128 || error.message?.includes('does not exist')) {
|
||||
return null
|
||||
}
|
||||
console.error(`获取文件 ${filePath} 在commit ${commitId} 的内容失败:`, error)
|
||||
throw new Error(`无法获取文件内容: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件在两个commit之间的差异
|
||||
* @param filePath 文件路径
|
||||
* @param oldCommitId 旧commit ID(可以为空字符串,表示文件是新增的)
|
||||
* @param newCommitId 新commit ID
|
||||
* @returns diff内容
|
||||
*/
|
||||
export async function getFileDiffBetweenCommits(
|
||||
filePath: string,
|
||||
oldCommitId: string,
|
||||
newCommitId: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 如果旧commit为空,说明是新增文件,显示整个文件作为新增
|
||||
if (!oldCommitId) {
|
||||
const content = await getFileContentAtCommit(filePath, newCommitId)
|
||||
if (!content) return ''
|
||||
|
||||
// 构造一个类似git diff的输出格式
|
||||
const lines = content.split('\n')
|
||||
const diffLines = [
|
||||
`diff --git a/${filePath} b/${filePath}`,
|
||||
'new file mode 100644',
|
||||
`--- /dev/null`,
|
||||
`+++ b/${filePath}`,
|
||||
`@@ -0,0 +1,${lines.length} @@`,
|
||||
...lines.map(line => `+${line}`)
|
||||
]
|
||||
return diffLines.join('\n')
|
||||
}
|
||||
|
||||
// 使用 git diff 获取两个commit之间的差异
|
||||
const { stdout } = await execAsync(
|
||||
`git diff "${oldCommitId}" "${newCommitId}" -- "${filePath}"`,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
|
||||
return stdout || '无变化'
|
||||
} catch (error) {
|
||||
console.error(`获取文件 ${filePath} 在commit ${oldCommitId}..${newCommitId} 之间的差异失败:`, error)
|
||||
throw new Error(`无法获取文件差异: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git分支信息
|
||||
*/
|
||||
export interface GitBranch {
|
||||
name: string
|
||||
isCurrent: boolean
|
||||
lastCommitId: string
|
||||
lastCommitMessage: string
|
||||
lastCommitDate: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分支列表,排除HEAD detached分支
|
||||
*/
|
||||
export async function getBranches(): Promise<GitBranch[]> {
|
||||
try {
|
||||
// 获取所有分支及其最后一次提交信息
|
||||
const { stdout } = await execAsync(
|
||||
'git branch -a --format="%(refname:short)|%(HEAD)|%(objectname:short=7)|%(subject)|%(committerdate:iso8601)" | grep -v "(HEAD detached at"',
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const branches: GitBranch[] = []
|
||||
const lines = stdout.trim().split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('|')
|
||||
if (parts.length >= 5) {
|
||||
branches.push({
|
||||
name: parts[0]!.trim(),
|
||||
isCurrent: parts[1]!.trim() === '*',
|
||||
lastCommitId: parts[2]!.trim(),
|
||||
lastCommitMessage: parts[3]!.trim(),
|
||||
lastCommitDate: new Date(parts[4]!.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return branches
|
||||
} catch (error) {
|
||||
console.error('获取分支列表失败:', error)
|
||||
throw new Error(`无法获取分支列表: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git提交历史信息
|
||||
*/
|
||||
export interface GitCommit {
|
||||
commitId: string
|
||||
shortId: string
|
||||
message: string
|
||||
author: string
|
||||
date: Date
|
||||
filesChanged: number
|
||||
insertions: number
|
||||
deletions: number
|
||||
isAfterHead: boolean // 标记是否在HEAD之后(用于显示灰色)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提交历史(时间轴)
|
||||
* 返回指定分支最新的limit个提交,并标记哪些提交在HEAD之后
|
||||
* @param limit 返回的提交数量限制
|
||||
* @param branchName 可选的分支名称,如果不提供则使用当前分支
|
||||
*/
|
||||
export async function getCommitHistory(limit: number = 50, branchName?: string): Promise<GitCommit[]> {
|
||||
try {
|
||||
// 获取当前HEAD的commit ID
|
||||
const currentHeadId = await getLatestCommitId()
|
||||
|
||||
// 如果提供了分支名称,使用指定分支;否则使用当前分支
|
||||
let refName: string
|
||||
if (branchName) {
|
||||
refName = branchName
|
||||
} else {
|
||||
// 获取当前分支名称
|
||||
const currentBranch = await getCurrentBranch()
|
||||
// 如果不在任何分支上(detached HEAD),使用HEAD
|
||||
refName = currentBranch || 'HEAD'
|
||||
}
|
||||
|
||||
// 获取当前分支的提交历史,包含统计信息(去除空行)
|
||||
const { stdout } = await execAsync(
|
||||
`git log ${refName} -${limit} --format="%H|%h|%s|%an|%aI" --shortstat | grep -v '^$'`,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const commits: GitCommit[] = []
|
||||
const lines = stdout.trim().split('\n')
|
||||
let i = 0
|
||||
let foundHead = false
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (!line || !line.includes('|')) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = line.split('|')
|
||||
if (parts.length >= 5) {
|
||||
const fullCommitId = parts[0]!.trim()
|
||||
const shortId = parts[1]!.trim()
|
||||
|
||||
// 检查是否到达HEAD
|
||||
if (!foundHead && shortId === currentHeadId) {
|
||||
foundHead = true
|
||||
}
|
||||
|
||||
const commit: GitCommit = {
|
||||
commitId: fullCommitId,
|
||||
shortId,
|
||||
message: parts[2]!.trim(),
|
||||
author: parts[3]!.trim(),
|
||||
date: new Date(parts[4]!.trim()),
|
||||
filesChanged: 0,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
isAfterHead: !foundHead, // 在找到HEAD之前的提交都是在HEAD之后的
|
||||
}
|
||||
|
||||
// 检查下一行是否是统计信息
|
||||
i++
|
||||
if (i < lines.length && lines[i]!.includes('changed')) {
|
||||
const statLine = lines[i]!
|
||||
const filesMatch = statLine.match(/(\d+) files? changed/)
|
||||
const insertionsMatch = statLine.match(/(\d+) insertions?/)
|
||||
const deletionsMatch = statLine.match(/(\d+) deletions?/)
|
||||
|
||||
if (filesMatch) commit.filesChanged = parseInt(filesMatch[1]!)
|
||||
if (insertionsMatch) commit.insertions = parseInt(insertionsMatch[1]!)
|
||||
if (deletionsMatch) commit.deletions = parseInt(deletionsMatch[1]!)
|
||||
i++
|
||||
}
|
||||
|
||||
commits.push(commit)
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return commits
|
||||
} catch (error) {
|
||||
console.error('获取提交历史失败:', error)
|
||||
throw new Error(`无法获取提交历史: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新提交
|
||||
* @param message 提交信息
|
||||
* @param amend 是否修订上一次提交(默认false)
|
||||
*/
|
||||
export async function createCommit(message: string, amend: boolean = false): Promise<string> {
|
||||
try {
|
||||
// 先添加所有更改
|
||||
await execAsync('git add -A', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
// 创建提交,如果amend为true则添加--amend参数
|
||||
const commitCommand = amend
|
||||
? `git commit --amend -m "${message.replace(/"/g, '\\"')}"`
|
||||
: `git commit -m "${message.replace(/"/g, '\\"')}"`
|
||||
|
||||
await execAsync(commitCommand, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
// 获取新提交的ID
|
||||
const { stdout } = await execAsync('git rev-parse --short=7 HEAD', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
return stdout.trim()
|
||||
} catch (error: any) {
|
||||
// 如果没有更改,git commit会返回错误
|
||||
if (error.message?.includes('nothing to commit')) {
|
||||
throw new Error('没有需要提交的更改')
|
||||
}
|
||||
console.error('创建提交失败:', error)
|
||||
throw new Error(`无法创建提交: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到指定提交(detached HEAD状态)
|
||||
*/
|
||||
export async function checkoutCommit(commitId: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git checkout "${commitId}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`切换到提交失败:`, error)
|
||||
throw new Error(`无法切换到提交 ${commitId}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到指定分支
|
||||
* @param branchName 分支名称
|
||||
*/
|
||||
export async function checkoutBranch(branchName: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git checkout "${branchName}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`切换分支失败:`, error)
|
||||
throw new Error(`无法切换到分支 ${branchName}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转提交(创建一个反向提交,保留历史)
|
||||
*/
|
||||
export async function revertCommit(commitId: string): Promise<string> {
|
||||
try {
|
||||
// 使用 git revert 创建反向提交
|
||||
await execAsync(`git revert --no-edit "${commitId}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
// 获取新创建的revert提交ID
|
||||
const { stdout } = await execAsync('git rev-parse --short=7 HEAD', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
return stdout.trim()
|
||||
} catch (error: any) {
|
||||
// 如果有冲突,需要特殊处理
|
||||
if (error.message?.includes('conflict')) {
|
||||
throw new Error('回滚时发生冲突,请手动解决冲突后提交')
|
||||
}
|
||||
console.error('反转提交失败:', error)
|
||||
throw new Error(`无法反转提交 ${commitId}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制回滚到指定提交(危险操作,会丢失之后的所有提交)
|
||||
*/
|
||||
export async function resetToCommit(commitId: string): Promise<void> {
|
||||
try {
|
||||
// 使用 git reset --hard 强制回滚
|
||||
await execAsync(`git reset --hard "${commitId}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('强制回滚失败:', error)
|
||||
throw new Error(`无法强制回滚到提交 ${commitId}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前分支名称
|
||||
*/
|
||||
export async function getCurrentBranch(): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch --show-current', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim()
|
||||
} catch (error) {
|
||||
console.error('获取当前分支失败:', error)
|
||||
throw new Error(`无法获取当前分支: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有未提交的更改
|
||||
*/
|
||||
export async function hasUncommittedChanges(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --porcelain', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim().length > 0
|
||||
} catch (error) {
|
||||
console.error('检查未提交更改失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
141
src/server/utils/node-helper.ts
Normal file
141
src/server/utils/node-helper.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { builtinModules } from 'module'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { fetchWithTimeout } from '@/lib/utils'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* 将各种格式的 仓库 URL 转换为标准的 HTTPS URL
|
||||
* @param url 原始仓库 URL
|
||||
* @returns 标准化的 HTTPS URL,如果无法解析则返回原始 URL
|
||||
*/
|
||||
function normalizeRepositoryUrl(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined
|
||||
|
||||
try {
|
||||
// 移除 git+ 前缀
|
||||
let normalized = url.replace(/^git\+/, '')
|
||||
|
||||
// 将 git:// 协议转换为 https://
|
||||
normalized = normalized.replace(/^git:\/\//, 'https://')
|
||||
|
||||
// 移除 .git 后缀
|
||||
normalized = normalized.replace(/\.git$/, '')
|
||||
|
||||
// 使用正则表达式提取 URL
|
||||
// 匹配 http(s):// 或 git@ 开头的 URL
|
||||
const urlPattern = /(?:https?:\/\/|git@)[\w\-.]+(\/[\w\-./]+)?/i
|
||||
const match = normalized.match(urlPattern)
|
||||
|
||||
if (match) {
|
||||
// 如果是 git@github.com:user/repo 格式,转换为 https://
|
||||
let extractedUrl = match[0]
|
||||
if (extractedUrl.startsWith('git@')) {
|
||||
extractedUrl = extractedUrl
|
||||
.replace(/^git@/, 'https://')
|
||||
.replace(/:/, '/')
|
||||
}
|
||||
return extractedUrl
|
||||
}
|
||||
|
||||
return normalized
|
||||
} catch (error) {
|
||||
console.error('标准化仓库 URL 失败:', error)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模块是否是 Node.js 内置模块
|
||||
*/
|
||||
export function isBuiltinModule(moduleName: string): boolean {
|
||||
// 移除可能的 'node:' 前缀
|
||||
const name = moduleName.replace(/^node:/, '')
|
||||
|
||||
return builtinModules.includes(name) ||
|
||||
builtinModules.includes(`node:${name}`)
|
||||
}
|
||||
|
||||
let NODE_RELEASE_DATE: Date | null = null
|
||||
|
||||
async function getNodeReleaseDate(): Promise<Date | null> {
|
||||
if (NODE_RELEASE_DATE) {
|
||||
return NODE_RELEASE_DATE
|
||||
}
|
||||
try {
|
||||
const response = await fetchWithTimeout('https://npmmirror.com/dist/index.json', undefined, 3000);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const releases = await response.json();
|
||||
const currentRelease = releases.find(
|
||||
(r: any) => r.version === process.version
|
||||
);
|
||||
if (currentRelease?.date) {
|
||||
NODE_RELEASE_DATE = new Date(currentRelease.date)
|
||||
}
|
||||
return NODE_RELEASE_DATE
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包信息接口
|
||||
*/
|
||||
export interface PackageInfo {
|
||||
version: string
|
||||
modifiedAt: Date
|
||||
description: string
|
||||
homepage?: string
|
||||
repositoryUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取包的基本信息
|
||||
* @param packageName 包名
|
||||
* @returns 包信息,如果获取失败返回 null
|
||||
*/
|
||||
export async function getPackageInfo(packageName: string): Promise<PackageInfo | null> {
|
||||
try {
|
||||
// 对于内置模块,返回 Node.js 的信息
|
||||
if (isBuiltinModule(packageName)) {
|
||||
return {
|
||||
version: process.version.trim().replace('v', ''),
|
||||
modifiedAt: await getNodeReleaseDate() || new Date(), // 内置模块默认使用当前时间
|
||||
description: `Node.js 内置模块`,
|
||||
homepage: 'https://nodejs.org/api/',
|
||||
repositoryUrl: 'https://github.com/nodejs/node',
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 获取本项目安装的版本号
|
||||
const { stdout: installedVersionOutput } = await execAsync(
|
||||
`npm list ${packageName} --depth=0 --json`
|
||||
);
|
||||
const listData = JSON.parse(installedVersionOutput);
|
||||
const version = listData.dependencies?.[packageName]?.version;
|
||||
|
||||
if (!version) {
|
||||
throw new Error(`Package ${packageName} not found in project`);
|
||||
}
|
||||
|
||||
// 2. 一次性获取该版本的所有信息
|
||||
const { stdout: infoOutput } = await execAsync(
|
||||
`npm view ${packageName}@${version} version description homepage repository.url "time[${version}]" --json`
|
||||
);
|
||||
const info = JSON.parse(infoOutput);
|
||||
|
||||
return {
|
||||
version: info.version || version,
|
||||
description: info.description || '',
|
||||
homepage: info.homepage || undefined,
|
||||
repositoryUrl: normalizeRepositoryUrl(info['repository.url']),
|
||||
modifiedAt: new Date(info[`time[${version}]`] || Date.now())
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`获取包 ${packageName} 的信息失败:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
54
src/server/utils/request-helper.ts
Normal file
54
src/server/utils/request-helper.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'server-only'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/server/auth'
|
||||
import { evaluatePermissionExpression } from '@/constants/permissions'
|
||||
|
||||
/**
|
||||
* 权限检查高阶函数
|
||||
* 用于包装需要权限验证的服务器端函数
|
||||
*/
|
||||
export function withPermission<TArgs extends any[], TReturn>(
|
||||
permission: string,
|
||||
fn: (...args: TArgs) => Promise<TReturn>
|
||||
) {
|
||||
return async (...args: TArgs): Promise<TReturn> => {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('未登录')
|
||||
}
|
||||
|
||||
const userPermissions = session.user.permissions as string[]
|
||||
|
||||
if (
|
||||
!session.user.isSuperAdmin &&
|
||||
!evaluatePermissionExpression(permission, userPermissions)
|
||||
) {
|
||||
throw new Error('没有权限')
|
||||
}
|
||||
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员权限检查高阶函数
|
||||
* 用于包装需要超级管理员权限的服务器端函数
|
||||
*/
|
||||
export function withSuperAdmin<TArgs extends any[], TReturn>(
|
||||
fn: (...args: TArgs) => Promise<TReturn>
|
||||
) {
|
||||
return async (...args: TArgs): Promise<TReturn> => {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('未登录')
|
||||
}
|
||||
|
||||
if (!session.user.isSuperAdmin) {
|
||||
throw new Error('需要超级管理员权限')
|
||||
}
|
||||
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user