Afilmory 自建完整教程(静态版 + 多租户 SaaS 版)
服务器:香港节点,Debian 12
域名:XXX.com
服务器 IP:XXX.XXX.XXX.XXX
SSH 端口:47520
七牛云存储桶:chronoframe
一、项目简介
Afilmory 是一个现代摄影画廊平台,支持从 S3/GitHub 自动同步照片、WebGL 渲染、完整 EXIF 展示、地图、Live Photo、HDR。
两种部署模式:
- 静态版:纯静态文件,Nginx 托管,无需登录,适合个人作品展示、对外分享。
- 多租户 SaaS 版:带后端 core 服务、数据库、OAuth,适合多用户管理、后台 CMS。
二、环境准备
服务器配置建议:内存最低 4G(推荐 8G),CPU 最低 2 核(推荐 8 核),系统盘最低 30G(推荐 50G+)。
踩坑:2G 内存构建时反复 OOM,最终升级到 8G 才成功。
安装 Node.js 22:
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
node --version
npm --version
安装 pnpm:
npm install -g pnpm@10.19.0
pnpm --version
踩坑:如果报 syntax error near unexpected token,说明 pnpm 残留损坏,执行:
rm -rf /root/.local/share/pnpm
rm -f /usr/bin/pnpm
npm install -g pnpm@10.19.0
克隆项目:
cd /www/wwwroot
git clone --depth=1 https://github.com/Afilmory/Afilmory.git afilmory
cd afilmory
安装依赖:
pnpm config set registry https://registry.npmmirror.com
pnpm config set fetch-timeout 600000
sed -i '/"packageManager"/d' package.json
pnpm install
踩坑:package.json 里的 "packageManager": "pnpm@12.3.2" 会导致 pnpm 自动切换到一个损坏的版本,必须删除。
三、静态版部署
创建 .env 文件:
cat > /www/wwwroot/afilmory/.env << 'EOF'
S3_REGION=cn-south-1
S3_ACCESS_KEY_ID=你的七牛云AccessKey
S3_SECRET_ACCESS_KEY=你的七牛云SecretKey
S3_ENDPOINT=https://s3.cn-south-1.qiniucs.com
S3_BUCKET_NAME=chronoframe
S3_PREFIX=duomi/
S3_CUSTOM_DOMAIN=
S3_EXCLUDE_REGEX=
PG_CONNECTION_STRING=postgresql://afilmory:afilmory@127.0.0.1:5432/afilmory
GIT_TOKEN=
EOF
创建 config.json 文件:
cat > /www/wwwroot/afilmory/config.json << 'EOF'
{
"name": "你的网站名称",
"title": "你的网站标题",
"description": "网站描述",
"url": "https://XXX.com",
"accentColor": "#007bff",
"author": {
"name": "你的名字",
"url": "https://XXX.com/",
"avatar": "https://XXX.com/logo.png"},
"social": { "github": "", "twitter": "" },
"map": ["maplibre"],
"mapStyle": "builtin",
"mapProjection": "mercator"
}
EOF
生成照片清单:
cd /www/wwwroot/afilmory
pnpm run build:manifest
踩坑:如果报大量 下载失败:duomi/xxx.jpg (60秒超时),需要修改 builder.config.default.ts:
sed -i 's/downloadConcurrency: 16/downloadConcurrency: 4/' builder.config.default.ts
sed -i 's/totalTimeoutMs: 60_000/totalTimeoutMs: 600_000/' builder.config.default.ts
构建静态版:
cd /www/wwwroot/afilmory
pnpm --filter @afilmory/web build
踩坑:必须用 build 而不是 build:serve。build 会注入 MANIFEST 全局变量,适合纯静态托管;build:serve 会请求 /api/manifest 接口,适合有后端服务。
修复 base 路径:
sed -i "s|base: BUILD_FOR_SERVER_SERVE ? '/static/web/' : '/'|base: '/'|" apps/web/vite.config.ts
pnpm --filter @afilmory/web build
用 Nginx 托管:
docker run -d \
--name afilmory-web \
-p 8086:80 \
-v /www/wwwroot/afilmory/apps/web/dist:/usr/share/nginx/html:ro \
nginx:alpine
访问:http://XXX.XXX.XXX.XXX:8086
踩坑:如果 403,重建容器即可:
docker rm -f afilmory-web
docker run -d --name afilmory-web -p 8086:80 \
-v /www/wwwroot/afilmory/apps/web/dist:/usr/share/nginx/html:ro \
nginx:alpine
更新照片流程:
cd /www/wwwroot/afilmory
pnpm run build:manifest
pnpm --filter @afilmory/web build
docker restart afilmory-web
四、多租户 SaaS 版部署
启动数据库和 Redis:
cd /www/wwwroot/afilmory
docker-compose up -d db redis
执行数据库迁移:
for f in $(ls be/packages/db/migrations/*.sql | sort); do
echo "执行: $f"
docker exec -i afilmory_db psql -U afilmory -d afilmory < "$f"
done
docker exec -it afilmory_db psql -U afilmory -d afilmory -c "\dt"
配置 systemd 服务:
cat > /etc/systemd/system/afilmory-core.service << 'EOF'
[Unit]
Description=Afilmory Core Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/www/wwwroot/afilmory
Environment=PORT=3001
Environment=NODE_ENV=production
Environment=BETTER_AUTH_SECRET=你的随机密钥
Environment=BETTER_AUTH_URL=https://root.XXX.com
ExecStart=/usr/bin/node be/apps/core/dist/main.js
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable afilmory-core
systemctl start afilmory-core
踩坑:BETTER_AUTH_SECRET 必须设置,否则报错 BetterAuthError: You are using the default secret。用 openssl rand -hex 32 生成。
构建并复制前端静态文件:
cd /www/wwwroot/afilmory
pnpm --filter @afilmory/dashboard build:prod:saas
sed -i "s|base: isSaas ? 'https://static.afilmory.art/platform/' : '/platform/',|base: '/platform/',|" be/apps/dashboard/vite.config.ts
pnpm --filter @afilmory/dashboard build:prod:saas
mkdir -p be/apps/core/dist/static/web
mkdir -p be/apps/core/dist/static/dashboard
cp -r apps/web/dist/* be/apps/core/dist/static/web/
cp -r be/apps/dashboard/dist/* be/apps/core/dist/static/dashboard/
设置 baseDomain:
docker exec -it afilmory_db psql -U afilmory -d afilmory
INSERT INTO system_setting (id, key, value, is_sensitive, created_at, updated_at)
VALUES (gen_random_uuid(), 'system.domain.base', '"XXX.com"'::jsonb, false, NOW(), NOW())
ON CONFLICT (key) DO UPDATE SET value = '"XXX.com"'::jsonb;
\q
踩坑:value 字段是 JSON 类型,字符串必须写成 '"XXX.com"'::jsonb,注意单引号嵌套。
重置 root 账号(如果密码丢失):
docker exec -it afilmory_db psql -U afilmory -d afilmory
DELETE FROM system_setting WHERE key = 'system.app.initialized';
DELETE FROM auth_user WHERE email = 'root@local.host';
\q
systemctl restart afilmory-core
sleep 10
journalctl -u afilmory-core -n 100 --no-pager | grep -A 5 "Root dashboard"
日志会打印新的 root 密码,只显示一次,务必保存。
启动 core 服务:
systemctl restart afilmory-core
systemctl status afilmory-core
ss -tlnp | grep 3001
五、域名与反向代理配置
DNS 解析需要添加:
| 记录类型 | 主机记录 | 记录值 | 说明 |
|---|---|---|---|
| A | @ | 服务器 IP | 主域名 |
| A | root | 服务器 IP | 后台管理 |
| A | static | 服务器 IP | 静态资源 |
| CNAME | * | 服务器 IP | 泛解析(租户子域名) |
root.XXX.com 站点配置:
server {
listen 80;
server_name root.XXX.com;
location / {
proxy_pass http://127.0.0.1:3001;
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;
}}
static.XXX.com 站点配置(关键):
server {
listen 443 ssl;
server_name static.XXX.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location /platform/ {
alias /www/wwwroot/afilmory/be/apps/core/dist/static/dashboard/;
try_files $uri $uri/ /platform/index.html;
}
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
}}
踩坑:SaaS 模式会把前端资源指向 static.XXX.com,必须给这个子域名配 SSL 证书和静态文件映射,否则页面白屏。
申请通配符证书:宝塔 → 网站 → SSL → Let's Encrypt → DNS 验证 → 域名填 *.XXX.com。
六、踩坑记录汇总
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Docker 构建超时 | 服务器访问 npm 源慢 | 用淘宝镜像 + 增加超时时间 |
| OOM 内存不足 | 2G 内存不够 | 升级到 8G |
| pnpm 报 12.3.2 语法错误 | packageManager 字段强制切换版本 | 删除该字段 |
| MANIFEST is not defined | 用了 build:serve 而不是 build | 静态版用 build |
| 页面白屏,JS 404 | base 路径带 /static/web/ | 改成 / |
| Workspace unavailable | 租户解析失败 | 设置 baseDomain |
| Failed to provision root | BETTER_AUTH_SECRET 未设置 | systemd 里加环境变量 |
| dashboard 资源指向 static.afilmory.art | 官方 CDN 地址 | 改 vite.config.ts 的 base |
| 图片下载超时 | 并发太高 + 超时太短 | 降并发 + 增超时 |
| Nginx 403 | dist 被构建清空 | 重建容器或重启 Nginx |
| 静态资源证书错误 | static.XXX.com 没证书 | 申请通配符证书 |
| 系统盘爆满 | Docker 构建缓存 + node_modules | docker system prune -fa |
七、日常维护命令
静态版更新照片:
cd /www/wwwroot/afilmory
pnpm run build:manifest
pnpm --filter @afilmory/web build
docker restart afilmory-web
多租户版管理:
systemctl status afilmory-core
systemctl restart afilmory-core
journalctl -u afilmory-core -f
ss -tlnp | grep 3001
数据库管理:
docker exec -it afilmory_db psql -U afilmory -d afilmory
SELECT id, slug, name FROM tenant;
SELECT id, email FROM auth_user;
SELECT key, value FROM system_setting;
\q
清理磁盘:
docker system prune -fa
docker builder prune -af
df -h /
du -sh /www/wwwroot/afilmory/*
八、七牛云 S3 配置参考
| 配置项 | 值 |
|---|---|
| Provider | s3 |
| Bucket | chronoframe |
| Region | cn-south-1 |
| Endpoint | https://s3.cn-south-1.qiniucs.com |
| Access Key ID | 你的AccessKey |
| Secret Access Key | 你的SecretKey |
| Prefix | duomi/ 或 afilmory/ |
| Custom Domain | 留空(私有空间) |
七牛云私有空间 + 不绑 CDN 也可以用,core 服务会代理图片访问。
九、访问地址汇总
| 服务 | 地址 |
|---|---|
| 静态版前台 | http://XXX.XXX.XXX.XXX:8086 |
| 多租户后台登录 | https://root.XXX.com/platform/root-login |
| 多租户前台 | https:// |
| 静态资源 CDN | https://static.XXX.com/platform/ |
文档结束