用阿里云oss自托管一个摄影相册afilmory

为什么要自托管一个摄影相册

买了相机拍了一些照片之后,还是想找个地方好好存照片,最好还能优雅地展示出来。
最开始查的一些方案,在v2ex等地方搜索,大家都还是用piwigoimmichphotoprism这些老牌的应用,技术成熟,但是据我所试是自托管基本只能把图片放在本机了,想在国内访问速度快,一个服务器加个域名备案,性能稍微差点的还体验不到一些功能(例如immich的人脸识别,机器学习等),目前阿里云、腾讯云的这些服务器要有个大点的SSD做存储还是挺贵的,动辄一个月大几百。
也考虑过exif-photo-blogthumbsup)这样优秀的项目,还有UI流畅的mtphoto(可惜闭源,访问相册也得输入账号密码,不适合公开访问)

最终还是选择了:
Afilmory,一个零数据库的照片相册系统,照片存 OSS,配置存 JSON,前端静态化部署。重点是:

  • ✅ 自动提取 EXIF 信息(拍摄时间、地点、相机型号等)
  • ✅ 自动生成缩略图
  • ✅ 支持按时间线、地图、相机分类浏览
  • ✅ iPhone 直接用阿里云 App 上传照片,自动触发构建
  • ✅ 成本低
  • ✅ 支持实况照片
  • ✅ 瀑布流 or 列表视图展示

搭完之后,从 iPhone 上传照片,30 秒后自动出现在相册里,还挺爽的。
这个项目自2025-09开源重构后,算是比较新的一个优秀相册,真的挺值得尝试的!

其实aflimory还推荐以下两种方式搭建:

选项 1:官方 SaaS(推荐)

👉从 aflimory.art 开始 - 零设置,几分钟内上线!

创建照片库的最简单方法。无需部署、无需服务器、无需维护。
优点:
✅ 零配置 - 注册并立即上线
✅ Live CMS - 实时编辑照片、标题和元数据
✅ 自定义域名 - 通过 DNS 验证绑定自己的域名
✅ 自动更新 - 始终运行最新功能
✅ 托管基础设施 - 作者和他的小伙伴们负责扩展、备份和维护

选项2: Docker部署

详见文档:

Docker部署(英文)
Docker部署(中文)

手动安装

这篇文档我采用手动安装的方式,想着之后可能会对项目代码有点改动方便点


准备工作

环境要求

  • 一台 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:勾选 GETHEAD
  • 允许 Headers*
  • 暴露 HeadersETag(可选)
  • 缓存时间600

点击”确定”保存。

如果不配置 CORS,前端从浏览器直接访问 OSS 照片时会报跨域错误。

1.4 创建 RAM 用户(获取 AccessKey)

访问控制 RAM 控制台(https://ram.console.aliyun.com/),创建专用账号:

步骤 1:创建用户

  • 点击”身份管理” → “用户” → “创建用户”
  • 登录名称afilmory
  • 显示名称Afilmory 照片相册
  • 访问方式:勾选 OpenAPI 调用访问(会生成 AccessKey)
  • ⚠️ 不要勾选”控制台访问”(不需要登录控制台)

步骤 2:授予权限

  • 创建后点击”添加权限”
  • 选择权限:AliyunOSSFullAccess(OSS 完全访问权限)
  • 点击”确定”

步骤 3:保存 AccessKey

  • 创建完成后会显示 AccessKey IDAccessKey Secret
  • ⚠️ AccessKey Secret 只显示一次,务必复制保存到安全的地方
  • 建议保存到密码管理器或本地加密文件

安全提示:

  • AccessKey 相当于账号密码,不要泄露给他人
  • 不要提交到 GitHub 等公开代码仓库
  • 如果泄露,立即在 RAM 控制台禁用或删除该 AccessKey

1.5 配置 OSS 事件通知

配置完 OSS 事件通知后,iPhone 上传照片就能自动触发构建,无需手动操作。

步骤 1:创建 OSS 事件通知规则

OSS 控制台 → 事件通知 → 创建规则:

  • 规则名称photo-upload-notification
  • 事件类型:勾选以下事件(支持上传、删除、重命名)
    • ObjectCreated 类型(上传、复制):
      • ObjectCreated:PutObject - 简单上传
      • ObjectCreated:PostObject - 表单上传
      • ObjectCreated:CopyObject - 复制对象(重命名第1步)
      • ObjectCreated:CompleteMultipartUpload - 分片上传完成
    • ObjectRemoved 类型(删除,可选):
      • ObjectRemoved:DeleteObject - 删除对象(支持删除照片和重命名第2步)
  • 资源描述:前缀 photos/
  • 接收终端:消息服务 MNS 主题(创建新主题 afilmory-oss-events

说明:

  • 如果只需要上传功能,可以只勾选 ObjectCreated 类型的 4 个事件
  • 如果需要支持删除照片或重命名功能,还要勾选 ObjectRemoved:DeleteObject
  • iPhone 通过阿里云 App 上传通常使用 PutObject(小文件)或 CompleteMultipartUpload(大文件)

步骤 2:创建 MNS 订阅

消息服务 MNS → 主题管理 → 订阅:

  • 订阅名称webhook-subscription
  • 推送类型:HTTP
  • 接收终端地址http://你的服务器IP/webhook/oss(暂时填写,部署后会用到)
  • 重试策略:EXPONENTIAL_DECAY_RETRY

注意:部署完服务器后,记得回来把”接收终端地址”改成实际的服务器 IP。


二、服务器环境配置

现在切换到服务器端,SSH 登录后开始配置环境。

2.1 安装基础软件

1
2
3
4
5
6
7
8
9
10
11
12
# 更新系统
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 nginx

2.2 安装并配置 ossutil

1
2
3
4
5
6
7
# 下载 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
  • Endpointoss-cn-chengdu.aliyuncs.com(根据你的 OSS 区域填写)
    • 成都:oss-cn-chengdu.aliyuncs.com
    • 北京:oss-cn-beijing.aliyuncs.com
    • 上海:oss-cn-shanghai.aliyuncs.com
    • 杭州:oss-cn-hangzhou.aliyuncs.com
  • AccessKeyId:粘贴第一章保存的 AccessKey ID
  • AccessKeySecret:粘贴第一章保存的 AccessKey Secret

测试连接:

1
ossutil ls oss://afilmory-xxx

如果能正常列出文件(或显示为空),说明配置成功。


三、部署 Afilmory(SSR 模式)

3.1 克隆项目

1
2
3
4
cd /root/projects
git clone https://github.com/meetqy/afilmory.git
cd afilmory
pnpm install

3.2 配置环境变量

创建 /root/projects/afilmory/.env

1
2
3
4
5
6
7
8
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=

重要:

  • 必须填写 S3_REGIONS3_ENDPOINT,否则会使用 AWS S3 的默认值导致连接失败
  • S3_ACCESS_KEY_IDS3_SECRET_ACCESS_KEY 填入第一章保存的 AccessKey
  • S3_BUCKET_NAME 填入你创建的 Bucket 名称(如 afilmory-specialhua

3.3 配置站点信息

编辑 /root/projects/afilmory/config.json

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "我的相册",
"title": "一些美好的记忆",
"description": "时光不老,我们不散",
"url": "http://你的服务器IP",
"accentColor": "#007bff",
"author": {
"name": "你的名字",
"url": "https://你的博客.com",
"avatar": "https://你的头像地址.png"
}
}

备案前用 http://IP,备案后改为 https://你的域名.com

3.4 首次运行 Builder

1
2
cd /root/projects/afilmory
pnpm run build:manifest

Builder 会:

  1. 连接 OSS 读取 photos/ 目录
  2. 下载照片生成缩略图
  3. 创建 apps/web/src/data/photos-manifest.json

3.5 上传 manifest 到 OSS

1
2
3
4
5
6
7
8
9
10
11
# 上传 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 --update

3.6 启动应用

1
2
3
4
5
cd /root/projects/afilmory
pnpm build
pm2 start pnpm --name afilmory -- start
pm2 save
pm2 startup

3.7 配置 Nginx

创建 /etc/nginx/sites-available/afilmory

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}
1
2
3
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 服务

1
2
3
4
mkdir -p /root/webhook-service
cd /root/webhook-service
npm init -y
npm install express

创建 /root/webhook-service/webhook-server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
const 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;

// OSS 通过 SMQ 发送的消息是 Base64 编码的
if (typeof body === 'string') {
try {
const decoded = Buffer.from(body, 'base64').toString('utf-8');
log(`✅ Base64 解码成功`);
body = JSON.parse(decoded);
log(`✅ JSON 解析成功`);
} catch (e) {
log(`⚠️ Base64 解码或 JSON 解析失败: ${e.message}`);
try {
body = JSON.parse(body);
} catch (e2) {
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);
});

配置说明:

  • 脚本开头的 CONFIG 对象包含所有需要自定义的配置
  • 修改 bucket 为你的 OSS Bucket 名称
  • 修改 projectDir 为你的项目目录(如果不是 /root/projects/afilmory
  • 修改 buildScript 为构建脚本的完整路径
  • buildDelay 是防抖延迟时间(默认 30 秒),可根据需要调整

核心功能:

  • Base64 解码:自动处理 OSS 通过 SMQ 发送的 Base64 编码消息
  • 30秒防抖:多张照片上传、重命名(复制+删除)只触发一次构建
  • 构建队列:防止并发构建,失败后自动重试
  • Web 监控界面:访问 http://你的IP/webhook 查看实时状态
  • 日志脱敏:自动隐藏 AccessKey、IP、路径等敏感信息
  • 构建历史:记录最近 10 次构建状态(成功/失败/进行中)
  • 健康检查:提供 /webhook/health 接口供监控使用

4.2 创建构建脚本

创建 /root/webhook-service/build-afilmory.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#!/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

配置说明:

  • 脚本开头的配置区域包含了所有需要自定义的变量
  • 修改 OSS_BUCKET 为你的 Bucket 名称
  • 如果项目目录不在 /root/projects/afilmory,修改 PROJECT_DIR
  • 如果 PM2 应用名称不是 afilmory,修改 PM2_APP_NAME
1
chmod +x /root/webhook-service/build-afilmory.sh

4.3 启动 Webhook 服务

1
2
3
cd /root/webhook-service
pm2 start webhook-server.js --name webhook
pm2 save

4.4 配置 Nginx(添加 webhook 路由)

编辑 /etc/nginx/sites-available/afilmory,在 server 块中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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;
}

说明:

  • /webhook/oss:仅允许 POST 请求,用于接收 OSS 事件通知
  • /webhook/:管理界面和 API 接口(状态、日志、健康检查),日志已自动脱敏
  • 所有接口都监听在 127.0.0.1,只能通过 Nginx 访问,外部无法直接访问
1
sudo nginx -t && sudo systemctl reload nginx

4.5 测试

方法 1:查看 Web 管理界面

浏览器访问 http://你的服务器IP/webhook,可以看到:

  • 实时构建状态(空闲/构建中)
  • 服务运行时间
  • 最近 5 次构建历史
  • 快捷链接(状态 JSON、日志、健康检查)

界面每 10 秒自动刷新,方便实时监控。

方法 2:查看 PM2 日志

1
pm2 logs webhook

方法 3:上传照片测试

iPhone 上传一张照片到 OSS 的 photos/ 目录,等待 30 秒,观察 Web 界面或日志:

1
2
3
4
5
6
📩 收到 OSS 事件通知
✅ Base64 解码成功
📸 检测到 1 张照片上传
⏱️ 将在 30 秒后开始构建
🔄 开始自动构建...
✅ 构建完成!

刷新浏览器,新照片就出现了。

提示:

  • 访问 http://你的IP/webhook/logs 可以查看最近 100 行日志(已自动脱敏)
  • OSS 事件通知已在第一章配置完成,上传照片就会自动触发

五、使用体验

日常使用流程:

  1. iPhone 打开阿里云 App(App Store 搜索”OSS”)
  2. 进入 photos/ 目录,这里最好使用文件上传,因为OSS调用的是IOS的图库选择器,IOS有严格的照片分享机制,会把exif信息给隐藏掉,而我们搭建的摄影相册还是挺系统把镜头、光圈信息等展现出来的。可以在相册里提前把要上传的图片共享——保留原始信息存储到文件,建个文件夹之类的,并且把名字命好,上传后照片的名字就读取的文件名
  3. 等 30 秒(防抖时间)
  4. 刷新网页,新照片自动出现

实测从上传到显示约 40-60 秒,完全可以接受。缩略图加载很快(100KB 左右),原图按需加载(5MB+)。


六、常见问题

6.1 照片上传后不显示

1
2
3
4
5
6
7
8
9
10
11
12
# 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 afilmory

6.2 Webhook 未触发

1
2
3
4
5
6
7
8
9
10
11
# 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/oss

6.3 Nginx 502 错误

1
2
3
4
5
6
7
8
# 1. 检查 PM2 进程
pm2 list

# 2. 查看日志
pm2 logs afilmory

# 3. 重启服务
pm2 restart afilmory

七、备案后优化(可选)

域名备案完成后,可以进行一些优化:配置防盗链、启用 CDN 加速、迁移到静态托管等。

7.1 配置 Referer 防盗链

OSS 控制台 → 数据安全 → 防盗链:

  • 类型:白名单
  • Referer 列表
    1
    2
    3
    4
    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

1
S3_CUSTOM_DOMAIN=https://cdn.你的域名.com

重启应用:

1
2
cd /root/projects/afilmory
pm2 restart afilmory

7.3 迁移到 OSS 静态托管

如果想降低成本,可以从 SSR 模式迁移到 OSS 静态托管。

步骤 1:修改配置

1
2
3
# 修改 config.json
nano /root/projects/afilmory/config.json
# 将 url 改为 https://你的域名.com

步骤 2:构建静态站点

1
2
cd /root/projects/afilmory
pnpm build:static

步骤 3:上传到 OSS

1
2
3
4
5
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:绑定域名 + SSL

OSS 控制台 → 传输管理 → 域名管理 → 绑定域名 → 上传 SSL 证书。

步骤 6:停止 SSR 服务(可选)

1
2
3
pm2 stop afilmory
pm2 delete afilmory
pm2 save

保留 webhook 服务!备案后仍然需要自动构建功能。


后记

折腾了两天总算搭好了,考虑到是相机原图,所以还是得选择国内备案+oss来搭建,aflirmory目前是我最满意的方案,适合摄影作品、后端OSS无数据库,以后迁移也方便。

成本方面,构建用的服务器2C2G之类的,有活动几十块钱拿一个一年的完全够用,主要还是的OSS的存储和CDN的费用,跟我一样看重速度的还是看个人需求吧。

Afilmory 还在活跃开发中,作者 @meetqy 更新很频繁,后续可能会支持更多功能(比如人脸识别、视频支持等)。

有个自己的相册,不妨试试自托管吧


参考资料


用阿里云oss自托管一个摄影相册afilmory
https://inkcodes.com/2026/03/04/用阿里云oss自托管一个摄影相册afilmory/
作者
Specialhua
发布于
2026年3月4日
许可协议