diff --git a/apps/pb_migrations/1710000001_init_collections.js b/apps/pb_migrations/1710000001_init_collections.js new file mode 100644 index 0000000..ac29abc --- /dev/null +++ b/apps/pb_migrations/1710000001_init_collections.js @@ -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) +}) \ No newline at end of file diff --git a/apps/pb_migrations/1710000002_seed_data.js b/apps/pb_migrations/1710000002_seed_data.js new file mode 100644 index 0000000..3c32cc2 --- /dev/null +++ b/apps/pb_migrations/1710000002_seed_data.js @@ -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 < 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) + } +}) \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..cd3fec4 --- /dev/null +++ b/scripts/install.ps1 @@ -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 \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..47582ae --- /dev/null +++ b/scripts/install.sh @@ -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 < /etc/nginx/sites-available/yunsheng-digital < 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 < /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 "$@" \ No newline at end of file