feat: add production release safeguards
This commit is contained in:
parent
b6138475f0
commit
133f61e4ad
39
README.md
39
README.md
@ -15,23 +15,24 @@
|
|||||||
|
|
||||||
| 变量 | 必填 | 默认 | 说明 |
|
| 变量 | 必填 | 默认 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `MYSQL_URL` | 否 | `jdbc:mysql://127.0.0.1:3306/petstore?...` | 数据库 JDBC URL |
|
| `MYSQL_URL` | 生产必填 | `jdbc:mysql://127.0.0.1:3306/petstore?...` | 数据库 JDBC URL;发布预检要求显式提供 |
|
||||||
| `MYSQL_USERNAME` | 否 | `root` | 数据库用户名 |
|
| `MYSQL_USERNAME` | 生产必填 | `root` | 数据库用户名;生产使用最小权限专用账号 |
|
||||||
| `MYSQL_PASSWORD` | **是** | 空 | 数据库密码 |
|
| `MYSQL_PASSWORD` | **是** | 空 | 数据库密码 |
|
||||||
| `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID |
|
| `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID |
|
||||||
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
|
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
|
||||||
| `WECHAT_REDIRECT_URI` | 否 | `http://localhost:8080/api/wechat/callback` | 微信回调 |
|
| `WECHAT_REDIRECT_URI` | 生产必填 | `http://localhost:8080/api/wechat/callback` | 微信回调;生产必须为真实 HTTPS 地址 |
|
||||||
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
|
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
|
||||||
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期 |
|
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期 |
|
||||||
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL(用于生成媒体绝对 URL) |
|
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL(用于生成媒体绝对 URL) |
|
||||||
| `UPLOAD_PATH` | 否 | `/www/petstore/uploads` | 上传目录绝对路径 |
|
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
|
||||||
|
| `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 |
|
||||||
| `HIGHLIGHT_FFMPEG` | 否 | `ffmpeg` | ffmpeg 可执行路径 |
|
| `HIGHLIGHT_FFMPEG` | 否 | `ffmpeg` | ffmpeg 可执行路径 |
|
||||||
| `HIGHLIGHT_FFPROBE` | 否 | `ffprobe` | ffprobe 可执行路径 |
|
| `HIGHLIGHT_FFPROBE` | 否 | `ffprobe` | ffprobe 可执行路径 |
|
||||||
| `HIGHLIGHT_MAX_DURATION_SEC` | 否 | `300` | 成片最大总秒数 |
|
| `HIGHLIGHT_MAX_DURATION_SEC` | 否 | `300` | 成片最大总秒数 |
|
||||||
| `HIGHLIGHT_BGM_PATH` | 否 | 空 | 商用授权 BGM 绝对路径;留空不混音 |
|
| `HIGHLIGHT_BGM_PATH` | 否 | 空 | 商用授权 BGM 绝对路径;留空不混音 |
|
||||||
| `SMS_UNIVERSAL_CODE` | **生产必须为空** | `123456`(dev)/ 空(production) | 演示万能验证码;production profile 自动关闭 |
|
| `SMS_UNIVERSAL_CODE` | **生产必须为空** | `123456`(dev)/ 空(production) | 演示万能验证码;production profile 自动关闭 |
|
||||||
| `JPA_DDL_AUTO` | 否 | `update`(dev)/ `validate`(production) | JPA DDL 模式 |
|
| `SPRING_JPA_HIBERNATE_DDL_AUTO` | 否 | `validate`(production) | 生产只能为 `validate`;禁止自动改表 |
|
||||||
| `JPA_SHOW_SQL` | 否 | `false` | SQL 日志 |
|
| `SPRING_JPA_SHOW_SQL` | 否 | `false` | 生产 SQL 日志开关,必须为 `false` |
|
||||||
| `LOG_LEVEL_PETSTORE` | 否 | `info` | `com.petstore` 包日志级别 |
|
| `LOG_LEVEL_PETSTORE` | 否 | `info` | `com.petstore` 包日志级别 |
|
||||||
|
|
||||||
> ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。
|
> ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。
|
||||||
@ -94,6 +95,12 @@ curl http://localhost:8080/api/store/list
|
|||||||
```
|
```
|
||||||
脚本创建不可变 `t_business_event` 并回填可确认的历史事实。末尾四个验证计数必须全部为 0;报告打开和服务开始没有可靠历史数据,不做猜测性回填。
|
脚本创建不可变 `t_business_event` 并回填可确认的历史事实。末尾四个验证计数必须全部为 0;报告打开和服务开始没有可靠历史数据,不做猜测性回填。
|
||||||
|
|
||||||
|
7. **真实预约容量**:业务事件验证完成后执行:
|
||||||
|
```bash
|
||||||
|
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_booking_capacity.sql
|
||||||
|
```
|
||||||
|
脚本新增门店并发容量、服务时长、预约时长快照和连续排班占用。末尾容量、时长和快照验证计数必须全部为 0。
|
||||||
|
|
||||||
### production profile
|
### production profile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -104,6 +111,19 @@ production profile 下:
|
|||||||
- `ddl-auto=validate`(只校验,不改表)
|
- `ddl-auto=validate`(只校验,不改表)
|
||||||
- `show-sql=false`
|
- `show-sql=false`
|
||||||
- `SMS_UNIVERSAL_CODE` 默认空(关闭万能验证码)
|
- `SMS_UNIVERSAL_CODE` 默认空(关闭万能验证码)
|
||||||
|
- 不执行开发期默认服务初始化,启动和预检不会写入业务数据
|
||||||
|
- `/actuator/health/liveness` 与 `/actuator/health/readiness` 提供编排探针;响应不暴露内部细节
|
||||||
|
|
||||||
|
### 生产只读预检
|
||||||
|
|
||||||
|
完成备份和四个迁移后,以最终生产环境变量运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
||||||
|
BUILD_FIRST=1 deploy/release-preflight.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 15 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
@ -138,6 +158,9 @@ mvn -f backend/pom.xml test
|
|||||||
npm --prefix frontend install
|
npm --prefix frontend install
|
||||||
npm --prefix frontend run build:h5
|
npm --prefix frontend run build:h5
|
||||||
npm --prefix frontend run build:mp-weixin
|
npm --prefix frontend run build:mp-weixin
|
||||||
|
|
||||||
|
# 5. 上线后只读冒烟
|
||||||
|
API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### RC 检查项
|
### RC 检查项
|
||||||
@ -155,6 +178,10 @@ npm --prefix frontend run build:mp-weixin
|
|||||||
- [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果
|
- [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果
|
||||||
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
|
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
|
||||||
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
||||||
|
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
|
||||||
|
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
||||||
|
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
||||||
|
- [ ] readiness 与上线后只读冒烟全部通过
|
||||||
- [ ] 已暴露凭据(DB 密码、微信 AppSecret)已轮换
|
- [ ] 已暴露凭据(DB 密码、微信 AppSecret)已轮换
|
||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|||||||
62
deploy/nginx-petstore.conf.example
Normal file
62
deploy/nginx-petstore.conf.example
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Dedicated Petstore server blocks. Replace domains/certificate paths before use.
|
||||||
|
# Keep these blocks separate from Gitea/GitLab configurations on a shared host.
|
||||||
|
|
||||||
|
upstream petstore_backend_8080 {
|
||||||
|
server 127.0.0.1:8080;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name api.petstore.invalid;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/api.petstore.invalid/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/api.petstore.invalid/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://petstore_backend_8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name admin.petstore.invalid;
|
||||||
|
root /opt/petstore/admin/current;
|
||||||
|
index index.html;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/admin.petstore.invalid/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/admin.petstore.invalid/privkey.pem;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://petstore_backend_8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://petstore_backend_8080;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name report.petstore.invalid;
|
||||||
|
root /opt/petstore/frontend-h5/current;
|
||||||
|
index index.html;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/report.petstore.invalid/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/report.petstore.invalid/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
deploy/petstore-backend.env.example
Normal file
24
deploy/petstore-backend.env.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Copy to /etc/petstore/backend.env with mode 0600. Never commit real values.
|
||||||
|
SPRING_PROFILES_ACTIVE=production
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO=validate
|
||||||
|
SPRING_JPA_SHOW_SQL=false
|
||||||
|
|
||||||
|
MYSQL_URL='jdbc:mysql://db-host:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai'
|
||||||
|
MYSQL_USERNAME=
|
||||||
|
MYSQL_PASSWORD=
|
||||||
|
|
||||||
|
WECHAT_APPID=
|
||||||
|
WECHAT_APPSECRET=
|
||||||
|
WECHAT_REDIRECT_URI=https://api.petstore.invalid/api/wechat/callback
|
||||||
|
PETSTORE_SESSION_SECRET=
|
||||||
|
SMS_UNIVERSAL_CODE=
|
||||||
|
|
||||||
|
APP_BASE_URL=https://api.petstore.invalid
|
||||||
|
CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid
|
||||||
|
UPLOAD_PATH=/var/lib/petstore/uploads
|
||||||
|
|
||||||
|
HIGHLIGHT_FFMPEG=/usr/bin/ffmpeg
|
||||||
|
HIGHLIGHT_FFPROBE=/usr/bin/ffprobe
|
||||||
|
HIGHLIGHT_MAX_DURATION_SEC=300
|
||||||
|
HIGHLIGHT_BGM_PATH=
|
||||||
|
HIGHLIGHT_FONT_PATH=
|
||||||
24
deploy/petstore-backend.service.example
Normal file
24
deploy/petstore-backend.service.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Petstore backend
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=petstore
|
||||||
|
Group=petstore
|
||||||
|
WorkingDirectory=/opt/petstore/backend/current
|
||||||
|
EnvironmentFile=/etc/petstore/backend.env
|
||||||
|
ExecStart=/usr/bin/java -jar /opt/petstore/backend/current/petstore-backend.jar
|
||||||
|
SuccessExitStatus=143
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=30
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/petstore/uploads
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
60
deploy/production-smoke.sh
Executable file
60
deploy/production-smoke.sh
Executable file
@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_ORIGIN="${API_ORIGIN:-}"
|
||||||
|
SMOKE_BEARER_TOKEN="${SMOKE_BEARER_TOKEN:-}"
|
||||||
|
|
||||||
|
[[ "$API_ORIGIN" == https://* ]] || {
|
||||||
|
printf 'SMOKE FAIL: API_ORIGIN must be an https origin\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$API_ORIGIN" != *localhost* && "$API_ORIGIN" != *127.0.0.1* && "$API_ORIGIN" != *.invalid* ]] || {
|
||||||
|
printf 'SMOKE FAIL: API_ORIGIN must use a production domain\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$API_ORIGIN" != *.example* && "$API_ORIGIN" != *.test* ]] || {
|
||||||
|
printf 'SMOKE FAIL: API_ORIGIN must not use a reserved example/test domain\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
API_ORIGIN="${API_ORIGIN%/}"
|
||||||
|
|
||||||
|
json_assert_status_up() {
|
||||||
|
python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("status") == "UP", d; print("UP")'
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '1/5 liveness: '
|
||||||
|
curl --fail --silent --show-error "$API_ORIGIN/actuator/health/liveness" | json_assert_status_up
|
||||||
|
|
||||||
|
printf '2/5 readiness: '
|
||||||
|
curl --fail --silent --show-error "$API_ORIGIN/actuator/health/readiness" | json_assert_status_up
|
||||||
|
|
||||||
|
STORES="$(curl --fail --silent --show-error "$API_ORIGIN/api/store/list")"
|
||||||
|
STORE_ID="$(printf '%s' "$STORES" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; rows=d.get("data") or []; assert rows,"no pilot store"; print(rows[0]["id"])')"
|
||||||
|
printf '3/5 public store list: PASS\n'
|
||||||
|
|
||||||
|
SERVICES="$(curl --fail --silent --show-error "$API_ORIGIN/api/service-type/list?storeId=$STORE_ID")"
|
||||||
|
SERVICE_NAME="$(printf '%s' "$SERVICES" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; rows=d.get("data") or []; assert rows,"no service type"; assert all(int(x.get("durationMinutes") or 0)>=30 for x in rows),rows; print(rows[0]["name"])')"
|
||||||
|
printf '4/5 service duration contract: PASS\n'
|
||||||
|
|
||||||
|
if date -v+1d +%F >/dev/null 2>&1; then
|
||||||
|
TOMORROW="$(date -v+1d +%F)"
|
||||||
|
else
|
||||||
|
TOMORROW="$(date -d tomorrow +%F)"
|
||||||
|
fi
|
||||||
|
curl --fail --silent --show-error --get "$API_ORIGIN/api/appointment/available-slots" \
|
||||||
|
--data-urlencode "storeId=$STORE_ID" \
|
||||||
|
--data-urlencode "date=$TOMORROW" \
|
||||||
|
--data-urlencode "serviceType=$SERVICE_NAME" \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; data=d["data"]; assert int(data["durationMinutes"])>=30; assert int(data["bookingCapacity"])>=1; assert isinstance(data.get("slots"),list); print("5/5 capacity slot contract: PASS")'
|
||||||
|
|
||||||
|
if [[ -n "$SMOKE_BEARER_TOKEN" ]]; then
|
||||||
|
AUTH_HEADER="Authorization: Bearer $SMOKE_BEARER_TOKEN"
|
||||||
|
curl --fail --silent --show-error -H "$AUTH_HEADER" "$API_ORIGIN/api/admin/store" \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; assert int(d["data"]["bookingCapacity"])>=1; print("protected admin store: PASS")'
|
||||||
|
curl --fail --silent --show-error -H "$AUTH_HEADER" "$API_ORIGIN/api/appointment/list?page=1&pageSize=5" \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; print("protected appointment list: PASS")'
|
||||||
|
else
|
||||||
|
printf 'Protected smoke SKIPPED: SMOKE_BEARER_TOKEN not provided\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Production read-only smoke PASS\n'
|
||||||
81
deploy/release-preflight.sh
Executable file
81
deploy/release-preflight.sh
Executable file
@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
JAR_PATH="${JAR_PATH:-$BACKEND_DIR/target/petstore-backend-1.0.0.jar}"
|
||||||
|
JAVA_BIN="${JAVA_BIN:-java}"
|
||||||
|
BUILD_FIRST="${BUILD_FIRST:-1}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'PRECHECK FAIL: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -n "${!name:-}" ]] || fail "missing environment variable: $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in MYSQL_URL MYSQL_USERNAME MYSQL_PASSWORD WECHAT_APPID WECHAT_APPSECRET \
|
||||||
|
WECHAT_REDIRECT_URI PETSTORE_SESSION_SECRET APP_BASE_URL UPLOAD_PATH CORS_ALLOWED_ORIGINS; do
|
||||||
|
require_env "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$APP_BASE_URL" == https://* ]] || fail "APP_BASE_URL must use https"
|
||||||
|
[[ "$APP_BASE_URL" != *localhost* ]] || fail "APP_BASE_URL must not use localhost"
|
||||||
|
[[ "$APP_BASE_URL" != *.invalid* ]] || fail "APP_BASE_URL must not use a placeholder domain"
|
||||||
|
[[ "$APP_BASE_URL" != *.example* && "$APP_BASE_URL" != *.test* ]] \
|
||||||
|
|| fail "APP_BASE_URL must not use a reserved example/test domain"
|
||||||
|
[[ "$WECHAT_REDIRECT_URI" == https://* ]] || fail "WECHAT_REDIRECT_URI must use https"
|
||||||
|
[[ "$WECHAT_REDIRECT_URI" != *localhost* && "$WECHAT_REDIRECT_URI" != *.invalid* ]] \
|
||||||
|
|| fail "WECHAT_REDIRECT_URI must use a production domain"
|
||||||
|
[[ "$WECHAT_REDIRECT_URI" != *.example* && "$WECHAT_REDIRECT_URI" != *.test* ]] \
|
||||||
|
|| fail "WECHAT_REDIRECT_URI must not use a reserved example/test domain"
|
||||||
|
[[ ${#PETSTORE_SESSION_SECRET} -ge 32 ]] || fail "PETSTORE_SESSION_SECRET must be at least 32 characters"
|
||||||
|
[[ "$PETSTORE_SESSION_SECRET" != "dev-change-me" ]] || fail "PETSTORE_SESSION_SECRET must not use the default"
|
||||||
|
[[ -z "${SMS_UNIVERSAL_CODE:-}" ]] || fail "SMS_UNIVERSAL_CODE must be empty in production"
|
||||||
|
[[ "${SPRING_JPA_HIBERNATE_DDL_AUTO:-validate}" == "validate" ]] || fail "production ddl-auto must be validate"
|
||||||
|
[[ "${SPRING_JPA_SHOW_SQL:-false}" == "false" ]] || fail "production show-sql must be false"
|
||||||
|
[[ "$UPLOAD_PATH" == /* ]] || fail "UPLOAD_PATH must be absolute"
|
||||||
|
[[ -d "$UPLOAD_PATH" && -r "$UPLOAD_PATH" && -w "$UPLOAD_PATH" ]] \
|
||||||
|
|| fail "UPLOAD_PATH must exist and be readable/writable by the service user"
|
||||||
|
|
||||||
|
IFS=',' read -r -a cors_origins <<< "$CORS_ALLOWED_ORIGINS"
|
||||||
|
[[ ${#cors_origins[@]} -gt 0 ]] || fail "CORS_ALLOWED_ORIGINS must not be empty"
|
||||||
|
for origin in "${cors_origins[@]}"; do
|
||||||
|
origin="${origin#${origin%%[![:space:]]*}}"
|
||||||
|
origin="${origin%${origin##*[![:space:]]}}"
|
||||||
|
[[ "$origin" == https://* && "$origin" != *'*'* \
|
||||||
|
&& "$origin" != *localhost* && "$origin" != *127.0.0.1* && "$origin" != *.invalid* ]] \
|
||||||
|
|| fail "every CORS origin must be an explicit https origin"
|
||||||
|
[[ "$origin" != *.example* && "$origin" != *.test* ]] \
|
||||||
|
|| fail "CORS origins must not use reserved example/test domains"
|
||||||
|
done
|
||||||
|
|
||||||
|
command_ready() {
|
||||||
|
local binary="$1"
|
||||||
|
if [[ "$binary" == */* ]]; then
|
||||||
|
[[ -x "$binary" ]]
|
||||||
|
else
|
||||||
|
command -v "$binary" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
FFMPEG_BINARY="${HIGHLIGHT_FFMPEG:-ffmpeg}"
|
||||||
|
FFPROBE_BINARY="${HIGHLIGHT_FFPROBE:-ffprobe}"
|
||||||
|
command_ready "$FFMPEG_BINARY" || fail "HIGHLIGHT_FFMPEG is not executable"
|
||||||
|
command_ready "$FFPROBE_BINARY" || fail "HIGHLIGHT_FFPROBE is not executable"
|
||||||
|
command -v "$JAVA_BIN" >/dev/null 2>&1 || fail "JAVA_BIN is not executable"
|
||||||
|
|
||||||
|
if [[ "$BUILD_FIRST" == "1" ]]; then
|
||||||
|
(cd "$BACKEND_DIR" && mvn -q -DskipTests package)
|
||||||
|
fi
|
||||||
|
[[ -r "$JAR_PATH" ]] || fail "backend jar not found: $JAR_PATH"
|
||||||
|
|
||||||
|
printf 'Static production precheck PASS\n'
|
||||||
|
printf 'Running read-only Spring/JPA/database invariant preflight...\n'
|
||||||
|
exec "$JAVA_BIN" -jar "$JAR_PATH" \
|
||||||
|
--spring.profiles.active=production \
|
||||||
|
--spring.main.web-application-type=none \
|
||||||
|
--app.preflight-only=true
|
||||||
4
pom.xml
4
pom.xml
@ -46,6 +46,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import org.springframework.boot.SpringApplication;
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -26,6 +27,7 @@ public class PetstoreApplication {
|
|||||||
* 启动期不再改表结构,便于审计与生产回滚。
|
* 启动期不再改表结构,便于审计与生产回滚。
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
|
@Profile("!production")
|
||||||
CommandLineRunner initRunner(ServiceTypeService serviceTypeService) {
|
CommandLineRunner initRunner(ServiceTypeService serviceTypeService) {
|
||||||
return args -> serviceTypeService.initDefaults();
|
return args -> serviceTypeService.initDefaults();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,17 +2,27 @@ package com.petstore.config;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
import org.springframework.web.filter.CorsFilter;
|
import org.springframework.web.filter.CorsFilter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig {
|
public class CorsConfig {
|
||||||
|
private final List<String> allowedOrigins;
|
||||||
|
|
||||||
|
public CorsConfig(@Value("${app.cors.allowed-origins:http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174}") String origins) {
|
||||||
|
this.allowedOrigins = parseOrigins(origins);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsFilter corsFilter() {
|
public CorsFilter corsFilter() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
config.addAllowedOriginPattern("*");
|
config.setAllowedOrigins(allowedOrigins);
|
||||||
config.addAllowedHeader("*");
|
config.addAllowedHeader("*");
|
||||||
config.addAllowedMethod("*");
|
config.addAllowedMethod("*");
|
||||||
|
|
||||||
@ -20,4 +30,16 @@ public class CorsConfig {
|
|||||||
source.registerCorsConfiguration("/**", config);
|
source.registerCorsConfiguration("/**", config);
|
||||||
return new CorsFilter(source);
|
return new CorsFilter(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> parseOrigins(String origins) {
|
||||||
|
if (origins == null || origins.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return Arrays.stream(origins.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isBlank())
|
||||||
|
.map(value -> value.endsWith("/") ? value.substring(0, value.length() - 1) : value)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,88 @@
|
|||||||
|
package com.petstore.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** 生产 readiness 的文件系统与 FFmpeg 依赖检查;响应细节不会对公网展示。 */
|
||||||
|
@Component("petstoreRuntime")
|
||||||
|
@Profile("production")
|
||||||
|
public class PetstoreRuntimeHealthIndicator implements HealthIndicator {
|
||||||
|
private static final long CACHE_MILLIS = 30_000L;
|
||||||
|
private final Path uploadPath;
|
||||||
|
private final String ffmpegBinary;
|
||||||
|
private final String ffprobeBinary;
|
||||||
|
private volatile Health cachedHealth;
|
||||||
|
private volatile long cachedUntil;
|
||||||
|
|
||||||
|
public PetstoreRuntimeHealthIndicator(
|
||||||
|
@Value("${upload.path}") String uploadPath,
|
||||||
|
@Value("${app.highlight-video.ffmpeg-binary:ffmpeg}") String ffmpegBinary,
|
||||||
|
@Value("${app.highlight-video.ffprobe-binary:ffprobe}") String ffprobeBinary) {
|
||||||
|
this.uploadPath = Path.of(uploadPath);
|
||||||
|
this.ffmpegBinary = ffmpegBinary;
|
||||||
|
this.ffprobeBinary = ffprobeBinary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Health cached = cachedHealth;
|
||||||
|
if (cached != null && now < cachedUntil) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
now = System.currentTimeMillis();
|
||||||
|
if (cachedHealth != null && now < cachedUntil) {
|
||||||
|
return cachedHealth;
|
||||||
|
}
|
||||||
|
cachedHealth = inspectRuntime();
|
||||||
|
cachedUntil = now + CACHE_MILLIS;
|
||||||
|
return cachedHealth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Health inspectRuntime() {
|
||||||
|
boolean uploadReady = Files.isDirectory(uploadPath)
|
||||||
|
&& Files.isReadable(uploadPath)
|
||||||
|
&& Files.isWritable(uploadPath);
|
||||||
|
boolean ffmpegReady = commandReady(ffmpegBinary);
|
||||||
|
boolean ffprobeReady = commandReady(ffprobeBinary);
|
||||||
|
Health.Builder builder = uploadReady && ffmpegReady && ffprobeReady ? Health.up() : Health.down();
|
||||||
|
return builder
|
||||||
|
.withDetail("uploadReady", uploadReady)
|
||||||
|
.withDetail("ffmpegReady", ffmpegReady)
|
||||||
|
.withDetail("ffprobeReady", ffprobeReady)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean commandReady(String command) {
|
||||||
|
if (command == null || command.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Process process = null;
|
||||||
|
try {
|
||||||
|
process = new ProcessBuilder(command, "-version")
|
||||||
|
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||||
|
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||||
|
.start();
|
||||||
|
if (!process.waitFor(3, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return process.exitValue() == 0;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (process != null && process.isAlive()) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
package com.petstore.config;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 生产 profile 启动门禁:只校验配置,不打印任何配置值。 */
|
||||||
|
@Component
|
||||||
|
@Profile("production")
|
||||||
|
public class ProductionConfigurationValidator {
|
||||||
|
private final String ddlAuto;
|
||||||
|
private final boolean showSql;
|
||||||
|
private final String mysqlUsername;
|
||||||
|
private final String mysqlPassword;
|
||||||
|
private final String wechatAppId;
|
||||||
|
private final String wechatAppSecret;
|
||||||
|
private final String wechatRedirectUri;
|
||||||
|
private final String sessionSecret;
|
||||||
|
private final String smsUniversalCode;
|
||||||
|
private final String appBaseUrl;
|
||||||
|
private final String uploadPath;
|
||||||
|
private final String corsAllowedOrigins;
|
||||||
|
|
||||||
|
public ProductionConfigurationValidator(
|
||||||
|
@Value("${spring.jpa.hibernate.ddl-auto:}") String ddlAuto,
|
||||||
|
@Value("${spring.jpa.show-sql:false}") boolean showSql,
|
||||||
|
@Value("${spring.datasource.username:}") String mysqlUsername,
|
||||||
|
@Value("${spring.datasource.password:}") String mysqlPassword,
|
||||||
|
@Value("${wechat.appid:}") String wechatAppId,
|
||||||
|
@Value("${wechat.appsecret:}") String wechatAppSecret,
|
||||||
|
@Value("${wechat.redirect_uri:}") String wechatRedirectUri,
|
||||||
|
@Value("${auth.session-secret:}") String sessionSecret,
|
||||||
|
@Value("${app.demo.sms-universal-code:}") String smsUniversalCode,
|
||||||
|
@Value("${app.base-url:}") String appBaseUrl,
|
||||||
|
@Value("${upload.path:}") String uploadPath,
|
||||||
|
@Value("${app.cors.allowed-origins:}") String corsAllowedOrigins) {
|
||||||
|
this.ddlAuto = ddlAuto;
|
||||||
|
this.showSql = showSql;
|
||||||
|
this.mysqlUsername = mysqlUsername;
|
||||||
|
this.mysqlPassword = mysqlPassword;
|
||||||
|
this.wechatAppId = wechatAppId;
|
||||||
|
this.wechatAppSecret = wechatAppSecret;
|
||||||
|
this.wechatRedirectUri = wechatRedirectUri;
|
||||||
|
this.sessionSecret = sessionSecret;
|
||||||
|
this.smsUniversalCode = smsUniversalCode;
|
||||||
|
this.appBaseUrl = appBaseUrl;
|
||||||
|
this.uploadPath = uploadPath;
|
||||||
|
this.corsAllowedOrigins = corsAllowedOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void validate() {
|
||||||
|
List<String> failures = validateValues();
|
||||||
|
if (!failures.isEmpty()) {
|
||||||
|
throw new IllegalStateException("生产配置校验失败,需修正: " + String.join(", ", failures));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> validateValues() {
|
||||||
|
List<String> failures = new ArrayList<>();
|
||||||
|
if (!"validate".equalsIgnoreCase(text(ddlAuto))) failures.add("JPA_DDL_AUTO=validate");
|
||||||
|
if (showSql) failures.add("JPA_SHOW_SQL=false");
|
||||||
|
require(mysqlUsername, "MYSQL_USERNAME", failures);
|
||||||
|
require(mysqlPassword, "MYSQL_PASSWORD", failures);
|
||||||
|
require(wechatAppId, "WECHAT_APPID", failures);
|
||||||
|
require(wechatAppSecret, "WECHAT_APPSECRET", failures);
|
||||||
|
requireHttpsUrl(wechatRedirectUri, "WECHAT_REDIRECT_URI", failures);
|
||||||
|
if (text(sessionSecret).length() < 32 || "dev-change-me".equals(sessionSecret)) {
|
||||||
|
failures.add("PETSTORE_SESSION_SECRET(至少32字符且非默认值)");
|
||||||
|
}
|
||||||
|
if (!text(smsUniversalCode).isEmpty()) failures.add("SMS_UNIVERSAL_CODE必须为空");
|
||||||
|
requireHttpsOrigin(appBaseUrl, "APP_BASE_URL", failures);
|
||||||
|
if (text(uploadPath).isEmpty() || !Path.of(uploadPath).isAbsolute()) {
|
||||||
|
failures.add("UPLOAD_PATH必须为绝对路径");
|
||||||
|
}
|
||||||
|
List<String> origins = CorsConfig.parseOrigins(corsAllowedOrigins);
|
||||||
|
if (origins.isEmpty()) {
|
||||||
|
failures.add("CORS_ALLOWED_ORIGINS");
|
||||||
|
} else {
|
||||||
|
boolean hasWildcard = origins.stream().anyMatch(origin -> origin.contains("*"));
|
||||||
|
if (hasWildcard) {
|
||||||
|
failures.add("CORS_ALLOWED_ORIGINS不得含通配符");
|
||||||
|
} else if (origins.stream().anyMatch(origin -> !isSafeHttpsOrigin(origin))) {
|
||||||
|
failures.add("CORS_ALLOWED_ORIGINS必须全部为显式生产https源");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(String value, String name, List<String> failures) {
|
||||||
|
if (text(value).isEmpty()) failures.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireHttpsUrl(String value, String name, List<String> failures) {
|
||||||
|
String normalized = text(value);
|
||||||
|
if (!isSafeHttpsUrl(normalized)) {
|
||||||
|
failures.add(name + "必须为生产https地址");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireHttpsOrigin(String value, String name, List<String> failures) {
|
||||||
|
if (!isSafeHttpsOrigin(text(value))) {
|
||||||
|
failures.add(name + "必须为生产https源");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSafeHttpsOrigin(String value) {
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(value);
|
||||||
|
String path = uri.getPath();
|
||||||
|
return isSafeHttpsUri(uri)
|
||||||
|
&& uri.getUserInfo() == null
|
||||||
|
&& uri.getQuery() == null
|
||||||
|
&& uri.getFragment() == null
|
||||||
|
&& (path == null || path.isEmpty() || "/".equals(path));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSafeHttpsUrl(String value) {
|
||||||
|
try {
|
||||||
|
return isSafeHttpsUri(URI.create(value));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSafeHttpsUri(URI uri) {
|
||||||
|
String host = text(uri.getHost()).toLowerCase();
|
||||||
|
return "https".equalsIgnoreCase(uri.getScheme())
|
||||||
|
&& !host.isEmpty()
|
||||||
|
&& !"localhost".equals(host)
|
||||||
|
&& !"127.0.0.1".equals(host)
|
||||||
|
&& !"::1".equals(host)
|
||||||
|
&& !isPlaceholderHost(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPlaceholderHost(String host) {
|
||||||
|
return host.endsWith(".invalid")
|
||||||
|
|| host.endsWith(".test")
|
||||||
|
|| host.endsWith(".example")
|
||||||
|
|| "example.com".equals(host)
|
||||||
|
|| host.endsWith(".example.com")
|
||||||
|
|| "example.net".equals(host)
|
||||||
|
|| host.endsWith(".example.net")
|
||||||
|
|| "example.org".equals(host)
|
||||||
|
|| host.endsWith(".example.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
package com.petstore.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式生产预检模式:JPA validate 通过后,只读核对迁移回填不变量并退出。
|
||||||
|
* 仅在 {@code --app.preflight-only=true} 时启用,不参与正常服务启动。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Profile("production")
|
||||||
|
@ConditionalOnProperty(prefix = "app", name = "preflight-only", havingValue = "true")
|
||||||
|
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class ProductionDatabasePreflightRunner implements ApplicationRunner {
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ConfigurableApplicationContext applicationContext;
|
||||||
|
|
||||||
|
private static final List<Invariant> INVARIANTS = List.of(
|
||||||
|
new Invariant("unresolved_assisted_appointments", """
|
||||||
|
SELECT COUNT(*) FROM t_appointment
|
||||||
|
WHERE deleted = 0 AND customer_user_id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("reports_without_customer", """
|
||||||
|
SELECT COUNT(*) FROM t_report
|
||||||
|
WHERE deleted = 0 AND customer_user_id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("reports_without_author", """
|
||||||
|
SELECT COUNT(*) FROM t_report
|
||||||
|
WHERE deleted = 0 AND author_staff_id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("appointments_without_store_customer", """
|
||||||
|
SELECT COUNT(*) FROM t_appointment a
|
||||||
|
LEFT JOIN t_store_customer sc
|
||||||
|
ON sc.store_id = a.store_id
|
||||||
|
AND sc.customer_user_id = a.customer_user_id
|
||||||
|
AND sc.deleted = 0
|
||||||
|
WHERE a.deleted = 0 AND a.customer_user_id IS NOT NULL AND sc.id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("leads_without_store_customer", """
|
||||||
|
SELECT COUNT(*) FROM t_report_lead l
|
||||||
|
LEFT JOIN t_store_customer sc
|
||||||
|
ON sc.store_id = l.store_id AND sc.phone = l.phone AND sc.deleted = 0
|
||||||
|
WHERE l.store_id IS NOT NULL AND l.phone IS NOT NULL AND l.phone <> '' AND sc.id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("store_customers_linked_to_non_customer", """
|
||||||
|
SELECT COUNT(*) FROM t_store_customer sc
|
||||||
|
JOIN t_user u ON u.id = sc.customer_user_id
|
||||||
|
WHERE sc.deleted = 0 AND (u.deleted <> 0 OR u.role <> 'customer')
|
||||||
|
"""),
|
||||||
|
new Invariant("appointments_without_created_event", """
|
||||||
|
SELECT COUNT(*) FROM t_appointment a
|
||||||
|
LEFT JOIN t_business_event e
|
||||||
|
ON e.idempotency_key = CONCAT('appointment_created:', a.id)
|
||||||
|
WHERE a.deleted = 0 AND a.store_id IS NOT NULL AND e.id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("reports_without_submitted_event", """
|
||||||
|
SELECT COUNT(*) FROM t_report r
|
||||||
|
LEFT JOIN t_business_event e
|
||||||
|
ON e.idempotency_key = CONCAT('report_submitted:', r.id)
|
||||||
|
WHERE r.deleted = 0 AND r.store_id IS NOT NULL AND e.id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("leads_without_submitted_event", """
|
||||||
|
SELECT COUNT(*) FROM t_report_lead l
|
||||||
|
LEFT JOIN t_business_event e
|
||||||
|
ON e.idempotency_key = CONCAT('lead_submitted:', l.id)
|
||||||
|
WHERE l.store_id IS NOT NULL AND e.id IS NULL
|
||||||
|
"""),
|
||||||
|
new Invariant("events_without_store", "SELECT COUNT(*) FROM t_business_event WHERE store_id IS NULL"),
|
||||||
|
new Invariant("invalid_service_duration", """
|
||||||
|
SELECT COUNT(*) FROM t_service_type
|
||||||
|
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0
|
||||||
|
"""),
|
||||||
|
new Invariant("invalid_appointment_duration", """
|
||||||
|
SELECT COUNT(*) FROM t_appointment
|
||||||
|
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0
|
||||||
|
"""),
|
||||||
|
new Invariant("invalid_block_duration", """
|
||||||
|
SELECT COUNT(*) FROM t_schedule_block
|
||||||
|
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0
|
||||||
|
"""),
|
||||||
|
new Invariant("invalid_store_capacity", """
|
||||||
|
SELECT COUNT(*) FROM t_store
|
||||||
|
WHERE booking_capacity < 1 OR booking_capacity > 10
|
||||||
|
"""),
|
||||||
|
new Invariant("duplicate_report_appointment", """
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT appointment_id FROM t_report
|
||||||
|
WHERE deleted = 0 AND appointment_id IS NOT NULL
|
||||||
|
GROUP BY appointment_id HAVING COUNT(*) > 1
|
||||||
|
) duplicated
|
||||||
|
""")
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
List<String> failures = INVARIANTS.stream()
|
||||||
|
.filter(invariant -> count(invariant.sql()) != 0L)
|
||||||
|
.map(Invariant::name)
|
||||||
|
.toList();
|
||||||
|
if (!failures.isEmpty()) {
|
||||||
|
throw new IllegalStateException("生产数据库预检失败,非零检查项: " + String.join(", ", failures));
|
||||||
|
}
|
||||||
|
log.info("Production database preflight PASS: {} invariants", INVARIANTS.size());
|
||||||
|
int exitCode = SpringApplication.exit(applicationContext, () -> 0);
|
||||||
|
System.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long count(String sql) {
|
||||||
|
Long value = jdbcTemplate.queryForObject(sql, Long.class);
|
||||||
|
return value == null ? -1L : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Invariant(String name, String sql) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
# 复制为 application.yml 后填写本地/生产配置(application.yml 已加入 .gitignore,勿提交密钥)
|
# 配置参考。真实密钥仅通过环境变量或服务器权限受控的 EnvironmentFile 注入,勿提交。
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: petstore-backend
|
name: petstore-backend
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://127.0.0.1:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&autoReconnect=true
|
url: ${MYSQL_URL:jdbc:mysql://127.0.0.1:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&autoReconnect=true}
|
||||||
username: root
|
username: ${MYSQL_USERNAME:root}
|
||||||
password: ${MYSQL_PASSWORD:changeme}
|
password: ${MYSQL_PASSWORD:}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
hikari:
|
hikari:
|
||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
@ -14,8 +14,8 @@ spring:
|
|||||||
idle-timeout: 600000
|
idle-timeout: 600000
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||||
show-sql: true
|
show-sql: ${JPA_SHOW_SQL:false}
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
dialect: org.hibernate.dialect.MySQLDialect
|
dialect: org.hibernate.dialect.MySQLDialect
|
||||||
@ -34,27 +34,32 @@ server:
|
|||||||
max-swallow-size: 200MB
|
max-swallow-size: 200MB
|
||||||
|
|
||||||
upload:
|
upload:
|
||||||
path: uploads
|
path: ${UPLOAD_PATH:/www/petstore/uploads}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.petstore: debug
|
com.petstore: ${LOG_LEVEL_PETSTORE:info}
|
||||||
|
|
||||||
wechat:
|
wechat:
|
||||||
appid: ${WECHAT_APPID:}
|
appid: ${WECHAT_APPID:}
|
||||||
appsecret: ${WECHAT_APPSECRET:}
|
appsecret: ${WECHAT_APPSECRET:}
|
||||||
redirect_uri: http://localhost:8080/api/wechat/callback
|
redirect_uri: ${WECHAT_REDIRECT_URI:http://localhost:8080/api/wechat/callback}
|
||||||
|
|
||||||
# 服务回顾短片(ffmpeg;可选 ffprobe 读时长/音轨)
|
# 服务回顾短片(ffmpeg;可选 ffprobe 读时长/音轨)
|
||||||
app:
|
app:
|
||||||
base-url: ${APP_BASE_URL:http://localhost:8080}
|
base-url: ${APP_BASE_URL:http://localhost:8080}
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174}
|
||||||
|
preflight-only: false
|
||||||
|
demo:
|
||||||
|
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
||||||
highlight-video:
|
highlight-video:
|
||||||
ffmpeg-binary: ffmpeg
|
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
||||||
ffprobe-binary: ffprobe
|
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
||||||
image-segment-sec: 2.0
|
image-segment-sec: 2.0
|
||||||
video-cap-sec: 8.0
|
video-cap-sec: 8.0
|
||||||
max-duration-sec: 300
|
max-duration-sec: ${HIGHLIGHT_MAX_DURATION_SEC:300}
|
||||||
bgm-path: ""
|
bgm-path: ${HIGHLIGHT_BGM_PATH:}
|
||||||
ken-burns-enabled: true
|
ken-burns-enabled: true
|
||||||
# 中文字体文件路径(drawtext 字幕用;留空则自动探测系统字体,找不到则跳过字幕)
|
# 中文字体文件路径(drawtext 字幕用;留空则自动探测系统字体,找不到则跳过字幕)
|
||||||
font-path: ${HIGHLIGHT_FONT_PATH:}
|
font-path: ${HIGHLIGHT_FONT_PATH:}
|
||||||
@ -65,9 +70,16 @@ auth:
|
|||||||
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
|
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
|
||||||
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
|
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
|
||||||
|
|
||||||
# 演示模式:仅 dev 默认万能验证码 123456;production profile 默认空(关闭)
|
management:
|
||||||
app.demo:
|
endpoints:
|
||||||
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: never
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
# 生产 profile:激活后禁用 ddl-auto:update / show-sql / 万能验证码
|
# 生产 profile:激活后禁用 ddl-auto:update / show-sql / 万能验证码
|
||||||
# 启动:java -jar petstore-backend.jar --spring.profiles.active=production
|
# 启动:java -jar petstore-backend.jar --spring.profiles.active=production
|
||||||
@ -81,5 +93,13 @@ spring:
|
|||||||
ddl-auto: validate
|
ddl-auto: validate
|
||||||
show-sql: false
|
show-sql: false
|
||||||
app:
|
app:
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
|
||||||
demo:
|
demo:
|
||||||
sms-universal-code: ${SMS_UNIVERSAL_CODE:}
|
sms-universal-code: ${SMS_UNIVERSAL_CODE:}
|
||||||
|
management:
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
group:
|
||||||
|
readiness:
|
||||||
|
include: readinessState,db,diskSpace,petstoreRuntime
|
||||||
|
|||||||
@ -47,6 +47,11 @@ logging:
|
|||||||
# 服务回顾短片:本地 ffmpeg 拼接(穿插编排、Ken Burns、可选 BGM)
|
# 服务回顾短片:本地 ffmpeg 拼接(穿插编排、Ken Burns、可选 BGM)
|
||||||
app:
|
app:
|
||||||
base-url: ${APP_BASE_URL:http://localhost:8080}
|
base-url: ${APP_BASE_URL:http://localhost:8080}
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174}
|
||||||
|
preflight-only: false
|
||||||
|
demo:
|
||||||
|
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
||||||
highlight-video:
|
highlight-video:
|
||||||
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
||||||
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
||||||
@ -74,9 +79,16 @@ auth:
|
|||||||
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
|
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
|
||||||
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
|
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
|
||||||
|
|
||||||
# 演示模式:仅 dev profile 允许万能验证码 123456;production profile 必须关闭
|
management:
|
||||||
app.demo:
|
endpoints:
|
||||||
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: never
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -91,5 +103,14 @@ spring:
|
|||||||
show-sql: false
|
show-sql: false
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
|
||||||
demo:
|
demo:
|
||||||
sms-universal-code: ${SMS_UNIVERSAL_CODE:}
|
sms-universal-code: ${SMS_UNIVERSAL_CODE:}
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
group:
|
||||||
|
readiness:
|
||||||
|
include: readinessState,db,diskSpace,petstoreRuntime
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.petstore.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.boot.actuate.health.Status;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class PetstoreRuntimeHealthIndicatorTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path uploadDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsUpWhenUploadDirectoryAndMediaCommandsAreReady() {
|
||||||
|
String javaBinary = Path.of(System.getProperty("java.home"), "bin", "java").toString();
|
||||||
|
PetstoreRuntimeHealthIndicator indicator = new PetstoreRuntimeHealthIndicator(
|
||||||
|
uploadDirectory.toString(), javaBinary, javaBinary
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(Status.UP, indicator.health().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsDownWhenUploadDirectoryIsMissing() {
|
||||||
|
String javaBinary = Path.of(System.getProperty("java.home"), "bin", "java").toString();
|
||||||
|
PetstoreRuntimeHealthIndicator indicator = new PetstoreRuntimeHealthIndicator(
|
||||||
|
uploadDirectory.resolve("missing").toString(), javaBinary, javaBinary
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(Status.DOWN, indicator.health().getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package com.petstore.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ProductionConfigurationValidatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validProductionConfigurationPasses() {
|
||||||
|
ProductionConfigurationValidator validator = validator(
|
||||||
|
"validate", false, "", "https://admin.chongxiaota.cn,https://report.chongxiaota.cn"
|
||||||
|
);
|
||||||
|
assertTrue(validator.validateValues().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unsafeDefaultsAreReportedWithoutValues() {
|
||||||
|
ProductionConfigurationValidator validator = validator(
|
||||||
|
"update", true, "123456", "*"
|
||||||
|
);
|
||||||
|
var failures = validator.validateValues();
|
||||||
|
assertTrue(failures.contains("JPA_DDL_AUTO=validate"));
|
||||||
|
assertTrue(failures.contains("JPA_SHOW_SQL=false"));
|
||||||
|
assertTrue(failures.contains("SMS_UNIVERSAL_CODE必须为空"));
|
||||||
|
assertTrue(failures.contains("CORS_ALLOWED_ORIGINS不得含通配符"));
|
||||||
|
assertEquals(4, failures.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void corsParserTrimsDeduplicatesAndDropsEmptyValues() {
|
||||||
|
assertEquals(
|
||||||
|
java.util.List.of("https://a.chongxiaota.cn", "https://b.chongxiaota.cn"),
|
||||||
|
CorsConfig.parseOrigins(" https://a.chongxiaota.cn/, ,https://b.chongxiaota.cn,https://a.chongxiaota.cn ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void placeholderAndPathOriginsAreRejected() {
|
||||||
|
ProductionConfigurationValidator placeholder = validator(
|
||||||
|
"validate", false, "", "https://admin.petstore.invalid"
|
||||||
|
);
|
||||||
|
assertTrue(placeholder.validateValues().contains("CORS_ALLOWED_ORIGINS必须全部为显式生产https源"));
|
||||||
|
|
||||||
|
ProductionConfigurationValidator pathOrigin = validator(
|
||||||
|
"validate", false, "", "https://admin.chongxiaota.cn/app"
|
||||||
|
);
|
||||||
|
assertTrue(pathOrigin.validateValues().contains("CORS_ALLOWED_ORIGINS必须全部为显式生产https源"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductionConfigurationValidator validator(
|
||||||
|
String ddlAuto,
|
||||||
|
boolean showSql,
|
||||||
|
String smsCode,
|
||||||
|
String origins) {
|
||||||
|
return new ProductionConfigurationValidator(
|
||||||
|
ddlAuto,
|
||||||
|
showSql,
|
||||||
|
"petstore",
|
||||||
|
"database-secret",
|
||||||
|
"wx-app-id",
|
||||||
|
"wx-app-secret",
|
||||||
|
"https://api.chongxiaota.cn/api/wechat/callback",
|
||||||
|
"01234567890123456789012345678901",
|
||||||
|
smsCode,
|
||||||
|
"https://api.chongxiaota.cn",
|
||||||
|
"/var/lib/petstore/uploads",
|
||||||
|
origins
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user