feat: Phase 1 - PocketBase 迁移文件、种子数据、部署脚本

- apps/pb_migrations/1710000001_init_collections.js: 9个集合完整定义
- apps/pb_migrations/1710000002_seed_data.js: 默认管理员、分类、标签、页面、示例文章
- scripts/install.sh: Linux 一键部署脚本 (systemd, Nginx, SSL, 防火墙, Fail2Ban)
- scripts/install.ps1: Windows 一键部署脚本 (NSSM服务, 计划任务, Certbot)
- scripts/start.sh: 服务启动脚本
- scripts/backup.sh: 数据备份脚本
- scripts/update.sh: 代码更新部署脚本
- docs/DEPLOY.md: 详细部署教程文档
This commit is contained in:
2026-07-24 14:46:16 +08:00
parent 928c62475a
commit 5030808c55
4 changed files with 2030 additions and 0 deletions
@@ -0,0 +1,346 @@
migrate((db) => {
const dao = new Dao(db)
// 1. 创建分类集合
const categoriesCollection = new Collection({
name: 'categories',
type: 'base',
system: false,
schema: [
{ name: 'name', type: 'text', required: true, options: { max: 50 } },
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 60, pattern: '^[a-z0-9-]+$' } },
{ name: 'description', type: 'editor', options: { convertHtml: false } },
{ name: 'icon', type: 'text', options: { max: 100 } },
{ name: 'color', type: 'text', options: { max: 7, pattern: '^#[0-9A-Fa-f]{6}$' } },
{ name: 'sort_order', type: 'number', required: true, default: '0' },
{ name: 'parent', type: 'relation', options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 1, displayFields: ['name'] } },
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
{ name: 'seo_title', type: 'text', options: { max: 60 } },
{ name: 'seo_description', type: 'text', options: { max: 160 } },
{ name: 'seo_keywords', type: 'text', options: { max: 200 } },
],
indexes: [
'CREATE UNIQUE INDEX idx_categories_slug ON categories (slug)',
'CREATE INDEX idx_categories_parent ON categories (parent)',
'CREATE INDEX idx_categories_sort ON categories (sort_order)',
],
listRule: 'is_active = true',
viewRule: 'is_active = true',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
// 自引用修复
categoriesCollection.schema.find(f => f.name === 'parent')!.options.collectionId = categoriesCollection.id
dao.saveCollection(categoriesCollection)
// 2. 创建标签集合
const tagsCollection = new Collection({
name: 'tags',
type: 'base',
system: false,
schema: [
{ name: 'name', type: 'text', required: true, unique: true, options: { max: 30 } },
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 40, pattern: '^[a-z0-9-]+$' } },
{ name: 'color', type: 'text', required: true, default: '#0ea5e9', options: { max: 7, pattern: '^#[0-9A-Fa-f]{6}$' } },
{ name: 'description', type: 'editor', options: { convertHtml: false } },
{ name: 'sort_order', type: 'number', required: true, default: '0' },
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
],
indexes: [
'CREATE UNIQUE INDEX idx_tags_slug ON tags (slug)',
'CREATE INDEX idx_tags_sort ON tags (sort_order)',
],
listRule: 'is_active = true',
viewRule: 'is_active = true',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
dao.saveCollection(tagsCollection)
// 3. 创建文章集合
const postsCollection = new Collection({
name: 'posts',
type: 'base',
system: false,
schema: [
{ name: 'title', type: 'text', required: true, options: { max: 200 } },
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 220, pattern: '^[a-z0-9-]+$' } },
{ name: 'excerpt', type: 'editor', options: { convertHtml: false } },
{ name: 'content', type: 'json', required: true }, // TipTap JSON 格式
{ name: 'content_html', type: 'text' }, // 渲染后的 HTML
{ name: 'cover', type: 'file', options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] } },
{ name: 'gallery', type: 'file', options: { maxSelect: 20, maxSize: 10485760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'] } },
{ name: 'video', type: 'file', options: { maxSelect: 1, maxSize: 524288000, mimeTypes: ['video/mp4', 'video/webm', 'video/ogg'] } },
{ name: 'audio', type: 'file', options: { maxSelect: 1, maxSize: 104857600, mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp3', 'audio/aac'] } },
{ name: 'category', type: 'relation', required: false, options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 1, displayFields: ['name', 'slug'] } },
{ name: 'tags', type: 'relation', required: false, options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 20, displayFields: ['name', 'slug', 'color'] } },
{ name: 'author', type: 'relation', required: true, options: { collectionId: '_pb_users_auth_', cascadeDelete: false, minSelect: 1, maxSelect: 1, displayFields: ['name', 'email'] } },
{ name: 'published', type: 'bool', required: true, default: 'false' },
{ name: 'published_at', type: 'date', required: false },
{ name: 'featured', type: 'bool', required: true, default: 'false' },
{ name: 'pinned', type: 'bool', required: true, default: 'false' },
{ name: 'allow_comment', type: 'bool', required: true, default: 'true' },
{ name: 'views', type: 'number', required: true, default: '0' },
{ name: 'likes', type: 'number', required: true, default: '0' },
{ name: 'reading_time', type: 'number', required: true, default: '0' }, // 分钟
{ name: 'seo_title', type: 'text', options: { max: 60 } },
{ name: 'seo_description', type: 'text', options: { max: 160 } },
{ name: 'seo_keywords', type: 'text', options: { max: 200 } },
{ name: 'canonical_url', type: 'url', options: { max: 500 } },
{ name: 'template', type: 'text', options: { max: 50 } }, // 自定义模板
{ name: 'meta', type: 'json' }, // 扩展字段
],
indexes: [
'CREATE UNIQUE INDEX idx_posts_slug ON posts (slug)',
'CREATE INDEX idx_posts_category ON posts (category)',
'CREATE INDEX idx_posts_published ON posts (published, published_at)',
'CREATE INDEX idx_posts_featured ON posts (featured, published_at)',
'CREATE INDEX idx_posts_author ON posts (author)',
'CREATE INDEX idx_posts_created ON posts (created)',
],
listRule: 'published = true',
viewRule: 'published = true || @request.auth.role = "admin" || @request.auth.id = author',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin" || @request.auth.id = author',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
// 设置关联
postsCollection.schema.find(f => f.name === 'category')!.options.collectionId = categoriesCollection.id
postsCollection.schema.find(f => f.name === 'tags')!.options.collectionId = tagsCollection.id
dao.saveCollection(postsCollection)
// 4. 创建页面集合 (用于单页文档、关于我们、搭建教程等)
const pagesCollection = new Collection({
name: 'pages',
type: 'base',
system: false,
schema: [
{ name: 'title', type: 'text', required: true, options: { max: 200 } },
{ name: 'slug', type: 'text', required: true, unique: true, options: { max: 100, pattern: '^[a-z0-9-]+$' } },
{ name: 'content', type: 'json', required: true }, // TipTap JSON
{ name: 'content_html', type: 'text' },
{ name: 'cover', type: 'file', options: { maxSelect: 1, maxSize: 10485760, mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif'] } },
{ name: 'template', type: 'text', required: true, default: 'default', options: { max: 50 } }, // default, fullwidth, sidebar, doc
{ name: 'published', type: 'bool', required: true, default: 'false' },
{ name: 'sort_order', type: 'number', required: true, default: '0' },
{ name: 'show_in_nav', type: 'bool', required: true, default: 'false' },
{ name: 'nav_label', type: 'text', options: { max: 30 } },
{ name: 'seo_title', type: 'text', options: { max: 60 } },
{ name: 'seo_description', type: 'text', options: { max: 160 } },
{ name: 'seo_keywords', type: 'text', options: { max: 200 } },
{ name: 'meta', type: 'json' },
],
indexes: [
'CREATE UNIQUE INDEX idx_pages_slug ON pages (slug)',
'CREATE INDEX idx_pages_published ON pages (published, sort_order)',
],
listRule: 'published = true',
viewRule: 'published = true || @request.auth.role = "admin"',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
dao.saveCollection(pagesCollection)
// 5. 创建站点设置集合 (单例)
const settingsCollection = new Collection({
name: 'site_settings',
type: 'base',
system: false,
schema: [
// 基础信息
{ name: 'site_name', type: 'text', required: true, default: '云升数码', options: { max: 100 } },
{ name: 'site_subtitle', type: 'text', options: { max: 200 } },
{ name: 'site_description', type: 'editor', options: { convertHtml: false } },
{ name: 'site_keywords', type: 'text', options: { max: 500 } },
{ name: 'site_url', type: 'url', options: { max: 500 } },
// Logo & Favicon
{ name: 'logo', type: 'file', options: { maxSelect: 1, maxSize: 2097152, mimeTypes: ['image/svg+xml', 'image/png', 'image/webp'] } },
{ name: 'logo_dark', type: 'file', options: { maxSelect: 1, maxSize: 2097152, mimeTypes: ['image/svg+xml', 'image/png', 'image/webp'] } },
{ name: 'favicon', type: 'file', options: { maxSelect: 1, maxSize: 1048576, mimeTypes: ['image/svg+xml', 'image/png', 'image/x-icon'] } },
{ name: 'apple_touch_icon', type: 'file', options: { maxSelect: 1, maxSize: 1048576, mimeTypes: ['image/png'] } },
// 背景音乐
{ name: 'background_music_enabled', type: 'bool', required: true, default: 'true' },
{ name: 'background_music_type', type: 'select', required: true, default: 'local', options: { maxSelect: 1, values: ['local', 'netease', 'tencent', 'xiami', 'kugou', 'kuwo'] } },
{ name: 'background_music_file', type: 'file', options: { maxSelect: 1, maxSize: 52428800, mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp3', 'audio/aac'] } },
{ name: 'background_music_url', type: 'url', options: { max: 500 } },
{ name: 'background_music_title', type: 'text', options: { max: 100 } },
{ name: 'background_music_artist', type: 'text', options: { max: 100 } },
{ name: 'background_music_cover', type: 'file', options: { maxSelect: 1, maxSize: 2097152, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
{ name: 'background_music_volume', type: 'number', required: true, default: '0.5', options: { min: 0, max: 1, step: 0.1 } },
{ name: 'background_music_autoplay', type: 'bool', required: true, default: 'false' },
{ name: 'background_music_loop', type: 'bool', required: true, default: 'true' },
// Footer
{ name: 'footer_text', type: 'text', options: { max: 500 } },
{ name: 'footer_copyright', type: 'text', options: { max: 200 } },
{ name: 'icp_number', type: 'text', options: { max: 50 } },
{ name: 'police_number', type: 'text', options: { max: 50 } },
// 社交链接
{ name: 'social_links', type: 'json' }, // { github, twitter, bilibili, weibo, zhihu, email, rss }
// SEO & Analytics
{ name: 'google_analytics_id', type: 'text', options: { max: 50 } },
{ name: 'baidu_analytics_id', type: 'text', options: { max: 50 } },
{ name: 'custom_head_code', type: 'editor', options: { convertHtml: false } },
{ name: 'custom_body_code', type: 'editor', options: { convertHtml: false } },
// 评论系统
{ name: 'comment_enabled', type: 'bool', required: true, default: 'false' },
{ name: 'comment_system', type: 'select', required: true, default: 'giscus', options: { maxSelect: 1, values: ['giscus', 'waline', 'twikoo', 'cusdis', 'disqus'] } },
{ name: 'comment_config', type: 'json' },
// 功能开关
{ name: 'maintenance_mode', type: 'bool', required: true, default: 'false' },
{ name: 'maintenance_message', type: 'editor', options: { convertHtml: false } },
{ name: 'registration_enabled', type: 'bool', required: true, default: 'false' },
{ name: 'rss_enabled', type: 'bool', required: true, default: 'true' },
{ name: 'search_enabled', type: 'bool', required: true, default: 'true' },
// 缓存 & 性能
{ name: 'cache_ttl', type: 'number', required: true, default: '3600' },
{ name: 'image_optimization', type: 'bool', required: true, default: 'true' },
{ name: 'cdn_url', type: 'url', options: { max: 500 } },
],
indexes: [],
listRule: '@request.auth.role = "admin"',
viewRule: '@request.auth.role = "admin" || true', // 公开读取部分字段
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {
// 单例模式:只能有一条记录
},
})
dao.saveCollection(settingsCollection)
// 6. 创建媒体集合 (扩展 PocketBase 默认文件管理)
const mediaCollection = new Collection({
name: 'media',
type: 'base',
system: false,
schema: [
{ name: 'file', type: 'file', required: true, options: { maxSelect: 1, maxSize: 104857600 } },
{ name: 'title', type: 'text', options: { max: 200 } },
{ name: 'alt', type: 'text', options: { max: 200 } },
{ name: 'description', type: 'editor', options: { convertHtml: false } },
{ name: 'folder', type: 'text', options: { max: 100 } },
{ name: 'tags', type: 'relation', options: { collectionId: '', cascadeDelete: false, minSelect: 0, maxSelect: 20, displayFields: ['name'] } },
{ name: 'metadata', type: 'json' }, // width, height, duration, bitrate, exif 等
{ name: 'uploaded_by', type: 'relation', required: true, options: { collectionId: '_pb_users_auth_', cascadeDelete: false, minSelect: 1, maxSelect: 1, displayFields: ['name', 'email'] } },
],
indexes: [
'CREATE INDEX idx_media_folder ON media (folder)',
'CREATE INDEX idx_media_uploaded_by ON media (uploaded_by)',
'CREATE INDEX idx_media_created ON media (created)',
],
listRule: '@request.auth.role = "admin"',
viewRule: '@request.auth.role = "admin"',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
mediaCollection.schema.find(f => f.name === 'tags')!.options.collectionId = tagsCollection.id
dao.saveCollection(mediaCollection)
// 7. 创建菜单集合
const menusCollection = new Collection({
name: 'menus',
type: 'base',
system: false,
schema: [
{ name: 'name', type: 'text', required: true, options: { max: 50 } },
{ name: 'location', type: 'select', required: true, default: 'header', options: { maxSelect: 1, values: ['header', 'footer', 'mobile', 'sidebar'] } },
{ name: 'items', type: 'json', required: true }, // [{ label, url, target, children[], icon, badge, order }]
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
],
indexes: [
'CREATE UNIQUE INDEX idx_menus_location ON menus (location)',
],
listRule: 'is_active = true',
viewRule: 'is_active = true',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
dao.saveCollection(menusCollection)
// 8. 创建重定向集合 (SEO 友好)
const redirectsCollection = new Collection({
name: 'redirects',
type: 'base',
system: false,
schema: [
{ name: 'from_path', type: 'text', required: true, unique: true, options: { max: 500 } },
{ name: 'to_path', type: 'text', required: true, options: { max: 500 } },
{ name: 'status_code', type: 'select', required: true, default: '301', options: { maxSelect: 1, values: ['301', '302', '307', '308'] } },
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
{ name: 'hits', type: 'number', required: true, default: '0' },
],
indexes: [
'CREATE UNIQUE INDEX idx_redirects_from ON redirects (from_path)',
],
listRule: '@request.auth.role = "admin"',
viewRule: '@request.auth.role = "admin"',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
dao.saveCollection(redirectsCollection)
// 9. 创建通知/公告集合
const noticesCollection = new Collection({
name: 'notices',
type: 'base',
system: false,
schema: [
{ name: 'title', type: 'text', required: true, options: { max: 100 } },
{ name: 'content', type: 'editor', required: true, options: { convertHtml: false } },
{ name: 'type', type: 'select', required: true, default: 'info', options: { maxSelect: 1, values: ['info', 'success', 'warning', 'error', 'announcement'] } },
{ name: 'position', type: 'select', required: true, default: 'top', options: { maxSelect: 1, values: ['top', 'bottom', 'center', 'top-right', 'top-left'] } },
{ name: 'show_close', type: 'bool', required: true, default: 'true' },
{ name: 'auto_close', type: 'number', required: true, default: '0' }, // 0 = 不自动关闭,单位毫秒
{ name: 'link', type: 'url', options: { max: 500 } },
{ name: 'link_text', type: 'text', options: { max: 30 } },
{ name: 'start_at', type: 'date', required: false },
{ name: 'end_at', type: 'date', required: false },
{ name: 'is_active', type: 'bool', required: true, default: 'true' },
{ name: 'target_roles', type: 'json' }, // ['admin', 'user', 'guest']
{ name: 'dismissible', type: 'bool', required: true, default: 'true' },
],
indexes: [
'CREATE INDEX idx_notices_active ON notices (is_active, start_at, end_at)',
],
listRule: 'is_active = true && (start_at = "" || start_at <= @now) && (end_at = "" || end_at >= @now)',
viewRule: 'is_active = true && (start_at = "" || start_at <= @now) && (end_at = "" || end_at >= @now)',
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"',
options: {},
})
dao.saveCollection(noticesCollection)
})