feat(openapi): harden contract export and align ljwx-phone to spec

This commit is contained in:
BrunoGaoSZ
2026-02-22 00:40:07 +01:00
parent d43381ac98
commit fdb7cb8c85
7 changed files with 298 additions and 28 deletions
+19 -3
View File
@@ -9,6 +9,7 @@ name: OpenAPI 契约维护
- 'ljwx-boot/**/*.java'
- 'ljwx-boot/**/pom.xml'
- 'ljwx-boot/api/openapi.json'
- 'ljwx-boot/api/openapi.yaml'
- '.gitea/workflows/openapi-contract.yml'
# Push 到主分支时更新基线
@@ -17,6 +18,7 @@ name: OpenAPI 契约维护
paths:
- 'ljwx-boot/**/*.java'
- 'ljwx-boot/**/pom.xml'
- 'ljwx-boot/scripts/export-openapi.sh'
# 允许手动触发
workflow_dispatch:
@@ -85,12 +87,19 @@ jobs:
./scripts/export-openapi.sh
echo "OpenAPI 契约导出成功"
- name: 校验 ljwx-phone OpenAPI 同步
run: |
chmod +x scripts/validate-phone-openapi-sync.sh
./scripts/validate-phone-openapi-sync.sh
# 7. 上传契约文件(用于调试)
- name: 上传 OpenAPI 契约
uses: actions/upload-artifact@v3
with:
name: openapi-contract
path: ljwx-boot/api/openapi.json
path: |
ljwx-boot/api/openapi.json
ljwx-boot/api/openapi.yaml
retention-days: 30
# 8. 检测破坏性变更(仅 PR)
@@ -237,11 +246,18 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
if git diff --quiet api/openapi.json; then
if git diff --quiet api/openapi.yaml; then
echo "契约无变化,跳过提交"
exit 0
fi
fi
git add api/openapi.json api/openapi.yaml
if git diff --cached --quiet; then
echo "契约无变化,跳过提交"
exit 0
fi
git add api/openapi.json
git commit -m "chore: update OpenAPI contract baseline [skip ci]"
git push origin main
+93 -9
View File
@@ -10,10 +10,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PORT="${PORT:-9998}"
OUT="${OUT:-${PROJECT_ROOT}/api/openapi.json}"
HEALTH_CHECK_URL="http://127.0.0.1:${PORT}/actuator/health"
API_DOCS_URL="http://127.0.0.1:${PORT}/v3/api-docs"
MAX_WAIT_TIME=120 # 最长等待时间(秒)
CHECK_INTERVAL=2 # 健康检查间隔(秒)
MONOREPO_ROOT="$(cd "${PROJECT_ROOT}/.." && pwd)"
OUT_YAML="${OUT_YAML:-${PROJECT_ROOT}/api/openapi.yaml}"
SYNC_ROOT="${SYNC_ROOT:-true}"
FORCE_REMOTE="${FORCE_REMOTE:-false}"
CURL_INSECURE="${CURL_INSECURE:-false}"
HEALTH_CHECK_URL="${HEALTH_CHECK_URL:-http://127.0.0.1:${PORT}/actuator/health}"
API_DOCS_URL="${API_DOCS_URL:-http://127.0.0.1:${PORT}/v3/api-docs}"
API_DOCS_YAML_URL="${API_DOCS_YAML_URL:-http://127.0.0.1:${PORT}/v3/api-docs.yaml}"
MAX_WAIT_TIME="${MAX_WAIT_TIME:-120}" # 最长等待时间(秒)
CHECK_INTERVAL="${CHECK_INTERVAL:-2}" # 健康检查间隔(秒)
# 颜色定义
GREEN='\033[0;32m'
@@ -27,6 +33,16 @@ log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
curl_fetch() {
local url="$1"
local out="$2"
if [ "${CURL_INSECURE}" = "true" ]; then
curl -k -fsS "${url}" -o "${out}"
else
curl -fsS "${url}" -o "${out}"
fi
}
# ==================== 环境检查 ====================
check_dependencies() {
local missing_deps=()
@@ -44,9 +60,45 @@ check_dependencies() {
fi
}
validate_required_paths() {
local spec_file="$1"
local required_paths=(
"/auth/user_name"
"/mobile/user/info"
"/mobile/health/data"
"/mobile/messages"
"/mobile/alerts"
"/mobile/alerts/process"
"/mobile/reset_password"
"/upload_health_data"
"/upload_device_info"
"/upload_common_event"
)
local missing_count=0
for path in "${required_paths[@]}"; do
if ! jq -e --arg p "${path}" --arg p2 "/api${path}" '.paths[$p] != null or .paths[$p2] != null' "${spec_file}" >/dev/null 2>&1; then
log_error "契约缺少关键路径: ${path}"
missing_count=$((missing_count + 1))
fi
done
if [ "${missing_count}" -gt 0 ]; then
log_error "OpenAPI 不完整,缺少 ${missing_count} 个关键路径"
return 1
fi
log_success "关键路径完整性检查通过"
return 0
}
# ==================== 应用状态检查 ====================
is_app_running() {
curl -fsS "${HEALTH_CHECK_URL}" >/dev/null 2>&1
if [ "${CURL_INSECURE}" = "true" ]; then
curl -k -fsS "${HEALTH_CHECK_URL}" >/dev/null 2>&1
else
curl -fsS "${HEALTH_CHECK_URL}" >/dev/null 2>&1
fi
}
wait_for_app_ready() {
@@ -120,13 +172,14 @@ cleanup_on_error() {
# ==================== OpenAPI 导出 ====================
export_openapi() {
log_info "导出 OpenAPI 契约..."
log_info "导出 OpenAPI 契约(JSON + YAML)..."
# 创建输出目录
mkdir -p "$(dirname "${OUT}")"
mkdir -p "$(dirname "${OUT_YAML}")"
# 获取 OpenAPI JSON
if ! curl -fsS "${API_DOCS_URL}" -o "${OUT}.tmp"; then
if ! curl_fetch "${API_DOCS_URL}" "${OUT}.tmp"; then
log_error "无法访问 ${API_DOCS_URL}"
return 1
fi
@@ -143,6 +196,33 @@ export_openapi() {
jq '.' "${OUT}.tmp" > "${OUT}"
rm -f "${OUT}.tmp"
# 关键路径完整性校验
if ! validate_required_paths "${OUT}"; then
return 1
fi
# 获取 OpenAPI YAML
if ! curl_fetch "${API_DOCS_YAML_URL}" "${OUT_YAML}.tmp"; then
log_error "无法访问 ${API_DOCS_YAML_URL}"
return 1
fi
if ! grep -q "^openapi:" "${OUT_YAML}.tmp"; then
log_error "返回的 YAML 缺少 openapi 头"
rm -f "${OUT_YAML}.tmp"
return 1
fi
mv "${OUT_YAML}.tmp" "${OUT_YAML}"
# 同步 monorepo 根目录契约,避免多份漂移
if [ "${SYNC_ROOT}" = "true" ]; then
mkdir -p "${MONOREPO_ROOT}/api" "${MONOREPO_ROOT}/openapi"
cp "${OUT}" "${MONOREPO_ROOT}/api/openapi.json"
cp "${OUT_YAML}" "${MONOREPO_ROOT}/openapi/openapi.yaml"
log_success "已同步根目录契约: api/openapi.json, openapi/openapi.yaml"
fi
# 提取关键信息
local api_version=$(jq -r '.info.version // "unknown"' "${OUT}")
local api_title=$(jq -r '.info.title // "unknown"' "${OUT}")
@@ -155,7 +235,8 @@ export_openapi() {
log_info " 版本: ${api_version}"
log_info " 接口数: ${paths_count}"
log_info " Schema数: ${schemas_count}"
log_info " 文件大小: $(ls -lh "${OUT}" | awk '{print $5}')"
log_info " JSON大小: $(ls -lh "${OUT}" | awk '{print $5}')"
log_info " YAML大小: $(ls -lh "${OUT_YAML}" | awk '{print $5}')"
}
# ==================== 主流程 ====================
@@ -170,7 +251,10 @@ main() {
check_dependencies
# 2. 检查应用是否已运行
if is_app_running; then
if [ "${FORCE_REMOTE}" = "true" ]; then
log_info "FORCE_REMOTE=true,跳过本地启动,直接导出..."
app_was_running=true
elif is_app_running; then
log_info "检测到应用已在运行,直接导出..."
app_was_running=true
else
+22 -1
View File
@@ -229,6 +229,27 @@ lib/
└── 📊 utils/ # 工具类
```
## OpenAPI 同步
`ljwx-phone` 现已基于后端 OpenAPI 契约统一接口路径常量,避免手写 URL 漂移。
1. 导出后端最新契约
```bash
cd ljwx-boot
./scripts/export-openapi.sh
```
2. 生成移动端接口常量
```bash
cd ../ljwx-phone
./tool/generate_openapi_endpoints.sh
```
3. 校验同步(仓库根目录)
```bash
./scripts/validate-phone-openapi-sync.sh
```
## 🔧 安装与运行
### 前置要求
@@ -387,4 +408,4 @@ double calculateHealthScore() {
**Built with ❤️ by ljwx Team**
`Last Updated: 2025-08-29`
</div>
</div>
+16 -15
View File
@@ -12,6 +12,7 @@ import '../models/risk_alert.dart';
import '../models/ai_health_suggestion.dart';
import 'auth_service_v2.dart';
import 'cache_service.dart';
import 'openapi_endpoints.dart';
import '../utils/error_handler.dart';
import '../utils/logger.dart';
@@ -211,7 +212,7 @@ class ApiClient {
}
try {
final response = await _dio.get('/mobile/user/info');
final response = await _dio.get(OpenApiEndpoints.mobileUserInfo);
if (response.data['code'] == 200) {
final data = response.data['data'];
@@ -241,7 +242,7 @@ class ApiClient {
}
try {
final response = await _dio.get('/mobile/user/info');
final response = await _dio.get(OpenApiEndpoints.mobileUserInfo);
if (response.data['code'] == 200) {
final data = response.data['data'];
@@ -286,7 +287,7 @@ class ApiClient {
// User context (userId, customerId, orgId) is automatically injected by backend
// No need to pass these parameters manually
final response = await _dio.get('/mobile/health/data', queryParameters: queryParams);
final response = await _dio.get(OpenApiEndpoints.mobileHealthData, queryParameters: queryParams);
if (response.data['code'] == 200) {
final records = response.data['data']['records'] as List;
@@ -373,7 +374,7 @@ class ApiClient {
}) async {
try {
// Use new mobile API endpoint
final response = await _dio.get('/mobile/messages', queryParameters: {
final response = await _dio.get(OpenApiEndpoints.mobileMessages, queryParameters: {
'page': page,
'pageSize': pageSize,
if (status != null) 'status': status,
@@ -426,7 +427,7 @@ class ApiClient {
String? level,
}) async {
try {
final response = await _dio.get('/mobile/alerts', queryParameters: {
final response = await _dio.get(OpenApiEndpoints.mobileAlerts, queryParameters: {
'page': page,
'pageSize': pageSize,
if (status != null) 'status': status,
@@ -455,7 +456,7 @@ class ApiClient {
/// 获取未处理告警数量
Future<int> getUnprocessedAlertCount() async {
try {
final response = await _dio.get('/mobile/alerts/unprocessed/count');
final response = await _dio.get(OpenApiEndpoints.mobileAlertsUnprocessedCount);
if (response.data['code'] == 200) {
return (response.data['data'] as num?)?.toInt() ?? 0;
@@ -481,7 +482,7 @@ class ApiClient {
return await _retryOnNetworkError<GlobalHealthStatus?>(
() async {
final response = await _dio.get('/mobile/health/status/global');
final response = await _dio.get(OpenApiEndpoints.mobileHealthStatusGlobal);
if (response.data['code'] == 200) {
final status = GlobalHealthStatus.fromJson(response.data['data']);
@@ -510,7 +511,7 @@ class ApiClient {
final result = await _retryOnNetworkError<List<HealthMetric>>(
() async {
final response = await _dio.get('/mobile/health/metrics/key');
final response = await _dio.get(OpenApiEndpoints.mobileHealthMetricsKey);
if (response.data['code'] == 200) {
final List<dynamic> data = response.data['data'];
@@ -542,7 +543,7 @@ class ApiClient {
return await _retryOnNetworkError<RiskAlert?>(
() async {
final response = await _dio.get('/mobile/health/alerts/summary', queryParameters: {
final response = await _dio.get(OpenApiEndpoints.mobileHealthAlertsSummary, queryParameters: {
'hours': hours,
});
@@ -576,7 +577,7 @@ class ApiClient {
return await _retryOnNetworkError<AIHealthSuggestion?>(
() async {
final response = await _dio.get('/mobile/health/ai/suggestions');
final response = await _dio.get(OpenApiEndpoints.mobileHealthAiSuggestions);
if (response.data['code'] == 200) {
final data = response.data['data'];
@@ -597,7 +598,7 @@ class ApiClient {
/// 处理告警
Future<bool> processAlert(String alertId, String status) async {
try {
final response = await _dio.post('/t_alert_info/process', data: {
final response = await _dio.post(OpenApiEndpoints.mobileAlertsProcess, data: {
'alertId': alertId,
'status': status,
'processedTime': DateTime.now().toIso8601String(),
@@ -613,7 +614,7 @@ class ApiClient {
/// 上传健康数据(保留兼容旧接口)
Future<bool> uploadHealthData(Map<String, dynamic> healthData) async {
try {
final response = await _dio.post('/upload_health_data', data: healthData);
final response = await _dio.post(OpenApiEndpoints.uploadHealthData, data: healthData);
return response.statusCode == 200;
} catch (e) {
@@ -625,7 +626,7 @@ class ApiClient {
/// 上传设备信息(保留兼容旧接口)
Future<bool> uploadDeviceInfo(Map<String, dynamic> deviceInfo) async {
try {
final response = await _dio.post('/upload_device_info', data: deviceInfo);
final response = await _dio.post(OpenApiEndpoints.uploadDeviceInfo, data: deviceInfo);
return response.statusCode == 200;
} catch (e) {
@@ -638,7 +639,7 @@ class ApiClient {
Future<bool> resetPassword(String oldPassword, String newPassword) async {
try {
// Use mobile API endpoint
final response = await _dio.post('/mobile/reset_password', data: {
final response = await _dio.post(OpenApiEndpoints.mobileResetPassword, data: {
'oldPassword': oldPassword,
'newPassword': newPassword,
});
@@ -653,7 +654,7 @@ class ApiClient {
/// 更新用户信息
Future<bool> updateUserInfo(Map<String, dynamic> userInfo) async {
try {
final response = await _dio.put('/sys/user/info', data: userInfo);
final response = await _dio.put(OpenApiEndpoints.authUserInfo, data: userInfo);
return response.data['code'] == 200;
} catch (e) {
@@ -0,0 +1,25 @@
// GENERATED FILE - DO NOT EDIT MANUALLY
// Generated by: ljwx-phone/tool/generate_openapi_endpoints.sh
// Source: ljwx-boot/api/openapi.json
class OpenApiEndpoints {
OpenApiEndpoints._();
static const mobileUserInfo = '/mobile/user/info';
static const mobileHealthData = '/mobile/health/data';
static const mobileMessages = '/mobile/messages';
static const mobileAlerts = '/mobile/alerts';
static const mobileAlertsUnprocessedCount = '/mobile/alerts/unprocessed/count';
static const mobileHealthStatusGlobal = '/mobile/health/status/global';
static const mobileHealthMetricsKey = '/mobile/health/metrics/key';
static const mobileHealthAlertsSummary = '/mobile/health/alerts/summary';
static const mobileHealthAiSuggestions = '/mobile/health/ai/suggestions';
static const mobileAlertsProcess = '/mobile/alerts/process';
static const mobileResetPassword = '/mobile/reset_password';
static const authUserInfo = '/auth/user_info';
// Compatibility upload endpoints retained for device-side integration.
static const uploadHealthData = '/upload_health_data';
static const uploadDeviceInfo = '/upload_device_info';
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PHONE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${PHONE_ROOT}/.." && pwd)"
OPENAPI_JSON="${OPENAPI_JSON:-${REPO_ROOT}/ljwx-boot/api/openapi.json}"
OUT_FILE="${OUT_FILE:-${PHONE_ROOT}/lib/services/openapi_endpoints.dart}"
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq is required"
exit 1
fi
if [[ ! -f "${OPENAPI_JSON}" ]]; then
echo "[ERROR] OpenAPI contract not found: ${OPENAPI_JSON}"
exit 1
fi
has_path() {
local p="$1"
jq -e --arg p "${p}" '.paths[$p] != null' "${OPENAPI_JSON}" >/dev/null 2>&1
}
resolve_path() {
local canonical="$1"
if has_path "${canonical}"; then
echo "${canonical}"
return 0
fi
if has_path "/api${canonical}"; then
echo "/api${canonical}"
return 0
fi
return 1
}
declare -A resolved
declare -A required_map=(
[mobileUserInfo]="/mobile/user/info"
[mobileHealthData]="/mobile/health/data"
[mobileMessages]="/mobile/messages"
[mobileAlerts]="/mobile/alerts"
[mobileAlertsUnprocessedCount]="/mobile/alerts/unprocessed/count"
[mobileHealthStatusGlobal]="/mobile/health/status/global"
[mobileHealthMetricsKey]="/mobile/health/metrics/key"
[mobileHealthAlertsSummary]="/mobile/health/alerts/summary"
[mobileHealthAiSuggestions]="/mobile/health/ai/suggestions"
[mobileAlertsProcess]="/mobile/alerts/process"
[mobileResetPassword]="/mobile/reset_password"
[authUserInfo]="/auth/user_info"
[uploadHealthData]="/upload_health_data"
[uploadDeviceInfo]="/upload_device_info"
)
missing=0
for key in "${!required_map[@]}"; do
canonical="${required_map[$key]}"
if ! resolved_value="$(resolve_path "${canonical}")"; then
echo "[ERROR] missing endpoint in OpenAPI: ${canonical} (or /api${canonical})"
missing=$((missing + 1))
continue
fi
resolved["$key"]="${resolved_value}"
done
if [[ "${missing}" -gt 0 ]]; then
echo "[ERROR] OpenAPI is incomplete for ljwx-phone, missing=${missing}"
exit 1
fi
mkdir -p "$(dirname "${OUT_FILE}")"
cat > "${OUT_FILE}" <<EOF
// GENERATED FILE - DO NOT EDIT MANUALLY
// Generated by: ljwx-phone/tool/generate_openapi_endpoints.sh
// Source: ljwx-boot/api/openapi.json
class OpenApiEndpoints {
OpenApiEndpoints._();
static const mobileUserInfo = '${resolved[mobileUserInfo]}';
static const mobileHealthData = '${resolved[mobileHealthData]}';
static const mobileMessages = '${resolved[mobileMessages]}';
static const mobileAlerts = '${resolved[mobileAlerts]}';
static const mobileAlertsUnprocessedCount = '${resolved[mobileAlertsUnprocessedCount]}';
static const mobileHealthStatusGlobal = '${resolved[mobileHealthStatusGlobal]}';
static const mobileHealthMetricsKey = '${resolved[mobileHealthMetricsKey]}';
static const mobileHealthAlertsSummary = '${resolved[mobileHealthAlertsSummary]}';
static const mobileHealthAiSuggestions = '${resolved[mobileHealthAiSuggestions]}';
static const mobileAlertsProcess = '${resolved[mobileAlertsProcess]}';
static const mobileResetPassword = '${resolved[mobileResetPassword]}';
static const authUserInfo = '${resolved[authUserInfo]}';
// Compatibility upload endpoints retained for device-side integration.
static const uploadHealthData = '${resolved[uploadHealthData]}';
static const uploadDeviceInfo = '${resolved[uploadDeviceInfo]}';
}
EOF
echo "[SUCCESS] generated: ${OUT_FILE}"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO_ROOT}"
echo "[INFO] generating ljwx-phone OpenAPI endpoint constants..."
chmod +x ljwx-phone/tool/generate_openapi_endpoints.sh
ljwx-phone/tool/generate_openapi_endpoints.sh
if ! git diff --quiet -- ljwx-phone/lib/services/openapi_endpoints.dart; then
echo "[ERROR] ljwx-phone OpenAPI endpoint constants are out of sync."
echo "[HINT] run: ljwx-phone/tool/generate_openapi_endpoints.sh"
echo "[HINT] then commit: ljwx-phone/lib/services/openapi_endpoints.dart"
git --no-pager diff -- ljwx-phone/lib/services/openapi_endpoints.dart | sed -n '1,200p'
exit 1
fi
echo "[SUCCESS] ljwx-phone OpenAPI endpoint constants are in sync."