feat(deploy): add Ubuntu 26.04 production package

This commit is contained in:
2026-08-15 22:50:07 +08:00
parent 0a91cafc4e
commit 65eac893d8
28 changed files with 1101 additions and 4 deletions
+11 -3
View File
@@ -1,9 +1,17 @@
.git .git
.github .github
.env
.env.*
node_modules
**/node_modules **/node_modules
dist
**/dist **/dist
coverage coverage
.env **/coverage
*.log *.log
docs apps/miniprogram/.miniprogram
*.png apps/miniprogram/miniprogram_npm
mysql-data
minio-data
redis-data
docs/ui_reference.png
+6
View File
@@ -35,3 +35,9 @@ pnpm build
集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。 集成测试只允许连接数据库名为 `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`。 项目约束、当前阶段和决策分别见 `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、隐私法务、告警接收与异地备份验收;外部项未完成前不得宣称已生产上线。
+19
View File
@@ -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
+2 -1
View File
@@ -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 FROM node:24.13.0-alpine AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
RUN corepack enable
COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./ 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/node_modules ./node_modules
COPY --from=build /app/apps/api/package.json ./apps/api/package.json 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/node_modules ./apps/api/node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist 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/package.json ./packages/contracts/package.json
COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist
USER node USER node
+14
View File
@@ -6,6 +6,20 @@
2026-08-15 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,防止不安全发布配置。 完成:在既有未提交安全改动基础上完成复核和修正。审计增加原始会话 token 防御性剥离;后台写请求使用精确 Origin 或无 Origin 时的完整 Referer origin 校验,不允许前缀或通配来源;管理 Cookie 仅在 Secure 模式附加 Partitioned,避免本地 HTTP 丢失登录态;私有上传签名由 600 秒缩短到 120 秒;台账导出审计补充关键词条件;数据库启动只记录已完成 migration 数量与最新名称,不把运行时日志冒充待部署校验。小程序开发版保留 localhost 回环调试,体验版/正式版拒绝回环、`0.0.0.0` 和非 HTTPS API,防止不安全发布配置。
+14
View File
@@ -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 若有会改变已批准架构的建议,只能写在这里,不得直接实施: Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施:
+12
View File
@@ -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 已完成。
+5
View File
@@ -50,8 +50,13 @@
- [x] health - [x] health
- [x] monitor - [x] monitor
- [x] alert - [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 - [ ] backup
- [x] restore procedure - [x] restore procedure
- [ ] offsite encrypted backup configured
- [ ] systemd backup timer verified
- [ ] rollback image available - [ ] rollback image available
## UAT ## UAT
+2
View File
@@ -66,6 +66,8 @@ Phase 0–7 的可自动化开发与本地生产工程验证已完成。当前
当前证据:统计/导出、health/metrics、权限扫描、官方依赖审计、密钥扫描、CodeQL 工作流、备份/恢复脚本、Nginx、immutable image Compose、预检/冒烟和告警规则已完成;本地恢复演练源库与恢复库均为 42 张表。真实 staging、UAT、告警接收和生产发布尚未执行,因此本退出条件仍未满足。 当前证据:统计/导出、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. 需要人工或外部环境配合 ## 3. 需要人工或外部环境配合
以下内容不能用测试值替代正式验收: 以下内容不能用测试值替代正式验收:
+4
View File
@@ -18,3 +18,7 @@
6. 异常时将 `IMAGE_TAG` 恢复到上一批准版本重新部署。若 migration 不向后兼容,停止发布并按已验证的数据库恢复步骤执行。 6. 异常时将 `IMAGE_TAG` 恢复到上一批准版本重新部署。若 migration 不向后兼容,停止发布并按已验证的数据库恢复步骤执行。
备份默认每日一次,保留 30 天;必须每季度在独立数据库做恢复演练并记录表数量、关键行数和耗时。脚本会校验 SHA-256,恢复目标数据库名必须显式提供。 备份默认每日一次,保留 30 天;必须每季度在独立数据库做恢复演练并记录表数量、关键行数和耗时。脚本会校验 SHA-256,恢复目标数据库名必须显式提供。
## Ubuntu 26.04
原有 `.ps1` 工具保留用于 Windows 运维环境。Ubuntu 26.04 云服务器应使用 `infra/ubuntu/` 中的 Compose、Bash 脚本与 systemd timer;完整步骤见 `infra/ubuntu/README.md`。两套工具遵循相同的不可变标签、migration、备份、冒烟、观察和应用回滚顺序。
+131
View File
@@ -0,0 +1,131 @@
# Ubuntu 26.04 LTS 单机部署包
本目录用于把乐活公益平台部署到一台已安装 Ubuntu 26.04 LTS 的云服务器。Docker 官方已将 Ubuntu 26.04(Resolute)列为 Docker Engine 支持系统:<https://docs.docker.com/engine/install/ubuntu/>。
该部署包适合初期单机 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、数据库连接、通知死信和备份缺失告警需接入实际有人值守的监控渠道。
+223
View File
@@ -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:
+44
View File
@@ -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
+30
View File
@@ -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 <<EOF
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
<AllowedOrigin>${ADMIN_WEB_ORIGIN}</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>HEAD</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
EOF
mc cors set "lehuo/$OBJECT_STORAGE_BUCKET" /tmp/cors.xml >/dev/null
rm -f /tmp/cors.xml
echo "MinIO private bucket initialized."
+83
View File
@@ -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;
}
}
+38
View File
@@ -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 文件同步到异地加密存储。'
+26
View File
@@ -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 '初始管理员创建完成;命令会拒绝重复初始化。'
+46
View File
@@ -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"
+87
View File
@@ -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" <<EOF
IMAGE_TAG=${image_tag}
PUBLIC_HOST=${public_host}
FILES_HOST=${files_host}
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=${mysql_password}
MYSQL_ROOT_PASSWORD=${mysql_root_password}
DATABASE_URL=mysql://lehuo:${mysql_password}@mysql:3306/lehuo
REDIS_PASSWORD=${redis_password}
REDIS_URL=redis://:${redis_password}@redis:6379
MINIO_ROOT_USER=lehuo_storage
MINIO_ROOT_PASSWORD=${minio_password}
OBJECT_STORAGE_ENDPOINT=https://${files_host}
OBJECT_STORAGE_REGION=auto
OBJECT_STORAGE_BUCKET=lehuo-private
OBJECT_STORAGE_ACCESS_KEY=lehuo_storage
OBJECT_STORAGE_SECRET_KEY=${minio_password}
SESSION_SECRET=${session_secret}
SESSION_TOKEN_PEPPER=${session_pepper}
AUDIT_IP_HASH_KEY=${audit_key}
FIELD_ENCRYPTION_KEY=${field_key}
ADMIN_WEB_ORIGIN=https://${public_host}
AUTH_SESSION_TTL_SECONDS=28800
WECHAT_SESSION_TTL_SECONDS=7200
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
EOF
chmod 600 "$output"
install -d -m 0700 /opt/lehuo/shared/tls /opt/lehuo/backups /opt/lehuo/releases
printf '环境文件已生成:%s\n' "$output"
echo '下一步:放置覆盖两个域名的 TLS 证书,然后运行 preflight.sh。'
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo '请使用 sudo 运行此脚本。' >&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 '每日备份定时器已安装。首次正式发布前仍须手工执行一次备份和恢复演练。'
+61
View File
@@ -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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: ${codename}
Components: stable
Architectures: ${architecture}
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker version
docker compose version
echo 'Docker 安装完成。Docker 组等同 root 权限,本脚本不会自动把普通用户加入 docker 组。'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ubuntu_dir() {
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd
}
repo_root() {
cd -- "$(ubuntu_dir)/../.." && pwd
}
compose_file() {
printf '%s/compose.yaml\n' "$(ubuntu_dir)"
}
require_command() {
command -v "$1" >/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)" "$@"
}
+54
View File
@@ -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 生产部署预检通过。'
+46
View File
@@ -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 <backup.sql.gz> <target_database> --confirm <target_database> [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"
+27
View File
@@ -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"
+19
View File
@@ -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、管理后台与对象存储冒烟检查通过。'
+16
View File
@@ -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
+11
View File
@@ -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