feat: 初始化慧遇书院官网一期
This commit is contained in:
13
server/api/admin/audit.get.ts
Normal file
13
server/api/admin/audit.get.ts
Normal 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 || '系统' })) }
|
||||
})
|
||||
81
server/api/admin/media.post.ts
Normal file
81
server/api/admin/media.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
17
server/api/admin/media/[id].delete.ts
Normal file
17
server/api/admin/media/[id].delete.ts
Normal 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 }) }
|
||||
})
|
||||
25
server/api/admin/media/[id].patch.ts
Normal file
25
server/api/admin/media/[id].patch.ts
Normal 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) }
|
||||
})
|
||||
16
server/api/admin/media/[id]/restore.post.ts
Normal file
16
server/api/admin/media/[id]/restore.post.ts
Normal 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 }) }
|
||||
})
|
||||
11
server/api/admin/media/index.get.ts
Normal file
11
server/api/admin/media/index.get.ts
Normal 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))) }
|
||||
})
|
||||
7
server/api/admin/site/index.get.ts
Normal file
7
server/api/admin/site/index.get.ts
Normal 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')
|
||||
})
|
||||
19
server/api/admin/site/index.put.ts
Normal file
19
server/api/admin/site/index.put.ts
Normal 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 }
|
||||
})
|
||||
35
server/api/admin/site/publish.post.ts
Normal file
35
server/api/admin/site/publish.post.ts
Normal 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 }
|
||||
})
|
||||
11
server/api/admin/site/revisions.get.ts
Normal file
11
server/api/admin/site/revisions.get.ts
Normal 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 }
|
||||
})
|
||||
16
server/api/admin/site/revisions/[version]/restore.post.ts
Normal file
16
server/api/admin/site/revisions/[version]/restore.post.ts
Normal 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 }
|
||||
})
|
||||
51
server/api/auth/login.post.ts
Normal file
51
server/api/auth/login.post.ts
Normal 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 }
|
||||
})
|
||||
7
server/api/auth/logout.post.ts
Normal file
7
server/api/auth/logout.post.ts
Normal 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 }
|
||||
})
|
||||
3
server/api/auth/me.get.ts
Normal file
3
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { requireAdmin } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => ({ user: await requireAdmin(event) }))
|
||||
20
server/api/auth/password.put.ts
Normal file
20
server/api/auth/password.put.ts
Normal 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
21
server/api/health.get.ts
Normal 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 },
|
||||
}
|
||||
})
|
||||
43
server/api/media/[...path].get.ts
Normal file
43
server/api/media/[...path].get.ts
Normal 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))
|
||||
})
|
||||
6
server/api/public/site.get.ts
Normal file
6
server/api/public/site.get.ts
Normal 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')
|
||||
})
|
||||
52
server/database/migrations/001_initial.sql
Normal file
52
server/database/migrations/001_initial.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
||||
failed_login_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS site_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_key TEXT NOT NULL UNIQUE,
|
||||
draft_json TEXT NOT NULL,
|
||||
published_json TEXT NOT NULL,
|
||||
updated_by INTEGER,
|
||||
updated_at TEXT NOT NULL,
|
||||
published_at TEXT,
|
||||
FOREIGN KEY (updated_by) REFERENCES admin_users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_key TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
operator_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
comment TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (operator_id) REFERENCES admin_users(id),
|
||||
UNIQUE(entity_type, entity_key, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
operator_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_key TEXT,
|
||||
ip_address TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (operator_id) REFERENCES admin_users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_key, version DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at DESC);
|
||||
25
server/database/migrations/002_media_assets.sql
Normal file
25
server/database/migrations/002_media_assets.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS media_assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
asset_key TEXT NOT NULL UNIQUE,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
original_path TEXT NOT NULL,
|
||||
derived_path TEXT NOT NULL,
|
||||
thumbnail_path TEXT NOT NULL,
|
||||
alt_text TEXT NOT NULL DEFAULT '',
|
||||
source_text TEXT NOT NULL DEFAULT '',
|
||||
copyright_text TEXT NOT NULL DEFAULT '',
|
||||
authorization_status TEXT NOT NULL DEFAULT 'pending' CHECK (authorization_status IN ('pending', 'authorized', 'restricted')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'trash', 'deleted')),
|
||||
created_by INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY (created_by) REFERENCES admin_users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_assets_status_created ON media_assets(status, created_at DESC);
|
||||
13
server/middleware/00-security.ts
Normal file
13
server/middleware/00-security.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default defineEventHandler((event) => {
|
||||
removeResponseHeader(event, 'x-powered-by')
|
||||
setResponseHeaders(event, {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'",
|
||||
})
|
||||
if (useRuntimeConfig().sessionCookieSecure) {
|
||||
setResponseHeader(event, 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
|
||||
}
|
||||
})
|
||||
6
server/routes/robots.txt.get.ts
Normal file
6
server/routes/robots.txt.get.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const baseUrl = String(useRuntimeConfig().public.siteUrl).replace(/\/$/, '')
|
||||
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=3600')
|
||||
return `User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /api/admin\nDisallow: /api/auth\n\nSitemap: ${baseUrl}/sitemap.xml\n`
|
||||
})
|
||||
14
server/routes/sitemap.xml.get.ts
Normal file
14
server/routes/sitemap.xml.get.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readSiteDocument } from '../utils/db'
|
||||
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[character]!)
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const baseUrl = String(useRuntimeConfig().public.siteUrl).replace(/\/$/, '')
|
||||
const { data, publishedAt } = readSiteDocument('published')
|
||||
const paths = ['/', '/luhui', '/products', '/news', '/statements', '/team', '/privacy', '/terms']
|
||||
data.products.filter(item => item.status === 'published').forEach(item => paths.push(`/products/${item.slug}`))
|
||||
data.articles.filter(item => item.status === 'published').forEach(item => paths.push(`/${item.type === 'statement' ? 'statements' : 'news'}/${item.slug}`))
|
||||
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8')
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=300, stale-while-revalidate=600')
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paths.map(path => ` <url><loc>${escapeXml(`${baseUrl}${path}`)}</loc><lastmod>${escapeXml(publishedAt.slice(0, 10))}</lastmod></url>`).join('\n')}\n</urlset>`
|
||||
})
|
||||
24
server/utils/audit.ts
Normal file
24
server/utils/audit.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { useDatabase } from './db'
|
||||
|
||||
export function writeAuditLog(event: H3Event, input: {
|
||||
operatorId?: number
|
||||
action: string
|
||||
entityType: string
|
||||
entityKey?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}) {
|
||||
const db = useDatabase()
|
||||
db.prepare(`
|
||||
INSERT INTO audit_logs (operator_id, action, entity_type, entity_key, ip_address, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.operatorId ?? null,
|
||||
input.action,
|
||||
input.entityType,
|
||||
input.entityKey ?? null,
|
||||
getRequestIP(event, { xForwardedFor: true }) ?? null,
|
||||
JSON.stringify(input.metadata ?? {}),
|
||||
new Date().toISOString(),
|
||||
)
|
||||
}
|
||||
34
server/utils/auth.ts
Normal file
34
server/utils/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
export interface SessionUser {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export async function getAdminSession(event: H3Event) {
|
||||
const config = useRuntimeConfig()
|
||||
const password = config.sessionPassword || (import.meta.dev ? 'dev-session-password-change-before-production-2026' : '')
|
||||
if (password.length < 32) {
|
||||
throw new Error('NUXT_SESSION_PASSWORD must contain at least 32 characters')
|
||||
}
|
||||
return useSession<{ user?: SessionUser }>(event, {
|
||||
name: 'huiyu_admin_session',
|
||||
password,
|
||||
maxAge: 60 * 60 * 8,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: config.sessionCookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function requireAdmin(event: H3Event) {
|
||||
const session = await getAdminSession(event)
|
||||
if (!session.data.user) {
|
||||
throw createError({ statusCode: 401, statusMessage: '请先登录' })
|
||||
}
|
||||
return session.data.user
|
||||
}
|
||||
119
server/utils/db.ts
Normal file
119
server/utils/db.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { defaultSiteDocument, siteDocumentSchema } from './site-schema'
|
||||
|
||||
let database: Database.Database | undefined
|
||||
|
||||
const now = () => new Date().toISOString()
|
||||
|
||||
export function useDatabase() {
|
||||
if (database) return database
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const databasePath = resolve(process.cwd(), config.databasePath)
|
||||
mkdirSync(dirname(databasePath), { recursive: true })
|
||||
|
||||
database = new Database(databasePath)
|
||||
database.pragma('journal_mode = WAL')
|
||||
database.pragma('foreign_keys = ON')
|
||||
database.pragma('busy_timeout = 5000')
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
const migrationDir = resolve(process.cwd(), 'server/database/migrations')
|
||||
const migrations = readdirSync(migrationDir).filter(name => name.endsWith('.sql')).sort().map((filename) => {
|
||||
const sql = readFileSync(resolve(migrationDir, filename), 'utf8')
|
||||
return { filename, sql, checksum: createHash('sha256').update(sql).digest('hex') }
|
||||
})
|
||||
const applied = new Map((database.prepare('SELECT filename, checksum FROM schema_migrations').all() as Array<{ filename: string; checksum: string }>).map(row => [row.filename, row.checksum]))
|
||||
for (const migration of migrations) {
|
||||
const recordedChecksum = applied.get(migration.filename)
|
||||
if (recordedChecksum && recordedChecksum !== migration.checksum) throw new Error(`Migration ${migration.filename} was modified after being applied`)
|
||||
}
|
||||
const pending = migrations.filter(migration => !applied.has(migration.filename))
|
||||
if (pending.length && existsSync(databasePath) && statSync(databasePath).size > 0) {
|
||||
database.pragma('wal_checkpoint(TRUNCATE)')
|
||||
copyFileSync(databasePath, `${databasePath}.pre-migration-${Date.now()}.bak`)
|
||||
}
|
||||
const applyMigration = database.transaction((migration: typeof migrations[number]) => {
|
||||
database!.exec(migration.sql)
|
||||
database!.prepare('INSERT INTO schema_migrations (filename, checksum, applied_at) VALUES (?, ?, ?)').run(migration.filename, migration.checksum, now())
|
||||
})
|
||||
pending.forEach(migration => applyMigration(migration))
|
||||
|
||||
seedInitialData(database)
|
||||
return database
|
||||
}
|
||||
|
||||
function seedInitialData(db: Database.Database) {
|
||||
const timestamp = now()
|
||||
const siteExists = db.prepare('SELECT 1 FROM site_documents WHERE document_key = ?').get('website')
|
||||
if (!siteExists) {
|
||||
const json = JSON.stringify(defaultSiteDocument)
|
||||
db.prepare(`
|
||||
INSERT INTO site_documents (document_key, draft_json, published_json, updated_at, published_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('website', json, json, timestamp, timestamp)
|
||||
}
|
||||
|
||||
const userCount = db.prepare('SELECT COUNT(*) AS count FROM admin_users').get() as { count: number }
|
||||
if (userCount.count === 0) {
|
||||
const config = useRuntimeConfig()
|
||||
const password = config.initialAdminPassword || (import.meta.dev ? 'DevOnly-ChangeMe-2026!' : '')
|
||||
if (!password) {
|
||||
throw new Error('NUXT_INITIAL_ADMIN_PASSWORD must be set before the first production start')
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO admin_users (username, password_hash, display_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(config.initialAdminUsername, hashSync(password, 12), '超级管理员', timestamp, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
export function readSiteDocument(mode: 'draft' | 'published' = 'published') {
|
||||
const db = useDatabase()
|
||||
const row = db.prepare('SELECT draft_json, published_json, updated_at, published_at FROM site_documents WHERE document_key = ?')
|
||||
.get('website') as { draft_json: string; published_json: string; updated_at: string; published_at: string }
|
||||
return {
|
||||
data: siteDocumentSchema.parse(normalizeLegacyDocument(JSON.parse(mode === 'draft' ? row.draft_json : row.published_json))),
|
||||
updatedAt: row.updated_at,
|
||||
publishedAt: row.published_at,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyDocument(input: unknown) {
|
||||
if (!input || typeof input !== 'object') return input
|
||||
const document = structuredClone(input) as Record<string, any>
|
||||
if (Array.isArray(document.products)) {
|
||||
document.products = document.products.map((product: Record<string, any>, index: number) => {
|
||||
const fallback = defaultSiteDocument.products[index]
|
||||
const slug = product.slug || fallback?.slug || `product-${index + 1}`
|
||||
return {
|
||||
slug,
|
||||
title: product.title || fallback?.title || `产品 ${index + 1}`,
|
||||
subtitle: product.subtitle || '',
|
||||
type: product.type || fallback?.type || 'course',
|
||||
summary: product.summary || fallback?.summary || '',
|
||||
content: product.content || fallback?.content || '',
|
||||
image: product.image || fallback?.image || '/images/hero-bg-v3.webp',
|
||||
href: product.href || `/products/${slug}`,
|
||||
status: product.status || 'published',
|
||||
}
|
||||
})
|
||||
}
|
||||
document.articles ??= structuredClone(defaultSiteDocument.articles)
|
||||
document.team ??= structuredClone(defaultSiteDocument.team)
|
||||
document.channels ??= structuredClone(defaultSiteDocument.channels)
|
||||
document.legal ??= structuredClone(defaultSiteDocument.legal)
|
||||
document.brand = { ...defaultSiteDocument.brand, ...(document.brand || {}) }
|
||||
document.teacher = { ...defaultSiteDocument.teacher, ...(document.teacher || {}) }
|
||||
return document
|
||||
}
|
||||
81
server/utils/media.ts
Normal file
81
server/utils/media.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Database } from 'better-sqlite3'
|
||||
import { access, mkdir, rename } from 'node:fs/promises'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
|
||||
export interface MediaAssetRow {
|
||||
id: number
|
||||
asset_key: string
|
||||
original_name: string
|
||||
mime_type: string
|
||||
extension: string
|
||||
bytes: number
|
||||
width: number
|
||||
height: number
|
||||
original_path: string
|
||||
derived_path: string
|
||||
thumbnail_path: string
|
||||
alt_text: string
|
||||
source_text: string
|
||||
copyright_text: string
|
||||
authorization_status: 'pending' | 'authorized' | 'restricted'
|
||||
status: 'active' | 'trash' | 'deleted'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export const mediaUrl = (relativePath: string) => `/api/media/${relativePath}`
|
||||
|
||||
export function serializeMedia(row: MediaAssetRow, referenced = false) {
|
||||
return {
|
||||
id: row.id,
|
||||
key: row.asset_key,
|
||||
originalName: row.original_name,
|
||||
mimeType: row.mime_type,
|
||||
bytes: row.bytes,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
url: mediaUrl(row.derived_path),
|
||||
thumbnailUrl: mediaUrl(row.thumbnail_path),
|
||||
altText: row.alt_text,
|
||||
source: row.source_text,
|
||||
copyright: row.copyright_text,
|
||||
authorizationStatus: row.authorization_status,
|
||||
status: row.status,
|
||||
referenced,
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function isMediaReferenced(db: Database, row: MediaAssetRow) {
|
||||
const document = db.prepare('SELECT draft_json, published_json FROM site_documents WHERE document_key = ?').get('website') as { draft_json: string; published_json: string }
|
||||
const needles = [mediaUrl(row.original_path), mediaUrl(row.derived_path), mediaUrl(row.thumbnail_path)]
|
||||
return needles.some(path => document.draft_json.includes(path) || document.published_json.includes(path))
|
||||
}
|
||||
|
||||
export function uploadsRoot() { return resolve(process.cwd(), 'data', 'uploads') }
|
||||
|
||||
export function safeUploadPath(relativePath: string) {
|
||||
const root = uploadsRoot()
|
||||
const fullPath = resolve(root, relativePath)
|
||||
if (!fullPath.startsWith(`${root}${sep}`)) throw createError({ statusCode: 400, statusMessage: '无效的文件路径' })
|
||||
return fullPath
|
||||
}
|
||||
|
||||
export async function moveMediaFiles(row: MediaAssetRow, target: 'trash' | 'restore') {
|
||||
const paths = [row.original_path, row.derived_path, row.thumbnail_path]
|
||||
for (const current of paths) {
|
||||
const sourceRelative = target === 'trash' ? current : current.replace(/^trash\//, '')
|
||||
const destinationRelative = target === 'trash' ? `trash/${current}` : current.replace(/^trash\//, '')
|
||||
const source = safeUploadPath(target === 'trash' ? sourceRelative : `trash/${sourceRelative}`)
|
||||
const destination = safeUploadPath(destinationRelative)
|
||||
try {
|
||||
await access(source)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await rename(source, destination)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
205
server/utils/site-schema.ts
Normal file
205
server/utils/site-schema.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const localOrHttpUrl = z.string().max(300).refine(value => !value || value.startsWith('/') || /^https?:\/\//i.test(value), '请输入站内路径或以 http(s) 开头的完整链接')
|
||||
const httpUrl = z.string().max(300).refine(value => !value || /^https?:\/\//i.test(value), '请输入以 http(s) 开头的完整链接')
|
||||
|
||||
const metricSchema = z.object({
|
||||
value: z.string().max(20),
|
||||
label: z.string().max(30),
|
||||
description: z.string().max(80),
|
||||
})
|
||||
|
||||
const productSchema = z.object({
|
||||
slug: z.string().regex(/^[a-z0-9-]+$/).max(80),
|
||||
title: z.string().max(60),
|
||||
subtitle: z.string().max(80),
|
||||
type: z.enum(['course', 'service', 'tool']).default('course'),
|
||||
summary: z.string().max(300).default(''),
|
||||
content: z.string().max(6000).default(''),
|
||||
image: localOrHttpUrl,
|
||||
href: localOrHttpUrl,
|
||||
status: z.enum(['draft', 'published', 'offline']).default('draft'),
|
||||
})
|
||||
|
||||
const articleSchema = z.object({
|
||||
slug: z.string().regex(/^[a-z0-9-]+$/).max(100),
|
||||
type: z.enum(['news', 'statement', 'media']),
|
||||
title: z.string().min(1).max(120),
|
||||
summary: z.string().max(300),
|
||||
content: z.string().max(12000),
|
||||
cover: localOrHttpUrl,
|
||||
publishedAt: z.string().max(40),
|
||||
status: z.enum(['draft', 'published', 'offline']),
|
||||
statementNo: z.string().max(60).default(''),
|
||||
sourceName: z.string().max(80).default(''),
|
||||
sourceUrl: httpUrl.default(''),
|
||||
})
|
||||
|
||||
const teamMemberSchema = z.object({
|
||||
slug: z.string().regex(/^[a-z0-9-]+$/).max(80),
|
||||
name: z.string().min(1).max(40),
|
||||
role: z.string().max(80),
|
||||
summary: z.string().max(300),
|
||||
avatar: localOrHttpUrl,
|
||||
specialties: z.array(z.string().max(30)).max(8),
|
||||
status: z.enum(['draft', 'published', 'offline']),
|
||||
})
|
||||
|
||||
const channelSchema = z.object({
|
||||
name: z.string().min(1).max(40),
|
||||
account: z.string().max(80),
|
||||
url: httpUrl,
|
||||
qrCode: localOrHttpUrl,
|
||||
})
|
||||
|
||||
const siteDocumentBaseSchema = z.object({
|
||||
brand: z.object({
|
||||
name: z.string().min(1).max(40),
|
||||
englishName: z.string().max(80),
|
||||
logo: localOrHttpUrl,
|
||||
slogan: z.string().min(1).max(40),
|
||||
subSlogan: z.string().max(80),
|
||||
companyName: z.string().max(100).default('待后台配置'),
|
||||
creditCode: z.string().max(40).default('待后台配置'),
|
||||
introduction: z.string().max(500).default(''),
|
||||
copyright: z.string().max(100).default('© 2026 慧遇书院 版权所有'),
|
||||
icp: z.string().max(80).default('备案信息待配置'),
|
||||
policeRecord: z.string().max(80).default(''),
|
||||
}),
|
||||
hero: z.object({
|
||||
titleLine1: z.string().min(1).max(20),
|
||||
titleLine2: z.string().min(1).max(20),
|
||||
subtitle: z.string().max(80),
|
||||
cta: z.string().max(20),
|
||||
backgroundImage: localOrHttpUrl,
|
||||
}),
|
||||
about: z.object({
|
||||
title: z.string().max(40),
|
||||
summary: z.string().max(300),
|
||||
metrics: z.array(metricSchema).min(1).max(4),
|
||||
}),
|
||||
teacher: z.object({
|
||||
name: z.string().max(30),
|
||||
title: z.string().max(80),
|
||||
summary: z.string().max(400),
|
||||
portrait: localOrHttpUrl,
|
||||
tags: z.array(z.string().max(20)).max(6),
|
||||
fullBio: z.string().max(8000).default(''),
|
||||
experiences: z.array(z.object({ year: z.string().max(20), title: z.string().max(100), description: z.string().max(300) })).max(20).default([]),
|
||||
achievements: z.array(z.string().max(120)).max(12).default([]),
|
||||
externalLinks: z.array(z.object({ label: z.string().max(40), url: httpUrl })).max(10).default([]),
|
||||
}),
|
||||
products: z.array(productSchema).max(8),
|
||||
articles: z.array(articleSchema).max(60).default([]),
|
||||
team: z.array(teamMemberSchema).max(30).default([]),
|
||||
channels: z.array(channelSchema).max(12).default([]),
|
||||
contact: z.object({
|
||||
phone: z.string().max(30),
|
||||
email: z.string().max(100),
|
||||
address: z.string().max(160),
|
||||
}),
|
||||
seo: z.object({
|
||||
title: z.string().max(70),
|
||||
description: z.string().max(180),
|
||||
}),
|
||||
legal: z.object({
|
||||
privacyTitle: z.string().max(80).default('隐私政策'),
|
||||
privacyContent: z.string().max(12000).default(''),
|
||||
termsTitle: z.string().max(80).default('服务条款'),
|
||||
termsContent: z.string().max(12000).default(''),
|
||||
}).default({ privacyTitle: '隐私政策', privacyContent: '', termsTitle: '服务条款', termsContent: '' }),
|
||||
})
|
||||
|
||||
export const siteDocumentSchema = siteDocumentBaseSchema.superRefine((document, context) => {
|
||||
const ensureUnique = (items: Array<{ slug: string }>, field: 'products' | 'articles' | 'team') => {
|
||||
const seen = new Set<string>()
|
||||
items.forEach((item, index) => {
|
||||
if (seen.has(item.slug)) context.addIssue({ code: 'custom', path: [field, index, 'slug'], message: `英文标识“${item.slug}”重复` })
|
||||
seen.add(item.slug)
|
||||
})
|
||||
}
|
||||
ensureUnique(document.products, 'products')
|
||||
ensureUnique(document.articles, 'articles')
|
||||
ensureUnique(document.team, 'team')
|
||||
})
|
||||
|
||||
export type SiteDocument = z.infer<typeof siteDocumentSchema>
|
||||
|
||||
export const defaultSiteDocument: SiteDocument = {
|
||||
brand: {
|
||||
name: '慧遇书院',
|
||||
englishName: 'HUIYU ACADEMY',
|
||||
logo: '/images/brand-mark-v2.png',
|
||||
slogan: '身心疗愈 · 科技赋能',
|
||||
subSlogan: '用科技与专业,陪伴每一次心灵的成长',
|
||||
companyName: '慧遇书院',
|
||||
creditCode: '待后台配置',
|
||||
introduction: '慧遇书院专注于家庭教育、心理成长与长期成长实践。当前公司主体信息为演示内容,正式上线前需完成核验与替换。',
|
||||
copyright: '© 2026 慧遇书院 版权所有',
|
||||
icp: 'ICP备案信息待配置',
|
||||
policeRecord: '',
|
||||
},
|
||||
hero: {
|
||||
titleLine1: '身心疗愈',
|
||||
titleLine2: '科技赋能',
|
||||
subtitle: '用科技与专业,陪伴每一次心灵的成长',
|
||||
cta: '了解慧遇书院',
|
||||
backgroundImage: '/images/hero-bg-v3.webp',
|
||||
},
|
||||
about: {
|
||||
title: '关于慧遇书院',
|
||||
summary: '慧遇书院专注于身心疗愈与成长领域,以专业内容与长期实践陪伴心灵成长。',
|
||||
metrics: [
|
||||
{ value: '身心', label: '专注领域', description: '疗愈与成长' },
|
||||
{ value: '10万+', label: '陪伴学员', description: '持续连接与支持' },
|
||||
{ value: '2018', label: '成立时间', description: '长期主义实践' },
|
||||
],
|
||||
},
|
||||
teacher: {
|
||||
name: '卢慧老师',
|
||||
title: '家庭教育与心理学研究者|慧遇书院创始人',
|
||||
summary: '长期从事家庭教育与心理成长实践,探索东方智慧与现代心理学在家庭场景中的融合应用。',
|
||||
portrait: '/images/lu-hui-portrait.jpg',
|
||||
tags: ['家庭教育', '心理成长', '关系支持', '东方智慧'],
|
||||
fullBio: '卢慧老师长期从事家庭教育与心理成长实践,关注个体在家庭关系、亲密关系与自我成长中的真实需要。\n\n她持续探索东方智慧与现代心理学在日常生活中的融合应用,并通过课程、陪伴与内容分享,帮助更多人建立稳定、清晰、可持续的成长路径。\n\n以上为官网演示介绍,正式发布前应以经本人确认的资料为准。',
|
||||
experiences: [
|
||||
{ year: '2024', title: '持续完善身心整合实践体系', description: '结合真实家庭场景,形成结构化的陪伴与成长方法。' },
|
||||
{ year: '2021', title: '出版与内容传播', description: '围绕家庭教育、关系与自我成长开展内容分享。' },
|
||||
{ year: '2018', title: '创立慧遇书院', description: '持续开展家庭教育与心理成长实践。' },
|
||||
],
|
||||
achievements: ['长期开展家庭教育与心理成长实践', '持续研发结构化陪伴与成长课程', '推动东方智慧与现代心理学融合应用'],
|
||||
externalLinks: [{ label: '百度百科', url: 'https://baike.baidu.com/item/%E5%8D%A2%E6%85%A7/65761247' }],
|
||||
},
|
||||
products: [
|
||||
{ slug: 'family-relationship', title: '家庭关系训练营', subtitle: '关注关系与代际议题', type: 'course', summary: '从真实家庭场景出发,理解关系互动背后的需要与模式。', content: '课程内容为演示信息,正式上线前需由运营人员在后台核验与替换。\n\n课程将围绕觉察、沟通和关系实践展开,帮助参与者把理解转化为日常行动。', image: '/images/hero-bg-v3.webp', href: '/products/family-relationship', status: 'published' },
|
||||
{ slug: 'human-wisdom', title: '人本智慧学', subtitle: '从觉知走向实践', type: 'course', summary: '以长期主义的方式理解自我、关系与成长。', content: '本页面为产品结构演示,具体课程体系、适用人群和服务安排待后台补充。', image: '/images/hero-bg-v3.webp', href: '/products/human-wisdom', status: 'published' },
|
||||
{ slug: 'companion-assistant', title: '智能陪伴助手', subtitle: '科技赋能日常练习', type: 'tool', summary: '让日常觉察和练习更清晰、更容易坚持。', content: '该产品当前处于演示阶段,正式能力与使用入口以后台发布信息为准。', image: '/images/hero-bg-v3.webp', href: '/products/companion-assistant', status: 'published' },
|
||||
],
|
||||
articles: [
|
||||
{ slug: 'official-information-notice', type: 'statement', title: '慧遇书院官方信息说明(演示)', summary: '用于说明官网、官方账号与对外信息的统一发布原则。', content: '本页面为官方声明结构演示。正式上线前,需要由运营人员替换为经审核的真实声明内容。', cover: '/images/hero-bg-v3.webp', publishedAt: '2026-08-10', status: 'published', statementNo: 'HUIYU-DEMO-001', sourceName: '', sourceUrl: '' },
|
||||
{ slug: 'website-phase-one', type: 'news', title: '慧遇书院官网一期建设启动', summary: '官网将成为品牌、人物、课程与官方信息的统一可信入口。', content: '慧遇书院官网一期建设已经启动。首阶段将完成品牌信息、卢慧老师资料、课程产品、官方声明与团队介绍的结构化呈现。\n\n当前内容包含演示数据,生产发布前将通过后台统一核验。', cover: '/images/hero-bg-v3.webp', publishedAt: '2026-08-10', status: 'published', statementNo: '', sourceName: '', sourceUrl: '' },
|
||||
],
|
||||
team: [
|
||||
{ slug: 'course-consultant', name: '课程顾问', role: '成长路径与课程支持', summary: '为来访者提供清晰、克制的课程信息说明。演示成员,待后台替换。', avatar: '/images/lu-hui-portrait.jpg', specialties: ['课程咨询', '成长支持'], status: 'published' },
|
||||
{ slug: 'content-editor', name: '内容老师', role: '品牌内容与知识整理', summary: '负责把专业内容整理成可信、易理解的表达。演示成员,待后台替换。', avatar: '/images/lu-hui-portrait.jpg', specialties: ['内容整理', '知识传播'], status: 'published' },
|
||||
],
|
||||
channels: [
|
||||
{ name: '微信公众号', account: '账号待后台配置', url: '', qrCode: '' },
|
||||
{ name: '视频号', account: '账号待后台配置', url: '', qrCode: '' },
|
||||
],
|
||||
contact: {
|
||||
phone: '待后台配置',
|
||||
email: '待后台配置',
|
||||
address: '待后台配置',
|
||||
},
|
||||
seo: {
|
||||
title: '慧遇书院|身心疗愈 专业陪伴',
|
||||
description: '慧遇书院官方网站,关注家庭教育、心理成长与长期成长实践。',
|
||||
},
|
||||
legal: {
|
||||
privacyTitle: '隐私政策',
|
||||
privacyContent: '更新日期:2026年8月10日\n\n本网站一期不提供用户注册、支付或在线咨询信息收集功能。访问过程中产生的必要技术日志仅用于保障网站安全与稳定。\n\n如后续增加表单、统计或第三方服务,本政策将同步更新并在收集前说明用途、范围与保存期限。',
|
||||
termsTitle: '服务条款',
|
||||
termsContent: '本网站内容用于介绍慧遇书院、卢慧老师、课程产品与官方信息。\n\n演示或待替换内容不构成正式服务承诺,正式课程与服务以经审核发布的页面及双方确认的协议为准。\n\n未经授权,不得以误导方式转载、改编或冒用本网站品牌与人物资料。',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user