部署优化

This commit is contained in:
wchino
2026-06-13 22:16:14 +08:00
parent 21dd9957f0
commit 73a94e6be3
28 changed files with 899 additions and 129 deletions

View File

@@ -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

View File

@@ -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

3
.gitignore vendored
View File

@@ -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

View File

@@ -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 }],
})

View File

@@ -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,
);
});
});

View File

@@ -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<boolean> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
private async checkRedis(): Promise<boolean> {
try {
return (await this.redis.raw.ping()) === 'PONG';
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -95,6 +95,7 @@ function selectTab(tab: SlipMode) {
}
function closeDrawer() {
slip.closeDrawer();
show.value = false;
error.value = '';
success.value = '';
@@ -305,7 +306,7 @@ watch(
<p class="drawer-kicker">{{ t('bet.bet_slip') }}</p>
<h3>{{ t('bet.slip_review_title') }}</h3>
</div>
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="closeDrawer">
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click.stop="closeDrawer">
</button>
</div>
@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -3,12 +3,16 @@ set -e
cd /app/apps/api
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..."

View File

@@ -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

View File

@@ -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-<tag>.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/` 的新代码包,并:

View File

@@ -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
$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
"@

88
docs/docker/build-and-export-images.sh Normal file → Executable file
View File

@@ -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-<tag>.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 <<EOF
服务器首次部署:
./scripts/deploy-first.sh --images thebet365-images.tar
./scripts/deploy-first.sh --images $(basename "$OUTPUT_PATH") --tag $TAG
服务器后续更新:
./scripts/deploy-update.sh --images thebet365-images.tar
./scripts/deploy-update.sh --images $(basename "$OUTPUT_PATH") --tag $TAG
EOF

View File

@@ -15,7 +15,7 @@
| `docs/docker/build-and-export-images.ps1` | WindowsPowerShell |
| `docs/docker/build-and-export-images.sh` | Linux / macOS / Git Bash |
两个脚本行为一致:在**项目根目录**执行 compose 构建 → 导出为根目录下的 `thebet365-images.tar`(默认)。
两个脚本行为一致:在**项目根目录**执行 compose 构建 → 导出为根目录下的 `thebet365-images-<tag>.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:<tag>` | NestJS API迁移由部署脚本执行 |
| `thebet365-player:<tag>` | 玩家前台Nginx 静态资源) |
| `thebet365-admin:<tag>` | 管理后台Nginx 静态资源) |
导出文件默认路径:
```text
<项目根目录>/thebet365-images.tar
<项目根目录>/thebet365-images-<tag>.tar
<项目根目录>/thebet365-images-<tag>.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-<tag>.tar`
- `thebet365-images-<tag>.manifest.txt`
- `docker-compose.prod.yml`
- `.env.docker`(或服务器上已有配置)
- `docker/nginx/` 等 compose 依赖目录(若仅 load 镜像、不 rebuildcompose 文件仍需要)
@@ -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约 200300 MB |
| **本地 build + 导出 tar** | 不占用服务器 CPU/内存;有 tag 与 manifest可重复部署同一包 | 需上传较大 tar约 200300 MB |
| **服务器 `docker compose build`** | 无需传 tar | 首次/全量构建慢,小内存机器易失败 |
发版推荐流程:**本地或构建机执行脚本 → 上传 tar → 服务器执行 `deploy-update.sh --images thebet365-images.tar`**。
发版推荐流程:**本地或构建机执行脚本 → 上传 tar + manifest → 服务器执行 `deploy-update.sh --images thebet365-images-<tag>.tar --tag <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:<tag>``thebet365-player:<tag>``thebet365-admin:<tag>`,并且服务器执行部署时传入同一 `--tag <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-<tag>.tar # 导出产物(默认,已 gitignore
├── thebet365-images-<tag>.manifest.txt
├── docker/
│ ├── api/Dockerfile
│ ├── player/Dockerfile

View File

@@ -387,7 +387,8 @@ cd /www/wwwroot/thebet365
./scripts/deploy-update.sh --pull
```
- 更新脚本会先备份数据库,再构建新镜像、执行 Prisma 迁移替换容器
- 更新脚本会先备份数据库与 uploads,再构建新镜像、执行 Prisma 迁移替换容器并等待健康检查
- 使用离线镜像包时推荐 `./scripts/deploy-update.sh --images thebet365-images-<tag>.tar --tag <tag>`,发布状态会记录到 `.deploy/current-release.env`
- **不要**把 `.env.docker` 提交到 Git服务器上单独保留
- `release/*.zip` 为旧打包方式Git 同步后不必再生成上传

View File

@@ -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 强优胜盘。
---

View File

@@ -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"

View File

@@ -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

69
scripts/backup-prod.sh Executable file
View File

@@ -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 "生产备份完成"

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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] 已跳过备份"
}

View File

@@ -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)"

91
scripts/rollback.sh Executable file
View File

@@ -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