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:
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,496 @@
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
|
||||
// 获取集合引用
|
||||
const categoriesCollection = dao.findCollectionByNameOrId('categories')
|
||||
const tagsCollection = dao.findCollectionByNameOrId('tags')
|
||||
const postsCollection = dao.findCollectionByNameOrId('posts')
|
||||
const pagesCollection = dao.findCollectionByNameOrId('pages')
|
||||
const settingsCollection = dao.findCollectionByNameOrId('site_settings')
|
||||
const menusCollection = dao.findCollectionByNameOrId('menus')
|
||||
|
||||
// 创建默认管理员用户
|
||||
const adminCollection = dao.findCollectionByNameOrId('_pb_users_auth_')
|
||||
const adminRecord = new Record(adminCollection, {
|
||||
email: 'admin@yunsheng.digital',
|
||||
password: 'YunSheng@2024!Admin',
|
||||
passwordConfirm: 'YunSheng@2024!Admin',
|
||||
name: '云升数码',
|
||||
role: 'admin',
|
||||
verified: true,
|
||||
})
|
||||
dao.saveRecord(adminRecord)
|
||||
|
||||
// 创建默认分类
|
||||
const categories = [
|
||||
{ name: '数码评测', slug: 'reviews', description: '专业数码产品深度评测', icon: '📱', color: '#0ea5e9', sort_order: 1, is_active: true },
|
||||
{ name: '技术教程', slug: 'tutorials', description: '实用技术教程与操作指南', icon: '🛠️', color: '#10b981', sort_order: 2, is_active: true },
|
||||
{ name: '行业资讯', slug: 'news', description: '数码科技行业最新动态', icon: '📰', color: '#f59e0b', sort_order: 3, is_active: true },
|
||||
{ name: '玩机技巧', slug: 'tips', description: '手机、电脑、配件使用技巧', icon: '💡', color: '#8b5cf6', sort_order: 4, is_active: true },
|
||||
{ name: '开箱体验', slug: 'unboxing', description: '新品开箱与第一印象', icon: '📦', color: '#ec4899', sort_order: 5, is_active: true },
|
||||
{ name: '对比横评', slug: 'comparison', description: '多款产品横向对比评测', icon: '⚖️', color: '#ef4444', sort_order: 6, is_active: true },
|
||||
]
|
||||
|
||||
const categoryRecords = {}
|
||||
for (const cat of categories) {
|
||||
const record = new Record(categoriesCollection, cat)
|
||||
dao.saveRecord(record)
|
||||
categoryRecords[cat.slug] = record.id
|
||||
}
|
||||
|
||||
// 创建默认标签
|
||||
const tags = [
|
||||
{ name: '手机', slug: 'phone', color: '#0ea5e9', sort_order: 1, is_active: true },
|
||||
{ name: '笔记本', slug: 'laptop', color: '#10b981', sort_order: 2, is_active: true },
|
||||
{ name: '耳机', slug: 'headphones', color: '#f59e0b', sort_order: 3, is_active: true },
|
||||
{ name: '显示器', slug: 'monitor', color: '#8b5cf6', sort_order: 4, is_active: true },
|
||||
{ name: '键盘', slug: 'keyboard', color: '#ec4899', sort_order: 5, is_active: true },
|
||||
{ name: '鼠标', slug: 'mouse', color: '#ef4444', sort_order: 6, is_active: true },
|
||||
{ name: '配件', slug: 'accessories', color: '#6366f1', sort_order: 7, is_active: true },
|
||||
{ name: '智能家居', slug: 'smart-home', color: '#14b8a6', sort_order: 8, is_active: true },
|
||||
{ name: '摄影', slug: 'photography', color: '#f97316', sort_order: 9, is_active: true },
|
||||
{ name: '音频', slug: 'audio', color: '#a855f7', sort_order: 10, is_active: true },
|
||||
{ name: '评测', slug: 'review', color: '#06b6d4', sort_order: 11, is_active: true },
|
||||
{ name: '教程', slug: 'tutorial', color: '#84cc16', sort_order: 12, is_active: true },
|
||||
{ name: '推荐', slug: 'recommend', color: '#eab308', sort_order: 13, is_active: true },
|
||||
{ name: '避坑', slug: 'avoid', color: '#dc2626', sort_order: 14, is_active: true },
|
||||
{ name: '性价比', slug: 'value', color: '#16a34a', sort_order: 15, is_active: true },
|
||||
]
|
||||
|
||||
const tagRecords = {}
|
||||
for (const tag of tags) {
|
||||
const record = new Record(tagsCollection, tag)
|
||||
dao.saveRecord(record)
|
||||
tagRecords[tag.slug] = record.id
|
||||
}
|
||||
|
||||
// 创建站点设置
|
||||
const settingsRecord = new Record(settingsCollection, {
|
||||
site_name: '云升数码',
|
||||
site_subtitle: '专业数码评测与技术分享',
|
||||
site_description: '云升数码致力于为用户提供专业、客观、深度的数码产品评测、技术教程和行业资讯。我们不做标题党,只做真实评测。',
|
||||
site_keywords: '数码评测,手机评测,笔记本评测,耳机评测,数码教程,科技资讯,开箱体验,数码推荐',
|
||||
site_url: 'https://yunsheng.digital',
|
||||
footer_text: '云升数码 - 专业数码评测与技术分享平台',
|
||||
footer_copyright: '© 2024 云升数码. All rights reserved.',
|
||||
icp_number: '京ICP备2024000000号',
|
||||
social_links: JSON.stringify({
|
||||
github: 'https://github.com/yunsheng-digital',
|
||||
twitter: 'https://twitter.com/yunsheng_digital',
|
||||
bilibili: 'https://space.bilibili.com/yunsheng',
|
||||
weibo: 'https://weibo.com/yunsheng',
|
||||
zhihu: 'https://zhihu.com/people/yunsheng',
|
||||
email: 'contact@yunsheng.digital',
|
||||
rss: '/rss.xml',
|
||||
}),
|
||||
background_music_enabled: true,
|
||||
background_music_type: 'local',
|
||||
background_music_title: '云端漫步',
|
||||
background_music_artist: '云升数码',
|
||||
background_music_volume: 0.4,
|
||||
background_music_autoplay: false,
|
||||
background_music_loop: true,
|
||||
comment_enabled: false,
|
||||
comment_system: 'giscus',
|
||||
rss_enabled: true,
|
||||
search_enabled: true,
|
||||
cache_ttl: 3600,
|
||||
image_optimization: true,
|
||||
maintenance_mode: false,
|
||||
})
|
||||
dao.saveRecord(settingsRecord)
|
||||
|
||||
// 创建默认菜单
|
||||
const headerMenuItems = [
|
||||
{ label: '首页', url: '/', order: 1 },
|
||||
{ label: '评测', url: '/category/reviews', order: 2 },
|
||||
{ label: '教程', url: '/category/tutorials', order: 3 },
|
||||
{ label: '资讯', url: '/category/news', order: 4 },
|
||||
{ label: '音乐', url: '/music', order: 5 },
|
||||
{ label: '关于', url: '/page/about', order: 6 },
|
||||
]
|
||||
|
||||
const footerMenuItems = [
|
||||
{ label: '关于我们', url: '/page/about', order: 1 },
|
||||
{ label: '联系我们', url: '/page/contact', order: 2 },
|
||||
{ label: '隐私政策', url: '/page/privacy', order: 3 },
|
||||
{ label: '服务条款', url: '/page/terms', order: 4 },
|
||||
{ label: '搭建教程', url: '/page/deploy-guide', order: 5 },
|
||||
{ label: 'RSS 订阅', url: '/rss.xml', order: 6 },
|
||||
]
|
||||
|
||||
const headerMenu = new Record(menusCollection, {
|
||||
name: '主导航',
|
||||
location: 'header',
|
||||
items: JSON.stringify(headerMenuItems),
|
||||
is_active: true,
|
||||
})
|
||||
dao.saveRecord(headerMenu)
|
||||
|
||||
const footerMenu = new Record(menusCollection, {
|
||||
name: '页脚导航',
|
||||
location: 'footer',
|
||||
items: JSON.stringify(footerMenuItems),
|
||||
is_active: true,
|
||||
})
|
||||
dao.saveRecord(footerMenu)
|
||||
|
||||
// 创建示例页面
|
||||
const pages = [
|
||||
{
|
||||
title: '关于我们',
|
||||
slug: 'about',
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '关于云升数码' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '云升数码成立于 2024 年,是一个专注于数码产品深度评测、技术教程分享和行业资讯报道的专业平台。' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '我们的使命' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '为消费者提供最真实、最专业、最有价值的数码产品购买参考,帮助每一位数码爱好者做出明智的选择。' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '核心价值观' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '客观公正:拒绝商业软文,坚持真实评测' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '专业深度:深挖技术细节,不止于参数堆砌' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '用户至上:站在消费者角度,解决真实痛点' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '持续创新:紧跟技术潮流,探索评测新形式' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '联系我们' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '商务合作:business@yunsheng.digital' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '投稿建议:tips@yunsheng.digital' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '技术支持:tech@yunsheng.digital' }] },
|
||||
],
|
||||
}),
|
||||
template: 'default',
|
||||
published: true,
|
||||
sort_order: 1,
|
||||
show_in_nav: true,
|
||||
nav_label: '关于',
|
||||
},
|
||||
{
|
||||
title: '联系我们',
|
||||
slug: 'contact',
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '联系我们' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '欢迎通过以下方式与我们取得联系:' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '商务合作' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '邮箱:business@yunsheng.digital' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '投稿建议' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '邮箱:tips@yunsheng.digital' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '技术支持' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '邮箱:tech@yunsheng.digital' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '社交媒体' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'GitHub: github.com/yunsheng-digital' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Twitter: @yunsheng_digital' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bilibili: 云升数码' }] }] },
|
||||
]},
|
||||
],
|
||||
}),
|
||||
template: 'default',
|
||||
published: true,
|
||||
sort_order: 2,
|
||||
show_in_nav: false,
|
||||
},
|
||||
{
|
||||
title: '隐私政策',
|
||||
slug: 'privacy',
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '隐私政策' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '云升数码(以下简称"我们")非常重视您的隐私保护。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '1. 信息收集' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '我们可能收集以下信息:访问日志(IP、浏览器、访问时间)、Cookie 数据、您主动提交的评论/联系表单信息。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '2. 信息使用' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '收集的信息仅用于:网站统计分析、改善用户体验、回复您的咨询、发送必要通知。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '3. 信息共享' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '未经您同意,我们不会向第三方披露您的个人信息,法律法规要求或保护公共利益除外。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '4. 您的权利' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '您有权访问、更正、删除您的个人信息,可通过联系我们行使相关权利。' }] }],
|
||||
],
|
||||
}),
|
||||
template: 'default',
|
||||
published: true,
|
||||
sort_order: 3,
|
||||
show_in_nav: false,
|
||||
},
|
||||
{
|
||||
title: '服务条款',
|
||||
slug: 'terms',
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '服务条款' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '欢迎使用云升数码网站服务。请仔细阅读以下条款。' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '1. 服务内容' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '我们提供数码评测文章、技术教程、行业资讯等内容服务。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '2. 用户行为' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '禁止发布违法、侵权、垃圾、广告等有害内容。违者将删除内容并封禁账号。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '3. 知识产权' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '本站原创内容版权归云升数码所有,转载请注明出处并获取授权。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '4. 免责声明' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '评测内容仅供参考,购买决策请以官方信息为准。因信息滞后或错误造成的损失,本站不承担责任。' }] }],
|
||||
],
|
||||
}),
|
||||
template: 'default',
|
||||
published: true,
|
||||
sort_order: 4,
|
||||
show_in_nav: false,
|
||||
},
|
||||
{
|
||||
title: '搭建教程',
|
||||
slug: 'deploy-guide',
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '云升数码站点搭建教程' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '本教程将指导您如何在自己的服务器上部署云升数码展示页面。' }] },
|
||||
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📋 准备工作' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '一台 Linux 服务器(推荐 Ubuntu 22.04+ / Debian 12+)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '域名一个,已解析到服务器 IP' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'SSH 客户端(Windows 推荐 Terminal / Tabby,Mac/Linux 自带 Terminal)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '基本 Linux 命令行操作知识' }] }] },
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '🚀 一键部署(推荐)' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '我们提供了一键部署脚本,适合大多数场景:' }] }],
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: '# 1. 克隆仓库\ngit clone https://git.grxiao.cn/yuns/plerr-open.git\ncd plerr-open\n\n# 2. 运行一键安装脚本\nchmod +x scripts/install.sh\nsudo ./scripts/install.sh\n\n# 3. 按提示配置域名、SSL 等\n# 4. 访问 https://your-domain.com 完成初始化' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '🐳 Docker 部署' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '如果您偏好容器化部署:' }] }],
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: '# 1. 进入 docker 目录\ncd docker\n\n# 2. 复制环境变量模板\ncp .env.example .env\n# 编辑 .env 配置域名、密钥等\n\n# 3. 启动服务\ndocker-compose up -d\n\n# 4. 查看日志\ndocker-compose logs -f' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '⚙️ 手动部署步骤' }] },
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '1. 安装依赖' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: '# 更新系统\nsudo apt update && sudo apt upgrade -y\n\n# 安装 Node.js 20 (用于前端构建)\ncurl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -\nsudo apt install -y nodejs\n\n# 安装 pnpm\nnpm install -g pnpm\n\n# 安装 PocketBase (后端)\ncd /opt\nwget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_linux_amd64.zip\nunzip pocketbase_0.22.0_linux_amd64.zip\nchmod +x pocketbase\n\n# 安装 Nginx\nsudo apt install -y nginx\n\n# 安装 Certbot (SSL)\nsudo apt install -y certbot python3-certbot-nginx' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '2. 配置 PocketBase' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: '# 创建数据目录\nmkdir -p /opt/pocketbase/pb_data\nmkdir -p /opt/pocketbase/pb_migrations\nmkdir -p /opt/pocketbase/pb_public\n\n# 复制迁移文件\ncp -r /path/to/project/apps/pb_migrations/* /opt/pocketbase/pb_migrations/\n\n# 创建 systemd 服务\nsudo tee /etc/systemd/system/pocketbase.service > /dev/null <<EOF\n[Unit]\nDescription=PocketBase\nAfter=network.target\n\n[Service]\nType=simple\nUser=www-data\nWorkingDirectory=/opt/pocketbase\nExecStart=/opt/pocketbase/pocketbase serve --http=127.0.0.1:8090 --dir=/opt/pocketbase/pb_data\nRestart=on-failure\nRestartSec=5\nEnvironment=PB_ENCRYPTION_KEY=your-32-char-encryption-key\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\n# 启动服务\nsudo systemctl daemon-reload\nsudo systemctl enable pocketbase\nsudo systemctl start pocketbase' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '3. 构建前端' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: 'cd /path/to/project/apps/web\npnpm install\npnpm build\n\n# 构建产物在 dist 目录,复制到 PocketBase 静态目录\ncp -r dist/* /opt/pocketbase/pb_public/' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '4. 配置 Nginx 反向代理' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'nginx' }, content: [
|
||||
{ type: 'text', text: 'server {\n listen 80;\n server_name your-domain.com;\n\n # 静态文件直接由 Nginx 服务\n location / {\n root /opt/pocketbase/pb_public;\n try_files $uri $uri/ /index.html;\n expires 1y;\n add_header Cache-Control "public, immutable";\n }\n\n # API 代理到 PocketBase\n location /api/ {\n proxy_pass http://127.0.0.1:8090;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection "upgrade";\n }\n\n # Realtime WebSocket\n location /api/realtime {\n proxy_pass http://127.0.0.1:8090;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection "upgrade";\n proxy_set_header Host $host;\n proxy_read_timeout 86400;\n }\n\n # PocketBase Admin UI\n location /_/ {\n proxy_pass http://127.0.0.1:8090;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n}' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '5. 申请 SSL 证书' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: 'sudo certbot --nginx -d your-domain.com\n\n# 设置自动续期\nsudo systemctl enable certbot.timer' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '🔧 后续维护' }] },
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '更新代码' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: 'cd /path/to/project\ngit pull\ncd apps/web\npnpm install\npnpm build\ncp -r dist/* /opt/pocketbase/pb_public/\nsudo systemctl reload nginx' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '备份数据' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: '# 备份 PocketBase 数据\ncp -r /opt/pocketbase/pb_data /backup/pocketbase_$(date +%Y%m%d_%H%M%S)\n\n# 或者使用 PocketBase 内置导出\n/opt/pocketbase/pocketbase dump --dir=/opt/pocketbase/pb_data --output=/backup/pb_dump_$(date +%Y%m%d).zip' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: '查看日志' }] },
|
||||
{ type: 'codeBlock', attrs: { language: 'bash' }, content: [
|
||||
{ type: 'text', text: '# PocketBase 日志\nsudo journalctl -u pocketbase -f\n\n# Nginx 日志\nsudo tail -f /var/log/nginx/access.log\nsudo tail -f /var/log/nginx/error.log' }
|
||||
]},
|
||||
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '🎯 常见问题' }] },
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Q: 访问提示 502 Bad Gateway' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'A: 检查 PocketBase 是否运行:systemctl status pocketbase,查看日志排查错误。' }] }],
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Q: 静态资源 404' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'A: 确认前端构建产物已复制到 /opt/pocketbase/pb_public/,Nginx root 指向正确。' }] }],
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Q: WebSocket 连接失败' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'A: Nginx 配置需添加 WebSocket 升级头,参考上面配置中的 /api/realtime 部分。' }] }],
|
||||
{ type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Q: 文件上传大小限制' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'A: 修改 Nginx client_max_body_size 和 PocketBase 启动参数 --max-upload-size。' }] }],
|
||||
],
|
||||
}),
|
||||
template: 'doc',
|
||||
published: true,
|
||||
sort_order: 5,
|
||||
show_in_nav: true,
|
||||
nav_label: '搭建教程',
|
||||
},
|
||||
]
|
||||
|
||||
for (const page of pages) {
|
||||
const record = new Record(pagesCollection, page)
|
||||
dao.saveRecord(record)
|
||||
}
|
||||
|
||||
// 创建示例文章
|
||||
const samplePosts = [
|
||||
{
|
||||
title: 'iPhone 16 Pro Max 深度评测:影像旗舰的新高度',
|
||||
slug: 'iphone-16-pro-max-review',
|
||||
excerpt: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '苹果最新旗舰 iPhone 16 Pro Max 带来了更大的传感器、更强的 A18 Pro 芯片和全新的相机控制键。经过两周的深度体验,这究竟是值得升级的「真香」机型,还是挤牙膏的过渡之作?' }] }
|
||||
]
|
||||
}),
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '前言:期待已久的影像旗舰' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '每年的 9 月,都是科技圈最热闹的时候。今年,iPhone 16 系列如期而至,而 Pro Max 版本作为「安卓机皇」的对标产品,自然成为了所有人关注的焦点。' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '外观与手感:熟悉的配方' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '依然是钛金属边框,依然是磨砂玻璃后盖,重量控制在 227g,单手握持感比上一代略好。新增的「沙漠钛金属」配色在光线下呈现微妙的暖金色调,质感极佳。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '相机控制键:一键直达的惊喜' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '这是本代最大的硬件创新。侧边的压感按键支持轻按对焦、重按拍照、滑动变焦,肌肉记忆建立后,抓拍速度确实快了不少。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '影像系统:4800 万像素主摄 + 5 倍潜望长焦' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '主摄升级为 1/1.28 英寸传感器,进光量提升显著。夜景模式下,噪点控制、动态范围、细节保留均达到行业第一梯队。5 倍潜望长焦在 5x-10x 区间表现惊艳,甚至可用于人像拍摄。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '性能与续航:A18 Pro 的效能比魔法' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '3nm 工艺的 A18 Pro 在 GeekBench 6 单核跑分突破 3500,多核超 9000。实测《原神》最高画质 60 帧满帧运行 1 小时,机身最高温度 42.3°C,功耗控制令人印象深刻。续航方面,重度使用 6.5 小时屏亮,轻松跑过一天。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '总结:值得买吗?' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '如果你是 iPhone 14 Pro 或更早机型用户,且重视影像、追求极致性能,iPhone 16 Pro Max 是毫不犹豫的「真香」选择。但如果你手持 15 Pro Max,除非极度需要相机控制键或 5 倍长焦,否则再战一年更划算。' }] }],
|
||||
]
|
||||
}),
|
||||
cover: '',
|
||||
category: 'reviews',
|
||||
tags: ['phone', 'review', 'recommend'],
|
||||
author: '', // 将在创建时填入 admin id
|
||||
published: true,
|
||||
published_at: new Date().toISOString(),
|
||||
featured: true,
|
||||
pinned: false,
|
||||
allow_comment: true,
|
||||
reading_time: 12,
|
||||
seo_title: 'iPhone 16 Pro Max 深度评测:影像旗舰新高度 | 云升数码',
|
||||
seo_description: '两周深度体验 iPhone 16 Pro Max:相机控制键实测、5倍潜望长焦样张、A18 Pro 性能功耗、续航实测,告诉你值不值得买。',
|
||||
seo_keywords: 'iPhone 16 Pro Max,评测,影像,相机控制键,A18 Pro',
|
||||
},
|
||||
{
|
||||
title: '2024 年双十一笔记本选购指南:避坑与真香推荐',
|
||||
slug: '2024-double11-laptop-guide',
|
||||
excerpt: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '一年一度双十一大促来袭,笔记本市场百花齐放。本文从预算、用途、参数三个维度,为你梳理 2024 年最值得买、最不值得买的笔记本清单,助你不花冤枉钱。' }] }
|
||||
]
|
||||
}),
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '开篇:别被参数忽悠了' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '每年双十一,商家最爱玩「首发价」「限时抢」「赠品堆砌」这一套。记住一个核心原则:CPU 和屏幕决定下限,散热和做工决定上限。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '💰 3000-4000 元:学生党/轻办公首选' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '✅ 真香:Redmi Book Pro 14 2024(OLED 屏、标压 U、金属机身)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '✅ 真香:联想小新 Pro 14 2024 酷睿版(做工扎实、售后好、扩展性强)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '❌ 避坑:某些「独显版」入门级 MX550,性能不如核显,纯坑钱' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '💰 5000-7000 元:全能本/轻薄游戏本甜点区' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '🏆 首推:ROG 幻 14 Air 2024(R9 8945HS + RTX 4060,1.15kg,OLED 屏,颜值性能全要)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '🏆 首推:联想拯救者 Y7000P 2024(i7-14650HX + RTX 4060,散热强、性价比高,不介意重可冲)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '✅ 备选:华硕天选 5 Pro、机械革命无界 14X、ThinkBook 14+ 2024' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '💰 8000-12000 元:高性能游戏本/创作者本' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '🏆 游戏首选:ROG 星幻 X 2024 / 惠普暗影精灵 10 Pro(RTX 4070/4080,满血释放)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '🏆 创作首选:MacBook Pro 14 M3 Pro/Max(生态闭环、色准极佳、续航无敌,不玩游戏选它)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '✅ Windows 创作备选:华硕 ProArt 创 16 2024、戴尔 Precision 5680' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '💰 12000+ 元:旗舰中的旗舰' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'ROG 幻 X 2024(双屏、液金、RTX 4090,预算无上限的终极答案)' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'MacBook Pro 16 M3 Max(专业视频/音频/代码创作,残值率高)' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '🎯 选购避坑清单' }] },
|
||||
{ type: 'orderedList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '认准「标压」CPU(H/HX 系列),拒绝低压版(U 系列)做主力机' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '屏幕必须 100% sRGB 以上,最好 100% DCI-P3,OLED > IPS' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '内存 16GB 起步,32GB 安心,焊死内存尽量买大内存版' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'SSD 1TB 起,PCIe 4.0,预留 M.2 位更佳' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '接口要全:USB-C (支持 PD 充电/DP)、USB-A、HDMI、SD 卡槽' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '查散热评测:同配置不同散热,性能释放相差 20%+' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📅 购买时机建议' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '预售期(10.20-11.10)定金膨胀力度大,但需锁定款式;正式期(11.11 当天)部分款式补贴到手价更低,但热门款易断货。建议:确定目标款 → 预售期付定金 → 尾款期观望补贴 → 最后一小时决策。' }] }],
|
||||
]
|
||||
}),
|
||||
cover: '',
|
||||
category: 'tutorials',
|
||||
tags: ['laptop', 'tutorial', 'recommend', 'value', 'avoid'],
|
||||
author: '',
|
||||
published: true,
|
||||
published_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
featured: true,
|
||||
reading_time: 15,
|
||||
seo_title: '2024 双十一笔记本选购指南:避坑与真香推荐全清单 | 云升数码',
|
||||
seo_description: '2024 双十一笔记本怎么选?3000-12000 元全价位段真香/避坑清单,从参数到实测教你不花冤枉钱。',
|
||||
seo_keywords: '双十一,笔记本,选购指南,推荐,避坑,性价比',
|
||||
},
|
||||
{
|
||||
title: 'Sony WH-1000XM5 使用半年长测:降噪之王的统治力还在吗?',
|
||||
slug: 'sony-wh-1000xm5-long-term-review',
|
||||
excerpt: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '作为降噪耳机领域的「长青树」,WH-1000XM5 发布已超两年。半年实战体验,聊聊它在 2024 年还能不能打,以及有哪些被忽略的缺点。' }] }
|
||||
]
|
||||
}),
|
||||
content: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '前言:为什么现在还要测 XM5?' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '市场上 Bose QC Ultra、Apple AirPods Max、技巧 Ace 5 等强敌环伺,XM5 似乎成了「上一代旗舰」。但销量数据显示,它依然是千元以上降噪耳机的销量冠军。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '降噪表现:依然是标杆' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '地铁、飞机、办公室、咖啡厅全场景测试,低频消噪深度行业顶尖,人声残留极少。自适应声控(根据气压/环境自动调节)在起飞降落时体感明显。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '音质:V 型调音,LDAC 加持' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '低频下潜深、量感足但不拖泥带水;高频延伸好,LDAC 传输下解析力在蓝牙耳机中第一梯队。EQ 调节范围大,可调出监听风。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '佩戴舒适度:褒贬不一' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '头梁无海绵垫、耳罩包裹感一般,大头/戴眼镜用户 2 小时后会有压迫感。夏天闷耳严重。建议换装第三方记忆棉耳罩(如 Dekoni)。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '续航与充电:无焦虑' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '开启降噪 30 小时、关闭 40 小时,实测符合官方数据。3 分钟充电 3 小时听歌,Type-C 接口通用性强。' }] }],
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '槽点:不折叠、无 aptX、连接切换慢' }] },
|
||||
{ type: 'bulletList', content: [
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '收纳体积大,随身包塞不下' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '仅支持 SBC/AAC/LDAC,安卓用户无 aptX Adaptive' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '多点连接切换延迟 3-5 秒,不如 Apple 生态丝滑' }] }] },
|
||||
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '触控区误触率高,冬天戴手套不可用' }] }] },
|
||||
]},
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '结论:买不买?' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '追求极致降噪、音质均衡、预算 2000 元左右 → 闭眼入。\n需折叠收纳、aptX、极致舒适、Apple 生态 → 看别家。' }] }],
|
||||
]
|
||||
}),
|
||||
cover: '',
|
||||
category: 'reviews',
|
||||
tags: ['headphones', 'audio', 'review', 'long-term'],
|
||||
author: '',
|
||||
published: true,
|
||||
published_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
featured: false,
|
||||
reading_time: 10,
|
||||
seo_title: 'Sony WH-1000XM5 半年长测:降噪之王统治力还在吗? | 云升数码',
|
||||
seo_description: 'WH-1000XM5 使用半年实测:降噪/音质/舒适度/续航全维度复盘,揭露不折叠/无aptX/切换慢等真实槽点。',
|
||||
seo_keywords: 'WH-1000XM5,索尼,降噪耳机,长测,评测,音质',
|
||||
},
|
||||
]
|
||||
|
||||
for (const post of samplePosts) {
|
||||
post.author = adminRecord.id
|
||||
post.category = categoryRecords[post.category]
|
||||
post.tags = post.tags.map(t => tagRecords[t])
|
||||
const record = new Record(postsCollection, post)
|
||||
dao.saveRecord(record)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,592 @@
|
||||
<#>
|
||||
.SYNOPSIS
|
||||
云升数码 - Windows 一键部署脚本
|
||||
|
||||
.DESCRIPTION
|
||||
在 Windows Server / Windows 10/11 上部署云升数码展示页面
|
||||
包含 PocketBase 后端、Nginx 反向代理、SSL 证书配置
|
||||
|
||||
.NOTES
|
||||
需要管理员权限运行
|
||||
支持 Windows 10/11, Windows Server 2019/2022
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Domain = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Email = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$SkipNginx = $false,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$SkipSSL = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
# 颜色输出函数
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = "INFO")
|
||||
$colors = @{
|
||||
INFO = "Cyan"
|
||||
SUCCESS = "Green"
|
||||
WARN = "Yellow"
|
||||
ERROR = "Red"
|
||||
}
|
||||
$color = $colors[$Level]
|
||||
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message" -ForegroundColor $color
|
||||
}
|
||||
|
||||
# 检查管理员权限
|
||||
function Check-Admin {
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Log "请以管理员身份运行 PowerShell" "ERROR"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 检测架构
|
||||
function Get-Architecture {
|
||||
$arch = [Environment]::Is64BitOperatingSystem
|
||||
if ($arch) { return "amd64" } else { return "386" }
|
||||
}
|
||||
|
||||
# 下载文件
|
||||
function Download-File {
|
||||
param([string]$Url, [string]$OutputPath)
|
||||
Write-Log "下载: $Url"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Url -OutFile $OutputPath -UseBasicParsing
|
||||
Write-Log "下载完成: $OutputPath" "SUCCESS"
|
||||
} catch {
|
||||
Write-Log "下载失败: $_" "ERROR"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# 安装 PocketBase
|
||||
function Install-PocketBase {
|
||||
$version = "0.22.0"
|
||||
$arch = Get-Architecture
|
||||
$url = "https://github.com/pocketbase/pocketbase/releases/download/v${version}/pocketbase_${version}_windows_${arch}.zip"
|
||||
$installDir = "C:\pocketbase"
|
||||
$dataDir = "$installDir\pb_data"
|
||||
$migrationsDir = "$installDir\pb_migrations"
|
||||
$publicDir = "$installDir\pb_public"
|
||||
|
||||
Write-Log "安装 PocketBase v$version ($arch)..."
|
||||
|
||||
# 创建目录
|
||||
New-Item -ItemType Directory -Force -Path $installDir, $dataDir, $migrationsDir, $publicDir | Out-Null
|
||||
|
||||
# 下载
|
||||
$zipPath = "$env:TEMP\pocketbase.zip"
|
||||
Download-File $url $zipPath
|
||||
|
||||
# 解压
|
||||
Write-Log "解压..."
|
||||
Expand-Archive -Path $zipPath -DestinationPath $installDir -Force
|
||||
Remove-Item $zipPath -Force
|
||||
|
||||
# 复制迁移文件
|
||||
$projectMigrations = "$PSScriptRoot\..\apps\pb_migrations"
|
||||
if (Test-Path $projectMigrations) {
|
||||
Copy-Item -Path "$projectMigrations\*" -Destination $migrationsDir -Recurse -Force
|
||||
Write-Log "迁移文件已复制" "SUCCESS"
|
||||
}
|
||||
|
||||
# 生成加密密钥
|
||||
$encryptionKey = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
|
||||
Write-Log "加密密钥: $encryptionKey" "WARN"
|
||||
Write-Log "请妥善保存加密密钥!" "WARN"
|
||||
|
||||
# 创建启动脚本
|
||||
$startScript = @"
|
||||
@echo off
|
||||
cd /d "$installDir"
|
||||
set PB_ENCRYPTION_KEY=$encryptionKey
|
||||
pocketbase.exe serve --http=127.0.0.1:8090 --dir="$dataDir" --publicDir="$publicDir" --migrationsDir="$migrationsDir"
|
||||
"@
|
||||
Set-Content -Path "$installDir\start.bat" -Value $startScript -Encoding UTF8
|
||||
|
||||
# 创建系统服务
|
||||
Write-Log "创建 Windows 服务..."
|
||||
$serviceName = "PocketBase"
|
||||
$serviceDisplayName = "云升数码 PocketBase 服务"
|
||||
$serviceDescription = "PocketBase 后端服务 for 云升数码"
|
||||
|
||||
# 使用 NSSM (Non-Sucking Service Manager) 创建服务
|
||||
# 这里使用 sc.exe 创建简单服务
|
||||
$servicePath = "C:\Windows\System32\cmd.exe"
|
||||
$serviceArgs = "/c `"$installDir\start.bat`""
|
||||
|
||||
# 检查是否已安装 NSSM
|
||||
$nssmPath = "C:\pocketbase\nssm.exe"
|
||||
if (-not (Test-Path $nssmPath)) {
|
||||
Write-Log "下载 NSSM..."
|
||||
$nssmUrl = "https://github.com/nssm/nssm/releases/download/v2.24/nssm-2.24.zip"
|
||||
$nssmZip = "$env:TEMP\nssm.zip"
|
||||
Download-File $nssmUrl $nssmZip
|
||||
Expand-Archive -Path $nssmZip -DestinationPath "$env:TEMP\nssm" -Force
|
||||
Copy-Item "$env:TEMP\nssm\win64\nssm.exe" -Destination $nssmPath -Force
|
||||
Remove-Item $nssmZip -Force
|
||||
Remove-Item "$env:TEMP\nssm" -Recurse -Force
|
||||
}
|
||||
|
||||
# 安装服务
|
||||
& $nssmPath install $serviceName $servicePath $serviceArgs
|
||||
& $nssmPath set $serviceName DisplayName $serviceDisplayName
|
||||
& $nssmPath set $serviceName Description $serviceDescription
|
||||
& $nssmPath set $serviceName AppDirectory $installDir
|
||||
& $nssmPath set $serviceName AppEnvironmentExtra "PB_ENCRYPTION_KEY=$encryptionKey"
|
||||
& $nssmPath set $serviceName Start SERVICE_AUTO_START
|
||||
& $nssmPath set $serviceName AppStdout "$installDir\logs\stdout.log"
|
||||
& $nssmPath set $serviceName AppStderr "$installDir\logs\stderr.log"
|
||||
& $nssmPath set $serviceName AppRotateFiles 1
|
||||
& $nssmPath set $serviceName AppRotateBytes 10485760
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "$installDir\logs" | Out-Null
|
||||
|
||||
Write-Log "PocketBase 服务创建完成" "SUCCESS"
|
||||
|
||||
return @{
|
||||
InstallDir = $installDir
|
||||
DataDir = $dataDir
|
||||
PublicDir = $publicDir
|
||||
EncryptionKey = $encryptionKey
|
||||
}
|
||||
}
|
||||
|
||||
# 安装 Nginx
|
||||
function Install-Nginx {
|
||||
param([string]$Domain)
|
||||
|
||||
if ($SkipNginx) {
|
||||
Write-Log "跳过 Nginx 安装" "WARN"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "安装 Nginx..."
|
||||
|
||||
$nginxVersion = "1.25.3"
|
||||
$url = "https://nginx.org/download/nginx-$nginxVersion.zip"
|
||||
$installDir = "C:\nginx"
|
||||
$zipPath = "$env:TEMP\nginx.zip"
|
||||
|
||||
Download-File $url $zipPath
|
||||
Expand-Archive -Path $zipPath -DestinationPath $installDir -Force
|
||||
Remove-Item $zipPath -Force
|
||||
|
||||
# 找到解压后的实际目录
|
||||
$extractedDir = Get-ChildItem "$installDir" -Directory | Select-Object -First 1
|
||||
if ($extractedDir.FullName -ne $installDir) {
|
||||
Move-Item -Path "$extractedDir\*" -Destination $installDir -Force
|
||||
Remove-Item $extractedDir.FullName -Recurse -Force
|
||||
}
|
||||
|
||||
# 配置 Nginx
|
||||
$nginxConf = @"
|
||||
# 云升数码 - Nginx 配置
|
||||
worker_processes auto;
|
||||
error_log logs/error.log warn;
|
||||
pid logs/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use iocp;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '\$remote_addr - \$remote_user [\$time_local] "\$request" '
|
||||
'\$status \$body_bytes_sent "\$http_referer" '
|
||||
'"\$http_user_agent" "\$http_x_forwarded_for"';
|
||||
|
||||
access_log logs/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 100M;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name $Domain www.$Domain;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root C:\nginx\acme;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 主站点
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name $Domain www.$Domain;
|
||||
|
||||
# SSL 证书路径 (Certbot 会自动配置)
|
||||
# ssl_certificate C:/Certbot/live/$Domain/fullchain.pem;
|
||||
# ssl_certificate_key C:/Certbot/live/$Domain/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
root $env:USERPROFILE\yunsheng-digital\apps\web\dist;
|
||||
index index.html;
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif|mp4|webm|ogg|mp3|wav|pdf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
# SPA 路由
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
# WebSocket
|
||||
location /api/realtime {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Admin UI
|
||||
location /_/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
|
||||
# 禁止隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
Set-Content -Path "$installDir\conf\nginx.conf" -Value $nginxConf -Encoding UTF8
|
||||
|
||||
# 创建 ACME 目录
|
||||
New-Item -ItemType Directory -Force -Path "C:\nginx\acme" | Out-Null
|
||||
|
||||
# 测试配置
|
||||
& "$installDir\nginx.exe" -t
|
||||
Write-Log "Nginx 配置测试通过" "SUCCESS"
|
||||
|
||||
# 创建服务
|
||||
$serviceName = "nginx"
|
||||
$nssmPath = "C:\pocketbase\nssm.exe"
|
||||
& $nssmPath install $serviceName "$installDir\nginx.exe" ""
|
||||
& $nssmPath set $serviceName DisplayName "Nginx Web Server"
|
||||
& $nssmPath set $serviceName Description "Nginx 反向代理 for 云升数码"
|
||||
& $nssmPath set $serviceName AppDirectory $installDir
|
||||
& $nssmPath set $serviceName Start SERVICE_AUTO_START
|
||||
|
||||
Write-Log "Nginx 安装完成" "SUCCESS"
|
||||
}
|
||||
|
||||
# 安装 Certbot (Windows)
|
||||
function Install-Certbot {
|
||||
param([string]$Domain, [string]$Email)
|
||||
|
||||
if ($SkipSSL) {
|
||||
Write-Log "跳过 SSL 证书申请" "WARN"
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $Domain -or -not $Email) {
|
||||
Write-Log "域名或邮箱为空,跳过 SSL 证书申请" "WARN"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "安装 Certbot..."
|
||||
|
||||
# 使用 Chocolatey 安装
|
||||
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
|
||||
Write-Log "安装 Chocolatey..."
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
}
|
||||
|
||||
choco install certbot -y --no-progress
|
||||
|
||||
# 申请证书
|
||||
Write-Log "申请 SSL 证书..."
|
||||
certbot certonly --standalone -d $Domain -d "www.$Domain" `
|
||||
--non-interactive --agree-tos --email $Email `
|
||||
--preferred-challenges http
|
||||
|
||||
Write-Log "SSL 证书申请完成" "SUCCESS"
|
||||
}
|
||||
|
||||
# 创建维护脚本
|
||||
function Create-MaintenanceScripts {
|
||||
$scriptsDir = "C:\pocketbase\scripts"
|
||||
New-Item -ItemType Directory -Force -Path $scriptsDir | Out-Null
|
||||
|
||||
# 备份脚本
|
||||
$backupScript = @"
|
||||
@echo off
|
||||
set BACKUP_DIR=C:\backup\yunsheng-digital
|
||||
set DATE=%date:~-4,4%%date:~-10,2%%date:~-7,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set DATE=%DATE: =0%
|
||||
mkdir "%BACKUP_DIR%" 2>nul
|
||||
|
||||
echo [INFO] 开始备份...
|
||||
|
||||
REM 备份 PocketBase 数据
|
||||
C:\pocketbase\pocketbase.exe dump --dir=C:\pocketbase\pb_data --output="%BACKUP_DIR%\pocketbase_%DATE%.zip"
|
||||
|
||||
REM 备份 Nginx 配置
|
||||
copy C:\nginx\conf\nginx.conf "%BACKUP_DIR%\nginx_%DATE%.conf"
|
||||
|
||||
REM 清理 30 天前的备份
|
||||
forfiles /p "%BACKUP_DIR%" /s /m *.* /d -30 /c "cmd /c del @path"
|
||||
|
||||
echo [SUCCESS] 备份完成
|
||||
"@
|
||||
Set-Content -Path "$scriptsDir\backup.bat" -Value $backupScript -Encoding UTF8
|
||||
|
||||
# 更新脚本
|
||||
$updateScript = @"
|
||||
@echo off
|
||||
cd /d "%USERPROFILE%\yunsheng-digital"
|
||||
echo [INFO] 拉取最新代码...
|
||||
git pull origin master
|
||||
|
||||
echo [INFO] 安装依赖...
|
||||
cd apps\web
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
echo [INFO] 构建前端...
|
||||
pnpm build
|
||||
|
||||
echo [INFO] 部署构建产物...
|
||||
xcopy /E /Y /I dist\* C:\pocketbase\pb_public\
|
||||
|
||||
echo [INFO] 重启服务...
|
||||
net stop nginx
|
||||
net stop PocketBase
|
||||
timeout /t 3
|
||||
net start PocketBase
|
||||
timeout /t 3
|
||||
net start nginx
|
||||
|
||||
echo [SUCCESS] 更新完成
|
||||
"@
|
||||
Set-Content -Path "$scriptsDir\update.bat" -Value $updateScript -Encoding UTF8
|
||||
|
||||
# 状态脚本
|
||||
$statusScript = @"
|
||||
@echo off
|
||||
echo === 云升数码 服务状态 ===
|
||||
echo.
|
||||
echo --- PocketBase ---
|
||||
sc query PocketBase
|
||||
echo.
|
||||
echo --- Nginx ---
|
||||
sc query nginx
|
||||
echo.
|
||||
echo --- 磁盘使用 ---
|
||||
wmic logicaldisk get size,freespace,caption
|
||||
echo.
|
||||
echo --- 网络连接 ---
|
||||
netstat -an | findstr "80 443 8090"
|
||||
"@
|
||||
Set-Content -Path "$scriptsDir\status.bat" -Value $statusScript -Encoding UTF8
|
||||
|
||||
Write-Log "维护脚本创建完成" "SUCCESS"
|
||||
}
|
||||
|
||||
# 配置防火墙
|
||||
function Configure-Firewall {
|
||||
Write-Log "配置 Windows 防火墙..."
|
||||
|
||||
# 允许 HTTP/HTTPS
|
||||
New-NetFirewallRule -DisplayName "云升数码 HTTP" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow -ErrorAction SilentlyContinue
|
||||
New-NetFirewallRule -DisplayName "云升数码 HTTPS" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow -ErrorAction SilentlyContinue
|
||||
New-NetFirewallRule -DisplayName "云升数码 PocketBase" -Direction Inbound -Protocol TCP -LocalPort 8090 -Action Allow -Profile Private -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Log "防火墙配置完成" "SUCCESS"
|
||||
}
|
||||
|
||||
# 创建计划任务
|
||||
function Create-ScheduledTasks {
|
||||
Write-Log "创建计划任务..."
|
||||
|
||||
# 每天备份
|
||||
$action = New-ScheduledTaskAction -Execute "C:\pocketbase\scripts\backup.bat"
|
||||
$trigger = New-ScheduledTaskTrigger -Daily -At 3am
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -DontStopOnIdleEnd
|
||||
Register-ScheduledTask -TaskName "云升数码-每日备份" -Action $action -Trigger $trigger -Settings $settings -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 每周清理日志
|
||||
$action2 = New-ScheduledTaskAction -Execute "wevtutil.exe" -Argument "cl System /bu:C:\backup\system_logs\system_%date%.evtx"
|
||||
$trigger2 = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 4am
|
||||
Register-ScheduledTask -TaskName "云升数码-周日志清理" -Action $action2 -Trigger $trigger2 -Settings $settings -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Log "计划任务创建完成" "SUCCESS"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
function Main {
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " 云升数码 - Windows 一键部署脚本 v1.0" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Check-Admin
|
||||
|
||||
# 获取参数
|
||||
if (-not $Domain) {
|
||||
$Domain = Read-Host "请输入域名 (例: yunsheng.digital)"
|
||||
}
|
||||
|
||||
if (-not $Email) {
|
||||
$Email = Read-Host "请输入邮箱 (用于 SSL 证书)"
|
||||
}
|
||||
|
||||
Write-Log "开始部署..."
|
||||
Write-Log "域名: $Domain"
|
||||
Write-Log "邮箱: $Email"
|
||||
|
||||
# 克隆/更新代码
|
||||
$projectDir = "$env:USERPROFILE\yunsheng-digital"
|
||||
if (Test-Path $projectDir) {
|
||||
Write-Log "更新现有代码..."
|
||||
cd $projectDir
|
||||
git pull origin master
|
||||
} else {
|
||||
Write-Log "克隆仓库..."
|
||||
git clone https://git.grxiao.cn/yuns/plerr-open.git $projectDir
|
||||
}
|
||||
|
||||
# 安装 Node.js (如果需要)
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Write-Log "安装 Node.js..."
|
||||
choco install nodejs-lts -y --no-progress
|
||||
refreshenv
|
||||
}
|
||||
|
||||
# 安装 pnpm
|
||||
if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) {
|
||||
Write-Log "安装 pnpm..."
|
||||
npm install -g pnpm
|
||||
}
|
||||
|
||||
# 构建前端
|
||||
Write-Log "构建前端..."
|
||||
cd "$projectDir\apps\web"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
# 复制构建产物到 PocketBase public 目录
|
||||
$pbPublic = "C:\pocketbase\pb_public"
|
||||
New-Item -ItemType Directory -Force -Path $pbPublic | Out-Null
|
||||
Copy-Item -Path "dist\*" -Destination $pbPublic -Recurse -Force
|
||||
Write-Log "前端构建产物已部署" "SUCCESS"
|
||||
|
||||
# 安装后端服务
|
||||
$pbInfo = Install-PocketBase
|
||||
|
||||
# 安装 Nginx
|
||||
Install-Nginx -Domain $Domain
|
||||
|
||||
# 配置防火墙
|
||||
Configure-Firewall
|
||||
|
||||
# 创建维护脚本
|
||||
Create-MaintenanceScripts
|
||||
|
||||
# 创建计划任务
|
||||
Create-ScheduledTasks
|
||||
|
||||
# SSL 证书
|
||||
Install-Certbot -Domain $Domain -Email $Email
|
||||
|
||||
# 启动服务
|
||||
Write-Log "启动服务..."
|
||||
net start PocketBase
|
||||
Start-Sleep -Seconds 3
|
||||
net start nginx
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Green
|
||||
Write-Host " 部署完成!" -ForegroundColor Green
|
||||
Write-Host "==========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "📋 重要信息:" -ForegroundColor Cyan
|
||||
Write-Host " - 网站地址: https://$Domain"
|
||||
Write-Host " - 后台管理: https://$Domain/_/"
|
||||
Write-Host " - 管理员账号: admin@yunsheng.digital"
|
||||
Write-Host " - 管理员密码: YunSheng@2024!Admin"
|
||||
Write-Host " - PocketBase 目录: C:\pocketbase"
|
||||
Write-Host " - 前端构建目录: C:\pocketbase\pb_public"
|
||||
Write-Host ""
|
||||
Write-Host "🔐 安全提醒:" -ForegroundColor Yellow
|
||||
Write-Host " 1. 请立即登录后台修改默认管理员密码"
|
||||
Write-Host " 2. 加密密钥已保存在服务配置中"
|
||||
Write-Host " 3. 建议配置 RDP 限制、启用 BitLocker"
|
||||
Write-Host ""
|
||||
Write-Host "🛠️ 常用命令:" -ForegroundColor Cyan
|
||||
Write-Host " - 查看状态: C:\pocketbase\scripts\status.bat"
|
||||
Write-Host " - 备份数据: C:\pocketbase\scripts\backup.bat"
|
||||
Write-Host " - 更新代码: C:\pocketbase\scripts\update.bat"
|
||||
Write-Host " - 查看日志: C:\pocketbase\logs\"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Main
|
||||
@@ -0,0 +1,596 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# PocketBase 版本
|
||||
PB_VERSION="0.22.0"
|
||||
PB_DIR="/opt/pocketbase"
|
||||
PB_DATA_DIR="${PB_DIR}/pb_data"
|
||||
PB_MIGRATIONS_DIR="${PB_DIR}/pb_migrations"
|
||||
PB_PUBLIC_DIR="${PB_DIR}/pb_public"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
# 检查是否为 root
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
log_error "请使用 root 权限运行 (sudo ./install.sh)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测系统架构
|
||||
detect_arch() {
|
||||
local arch=$(uname -m)
|
||||
case $arch in
|
||||
x86_64) echo "amd64" ;;
|
||||
aarch64|arm64) echo "arm64" ;;
|
||||
armv7l) echo "armv7" ;;
|
||||
*) log_error "不支持的架构: $arch"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 安装依赖
|
||||
install_deps() {
|
||||
log_info "安装系统依赖..."
|
||||
apt update && apt upgrade -y
|
||||
apt install -y curl wget unzip nginx certbot python3-certbot-nginx ufw fail2ban
|
||||
log_success "系统依赖安装完成"
|
||||
}
|
||||
|
||||
# 下载并安装 PocketBase
|
||||
install_pocketbase() {
|
||||
local arch=$(detect_arch)
|
||||
local pb_url="https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${arch}.zip"
|
||||
local tmp_dir=$(mktemp -d)
|
||||
|
||||
log_info "下载 PocketBase v${PB_VERSION} (${arch})..."
|
||||
cd "$tmp_dir"
|
||||
wget -q --show-progress "$pb_url" -O pocketbase.zip
|
||||
unzip -q pocketbase.zip
|
||||
|
||||
log_info "安装 PocketBase 到 ${PB_DIR}..."
|
||||
mkdir -p "$PB_DIR"
|
||||
mv pocketbase "$PB_DIR/"
|
||||
chmod +x "$PB_DIR/pocketbase"
|
||||
|
||||
# 创建数据目录
|
||||
mkdir -p "$PB_DATA_DIR" "$PB_MIGRATIONS_DIR" "$PB_PUBLIC_DIR"
|
||||
|
||||
# 复制迁移文件
|
||||
if [[ -d "$(dirname "$0")/../apps/pb_migrations" ]]; then
|
||||
cp -r "$(dirname "$0")/../apps/pb_migrations"/* "$PB_MIGRATIONS_DIR/"
|
||||
log_success "迁移文件已复制"
|
||||
fi
|
||||
|
||||
# 清理
|
||||
cd /
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
log_success "PocketBase 安装完成"
|
||||
}
|
||||
|
||||
# 生成加密密钥
|
||||
generate_encryption_key() {
|
||||
openssl rand -base64 32 | tr -d '\n'
|
||||
}
|
||||
|
||||
# 创建 systemd 服务
|
||||
create_systemd_service() {
|
||||
local encryption_key=$(generate_encryption_key)
|
||||
|
||||
log_info "创建 systemd 服务..."
|
||||
cat > /etc/systemd/system/pocketbase.service <<EOF
|
||||
[Unit]
|
||||
Description=PocketBase - 云升数码后端服务
|
||||
Documentation=https://pocketbase.io/docs/
|
||||
After=network.target network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=${PB_DIR}
|
||||
ExecStart=${PB_DIR}/pocketbase serve \\
|
||||
--http=127.0.0.1:8090 \\
|
||||
--dir=${PB_DATA_DIR} \\
|
||||
--publicDir=${PB_PUBLIC_DIR} \\
|
||||
--migrationsDir=${PB_MIGRATIONS_DIR} \\
|
||||
--encryptionEnv=PB_ENCRYPTION_KEY
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
# 安全配置
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=${PB_DATA_DIR} ${PB_PUBLIC_DIR} ${PB_MIGRATIONS_DIR}
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
|
||||
# 环境变量
|
||||
Environment=PB_ENCRYPTION_KEY=${encryption_key}
|
||||
Environment=GOGC=50
|
||||
|
||||
# 资源限制
|
||||
LimitNOFILE=65535
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 设置权限
|
||||
chown -R www-data:www-data "$PB_DIR"
|
||||
chmod 750 "$PB_DATA_DIR" "$PB_MIGRATIONS_DIR" "$PB_PUBLIC_DIR"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable pocketbase
|
||||
|
||||
log_success "systemd 服务创建完成"
|
||||
log_info "加密密钥: ${encryption_key}"
|
||||
log_warn "请妥善保存加密密钥!迁移服务器时需要它来解密数据。"
|
||||
}
|
||||
|
||||
# 配置 Nginx
|
||||
configure_nginx() {
|
||||
local domain="$1"
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
log_warn "未提供域名,跳过 Nginx 配置"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "配置 Nginx 反向代理 (域名: ${domain})..."
|
||||
|
||||
cat > /etc/nginx/sites-available/yunsheng-digital <<EOF
|
||||
# 云升数码 - Nginx 配置
|
||||
# 生成时间: $(date)
|
||||
|
||||
# HTTP -> HTTPS 重定向
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${domain} www.${domain};
|
||||
|
||||
# ACME 挑战路径 (用于 SSL 证书验证)
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
# 其他请求重定向到 HTTPS
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 主站点
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name ${domain} www.${domain};
|
||||
|
||||
# SSL 证书 (由 Certbot 自动填充)
|
||||
# ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
|
||||
|
||||
# SSL 安全配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; media-src 'self' https:; connect-src 'self' wss: https:; frame-ancestors 'self';" always;
|
||||
|
||||
# 根目录
|
||||
root ${PB_PUBLIC_DIR};
|
||||
index index.html;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif|mp4|webm|ogg|mp3|wav|pdf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
# 前端路由 (SPA fallback)
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
# PocketBase API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Port \$server_port;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 缓冲
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
|
||||
# 文件上传大小限制
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
# Realtime WebSocket
|
||||
location /api/realtime {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
|
||||
# WebSocket 超时
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
}
|
||||
|
||||
# PocketBase Admin UI
|
||||
location /_/ {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
|
||||
# 限制 Admin UI 访问 (可选:仅允许特定 IP)
|
||||
# allow 1.2.3.4;
|
||||
# deny all;
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# 禁止访问备份文件
|
||||
location ~* \.(bak|backup|sql|log|env|ini)$ {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 启用站点
|
||||
ln -sf /etc/nginx/sites-available/yunsheng-digital /etc/nginx/sites-enabled/
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# 测试配置
|
||||
nginx -t
|
||||
|
||||
log_success "Nginx 配置完成"
|
||||
}
|
||||
|
||||
# 申请 SSL 证书
|
||||
setup_ssl() {
|
||||
local domain="$1"
|
||||
local email="$2"
|
||||
|
||||
if [[ -z "$domain" || -z "$email" ]]; then
|
||||
log_warn "未提供域名或邮箱,跳过 SSL 证书申请"
|
||||
log_info "稍后可手动运行: certbot --nginx -d ${domain}"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "申请 Let's Encrypt SSL 证书..."
|
||||
|
||||
# 先启动 nginx (HTTP 模式)
|
||||
systemctl restart nginx
|
||||
|
||||
# 申请证书
|
||||
certbot --nginx -d "${domain}" -d "www.${domain}" \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email "${email}" \
|
||||
--redirect \
|
||||
--hsts \
|
||||
--staple-ocsp \
|
||||
--must-staple
|
||||
|
||||
# 设置自动续期
|
||||
systemctl enable certbot.timer
|
||||
|
||||
log_success "SSL 证书申请完成"
|
||||
}
|
||||
|
||||
# 配置防火墙
|
||||
configure_firewall() {
|
||||
log_info "配置 UFW 防火墙..."
|
||||
|
||||
ufw --force enable
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
|
||||
# 仅允许本地访问 PocketBase 直接端口
|
||||
ufw allow from 127.0.0.1 to any port 8090
|
||||
|
||||
log_success "防火墙配置完成"
|
||||
}
|
||||
|
||||
# 配置 Fail2Ban
|
||||
configure_fail2ban() {
|
||||
log_info "配置 Fail2Ban..."
|
||||
|
||||
cat > /etc/fail2ban/jail.d/yunsheng-digital.conf <<EOF
|
||||
[DEFAULT]
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
maxretry = 5
|
||||
backend = systemd
|
||||
|
||||
[nginx-http-auth]
|
||||
enabled = true
|
||||
port = http,https
|
||||
logpath = /var/log/nginx/error.log
|
||||
|
||||
[nginx-limit-req]
|
||||
enabled = true
|
||||
port = http,https
|
||||
logpath = /var/log/nginx/error.log
|
||||
maxretry = 10
|
||||
|
||||
[nginx-badbots]
|
||||
enabled = true
|
||||
port = http,https
|
||||
logpath = /var/log/nginx/access.log
|
||||
maxretry = 3
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 3
|
||||
bantime = 86400
|
||||
EOF
|
||||
|
||||
systemctl enable fail2ban
|
||||
systemctl restart fail2ban
|
||||
|
||||
log_success "Fail2Ban 配置完成"
|
||||
}
|
||||
|
||||
# 创建维护脚本
|
||||
create_maintenance_scripts() {
|
||||
log_info "创建维护脚本..."
|
||||
|
||||
# 备份脚本
|
||||
cat > /usr/local/bin/yunsheng-backup <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="/backup/yunsheng-digital"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
PB_DATA_DIR="/opt/pocketbase/pb_data"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[SUCCESS]\033[0m $*"; }
|
||||
|
||||
log_info "开始备份..."
|
||||
|
||||
# 备份 PocketBase 数据 (使用内置 dump 命令)
|
||||
/opt/pocketbase/pocketbase dump \
|
||||
--dir=/opt/pocketbase/pb_data \
|
||||
--output="${BACKUP_DIR}/pocketbase_${DATE}.zip"
|
||||
|
||||
# 备份 Nginx 配置
|
||||
cp -r /etc/nginx/sites-available/yunsheng-digital "${BACKUP_DIR}/nginx_${DATE}.conf"
|
||||
|
||||
# 备份 SSL 证书
|
||||
cp -r /etc/letsencrypt "${BACKUP_DIR}/letsencrypt_${DATE}"
|
||||
|
||||
# 清理 30 天前的备份
|
||||
find "$BACKUP_DIR" -type f -mtime +30 -delete
|
||||
|
||||
log_success "备份完成: ${BACKUP_DIR}/pocketbase_${DATE}.zip"
|
||||
EOF
|
||||
|
||||
chmod +x /usr/local/bin/yunsheng-backup
|
||||
|
||||
# 更新脚本
|
||||
cat > /usr/local/bin/yunsheng-update <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/yunsheng-digital"
|
||||
PB_DIR="/opt/pocketbase"
|
||||
PB_PUBLIC_DIR="${PB_DIR}/pb_public"
|
||||
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[SUCCESS]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*"; }
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
log_info "拉取最新代码..."
|
||||
git pull origin master
|
||||
|
||||
log_info "安装前端依赖..."
|
||||
cd apps/web
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
log_info "构建前端..."
|
||||
pnpm build
|
||||
|
||||
log_info "部署构建产物..."
|
||||
cp -r dist/* "$PB_PUBLIC_DIR/"
|
||||
|
||||
log_info "重载 Nginx..."
|
||||
systemctl reload nginx
|
||||
|
||||
log_success "更新完成!"
|
||||
EOF
|
||||
|
||||
chmod +x /usr/local/bin/yunsheng-update
|
||||
|
||||
# 状态检查脚本
|
||||
cat > /usr/local/bin/yunsheng-status <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "=== 云升数码 服务状态 ==="
|
||||
echo
|
||||
|
||||
echo "--- PocketBase ---"
|
||||
systemctl status pocketbase --no-pager -l
|
||||
|
||||
echo
|
||||
echo "--- Nginx ---"
|
||||
systemctl status nginx --no-pager -l
|
||||
|
||||
echo
|
||||
echo "--- 磁盘使用 ---"
|
||||
df -h /opt/pocketbase
|
||||
|
||||
echo
|
||||
echo "--- 内存使用 ---"
|
||||
free -h
|
||||
|
||||
echo
|
||||
echo "--- 网络连接 ---"
|
||||
ss -tlnp | grep -E '(80|443|8090)'
|
||||
EOF
|
||||
|
||||
chmod +x /usr/local/bin/yunsheng-status
|
||||
|
||||
log_success "维护脚本创建完成"
|
||||
}
|
||||
|
||||
# 设置定时任务
|
||||
setup_cron() {
|
||||
log_info "设置定时任务..."
|
||||
|
||||
# 每天凌晨 3 点备份
|
||||
(crontab -l 2>/dev/null | grep -v yunsheng-backup; echo "0 3 * * * /usr/local/bin/yunsheng-backup >> /var/log/yunsheng-backup.log 2>&1") | crontab -
|
||||
|
||||
# 每周一凌晨 4 点清理日志
|
||||
(crontab -l 2>/dev/null | grep -v "journalctl"; echo "0 4 * * 1 journalctl --vacuum-time=30d >> /var/log/yunsheng-cleanup.log 2>&1") | crontab -
|
||||
|
||||
log_success "定时任务设置完成"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
echo "=========================================="
|
||||
echo " 云升数码 - 一键部署脚本 v1.0"
|
||||
echo "=========================================="
|
||||
echo
|
||||
|
||||
check_root
|
||||
|
||||
# 获取参数
|
||||
DOMAIN="${1:-}"
|
||||
EMAIL="${2:-}"
|
||||
|
||||
if [[ -z "$DOMAIN" ]]; then
|
||||
read -rp "请输入域名 (例: yunsheng.digital): " DOMAIN
|
||||
fi
|
||||
|
||||
if [[ -z "$EMAIL" ]]; then
|
||||
read -rp "请输入邮箱 (用于 SSL 证书): " EMAIL
|
||||
fi
|
||||
|
||||
log_info "开始部署..."
|
||||
log_info "域名: ${DOMAIN}"
|
||||
log_info "邮箱: ${EMAIL}"
|
||||
|
||||
install_deps
|
||||
install_pocketbase
|
||||
create_systemd_service
|
||||
configure_nginx "$DOMAIN"
|
||||
setup_ssl "$DOMAIN" "$EMAIL"
|
||||
configure_firewall
|
||||
configure_fail2ban
|
||||
create_maintenance_scripts
|
||||
setup_cron
|
||||
|
||||
# 启动服务
|
||||
log_info "启动服务..."
|
||||
systemctl start pocketbase
|
||||
sleep 3
|
||||
systemctl restart nginx
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
log_success "部署完成!"
|
||||
echo "=========================================="
|
||||
echo
|
||||
echo "📋 重要信息:"
|
||||
echo " - 网站地址: https://${DOMAIN}"
|
||||
echo " - 后台管理: https://${DOMAIN}/_/"
|
||||
echo " - 管理员账号: admin@yunsheng.digital"
|
||||
echo " - 管理员密码: YunSheng@2024!Admin"
|
||||
echo " - PocketBase 数据目录: ${PB_DATA_DIR}"
|
||||
echo " - 前端构建目录: ${PB_PUBLIC_DIR}"
|
||||
echo
|
||||
echo "🔐 安全提醒:"
|
||||
echo " 1. 请立即登录后台修改默认管理员密码"
|
||||
echo " 2. 加密密钥已保存在 systemd 服务文件中"
|
||||
echo " 3. 建议配置 SSH 密钥登录,禁用密码登录"
|
||||
echo
|
||||
echo "🛠️ 常用命令:"
|
||||
echo " - 查看状态: yunsheng-status"
|
||||
echo " - 备份数据: yunsheng-backup"
|
||||
echo " - 更新代码: yunsheng-update"
|
||||
echo " - 查看日志: journalctl -u pocketbase -f"
|
||||
echo
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user