diff --git a/README.md b/README.md index 1defb9a..406583c 100644 --- a/README.md +++ b/README.md @@ -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 -u -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:// 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)已轮换 ## 安全说明 diff --git a/deploy/nginx-petstore.conf.example b/deploy/nginx-petstore.conf.example new file mode 100644 index 0000000..ee18bba --- /dev/null +++ b/deploy/nginx-petstore.conf.example @@ -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; + } +} diff --git a/deploy/petstore-backend.env.example b/deploy/petstore-backend.env.example new file mode 100644 index 0000000..e3493a9 --- /dev/null +++ b/deploy/petstore-backend.env.example @@ -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= diff --git a/deploy/petstore-backend.service.example b/deploy/petstore-backend.service.example new file mode 100644 index 0000000..607babf --- /dev/null +++ b/deploy/petstore-backend.service.example @@ -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 diff --git a/deploy/production-smoke.sh b/deploy/production-smoke.sh new file mode 100755 index 0000000..4f062c2 --- /dev/null +++ b/deploy/production-smoke.sh @@ -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' diff --git a/deploy/release-preflight.sh b/deploy/release-preflight.sh new file mode 100755 index 0000000..28e9ae8 --- /dev/null +++ b/deploy/release-preflight.sh @@ -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 diff --git a/pom.xml b/pom.xml index b789660..40479c6 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/petstore/PetstoreApplication.java b/src/main/java/com/petstore/PetstoreApplication.java index a6ffd4d..095a8ea 100644 --- a/src/main/java/com/petstore/PetstoreApplication.java +++ b/src/main/java/com/petstore/PetstoreApplication.java @@ -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(); } diff --git a/src/main/java/com/petstore/config/CorsConfig.java b/src/main/java/com/petstore/config/CorsConfig.java index eb84bd3..167bc9b 100644 --- a/src/main/java/com/petstore/config/CorsConfig.java +++ b/src/main/java/com/petstore/config/CorsConfig.java @@ -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 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 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(); + } } diff --git a/src/main/java/com/petstore/config/PetstoreRuntimeHealthIndicator.java b/src/main/java/com/petstore/config/PetstoreRuntimeHealthIndicator.java new file mode 100644 index 0000000..cb9e19b --- /dev/null +++ b/src/main/java/com/petstore/config/PetstoreRuntimeHealthIndicator.java @@ -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(); + } + } + } +} diff --git a/src/main/java/com/petstore/config/ProductionConfigurationValidator.java b/src/main/java/com/petstore/config/ProductionConfigurationValidator.java new file mode 100644 index 0000000..ce941b4 --- /dev/null +++ b/src/main/java/com/petstore/config/ProductionConfigurationValidator.java @@ -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 failures = validateValues(); + if (!failures.isEmpty()) { + throw new IllegalStateException("生产配置校验失败,需修正: " + String.join(", ", failures)); + } + } + + List validateValues() { + List 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 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 failures) { + if (text(value).isEmpty()) failures.add(name); + } + + private static void requireHttpsUrl(String value, String name, List failures) { + String normalized = text(value); + if (!isSafeHttpsUrl(normalized)) { + failures.add(name + "必须为生产https地址"); + } + } + + private static void requireHttpsOrigin(String value, String name, List 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(); + } +} diff --git a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java new file mode 100644 index 0000000..8ade182 --- /dev/null +++ b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java @@ -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 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 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) { + } +} diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml index f8f106b..e33bd50 100644 --- a/src/main/resources/application-example.yml +++ b/src/main/resources/application-example.yml @@ -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 默认万能验证码 123456;production 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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef5cc73..f18533c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 允许万能验证码 123456;production 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 diff --git a/src/test/java/com/petstore/config/PetstoreRuntimeHealthIndicatorTest.java b/src/test/java/com/petstore/config/PetstoreRuntimeHealthIndicatorTest.java new file mode 100644 index 0000000..6bf679f --- /dev/null +++ b/src/test/java/com/petstore/config/PetstoreRuntimeHealthIndicatorTest.java @@ -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()); + } +} diff --git a/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java b/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java new file mode 100644 index 0000000..e02e026 --- /dev/null +++ b/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java @@ -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 + ); + } +}