feat: 初始化慧遇书院官网一期

This commit is contained in:
2026-08-12 18:01:17 +08:00
commit 5d31ee5743
101 changed files with 17259 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { requireAdmin } from '../../utils/auth'
import { useDatabase } from '../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const rows = useDatabase().prepare(`
SELECT audit_logs.id, audit_logs.action, audit_logs.entity_type, audit_logs.entity_key,
audit_logs.ip_address, audit_logs.created_at, admin_users.display_name
FROM audit_logs LEFT JOIN admin_users ON admin_users.id = audit_logs.operator_id
ORDER BY audit_logs.id DESC LIMIT 50
`).all() as Array<Record<string, string | number | null>>
return { data: rows.map(row => ({ id: row.id, action: row.action, entityType: row.entity_type, entityKey: row.entity_key, ipAddress: row.ip_address, createdAt: row.created_at, operator: row.display_name || '系统' })) }
})

View File

@@ -0,0 +1,81 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { extname } from 'node:path'
import sharp from 'sharp'
import { z } from 'zod'
import { mediaUrl, safeUploadPath, serializeMedia, type MediaAssetRow } from '../../utils/media'
import { useDatabase } from '../../utils/db'
const allowedTypes = {
'image/jpeg': { extension: '.jpg', inputExtensions: ['.jpg', '.jpeg'] },
'image/png': { extension: '.png', inputExtensions: ['.png'] },
'image/webp': { extension: '.webp', inputExtensions: ['.webp'] },
} as const
const metadataSchema = z.object({
altText: z.string().trim().max(160).default(''),
source: z.string().trim().max(200).default(''),
copyright: z.string().trim().max(200).default(''),
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']).default('pending'),
})
function detectImageType(data: Buffer) {
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
}
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const parts = await readMultipartFormData(event)
const file = parts?.find(part => part.name === 'file' && part.filename)
if (!file?.data?.length) throw createError({ statusCode: 400, statusMessage: '请选择图片文件' })
if (file.data.length > 8 * 1024 * 1024) throw createError({ statusCode: 413, statusMessage: '单张图片不能超过 8MB' })
const detectedType = detectImageType(file.data)
if (!detectedType || !(detectedType in allowedTypes)) throw createError({ statusCode: 415, statusMessage: '仅支持 JPG、PNG、WebP 图片' })
const typeConfig = allowedTypes[detectedType as keyof typeof allowedTypes]
const inputExtension = extname(file.filename || '').toLowerCase()
if (!typeConfig.inputExtensions.includes(inputExtension as never)) throw createError({ statusCode: 415, statusMessage: '文件扩展名与实际图片类型不一致' })
let imageMetadata
try { imageMetadata = await sharp(file.data, { limitInputPixels: 40_000_000 }).metadata() }
catch { throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' }) }
const width = imageMetadata.width || 0
const height = imageMetadata.height || 0
if (!width || !height || width > 10_000 || height > 10_000 || width * height > 40_000_000) {
throw createError({ statusCode: 413, statusMessage: '图片尺寸不能超过 10000×10000 或 4000 万像素' })
}
const fields = Object.fromEntries((parts || []).filter(part => !part.filename && part.name).map(part => [part.name!, part.data.toString('utf8')]))
const metadata = metadataSchema.parse(fields)
const now = new Date()
const dateDir = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`
const assetKey = randomUUID()
const originalPath = `original/${dateDir}/${assetKey}${typeConfig.extension}`
const derivedPath = `derived/${dateDir}/${assetKey}-large.webp`
const thumbnailPath = `derived/${dateDir}/${assetKey}-thumb.webp`
const writtenPaths = [originalPath, derivedPath, thumbnailPath]
await Promise.all(writtenPaths.map(path => mkdir(safeUploadPath(path).replace(/\/[^/]+$/, ''), { recursive: true })))
try {
await writeFile(safeUploadPath(originalPath), file.data, { flag: 'wx' })
await sharp(file.data).rotate().resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true }).webp({ quality: 84 }).toFile(safeUploadPath(derivedPath))
await sharp(file.data).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(safeUploadPath(thumbnailPath))
const timestamp = now.toISOString()
const db = useDatabase()
const row = db.transaction(() => {
const result = db.prepare(`
INSERT INTO media_assets (asset_key, original_name, mime_type, extension, bytes, width, height, original_path, derived_path, thumbnail_path, alt_text, source_text, copyright_text, authorization_status, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
`).run(assetKey, file.filename, detectedType, typeConfig.extension, file.data.length, width, height, originalPath, derivedPath, thumbnailPath, metadata.altText, metadata.source, metadata.copyright, metadata.authorizationStatus, user.id, timestamp, timestamp)
writeAuditLog(event, { operatorId: user.id, action: 'media.upload', entityType: 'media', entityKey: assetKey, metadata: { originalName: file.filename, bytes: file.data.length, width, height, derivedUrl: mediaUrl(derivedPath) } })
return db.prepare('SELECT * FROM media_assets WHERE id = ?').get(result.lastInsertRowid) as MediaAssetRow
})()
return { url: mediaUrl(derivedPath), media: serializeMedia(row) }
} catch (error) {
await Promise.all(writtenPaths.map(path => rm(safeUploadPath(path), { force: true })))
throw error
}
})

View File

@@ -0,0 +1,17 @@
import { isMediaReferenced, moveMediaFiles, serializeMedia, type MediaAssetRow } from '../../../utils/media'
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isSafeInteger(id) || id < 1) throw createError({ statusCode: 400, statusMessage: '无效的素材编号' })
const db = useDatabase()
const row = db.prepare("SELECT * FROM media_assets WHERE id = ? AND status = 'active'").get(id) as MediaAssetRow | undefined
if (!row) throw createError({ statusCode: 404, statusMessage: '素材不存在' })
if (isMediaReferenced(db, row)) throw createError({ statusCode: 409, statusMessage: '素材正在被草稿或已发布页面引用,不能删除' })
await moveMediaFiles(row, 'trash')
const timestamp = new Date().toISOString()
db.prepare("UPDATE media_assets SET status = 'trash', deleted_at = ?, updated_at = ? WHERE id = ?").run(timestamp, timestamp, id)
writeAuditLog(event, { operatorId: user.id, action: 'media.trash', entityType: 'media', entityKey: row.asset_key })
return { data: serializeMedia({ ...row, status: 'trash', deleted_at: timestamp, updated_at: timestamp }) }
})

View File

@@ -0,0 +1,25 @@
import { z } from 'zod'
import { serializeMedia, type MediaAssetRow } from '../../../utils/media'
import { useDatabase } from '../../../utils/db'
const updateSchema = z.object({
altText: z.string().trim().max(160),
source: z.string().trim().max(200),
copyright: z.string().trim().max(200),
authorizationStatus: z.enum(['pending', 'authorized', 'restricted']),
})
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isSafeInteger(id) || id < 1) throw createError({ statusCode: 400, statusMessage: '无效的素材编号' })
const input = updateSchema.parse(await readBody(event))
const db = useDatabase()
const timestamp = new Date().toISOString()
const result = db.prepare(`UPDATE media_assets SET alt_text = ?, source_text = ?, copyright_text = ?, authorization_status = ?, updated_at = ? WHERE id = ? AND status = 'active'`)
.run(input.altText, input.source, input.copyright, input.authorizationStatus, timestamp, id)
if (!result.changes) throw createError({ statusCode: 404, statusMessage: '素材不存在' })
const row = db.prepare('SELECT * FROM media_assets WHERE id = ?').get(id) as MediaAssetRow
writeAuditLog(event, { operatorId: user.id, action: 'media.update', entityType: 'media', entityKey: row.asset_key })
return { data: serializeMedia(row) }
})

View File

@@ -0,0 +1,16 @@
import { moveMediaFiles, serializeMedia, type MediaAssetRow } from '../../../../utils/media'
import { useDatabase } from '../../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isSafeInteger(id) || id < 1) throw createError({ statusCode: 400, statusMessage: '无效的素材编号' })
const db = useDatabase()
const row = db.prepare("SELECT * FROM media_assets WHERE id = ? AND status = 'trash'").get(id) as MediaAssetRow | undefined
if (!row) throw createError({ statusCode: 404, statusMessage: '回收站中不存在该素材' })
await moveMediaFiles(row, 'restore')
const timestamp = new Date().toISOString()
db.prepare("UPDATE media_assets SET status = 'active', deleted_at = NULL, updated_at = ? WHERE id = ?").run(timestamp, id)
writeAuditLog(event, { operatorId: user.id, action: 'media.restore', entityType: 'media', entityKey: row.asset_key })
return { data: serializeMedia({ ...row, status: 'active', deleted_at: null, updated_at: timestamp }) }
})

View File

@@ -0,0 +1,11 @@
import { isMediaReferenced, serializeMedia, type MediaAssetRow } from '../../../utils/media'
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const status = query.status === 'trash' ? 'trash' : 'active'
const db = useDatabase()
const rows = db.prepare('SELECT * FROM media_assets WHERE status = ? ORDER BY created_at DESC LIMIT 200').all(status) as MediaAssetRow[]
return { data: rows.map(row => serializeMedia(row, isMediaReferenced(db, row))) }
})

View File

@@ -0,0 +1,7 @@
import { requireAdmin } from '../../../utils/auth'
import { readSiteDocument } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
return readSiteDocument('draft')
})

View File

@@ -0,0 +1,19 @@
import { requireAdmin } from '../../../utils/auth'
import { writeAuditLog } from '../../../utils/audit'
import { useDatabase } from '../../../utils/db'
import { siteDocumentSchema } from '../../../utils/site-schema'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const parsed = siteDocumentSchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({ statusCode: 422, statusMessage: '内容校验失败', data: { issues: parsed.error.issues } })
}
const document = parsed.data
const timestamp = new Date().toISOString()
const db = useDatabase()
db.prepare('UPDATE site_documents SET draft_json = ?, updated_by = ?, updated_at = ? WHERE document_key = ?')
.run(JSON.stringify(document), user.id, timestamp, 'website')
writeAuditLog(event, { operatorId: user.id, action: 'save_draft', entityType: 'site_document', entityKey: 'website' })
return { data: document, updatedAt: timestamp }
})

View File

@@ -0,0 +1,35 @@
import { requireAdmin } from '../../../utils/auth'
import { writeAuditLog } from '../../../utils/audit'
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const db = useDatabase()
const timestamp = new Date().toISOString()
const publish = db.transaction(() => {
const row = db.prepare('SELECT draft_json FROM site_documents WHERE document_key = ?').get('website') as { draft_json: string }
const versionRow = db.prepare(`
SELECT COALESCE(MAX(version), 0) + 1 AS version FROM revisions
WHERE entity_type = ? AND entity_key = ?
`).get('site_document', 'website') as { version: number }
db.prepare(`
INSERT INTO revisions (entity_type, entity_key, version, snapshot_json, operator_id, action, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('site_document', 'website', versionRow.version, row.draft_json, user.id, 'publish', timestamp)
db.prepare(`
UPDATE site_documents SET published_json = draft_json, updated_by = ?, updated_at = ?, published_at = ?
WHERE document_key = ?
`).run(user.id, timestamp, timestamp, 'website')
db.prepare(`
DELETE FROM revisions WHERE entity_type = ? AND entity_key = ? AND id NOT IN (
SELECT id FROM revisions WHERE entity_type = ? AND entity_key = ? ORDER BY version DESC LIMIT 20
)
`).run('site_document', 'website', 'site_document', 'website')
return versionRow.version
})
const version = publish()
writeAuditLog(event, { operatorId: user.id, action: 'publish', entityType: 'site_document', entityKey: 'website', metadata: { version } })
return { success: true, publishedAt: timestamp, version }
})

View File

@@ -0,0 +1,11 @@
import { useDatabase } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const rows = useDatabase().prepare(`
SELECT version, action, comment, created_at AS createdAt
FROM revisions WHERE entity_type = 'site_document' AND entity_key = 'website'
ORDER BY version DESC LIMIT 20
`).all() as Array<{ version: number; action: string; comment: string | null; createdAt: string }>
return { data: rows }
})

View File

@@ -0,0 +1,16 @@
import { useDatabase } from '../../../../../utils/db'
import { siteDocumentSchema } from '../../../../../utils/site-schema'
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const version = Number(getRouterParam(event, 'version'))
if (!Number.isSafeInteger(version) || version < 1) throw createError({ statusCode: 400, statusMessage: '无效的版本号' })
const db = useDatabase()
const revision = db.prepare(`SELECT snapshot_json FROM revisions WHERE entity_type = 'site_document' AND entity_key = 'website' AND version = ?`).get(version) as { snapshot_json: string } | undefined
if (!revision) throw createError({ statusCode: 404, statusMessage: '历史版本不存在' })
const snapshot = siteDocumentSchema.parse(JSON.parse(revision.snapshot_json))
const timestamp = new Date().toISOString()
db.prepare('UPDATE site_documents SET draft_json = ?, updated_by = ?, updated_at = ? WHERE document_key = ?').run(JSON.stringify(snapshot), user.id, timestamp, 'website')
writeAuditLog(event, { operatorId: user.id, action: 'revision.restore_to_draft', entityType: 'site_document', entityKey: 'website', metadata: { version } })
return { data: snapshot, restoredVersion: version, updatedAt: timestamp }
})

View File

@@ -0,0 +1,51 @@
import { compareSync } from 'bcryptjs'
import { z } from 'zod'
import { getAdminSession } from '../../utils/auth'
import { useDatabase } from '../../utils/db'
import { writeAuditLog } from '../../utils/audit'
const loginSchema = z.object({
username: z.string().trim().min(1).max(80),
password: z.string().min(8).max(200),
})
export default defineEventHandler(async (event) => {
const body = loginSchema.parse(await readBody(event))
const db = useDatabase()
const user = db.prepare(`
SELECT id, username, password_hash, display_name, status, failed_login_count, locked_until
FROM admin_users WHERE username = ?
`).get(body.username) as {
id: number
username: string
password_hash: string
display_name: string
status: string
failed_login_count: number
locked_until: string | null
} | undefined
const locked = user?.locked_until && new Date(user.locked_until) > new Date()
const valid = user && user.status === 'active' && !locked && compareSync(body.password, user.password_hash)
if (!valid) {
if (user && user.status === 'active') {
const failures = user.failed_login_count + 1
const lockedUntil = failures >= 5 ? new Date(Date.now() + 10 * 60_000).toISOString() : null
db.prepare('UPDATE admin_users SET failed_login_count = ?, locked_until = ?, updated_at = ? WHERE id = ?')
.run(failures, lockedUntil, new Date().toISOString(), user.id)
}
writeAuditLog(event, { operatorId: user?.id, action: 'login_failed', entityType: 'admin_user', entityKey: body.username })
throw createError({ statusCode: 401, statusMessage: '账号、密码或验证码错误' })
}
const timestamp = new Date().toISOString()
db.prepare('UPDATE admin_users SET failed_login_count = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE id = ?')
.run(timestamp, timestamp, user.id)
const session = await getAdminSession(event)
await session.update({ user: { id: user.id, username: user.username, displayName: user.display_name } })
writeAuditLog(event, { operatorId: user.id, action: 'login_success', entityType: 'admin_user', entityKey: String(user.id) })
return { user: session.data.user }
})

View File

@@ -0,0 +1,7 @@
import { getAdminSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const session = await getAdminSession(event)
await session.clear()
return { success: true }
})

View File

@@ -0,0 +1,3 @@
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler(async (event) => ({ user: await requireAdmin(event) }))

View File

@@ -0,0 +1,20 @@
import { compareSync, hashSync } from 'bcryptjs'
import { z } from 'zod'
import { requireAdmin } from '../../utils/auth'
import { useDatabase } from '../../utils/db'
const passwordSchema = z.object({
currentPassword: z.string().min(8).max(200),
newPassword: z.string().min(12).max(200).regex(/[a-z]/, '新密码需包含小写字母').regex(/[A-Z]/, '新密码需包含大写字母').regex(/\d/, '新密码需包含数字').regex(/[^A-Za-z0-9]/, '新密码需包含特殊字符'),
}).refine(input => input.currentPassword !== input.newPassword, { message: '新密码不能与当前密码相同', path: ['newPassword'] })
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event)
const input = passwordSchema.parse(await readBody(event))
const db = useDatabase()
const row = db.prepare('SELECT password_hash FROM admin_users WHERE id = ?').get(user.id) as { password_hash: string } | undefined
if (!row || !compareSync(input.currentPassword, row.password_hash)) throw createError({ statusCode: 400, statusMessage: '当前密码不正确' })
db.prepare('UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hashSync(input.newPassword, 12), new Date().toISOString(), user.id)
writeAuditLog(event, { operatorId: user.id, action: 'password.change', entityType: 'admin_user', entityKey: String(user.id) })
return { success: true }
})

21
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,21 @@
import { accessSync, constants, mkdirSync, statfsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
export default defineEventHandler(() => {
const config = useRuntimeConfig()
useDatabase().prepare('SELECT 1').get()
const databaseDirectory = dirname(resolve(process.cwd(), config.databasePath))
const uploadsDirectory = resolve(process.cwd(), 'data', 'uploads')
mkdirSync(uploadsDirectory, { recursive: true })
accessSync(databaseDirectory, constants.R_OK | constants.W_OK)
accessSync(uploadsDirectory, constants.R_OK | constants.W_OK)
const disk = statfsSync(resolve(process.cwd(), 'data'))
const freeBytes = Number(disk.bavail) * Number(disk.bsize)
if (freeBytes < 256 * 1024 * 1024) throw createError({ statusCode: 503, statusMessage: '可用磁盘空间不足' })
return {
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || 'development',
checks: { database: 'ok', uploads: 'writable', freeBytes },
}
})

View File

@@ -0,0 +1,43 @@
import { createReadStream, statSync } from 'node:fs'
import { resolve, sep } from 'node:path'
const contentTypes: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
}
export default defineEventHandler((event) => {
const requested = getRouterParam(event, 'path') || ''
const uploadsRoot = resolve(process.cwd(), 'data', 'uploads')
const legacyRoot = resolve(process.cwd(), 'data', 'media')
let mediaRoot = uploadsRoot
let filePath = resolve(mediaRoot, requested)
if (!filePath.startsWith(`${mediaRoot}${sep}`)) {
throw createError({ statusCode: 400, statusMessage: '无效的文件路径' })
}
let fileStat
try {
fileStat = statSync(filePath)
} catch {
const legacyPath = resolve(legacyRoot, requested)
if (!legacyPath.startsWith(`${legacyRoot}${sep}`)) throw createError({ statusCode: 400, statusMessage: '无效的文件路径' })
try { fileStat = statSync(legacyPath); filePath = legacyPath; mediaRoot = legacyRoot }
catch { throw createError({ statusCode: 404, statusMessage: '图片不存在' }) }
}
if (!fileStat.isFile()) {
throw createError({ statusCode: 404, statusMessage: '图片不存在' })
}
const extension = filePath.split('.').pop()?.toLowerCase() || ''
const contentType = contentTypes[extension]
if (!contentType) {
throw createError({ statusCode: 415, statusMessage: '不支持的文件类型' })
}
setHeader(event, 'Content-Type', contentType)
setHeader(event, 'Content-Length', fileStat.size)
setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable')
return sendStream(event, createReadStream(filePath))
})

View File

@@ -0,0 +1,6 @@
import { readSiteDocument } from '../../utils/db'
export default defineEventHandler((event) => {
setResponseHeader(event, 'Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
return readSiteDocument('published')
})