feat: add production release safeguards

This commit is contained in:
malei 2026-08-01 23:30:32 +08:00
parent b6138475f0
commit 133f61e4ad
16 changed files with 857 additions and 26 deletions

View File

@ -15,23 +15,24 @@
| 变量 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `MYSQL_URL` | | `jdbc:mysql://127.0.0.1:3306/petstore?...` | 数据库 JDBC URL |
| `MYSQL_USERNAME` | 否 | `root` | 数据库用户名 |
| `MYSQL_URL` | 生产必填 | `jdbc:mysql://127.0.0.1:3306/petstore?...` | 数据库 JDBC URL;发布预检要求显式提供 |
| `MYSQL_USERNAME` | 生产必填 | `root` | 数据库用户名;生产使用最小权限专用账号 |
| `MYSQL_PASSWORD` | **是** | 空 | 数据库密码 |
| `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID |
| `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_TTL_SECONDS` | 否 | `604800`7 天) | session token 有效期 |
| `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_FFPROBE` | 否 | `ffprobe` | ffprobe 可执行路径 |
| `HIGHLIGHT_MAX_DURATION_SEC` | 否 | `300` | 成片最大总秒数 |
| `HIGHLIGHT_BGM_PATH` | 否 | 空 | 商用授权 BGM 绝对路径;留空不混音 |
| `SMS_UNIVERSAL_CODE` | **生产必须为空** | `123456`dev/ 空production | 演示万能验证码production profile 自动关闭 |
| `JPA_DDL_AUTO` | 否 | `update`dev/ `validate`production | JPA DDL 模式 |
| `JPA_SHOW_SQL` | 否 | `false` | SQL 日志 |
| `SPRING_JPA_HIBERNATE_DDL_AUTO` | 否 | `validate`production | 生产只能为 `validate`;禁止自动改表 |
| `SPRING_JPA_SHOW_SQL` | 否 | `false` | 生产 SQL 日志开关,必须为 `false` |
| `LOG_LEVEL_PETSTORE` | 否 | `info` | `com.petstore` 包日志级别 |
> ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`
@ -94,6 +95,12 @@ curl http://localhost:8080/api/store/list
```
脚本创建不可变 `t_business_event` 并回填可确认的历史事实。末尾四个验证计数必须全部为 0报告打开和服务开始没有可靠历史数据不做猜测性回填。
7. **真实预约容量**:业务事件验证完成后执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_booking_capacity.sql
```
脚本新增门店并发容量、服务时长、预约时长快照和连续排班占用。末尾容量、时长和快照验证计数必须全部为 0。
### production profile
```bash
@ -104,6 +111,19 @@ production profile 下:
- `ddl-auto=validate`(只校验,不改表)
- `show-sql=false`
- `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 run build:h5
npm --prefix frontend run build:mp-weixin
# 5. 上线后只读冒烟
API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
```
### RC 检查项
@ -155,6 +178,10 @@ npm --prefix frontend run build:mp-weixin
- [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果
- [ ] 已按顺序执行 `20260801_create_store_customer.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已轮换
## 安全说明

View 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;
}
}

View 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=

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

View File

@ -46,6 +46,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@ -6,6 +6,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
@SpringBootApplication
@RequiredArgsConstructor
@ -26,6 +27,7 @@ public class PetstoreApplication {
* 启动期不再改表结构便于审计与生产回滚
*/
@Bean
@Profile("!production")
CommandLineRunner initRunner(ServiceTypeService serviceTypeService) {
return args -> serviceTypeService.initDefaults();
}

View File

@ -2,17 +2,27 @@ package com.petstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
import java.util.List;
@Configuration
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
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
config.setAllowedOrigins(allowedOrigins);
config.addAllowedHeader("*");
config.addAllowedMethod("*");
@ -20,4 +30,16 @@ public class CorsConfig {
source.registerCorsConfiguration("/**", config);
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();
}
}

View File

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

View File

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

View File

@ -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) {
}
}

View File

@ -1,11 +1,11 @@
# 复制为 application.yml 后填写本地/生产配置application.yml 已加入 .gitignore勿提交密钥
# 配置参考。真实密钥仅通过环境变量或服务器权限受控的 EnvironmentFile 注入,勿提交。
spring:
application:
name: petstore-backend
datasource:
url: jdbc:mysql://127.0.0.1:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&autoReconnect=true
username: root
password: ${MYSQL_PASSWORD:changeme}
url: ${MYSQL_URL:jdbc:mysql://127.0.0.1:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&autoReconnect=true}
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
max-lifetime: 1800000
@ -14,8 +14,8 @@ spring:
idle-timeout: 600000
jpa:
hibernate:
ddl-auto: update
show-sql: true
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
@ -34,27 +34,32 @@ server:
max-swallow-size: 200MB
upload:
path: uploads
path: ${UPLOAD_PATH:/www/petstore/uploads}
logging:
level:
com.petstore: debug
com.petstore: ${LOG_LEVEL_PETSTORE:info}
wechat:
appid: ${WECHAT_APPID:}
appsecret: ${WECHAT_APPSECRET:}
redirect_uri: http://localhost:8080/api/wechat/callback
redirect_uri: ${WECHAT_REDIRECT_URI:http://localhost:8080/api/wechat/callback}
# 服务回顾短片ffmpeg可选 ffprobe 读时长/音轨)
app:
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:
ffmpeg-binary: ffmpeg
ffprobe-binary: ffprobe
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
image-segment-sec: 2.0
video-cap-sec: 8.0
max-duration-sec: 300
bgm-path: ""
max-duration-sec: ${HIGHLIGHT_MAX_DURATION_SEC:300}
bgm-path: ${HIGHLIGHT_BGM_PATH:}
ken-burns-enabled: true
# 中文字体文件路径drawtext 字幕用;留空则自动探测系统字体,找不到则跳过字幕)
font-path: ${HIGHLIGHT_FONT_PATH:}
@ -65,9 +70,16 @@ auth:
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
# 演示模式:仅 dev 默认万能验证码 123456production profile 默认空(关闭)
app.demo:
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never
probes:
enabled: true
# 生产 profile激活后禁用 ddl-auto:update / show-sql / 万能验证码
# 启动java -jar petstore-backend.jar --spring.profiles.active=production
@ -81,5 +93,13 @@ spring:
ddl-auto: validate
show-sql: false
app:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
demo:
sms-universal-code: ${SMS_UNIVERSAL_CODE:}
management:
endpoint:
health:
group:
readiness:
include: readinessState,db,diskSpace,petstoreRuntime

View File

@ -47,6 +47,11 @@ logging:
# 服务回顾短片:本地 ffmpeg 拼接穿插编排、Ken Burns、可选 BGM
app:
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:
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
@ -74,9 +79,16 @@ auth:
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
# 演示模式:仅 dev profile 允许万能验证码 123456production profile 必须关闭
app.demo:
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never
probes:
enabled: true
---
@ -91,5 +103,14 @@ spring:
show-sql: false
app:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:}
demo:
sms-universal-code: ${SMS_UNIVERSAL_CODE:}
management:
endpoint:
health:
group:
readiness:
include: readinessState,db,diskSpace,petstoreRuntime

View File

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

View File

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