diff --git a/.dockerignore b/.dockerignore
index 14c4d77..00d1a0f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,9 +1,17 @@
.git
.github
+.env
+.env.*
+node_modules
**/node_modules
+dist
**/dist
coverage
-.env
+**/coverage
*.log
-docs
-*.png
+apps/miniprogram/.miniprogram
+apps/miniprogram/miniprogram_npm
+mysql-data
+minio-data
+redis-data
+docs/ui_reference.png
diff --git a/README.md b/README.md
index 2f41138..4a9108d 100644
--- a/README.md
+++ b/README.md
@@ -35,3 +35,9 @@ pnpm build
集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。
项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。
+
+## Ubuntu 26.04 云服务器部署
+
+Ubuntu 26.04 LTS 单机部署包位于 `infra/ubuntu/`,提供 Docker 官方源安装、生产环境密钥生成、双域名 TLS、私有 MySQL/Redis/MinIO、API/管理后台镜像、migration、备份恢复、冒烟和回滚脚本。完整步骤见 `infra/ubuntu/README.md`。
+
+该部署包不替代 staging、UAT、微信合法域名/真实 AppID、隐私法务、告警接收与异地备份验收;外部项未完成前不得宣称已生产上线。
diff --git a/apps/admin-web/Dockerfile b/apps/admin-web/Dockerfile
new file mode 100644
index 0000000..d493ccd
--- /dev/null
+++ b/apps/admin-web/Dockerfile
@@ -0,0 +1,19 @@
+FROM node:24.13.0-alpine AS build
+WORKDIR /app
+RUN corepack enable
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
+COPY apps/admin-web/package.json apps/admin-web/package.json
+COPY packages/contracts/package.json packages/contracts/package.json
+RUN pnpm install --frozen-lockfile
+COPY apps/admin-web apps/admin-web
+COPY packages/contracts packages/contracts
+ARG VITE_API_BASE_URL
+RUN test -n "$VITE_API_BASE_URL"
+ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
+RUN pnpm --filter @lehuo/admin-web build
+
+FROM nginx:1.28.0-alpine AS runtime
+COPY --from=build /app/apps/admin-web/dist /usr/share/nginx/html
+COPY infra/ubuntu/nginx/default.conf.template /etc/nginx/templates/default.conf.template
+RUN rm -f /etc/nginx/conf.d/default.conf
+EXPOSE 80 443
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index 9da4a3e..a330197 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -12,12 +12,13 @@ RUN DATABASE_URL=mysql://build:build@127.0.0.1:3306/build pnpm --filter @lehuo/a
FROM node:24.13.0-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
-RUN corepack enable
COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist
+COPY --from=build /app/apps/api/prisma ./apps/api/prisma
+COPY --from=build /app/apps/api/prisma.config.ts ./apps/api/prisma.config.ts
COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json
COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist
USER node
diff --git a/docs/10_TASK_STATE.md b/docs/10_TASK_STATE.md
index 1fd9b7c..d3cf395 100644
--- a/docs/10_TASK_STATE.md
+++ b/docs/10_TASK_STATE.md
@@ -6,6 +6,20 @@
2026-08-15
+任务:生成 Ubuntu 26.04 LTS 云服务器部署版本。
+
+完成:新增独立 `infra/ubuntu` 生产部署包,采用 Nginx/管理后台、API、MySQL 8.4、Redis 7.4、私有 MinIO 的单机 Docker Compose 基线,仅发布 80/443。提供 Ubuntu 26.04 Docker 官方仓库安装、生产环境文件生成与 600 权限保护、TLS/域名/磁盘/Compose 预检、不可变镜像构建、数据服务初始化、migration、首次管理员初始化、HTTPS/readiness 冒烟、应用镜像回滚、MySQL 校验和备份、受控恢复和 systemd 每日备份定时器。API 生产镜像包含 Prisma migration 与管理员初始化产物且运行时不依赖 Corepack 下载;管理后台使用独立生产镜像和 Nginx HTTPS 网关。同步补充根目录、国内部署、发布清单与生产就绪文档,新增 D-024。
+
+范围:未改变业务架构、认证、数据库 schema、API 契约、权限、状态机或 V1.0 产品范围;没有把单机基线扩展为 Kubernetes、微服务或多租户。部署包是可复现交付,不代表真实生产发布条件已经满足。
+
+验证:全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过;测试库 12 个 migration 无待执行项,小程序 28 个页面完整性与运行时依赖门禁通过。Ubuntu Compose 配置、全部 Bash 脚本语法、22 个控制器权限扫描和 npm 官方 High 级依赖审计通过;MySQL、Redis、MinIO、MinIO Client 的固定镜像标签均可拉取;API 与管理后台生产镜像构建通过,最终镜像内 Prisma 7.9.1 可直接执行,管理端静态资源完整且双域名 HTTPS Nginx 配置通过真实 `nginx -t`。
+
+遗留:部署前仍需用户提供两个已备案合法 HTTPS 域名及 TLS 证书、微信 AppID/AppSecret 与合法域名配置,并在真实服务器确认防火墙、安全组、DNS、告警接收方和异地加密备份目标;随后执行 staging、恢复演练、UAT 和生产发布审批。当前未使用示例域名或测试密钥冒充生产配置。
+
+---
+
+2026-08-15
+
任务:发布前安全与环境配置加固并重新同步仓库。
完成:在既有未提交安全改动基础上完成复核和修正。审计增加原始会话 token 防御性剥离;后台写请求使用精确 Origin 或无 Origin 时的完整 Referer origin 校验,不允许前缀或通配来源;管理 Cookie 仅在 Secure 模式附加 Partitioned,避免本地 HTTP 丢失登录态;私有上传签名由 600 秒缩短到 120 秒;台账导出审计补充关键词条件;数据库启动只记录已完成 migration 数量与最新名称,不把运行时日志冒充待部署校验。小程序开发版保留 localhost 回环调试,体验版/正式版拒绝回环、`0.0.0.0` 和非 HTTPS API,防止不安全发布配置。
diff --git a/docs/11_DECISIONS.md b/docs/11_DECISIONS.md
index 20f03b2..032f46d 100644
--- a/docs/11_DECISIONS.md
+++ b/docs/11_DECISIONS.md
@@ -285,6 +285,20 @@ Excel 在管理端浏览器本地读取和生成,API 只接收固定结构、
---
+## D-024 Ubuntu 26.04 初期生产采用可迁移单机 Compose 基线
+
+状态:ACCEPTED
+
+决定:为用户已准备的 Ubuntu 26.04 LTS 云服务器提供独立 Linux 部署包。初期拓扑在单机 Docker Compose 内运行 Nginx/管理后台、模块化单体 API、MySQL 8.4、Redis 7.4 和私有 MinIO,仅向宿主机发布 80/443;管理后台/API 与签名文件服务使用两个不同的合法 HTTPS 域名。数据、对象和缓存使用独立持久卷,生产环境文件以 600 权限保存在 `/etc/lehuo`,数据库每日备份并生成 SHA-256。
+
+API 与管理后台镜像使用批准的 Git commit 作为不可变标签并可本机构建;固定执行预检、构建、迁移前备份、migration、部署、HTTPS/readiness 冒烟和观察。应用回滚只允许切换到本机存在的上一不可变镜像,不自动回滚数据库。自建 MinIO 继续通过既有 S3 Adapter 接入,未来可替换为腾讯 COS 或其他 S3 兼容私有存储,不改变业务服务。
+
+该基线是可部署交付,不代表生产发布条件已满足。真实域名/备案/TLS、微信配置、staging、UAT、告警接收、异地加密备份、恢复演练和发布审批仍按 D-016 阻断;单机部署不扩展为 Kubernetes、微服务或多租户架构。
+
+原因:在不改变批准技术栈和 V1 边界的前提下,为现有 Ubuntu 云服务器提供可复现、可备份、可回滚的最低复杂度部署路径,并保持未来迁移到托管 MySQL/Redis/COS 的能力。
+
+---
+
## 待确认建议
Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施:
diff --git a/docs/13_DEPLOYMENT_CHINA.md b/docs/13_DEPLOYMENT_CHINA.md
index 475d699..2324dd6 100644
--- a/docs/13_DEPLOYMENT_CHINA.md
+++ b/docs/13_DEPLOYMENT_CHINA.md
@@ -153,3 +153,15 @@ CI 自动化测试。
- 审核材料
微信平台规则可能变化,因此上线前必须重新查官方文档,不得只依赖本文件。
+
+---
+
+## 10. Ubuntu 26.04 单机部署基线
+
+- Ubuntu 26.04 LTS 使用 Docker 官方 apt 仓库安装 Docker Engine 与 Compose plugin,不使用生产环境不推荐的 convenience script。
+- 初期单机拓扑采用 Docker Compose:Nginx/管理后台、API、MySQL 8.4、Redis 7.4 和私有 MinIO;仅发布 80/443,其他服务不映射宿主机端口。
+- 管理后台/API 与对象存储使用两个不同的合法 HTTPS 域名;对象存储保持私有 Bucket、版本控制、精确管理端 CORS 和 120 秒签名 URL。
+- API 和管理后台镜像以批准的 Git commit 作为不可变标签,本机构建不使用 `latest`;发布顺序仍为预检、构建、备份、migration、部署、冒烟和观察。
+- 环境文件固定保存到 `/etc/lehuo/lehuo.env` 且权限为 600;TLS、备份和发布状态分别保存在 `/opt/lehuo/shared`、`/opt/lehuo/backups` 和 `/opt/lehuo/releases`。
+- 每日备份由 systemd timer 执行并生成 SHA-256;本机 30 天保留不替代异地加密备份和季度独立库恢复演练。
+- 完整资产与执行命令见 `infra/ubuntu/README.md`。该基线只提供可部署版本,不代表真实 staging、UAT、微信联调或 production approval 已完成。
diff --git a/docs/14_RELEASE_CHECKLIST.md b/docs/14_RELEASE_CHECKLIST.md
index dfd8cef..ac8eb3e 100644
--- a/docs/14_RELEASE_CHECKLIST.md
+++ b/docs/14_RELEASE_CHECKLIST.md
@@ -50,8 +50,13 @@
- [x] health
- [x] monitor
- [x] alert
+- [ ] Ubuntu 26.04 host preflight pass
+- [ ] only 80/443 exposed by host/cloud firewall
+- [ ] TLS certificate covers admin/API and file domains
- [ ] backup
- [x] restore procedure
+- [ ] offsite encrypted backup configured
+- [ ] systemd backup timer verified
- [ ] rollback image available
## UAT
diff --git a/docs/17_PRODUCTION_READINESS_PLAN.md b/docs/17_PRODUCTION_READINESS_PLAN.md
index 0f50350..2e3a6a9 100644
--- a/docs/17_PRODUCTION_READINESS_PLAN.md
+++ b/docs/17_PRODUCTION_READINESS_PLAN.md
@@ -66,6 +66,8 @@ Phase 0–7 的可自动化开发与本地生产工程验证已完成。当前
当前证据:统计/导出、health/metrics、权限扫描、官方依赖审计、密钥扫描、CodeQL 工作流、备份/恢复脚本、Nginx、immutable image Compose、预检/冒烟和告警规则已完成;本地恢复演练源库与恢复库均为 42 张表。真实 staging、UAT、告警接收和生产发布尚未执行,因此本退出条件仍未满足。
+2026-08-15 补充 Ubuntu 26.04 单机部署包:增加 Docker 官方源安装、生产 Compose、管理后台/API 镜像、双域名 TLS、私有 MySQL/Redis/MinIO、Linux 预检、migration、备份恢复、初始管理员、systemd 定时备份、HTTPS 冒烟与应用回滚。该交付仅关闭“Ubuntu 可执行部署资产”差距,不改变真实域名、微信、staging、UAT、告警接收、异地备份和发布审批阻断项。
+
## 3. 需要人工或外部环境配合
以下内容不能用测试值替代正式验收:
diff --git a/infra/deploy/README.md b/infra/deploy/README.md
index 4810f33..62b181b 100644
--- a/infra/deploy/README.md
+++ b/infra/deploy/README.md
@@ -18,3 +18,7 @@
6. 异常时将 `IMAGE_TAG` 恢复到上一批准版本重新部署。若 migration 不向后兼容,停止发布并按已验证的数据库恢复步骤执行。
备份默认每日一次,保留 30 天;必须每季度在独立数据库做恢复演练并记录表数量、关键行数和耗时。脚本会校验 SHA-256,恢复目标数据库名必须显式提供。
+
+## Ubuntu 26.04
+
+原有 `.ps1` 工具保留用于 Windows 运维环境。Ubuntu 26.04 云服务器应使用 `infra/ubuntu/` 中的 Compose、Bash 脚本与 systemd timer;完整步骤见 `infra/ubuntu/README.md`。两套工具遵循相同的不可变标签、migration、备份、冒烟、观察和应用回滚顺序。
diff --git a/infra/ubuntu/README.md b/infra/ubuntu/README.md
new file mode 100644
index 0000000..006adb3
--- /dev/null
+++ b/infra/ubuntu/README.md
@@ -0,0 +1,131 @@
+# Ubuntu 26.04 LTS 单机部署包
+
+本目录用于把乐活公益平台部署到一台已安装 Ubuntu 26.04 LTS 的云服务器。Docker 官方已将 Ubuntu 26.04(Resolute)列为 Docker Engine 支持系统:。
+
+该部署包适合初期单机 staging/production:
+
+- 管理后台与 API:`https://PUBLIC_HOST`
+- 私有对象存储签名访问:`https://FILES_HOST`
+- 仅向宿主机发布 `80/443`;MySQL、Redis、MinIO 与 API 不发布主机端口
+- MySQL 8.4、Redis 7.4、MinIO、NestJS API、Vue 管理后台/Nginx 均使用 Docker Compose
+- 数据、对象和 Redis 使用独立持久卷
+- 自动 migration、上线前备份、HTTPS 冒烟、不可变 Git 提交标签和应用回滚
+- 每日 MySQL 备份、SHA-256 校验、30 天本机保留;异地加密同步仍须由运维配置
+
+这不是“已完成生产上线”的证明。真实域名/备案、TLS、微信配置、隐私材料、staging、UAT、告警接收人和异地备份仍是发布阻断项。
+
+## 1. 云服务器准备
+
+建议最低资源:4 vCPU、8 GiB 内存、100 GiB SSD;测试环境可降低,但生产容量必须以压测结果为准。云安全组仅开放:
+
+- SSH 管理端口(优先限制管理 IP,端口以服务器实际配置为准)
+- TCP 80(TLS 跳转/证书验证)
+- TCP 443(管理后台、API、小程序请求与签名文件上传)
+
+不要开放 3000、3306、6379、9000 或 9001。Docker 发布端口可能绕过 UFW 规则,因此本 Compose 只发布 80/443;仍需用云安全组做外层限制。
+
+为以下两个不同域名配置 A/AAAA 记录到服务器,并准备覆盖二者的证书(双域名 SAN 或通配证书):
+
+- `admin.example.com`:管理后台和 API
+- `files.example.com`:私有对象存储签名上传/下载
+
+## 2. 放置代码与安装 Docker
+
+```bash
+sudo install -d -m 0755 /opt/lehuo
+sudo git clone --branch main https://gitee.com/climber-ge/wechatapp.git /opt/lehuo/app
+cd /opt/lehuo/app
+sudo bash ./infra/ubuntu/scripts/install-docker.sh
+```
+
+安装脚本严格检查 Ubuntu 26.04,使用 Docker 官方 apt 仓库,并且不会自动卸载已有容器运行时,也不会自动把普通用户加入等同 root 权限的 `docker` 用户组。
+
+## 3. 生成生产环境文件
+
+```bash
+cd /opt/lehuo/app
+sudo bash ./infra/ubuntu/scripts/generate-env.sh \
+ --public-host admin.example.com \
+ --files-host files.example.com
+sudo install -m 0600 /path/to/fullchain.pem /opt/lehuo/shared/tls/fullchain.pem
+sudo install -m 0600 /path/to/privkey.pem /opt/lehuo/shared/tls/privkey.pem
+```
+
+生成结果位于 `/etc/lehuo/lehuo.env`,权限为 `600`,包含随机数据库密码、Redis 密码、对象存储密钥、会话 pepper、审计 HMAC key 和 32 字节 Base64 字段加密密钥。不要提交、复制到聊天、工单或普通日志。
+
+首次部署时微信参数可以保持空值且 `NOTIFICATION_WORKER_ENABLED=false`。取得有效 AppID/AppSecret 和全部模板后再填入真实值并重新部署,禁止使用测试值冒充成功。
+
+如果不用自带 MinIO 而使用腾讯 COS 或其他 S3 兼容私有对象存储,应单独评审并修改 Compose 服务,同时保持 Adapter、私有 Bucket、短期签名 URL、最小权限密钥与 CORS 边界。
+
+## 4. 预检与首次部署
+
+```bash
+cd /opt/lehuo/app
+sudo bash ./infra/ubuntu/scripts/preflight.sh /etc/lehuo/lehuo.env
+sudo bash ./infra/ubuntu/scripts/deploy.sh /etc/lehuo/lehuo.env
+sudo bash ./infra/ubuntu/scripts/bootstrap-admin.sh /etc/lehuo/lehuo.env
+```
+
+`deploy.sh` 固定执行:预检 → 以 Git 提交号构建不可变镜像 → 启动数据服务 → 初始化私有 Bucket/CORS/版本控制 →(已有版本时)迁移前备份 → Prisma migration → 部署 API/Nginx → HTTPS/ready/对象存储冒烟。
+
+初始管理员脚本从终端隐藏读取密码,不写入环境文件或 shell history;数据库已有后台凭据时服务端会拒绝重复初始化。
+
+## 5. 小程序生产地址
+
+在提审的小程序代码中把 `apps/miniprogram/miniprogram/app.ts` 的 `apiBaseUrl` 改为:
+
+```text
+https://admin.example.com
+```
+
+并在微信公众平台配置 request 合法域名 `https://admin.example.com`,uploadFile/downloadFile 合法域名 `https://files.example.com`。体验版/正式版会拒绝 localhost、`0.0.0.0` 和非 HTTPS 地址。
+
+## 6. 备份与恢复演练
+
+```bash
+sudo bash ./infra/ubuntu/scripts/install-backup-timer.sh
+sudo systemctl start lehuo-backup.service
+sudo journalctl -u lehuo-backup.service --since today
+
+# 恢复到隔离数据库做演练,不覆盖生产库
+sudo bash ./infra/ubuntu/scripts/restore.sh \
+ /opt/lehuo/backups/lehuo-YYYYMMDDTHHMMSSZ.sql.gz \
+ lehuo_restore_test --confirm lehuo_restore_test
+```
+
+备份文件与 `.sha256` 必须同步到异地加密存储。每季度至少在隔离数据库完成一次恢复演练,核对表数量、关键事实行数、耗时并留存记录。
+
+## 7. 升级与回滚
+
+```bash
+cd /opt/lehuo/app
+sudo git fetch --all --prune
+sudo git switch main
+sudo git pull --ff-only
+
+# 把 IMAGE_TAG 更新为本次批准的完整/短 Git commit;禁止 latest
+sudo editor /etc/lehuo/lehuo.env
+sudo bash ./infra/ubuntu/scripts/deploy.sh /etc/lehuo/lehuo.env
+```
+
+应用异常且 migration 向后兼容时:
+
+```bash
+sudo bash ./infra/ubuntu/scripts/rollback.sh /etc/lehuo/lehuo.env
+```
+
+回滚脚本只切换本机仍存在的上一版 API/管理后台镜像,不回滚数据库。若 migration 不向后兼容,必须停止发布并按已审批的数据库恢复方案处理,不能盲目执行应用回滚。
+
+## 8. 日常检查
+
+```bash
+cd /opt/lehuo/app
+sudo docker compose --project-directory infra/ubuntu \
+ --env-file /etc/lehuo/lehuo.env -f infra/ubuntu/compose.yaml ps
+curl -fsS https://admin.example.com/health/live
+curl -fsS https://admin.example.com/health/ready
+sudo docker system df
+sudo systemctl status lehuo-backup.timer
+```
+
+日志中禁止输出 token、完整手机号、证件、详细地址、儿童信息或服务描述。容器日志轮转、CPU/内存/磁盘/API 5xx/P95、数据库连接、通知死信和备份缺失告警需接入实际有人值守的监控渠道。
diff --git a/infra/ubuntu/compose.yaml b/infra/ubuntu/compose.yaml
new file mode 100644
index 0000000..1ea4c43
--- /dev/null
+++ b/infra/ubuntu/compose.yaml
@@ -0,0 +1,223 @@
+name: lehuo-production
+
+x-api-environment: &api-environment
+ APP_ENV: production
+ API_PORT: '3000'
+ DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
+ REDIS_URL: ${REDIS_URL:?REDIS_URL is required}
+ SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}
+ SESSION_TOKEN_PEPPER: ${SESSION_TOKEN_PEPPER:?SESSION_TOKEN_PEPPER is required}
+ AUDIT_IP_HASH_KEY: ${AUDIT_IP_HASH_KEY:?AUDIT_IP_HASH_KEY is required}
+ FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?FIELD_ENCRYPTION_KEY is required}
+ ADMIN_WEB_ORIGIN: ${ADMIN_WEB_ORIGIN:?ADMIN_WEB_ORIGIN is required}
+ ADMIN_COOKIE_SECURE: 'true'
+ AUTH_SESSION_TTL_SECONDS: ${AUTH_SESSION_TTL_SECONDS:-28800}
+ WECHAT_SESSION_TTL_SECONDS: ${WECHAT_SESSION_TTL_SECONDS:-7200}
+ WECHAT_APP_ID: ${WECHAT_APP_ID:-}
+ WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
+ WECHAT_TEMPLATE_APPLICATION_SUBMITTED: ${WECHAT_TEMPLATE_APPLICATION_SUBMITTED:-}
+ WECHAT_TEMPLATE_APPLICATION_ACCEPTED: ${WECHAT_TEMPLATE_APPLICATION_ACCEPTED:-}
+ WECHAT_TEMPLATE_SERVICE_ARRANGED: ${WECHAT_TEMPLATE_SERVICE_ARRANGED:-}
+ WECHAT_TEMPLATE_SERVICE_COMPLETED: ${WECHAT_TEMPLATE_SERVICE_COMPLETED:-}
+ WECHAT_TEMPLATE_FOLLOW_UP_PENDING: ${WECHAT_TEMPLATE_FOLLOW_UP_PENDING:-}
+ WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT: ${WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT:-}
+ NOTIFICATION_WORKER_ENABLED: ${NOTIFICATION_WORKER_ENABLED:-false}
+ OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:?OBJECT_STORAGE_ENDPOINT is required}
+ OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-auto}
+ OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:?OBJECT_STORAGE_BUCKET is required}
+ OBJECT_STORAGE_ACCESS_KEY: ${OBJECT_STORAGE_ACCESS_KEY:?OBJECT_STORAGE_ACCESS_KEY is required}
+ OBJECT_STORAGE_SECRET_KEY: ${OBJECT_STORAGE_SECRET_KEY:?OBJECT_STORAGE_SECRET_KEY is required}
+ OBJECT_STORAGE_FORCE_PATH_STYLE: 'true'
+
+services:
+ mysql:
+ image: mysql:8.4.8
+ restart: unless-stopped
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_0900_ai_ci
+ environment:
+ MYSQL_DATABASE: ${MYSQL_DATABASE:?MYSQL_DATABASE is required}
+ MYSQL_USER: ${MYSQL_USER:?MYSQL_USER is required}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD is required}
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}
+ TZ: Asia/Shanghai
+ volumes:
+ - mysql-data:/var/lib/mysql
+ networks:
+ - backend
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin ping -h 127.0.0.1 -u root --silent
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 40s
+ security_opt:
+ - no-new-privileges:true
+
+ redis:
+ image: redis:7.4-alpine
+ restart: unless-stopped
+ command:
+ - redis-server
+ - --appendonly
+ - 'yes'
+ - --requirepass
+ - ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
+ volumes:
+ - redis-data:/data
+ networks:
+ - backend
+ healthcheck:
+ test: [CMD-SHELL, 'redis-cli -a "$$REDIS_PASSWORD" ping | grep -q PONG']
+ interval: 10s
+ timeout: 5s
+ retries: 8
+ environment:
+ REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
+ security_opt:
+ - no-new-privileges:true
+
+ minio:
+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
+ restart: unless-stopped
+ command: server /data
+ environment:
+ MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required}
+ MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}
+ MINIO_BROWSER: 'off'
+ MINIO_SERVER_URL: https://${FILES_HOST:?FILES_HOST is required}
+ volumes:
+ - minio-data:/data
+ networks:
+ - backend
+ healthcheck:
+ test: [CMD, curl, -f, http://localhost:9000/minio/health/live]
+ interval: 10s
+ timeout: 5s
+ retries: 8
+ security_opt:
+ - no-new-privileges:true
+
+ minio-init:
+ image: minio/mc:RELEASE.2025-08-13T08-35-41Z
+ restart: 'no'
+ entrypoint: [/bin/sh, /bootstrap/init-minio.sh]
+ environment:
+ MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required}
+ MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}
+ OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:?OBJECT_STORAGE_BUCKET is required}
+ ADMIN_WEB_ORIGIN: ${ADMIN_WEB_ORIGIN:?ADMIN_WEB_ORIGIN is required}
+ volumes:
+ - ./minio/init-minio.sh:/bootstrap/init-minio.sh:ro
+ networks:
+ - backend
+ depends_on:
+ minio:
+ condition: service_healthy
+ security_opt:
+ - no-new-privileges:true
+
+ api:
+ image: lehuo-api:${IMAGE_TAG:?IMAGE_TAG is required}
+ build:
+ context: ../..
+ dockerfile: apps/api/Dockerfile
+ restart: unless-stopped
+ environment: *api-environment
+ extra_hosts:
+ - ${FILES_HOST:?FILES_HOST is required}:host-gateway
+ expose:
+ - '3000'
+ networks:
+ - backend
+ depends_on:
+ mysql:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ minio-init:
+ condition: service_completed_successfully
+ healthcheck:
+ test:
+ - CMD
+ - node
+ - -e
+ - fetch('http://127.0.0.1:3000/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
+ interval: 15s
+ timeout: 5s
+ retries: 8
+ start_period: 30s
+ read_only: true
+ tmpfs:
+ - /tmp:size=64m,noexec,nosuid
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+
+ migrate:
+ image: lehuo-api:${IMAGE_TAG:?IMAGE_TAG is required}
+ profiles: [tools]
+ command:
+ - ./apps/api/node_modules/.bin/prisma
+ - migrate
+ - deploy
+ - --config
+ - apps/api/prisma.config.ts
+ environment:
+ DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
+ networks:
+ - backend
+ depends_on:
+ mysql:
+ condition: service_healthy
+ read_only: true
+ tmpfs:
+ - /tmp:size=64m,noexec,nosuid
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+
+ web:
+ image: lehuo-admin:${IMAGE_TAG:?IMAGE_TAG is required}
+ build:
+ context: ../..
+ dockerfile: apps/admin-web/Dockerfile
+ args:
+ VITE_API_BASE_URL: https://${PUBLIC_HOST:?PUBLIC_HOST is required}
+ restart: unless-stopped
+ environment:
+ PUBLIC_HOST: ${PUBLIC_HOST:?PUBLIC_HOST is required}
+ FILES_HOST: ${FILES_HOST:?FILES_HOST is required}
+ NGINX_ENVSUBST_FILTER: ^(PUBLIC_HOST|FILES_HOST)$$
+ ports:
+ - '80:80'
+ - '443:443'
+ volumes:
+ - ${TLS_CERT_PATH:?TLS_CERT_PATH is required}:/etc/nginx/tls/fullchain.pem:ro
+ - ${TLS_KEY_PATH:?TLS_KEY_PATH is required}:/etc/nginx/tls/privkey.pem:ro
+ networks:
+ - backend
+ - edge
+ depends_on:
+ api:
+ condition: service_healthy
+ read_only: true
+ tmpfs:
+ - /var/cache/nginx:size=32m,noexec,nosuid
+ - /var/run:size=4m,noexec,nosuid
+ security_opt:
+ - no-new-privileges:true
+
+networks:
+ backend:
+ edge:
+
+volumes:
+ mysql-data:
+ minio-data:
+ redis-data:
diff --git a/infra/ubuntu/env.production.example b/infra/ubuntu/env.production.example
new file mode 100644
index 0000000..0c17008
--- /dev/null
+++ b/infra/ubuntu/env.production.example
@@ -0,0 +1,44 @@
+# 复制到服务器 /etc/lehuo/lehuo.env,并设置 chmod 600。
+# 推荐使用 scripts/generate-env.sh 自动生成随机密钥,不要直接沿用本文件占位值。
+IMAGE_TAG=replace-with-git-commit
+PUBLIC_HOST=admin.example.com
+FILES_HOST=files.example.com
+TLS_CERT_PATH=/opt/lehuo/shared/tls/fullchain.pem
+TLS_KEY_PATH=/opt/lehuo/shared/tls/privkey.pem
+BACKUP_DIR=/opt/lehuo/backups
+
+MYSQL_DATABASE=lehuo
+MYSQL_USER=lehuo
+MYSQL_PASSWORD=replace-with-random-url-safe-password
+MYSQL_ROOT_PASSWORD=replace-with-different-random-password
+DATABASE_URL=mysql://lehuo:replace-with-random-url-safe-password@mysql:3306/lehuo
+
+REDIS_PASSWORD=replace-with-random-password
+REDIS_URL=redis://:replace-with-random-password@redis:6379
+
+MINIO_ROOT_USER=lehuo_storage
+MINIO_ROOT_PASSWORD=replace-with-random-password
+OBJECT_STORAGE_ENDPOINT=https://files.example.com
+OBJECT_STORAGE_REGION=auto
+OBJECT_STORAGE_BUCKET=lehuo-private
+OBJECT_STORAGE_ACCESS_KEY=lehuo_storage
+OBJECT_STORAGE_SECRET_KEY=replace-with-same-minio-password
+
+SESSION_SECRET=replace-with-random-value
+SESSION_TOKEN_PEPPER=replace-with-random-value
+AUDIT_IP_HASH_KEY=replace-with-random-value
+FIELD_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key
+ADMIN_WEB_ORIGIN=https://admin.example.com
+AUTH_SESSION_TTL_SECONDS=28800
+WECHAT_SESSION_TTL_SECONDS=7200
+
+# 未取得真实微信参数前保持空值,并保持 worker 关闭。
+WECHAT_APP_ID=
+WECHAT_APP_SECRET=
+WECHAT_TEMPLATE_APPLICATION_SUBMITTED=
+WECHAT_TEMPLATE_APPLICATION_ACCEPTED=
+WECHAT_TEMPLATE_SERVICE_ARRANGED=
+WECHAT_TEMPLATE_SERVICE_COMPLETED=
+WECHAT_TEMPLATE_FOLLOW_UP_PENDING=
+WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT=
+NOTIFICATION_WORKER_ENABLED=false
diff --git a/infra/ubuntu/minio/init-minio.sh b/infra/ubuntu/minio/init-minio.sh
new file mode 100644
index 0000000..23e0876
--- /dev/null
+++ b/infra/ubuntu/minio/init-minio.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env sh
+set -eu
+
+: "${MINIO_ROOT_USER:?MINIO_ROOT_USER is required}"
+: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}"
+: "${OBJECT_STORAGE_BUCKET:?OBJECT_STORAGE_BUCKET is required}"
+: "${ADMIN_WEB_ORIGIN:?ADMIN_WEB_ORIGIN is required}"
+
+mc alias set lehuo http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --api S3v4 >/dev/null
+mc mb --ignore-existing "lehuo/$OBJECT_STORAGE_BUCKET" >/dev/null
+mc anonymous set none "lehuo/$OBJECT_STORAGE_BUCKET" >/dev/null
+mc version enable "lehuo/$OBJECT_STORAGE_BUCKET" >/dev/null
+
+cat > /tmp/cors.xml <
+
+ ${ADMIN_WEB_ORIGIN}
+ GET
+ PUT
+ HEAD
+ *
+ ETag
+ 3600
+
+
+EOF
+mc cors set "lehuo/$OBJECT_STORAGE_BUCKET" /tmp/cors.xml >/dev/null
+rm -f /tmp/cors.xml
+
+echo "MinIO private bucket initialized."
diff --git a/infra/ubuntu/nginx/default.conf.template b/infra/ubuntu/nginx/default.conf.template
new file mode 100644
index 0000000..9e878fe
--- /dev/null
+++ b/infra/ubuntu/nginx/default.conf.template
@@ -0,0 +1,83 @@
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s;
+
+server {
+ listen 80;
+ server_name ${PUBLIC_HOST} ${FILES_HOST};
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ http2 on;
+ server_name ${PUBLIC_HOST};
+
+ ssl_certificate /etc/nginx/tls/fullchain.pem;
+ ssl_certificate_key /etc/nginx/tls/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_session_cache shared:TLS:10m;
+ ssl_session_timeout 1d;
+
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header X-Frame-Options DENY always;
+
+ client_max_body_size 12m;
+
+ location /api/ {
+ limit_req zone=api_per_ip burst=30 nodelay;
+ 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 https;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_pass http://api:3000;
+ }
+
+ location /health/ {
+ 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 https;
+ proxy_pass http://api:3000;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
+
+server {
+ listen 443 ssl;
+ http2 on;
+ server_name ${FILES_HOST};
+
+ ssl_certificate /etc/nginx/tls/fullchain.pem;
+ ssl_certificate_key /etc/nginx/tls/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_session_cache shared:TLS:10m;
+ ssl_session_timeout 1d;
+
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy no-referrer always;
+
+ client_max_body_size 12m;
+
+ location / {
+ 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 https;
+ proxy_request_buffering off;
+ proxy_buffering off;
+ proxy_pass http://minio:9000;
+ }
+}
diff --git a/infra/ubuntu/scripts/backup.sh b/infra/ubuntu/scripts/backup.sh
new file mode 100644
index 0000000..b65c99d
--- /dev/null
+++ b/infra/ubuntu/scripts/backup.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+env_file="${1:-/etc/lehuo/lehuo.env}"
+require_command docker
+require_command gzip
+require_command sha256sum
+require_env_file "$env_file"
+
+backup_dir="$(env_value "$env_file" BACKUP_DIR)"
+[[ "$backup_dir" == /opt/lehuo/backups || "$backup_dir" == /opt/lehuo/backups/* ]] || {
+ echo 'BACKUP_DIR 必须位于 /opt/lehuo/backups。' >&2
+ exit 1
+}
+install -d -m 0700 "$backup_dir"
+
+database="$(env_value "$env_file" MYSQL_DATABASE)"
+timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
+target="$backup_dir/lehuo-${timestamp}.sql.gz"
+temporary="${target}.tmp"
+trap 'rm -f -- "$temporary"' EXIT
+
+compose "$env_file" exec -T mysql sh -c \
+ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump --single-transaction --routines --triggers --events --set-gtid-purged=OFF -u root "$MYSQL_DATABASE"' \
+ | gzip -9 > "$temporary"
+
+gzip -t "$temporary"
+mv -- "$temporary" "$target"
+chmod 600 "$target"
+sha256sum "$target" > "${target}.sha256"
+chmod 600 "${target}.sha256"
+find "$backup_dir" -maxdepth 1 -type f \( -name 'lehuo-*.sql.gz' -o -name 'lehuo-*.sql.gz.sha256' \) -mtime +30 -delete
+
+printf '数据库备份完成:%s(数据库:%s)\n' "$target" "$database"
+echo '请将备份及 SHA-256 文件同步到异地加密存储。'
diff --git a/infra/ubuntu/scripts/bootstrap-admin.sh b/infra/ubuntu/scripts/bootstrap-admin.sh
new file mode 100644
index 0000000..33868ec
--- /dev/null
+++ b/infra/ubuntu/scripts/bootstrap-admin.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+env_file="${1:-/etc/lehuo/lehuo.env}"
+require_env_file "$env_file"
+
+read -r -p '初始管理员用户名:' username
+read -r -p '初始管理员显示名:' display_name
+read -r -s -p '初始管理员密码:' password
+echo
+[[ -n "$username" && -n "$display_name" && ${#password} -ge 12 ]] || {
+ echo '用户名/显示名不能为空,密码至少 12 位。' >&2
+ exit 1
+}
+
+compose "$env_file" run --rm \
+ -e "BOOTSTRAP_ADMIN_USERNAME=${username}" \
+ -e "BOOTSTRAP_ADMIN_DISPLAY_NAME=${display_name}" \
+ -e "BOOTSTRAP_ADMIN_PASSWORD=${password}" \
+ api node apps/api/dist/scripts/bootstrap-admin.js
+
+unset password
+echo '初始管理员创建完成;命令会拒绝重复初始化。'
diff --git a/infra/ubuntu/scripts/deploy.sh b/infra/ubuntu/scripts/deploy.sh
new file mode 100644
index 0000000..a2693d6
--- /dev/null
+++ b/infra/ubuntu/scripts/deploy.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+env_file="${1:-/etc/lehuo/lehuo.env}"
+require_env_file "$env_file"
+bash "$(dirname -- "${BASH_SOURCE[0]}")/preflight.sh" "$env_file"
+
+image_tag="$(env_value "$env_file" IMAGE_TAG)"
+release_dir='/opt/lehuo/releases'
+install -d -m 0700 "$release_dir"
+
+current_tag=''
+if [[ -f "$release_dir/current" ]]; then
+ current_tag="$(<"$release_dir/current")"
+fi
+
+echo "构建不可变镜像:${image_tag}"
+compose "$env_file" build --pull api web
+
+echo '启动数据服务并初始化私有对象存储。'
+compose "$env_file" up -d mysql redis minio minio-init
+
+if [[ -n "$current_tag" ]]; then
+ echo '迁移前创建生产数据库备份。'
+ bash "$(dirname -- "${BASH_SOURCE[0]}")/backup.sh" "$env_file"
+fi
+
+echo '执行数据库 migration。'
+compose "$env_file" --profile tools run --rm migrate
+
+echo '部署 API 与管理后台网关。'
+compose "$env_file" up -d --remove-orphans api web
+
+bash "$(dirname -- "${BASH_SOURCE[0]}")/smoke.sh" "$env_file"
+
+if [[ -n "$current_tag" && "$current_tag" != "$image_tag" ]]; then
+ printf '%s\n' "$current_tag" > "$release_dir/previous"
+fi
+printf '%s\n' "$image_tag" > "$release_dir/current"
+chmod 600 "$release_dir/current"
+[[ ! -f "$release_dir/previous" ]] || chmod 600 "$release_dir/previous"
+
+printf '部署完成,当前镜像标签:%s\n' "$image_tag"
diff --git a/infra/ubuntu/scripts/generate-env.sh b/infra/ubuntu/scripts/generate-env.sh
new file mode 100644
index 0000000..a6dcc58
--- /dev/null
+++ b/infra/ubuntu/scripts/generate-env.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+usage() {
+ echo '用法:generate-env.sh --public-host admin.example.com --files-host files.example.com [--output /etc/lehuo/lehuo.env]'
+}
+
+public_host=''
+files_host=''
+output='/etc/lehuo/lehuo.env'
+while (($#)); do
+ case "$1" in
+ --public-host) public_host="${2:-}"; shift 2 ;;
+ --files-host) files_host="${2:-}"; shift 2 ;;
+ --output) output="${2:-}"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) usage >&2; exit 1 ;;
+ esac
+done
+
+[[ "$public_host" =~ ^[A-Za-z0-9.-]+$ ]] || { echo 'PUBLIC_HOST 格式无效。' >&2; exit 1; }
+[[ "$files_host" =~ ^[A-Za-z0-9.-]+$ ]] || { echo 'FILES_HOST 格式无效。' >&2; exit 1; }
+[[ "$public_host" != "$files_host" ]] || { echo '管理后台/API 域名与文件域名必须不同。' >&2; exit 1; }
+command -v openssl >/dev/null 2>&1 || { echo '缺少 openssl。' >&2; exit 1; }
+command -v git >/dev/null 2>&1 || { echo '缺少 git。' >&2; exit 1; }
+
+if [[ -e "$output" ]]; then
+ printf '拒绝覆盖已有环境文件:%s\n' "$output" >&2
+ exit 1
+fi
+
+install -d -m 0700 "$(dirname -- "$output")"
+umask 077
+
+mysql_password="$(openssl rand -hex 24)"
+mysql_root_password="$(openssl rand -hex 32)"
+redis_password="$(openssl rand -hex 32)"
+minio_password="$(openssl rand -hex 32)"
+session_secret="$(openssl rand -hex 32)"
+session_pepper="$(openssl rand -hex 32)"
+audit_key="$(openssl rand -hex 32)"
+field_key="$(openssl rand -base64 32 | tr -d '\n')"
+image_tag="$(git -C "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" rev-parse --short=12 HEAD)"
+
+cat > "$output" <&2
+ exit 1
+fi
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ubuntu_dir="$(cd -- "$script_dir/.." && pwd)"
+repo_root="$(cd -- "$ubuntu_dir/../.." && pwd)"
+[[ "$repo_root" == '/opt/lehuo/app' ]] || {
+ echo '安装定时器前请把仓库部署到 /opt/lehuo/app。' >&2
+ exit 1
+}
+[[ -f /etc/lehuo/lehuo.env ]] || { echo '缺少 /etc/lehuo/lehuo.env。' >&2; exit 1; }
+
+install -m 0644 "$ubuntu_dir/systemd/lehuo-backup.service" /etc/systemd/system/lehuo-backup.service
+install -m 0644 "$ubuntu_dir/systemd/lehuo-backup.timer" /etc/systemd/system/lehuo-backup.timer
+systemctl daemon-reload
+systemctl enable --now lehuo-backup.timer
+systemctl list-timers lehuo-backup.timer --no-pager
+
+echo '每日备份定时器已安装。首次正式发布前仍须手工执行一次备份和恢复演练。'
diff --git a/infra/ubuntu/scripts/install-docker.sh b/infra/ubuntu/scripts/install-docker.sh
new file mode 100644
index 0000000..05caa84
--- /dev/null
+++ b/infra/ubuntu/scripts/install-docker.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+if [[ "${EUID}" -ne 0 ]]; then
+ echo '请使用 sudo 运行此脚本。' >&2
+ exit 1
+fi
+
+if [[ ! -r /etc/os-release ]]; then
+ echo '无法识别操作系统。' >&2
+ exit 1
+fi
+
+# shellcheck disable=SC1091
+source /etc/os-release
+if [[ "${ID:-}" != 'ubuntu' || "${VERSION_ID:-}" != '26.04' ]]; then
+ printf '本部署包仅验证 Ubuntu 26.04;当前为 %s %s。\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2
+ exit 1
+fi
+
+if docker version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+ echo 'Docker Engine 与 Compose plugin 已安装。'
+ exit 0
+fi
+
+conflicts=()
+for package in docker.io docker-compose docker-compose-v2 podman-docker containerd runc; do
+ if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed'; then
+ conflicts+=("$package")
+ fi
+done
+if ((${#conflicts[@]})); then
+ printf '检测到可能冲突的软件包:%s\n' "${conflicts[*]}" >&2
+ echo '请先人工确认并按 Docker 官方文档移除冲突包,再重新运行;脚本不会自动卸载现有容器运行时。' >&2
+ exit 1
+fi
+
+apt-get update
+apt-get install -y ca-certificates curl
+install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+chmod a+r /etc/apt/keyrings/docker.asc
+
+architecture="$(dpkg --print-architecture)"
+codename="${UBUNTU_CODENAME:-${VERSION_CODENAME}}"
+cat > /etc/apt/sources.list.d/docker.sources </dev/null 2>&1 || {
+ printf '缺少命令:%s\n' "$1" >&2
+ exit 1
+ }
+}
+
+require_env_file() {
+ local file="$1"
+ [[ -f "$file" ]] || {
+ printf '环境文件不存在:%s\n' "$file" >&2
+ exit 1
+ }
+ local mode
+ mode="$(stat -c '%a' "$file")"
+ if [[ "$mode" != '600' && "$mode" != '400' ]]; then
+ printf '环境文件权限必须是 600 或 400,当前为 %s:%s\n' "$mode" "$file" >&2
+ exit 1
+ fi
+}
+
+env_value() {
+ local file="$1"
+ local key="$2"
+ sed -n "s/^${key}=//p" "$file" | tail -n 1
+}
+
+compose() {
+ local env_file="$1"
+ shift
+ docker compose --project-directory "$(ubuntu_dir)" --env-file "$env_file" -f "$(compose_file)" "$@"
+}
diff --git a/infra/ubuntu/scripts/preflight.sh b/infra/ubuntu/scripts/preflight.sh
new file mode 100644
index 0000000..12acc08
--- /dev/null
+++ b/infra/ubuntu/scripts/preflight.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+env_file="${1:-/etc/lehuo/lehuo.env}"
+require_command docker
+require_command curl
+require_command openssl
+require_env_file "$env_file"
+
+docker version >/dev/null
+docker compose version >/dev/null
+
+required_keys=(IMAGE_TAG PUBLIC_HOST FILES_HOST TLS_CERT_PATH TLS_KEY_PATH BACKUP_DIR DATABASE_URL REDIS_URL FIELD_ENCRYPTION_KEY ADMIN_WEB_ORIGIN)
+for key in "${required_keys[@]}"; do
+ value="$(env_value "$env_file" "$key")"
+ [[ -n "$value" && "$value" != replace-* ]] || {
+ printf '环境变量缺失或仍为占位值:%s\n' "$key" >&2
+ exit 1
+ }
+done
+
+image_tag="$(env_value "$env_file" IMAGE_TAG)"
+[[ "$image_tag" != 'latest' ]] || { echo '禁止使用 latest 镜像标签。' >&2; exit 1; }
+
+public_host="$(env_value "$env_file" PUBLIC_HOST)"
+files_host="$(env_value "$env_file" FILES_HOST)"
+[[ "$public_host" != "$files_host" ]] || { echo 'PUBLIC_HOST 与 FILES_HOST 必须不同。' >&2; exit 1; }
+
+cert_path="$(env_value "$env_file" TLS_CERT_PATH)"
+key_path="$(env_value "$env_file" TLS_KEY_PATH)"
+[[ -r "$cert_path" && -r "$key_path" ]] || { echo 'TLS 证书或私钥不存在/不可读。' >&2; exit 1; }
+openssl x509 -in "$cert_path" -noout -checkend 604800 >/dev/null || {
+ echo 'TLS 证书将在 7 天内过期或无效。' >&2
+ exit 1
+}
+openssl x509 -in "$cert_path" -noout -checkhost "$public_host" >/dev/null || {
+ printf 'TLS 证书不包含管理端域名:%s\n' "$public_host" >&2
+ exit 1
+}
+openssl x509 -in "$cert_path" -noout -checkhost "$files_host" >/dev/null || {
+ printf 'TLS 证书不包含文件域名:%s\n' "$files_host" >&2
+ exit 1
+}
+openssl pkey -in "$key_path" -noout -check >/dev/null
+
+compose "$env_file" config --quiet
+
+available_kb="$(df -Pk "$(repo_root)" | awk 'NR==2 {print $4}')"
+((available_kb >= 10 * 1024 * 1024)) || { echo '可用磁盘不足 10 GiB。' >&2; exit 1; }
+
+echo 'Ubuntu 生产部署预检通过。'
diff --git a/infra/ubuntu/scripts/restore.sh b/infra/ubuntu/scripts/restore.sh
new file mode 100644
index 0000000..fba33fc
--- /dev/null
+++ b/infra/ubuntu/scripts/restore.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+usage() {
+ echo '用法:restore.sh --confirm [env-file]'
+}
+
+backup="${1:-}"
+target_database="${2:-}"
+confirm_flag="${3:-}"
+confirm_database="${4:-}"
+env_file="${5:-/etc/lehuo/lehuo.env}"
+[[ -n "$backup" && -n "$target_database" && "$confirm_flag" == '--confirm' && "$confirm_database" == "$target_database" ]] || {
+ usage >&2
+ exit 1
+}
+[[ "$target_database" =~ ^[A-Za-z0-9_]+$ ]] || { echo '目标数据库名无效。' >&2; exit 1; }
+
+require_command docker
+require_command gzip
+require_command sha256sum
+require_command realpath
+require_env_file "$env_file"
+
+backup_dir="$(realpath -m "$(env_value "$env_file" BACKUP_DIR)")"
+backup_path="$(realpath -e "$backup")"
+[[ "$backup_path" == "$backup_dir"/* ]] || { echo '备份文件必须位于 BACKUP_DIR。' >&2; exit 1; }
+[[ -f "${backup_path}.sha256" ]] || { echo '缺少 SHA-256 校验文件。' >&2; exit 1; }
+(cd -- "$backup_dir" && sha256sum -c "$(basename -- "${backup_path}.sha256")")
+gzip -t "$backup_path"
+
+production_database="$(env_value "$env_file" MYSQL_DATABASE)"
+if [[ "$target_database" == "$production_database" && "${ALLOW_PRODUCTION_RESTORE:-}" != 'YES' ]]; then
+ echo '拒绝覆盖生产数据库。若已完成停机、审批和二次备份,请显式设置 ALLOW_PRODUCTION_RESTORE=YES。' >&2
+ exit 1
+fi
+
+compose "$env_file" exec -T -e RESTORE_DATABASE="$target_database" mysql sh -c \
+ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -u root -e "CREATE DATABASE IF NOT EXISTS \`$RESTORE_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"'
+gzip -dc "$backup_path" | compose "$env_file" exec -T -e RESTORE_DATABASE="$target_database" mysql sh -c \
+ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -u root "$RESTORE_DATABASE"'
+
+printf '恢复完成:%s → %s\n' "$backup_path" "$target_database"
diff --git a/infra/ubuntu/scripts/rollback.sh b/infra/ubuntu/scripts/rollback.sh
new file mode 100644
index 0000000..1660d30
--- /dev/null
+++ b/infra/ubuntu/scripts/rollback.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+env_file="${1:-/etc/lehuo/lehuo.env}"
+require_env_file "$env_file"
+release_dir='/opt/lehuo/releases'
+[[ -f "$release_dir/previous" ]] || { echo '没有可回滚的上一镜像标签。' >&2; exit 1; }
+
+previous_tag="$(<"$release_dir/previous")"
+current_tag="$(<"$release_dir/current")"
+[[ -n "$previous_tag" && "$previous_tag" != 'latest' ]] || { echo '上一镜像标签无效。' >&2; exit 1; }
+
+if ! docker image inspect "lehuo-api:${previous_tag}" >/dev/null 2>&1 || ! docker image inspect "lehuo-admin:${previous_tag}" >/dev/null 2>&1; then
+ echo '上一版本镜像在本机不存在,停止回滚。' >&2
+ exit 1
+fi
+
+echo '注意:此操作只回滚应用镜像,不回滚数据库。migration 必须保持向后兼容。'
+IMAGE_TAG="$previous_tag" compose "$env_file" up -d --no-build api web
+bash "$(dirname -- "${BASH_SOURCE[0]}")/smoke.sh" "$env_file"
+printf '%s\n' "$previous_tag" > "$release_dir/current"
+printf '%s\n' "$current_tag" > "$release_dir/previous"
+chmod 600 "$release_dir/current" "$release_dir/previous"
+printf '应用已回滚到:%s\n' "$previous_tag"
diff --git a/infra/ubuntu/scripts/smoke.sh b/infra/ubuntu/scripts/smoke.sh
new file mode 100644
index 0000000..5735ee4
--- /dev/null
+++ b/infra/ubuntu/scripts/smoke.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+# shellcheck source=lib.sh
+source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
+
+env_file="${1:-/etc/lehuo/lehuo.env}"
+require_command curl
+require_env_file "$env_file"
+
+public_host="$(env_value "$env_file" PUBLIC_HOST)"
+files_host="$(env_value "$env_file" FILES_HOST)"
+
+curl --fail --silent --show-error --max-time 15 "https://${public_host}/health/live" >/dev/null
+curl --fail --silent --show-error --max-time 15 "https://${public_host}/health/ready" >/dev/null
+curl --fail --silent --show-error --max-time 15 "https://${public_host}/" >/dev/null
+curl --fail --silent --show-error --max-time 15 "https://${files_host}/minio/health/live" >/dev/null
+
+echo 'HTTPS、API readiness、管理后台与对象存储冒烟检查通过。'
diff --git a/infra/ubuntu/systemd/lehuo-backup.service b/infra/ubuntu/systemd/lehuo-backup.service
new file mode 100644
index 0000000..ced0843
--- /dev/null
+++ b/infra/ubuntu/systemd/lehuo-backup.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Lehuo公益平台 MySQL backup
+Requires=docker.service
+After=docker.service
+
+[Service]
+Type=oneshot
+User=root
+WorkingDirectory=/opt/lehuo/app
+ExecStart=/usr/bin/bash /opt/lehuo/app/infra/ubuntu/scripts/backup.sh /etc/lehuo/lehuo.env
+Nice=10
+IOSchedulingClass=best-effort
+IOSchedulingPriority=7
+
+[Install]
+WantedBy=multi-user.target
diff --git a/infra/ubuntu/systemd/lehuo-backup.timer b/infra/ubuntu/systemd/lehuo-backup.timer
new file mode 100644
index 0000000..8034163
--- /dev/null
+++ b/infra/ubuntu/systemd/lehuo-backup.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Run Lehuo公益平台 MySQL backup daily
+
+[Timer]
+OnCalendar=*-*-* 03:30:00 Asia/Shanghai
+Persistent=true
+RandomizedDelaySec=15m
+Unit=lehuo-backup.service
+
+[Install]
+WantedBy=timers.target