From 73a94e6be3a964436c629d02ef278ec7a5b013ff Mon Sep 17 00:00:00 2001 From: wchino Date: Sat, 13 Jun 2026 22:16:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=83=A8=E7=BD=B2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 5 + .env.docker.example | 9 + .gitignore | 3 + apps/api/src/app.module.ts | 2 + .../health/health.controller.spec.ts | 42 +++ .../applications/health/health.controller.ts | 60 +++++ .../src/applications/health/health.module.ts | 7 + apps/player/src/components/BetSlipDrawer.vue | 11 +- docker-compose.prod.yml | 42 ++- docker/admin/Dockerfile | 2 +- docker/api/Dockerfile | 2 +- docker/api/entrypoint.sh | 16 +- docker/player/Dockerfile | 2 +- docs/Docker部署指南.md | 87 +++++-- docs/docker/build-and-export-images.ps1 | 87 ++++++- docs/docker/build-and-export-images.sh | 88 ++++++- docs/docker/镜像构建与导出.md | 41 +-- docs/项目启动指南.md | 3 +- docs/默认数据说明.md | 2 +- scripts/backup-db.ps1 | 24 +- scripts/backup-db.sh | 3 +- scripts/backup-prod.sh | 69 +++++ scripts/deploy-first.sh | 22 +- scripts/deploy-lib.sh | 244 +++++++++++++++--- scripts/deploy-update.sh | 30 ++- scripts/prod-init-db.ps1 | 23 +- scripts/prod-init-db.sh | 11 +- scripts/rollback.sh | 91 +++++++ 28 files changed, 899 insertions(+), 129 deletions(-) create mode 100644 apps/api/src/applications/health/health.controller.spec.ts create mode 100644 apps/api/src/applications/health/health.controller.ts create mode 100644 apps/api/src/applications/health/health.module.ts mode change 100644 => 100755 docs/docker/build-and-export-images.sh create mode 100755 scripts/backup-prod.sh create mode 100755 scripts/rollback.sh diff --git a/.dockerignore b/.dockerignore index 6bf3d41..7bdc42f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,11 @@ node_modules coverage .turbo **/*.tsbuildinfo +release +backups +.deploy +thebet365-images*.tar +thebet365-images*.manifest.txt apps/player/dist apps/admin/dist apps/api/dist diff --git a/.env.docker.example b/.env.docker.example index 6b26880..58413ad 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -4,6 +4,9 @@ # PostgreSQL(生产务必修改) POSTGRES_PASSWORD=thebet365 +# 发布镜像版本;部署脚本使用 --tag 时会写回服务器上的 .env.docker +IMAGE_TAG=latest + # JWT(生产务必修改) JWT_SECRET=change-me-in-production-use-long-random-string JWT_PLAYER_EXPIRES=24h @@ -12,13 +15,19 @@ JWT_AGENT_EXPIRES=8h # 首次部署如需写入默认数据,可临时改为 true;灌完后改回 false 并重启 api SEED_DATABASE=false +# 迁移由 deploy-first/deploy-update 脚本执行;仅应急兼容时改为 true +RUN_MIGRATIONS_ON_START=false # 可选:覆盖 admin 初始密码(仅 seed/重置时生效) # ADMIN_INITIAL_PASSWORD=YourStrongPasswordHere # 对外端口(宝塔/Nginx 反代推荐只暴露前端,API 经反向代理访问) +BIND_ADDR=127.0.0.1 PLAYER_PORT=8082 ADMIN_PORT=8081 +# 备份保留天数;留空表示不自动清理 +BACKUP_RETENTION_DAYS= + # API 安全开关 # CORS_ORIGINS=https://player.example.com,https://admin.example.com ENABLE_SWAGGER=false diff --git a/.gitignore b/.gitignore index 8f06421..10fe39b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ dist/ .pnpm-store/ release/ backups/ +.deploy/ docker-build.log thebet365-images.tar +thebet365-images-*.tar +thebet365-images-*.manifest.txt .claude/ *.log .DS_Store diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 495cf4a..d08f1ee 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,7 @@ import { OperationsModule } from './domains/operations/operations.module'; import { AdminModule } from './applications/admin/admin.module'; import { PlayerModule } from './applications/player/player.module'; import { AgentPortalModule } from './applications/agent/agent-portal.module'; +import { HealthModule } from './applications/health/health.module'; @Module({ imports: [ @@ -36,6 +37,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module'; AdminModule, PlayerModule, AgentPortalModule, + HealthModule, ], providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }], }) diff --git a/apps/api/src/applications/health/health.controller.spec.ts b/apps/api/src/applications/health/health.controller.spec.ts new file mode 100644 index 0000000..34eef71 --- /dev/null +++ b/apps/api/src/applications/health/health.controller.spec.ts @@ -0,0 +1,42 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + function createController(options?: { database?: boolean; redis?: boolean }) { + const database = options?.database ?? true; + const redis = options?.redis ?? true; + const prisma = { + $queryRaw: jest.fn().mockImplementation(() => { + if (!database) throw new Error('database unavailable'); + return Promise.resolve([{ ok: 1 }]); + }), + }; + const redisService = { + raw: { + ping: jest.fn().mockImplementation(() => { + if (!redis) throw new Error('redis unavailable'); + return Promise.resolve('PONG'); + }), + }, + }; + + return new HealthController(prisma as never, redisService as never); + } + + it('reports liveness without external checks', () => { + expect(createController().live()).toEqual({ status: 'ok' }); + }); + + it('reports readiness when database and redis respond', async () => { + await expect(createController().ready()).resolves.toEqual({ + status: 'ok', + checks: { database: 'ok', redis: 'ok' }, + }); + }); + + it('fails readiness when a dependency is unavailable', async () => { + await expect(createController({ redis: false }).ready()).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }); +}); diff --git a/apps/api/src/applications/health/health.controller.ts b/apps/api/src/applications/health/health.controller.ts new file mode 100644 index 0000000..2b00d4c --- /dev/null +++ b/apps/api/src/applications/health/health.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; +import { Public } from '../../shared/common/decorators'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { RedisService } from '../../shared/redis/redis.service'; + +type CheckStatus = 'ok' | 'error'; + +@Controller('health') +export class HealthController { + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + ) {} + + @Public() + @Get('live') + live() { + return { status: 'ok' as const }; + } + + @Public() + @Get('ready') + async ready() { + const checks: Record<'database' | 'redis', CheckStatus> = { + database: 'ok', + redis: 'ok', + }; + + const [databaseReady, redisReady] = await Promise.all([ + this.checkDatabase(), + this.checkRedis(), + ]); + + if (!databaseReady) checks.database = 'error'; + if (!redisReady) checks.redis = 'error'; + + if (!databaseReady || !redisReady) { + throw new ServiceUnavailableException({ status: 'error', checks }); + } + + return { status: 'ok' as const, checks }; + } + + private async checkDatabase(): Promise { + try { + await this.prisma.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } + } + + private async checkRedis(): Promise { + try { + return (await this.redis.raw.ping()) === 'PONG'; + } catch { + return false; + } + } +} diff --git a/apps/api/src/applications/health/health.module.ts b/apps/api/src/applications/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/apps/api/src/applications/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/player/src/components/BetSlipDrawer.vue b/apps/player/src/components/BetSlipDrawer.vue index fa3972f..ea157d0 100644 --- a/apps/player/src/components/BetSlipDrawer.vue +++ b/apps/player/src/components/BetSlipDrawer.vue @@ -95,6 +95,7 @@ function selectTab(tab: SlipMode) { } function closeDrawer() { + slip.closeDrawer(); show.value = false; error.value = ''; success.value = ''; @@ -305,7 +306,7 @@ watch(

{{ t('bet.bet_slip') }}

{{ t('bet.slip_review_title') }}

- @@ -473,6 +474,9 @@ watch( } .drawer-head { + position: relative; + z-index: 2; + flex-shrink: 0; display: flex; justify-content: space-between; align-items: flex-start; @@ -497,6 +501,9 @@ watch( } .close-btn { + position: relative; + z-index: 3; + flex-shrink: 0; width: 32px; height: 32px; border-radius: 50%; @@ -504,6 +511,8 @@ watch( color: var(--text-muted); font-size: 18px; padding: 0; + pointer-events: auto; + touch-action: manipulation; } .balance-bar { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 70c08df..db4a410 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -36,19 +36,21 @@ services: - thebet365 api: - image: thebet365-api:latest + image: thebet365-api:${IMAGE_TAG:-latest} build: context: . dockerfile: docker/api/Dockerfile container_name: thebet365-api env_file: - - .env.docker + - path: .env.docker + required: false environment: DATABASE_URL: postgresql://thebet365:${POSTGRES_PASSWORD:-thebet365}@postgres:5432/thebet365 REDIS_URL: redis://redis:6379 PORT: 3000 NODE_ENV: production UPLOAD_DIR: /app/uploads + RUN_MIGRATIONS_ON_START: ${RUN_MIGRATIONS_ON_START:-false} volumes: - uploads_data:/app/uploads depends_on: @@ -58,34 +60,58 @@ services: condition: service_healthy expose: - '3000' + healthcheck: + test: + [ + 'CMD-SHELL', + "node -e \"fetch('http://127.0.0.1:3000/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s restart: unless-stopped networks: - thebet365 player: - image: thebet365-player:latest + image: thebet365-player:${IMAGE_TAG:-latest} build: context: . dockerfile: docker/player/Dockerfile container_name: thebet365-player depends_on: - - api + api: + condition: service_healthy ports: - - '${PLAYER_PORT:-8082}:80' + - '${BIND_ADDR:-127.0.0.1}:${PLAYER_PORT:-8082}:80' + healthcheck: + test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1'] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s restart: unless-stopped networks: - thebet365 admin: - image: thebet365-admin:latest + image: thebet365-admin:${IMAGE_TAG:-latest} build: context: . dockerfile: docker/admin/Dockerfile container_name: thebet365-admin depends_on: - - api + api: + condition: service_healthy ports: - - '${ADMIN_PORT:-8081}:80' + - '${BIND_ADDR:-127.0.0.1}:${ADMIN_PORT:-8081}:80' + healthcheck: + test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1'] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s restart: unless-stopped networks: - thebet365 diff --git a/docker/admin/Dockerfile b/docker/admin/Dockerfile index da1d58f..460b9de 100644 --- a/docker/admin/Dockerfile +++ b/docker/admin/Dockerfile @@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/ COPY apps/player/package.json apps/player/ COPY apps/admin/package.json apps/admin/ COPY packages/shared/package.json packages/shared/ -RUN pnpm install --no-frozen-lockfile +RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index a9509a1..99453d3 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/ COPY apps/player/package.json apps/player/ COPY apps/admin/package.json apps/admin/ COPY packages/shared/package.json packages/shared/ -RUN pnpm install --no-frozen-lockfile +RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app diff --git a/docker/api/entrypoint.sh b/docker/api/entrypoint.sh index 1250fb8..496081a 100644 --- a/docker/api/entrypoint.sh +++ b/docker/api/entrypoint.sh @@ -3,12 +3,16 @@ set -e cd /app/apps/api -echo "[api] running migrations..." -until npx prisma migrate deploy; do - echo "[api] waiting for database..." - sleep 2 -done -npx prisma generate +if [ "${RUN_MIGRATIONS_ON_START:-false}" = "true" ]; then + echo "[api] running migrations..." + until npx prisma migrate deploy; do + echo "[api] waiting for database..." + sleep 2 + done + npx prisma generate +else + echo "[api] skipping startup migrations (RUN_MIGRATIONS_ON_START=false)" +fi if [ "$SEED_DATABASE" = "true" ]; then echo "[api] seeding database..." diff --git a/docker/player/Dockerfile b/docker/player/Dockerfile index 5569bbf..ad1528b 100644 --- a/docker/player/Dockerfile +++ b/docker/player/Dockerfile @@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/ COPY apps/player/package.json apps/player/ COPY apps/admin/package.json apps/admin/ COPY packages/shared/package.json packages/shared/ -RUN pnpm install --no-frozen-lockfile +RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app diff --git a/docs/Docker部署指南.md b/docs/Docker部署指南.md index abda526..0d7414d 100644 --- a/docs/Docker部署指南.md +++ b/docs/Docker部署指南.md @@ -10,8 +10,9 @@ ```text 浏览器 - ├─ :8082 player (Nginx) ── /api、/uploads ──► api (NestJS :3000) - └─ :8081 admin (Nginx) ── /api ────────────► api (NestJS :3000) + └─ 宝塔/Nginx HTTPS 反代 + ├─ 127.0.0.1:8082 player (Nginx) ── /api、/uploads ──► api (NestJS :3000) + └─ 127.0.0.1:8081 admin (Nginx) ── /api、/uploads ──► api (NestJS :3000) api ──► postgres:5432 └──► redis:6379 @@ -19,9 +20,9 @@ api ──► postgres:5432 | 容器 | 默认端口 | 说明 | |------|----------|------| -| `thebet365-player` | 8082 | 玩家 H5 前台 | -| `thebet365-admin` | 8081 | 管理后台(平台 + 代理) | -| `thebet365-api` | 3000 | NestJS API / Swagger | +| `thebet365-player` | 127.0.0.1:8082 | 玩家 H5 前台 | +| `thebet365-admin` | 127.0.0.1:8081 | 管理后台(平台 + 代理) | +| `thebet365-api` | 不对外暴露 | NestJS API / Swagger / 健康检查 | | `thebet365-postgres` | 不对外暴露 | PostgreSQL 16 | | `thebet365-redis` | 不对外暴露 | Redis 7 | @@ -58,6 +59,10 @@ cp .env.docker.example .env.docker - `POSTGRES_PASSWORD` — 数据库密码 - `JWT_SECRET` — 足够长的随机字符串 +- `IMAGE_TAG=latest` — 首次部署可保留;后续用 `--tag` 发布时脚本会写回真实版本 +- `BIND_ADDR=127.0.0.1` — 默认只允许宝塔本机反代访问 player/admin 端口 +- `RUN_MIGRATIONS_ON_START=false` — 迁移由部署脚本执行,API 容器启动时不重复跑迁移 +- `BACKUP_RETENTION_DAYS=` — 留空表示不自动清理备份;填数字时脚本会清理更旧备份 - `SEED_DATABASE=false` — 保持默认即可;首次部署脚本会在没有 `admin` 时一次性执行生产 seed ### 3. 首次部署 @@ -81,12 +86,14 @@ chmod +x scripts/*.sh ./scripts/deploy-first.sh --no-build ``` -如果上传的是 `thebet365-images.tar`,可让脚本自动加载镜像: +如果上传的是版本化镜像包,可让脚本自动加载镜像并记录发布 tag: ```bash -./scripts/deploy-first.sh --images thebet365-images.tar +./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3 ``` +镜像包文件名符合 `thebet365-images-.tar` 时,脚本也会自动推断 tag;显式传 `--tag` 更清晰。 + 如需全量初始化生产数据(会清空业务表,仅限全新库或明确重置): ```bash @@ -104,10 +111,10 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api | 服务 | 地址 | |------|------| -| 玩家前台 | http://服务器IP:8082 | -| 管理后台 | http://服务器IP:8081 | +| 玩家前台 | 宝塔绑定的玩家域名,或服务器本机 `http://127.0.0.1:8082` | +| 管理后台 | 宝塔绑定的管理域名,或服务器本机 `http://127.0.0.1:8081` | -API 只在 Docker 网络内暴露,前端容器通过 `/api` 代理到 API。外层域名反代由宝塔网站配置处理。 +API 只在 Docker 网络内暴露,前端容器通过 `/api` 代理到 API。player/admin 默认只绑定 `127.0.0.1`,外层域名和 HTTPS 由宝塔网站反代处理。临时需要公网 IP 直连时,才在 `.env.docker` 中设置 `BIND_ADDR=0.0.0.0`。 --- @@ -171,6 +178,9 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build a # 手动备份数据库 ./scripts/backup-db.sh +# 完整生产备份:数据库 + uploads +./scripts/backup-prod.sh --prefix pre-release + # 全量初始化(生产上线:仅 admin + WC2026 赛事,会先备份到 ./backups/) CONFIRM=YES ./scripts/prod-init-db.sh # Windows PowerShell: @@ -178,6 +188,9 @@ CONFIRM=YES ./scripts/prod-init-db.sh # 查看 API 日志 docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api + +# 回滚应用镜像到指定 tag(不自动回滚数据库) +./scripts/rollback.sh --to v1.2.2 ``` 根目录 `package.json` 快捷脚本(需已存在 `.env.docker`): @@ -197,21 +210,27 @@ pnpm docker:ps |------|------| | `postgres_data` | 数据库 | | `redis_data` | Redis | -| `uploads_data` | Banner 等用户上传文件 | +| `uploads_data` | Banner、充值截图、支付二维码等用户上传文件 | -备份 PostgreSQL 示例: +备份示例: ```bash +# 仅数据库:生成 backups/thebet365-db-*.sql.gz 和 .sha256 ./scripts/backup-db.sh + +# 数据库 + uploads:生成 .sql.gz、.tar.gz 和对应 .sha256 +./scripts/backup-prod.sh --prefix manual ``` +`BACKUP_RETENTION_DAYS` 留空时不自动删除历史备份;设置为数字时,部署和备份脚本会清理更早的备份文件。 + --- ## 八、后续更新部署 **推荐:先删旧代码再解压新 zip**(避免 `packages/shared/public/球员` 等中文目录残留导致 Vite 构建失败)。 -本地构建并导出镜像的详细步骤见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)(脚本位于 `docs/docker/build-and-export-images.ps1` / `build-and-export-images.sh`)。 +推荐主流程是:本地或构建机生成版本化镜像包 → 上传 tar 与 manifest → 服务器执行 `deploy-update.sh --images ... --tag ...`。详细步骤见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)(脚本位于 `docs/docker/build-and-export-images.ps1` / `build-and-export-images.sh`)。 ### 方式 A:服务器直接拉代码并构建 @@ -233,19 +252,30 @@ cd /www/wwwroot/thebet365 ```bash cd /www/wwwroot/thebet365 -./scripts/deploy-update.sh --images thebet365-images.tar +./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3 ``` 更新脚本默认会: -- 先备份 PostgreSQL 到 `./backups/` -- 构建或加载新镜像 +- 先备份 PostgreSQL 与 uploads 到 `./backups/`,并生成 `.sha256` +- 构建或加载指定 tag 的新镜像 - 使用新 API 镜像执行 `prisma migrate deploy` - 启动/替换 API、玩家端、管理端容器 +- 等待 API、玩家端、管理端健康检查通过 - 执行 `prisma migrate status` 检查数据库迁移状态 +- 将当前发布写入 `.deploy/current-release.env`,并保留上一次发布到 `.deploy/previous-release.env` 除非已经手工确认有其他备份,否则不要使用 `--no-backup`。 +### 回滚应用镜像 + +```bash +cd /www/wwwroot/thebet365 +./scripts/rollback.sh --to v1.2.2 +``` + +回滚脚本只切换 `api` / `player` / `admin` 镜像 tag,不自动恢复数据库。若新版本包含不可逆迁移或已写入不兼容数据,需要先按 `backups/` 中的 `.sql.gz` 备份手工恢复 PostgreSQL,再执行镜像回滚。 + --- ## 九、故障排查 @@ -256,21 +286,36 @@ cd /www/wwwroot/thebet365 docker logs thebet365-api ``` -常见原因:数据库未就绪(稍等重试)、`DATABASE_URL` 密码与 `POSTGRES_PASSWORD` 不一致。 +常见原因:数据库未就绪(稍等重试)、`DATABASE_URL` 密码与 `POSTGRES_PASSWORD` 不一致、`/api/health/ready` 检查 DB/Redis 失败。 ### 2. 前端 502 / 接口失败 -确认 `thebet365-api` 为 running,且 player/admin 容器能解析主机名 `api`(同一 compose 网络)。 +确认 `thebet365-api` 为 healthy,且 player/admin 容器能解析主机名 `api`(同一 compose 网络)。 -### 3. 构建慢或内存不足 +```bash +docker compose -f docker-compose.prod.yml --env-file .env.docker ps +docker compose -f docker-compose.prod.yml --env-file .env.docker logs --tail=120 api +``` + +### 3. player/admin 端口无法从公网 IP 直连 + +生产默认只绑定 `127.0.0.1`,需要通过宝塔网站反代访问。临时调试公网 IP 直连时,在 `.env.docker` 中设置: + +```bash +BIND_ADDR=0.0.0.0 +``` + +然后重新执行部署脚本。 + +### 4. 构建慢或内存不足 首次 `docker compose build` 会安装 pnpm 依赖并编译三端,建议服务器 ≥ 2 GB 内存;可在低峰期构建。 -### 4. 端口被占用 +### 5. 端口被占用 修改 `.env.docker` 中的 `PLAYER_PORT` / `ADMIN_PORT` 后重新部署。API 不对公网映射端口。 -### 5. player/admin 构建报错 `ENOENT ... packages/shared/public/球员` +### 6. player/admin 构建报错 `ENOENT ... packages/shared/public/球员` 旧版中文目录 `球员` 在 Linux 上编码异常。确认已使用含 `packages/shared/public/players/` 的新代码包,并: diff --git a/docs/docker/build-and-export-images.ps1 b/docs/docker/build-and-export-images.ps1 index 2e6526d..ab7410b 100644 --- a/docs/docker/build-and-export-images.ps1 +++ b/docs/docker/build-and-export-images.ps1 @@ -1,13 +1,15 @@ # 构建 api / player / admin 生产镜像并导出为 tar # 用法(在项目根目录或本目录执行均可): # .\docs\docker\build-and-export-images.ps1 +# .\docs\docker\build-and-export-images.ps1 -Tag v1.2.3 # .\build-and-export-images.ps1 -UseCache # .\build-and-export-images.ps1 -ExportOnly param( + [string]$Tag = $env:IMAGE_TAG, [switch]$UseCache, [switch]$ExportOnly, - [string]$Output = "thebet365-images.tar" + [string]$Output = "" ) $ErrorActionPreference = "Stop" @@ -18,6 +20,21 @@ $ComposeFile = "docker-compose.prod.yml" $EnvFile = ".env.docker" $Services = @("api", "player", "admin") +function Get-DefaultTag { + try { + $short = (& git rev-parse --short HEAD 2>$null).Trim() + if ($short) { return $short } + } catch {} + return Get-Date -Format "yyyyMMdd-HHmmss" +} + +function Assert-ImageTag { + param([string]$Value) + if ($Value -notmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') { + throw "镜像 tag 不合法: $Value" + } +} + if (-not (Test-Path $ComposeFile)) { throw "未找到 $ComposeFile(当前目录: $Root)" } @@ -31,8 +48,16 @@ if (-not (Test-Path $EnvFile)) { } } +if ([string]::IsNullOrWhiteSpace($Tag)) { + $Tag = Get-DefaultTag +} +Assert-ImageTag $Tag +if ([string]::IsNullOrWhiteSpace($Output)) { + $Output = "thebet365-images-$Tag.tar" +} + if (-not $ExportOnly) { - Write-Host "==> 构建镜像: $($Services -join ', ')" + Write-Host "==> 构建镜像: $($Services -join ', ') (tag: $Tag)" $buildArgs = @( "compose", "-f", $ComposeFile, "--env-file", $EnvFile, "build" ) @@ -40,28 +65,72 @@ if (-not $ExportOnly) { $buildArgs += "--no-cache" } $buildArgs += $Services - & docker @buildArgs - if ($LASTEXITCODE -ne 0) { throw "docker build 失败,退出码 $LASTEXITCODE" } + $oldImageTag = $env:IMAGE_TAG + try { + $env:IMAGE_TAG = $Tag + & docker @buildArgs + if ($LASTEXITCODE -ne 0) { throw "docker build 失败,退出码 $LASTEXITCODE" } + } finally { + $env:IMAGE_TAG = $oldImageTag + } } $OutputPath = if ([System.IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $Root $Output } Write-Host "==> 导出镜像 -> $OutputPath" & docker save ` - thebet365-api:latest ` - thebet365-player:latest ` - thebet365-admin:latest ` + "thebet365-api:${Tag}" ` + "thebet365-player:${Tag}" ` + "thebet365-admin:${Tag}" ` -o $OutputPath if ($LASTEXITCODE -ne 0) { throw "docker save 失败,退出码 $LASTEXITCODE" } +$manifestPath = if ($OutputPath.EndsWith(".tar")) { + $OutputPath.Substring(0, $OutputPath.Length - 4) + ".manifest.txt" +} else { + "$OutputPath.manifest.txt" +} + +$gitCommit = "unknown" +$gitDirty = "unknown" +try { + $gitCommit = (& git rev-parse HEAD 2>$null).Trim() + & git diff --quiet + $diffExit = $LASTEXITCODE + & git diff --cached --quiet + $cachedExit = $LASTEXITCODE + $gitDirty = if ($diffExit -eq 0 -and $cachedExit -eq 0) { "false" } else { "true" } +} catch {} + +$tarHash = (Get-FileHash -Algorithm SHA256 $OutputPath).Hash.ToLowerInvariant() +$apiImageId = (& docker image inspect "thebet365-api:${Tag}" --format "{{.Id}}").Trim() +$playerImageId = (& docker image inspect "thebet365-player:${Tag}" --format "{{.Id}}").Trim() +$adminImageId = (& docker image inspect "thebet365-admin:${Tag}" --format "{{.Id}}").Trim() + +@( + "tag=$Tag" + "built_at=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))" + "git_commit=$gitCommit" + "git_dirty=$gitDirty" + "tar=$([System.IO.Path]::GetFileName($OutputPath))" + "tar_sha256=$tarHash" + "api_image=thebet365-api:${Tag}" + "api_image_id=$apiImageId" + "player_image=thebet365-player:${Tag}" + "player_image_id=$playerImageId" + "admin_image=thebet365-admin:${Tag}" + "admin_image_id=$adminImageId" +) | Set-Content -Encoding UTF8 $manifestPath + $sizeMb = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2) Write-Host "完成: $OutputPath (${sizeMb} MB)" +Write-Host "Manifest: $manifestPath" Write-Host @" 服务器首次部署: - ./scripts/deploy-first.sh --images thebet365-images.tar + ./scripts/deploy-first.sh --images $([System.IO.Path]::GetFileName($OutputPath)) --tag $Tag 服务器后续更新: - ./scripts/deploy-update.sh --images thebet365-images.tar + ./scripts/deploy-update.sh --images $([System.IO.Path]::GetFileName($OutputPath)) --tag $Tag "@ diff --git a/docs/docker/build-and-export-images.sh b/docs/docker/build-and-export-images.sh old mode 100644 new mode 100755 index 27b5aa7..78e2338 --- a/docs/docker/build-and-export-images.sh +++ b/docs/docker/build-and-export-images.sh @@ -2,6 +2,7 @@ # 构建 api / player / admin 生产镜像并导出为 tar # 用法: # ./docs/docker/build-and-export-images.sh +# ./docs/docker/build-and-export-images.sh --tag v1.2.3 # ./build-and-export-images.sh --use-cache # ./build-and-export-images.sh --export-only @@ -13,25 +14,44 @@ cd "$ROOT" COMPOSE_FILE="docker-compose.prod.yml" ENV_FILE=".env.docker" -OUTPUT="thebet365-images.tar" +OUTPUT="" SERVICES=(api player admin) NO_CACHE=1 EXPORT_ONLY=0 +TAG="${IMAGE_TAG:-}" + +default_tag() { + if command -v git >/dev/null 2>&1 && git rev-parse --short HEAD >/dev/null 2>&1; then + git rev-parse --short HEAD + else + date +%Y%m%d-%H%M%S + fi +} + +validate_tag() { + printf '%s' "$1" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' || + { echo "错误: 镜像 tag 不合法: $1" >&2; exit 1; } +} usage() { cat <<'EOF' 用法: docs/docker/build-and-export-images.sh [选项] 选项: + --tag TAG 镜像 tag(默认当前 git 短提交号;非 git 目录则用时间戳) --use-cache 构建时使用 Docker 缓存(默认 --no-cache) - --export-only 跳过构建,仅导出已有 latest 镜像 - --output PATH 导出文件路径(默认项目根目录 thebet365-images.tar) + --export-only 跳过构建,仅导出已有指定 tag 镜像 + --output PATH 导出文件路径(默认项目根目录 thebet365-images-.tar) -h, --help 显示帮助 EOF } while [[ $# -gt 0 ]]; do case "$1" in + --tag) + TAG="${2:?缺少 --tag 参数值}" + shift 2 + ;; --use-cache) NO_CACHE=0; shift ;; --export-only) EXPORT_ONLY=1; shift ;; --output) @@ -44,28 +64,32 @@ while [[ $# -gt 0 ]]; do done if [[ ! -f "$COMPOSE_FILE" ]]; then - echo "错误: 未找到 $COMPOSE_FILE(目录: $ROOT)" >&2 + echo "错误: 未找到 ${COMPOSE_FILE}(目录: ${ROOT})" >&2 exit 1 fi if [[ ! -f "$ENV_FILE" ]]; then if [[ -f ".env.docker.example" ]]; then - echo "警告: 未找到 $ENV_FILE,将使用 .env.docker.example(生产部署请复制为 .env.docker 并修改密钥)" + echo "警告: 未找到 ${ENV_FILE},将使用 .env.docker.example(生产部署请复制为 .env.docker 并修改密钥)" ENV_FILE=".env.docker.example" else - echo "错误: 未找到 $ENV_FILE 或 .env.docker.example" >&2 + echo "错误: 未找到 ${ENV_FILE} 或 .env.docker.example" >&2 exit 1 fi fi +TAG="${TAG:-$(default_tag)}" +validate_tag "$TAG" +OUTPUT="${OUTPUT:-thebet365-images-$TAG.tar}" + if [[ "$EXPORT_ONLY" -eq 0 ]]; then - echo "==> 构建镜像: ${SERVICES[*]}" + echo "==> 构建镜像: ${SERVICES[*]} (tag: $TAG)" BUILD_ARGS=(compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build) if [[ "$NO_CACHE" -eq 1 ]]; then BUILD_ARGS+=(--no-cache) fi BUILD_ARGS+=("${SERVICES[@]}") - docker "${BUILD_ARGS[@]}" + IMAGE_TAG="$TAG" docker "${BUILD_ARGS[@]}" fi OUTPUT_PATH="$OUTPUT" @@ -75,23 +99,59 @@ fi echo "==> 导出镜像 -> $OUTPUT_PATH" docker save \ - thebet365-api:latest \ - thebet365-player:latest \ - thebet365-admin:latest \ + "thebet365-api:$TAG" \ + "thebet365-player:$TAG" \ + "thebet365-admin:$TAG" \ -o "$OUTPUT_PATH" +MANIFEST_PATH="${OUTPUT_PATH%.tar}.manifest.txt" +BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +GIT_COMMIT="unknown" +GIT_DIRTY="unknown" +if command -v git >/dev/null 2>&1 && git rev-parse HEAD >/dev/null 2>&1; then + GIT_COMMIT="$(git rev-parse HEAD)" + if git diff --quiet && git diff --cached --quiet; then + GIT_DIRTY="false" + else + GIT_DIRTY="true" + fi +fi + +CHECKSUM="unavailable" +if command -v sha256sum >/dev/null 2>&1; then + CHECKSUM="$(sha256sum "$OUTPUT_PATH" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + CHECKSUM="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" +fi + +{ + echo "tag=$TAG" + echo "built_at=$BUILT_AT" + echo "git_commit=$GIT_COMMIT" + echo "git_dirty=$GIT_DIRTY" + echo "tar=$(basename "$OUTPUT_PATH")" + echo "tar_sha256=$CHECKSUM" + echo "api_image=thebet365-api:$TAG" + echo "api_image_id=$(docker image inspect "thebet365-api:$TAG" --format '{{.Id}}')" + echo "player_image=thebet365-player:$TAG" + echo "player_image_id=$(docker image inspect "thebet365-player:$TAG" --format '{{.Id}}')" + echo "admin_image=thebet365-admin:$TAG" + echo "admin_image_id=$(docker image inspect "thebet365-admin:$TAG" --format '{{.Id}}')" +} > "$MANIFEST_PATH" + if command -v du >/dev/null 2>&1; then SIZE="$(du -h "$OUTPUT_PATH" | awk '{print $1}')" echo "完成: $OUTPUT_PATH ($SIZE)" else echo "完成: $OUTPUT_PATH" fi +echo "Manifest: $MANIFEST_PATH" -cat <<'EOF' +cat <.tar`(默认),并生成同名 `.manifest.txt`。 --- @@ -30,6 +30,7 @@ 生产环境务必在 `.env.docker` 中配置: - `POSTGRES_PASSWORD`、`JWT_SECRET` +- `IMAGE_TAG`、`BIND_ADDR`、`RUN_MIGRATIONS_ON_START` 保持 `.env.docker.example` 默认即可,部署脚本会按 `--tag` 写回真实版本 - `CHUANGLAN_ACCOUNT`、`CHUANGLAN_PASSWORD`(短信注册) - `SEED_DATABASE=false`(生产建议保持 false,由部署脚本按需一次性 seed) @@ -58,9 +59,10 @@ chmod +x docs/docker/build-and-export-images.sh | PowerShell | Bash | 说明 | |------------|------|------| +| `-Tag v1.2.3` | `--tag v1.2.3` | 指定镜像 tag;未指定时默认当前 Git 短提交号 | | (默认) | (默认) | `--no-cache` 全量构建,适合发版 | | `-UseCache` | `--use-cache` | 使用 Docker 缓存,构建更快 | -| `-ExportOnly` | `--export-only` | 跳过构建,仅导出已有 `latest` 镜像 | +| `-ExportOnly` | `--export-only` | 跳过构建,仅导出已有指定 tag 镜像 | | `-Output my.tar` | `--output my.tar` | 自定义导出文件名 | 示例:仅重新导出已有镜像 @@ -79,17 +81,18 @@ chmod +x docs/docker/build-and-export-images.sh | 镜像名 | 说明 | |--------|------| -| `thebet365-api:latest` | NestJS API(含 Prisma 迁移入口) | -| `thebet365-player:latest` | 玩家前台(Nginx 静态资源) | -| `thebet365-admin:latest` | 管理后台(Nginx 静态资源) | +| `thebet365-api:` | NestJS API(迁移由部署脚本执行) | +| `thebet365-player:` | 玩家前台(Nginx 静态资源) | +| `thebet365-admin:` | 管理后台(Nginx 静态资源) | 导出文件默认路径: ```text -<项目根目录>/thebet365-images.tar +<项目根目录>/thebet365-images-.tar +<项目根目录>/thebet365-images-.manifest.txt ``` -该文件已加入 `.gitignore`,**请勿提交到 Git**。 +这些文件已加入 `.gitignore`,**请勿提交到 Git**。manifest 会记录 tag、构建时间、Git commit、镜像 ID 和 tar 的 SHA-256,便于服务器核对发布包。 --- @@ -99,7 +102,8 @@ chmod +x docs/docker/build-and-export-images.sh 将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`): -- `thebet365-images.tar` +- `thebet365-images-.tar` +- `thebet365-images-.manifest.txt` - `docker-compose.prod.yml` - `.env.docker`(或服务器上已有配置) - `docker/nginx/` 等 compose 依赖目录(若仅 load 镜像、不 rebuild,compose 文件仍需要) @@ -111,16 +115,16 @@ chmod +x docs/docker/build-and-export-images.sh ```bash cd /www/wwwroot/thebet365 chmod +x scripts/*.sh -./scripts/deploy-first.sh --images thebet365-images.tar +./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3 ``` 后续更新同一个服务器时: ```bash -./scripts/deploy-update.sh --images thebet365-images.tar +./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3 ``` -更新脚本会先备份数据库,再用新 API 镜像执行 `prisma migrate deploy`,最后替换运行中的容器。 +更新脚本会先备份数据库与 uploads,再用新 API 镜像执行 `prisma migrate deploy`,最后替换运行中的容器并等待健康检查通过。 ### 3. 验证 @@ -131,8 +135,8 @@ docker logs thebet365-api --tail 50 浏览器访问(端口以 `.env.docker` 为准): -- 玩家端:`http://服务器IP:8082` -- 管理端:`http://服务器IP:8081` +- 玩家端:经宝塔反代访问,或服务器本机 `http://127.0.0.1:8082` +- 管理端:经宝塔反代访问,或服务器本机 `http://127.0.0.1:8081` --- @@ -140,10 +144,10 @@ docker logs thebet365-api --tail 50 | 方式 | 优点 | 缺点 | |------|------|------| -| **本地 build + 导出 tar** | 不占用服务器 CPU/内存;可重复部署同一包 | 需上传较大 tar(约 200–300 MB) | +| **本地 build + 导出 tar** | 不占用服务器 CPU/内存;有 tag 与 manifest,可重复部署同一包 | 需上传较大 tar(约 200–300 MB) | | **服务器 `docker compose build`** | 无需传 tar | 首次/全量构建慢,小内存机器易失败 | -发版推荐流程:**本地或构建机执行脚本 → 上传 tar → 服务器执行 `deploy-update.sh --images thebet365-images.tar`**。 +发版推荐流程:**本地或构建机执行脚本 → 上传 tar + manifest → 服务器执行 `deploy-update.sh --images thebet365-images-.tar --tag `**。 --- @@ -162,9 +166,9 @@ find packages/shared/public -mindepth 1 -maxdepth 1 -type d \ ! -name flags ! -name players -exec rm -rf {} + ``` -### 3. `docker load` 后 `up -d` 仍拉取或重建镜像 +### 3. `docker load` 后部署仍找不到镜像 -确保 compose 中 `image` 与 load 的 tag 一致(`thebet365-api:latest` 等),且使用同一 `docker-compose.prod.yml`。 +确保上传的 tar 中包含 `thebet365-api:`、`thebet365-player:`、`thebet365-admin:`,并且服务器执行部署时传入同一个 `--tag `。 ### 4. API 启动后不断重启 @@ -182,7 +186,8 @@ docker logs thebet365-api thebet365/ ├── docker-compose.prod.yml ├── .env.docker.example -├── thebet365-images.tar # 导出产物(默认,已 gitignore) +├── thebet365-images-.tar # 导出产物(默认,已 gitignore) +├── thebet365-images-.manifest.txt ├── docker/ │ ├── api/Dockerfile │ ├── player/Dockerfile diff --git a/docs/项目启动指南.md b/docs/项目启动指南.md index c26faa9..1262475 100644 --- a/docs/项目启动指南.md +++ b/docs/项目启动指南.md @@ -387,7 +387,8 @@ cd /www/wwwroot/thebet365 ./scripts/deploy-update.sh --pull ``` -- 更新脚本会先备份数据库,再构建新镜像、执行 Prisma 迁移并替换容器 +- 更新脚本会先备份数据库与 uploads,再构建新镜像、执行 Prisma 迁移、替换容器并等待健康检查 +- 使用离线镜像包时推荐 `./scripts/deploy-update.sh --images thebet365-images-.tar --tag `,发布状态会记录到 `.deploy/current-release.env` - **不要**把 `.env.docker` 提交到 Git;服务器上单独保留 - `release/*.zip` 为旧打包方式,Git 同步后不必再生成上传 diff --git a/docs/默认数据说明.md b/docs/默认数据说明.md index 456aeee..4d3f1dd 100644 --- a/docs/默认数据说明.md +++ b/docs/默认数据说明.md @@ -192,7 +192,7 @@ pnpm db:seed CONFIRM=YES ./scripts/prod-init-db.sh ``` -该脚本会先备份 PostgreSQL,再执行 Prisma 迁移,然后以 production 模式清空业务表并写入 `admin`、WC2026 小组赛 72 场和 48 强优胜盘。 +该脚本会先备份 PostgreSQL 到 `backups/*.sql.gz` 并生成 `.sha256`,再执行 Prisma 迁移,然后以 production 模式清空业务表并写入 `admin`、WC2026 小组赛 72 场和 48 强优胜盘。 --- diff --git a/scripts/backup-db.ps1 b/scripts/backup-db.ps1 index d231ae6..ca4ae2a 100644 --- a/scripts/backup-db.ps1 +++ b/scripts/backup-db.ps1 @@ -11,7 +11,8 @@ $ErrorActionPreference = "Stop" New-Item -ItemType Directory -Force -Path $OutDir | Out-Null $stamp = Get-Date -Format "yyyyMMdd-HHmmss" -$outFile = Join-Path $OutDir "thebet365-$stamp.sql" +$tempFile = Join-Path $OutDir "thebet365-db-manual-$stamp.sql" +$outFile = "$tempFile.gz" Write-Host "Backing up $DbName to $outFile ..." $env:PGPASSWORD = $env:THEBET365_DB_PASSWORD @@ -19,5 +20,24 @@ if (-not $env:PGPASSWORD) { Write-Warning "Set THEBET365_DB_PASSWORD if your Postgres requires a password." } -pg_dump -h $DbHost -p $DbPort -U $DbUser -d $DbName -F p -f $outFile +pg_dump -h $DbHost -p $DbPort -U $DbUser -d $DbName -F p -f $tempFile + +$inputStream = [System.IO.File]::OpenRead($tempFile) +$outputStream = [System.IO.File]::Create($outFile) +try { + $gzipStream = [System.IO.Compression.GzipStream]::new($outputStream, [System.IO.Compression.CompressionMode]::Compress) + try { + $inputStream.CopyTo($gzipStream) + } finally { + $gzipStream.Dispose() + } +} finally { + $inputStream.Dispose() + $outputStream.Dispose() +} +Remove-Item $tempFile -Force + +$hash = (Get-FileHash -Algorithm SHA256 $outFile).Hash.ToLowerInvariant() +"$hash $(Split-Path -Leaf $outFile)" | Set-Content -Encoding UTF8 "$outFile.sha256" Write-Host "Done: $outFile" +Write-Host "Checksum: $outFile.sha256" diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh index c38fdfd..e50df81 100755 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 备份生产 Docker PostgreSQL 到 ./backups +# 备份生产 Docker PostgreSQL 到 ./backups,输出 gzip 与 sha256 校验文件 set -euo pipefail @@ -44,3 +44,4 @@ require_docker ensure_env_file || exit 1 start_infra backup_database "$PREFIX" +prune_old_backups diff --git a/scripts/backup-prod.sh b/scripts/backup-prod.sh new file mode 100755 index 0000000..5f2882c --- /dev/null +++ b/scripts/backup-prod.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# 完整生产备份:PostgreSQL + uploads 卷,输出 gzip/tar.gz 与 sha256 校验文件。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/deploy-lib.sh +source "$SCRIPT_DIR/deploy-lib.sh" + +PREFIX="manual" +SKIP_UPLOADS=false + +usage() { + cat <<'EOF' +用法: scripts/backup-prod.sh [选项] + +默认流程: + 1. 检查 .env.docker + 2. 启动并等待 PostgreSQL / Redis + 3. 备份 PostgreSQL 到 ./backups/*.sql.gz + 4. 通过正在运行的 api 容器备份 uploads 到 ./backups/*.tar.gz + 5. 为备份文件生成 .sha256 校验文件 + +选项: + --prefix NAME 备份文件名前缀,默认 manual + --skip-uploads 只备份数据库,不备份 uploads + -h, --help 显示帮助 + +示例: + ./scripts/backup-prod.sh + ./scripts/backup-prod.sh --prefix pre-release + ./scripts/backup-prod.sh --skip-uploads +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) + PREFIX="${2:?缺少 --prefix 参数值}" + shift 2 + ;; + --skip-uploads) + SKIP_UPLOADS=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "未知参数: $1" + ;; + esac +done + +cd "$ROOT" +require_docker +ensure_env_file || exit 1 +start_infra +backup_database "$PREFIX" + +if [ "$SKIP_UPLOADS" = false ]; then + backup_uploads "$PREFIX" +else + warn "已跳过 uploads 备份" +fi + +prune_old_backups +log "生产备份完成" diff --git a/scripts/deploy-first.sh b/scripts/deploy-first.sh index ad047de..25ca764 100755 --- a/scripts/deploy-first.sh +++ b/scripts/deploy-first.sh @@ -29,6 +29,7 @@ usage() { 选项: --no-build 不构建镜像,直接使用服务器已有镜像 --images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建 + --tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG --no-seed 不执行生产 seed --init-db 调用 scripts/prod-init-db.sh 清空业务数据并初始化生产数据,仅限全新库或明确重置 --skip-backup 与 --init-db 一起使用,跳过 prod-init-db 的备份 @@ -37,7 +38,7 @@ usage() { 示例: ./scripts/deploy-first.sh - ./scripts/deploy-first.sh --images thebet365-images.tar + ./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3 ./scripts/deploy-first.sh --no-build ./scripts/deploy-first.sh --init-db EOF @@ -54,6 +55,10 @@ while [ $# -gt 0 ]; do NO_BUILD=true shift 2 ;; + --tag) + set_deploy_image_tag "${2:?缺少 --tag 参数值}" + shift 2 + ;; --no-seed) RUN_SEED=false shift @@ -86,6 +91,14 @@ require_docker ensure_env_file || exit 1 validate_prod_env "$ALLOW_DEFAULT_SECRETS" +if [ -n "$IMAGE_TAR" ] && [ -z "${DEPLOY_IMAGE_TAG:-}" ]; then + inferred_tag="$(infer_image_tag_from_tar "$IMAGE_TAR")" + if [ -n "$inferred_tag" ]; then + set_deploy_image_tag "$inferred_tag" + log "从镜像包文件名推断 tag: $inferred_tag" + fi +fi + if [ -n "$IMAGE_TAR" ]; then load_image_tar "$IMAGE_TAR" fi @@ -96,6 +109,7 @@ if [ "$NO_BUILD" = false ]; then build_app_images else log "跳过镜像构建,使用服务器已有镜像" + require_images_for_current_tag fi run_prisma_migrations @@ -106,7 +120,7 @@ fi log "启动 api / player / admin" compose up -d api player admin -wait_for_service_running api 120 +wait_for_stack_ready if [ "$INIT_DB" = true ]; then init_args=() @@ -116,9 +130,11 @@ if [ "$INIT_DB" = true ]; then log "执行生产初始化:这会清空业务数据" CONFIRM=YES "$ROOT/scripts/prod-init-db.sh" "${init_args[@]}" compose restart api - wait_for_service_running api 120 + wait_for_service_health api 180 fi show_prisma_status compose ps +persist_image_tag +record_release_state "first-deploy" print_stack_urls diff --git a/scripts/deploy-lib.sh b/scripts/deploy-lib.sh index bf09d83..44d238a 100755 --- a/scripts/deploy-lib.sh +++ b/scripts/deploy-lib.sh @@ -6,6 +6,14 @@ COMPOSE_FILE="$ROOT/docker-compose.prod.yml" ENV_FILE="$ROOT/.env.docker" ENV_EXAMPLE_FILE="$ROOT/.env.docker.example" BACKUP_DIR="$ROOT/backups" +DEPLOY_STATE_DIR="$ROOT/.deploy" +RELEASES_DIR="$DEPLOY_STATE_DIR/releases" +CURRENT_RELEASE_FILE="$DEPLOY_STATE_DIR/current-release.env" +PREVIOUS_RELEASE_FILE="$DEPLOY_STATE_DIR/previous-release.env" + +DEPLOY_IMAGE_TAG="${DEPLOY_IMAGE_TAG:-}" +LAST_DB_BACKUP="" +LAST_UPLOADS_BACKUP="" log() { printf '%s\n' "[$(basename "$0")] $*" @@ -29,8 +37,36 @@ require_docker() { docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2,请确认可执行 docker compose" } +validate_image_tag() { + local tag="$1" + printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' || + die "镜像 tag 不合法: $tag" +} + +set_deploy_image_tag() { + local tag="$1" + [ -n "$tag" ] || die "镜像 tag 不能为空" + validate_image_tag "$tag" + DEPLOY_IMAGE_TAG="$tag" +} + +infer_image_tag_from_tar() { + local image_tar="$1" + local base tag + + base="$(basename "$image_tar")" + tag="${base#thebet365-images-}" + tag="${tag%.tar}" + + if [ "$tag" != "$base" ] && [ -n "$tag" ]; then + printf '%s' "$tag" + fi +} + compose() { - docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" + local tag + tag="$(current_image_tag)" + IMAGE_TAG="$tag" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" } ensure_env_file() { @@ -61,13 +97,61 @@ env_value() { printf '%s' "$line" } +set_env_value() { + local key="$1" + local value="$2" + local tmp + + tmp="$(mktemp)" + if grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then + awk -v key="$key" -v value="$value" ' + BEGIN { done = 0 } + $0 ~ "^[[:space:]]*" key "=" { + if (!done) { + print key "=" value + done = 1 + } + next + } + { print } + END { + if (!done) print key "=" value + } + ' "$ENV_FILE" > "$tmp" + else + cp "$ENV_FILE" "$tmp" + printf '\n%s=%s\n' "$key" "$value" >> "$tmp" + fi + mv "$tmp" "$ENV_FILE" +} + +current_image_tag() { + local tag="${DEPLOY_IMAGE_TAG:-}" + + if [ -z "$tag" ] && [ -f "$ENV_FILE" ]; then + tag="$(env_value IMAGE_TAG)" + fi + + tag="${tag:-latest}" + validate_image_tag "$tag" + printf '%s' "$tag" +} + +persist_image_tag() { + local tag + tag="$(current_image_tag)" + set_env_value IMAGE_TAG "$tag" + log "当前发布镜像 tag: $tag" +} + validate_prod_env() { local allow_defaults="${1:-false}" - local postgres_password jwt_secret seed_database + local postgres_password jwt_secret seed_database bind_addr postgres_password="$(env_value POSTGRES_PASSWORD)" jwt_secret="$(env_value JWT_SECRET)" seed_database="$(env_value SEED_DATABASE)" + bind_addr="$(env_value BIND_ADDR)" [ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD" [ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET" @@ -81,6 +165,10 @@ validate_prod_env() { warn ".env.docker 中 SEED_DATABASE=true 会让 api 每次启动都执行 seed;生产建议设为 false,首次部署脚本会按需一次性 seed" fi + if [ "${bind_addr:-127.0.0.1}" = "0.0.0.0" ]; then + warn "BIND_ADDR=0.0.0.0 会让 player/admin 端口直接对外监听;生产建议经宝塔反代访问" + fi + if [ "$(env_value CHUANGLAN_ACCOUNT)" = "your_account" ] || [ "$(env_value CHUANGLAN_PASSWORD)" = "your_password" ]; then warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置" fi @@ -103,35 +191,17 @@ wait_for_service_health() { now="$(date +%s)" if [ $((now - start)) -ge "$timeout" ]; then - compose logs --tail=80 "$service" || true + compose logs --tail=120 "$service" || true die "$service 在 ${timeout}s 内未就绪" fi sleep 2 done } -wait_for_service_running() { - local service="$1" - local timeout="${2:-120}" - local start now container_id running - - start="$(date +%s)" - while true; do - container_id="$(compose ps -q "$service" 2>/dev/null || true)" - if [ -n "$container_id" ]; then - running="$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || true)" - if [ "$running" = "true" ]; then - return 0 - fi - fi - - now="$(date +%s)" - if [ $((now - start)) -ge "$timeout" ]; then - compose logs --tail=120 "$service" || true - die "$service 在 ${timeout}s 内未保持运行" - fi - sleep 2 - done +wait_for_stack_ready() { + wait_for_service_health api 180 + wait_for_service_health player 120 + wait_for_service_health admin 120 } start_infra() { @@ -142,7 +212,7 @@ start_infra() { } build_app_images() { - log "构建 api / player / admin 镜像" + log "构建 api / player / admin 镜像: $(current_image_tag)" compose build api player admin } @@ -153,6 +223,14 @@ load_image_tar() { docker load -i "$image_tar" } +require_images_for_current_tag() { + local tag + tag="$(current_image_tag)" + docker image inspect "thebet365-api:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-api:$tag" + docker image inspect "thebet365-player:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-player:$tag" + docker image inspect "thebet365-admin:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-admin:$tag" +} + run_prisma_migrations() { log "执行 Prisma 迁移" compose run --rm --no-deps --entrypoint sh api -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate' @@ -163,19 +241,81 @@ show_prisma_status() { compose exec -T api sh -c 'cd /app/apps/api && npx prisma migrate status' } +safe_backup_prefix() { + local prefix="${1:-manual}" + prefix="$(printf '%s' "$prefix" | tr -c 'A-Za-z0-9_.-' '-')" + prefix="${prefix:-manual}" + printf '%s' "$prefix" +} + +write_checksum() { + local file="$1" + local dir base + + dir="$(dirname "$file")" + base="$(basename "$file")" + if command -v sha256sum >/dev/null 2>&1; then + (cd "$dir" && sha256sum "$base" > "$base.sha256") + elif command -v shasum >/dev/null 2>&1; then + (cd "$dir" && shasum -a 256 "$base" > "$base.sha256") + else + warn "未找到 sha256sum 或 shasum,跳过校验文件: $file" + return 0 + fi + log "校验文件完成: $file.sha256" +} + backup_database() { local prefix="${1:-manual}" local stamp backup_file + require_command gzip mkdir -p "$BACKUP_DIR" + prefix="$(safe_backup_prefix "$prefix")" stamp="$(date +%Y%m%d-%H%M%S)" - backup_file="$BACKUP_DIR/thebet365-${prefix}-${stamp}.sql" + backup_file="$BACKUP_DIR/thebet365-db-${prefix}-${stamp}.sql.gz" log "备份 PostgreSQL -> $backup_file" - compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$backup_file" + compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$backup_file" + write_checksum "$backup_file" + LAST_DB_BACKUP="$backup_file" log "数据库备份完成: $backup_file" } +backup_uploads() { + local prefix="${1:-manual}" + local stamp backup_file + + mkdir -p "$BACKUP_DIR" + prefix="$(safe_backup_prefix "$prefix")" + stamp="$(date +%Y%m%d-%H%M%S)" + backup_file="$BACKUP_DIR/thebet365-uploads-${prefix}-${stamp}.tar.gz" + + log "备份 uploads 卷 -> $backup_file" + compose exec -T api sh -c 'cd /app && mkdir -p uploads && tar -czf - uploads' > "$backup_file" + write_checksum "$backup_file" + LAST_UPLOADS_BACKUP="$backup_file" + log "uploads 备份完成: $backup_file" +} + +prune_old_backups() { + local days + days="$(env_value BACKUP_RETENTION_DAYS)" + [ -n "$days" ] || return 0 + + case "$days" in + *[!0-9]*) + warn "BACKUP_RETENTION_DAYS 不是数字,跳过自动清理: $days" + return 0 + ;; + esac + + [ "$days" -gt 0 ] || return 0 + [ -d "$BACKUP_DIR" ] || return 0 + log "清理 ${days} 天前的备份" + find "$BACKUP_DIR" -type f \( -name '*.sql.gz' -o -name '*.tar.gz' -o -name '*.sha256' \) -mtime +"$days" -delete +} + admin_user_count() { compose exec -T postgres psql -U thebet365 -d thebet365 -tAc "select count(*) from users where username = 'admin';" 2>/dev/null | tr -d '[:space:]' } @@ -193,17 +333,59 @@ seed_production_if_missing_admin() { fi } +current_release_tag() { + if [ -f "$CURRENT_RELEASE_FILE" ]; then + grep -E '^tag=' "$CURRENT_RELEASE_FILE" | tail -n 1 | cut -d= -f2- || true + fi +} + +record_release_state() { + local source="${1:-deploy}" + local tag stamp safe_tag manifest previous_tag + + tag="$(current_image_tag)" + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + safe_tag="$(printf '%s' "$tag" | tr -c 'A-Za-z0-9_.-' '-')" + manifest="$RELEASES_DIR/${stamp}-${safe_tag}.env" + previous_tag="$(current_release_tag)" + + mkdir -p "$RELEASES_DIR" + if [ -f "$CURRENT_RELEASE_FILE" ]; then + cp "$CURRENT_RELEASE_FILE" "$PREVIOUS_RELEASE_FILE" + fi + + { + printf 'tag=%s\n' "$tag" + printf 'previous_tag=%s\n' "$previous_tag" + printf 'deployed_at=%s\n' "$stamp" + printf 'source=%s\n' "$source" + printf 'api_image=thebet365-api:%s\n' "$tag" + printf 'player_image=thebet365-player:%s\n' "$tag" + printf 'admin_image=thebet365-admin:%s\n' "$tag" + [ -n "$LAST_DB_BACKUP" ] && printf 'db_backup=%s\n' "$LAST_DB_BACKUP" + [ -n "$LAST_UPLOADS_BACKUP" ] && printf 'uploads_backup=%s\n' "$LAST_UPLOADS_BACKUP" + } > "$manifest" + + cp "$manifest" "$CURRENT_RELEASE_FILE" + log "发布状态已记录: $manifest" +} + print_stack_urls() { - local player_port admin_port + local player_port admin_port bind_addr tag player_port="$(env_value PLAYER_PORT)" admin_port="$(env_value ADMIN_PORT)" + bind_addr="$(env_value BIND_ADDR)" + tag="$(current_image_tag)" player_port="${player_port:-8082}" admin_port="${admin_port:-8081}" + bind_addr="${bind_addr:-127.0.0.1}" printf '\n' printf '%s\n' "部署完成:" - printf '%s\n' " 玩家端: http://服务器IP:${player_port}" - printf '%s\n' " 管理端: http://服务器IP:${admin_port}" + printf '%s\n' " 镜像 tag: ${tag}" + printf '%s\n' " 玩家端: http://${bind_addr}:${player_port}" + printf '%s\n' " 管理端: http://${bind_addr}:${admin_port}" printf '%s\n' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问" printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps" + printf '%s\n' " 当前发布: .deploy/current-release.env" } diff --git a/scripts/deploy-update.sh b/scripts/deploy-update.sh index 3c779eb..16b7e42 100755 --- a/scripts/deploy-update.sh +++ b/scripts/deploy-update.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 后续更新:可选拉代码/加载镜像,备份数据库,构建或使用新镜像,执行迁移并滚动到新容器。 +# 后续更新:可选拉代码/加载镜像,备份数据,构建或使用新镜像,执行迁移并滚动到新容器。 set -euo pipefail @@ -20,7 +20,7 @@ usage() { 默认流程: 1. 检查 .env.docker 2. 启动并等待 postgres / redis - 3. 备份 PostgreSQL 到 ./backups + 3. 备份 PostgreSQL 与 uploads 到 ./backups 4. 构建 api / player / admin 镜像 5. 使用新 api 镜像执行 prisma migrate deploy 6. 启动/替换 api、player、admin 容器 @@ -29,14 +29,15 @@ usage() { 选项: --pull 先执行 git pull --ff-only --images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建 + --tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG --no-build 不构建镜像,直接使用服务器已有镜像 - --no-backup 跳过数据库备份 + --no-backup 跳过 PostgreSQL 与 uploads 备份 --allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用 -h, --help 显示帮助 示例: ./scripts/deploy-update.sh --pull - ./scripts/deploy-update.sh --images thebet365-images.tar + ./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3 ./scripts/deploy-update.sh --no-build EOF } @@ -52,6 +53,10 @@ while [ $# -gt 0 ]; do NO_BUILD=true shift 2 ;; + --tag) + set_deploy_image_tag "${2:?缺少 --tag 参数值}" + shift 2 + ;; --no-build) NO_BUILD=true shift @@ -79,6 +84,14 @@ require_docker ensure_env_file || exit 1 validate_prod_env "$ALLOW_DEFAULT_SECRETS" +if [ -n "$IMAGE_TAR" ] && [ -z "${DEPLOY_IMAGE_TAG:-}" ]; then + inferred_tag="$(infer_image_tag_from_tar "$IMAGE_TAR")" + if [ -n "$inferred_tag" ]; then + set_deploy_image_tag "$inferred_tag" + log "从镜像包文件名推断 tag: $inferred_tag" + fi +fi + if [ "$PULL_CODE" = true ]; then require_command git log "拉取代码: git pull --ff-only" @@ -93,21 +106,26 @@ start_infra if [ "$SKIP_BACKUP" = false ]; then backup_database "pre-update" + backup_uploads "pre-update" + prune_old_backups else - warn "已跳过数据库备份" + warn "已跳过 PostgreSQL 与 uploads 备份" fi if [ "$NO_BUILD" = false ]; then build_app_images else log "跳过镜像构建,使用服务器已有镜像" + require_images_for_current_tag fi run_prisma_migrations log "启动/更新 api / player / admin" compose up -d api player admin -wait_for_service_running api 120 +wait_for_stack_ready show_prisma_status compose ps +persist_image_tag +record_release_state "update" print_stack_urls diff --git a/scripts/prod-init-db.ps1 b/scripts/prod-init-db.ps1 index 6f832b0..601f0f7 100644 --- a/scripts/prod-init-db.ps1 +++ b/scripts/prod-init-db.ps1 @@ -38,9 +38,28 @@ if (-not $SkipBackup) { $backupDir = Join-Path $Root "backups" New-Item -ItemType Directory -Force -Path $backupDir | Out-Null $stamp = Get-Date -Format "yyyyMMdd-HHmmss" - $backupFile = Join-Path $backupDir "thebet365-$stamp.sql" + $tempFile = Join-Path $backupDir "thebet365-db-prod-init-$stamp.sql" + $backupFile = "$tempFile.gz" Write-Host "[prod-init-db] 备份 → $backupFile" - docker compose @composeArgs exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | Set-Content -Encoding utf8 $backupFile + docker compose @composeArgs exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | Set-Content -Encoding utf8 $tempFile + + $inputStream = [System.IO.File]::OpenRead($tempFile) + $outputStream = [System.IO.File]::Create($backupFile) + try { + $gzipStream = [System.IO.Compression.GzipStream]::new($outputStream, [System.IO.Compression.CompressionMode]::Compress) + try { + $inputStream.CopyTo($gzipStream) + } finally { + $gzipStream.Dispose() + } + } finally { + $inputStream.Dispose() + $outputStream.Dispose() + } + Remove-Item $tempFile -Force + + $hash = (Get-FileHash -Algorithm SHA256 $backupFile).Hash.ToLowerInvariant() + "$hash $(Split-Path -Leaf $backupFile)" | Set-Content -Encoding UTF8 "$backupFile.sha256" } else { Write-Host "[prod-init-db] 已跳过备份" } diff --git a/scripts/prod-init-db.sh b/scripts/prod-init-db.sh index ba0084a..0c007bb 100755 --- a/scripts/prod-init-db.sh +++ b/scripts/prod-init-db.sh @@ -49,9 +49,16 @@ if [ "$SKIP_BACKUP" = false ]; then BACKUP_DIR="$ROOT/backups" mkdir -p "$BACKUP_DIR" STAMP="$(date +%Y%m%d-%H%M%S)" - BACKUP_FILE="$BACKUP_DIR/thebet365-$STAMP.sql" + BACKUP_FILE="$BACKUP_DIR/thebet365-db-prod-init-$STAMP.sql.gz" echo "[prod-init-db] 备份 PostgreSQL → $BACKUP_FILE" - "${COMPOSE[@]}" exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$BACKUP_FILE" + "${COMPOSE[@]}" exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$BACKUP_FILE" + if command -v sha256sum >/dev/null 2>&1; then + (cd "$BACKUP_DIR" && sha256sum "$(basename "$BACKUP_FILE")" > "$(basename "$BACKUP_FILE").sha256") + elif command -v shasum >/dev/null 2>&1; then + (cd "$BACKUP_DIR" && shasum -a 256 "$(basename "$BACKUP_FILE")" > "$(basename "$BACKUP_FILE").sha256") + else + echo "[prod-init-db] 警告:未找到 sha256sum 或 shasum,跳过校验文件" + fi echo "[prod-init-db] 备份完成" else echo "[prod-init-db] 已跳过备份 (--skip-backup)" diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..17a536f --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# 只回滚 api / player / admin 镜像 tag,不自动恢复数据库。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/deploy-lib.sh +source "$SCRIPT_DIR/deploy-lib.sh" + +TARGET_TAG="" +SKIP_BACKUP=false +ALLOW_DEFAULT_SECRETS=false + +usage() { + cat <<'EOF' +用法: scripts/rollback.sh --to TAG [选项] + +默认流程: + 1. 检查 .env.docker + 2. 确认本机存在 thebet365-api/player/admin:TAG + 3. 备份 PostgreSQL 与 uploads 到 ./backups + 4. 将 api / player / admin 切回指定 TAG + 5. 等待健康检查并记录发布状态 + +注意: + 本脚本只回滚镜像,不回滚数据库结构或数据。 + 如果目标版本涉及不可逆迁移,必须先按备份文件手工恢复数据库。 + +选项: + --to TAG 目标镜像 tag,必填 + --no-backup 跳过 PostgreSQL 与 uploads 备份 + --allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用 + -h, --help 显示帮助 + +示例: + ./scripts/rollback.sh --to v1.2.2 +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --to) + TARGET_TAG="${2:?缺少 --to 参数值}" + shift 2 + ;; + --no-backup) + SKIP_BACKUP=true + shift + ;; + --allow-default-secrets) + ALLOW_DEFAULT_SECRETS=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "未知参数: $1" + ;; + esac +done + +[ -n "$TARGET_TAG" ] || die "缺少 --to TAG" +set_deploy_image_tag "$TARGET_TAG" + +cd "$ROOT" +require_docker +ensure_env_file || exit 1 +validate_prod_env "$ALLOW_DEFAULT_SECRETS" +require_images_for_current_tag + +start_infra + +if [ "$SKIP_BACKUP" = false ]; then + backup_database "pre-rollback" + backup_uploads "pre-rollback" + prune_old_backups +else + warn "已跳过 PostgreSQL 与 uploads 备份" +fi + +log "回滚 api / player / admin 到 tag: $(current_image_tag)" +compose up -d api player admin +wait_for_stack_ready +show_prisma_status +compose ps +persist_image_tag +record_release_state "rollback" +warn "镜像已回滚;数据库未自动恢复。若此次回滚涉及不可逆迁移,请按备份文件手工恢复数据库。" +print_stack_urls