为什么要自托管一个摄影相册买了相机拍了一些照片之后,还是想找个地方好好存照片,最好还能优雅地展示出来。 最开始查的一些方案,在v2ex等地方搜索,大家都还是用piwigo、immich、photoprism这些老牌的应用,技术成熟,但是据我所试是自托管基本只能把图片放在本机了,想在国内访问速度快,一个服务器加个域名备案,性能稍微差点的还体验不到一些功能(例如immich的人脸识别,机器学习等),目前阿里云、腾讯云的这些服务器要有个大点的SSD做存储还是挺贵的,动辄一个月大几百。 也考虑过exif-photo-blog和thumbsup这样优秀的项目,还有UI流畅的mtphoto,可惜闭源,访问相册也得输入账号密码,不适合公开访问最终还是选择了: Afilmory,一个零数据库的照片相册系统,照片存 OSS,配置存 JSON,前端静态化部署。重点是:✅ 自动提取 EXIF 信息(拍摄时间、地点、相机型号等)✅ 自动生成缩略图✅ 支持按时间线、地图、相机分类浏览✅ iPhone 直接用阿里云 App 上传照片,自动触发构建✅ 成本低✅ 支持实况照片✅ 瀑布流 or 列表视图展示搭完之后,从 iPhone 上传照片,30 秒后自动出现在相册里,还挺爽的。 这个项目自2025-09开源重构后,算是比较新的一个优秀相册,真的挺值得尝试的!其实aflimory还推荐以下两种方式搭建: 选项 1:官方 SaaS(推荐)👉 从 afilmory.art 开始——零设置,几分钟内上线!创建照片库的最简单方法。无需部署、无需服务器、无需维护。 优点:✅ 零配置——注册并立即上线✅ Live CMS——实时编辑照片、标题和元数据✅ 自定义域名——通过 DNS 验证绑定自己的域名✅ 自动更新——始终运行最新功能✅ 托管基础设施——作者和他的小伙伴们负责扩展、备份和维护选项2: Docker部署 详见文档:Docker部署(英文) Docker部署(中文)手动安装 这篇文档我采用手动安装的方式,想着之后可能会对项目代码有点改动方便点这是我的相册:查看我的 Afilmory 相册准备工作环境要求一台 VPS(Ubuntu 20.04+ / Debian 12 +,1C2G、2C2G够用),轻量应用服务器便宜也够用阿里云 OSS(存照片用,按量付费)域名(备案前也能用 IP 访问,备案后可上 CDN)重要提醒 ⚠️ 注意域名备案前后的部署方式不同:备案前:VPS 跑 SSR 应用(Next.js),用服务器 IP 访问备案后:可迁移到 OSS 静态托管 + CDN 加速,速度更快成本更低如果你的域名还没备案,先按本文搭建 SSR 模式,备案成功后再迁移到静态托管(我会在文末说明)。操作流程 按照本文顺序,你会先在阿里云控制台完成所有云端配置,然后 SSH 登录服务器部署应用,最后测试完成。一、阿里云 OSS 配置先把阿里云控制台的配置一次性做完,后面就不用来回切换了。1.1 创建 Bucket登录阿里云控制台(https://oss.console.aliyun.com/),创建 OSS Bucket:基本信息:Bucket 名称:afilmory-xxx(全局唯一,可以用 afilmory-你的昵称)区域:成都(或选离你最近的区域,影响访问速度)存储类型:标准存储同城冗余存储:不开启(个人相册不需要)读写权限(注意):选择 公共读(允许匿名用户读取,但只有授权用户可以写入)⚠️ 别选"私有",否则照片无法通过 URL 访问⚠️ 别选"公共读写",有安全风险其他设置:版本控制:不开启(节省费用)服务端加密:不开启(照片无需加密)实时日志查询:不开启(非必需)点击"确定"创建 Bucket。 ⚠️ 注意权限说明:公共读:任何人都可以通过 URL 查看照片(适合相册场景)如果设置为"私有",需要签名 URL 才能访问,会增加复杂度和成本后续可以通过 Referer 防盗链限制访问来源(第七章说明)1.2 创建目录结构进入刚创建的 Bucket,点击"文件管理",手动创建以下目录:photos/ - 存放原始照片(从 iPhone 上传到这里)thumbnails/ - 存放缩略图(Builder 自动生成后上传) ℹ️ 说明也可以先不创建,直接上传照片到 photos/ 目录,OSS 会自动创建。1.3 配置 CORS(跨域访问)OSS 控制台 → 数据安全 → 跨域设置 → 创建规则:来源:*(允许所有来源,也可以填写你的域名)允许 Methods:勾选 GET 和 HEAD允许 Headers:*暴露 Headers:ETag(可选)缓存时间:600 秒点击"确定"保存。 ℹ️ 说明如果不配置 CORS,前端从浏览器直接访问 OSS 照片时会报跨域错误。1.4 创建 RAM 用户(获取 AccessKey)访问控制 RAM 控制台(https://ram.console.aliyun.com/),创建专用账号:步骤 1:创建用户点击"身份管理" → "用户" → "创建用户"登录名称:afilmory显示名称:Afilmory 照片相册访问方式:勾选 OpenAPI 调用访问(会生成 AccessKey)⚠️ 不要勾选"控制台访问"(不需要登录控制台)步骤 2:授予权限创建后点击"添加权限"选择权限:AliyunOSSFullAccess(OSS 完全访问权限)点击"确定"步骤 3:保存 AccessKey创建完成后会显示 AccessKey ID 和 AccessKey Secret⚠️ AccessKey Secret 只显示一次,务必复制保存到安全的地方建议保存到密码管理器或本地加密文件 ⚠️ 注意WarningAccessKey 相当于账号密码,不要泄露给他人不要提交到 GitHub 等公开代码仓库如果泄露,立即在 RAM 控制台禁用或删除该 AccessKey1.5 配置 OSS 事件通知(使用函数计算)配置完 OSS 事件通知后,iPhone 上传照片就能自动触发构建,无需手动操作。我们使用阿里云函数计算来接收 OSS 事件通知并转发到 webhook 服务器,完全免费(个人相册用量在免费额度内)。步骤 1:创建函数计算服务登录阿里云控制台 → 函数计算 FC → 创建服务:服务名称:afilmory-webhook地域:西南1(成都)- cn-chengdu(与 OSS 保持一致)其他保持默认,点击"创建"步骤 2:创建事件函数在服务内创建函数 → 选择 **"事件函数"**:基础配置:函数名称:oss-event-forwarder实例类型:弹性实例vCPU:0.05 或 0.2(都够用)内存:128 MB 或 256 MB(都够用)最小实例数:0(缩容到零,完全免费)运行环境:Node.js 16(推荐)代码上传方式:使用示例代码 → 选择 "Hello, world! 示例"执行超时时间:30 秒高级配置:函数角色:不选(或选默认角色)挂载 NAS 文件系统:不勾选挂载 OSS 对象存储:不勾选点击"创建"。步骤 3:修改函数代码函数创建成功后,进入函数详情页 → "代码"标签,删除示例代码,替换为:JavaScriptconst http = require('http'); exports.handler = function(event, context, callback) { const webhookUrl = '你的服务器IP'; // 改为你的实际 IP const webhookPath = '/webhook/oss'; console.log('函数触发时间:', new Date().toISOString()); console.log('请求 ID:', context.requestId); // 解析 OSS 事件 let ossEvent; try { ossEvent = JSON.parse(event.toString()); console.log('事件数量:', ossEvent.events ? ossEvent.events.length : 0); } catch (e) { console.error('解析事件失败:', e); ossEvent = { events: [] }; } // 转发给 webhook 服务器 const postData = JSON.stringify(ossEvent); const options = { hostname: webhookUrl, port: 80, path: webhookPath, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData), 'User-Agent': 'AliyunFC-OSS-Trigger/1.0' }, timeout: 10000 }; console.log(`转发到: http://${webhookUrl}${webhookPath}`); const req = http.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { console.log(`✅ 转发成功 - 状态:${res.statusCode}`); callback(null, { statusCode: 200, body: '转发成功' }); }); }); req.on('error', (e) => { console.error(`❌ 转发失败: ${e.message}`); callback(null, { statusCode: 200, body: '已记录' }); }); req.on('timeout', () => { req.destroy(); console.error('⏱️ 请求超时'); callback(null, { statusCode: 200, body: '超时' }); }); req.write(postData); req.end(); };展開 · 60 行複製Tip修改第 4 行的 webhookUrl 为你的实际服务器 IP。点击"部署"保存代码。步骤 4:配置 OSS 触发器函数详情页 → "触发器"标签 → "创建触发器":触发器类型:对象存储(OSS)触发器名称:photo-upload-triggerOSS Bucket:选择 afilmory-xxx(你创建的 Bucket)触发事件:勾选以下事件(支持上传、删除、重命名)oss:ObjectCreated:PutObject - 简单上传oss:ObjectCreated:PostObject - 表单上传oss:ObjectCreated:CompleteMultipartUpload - 分片上传完成oss:ObjectCreated:CopyObject - 复制对象(重命名第1步)oss:ObjectRemoved:DeleteObject - 删除对象(可选,支持删除照片和重命名第2步)资源描述:前缀:photos/后缀:留空调用方式:异步调用点击"确定"创建触发器。 Tip函数计算优势:✅ 完全免费(每月 100 万次调用免费额度)✅ 实时触发(延迟 < 1s)✅ 无需维护消息队列(省钱)✅ 自动扩缩容步骤 5:测试函数(可选)在函数详情页 → "测试函数",输入测试事件:JSON複製{ "events": [ { "eventName": "ObjectCreated:PutObject", "eventSource": "acs:oss", "oss": { "bucket": { "name": "afilmory-xxx" }, "object": { "key": "photos/test.jpg", "size": 1048576 } } } ] }点击"执行",查看日志是否显示"✅ 转发成功"。 ℹ️ 说明说明:函数计算会将 OSS 事件直接转发到你的 webhook 服务器(JSON 格式,无需 Base64 解码)如果只需要上传功能,可以只勾选 ObjectCreated 类型的事件iPhone 通过阿里云 App 上传通常使用 PutObject(小文件)或 CompleteMultipartUpload(大文件)二、服务器环境配置现在切换到服务器端,SSH 登录后开始配置环境。2.1 安装基础软件BASH複製# 更新系统 sudo apt update && sudo apt upgrade -y # 安装 Node.js (18+) curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt install -y nodejs # 安装 pnpm 和 PM2 npm install -g pnpm pm2 # 安装 Nginx sudo apt install -y nginx2.2 安装并配置 ossutilBASH複製# 下载 ossutil wget https://gosspublic.alicdn.com/ossutil/1.7.15/ossutil64 chmod +x ossutil64 sudo mv ossutil64 /usr/local/bin/ossutil # 配置 ossutil ossutil config按提示填入(使用第一章保存的 AccessKey):配置文件路径:直接回车(使用默认 ~/.ossutilconfig)Endpoint:oss-cn-chengdu.aliyuncs.com(根据你的 OSS 区域填写)成都:oss-cn-chengdu.aliyuncs.com北京:oss-cn-beijing.aliyuncs.com上海:oss-cn-shanghai.aliyuncs.com杭州:oss-cn-hangzhou.aliyuncs.comAccessKeyId:粘贴第一章保存的 AccessKey IDAccessKeySecret:粘贴第一章保存的 AccessKey Secret测试连接:BASH複製ossutil ls oss://afilmory-xxx如果能正常列出文件(或显示为空),说明配置成功。三、部署 Afilmory(SSR 模式)3.1 克隆项目BASH複製cd /root/projects git clone https://github.com/Afilmory/afilmory.git cd afilmory pnpm install3.2 配置环境变量创建 /root/projects/afilmory/.env:BASH複製S3_BUCKET_NAME=afilmory-xxx S3_REGION=cn-chengdu S3_ENDPOINT=https://oss-cn-chengdu.aliyuncs.com S3_ACCESS_KEY_ID=你的AccessKeyId S3_SECRET_ACCESS_KEY=你的AccessKeySecret S3_PREFIX=photos/ S3_CUSTOM_DOMAIN= S3_EXCLUDE_REGEX=Tip必须填写 S3_REGION 和 S3_ENDPOINT,否则会使用 AWS S3 的默认值导致连接失败S3_ACCESS_KEY_ID 和 S3_SECRET_ACCESS_KEY 填入第一章保存的 AccessKeyS3_BUCKET_NAME 填入你创建的 Bucket 名称(如 afilmory-specialhua)3.3 配置站点信息编辑 /root/projects/afilmory/config.json:JSON複製{ "name": "我的相册", "title": "一些美好的记忆", "description": "时光不老,我们不散", "url": "http://你的服务器IP", "accentColor": "#007bff", "author": { "name": "你的名字", "url": "https://你的博客.com", "avatar": "https://你的头像地址.png" } }Tip备案前用 http://IP,备案后改为 https://你的域名.com3.4 首次运行 BuilderBASH複製cd /root/projects/afilmory pnpm run build:manifestBuilder 会:连接 OSS 读取 photos/ 目录下载照片生成缩略图创建 apps/web/src/data/photos-manifest.json3.5 上传 manifest 到 OSSBASH複製# 上传 manifest ossutil cp apps/web/src/data/photos-manifest.json \ oss://afilmory-xxx/photos-manifest.json --update # 上传缩略图 ossutil cp -r apps/web/src/data/thumbnails/ \ oss://afilmory-xxx/thumbnails/ --update # 设置 Content-Type ossutil set-meta oss://afilmory-xxx/photos-manifest.json \ Content-Type:application/json --update3.6 启动应用BASH複製cd /root/projects/afilmory pnpm build pm2 start pnpm --name afilmory -- start pm2 save pm2 startup3.7 配置 Nginx创建 /etc/nginx/sites-available/afilmory:nginx複製server { listen 80; server_name _; location / { proxy_pass http://127.0.0.1:3000; 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; } }BASH複製sudo ln -s /etc/nginx/sites-available/afilmory /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl restart nginx现在访问 http://你的IP 就能看到相册了。四、Webhook 自动化构建每次手动跑 Builder 太麻烦,用AI写了个Webhook,可以实现:iPhone 上传照片 → OSS 事件通知 → 自动构建 → 相册更新 有时候连服务器看日志挺麻烦的,直接webhook一个构建状态可以随时查看4.1 创建 Webhook 服务BASH複製mkdir -p /root/webhook-service cd /root/webhook-service npm init -y npm install express创建 /root/webhook-service/webhook-server.js,因为代码较长,这里折叠一下: Webhook-server.js 代码示例:JavaScriptconst express = require('express'); const { exec } = require('child_process'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(express.text({ type: 'text/plain' })); // ==================== 配置区域(修改这里) ==================== const CONFIG = { projectDir: '/root/projects/afilmory', // 项目目录 bucket: 'afilmory-XXXX', // OSS Bucket 名称 buildDelay: 30000, // 防抖延迟(毫秒) logFile: path.join(__dirname, 'webhook.log'), // 日志文件路径 buildScript: '/root/webhook-service/build-afilmory.sh', // 构建脚本路径 }; // ============================================================ // 日志函数 function log(message) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}`; console.log(logMessage); fs.appendFileSync(CONFIG.logFile, logMessage + '\n'); } // 脱敏函数 - 隐藏日志中的敏感信息 function sanitizeLog(logContent) { return logContent // 隐藏 AccessKey .replace(/AccessKey(Id|Secret)[:=]\s*[\w\/\+=]+/gi, 'AccessKey***隐藏***') // 隐藏完整服务器路径 .replace(/\/root\/[^\s]+/g, '/项目目录/***') // 隐藏 IP 地址 .replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '***.***.***.**') // 隐藏 OSS bucket 完整名称(保留前3个字符) .replace(/afilmory-\w+/g, 'afi***') // 隐藏 OSS endpoint 中的 bucket 名称 .replace(/oss:\/\/afilmory-[\w-]+/g, 'oss://afi***'); } // 构建队列 let isBuilding = false; let pendingBuild = false; let buildHistory = []; const MAX_HISTORY = 10; // 记录构建历史 function addBuildHistory(status, message) { buildHistory.unshift({ timestamp: new Date().toISOString(), status, message }); if (buildHistory.length > MAX_HISTORY) { buildHistory = buildHistory.slice(0, MAX_HISTORY); } } // 执行构建 function runBuild() { if (isBuilding) { log('⏳ 已有构建在进行中,标记为待构建'); pendingBuild = true; return; } isBuilding = true; log('🔄 开始自动构建...'); addBuildHistory('building', '开始构建'); exec(CONFIG.buildScript, { shell: '/bin/bash' }, (error, stdout, stderr) => { isBuilding = false; if (stdout) { log(`输出:\n${stdout}`); } if (error) { const errorMsg = `构建失败: ${error.message}`; log(`❌ ${errorMsg}`); addBuildHistory('failed', errorMsg); if (stderr) { log(`错误详情:\n${stderr}`); } if (pendingBuild) { pendingBuild = false; setTimeout(runBuild, 5000); } return; } log('✅ 自动构建完成'); addBuildHistory('success', '构建完成'); if (pendingBuild) { pendingBuild = false; setTimeout(runBuild, 2000); } }); } // Webhook 接口(OSS 事件通知 - 公开) app.post('/webhook/oss', (req, res) => { log(`📩 收到 OSS 事件通知`); try { let body = req.body; // 函数计算转发的消息是 JSON 格式(也兼容 Base64 编码,以防使用其他方式) if (typeof body === 'string') { try { // 先尝试直接解析 JSON(函数计算格式) body = JSON.parse(body); log(`✅ JSON 解析成功(函数计算格式)`); } catch (e1) { // 如果失败,尝试 Base64 解码(兼容其他方式) try { const decoded = Buffer.from(body, 'base64').toString('utf-8'); body = JSON.parse(decoded); log(`✅ Base64 解码成功(兼容模式)`); } catch (e2) { log(`⚠️ 消息解析失败: ${e1.message}`); body = {}; } } } // 检查是否有 events 字段 if (!body || !body.events || !Array.isArray(body.events)) { log('⚠️ 请求体格式不正确,触发兜底构建'); clearTimeout(global.buildTimeout); global.buildTimeout = setTimeout(() => { runBuild(); }, CONFIG.buildDelay); return res.json({ success: true, message: '已接收通知(格式异常,触发兜底构建)' }); } const events = body.events; log(`收到 ${events.length} 个事件`); // 过滤照片相关事件(创建、删除、重命名) const photoEvents = events.filter(e => { const isPhotoEvent = e.eventName && ( e.eventName.startsWith('ObjectCreated') || // 上传、复制(重命名第1步) e.eventName.startsWith('ObjectRemoved') // 删除、删除旧文件(重命名第2步) ); const hasKey = e.oss && e.oss.object && e.oss.object.key; const inPhotos = hasKey && e.oss.object.key.startsWith('photos/'); const isImage = hasKey && /\.(jpg|jpeg|png|heic|heif|tiff|webp|raw|dng|cr2|nef)$/i.test(e.oss.object.key); if (hasKey && isPhotoEvent && inPhotos && isImage) { const eventType = e.eventName.includes('Created') ? '📤 上传/复制' : e.eventName.includes('Removed') ? '🗑️ 删除' : e.eventName; const fileName = e.oss.object.key.split('/').pop(); log(` ${eventType}: ${fileName}`); } return isPhotoEvent && inPhotos && isImage; }); if (photoEvents.length === 0) { log('ℹ️ 非照片相关事件,忽略'); return res.json({ success: true, message: '非照片相关事件' }); } log(`📸 检测到 ${photoEvents.length} 个照片变化(上传/删除/重命名)`); // 30秒防抖:多个操作(如重命名=复制+删除)只触发一次构建 clearTimeout(global.buildTimeout); global.buildTimeout = setTimeout(() => { runBuild(); }, CONFIG.buildDelay); log(`⏱️ 将在 ${CONFIG.buildDelay / 1000} 秒后开始构建`); res.json({ success: true, message: `已接收 ${photoEvents.length} 个照片变化,将在 ${CONFIG.buildDelay / 1000} 秒后构建` }); } catch (error) { log(`❌ 处理事件失败: ${error.message}`); // 出错也触发构建(兜底) clearTimeout(global.buildTimeout); global.buildTimeout = setTimeout(() => { runBuild(); }, CONFIG.buildDelay); res.status(200).json({ success: true, message: '已接收通知(处理异常,触发兜底构建)' }); } }); // 查看状态(公开,只显示安全信息) app.get('/webhook/status', (req, res) => { res.json({ status: 'ok', isBuilding, pendingBuild, uptime: Math.floor(process.uptime()), uptimeFormatted: formatUptime(process.uptime()), buildHistory: buildHistory.slice(0, 5), // 只返回最近5条 buildDelay: CONFIG.buildDelay / 1000 }); }); // 格式化运行时间 function formatUptime(seconds) { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const parts = []; if (days > 0) parts.push(`${days}天`); if (hours > 0) parts.push(`${hours}小时`); if (minutes > 0) parts.push(`${minutes}分钟`); return parts.join(' ') || '刚刚启动'; } // 查看日志(公开,但自动脱敏) app.get('/webhook/logs', (req, res) => { if (!fs.existsSync(CONFIG.logFile)) { return res.type('text/plain').send('暂无日志'); } try { const logs = fs.readFileSync(CONFIG.logFile, 'utf-8'); const lines = logs.split('\n').slice(-100); // 最近100行 // 脱敏处理 const sanitizedLogs = lines.map(line => sanitizeLog(line)).join('\n'); res.type('text/plain'); res.send(sanitizedLogs); } catch (error) { res.type('text/plain').send('读取日志失败'); } }); // 健康检查(公开) app.get('/webhook/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), building: isBuilding }); }); // 管理界面(公开,只读) app.get('/webhook', (req, res) => { res.send(` <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Afilmory Webhook 监控</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 900px; margin: 0 auto; } .card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); } h1 { color: #333; margin-bottom: 8px; font-size: 28px; } .subtitle { color: #666; font-size: 14px; margin-bottom: 20px; } .status-indicator { display: inline-flex; align-items: center; padding: 8px 16px; border-radius: 20px; font-weight: 500; font-size: 14px; margin-bottom: 12px; } .status-idle { background: #d4edda; color: #155724; } .status-building { background: #fff3cd; color: #856404; } .status-dot { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; animation: pulse 2s infinite; } .status-idle .status-dot { background: #28a745; } .status-building .status-dot { background: #ffc107; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 20px 0; } .info-item { background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #667eea; } .info-label { font-size: 12px; color: #666; margin-bottom: 4px; } .info-value { font-size: 20px; font-weight: 600; color: #333; } .section-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid #e9ecef; } .history-item { display: flex; align-items: center; padding: 12px; margin-bottom: 8px; background: #f8f9fa; border-radius: 6px; border-left: 4px solid transparent; } .history-item.success { border-left-color: #28a745; } .history-item.failed { border-left-color: #dc3545; } .history-item.building { border-left-color: #ffc107; } .history-time { font-size: 12px; color: #666; margin-right: 12px; min-width: 100px; } .history-message { flex: 1; font-size: 14px; color: #333; } .history-badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; } .badge-success { background: #d4edda; color: #155724; } .badge-failed { background: #f8d7da; color: #721c24; } .badge-building { background: #fff3cd; color: #856404; } .btn { display: inline-block; padding: 10px 20px; background: #667eea; color: white; text-decoration: none; border-radius: 6px; font-size: 14px; margin-right: 10px; transition: background 0.3s; } .btn:hover { background: #5568d3; } .footer { text-align: center; color: white; margin-top: 32px; font-size: 14px; opacity: 0.9; } .empty-state { text-align: center; padding: 40px; color: #999; } </style> <script> // 自动刷新状态 async function refreshStatus() { try { const response = await fetch('/webhook/status'); const data = await response.json(); // 更新状态 const statusEl = document.getElementById('status'); const uptimeEl = document.getElementById('uptime'); const historyEl = document.getElementById('history'); if (data.isBuilding) { statusEl.innerHTML = '<span class="status-dot"></span>正在构建中...'; statusEl.className = 'status-indicator status-building'; } else { statusEl.innerHTML = '<span class="status-dot"></span>空闲'; statusEl.className = 'status-indicator status-idle'; } uptimeEl.textContent = data.uptimeFormatted; // 更新历史 if (data.buildHistory && data.buildHistory.length > 0) { historyEl.innerHTML = data.buildHistory.map(item => { const time = new Date(item.timestamp).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); const badgeClass = item.status === 'success' ? 'badge-success' : item.status === 'failed' ? 'badge-failed' : 'badge-building'; const statusText = item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : '构建中'; return \` <div class="history-item \${item.status}"> <div class="history-time">\${time}</div> <div class="history-message">\${item.message}</div> <span class="history-badge \${badgeClass}">\${statusText}</span> </div> \`; }).join(''); } else { historyEl.innerHTML = '<div class="empty-state">暂无构建记录</div>'; } } catch (error) { console.error('刷新状态失败:', error); } } // 页面加载时刷新 document.addEventListener('DOMContentLoaded', () => { refreshStatus(); // 每10秒自动刷新 setInterval(refreshStatus, 10000); }); </script> </head> <body> <div class="container"> <div class="card"> <h1>📸 Afilmory Webhook 监控</h1> <p class="subtitle">自动构建监控 · 只读模式</p> <div id="status" class="status-indicator status-${isBuilding ? 'building' : 'idle'}"> <span class="status-dot"></span> ${isBuilding ? '正在构建中...' : '空闲'} </div> ${pendingBuild ? '<p style="color: #856404; font-size: 14px; margin-top: 8px;">⏳ 有待处理的构建任务</p>' : ''} <div class="info-grid"> <div class="info-item"> <div class="info-label">服务运行时间</div> <div class="info-value" id="uptime">${formatUptime(process.uptime())}</div> </div> <div class="info-item"> <div class="info-label">防抖延迟</div> <div class="info-value">${CONFIG.buildDelay / 1000}秒</div> </div> <div class="info-item"> <div class="info-label">构建状态</div> <div class="info-value">${isBuilding ? '进行中' : '空闲'}</div> </div> </div> </div> <div class="card"> <div class="section-title">🕐 最近构建历史</div> <div id="history"> ${buildHistory.length > 0 ? buildHistory.slice(0, 5).map(item => { const time = new Date(item.timestamp).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); const badgeClass = item.status === 'success' ? 'badge-success' : item.status === 'failed' ? 'badge-failed' : 'badge-building'; const statusText = item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : '构建中'; return ` <div class="history-item ${item.status}"> <div class="history-time">${time}</div> <div class="history-message">${item.message}</div> <span class="history-badge ${badgeClass}">${statusText}</span> </div> `; }).join('') : '<div class="empty-state">暂无构建记录</div>'} </div> </div> <div class="card"> <div class="section-title">🔗 快捷链接</div> <a href="/webhook/status" class="btn" target="_blank">📊 查看状态 JSON</a> <a href="/webhook/logs" class="btn" target="_blank">📝 查看日志</a> <a href="/webhook/health" class="btn" target="_blank">💚 健康检查</a> </div> <div class="footer"> <p>Afilmory Webhook Service · 自动化构建监控</p> <p style="margin-top: 8px; font-size: 12px;">页面每10秒自动刷新</p> </div> </div> </body> </html> `); }); // 首页重定向到 webhook 管理界面 app.get('/', (req, res) => { res.redirect('/webhook'); }); // 监听地址配置 const HOST = process.env.WEBHOOK_HOST || '127.0.0.1'; const PORT = process.env.WEBHOOK_PORT || 3002; app.listen(PORT, HOST, () => { log(`🎣 Webhook 服务启动成功`); log(`📍 监听地址: ${HOST}:${PORT}`); log(`📝 日志文件: ${CONFIG.logFile}`); log(`⏱️ 防抖延迟: ${CONFIG.buildDelay / 1000} 秒`); if (HOST === '127.0.0.1' || HOST === 'localhost') { log(`✅ 安全模式:仅监听 localhost,通过 Nginx 访问`); } }); process.on('SIGTERM', () => { log('收到 SIGTERM 信号,准备退出...'); process.exit(0); }); process.on('SIGINT', () => { log('收到 SIGINT 信号,准备退出...'); process.exit(0); });展開 · 601 行複製Tip配置说明:脚本开头的 CONFIG 对象包含所有需要自定义的配置修改 bucket 为你的 OSS Bucket 名称修改 projectDir 为你的项目目录(如果不是 /root/projects/afilmory)修改 buildScript 为构建脚本的完整路径buildDelay 是防抖延迟时间(默认 30 秒),可根据需要调整核心功能:✅ 智能消息解析:自动识别函数计算的 JSON 格式消息(也兼容其他格式)✅ 30秒防抖:多张照片上传、重命名(复制+删除)只触发一次构建✅ 构建队列:防止并发构建,失败后自动重试✅ Web 监控界面:访问 http://你的IP/webhook 查看实时状态✅ 日志脱敏:自动隐藏 AccessKey、IP、路径等敏感信息✅ 构建历史:记录最近 10 次构建状态(成功/失败/进行中)✅ 健康检查:提供 /webhook/health 接口供监控使用4.2 创建构建脚本创建 /root/webhook-service/build-afilmory.sh:BASH#!/bin/bash # ==================== 配置区域(修改这里) ==================== # 项目目录 PROJECT_DIR="/root/projects/afilmory" # OSS Bucket 名称 OSS_BUCKET="afilmory-xxx" # PM2 应用名称 PM2_APP_NAME="afilmory" # Manifest 和缩略图路径(相对于项目根目录) MANIFEST_PATH="apps/web/src/data/photos-manifest.json" THUMBNAILS_PATH="apps/web/src/data/thumbnails" # SSR 应用目录(如果需要重新构建) SSR_DIR="apps/ssr" # ============================================================ set -e # 遇到错误立即退出 # 日志函数 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" } log "==========================================" log "开始自动构建 Afilmory" log "==========================================" # 进入项目目录 cd "$PROJECT_DIR" || { log "❌ 错误:无法进入项目目录 $PROJECT_DIR" exit 1 } # 加载环境变量 if [ -f .env ]; then log "📝 加载环境变量" set -a source .env set +a else log "⚠️ 警告:.env 文件不存在" fi # 运行 Builder log "📸 运行 Builder 处理照片" if pnpm run build:manifest 2>&1; then log "✅ Builder 执行成功" else log "❌ Builder 执行失败" exit 1 fi # 检查 manifest 文件是否存在 if [ ! -f "$MANIFEST_PATH" ]; then log "❌ 错误:找不到 manifest 文件: $MANIFEST_PATH" exit 1 fi # 检查缩略图目录是否存在 if [ ! -d "$THUMBNAILS_PATH" ]; then log "⚠️ 警告:找不到 thumbnails 目录: $THUMBNAILS_PATH" log "尝试查找 thumbnails 目录..." THUMBNAILS_PATH=$(find . -name "thumbnails" -type d | grep -v node_modules | head -1) if [ -z "$THUMBNAILS_PATH" ]; then log "❌ 错误:无法找到 thumbnails 目录" exit 1 fi log "✅ 找到 thumbnails 目录: $THUMBNAILS_PATH" fi # 上传 manifest 到 OSS log "📤 上传 manifest 到 OSS" if ossutil cp "$MANIFEST_PATH" \ oss://$OSS_BUCKET/photos-manifest.json --update 2>&1; then log "✅ Manifest 上传成功" else log "❌ Manifest 上传失败" exit 1 fi # 上传缩略图到 OSS log "📤 上传缩略图到 OSS" if ossutil cp -r "$THUMBNAILS_PATH/" \ oss://$OSS_BUCKET/thumbnails/ --update 2>&1; then log "✅ 缩略图上传成功" else log "❌ 缩略图上传失败" exit 1 fi # 设置 Content-Type log "🔧 设置 manifest Content-Type" if ossutil set-meta oss://$OSS_BUCKET/photos-manifest.json \ Content-Type:application/json --update 2>&1; then log "✅ Content-Type 设置成功" else log "⚠️ Content-Type 设置失败(不影响使用)" fi # 重新构建 SSR 应用(可选) log "🔨 重新构建 SSR 应用" cd "$PROJECT_DIR/$SSR_DIR" if pnpm run build 2>&1; then log "✅ SSR 构建成功" else log "⚠️ SSR 构建失败(可能不影响)" fi # 重启服务 log "🔄 重启 Afilmory 服务" if pm2 restart "$PM2_APP_NAME" --update-env 2>&1; then log "✅ 服务重启成功" else log "❌ 服务重启失败" exit 1 fi log "==========================================" log "✅ 构建完成!" log "==========================================" exit 0展開 · 126 行複製ℹ️ 说明Tip配置说明:脚本开头的配置区域包含了所有需要自定义的变量修改 OSS_BUCKET 为你的 Bucket 名称如果项目目录不在 /root/projects/afilmory,修改 PROJECT_DIR如果 PM2 应用名称不是 afilmory,修改 PM2_APP_NAMEBASH複製chmod +x /root/webhook-service/build-afilmory.sh4.3 启动 Webhook 服务BASH複製cd /root/webhook-service pm2 start webhook-server.js --name webhook pm2 save4.4 配置 Nginx(添加 webhook 路由)编辑 /etc/nginx/sites-available/afilmory,在 server 块中添加:nginx複製# OSS 事件 Webhook(公开,仅允许 POST) location = /webhook/oss { proxy_pass http://127.0.0.1:3002/webhook/oss; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; limit_except POST { deny all; } } # Webhook 管理界面和 API(公开,只读) location /webhook/ { proxy_pass http://127.0.0.1:3002/webhook/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 300s; }ℹ️ 说明Tip/webhook/oss:仅允许 POST 请求,用于接收 OSS 事件通知/webhook/:管理界面和 API 接口(状态、日志、健康检查),日志已自动脱敏所有接口都监听在 127.0.0.1,只能通过 Nginx 访问,外部无法直接访问BASH複製sudo nginx -t && sudo systemctl reload nginx4.5 测试方法 1:查看 Web 管理界面浏览器访问 http://你的服务器IP/webhook,可以看到:实时构建状态(空闲/构建中)服务运行时间最近 5 次构建历史快捷链接(状态 JSON、日志、健康检查)界面每 10 秒自动刷新,方便实时监控。方法 2:查看 PM2 日志BASH複製pm2 logs webhook方法 3:上传照片测试iPhone 上传一张照片到 OSS 的 photos/ 目录,等待 30 秒,观察 Web 界面或日志:複製📩 收到 OSS 事件通知 ✅ Base64 解码成功 📸 检测到 1 张照片上传 ⏱️ 将在 30 秒后开始构建 🔄 开始自动构建... ✅ 构建完成!刷新浏览器,新照片就出现了。 ✅ 提示Tip访问 http://你的IP/webhook/logs 可以查看最近 100 行日志(已自动脱敏)OSS 事件通知已在第一章配置完成,上传照片就会自动触发五、使用体验日常使用流程:iPhone 打开阿里云 App(App Store 搜索"OSS")进入 photos/ 目录,这里最好使用文件上传,因为OSS调用的是IOS的图库选择器,IOS有严格的照片分享机制,会把exif信息给隐藏掉,而我们搭建的摄影相册还是挺系统把镜头、光圈信息等展现出来的。可以在相册里提前把要上传的图片共享——保留原始信息存储到文件,建个文件夹之类的,并且把名字命好,上传后照片的名字就读取的文件名等 30 秒(防抖时间)刷新网页,新照片自动出现 ✅ 提示实测从上传到显示约 40-60 秒,完全可以接受。缩略图加载很快(100KB 左右),原图按需加载(5MB+)。六、常见问题6.1 照片上传后不显示BASH複製# 1. 检查 webhook 日志 pm2 logs webhook # 2. 检查 OSS manifest ossutil cat oss://afilmory-xxx/photos-manifest.json # 3. 手动运行 Builder cd /root/projects/afilmory pnpm run build:manifest # 4. 重启服务 pm2 restart afilmory6.2 Webhook 未触发BASH複製# 1. 检查 webhook 服务状态 pm2 list # 2. 手动测试 curl -X POST http://localhost:3002/webhook/oss -d '{"test":true}' # 3. 查看 OSS 事件通知规则 # 登录 OSS 控制台 → 事件通知 → 查看规则状态 # 4. 检查 MNS 订阅的接收终端地址是否正确 # 消息服务 MNS → 主题管理 → 订阅 → 确认 URL 为 http://你的IP/webhook/oss6.3 Nginx 502 错误BASH複製# 1. 检查 PM2 进程 pm2 list # 2. 查看日志 pm2 logs afilmory # 3. 重启服务 pm2 restart afilmory七、备案后优化(可选)域名备案完成后,可以进行一些优化:配置防盗链、启用 CDN 加速、迁移到静态托管等。7.1 配置 Referer 防盗链OSS 控制台 → 数据安全 → 防盗链:类型:白名单Referer 列表:填写以下内容TEXT複製https://你的域名.com https://你的域名.com/* http://你的域名.com http://你的域名.com/*允许空 Referer:建议不勾选(防止直接访问) ℹ️ 说明配置后,只有从你的域名访问的用户才能加载照片,防止别人盗链。7.2 配置 CDN 加速(可选)如果觉得 OSS 直连速度慢,可以配置 CDN 加速:步骤 1:创建 CDN 域名CDN 控制台 → 域名管理 → 添加域名:加速域名:cdn.你的域名.com业务类型:图片小文件源站类型:OSS 域名源站域名:afilmory-xxx.oss-cn-chengdu.aliyuncs.com步骤 2:配置 HTTPS 证书CDN 控制台 → cdn.你的域名.com → HTTPS 配置:上传 SSL 证书或使用免费证书开启强制 HTTPS 跳转步骤 3:配置缓存规则CDN 控制台 → 缓存配置 → 缓存规则:缩略图(.webp):缓存 7 天原图(.jpg, .jpeg, .png):缓存 1 天Manifest(.json):缓存 5 分钟步骤 4:更新项目配置编辑 /root/projects/afilmory/.env:BASH複製S3_CUSTOM_DOMAIN=https://cdn.你的域名.com重启应用:BASH複製cd /root/projects/afilmory pm2 restart afilmory7.3 迁移到 OSS 静态托管如果想降低成本,可以从 SSR 模式迁移到 OSS 静态托管。步骤 1:修改配置BASH複製# 修改 config.json nano /root/projects/afilmory/config.json # 将 url 改为 https://你的域名.com步骤 2:构建静态站点BASH複製cd /root/projects/afilmory pnpm build:static步骤 3:上传到 OSSBASH複製ossutil cp -r apps/web/dist/ oss://afilmory-xxx/ --update # 设置 Content-Type ossutil set-meta oss://afilmory-xxx/index.html \ Content-Type:text/html --update步骤 4:配置 OSS 静态网站托管OSS 控制台 → 基础设置 → 静态页面:默认首页:index.html默认 404 页:404.html步骤 5:绑定域名 + SSLOSS 控制台 → 传输管理 → 域名管理 → 绑定域名 → 上传 SSL 证书。步骤 6:停止 SSR 服务(可选)BASH複製pm2 stop afilmory pm2 delete afilmory pm2 saveWarning保留 webhook 服务!备案后仍然需要自动构建功能。后记折腾了两天总算搭好了,考虑到是相机原图,所以还是得选择国内备案+oss来搭建,aflirmory目前是我最满意的方案,适合摄影作品、后端OSS无数据库,以后迁移也方便。成本方面:服务器:2C2G 轻量应用服务器,有活动几十块钱一年完全够用函数计算:完全免费(每月 100 万次调用免费额度,个人相册远用不完)OSS 存储:主要费用在这里(按量付费,约 ¥0.12/GB/月)CDN 加速:可选,如果看重速度可以加上(按流量计费)Afilmory 还在活跃开发中,作者 @meetqy 更新很频繁,后续可能会支持更多功能(比如人脸识别、视频支持等)。有个自己的相册,不妨试试自托管吧参考资料Afilmory 官方文档阿里云 OSS 文档
为什么要自托管一个摄影相册
买了相机拍了一些照片之后,还是想找个地方好好存照片,最好还能优雅地展示出来。 最开始查的一些方案,在v2ex等地方搜索,大家都还是用piwigo、immich、photoprism这些老牌的应用,技术成熟,但是据我所试是自托管基本只能把图片放在本机了,想在国内访问速度快,一个服务器加个域名备案,性能稍微差点的还体验不到一些功能(例如immich的人脸识别,机器学习等),目前阿里云、腾讯云的这些服务器要有个大点的SSD做存储还是挺贵的,动辄一个月大几百。 也考虑过exif-photo-blog和thumbsup这样优秀的项目,还有UI流畅的mtphoto,可惜闭源,访问相册也得输入账号密码,不适合公开访问
最终还是选择了: Afilmory,一个零数据库的照片相册系统,照片存 OSS,配置存 JSON,前端静态化部署。重点是:
搭完之后,从 iPhone 上传照片,30 秒后自动出现在相册里,还挺爽的。 这个项目自2025-09开源重构后,算是比较新的一个优秀相册,真的挺值得尝试的!
其实aflimory还推荐以下两种方式搭建: 选项 1:官方 SaaS(推荐)
👉 从 afilmory.art 开始——零设置,几分钟内上线!
创建照片库的最简单方法。无需部署、无需服务器、无需维护。 优点:
选项2: Docker部署 详见文档:
Docker部署(英文) Docker部署(中文)
手动安装 这篇文档我采用手动安装的方式,想着之后可能会对项目代码有点改动方便点
这是我的相册:
查看我的 Afilmory 相册
准备工作
环境要求
重要提醒 ⚠️ 注意
域名备案前后的部署方式不同:
如果你的域名还没备案,先按本文搭建 SSR 模式,备案成功后再迁移到静态托管(我会在文末说明)。
操作流程 按照本文顺序,你会先在阿里云控制台完成所有云端配置,然后 SSH 登录服务器部署应用,最后测试完成。
一、阿里云 OSS 配置
先把阿里云控制台的配置一次性做完,后面就不用来回切换了。
1.1 创建 Bucket
登录阿里云控制台(https://oss.console.aliyun.com/),创建 OSS Bucket:
基本信息:
读写权限(注意):
其他设置:
点击"确定"创建 Bucket。 ⚠️ 注意
权限说明:
1.2 创建目录结构
进入刚创建的 Bucket,点击"文件管理",手动创建以下目录:
也可以先不创建,直接上传照片到 photos/ 目录,OSS 会自动创建。
1.3 配置 CORS(跨域访问)
OSS 控制台 → 数据安全 → 跨域设置 → 创建规则:
点击"确定"保存。 ℹ️ 说明
如果不配置 CORS,前端从浏览器直接访问 OSS 照片时会报跨域错误。
1.4 创建 RAM 用户(获取 AccessKey)
访问控制 RAM 控制台(https://ram.console.aliyun.com/),创建专用账号:
步骤 1:创建用户
步骤 2:授予权限
步骤 3:保存 AccessKey
1.5 配置 OSS 事件通知(使用函数计算)
配置完 OSS 事件通知后,iPhone 上传照片就能自动触发构建,无需手动操作。
我们使用阿里云函数计算来接收 OSS 事件通知并转发到 webhook 服务器,完全免费(个人相册用量在免费额度内)。
步骤 1:创建函数计算服务
登录阿里云控制台 → 函数计算 FC → 创建服务:
步骤 2:创建事件函数
在服务内创建函数 → 选择 **"事件函数"**:
基础配置:
高级配置:
点击"创建"。
步骤 3:修改函数代码
函数创建成功后,进入函数详情页 → "代码"标签,删除示例代码,替换为:
修改第 4 行的 webhookUrl 为你的实际服务器 IP。
点击"部署"保存代码。
步骤 4:配置 OSS 触发器
函数详情页 → "触发器"标签 → "创建触发器":
点击"确定"创建触发器。
函数计算优势:
步骤 5:测试函数(可选)
在函数详情页 → "测试函数",输入测试事件:
点击"执行",查看日志是否显示"✅ 转发成功"。 ℹ️ 说明
说明:
二、服务器环境配置
现在切换到服务器端,SSH 登录后开始配置环境。
2.1 安装基础软件
2.2 安装并配置 ossutil
按提示填入(使用第一章保存的 AccessKey):
测试连接:
如果能正常列出文件(或显示为空),说明配置成功。
三、部署 Afilmory(SSR 模式)
3.1 克隆项目
3.2 配置环境变量
创建 /root/projects/afilmory/.env:
3.3 配置站点信息
编辑 /root/projects/afilmory/config.json:
备案前用 http://IP,备案后改为 https://你的域名.com
3.4 首次运行 Builder
Builder 会:
3.5 上传 manifest 到 OSS
3.6 启动应用
3.7 配置 Nginx
创建 /etc/nginx/sites-available/afilmory:
现在访问 http://你的IP 就能看到相册了。
四、Webhook 自动化构建
每次手动跑 Builder 太麻烦,用AI写了个Webhook,可以实现:iPhone 上传照片 → OSS 事件通知 → 自动构建 → 相册更新 有时候连服务器看日志挺麻烦的,直接webhook一个构建状态可以随时查看
4.1 创建 Webhook 服务
创建 /root/webhook-service/webhook-server.js,因为代码较长,这里折叠一下: Webhook-server.js 代码示例:
配置说明:
核心功能:
4.2 创建构建脚本
创建 /root/webhook-service/build-afilmory.sh:
ℹ️ 说明
配置说明:
4.3 启动 Webhook 服务
4.4 配置 Nginx(添加 webhook 路由)
编辑 /etc/nginx/sites-available/afilmory,在 server 块中添加:
ℹ️ 说明
4.5 测试
方法 1:查看 Web 管理界面
浏览器访问 http://你的服务器IP/webhook,可以看到:
界面每 10 秒自动刷新,方便实时监控。
方法 2:查看 PM2 日志
方法 3:上传照片测试
iPhone 上传一张照片到 OSS 的 photos/ 目录,等待 30 秒,观察 Web 界面或日志:
刷新浏览器,新照片就出现了。 ✅ 提示
五、使用体验
日常使用流程:
实测从上传到显示约 40-60 秒,完全可以接受。缩略图加载很快(100KB 左右),原图按需加载(5MB+)。
六、常见问题
6.1 照片上传后不显示
6.2 Webhook 未触发
6.3 Nginx 502 错误
七、备案后优化(可选)
域名备案完成后,可以进行一些优化:配置防盗链、启用 CDN 加速、迁移到静态托管等。
7.1 配置 Referer 防盗链
OSS 控制台 → 数据安全 → 防盗链:
配置后,只有从你的域名访问的用户才能加载照片,防止别人盗链。
7.2 配置 CDN 加速(可选)
如果觉得 OSS 直连速度慢,可以配置 CDN 加速:
步骤 1:创建 CDN 域名
CDN 控制台 → 域名管理 → 添加域名:
步骤 2:配置 HTTPS 证书
CDN 控制台 → cdn.你的域名.com → HTTPS 配置:
步骤 3:配置缓存规则
CDN 控制台 → 缓存配置 → 缓存规则:
步骤 4:更新项目配置
编辑 /root/projects/afilmory/.env:
重启应用:
7.3 迁移到 OSS 静态托管
如果想降低成本,可以从 SSR 模式迁移到 OSS 静态托管。
步骤 1:修改配置
步骤 2:构建静态站点
步骤 3:上传到 OSS
步骤 4:配置 OSS 静态网站托管
OSS 控制台 → 基础设置 → 静态页面:
步骤 5:绑定域名 + SSL
OSS 控制台 → 传输管理 → 域名管理 → 绑定域名 → 上传 SSL 证书。
步骤 6:停止 SSR 服务(可选)
保留 webhook 服务!备案后仍然需要自动构建功能。
后记
折腾了两天总算搭好了,考虑到是相机原图,所以还是得选择国内备案+oss来搭建,aflirmory目前是我最满意的方案,适合摄影作品、后端OSS无数据库,以后迁移也方便。
成本方面:
Afilmory 还在活跃开发中,作者 @meetqy 更新很频繁,后续可能会支持更多功能(比如人脸识别、视频支持等)。
有个自己的相册,不妨试试自托管吧
参考资料