Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

View 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,
}
}

View 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: '无法分析此文件夹',
}
}

View 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: '无法分析此包的使用模式',
}
}

View 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 hooksuseState, 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
View 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
View 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
View 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
View 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}`;
}

View 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(),
})

View 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)
})

View 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(),
})

View 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)
})

View 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(),
})

View 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)
})

View 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
View 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
}

View 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

View 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' } })
),
})

View 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];

View 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']>;

View 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;

View 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

View 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
View 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),
})

View 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个避免数据量过大
});
// 返回一个易于查找的Mapkey为optionIdvalue为评估的选项优先级暂时用频率
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;

View 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
View 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']>;

View 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
View 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))

View 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: [],
}
}
}

View 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,
};
}

View 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
}
}

View 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
}
}

View 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)
}
}