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_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)已轮换
|
||||
|
||||
## 安全说明
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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