Initial commit
This commit is contained in:
17
api/modules/pydemos/index.ts
Normal file
17
api/modules/pydemos/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from '../../infra/container.js'
|
||||
import { createApiModule } from '../../infra/createModule.js'
|
||||
import { PYDEMOS_MODULE } from '../../../shared/modules/pydemos/index.js'
|
||||
import { createPyDemosRoutes } from './routes.js'
|
||||
|
||||
export * from './routes.js'
|
||||
|
||||
export const createPyDemosModule = () => {
|
||||
return createApiModule(PYDEMOS_MODULE, {
|
||||
routes: (_container: ServiceContainer): Router => {
|
||||
return createPyDemosRoutes()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default createPyDemosModule
|
||||
258
api/modules/pydemos/routes.ts
Normal file
258
api/modules/pydemos/routes.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import express, { type Request, type Response, type Router } from 'express'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import multer from 'multer'
|
||||
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||
import { successResponse } from '../../utils/response.js'
|
||||
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||
import { getTempDir } from '../../utils/tempDir.js'
|
||||
import { validateBody, validateQuery } from '../../middlewares/validate.js'
|
||||
import {
|
||||
listPyDemosQuerySchema,
|
||||
createPyDemoSchema,
|
||||
deletePyDemoSchema,
|
||||
renamePyDemoSchema,
|
||||
} from '../../schemas/index.js'
|
||||
import { NotFoundError, AlreadyExistsError, isNodeError, ValidationError } from '../../../shared/errors/index.js'
|
||||
import type { PyDemoItem, PyDemoMonth } from '../../../shared/types/pydemos.js'
|
||||
|
||||
const tempDir = getTempDir()
|
||||
|
||||
const upload = multer({
|
||||
dest: tempDir,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024
|
||||
}
|
||||
})
|
||||
|
||||
const toPosixPath = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const getYearPath = (year: number): { relPath: string; fullPath: string } => {
|
||||
const relPath = `pydemos/${year}`
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
return { relPath, fullPath }
|
||||
}
|
||||
|
||||
const getMonthPath = (year: number, month: number): { relPath: string; fullPath: string } => {
|
||||
const monthStr = month.toString().padStart(2, '0')
|
||||
const relPath = `pydemos/${year}/${monthStr}`
|
||||
const { fullPath } = resolveNotebookPath(relPath)
|
||||
return { relPath, fullPath }
|
||||
}
|
||||
|
||||
const countFilesInDir = async (dirPath: string): Promise<number> => {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
return entries.filter(e => e.isFile()).length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const createPyDemosRoutes = (): Router => {
|
||||
const router = express.Router()
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
validateQuery(listPyDemosQuerySchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const year = parseInt(req.query.year as string) || new Date().getFullYear()
|
||||
|
||||
const { fullPath: yearPath } = getYearPath(year)
|
||||
const months: PyDemoMonth[] = []
|
||||
|
||||
try {
|
||||
await fs.access(yearPath)
|
||||
} catch {
|
||||
successResponse(res, { months })
|
||||
return
|
||||
}
|
||||
|
||||
const monthEntries = await fs.readdir(yearPath, { withFileTypes: true })
|
||||
|
||||
for (const monthEntry of monthEntries) {
|
||||
if (!monthEntry.isDirectory()) continue
|
||||
|
||||
const monthNum = parseInt(monthEntry.name)
|
||||
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) continue
|
||||
|
||||
const monthPath = path.join(yearPath, monthEntry.name)
|
||||
const demoEntries = await fs.readdir(monthPath, { withFileTypes: true })
|
||||
|
||||
const demos: PyDemoItem[] = []
|
||||
|
||||
for (const demoEntry of demoEntries) {
|
||||
if (!demoEntry.isDirectory()) continue
|
||||
|
||||
const demoPath = path.join(monthPath, demoEntry.name)
|
||||
const relDemoPath = `pydemos/${year}/${monthEntry.name}/${demoEntry.name}`
|
||||
|
||||
let created: string
|
||||
try {
|
||||
const stats = await fs.stat(demoPath)
|
||||
created = stats.birthtime.toISOString()
|
||||
} catch {
|
||||
created = new Date().toISOString()
|
||||
}
|
||||
|
||||
const fileCount = await countFilesInDir(demoPath)
|
||||
|
||||
demos.push({
|
||||
name: demoEntry.name,
|
||||
path: toPosixPath(relDemoPath),
|
||||
created,
|
||||
fileCount
|
||||
})
|
||||
}
|
||||
|
||||
demos.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
|
||||
if (demos.length > 0) {
|
||||
months.push({
|
||||
month: monthNum,
|
||||
demos
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
months.sort((a, b) => a.month - b.month)
|
||||
|
||||
successResponse(res, { months })
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/create',
|
||||
upload.array('files'),
|
||||
validateBody(createPyDemoSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { name, year, month, folderStructure } = req.body
|
||||
|
||||
const yearNum = parseInt(year)
|
||||
const monthNum = parseInt(month)
|
||||
|
||||
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(name)) {
|
||||
throw new ValidationError('Invalid name format')
|
||||
}
|
||||
|
||||
const { fullPath: monthPath, relPath: monthRelPath } = getMonthPath(yearNum, monthNum)
|
||||
const demoPath = path.join(monthPath, name)
|
||||
const relDemoPath = `${monthRelPath}/${name}`
|
||||
|
||||
try {
|
||||
await fs.access(demoPath)
|
||||
throw new AlreadyExistsError('Demo already exists')
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
// 目录不存在,可以创建
|
||||
} else if (err instanceof AlreadyExistsError) {
|
||||
throw err
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(demoPath, { recursive: true })
|
||||
|
||||
const files = req.files as Express.Multer.File[] | undefined
|
||||
let fileCount = 0
|
||||
|
||||
if (files && files.length > 0) {
|
||||
let structure: Record<string, string> = {}
|
||||
if (folderStructure) {
|
||||
try {
|
||||
structure = JSON.parse(folderStructure)
|
||||
} catch {
|
||||
structure = {}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = structure[file.originalname] || file.originalname
|
||||
const targetPath = path.join(demoPath, relativePath)
|
||||
const targetDir = path.dirname(targetPath)
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await fs.copyFile(file.path, targetPath)
|
||||
await fs.unlink(file.path).catch(() => { })
|
||||
fileCount++
|
||||
}
|
||||
}
|
||||
|
||||
successResponse(res, { path: toPosixPath(relDemoPath), fileCount })
|
||||
}),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/delete',
|
||||
validateBody(deletePyDemoSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { path: demoPath } = req.body
|
||||
|
||||
if (!demoPath.startsWith('pydemos/')) {
|
||||
throw new ValidationError('Invalid path')
|
||||
}
|
||||
|
||||
const { fullPath } = resolveNotebookPath(demoPath)
|
||||
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
} catch {
|
||||
throw new NotFoundError('Demo not found')
|
||||
}
|
||||
|
||||
await fs.rm(fullPath, { recursive: true, force: true })
|
||||
|
||||
successResponse(res, null)
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/rename',
|
||||
validateBody(renamePyDemoSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { oldPath, newName } = req.body
|
||||
|
||||
if (!oldPath.startsWith('pydemos/')) {
|
||||
throw new ValidationError('Invalid path')
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) {
|
||||
throw new ValidationError('Invalid name format')
|
||||
}
|
||||
|
||||
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
|
||||
|
||||
try {
|
||||
await fs.access(oldFullPath)
|
||||
} catch {
|
||||
throw new NotFoundError('Demo not found')
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(oldFullPath)
|
||||
const newFullPath = path.join(parentDir, newName)
|
||||
const newPath = toPosixPath(path.join(path.dirname(oldPath), newName))
|
||||
|
||||
try {
|
||||
await fs.access(newFullPath)
|
||||
throw new AlreadyExistsError('Demo with this name already exists')
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
// 目录不存在,可以重命名
|
||||
} else if (err instanceof AlreadyExistsError) {
|
||||
throw err
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rename(oldFullPath, newFullPath)
|
||||
|
||||
successResponse(res, { newPath })
|
||||
}),
|
||||
)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
export default createPyDemosRoutes()
|
||||
Reference in New Issue
Block a user