feat: complete V1 phases 0-7
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
param([Parameter(Mandatory=$true)][string]$OutputDirectory,[int]$RetentionDays=30)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $env:DATABASE_URL){throw 'DATABASE_URL is required'}
|
||||
$uri=[Uri]$env:DATABASE_URL
|
||||
if($uri.Scheme -ne 'mysql'){throw 'DATABASE_URL must use mysql'}
|
||||
$database=$uri.AbsolutePath.TrimStart('/')
|
||||
if(-not $database){throw 'database name is required'}
|
||||
$resolved=[IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
|
||||
$stamp=(Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
||||
$target=Join-Path $resolved "$database-$stamp.sql"
|
||||
$credential=$uri.UserInfo.Split(':',2)
|
||||
$env:MYSQL_PWD=[Uri]::UnescapeDataString($credential[1])
|
||||
& mysqldump --single-transaction --set-gtid-purged=OFF --host $uri.Host --port $uri.Port --user ([Uri]::UnescapeDataString($credential[0])) --result-file $target $database
|
||||
if($LASTEXITCODE -ne 0){throw 'mysqldump failed'}
|
||||
$hash=(Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash.ToLowerInvariant()
|
||||
Set-Content -LiteralPath "$target.sha256" -Value "$hash $([IO.Path]::GetFileName($target))"
|
||||
Get-ChildItem -LiteralPath $resolved -File | Where-Object {$_.LastWriteTimeUtc -lt (Get-Date).ToUniversalTime().AddDays(-$RetentionDays) -and ($_.Extension -eq '.sql' -or $_.Name.EndsWith('.sql.sha256'))} | Remove-Item -Force
|
||||
Write-Output $target
|
||||
@@ -0,0 +1,15 @@
|
||||
param([Parameter(Mandatory=$true)][string]$BackupFile,[Parameter(Mandatory=$true)][string]$TargetDatabase)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $env:DATABASE_URL){throw 'DATABASE_URL is required'}
|
||||
if($TargetDatabase -notmatch '^[A-Za-z0-9_]+$'){throw 'TargetDatabase contains invalid characters'}
|
||||
$file=(Resolve-Path -LiteralPath $BackupFile).Path
|
||||
$checksum="$file.sha256"
|
||||
if(-not(Test-Path -LiteralPath $checksum)){throw 'checksum file is required'}
|
||||
$expected=(Get-Content -LiteralPath $checksum).Split(' ')[0]
|
||||
if((Get-FileHash -Algorithm SHA256 -LiteralPath $file).Hash.ToLowerInvariant() -ne $expected){throw 'backup checksum mismatch'}
|
||||
$uri=[Uri]$env:DATABASE_URL;$credential=$uri.UserInfo.Split(':',2);$env:MYSQL_PWD=[Uri]::UnescapeDataString($credential[1]);$user=[Uri]::UnescapeDataString($credential[0])
|
||||
& mysql --host $uri.Host --port $uri.Port --user $user --execute "CREATE DATABASE IF NOT EXISTS ``$TargetDatabase`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
if($LASTEXITCODE -ne 0){throw 'target database creation failed'}
|
||||
Get-Content -LiteralPath $file -Raw | & mysql --host $uri.Host --port $uri.Port --user $user $TargetDatabase
|
||||
if($LASTEXITCODE -ne 0){throw 'restore failed'}
|
||||
Write-Output "Restore completed into $TargetDatabase"
|
||||
@@ -0,0 +1,20 @@
|
||||
# 发布与运维
|
||||
|
||||
生产发布只允许不可变镜像标签,顺序固定为:staging 预检 → migration 预检与备份 → 冒烟测试 → UAT 签字 → 生产备份 → 部署 → `/health/live`、`/health/ready` 和公开内容冒烟 → 观察 → 必要时回滚到上一镜像标签。禁止使用 `latest`。
|
||||
|
||||
## 外部准备
|
||||
|
||||
- 私有镜像仓库、TLS 证书和合法域名;生产 MySQL、Redis、私有对象存储。
|
||||
- 微信 AppID/AppSecret、六类订阅消息模板、合法 HTTPS 域名和真机授权。
|
||||
- 告警接收渠道、异地备份目标、隐私政策/用户协议/主体资质与 UAT/发布审批人。
|
||||
|
||||
## 执行
|
||||
|
||||
1. 设置 `IMAGE_TAG`、`REGISTRY_IMAGE` 及 `.env.example` 中所有生产必填环境变量。
|
||||
2. `./infra/deploy/preflight.ps1 staging`,部署 staging,执行 migration 和 `smoke.ps1`。
|
||||
3. 完成 `docs/14_RELEASE_CHECKLIST.md` 中 staging/UAT 项并留存证据。
|
||||
4. 使用 `infra/backup/backup-mysql.ps1` 备份并将 SQL、SHA-256 文件同步到异地加密存储。
|
||||
5. `./infra/deploy/preflight.ps1 production`,部署固定镜像;执行冒烟并观察 5xx、p95、数据库、磁盘、通知死信和备份告警。
|
||||
6. 异常时将 `IMAGE_TAG` 恢复到上一批准版本重新部署。若 migration 不向后兼容,停止发布并按已验证的数据库恢复步骤执行。
|
||||
|
||||
备份默认每日一次,保留 30 天;必须每季度在独立数据库做恢复演练并记录表数量、关键行数和耗时。脚本会校验 SHA-256,恢复目标数据库名必须显式提供。
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
api:
|
||||
image: ${REGISTRY_IMAGE:?REGISTRY_IMAGE required}:${IMAGE_TAG:?IMAGE_TAG required}
|
||||
restart: always
|
||||
environment:
|
||||
APP_ENV: production
|
||||
ADMIN_COOKIE_SECURE: 'true'
|
||||
NOTIFICATION_WORKER_ENABLED: 'true'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,noexec,nosuid
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
api:
|
||||
image: ${REGISTRY_IMAGE:?REGISTRY_IMAGE required}:${IMAGE_TAG:?IMAGE_TAG required}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: staging
|
||||
ADMIN_COOKIE_SECURE: 'true'
|
||||
@@ -0,0 +1,9 @@
|
||||
param([Parameter(Mandatory=$true)][ValidateSet('staging','production')][string]$Environment)
|
||||
$ErrorActionPreference='Stop'
|
||||
$required=@('IMAGE_TAG','DATABASE_URL','SESSION_TOKEN_PEPPER','AUDIT_IP_HASH_KEY','FIELD_ENCRYPTION_KEY','ADMIN_WEB_ORIGIN')
|
||||
foreach($name in $required){if(-not [Environment]::GetEnvironmentVariable($name)){throw "Missing required environment variable: $name"}}
|
||||
if($env:IMAGE_TAG -eq 'latest'){throw 'IMAGE_TAG must be immutable and cannot be latest'}
|
||||
if($Environment -eq 'production' -and $env:ADMIN_COOKIE_SECURE -ne 'true'){throw 'production requires ADMIN_COOKIE_SECURE=true'}
|
||||
docker compose -f compose.yaml -f "infra/deploy/compose.$Environment.yaml" config --quiet
|
||||
if($LASTEXITCODE -ne 0){throw 'compose validation failed'}
|
||||
Write-Output "Preflight passed for $Environment"
|
||||
@@ -0,0 +1,6 @@
|
||||
param([Parameter(Mandatory=$true)][string]$BaseUrl)
|
||||
$ErrorActionPreference='Stop';$base=$BaseUrl.TrimEnd('/');$live=Invoke-RestMethod -Uri "$base/health/live" -TimeoutSec 10;$ready=Invoke-RestMethod -Uri "$base/health/ready" -TimeoutSec 10
|
||||
if($live.status -ne 'ok' -or $ready.status -ne 'ready'){throw 'health smoke test failed'}
|
||||
$content=Invoke-RestMethod -Uri "$base/api/v1/content?page=1&pageSize=1" -TimeoutSec 10
|
||||
if($content.success -ne $true){throw 'public content smoke test failed'}
|
||||
Write-Output 'Smoke tests passed'
|
||||
@@ -0,0 +1,22 @@
|
||||
# 本地基础设施
|
||||
|
||||
根目录 `compose.yaml` 启动 MySQL 8、Redis 7 与 MinIO。它们仅用于开发环境,不保存不可恢复的生产业务事实。
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
默认端口:MySQL `3306`、Redis `6379`、MinIO API `9000`、MinIO Console `9001`。本地密码必须在 `.env` 中修改;`.env` 禁止提交。
|
||||
|
||||
认证、权限、服务申请和工单数据库集成测试使用隔离的 MySQL(端口 `3307`、数据库 `lehuo_test`):
|
||||
|
||||
```bash
|
||||
docker compose --profile test up -d mysql-test
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
Phase 3 附件使用私有 MinIO Bucket。开发者需在 MinIO Console 创建 `.env` 中的 `OBJECT_STORAGE_BUCKET`(默认 `lehuo-private`),并将 `OBJECT_STORAGE_ACCESS_KEY/SECRET_KEY` 配置为具备该私有 Bucket 最小读写权限的凭据;管理端直传还需为已批准的管理端 Origin 配置仅允许 `PUT` 和所需响应头的 Bucket CORS。可在纯本地环境临时使用 MinIO 开发账号,但生产必须使用独立最小权限密钥。未配置凭据时附件接口会明确返回存储服务不可用。
|
||||
|
||||
停止服务使用 `docker compose down`。不要在有需保留的本地数据时添加 `--volumes`。
|
||||
@@ -0,0 +1,23 @@
|
||||
groups:
|
||||
- name: lehuo-api
|
||||
rules:
|
||||
- alert: ApiUnavailable
|
||||
expr: probe_success{job="lehuo-api"} == 0
|
||||
for: 2m
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: '乐活公益 API 不可用' }
|
||||
- alert: ApiHighLatency
|
||||
expr: lehuo_api_p95_milliseconds > 1000
|
||||
for: 10m
|
||||
labels: { severity: warning }
|
||||
annotations: { summary: 'API P95 超过 1 秒' }
|
||||
- alert: NotificationDeadLetters
|
||||
expr: lehuo_notification_dead_total > 0
|
||||
for: 5m
|
||||
labels: { severity: warning }
|
||||
annotations: { summary: '微信通知存在死信' }
|
||||
- alert: BackupMissing
|
||||
expr: time() - lehuo_backup_last_success_timestamp > 93600
|
||||
for: 10m
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: '超过 26 小时无成功数据库备份' }
|
||||
@@ -0,0 +1,10 @@
|
||||
limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s;
|
||||
server { listen 80; server_name _; return 301 https://$host$request_uri; }
|
||||
server {
|
||||
listen 443 ssl http2; server_name ${PUBLIC_HOST};
|
||||
ssl_certificate /etc/nginx/tls/fullchain.pem; ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
client_max_body_size 12m;
|
||||
location / { limit_req zone=api_per_ip burst=30 nodelay; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://api:3000; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$controllers=Get-ChildItem -Path apps/api/src -Recurse -Filter '*.controller.ts'
|
||||
$findings=@()
|
||||
foreach($file in $controllers){$text=Get-Content -LiteralPath $file.FullName -Raw;if($text -match "Controller\('api/v1/admin" -and $text -notmatch 'RequireSessionChannel'){ $findings+="$($file.FullName): admin controller lacks session-channel guard" };if($text -match "Controller\('api/v1/me" -and $text -notmatch 'RequireSessionChannel'){ $findings+="$($file.FullName): me controller lacks session-channel guard" }}
|
||||
if($findings.Count){$findings|ForEach-Object{Write-Error $_};exit 1}
|
||||
Write-Output "Permission scan passed for $($controllers.Count) controllers"
|
||||
Reference in New Issue
Block a user