feat(deploy): 添加生产环境部署配置和联调环境切换功能

- 新增 .env.production.example 配置文件,包含所有微服务的数据库和Redis配置
- 添加 compose.production.yaml Docker Compose部署文件,定义web和各服务容器
- 实现Android SDK环境切换功能,支持外部服务和本地联调模式切换
- 添加推送注册状态管理和接收开关设置界面
- 集成演示服务的应用密钥客户端和认证服务实现
- 完善文档说明各SDK模块的集成和使用方法
这个提交包含在:
XuqmGroup 2026-04-30 11:47:01 +08:00
父节点 32b0e49e61
当前提交 f6c06db04b
共有 25 个文件被更改,包括 265 次插入53 次删除

1
Jenkinsfile vendored
查看文件

@ -2,6 +2,7 @@ pipeline {
agent any agent any
parameters { parameters {
string(name: 'BRANCH', defaultValue: 'main', description: 'Git 分支名')
choice(name: 'SERVICE', choices: ['tenant-service', 'im-service', 'push-service', 'update-service', 'demo-service', 'file-service'], description: '要构建的服务模块') choice(name: 'SERVICE', choices: ['tenant-service', 'im-service', 'push-service', 'update-service', 'demo-service', 'file-service'], description: '要构建的服务模块')
string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag如 v1.2.3 或 latest') string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag如 v1.2.3 或 latest')
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器') booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器')

查看文件

@ -21,7 +21,7 @@ public class DemoAppSecretClient {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final Map<String, String> cache = new ConcurrentHashMap<>(); private final Map<String, String> cache = new ConcurrentHashMap<>();
@Value("${demo.tenant-service-url:http://192.168.116.9:8081}") @Value("${demo.tenant-service-url:http://127.0.0.1:8081}")
private String tenantServiceUrl; private String tenantServiceUrl;
@Value("${demo.internal-token:xuqm-internal-token}") @Value("${demo.internal-token:xuqm-internal-token}")

查看文件

@ -35,7 +35,7 @@ public class DemoAuthService {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final DemoAppSecretClient appSecretClient; private final DemoAppSecretClient appSecretClient;
@Value("${demo.im-service-url:http://192.168.116.9:8082}") @Value("${demo.im-service-url:http://127.0.0.1:8082}")
private String imServiceUrl; private String imServiceUrl;
public DemoAuthService(DemoUserRepository userRepository, public DemoAuthService(DemoUserRepository userRepository,

查看文件

@ -35,9 +35,9 @@ jwt:
expiration: 86400000 expiration: 86400000
demo: demo:
tenant-service-url: ${TENANT_SERVICE_URL:http://192.168.116.9:8081} tenant-service-url: ${TENANT_SERVICE_URL:http://127.0.0.1:8081}
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
im-service-url: ${IM_SERVICE_URL:http://192.168.116.9:8082} im-service-url: ${IM_SERVICE_URL:http://127.0.0.1:8082}
logging: logging:
level: level:

查看文件

@ -88,7 +88,6 @@
| PUT | `/api/apps/{id}` | 是 | 更新应用 | | PUT | `/api/apps/{id}` | 是 | 更新应用 |
| DELETE | `/api/apps/{id}` | 是 | 删除应用 | | DELETE | `/api/apps/{id}` | 是 | 删除应用 |
| GET | `/api/apps/{appId}/services` | 是 | 服务列表 | | GET | `/api/apps/{appId}/services` | 是 | 服务列表 |
| GET | `/api/apps/{appId}/services/item` | 是 | 按平台和服务类型查询单条服务配置 |
| PUT | `/api/apps/{appId}/services/config` | 是 | 更新服务配置,IM 和 UPDATE 走各自的配置模型 | | PUT | `/api/apps/{appId}/services/config` | 是 | 更新服务配置,IM 和 UPDATE 走各自的配置模型 |
| POST | `/api/apps/{appId}/services/toggle` | 是 | 开关服务 | | POST | `/api/apps/{appId}/services/toggle` | 是 | 开关服务 |
| POST | `/api/apps/{appId}/services/{id}/regenerate-key` | 是 | 重新生成服务密钥 | | POST | `/api/apps/{appId}/services/{id}/regenerate-key` | 是 | 重新生成服务密钥 |
@ -173,6 +172,8 @@
- 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。 - 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。
- 上下架、上传、发布、灰度、市场提交、商店配置变更都会写入 `update_operation_log`,可通过 `GET /api/v1/updates/ops/logs?appId=...` 查询。 - 上下架、上传、发布、灰度、市场提交、商店配置变更都会写入 `update_operation_log`,可通过 `GET /api/v1/updates/ops/logs?appId=...` 查询。
- 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。 - 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。
- 租户平台控制台新增 `GET /api/dashboard/stats`,返回当前租户的应用数、已开通服务数和子账号数,同时会写一条 `CONSOLE / DASHBOARD / VIEW_DASHBOARD` 操作日志。
- 租户平台“操作日志”菜单现在集中查看租户平台与版本管理两类日志;版本管理日志继续按 `appId` 查询,控制台访问日志则落在 `t_operation_log`
## update-sdk 自动发版 ## update-sdk 自动发版

查看文件

@ -54,7 +54,6 @@ public class SecurityConfig {
config.setAllowedOriginPatterns(List.of( config.setAllowedOriginPatterns(List.of(
"http://localhost:*", "http://localhost:*",
"http://127.0.0.1:*", "http://127.0.0.1:*",
"http://192.168.116.9:*",
"http://*.xuqinmin.com", "http://*.xuqinmin.com",
"https://*.xuqinmin.com" "https://*.xuqinmin.com"
)); ));

查看文件

@ -41,7 +41,7 @@ jwt:
file: file:
upload-dir: ${FILE_UPLOAD_DIR:/tmp/xuqm-file-upload} upload-dir: ${FILE_UPLOAD_DIR:/tmp/xuqm-file-upload}
base-url: ${FILE_BASE_URL:http://192.168.116.9:8086} base-url: ${FILE_BASE_URL:https://file.dev.xuqinmin.com}
logging: logging:
level: level:

查看文件

@ -56,7 +56,8 @@ public class SecurityConfig {
config.setAllowedOriginPatterns(List.of( config.setAllowedOriginPatterns(List.of(
"http://localhost:*", "http://localhost:*",
"http://127.0.0.1:*", "http://127.0.0.1:*",
"http://192.168.116.9:*" "http://*.xuqinmin.com",
"https://*.xuqinmin.com"
)); ));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); config.setAllowedHeaders(List.of("*"));

查看文件

@ -2,6 +2,8 @@ package com.xuqm.im.controller;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
@ -12,6 +14,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) { public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
return ResponseEntity.status(resolveStatus(e.getCode())) return ResponseEntity.status(resolveStatus(e.getCode()))
@ -40,8 +44,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) { public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(500, e.getMessage() == null ? "服务异常" : e.getMessage())); .body(ApiResponse.error(500, "服务异常"));
} }
private HttpStatus resolveStatus(int code) { private HttpStatus resolveStatus(int code) {

查看文件

@ -21,7 +21,7 @@ public class ImAppSecretClient {
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private final Map<String, String> cache = new ConcurrentHashMap<>(); private final Map<String, String> cache = new ConcurrentHashMap<>();
@Value("${im.tenant-service-url:http://192.168.116.9:8081}") @Value("${im.tenant-service-url:http://127.0.0.1:8081}")
private String tenantServiceUrl; private String tenantServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}") @Value("${im.internal-token:xuqm-internal-token}")

查看文件

@ -2,6 +2,8 @@ package com.xuqm.im.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -14,11 +16,12 @@ import org.springframework.web.util.UriComponentsBuilder;
@Component @Component
public class ImFeatureConfigClient { public class ImFeatureConfigClient {
private static final Logger log = LoggerFactory.getLogger(ImFeatureConfigClient.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
@Value("${im.tenant-service-url:http://192.168.116.9:8081}") @Value("${im.tenant-service-url:http://127.0.0.1:8081}")
private String tenantServiceUrl; private String tenantServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}") @Value("${im.internal-token:xuqm-internal-token}")
@ -93,7 +96,7 @@ public class ImFeatureConfigClient {
} }
} }
} catch (Exception e) { } catch (Exception e) {
// Fail closed: if config cannot be read, keep the feature disabled. log.warn("Failed to read IM feature config for appId={}: {}", appId, e.getMessage());
} }
return OBJECT_MAPPER.createObjectNode(); return OBJECT_MAPPER.createObjectNode();
} }

查看文件

@ -1,9 +1,10 @@
package com.xuqm.im.service; package com.xuqm.im.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@ -16,10 +17,12 @@ import java.util.Map;
@Component @Component
public class ImPushBridgeClient { public class ImPushBridgeClient {
private static final Logger log = LoggerFactory.getLogger(ImPushBridgeClient.class);
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Value("${im.push-service-url:http://192.168.116.9:8083}") @Value("${im.push-service-url:http://127.0.0.1:8083}")
private String pushServiceUrl; private String pushServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}") @Value("${im.internal-token:xuqm-internal-token}")
@ -48,7 +51,8 @@ public class ImPushBridgeClient {
.POST(HttpRequest.BodyPublishers.ofString(json)) .POST(HttpRequest.BodyPublishers.ofString(json))
.build(); .build();
httpClient.send(request, HttpResponse.BodyHandlers.ofString()); httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception ignored) { } catch (Exception e) {
log.warn("Failed to notify push-service for appId={}, userIds={}: {}", appId, userIds, e.getMessage());
} }
} }
} }

查看文件

@ -44,9 +44,9 @@ jwt:
expiration: 86400000 expiration: 86400000
im: im:
tenant-service-url: ${TENANT_SERVICE_URL:http://192.168.116.9:8081} tenant-service-url: ${TENANT_SERVICE_URL:http://127.0.0.1:8081}
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
push-service-url: ${PUSH_SERVICE_URL:http://192.168.116.9:8083} push-service-url: ${PUSH_SERVICE_URL:http://127.0.0.1:8083}
multi-login: true multi-login: true
message-history-days: 30 message-history-days: 30
webhook-timeout-ms: 3000 webhook-timeout-ms: 3000

查看文件

@ -0,0 +1,27 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.service.DashboardService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/dashboard")
public class DashboardController {
private final DashboardService dashboardService;
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
@GetMapping("/stats")
public ResponseEntity<ApiResponse<Map<String, Object>>> stats(@AuthenticationPrincipal String tenantId) {
return ResponseEntity.ok(ApiResponse.success(dashboardService.stats(tenantId)));
}
}

查看文件

@ -2,7 +2,11 @@ package com.xuqm.tenant.controller;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@ -13,9 +17,11 @@ import java.util.stream.Collectors;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex) { public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex) {
return ResponseEntity.status(ex.getCode()) return ResponseEntity.status(resolveStatus(ex.getCode()))
.body(ApiResponse.error(ex.getCode(), ex.getMessage())); .body(ApiResponse.error(ex.getCode(), ex.getMessage()));
} }
@ -27,9 +33,31 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(ApiResponse.badRequest(message)); return ResponseEntity.badRequest().body(ApiResponse.badRequest(message));
} }
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handle(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
.body(ApiResponse.badRequest(ex.getMessage() == null ? "参数错误" : ex.getMessage()));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handle(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误"));
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handle(Exception ex) { public ResponseEntity<ApiResponse<Void>> handle(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage())); .body(ApiResponse.error(500, "服务器内部错误"));
}
private HttpStatus resolveStatus(int code) {
return switch (code) {
case 400 -> HttpStatus.BAD_REQUEST;
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
case 404 -> HttpStatus.NOT_FOUND;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
} }
} }

查看文件

@ -28,7 +28,7 @@ public class SdkConfigController {
@Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}") @Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}")
private String imWsUrl; private String imWsUrl;
@Value("${sdk.file-service-url:http://192.168.116.9:8086}") @Value("${sdk.file-service-url:https://file.dev.xuqinmin.com}")
private String fileServiceUrl; private String fileServiceUrl;
@Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}") @Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}")

查看文件

@ -19,6 +19,7 @@ public interface TenantRepository extends JpaRepository<TenantEntity, String> {
boolean existsByUsername(String username); boolean existsByUsername(String username);
boolean existsByEmail(String email); boolean existsByEmail(String email);
List<TenantEntity> findByParentId(String parentId); List<TenantEntity> findByParentId(String parentId);
long countByParentId(String parentId);
@Query("SELECT t FROM TenantEntity t WHERE " + @Query("SELECT t FROM TenantEntity t WHERE " +
"(:keyword IS NULL OR :keyword = '' OR t.username LIKE %:keyword% OR t.email LIKE %:keyword%)") "(:keyword IS NULL OR :keyword = '' OR t.username LIKE %:keyword% OR t.email LIKE %:keyword%)")

查看文件

@ -0,0 +1,50 @@
package com.xuqm.tenant.service;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class DashboardService {
private final AppRepository appRepository;
private final FeatureServiceRepository featureServiceRepository;
private final TenantRepository tenantRepository;
private final OperationLogService operationLogService;
public DashboardService(AppRepository appRepository,
FeatureServiceRepository featureServiceRepository,
TenantRepository tenantRepository,
OperationLogService operationLogService) {
this.appRepository = appRepository;
this.featureServiceRepository = featureServiceRepository;
this.tenantRepository = tenantRepository;
this.operationLogService = operationLogService;
}
public Map<String, Object> stats(String tenantId) {
List<AppEntity> apps = appRepository.findByTenantId(tenantId);
long serviceCount = 0;
for (AppEntity app : apps) {
serviceCount += featureServiceRepository.findByAppId(app.getId()).stream()
.filter(FeatureServiceEntity::isEnabled)
.count();
}
long subAccountCount = tenantRepository.countByParentId(tenantId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("appCount", apps.size());
result.put("serviceCount", serviceCount);
result.put("subAccountCount", subAccountCount);
operationLogService.record(tenantId, "CONSOLE", "DASHBOARD", tenantId, "VIEW_DASHBOARD", result);
return result;
}
}

查看文件

@ -86,5 +86,5 @@ sdk:
bootstrap-app-package: ${SDK_BOOTSTRAP_APP_PACKAGE:com.xuqm.demo} bootstrap-app-package: ${SDK_BOOTSTRAP_APP_PACKAGE:com.xuqm.demo}
bootstrap-app-description: ${SDK_BOOTSTRAP_APP_DESCRIPTION:XuqmGroup demo app} bootstrap-app-description: ${SDK_BOOTSTRAP_APP_DESCRIPTION:XuqmGroup demo app}
im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im} im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im}
file-service-url: ${SDK_FILE_SERVICE_URL:http://192.168.116.9:8086} file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com}
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com} im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}

查看文件

@ -59,7 +59,6 @@ public class SecurityConfig {
config.setAllowedOriginPatterns(List.of( config.setAllowedOriginPatterns(List.of(
"http://localhost:*", "http://localhost:*",
"http://127.0.0.1:*", "http://127.0.0.1:*",
"http://192.168.116.9:*",
"http://*.xuqinmin.com", "http://*.xuqinmin.com",
"https://*.xuqinmin.com" "https://*.xuqinmin.com"
)); ));

查看文件

@ -9,6 +9,9 @@ import com.xuqm.update.service.StoreSubmissionService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -91,8 +94,7 @@ public class AppStoreController {
@PathVariable String versionId, @PathVariable String versionId,
@RequestBody Map<String, Object> body) throws Exception { @RequestBody Map<String, Object> body) throws Exception {
@SuppressWarnings("unchecked") List<String> storeTypes = extractStringList(body, "storeTypes");
List<String> storeTypes = (List<String>) body.get("storeTypes");
return ResponseEntity.ok(ApiResponse.success(storeService.markSubmitted(versionId, storeTypes))); return ResponseEntity.ok(ApiResponse.success(storeService.markSubmitted(versionId, storeTypes)));
} }
@ -110,8 +112,7 @@ public class AppStoreController {
@PathVariable String versionId, @PathVariable String versionId,
@RequestBody(required = false) Map<String, Object> body) throws Exception { @RequestBody(required = false) Map<String, Object> body) throws Exception {
@SuppressWarnings("unchecked") List<String> storeTypes = body != null ? extractStringList(body, "storeTypes") : null;
List<String> storeTypes = body != null ? (List<String>) body.get("storeTypes") : null;
String submitMode = body != null ? (String) body.get("submitMode") : null; String submitMode = body != null ? (String) body.get("submitMode") : null;
String scheduledAtText = body != null ? (String) body.get("scheduledPublishAt") : null; String scheduledAtText = body != null ? (String) body.get("scheduledPublishAt") : null;
Boolean autoPublishAfterReview = body != null && body.get("autoPublishAfterReview") != null Boolean autoPublishAfterReview = body != null && body.get("autoPublishAfterReview") != null
@ -145,23 +146,44 @@ public class AppStoreController {
@PathVariable String versionId, @PathVariable String versionId,
@RequestBody Map<String, Object> body) throws Exception { @RequestBody Map<String, Object> body) throws Exception {
String storeType = (String) body.get("storeType"); String storeType = body.get("storeType") instanceof String s ? s : null;
AppVersionEntity.StoreReviewState state = if (storeType == null || storeType.isBlank()) throw new IllegalArgumentException("storeType 不能为空");
AppVersionEntity.StoreReviewState.valueOf((String) body.get("state")); String stateStr = body.get("state") instanceof String s ? s : null;
if (stateStr == null) throw new IllegalArgumentException("state 不能为空");
AppVersionEntity.StoreReviewState state;
try {
state = AppVersionEntity.StoreReviewState.valueOf(stateStr);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("无效的审核状态: " + stateStr);
}
String reason = body.get("reason") == null ? null : body.get("reason").toString(); String reason = body.get("reason") == null ? null : body.get("reason").toString();
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
storeService.updateStoreReview(versionId, storeType, state, reason))); storeService.updateStoreReview(versionId, storeType, state, reason)));
} }
private List<String> extractStringList(Map<String, Object> body, String key) {
Object value = body.get(key);
if (value instanceof List<?> list) {
return list.stream()
.filter(item -> item instanceof String)
.map(item -> (String) item)
.toList();
}
return null;
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private void validateReviewWebhook(String configJson) { private void validateReviewWebhook(String configJson) {
try { try {
@SuppressWarnings("unchecked") Map<String, Object> config = OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
Map<String, Object> config = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(configJson, Map.class);
String webhookUrl = config.get("webhookUrl") == null ? "" : config.get("webhookUrl").toString().trim(); String webhookUrl = config.get("webhookUrl") == null ? "" : config.get("webhookUrl").toString().trim();
if (!webhookUrl.isBlank()) { if (!webhookUrl.isBlank()) {
connectivityValidationService.validateCallbackUrl(webhookUrl, "审核通知"); connectivityValidationService.validateCallbackUrl(webhookUrl, "审核通知");
} }
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e); throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e);
} }
@ -172,16 +194,12 @@ public class AppStoreController {
return; return;
} }
try { try {
@SuppressWarnings("unchecked") OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
Map<String, Object> config = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(configJson, Map.class);
if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) {
validateReviewWebhook(configJson);
}
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException("invalid store config payload: " + e.getMessage(), e); throw new IllegalArgumentException("invalid store config payload: " + e.getMessage(), e);
} }
if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) {
validateReviewWebhook(configJson);
}
} }
} }

查看文件

@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -139,7 +140,11 @@ public class AppVersionController {
entity.setGrayMode("PERCENT"); entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); try {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601如 2026-05-01T10:00:00");
}
} }
entity.setWebhookUrl(webhookUrl); entity.setWebhookUrl(webhookUrl);
entity.setStoreSubmitTargets(storeSubmitTargets); entity.setStoreSubmitTargets(storeSubmitTargets);
@ -206,7 +211,11 @@ public class AppVersionController {
} else { } else {
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); try {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601如 2026-05-01T10:00:00");
}
} }
} }
entity.setGrayEnabled(false); entity.setGrayEnabled(false);

查看文件

@ -0,0 +1,62 @@
package com.xuqm.update.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex) {
return ResponseEntity.status(resolveStatus(ex.getCode()))
.body(ApiResponse.error(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handle(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest().body(ApiResponse.badRequest(message));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handle(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
.body(ApiResponse.badRequest(ex.getMessage() == null ? "参数错误" : ex.getMessage()));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handle(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handle(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "服务器内部错误"));
}
private HttpStatus resolveStatus(int code) {
return switch (code) {
case 400 -> HttpStatus.BAD_REQUEST;
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
case 404 -> HttpStatus.NOT_FOUND;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
}
}

查看文件

@ -2,7 +2,6 @@ package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppPublishConfigEntity; import com.xuqm.update.entity.AppPublishConfigEntity;
import com.xuqm.update.service.ConnectivityValidationService;
import com.xuqm.update.service.PublishConfigService; import com.xuqm.update.service.PublishConfigService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -15,12 +14,9 @@ import java.util.Map;
public class PublishConfigController { public class PublishConfigController {
private final PublishConfigService publishConfigService; private final PublishConfigService publishConfigService;
private final ConnectivityValidationService connectivityValidationService;
public PublishConfigController(PublishConfigService publishConfigService, public PublishConfigController(PublishConfigService publishConfigService) {
ConnectivityValidationService connectivityValidationService) {
this.publishConfigService = publishConfigService; this.publishConfigService = publishConfigService;
this.connectivityValidationService = connectivityValidationService;
} }
@GetMapping("/publish/config") @GetMapping("/publish/config")
@ -64,11 +60,15 @@ public class PublishConfigController {
} }
String selectCallback = body.get("graySelectCallbackUrl") == null ? "" : body.get("graySelectCallbackUrl").toString().trim(); String selectCallback = body.get("graySelectCallbackUrl") == null ? "" : body.get("graySelectCallbackUrl").toString().trim();
String syncCallback = body.get("grayDirectorySyncCallbackUrl") == null ? "" : body.get("grayDirectorySyncCallbackUrl").toString().trim(); String syncCallback = body.get("grayDirectorySyncCallbackUrl") == null ? "" : body.get("grayDirectorySyncCallbackUrl").toString().trim();
if (!selectCallback.isBlank()) { if (!selectCallback.isBlank() && !isHttpUrl(selectCallback)) {
connectivityValidationService.validateCallbackUrl(selectCallback, "灰度成员选择回调"); throw new IllegalArgumentException("graySelectCallbackUrl must start with http:// or https://");
} }
if (!syncCallback.isBlank()) { if (!syncCallback.isBlank() && !isHttpUrl(syncCallback)) {
connectivityValidationService.validateCallbackUrl(syncCallback, "灰度成员同步回调"); throw new IllegalArgumentException("grayDirectorySyncCallbackUrl must start with http:// or https://");
} }
} }
private boolean isHttpUrl(String value) {
return value.startsWith("http://") || value.startsWith("https://");
}
} }

查看文件

@ -24,6 +24,10 @@ public class ConnectivityValidationService {
if (url == null || url.isBlank()) { if (url == null || url.isBlank()) {
return; return;
} }
String normalized = url.trim();
if (!(normalized.startsWith("http://") || normalized.startsWith("https://"))) {
throw new IllegalArgumentException(label + " must start with http:// or https://");
}
try { try {
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("probe", true); body.put("probe", true);
@ -31,7 +35,7 @@ public class ConnectivityValidationService {
body.put("timestamp", System.currentTimeMillis()); body.put("timestamp", System.currentTimeMillis());
String payload = objectMapper.writeValueAsString(body); String payload = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(normalized))
.timeout(Duration.ofSeconds(5)) .timeout(Duration.ofSeconds(5))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("X-Xuqm-Connectivity-Check", "true") .header("X-Xuqm-Connectivity-Check", "true")
@ -43,7 +47,7 @@ public class ConnectivityValidationService {
throw new IllegalStateException("connectivity check failed with HTTP " + status); throw new IllegalStateException("connectivity check failed with HTTP " + status);
} }
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException("cannot connect to " + label + ": " + url, e); throw new IllegalArgumentException("cannot connect to " + label + ": " + normalized, e);
} }
} }
} }