feat(deploy): 添加生产环境部署配置和联调环境切换功能
- 新增 .env.production.example 配置文件,包含所有微服务的数据库和Redis配置 - 添加 compose.production.yaml Docker Compose部署文件,定义web和各服务容器 - 实现Android SDK环境切换功能,支持外部服务和本地联调模式切换 - 添加推送注册状态管理和接收开关设置界面 - 集成演示服务的应用密钥客户端和认证服务实现 - 完善文档说明各SDK模块的集成和使用方法
这个提交包含在:
父节点
32b0e49e61
当前提交
f6c06db04b
1
Jenkinsfile
vendored
1
Jenkinsfile
vendored
@ -2,6 +2,7 @@ pipeline {
|
||||
agent any
|
||||
|
||||
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: '要构建的服务模块')
|
||||
string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag(如 v1.2.3 或 latest)')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器')
|
||||
|
||||
@ -21,7 +21,7 @@ public class DemoAppSecretClient {
|
||||
private final RestTemplate restTemplate;
|
||||
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;
|
||||
|
||||
@Value("${demo.internal-token:xuqm-internal-token}")
|
||||
|
||||
@ -35,7 +35,7 @@ public class DemoAuthService {
|
||||
private final RestTemplate restTemplate;
|
||||
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;
|
||||
|
||||
public DemoAuthService(DemoUserRepository userRepository,
|
||||
|
||||
@ -35,9 +35,9 @@ jwt:
|
||||
expiration: 86400000
|
||||
|
||||
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}
|
||||
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:
|
||||
level:
|
||||
|
||||
@ -88,7 +88,6 @@
|
||||
| PUT | `/api/apps/{id}` | 是 | 更新应用 |
|
||||
| DELETE | `/api/apps/{id}` | 是 | 删除应用 |
|
||||
| GET | `/api/apps/{appId}/services` | 是 | 服务列表 |
|
||||
| GET | `/api/apps/{appId}/services/item` | 是 | 按平台和服务类型查询单条服务配置 |
|
||||
| PUT | `/api/apps/{appId}/services/config` | 是 | 更新服务配置,IM 和 UPDATE 走各自的配置模型 |
|
||||
| POST | `/api/apps/{appId}/services/toggle` | 是 | 开关服务 |
|
||||
| POST | `/api/apps/{appId}/services/{id}/regenerate-key` | 是 | 重新生成服务密钥 |
|
||||
@ -173,6 +172,8 @@
|
||||
- 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。
|
||||
- 上下架、上传、发布、灰度、市场提交、商店配置变更都会写入 `update_operation_log`,可通过 `GET /api/v1/updates/ops/logs?appId=...` 查询。
|
||||
- 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。
|
||||
- 租户平台控制台新增 `GET /api/dashboard/stats`,返回当前租户的应用数、已开通服务数和子账号数,同时会写一条 `CONSOLE / DASHBOARD / VIEW_DASHBOARD` 操作日志。
|
||||
- 租户平台“操作日志”菜单现在集中查看租户平台与版本管理两类日志;版本管理日志继续按 `appId` 查询,控制台访问日志则落在 `t_operation_log`。
|
||||
|
||||
## update-sdk 自动发版
|
||||
|
||||
|
||||
@ -54,7 +54,6 @@ public class SecurityConfig {
|
||||
config.setAllowedOriginPatterns(List.of(
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://192.168.116.9:*",
|
||||
"http://*.xuqinmin.com",
|
||||
"https://*.xuqinmin.com"
|
||||
));
|
||||
|
||||
@ -41,7 +41,7 @@ jwt:
|
||||
|
||||
file:
|
||||
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:
|
||||
level:
|
||||
|
||||
@ -56,7 +56,8 @@ public class SecurityConfig {
|
||||
config.setAllowedOriginPatterns(List.of(
|
||||
"http://localhost:*",
|
||||
"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.setAllowedHeaders(List.of("*"));
|
||||
|
||||
@ -2,6 +2,8 @@ package com.xuqm.im.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;
|
||||
@ -12,6 +14,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||
return ResponseEntity.status(resolveStatus(e.getCode()))
|
||||
@ -40,8 +44,9 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
||||
log.error("Unhandled exception", e);
|
||||
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) {
|
||||
|
||||
@ -21,7 +21,7 @@ public class ImAppSecretClient {
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
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;
|
||||
|
||||
@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.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@ -14,11 +16,12 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||
@Component
|
||||
public class ImFeatureConfigClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImFeatureConfigClient.class);
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
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;
|
||||
|
||||
@Value("${im.internal-token:xuqm-internal-token}")
|
||||
@ -93,7 +96,7 @@ public class ImFeatureConfigClient {
|
||||
}
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
package com.xuqm.im.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@ -16,10 +17,12 @@ import java.util.Map;
|
||||
@Component
|
||||
public class ImPushBridgeClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImPushBridgeClient.class);
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
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;
|
||||
|
||||
@Value("${im.internal-token:xuqm-internal-token}")
|
||||
@ -48,7 +51,8 @@ public class ImPushBridgeClient {
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
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
|
||||
|
||||
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}
|
||||
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
|
||||
message-history-days: 30
|
||||
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.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.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
@ -13,9 +17,11 @@ 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(ex.getCode())
|
||||
return ResponseEntity.status(resolveStatus(ex.getCode()))
|
||||
.body(ApiResponse.error(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ -27,9 +33,31 @@ public class GlobalExceptionHandler {
|
||||
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, "服务器内部错误: " + 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}")
|
||||
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;
|
||||
|
||||
@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 existsByEmail(String email);
|
||||
List<TenantEntity> findByParentId(String parentId);
|
||||
long countByParentId(String parentId);
|
||||
|
||||
@Query("SELECT t FROM TenantEntity t WHERE " +
|
||||
"(: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-description: ${SDK_BOOTSTRAP_APP_DESCRIPTION:XuqmGroup demo app}
|
||||
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}
|
||||
|
||||
@ -59,7 +59,6 @@ public class SecurityConfig {
|
||||
config.setAllowedOriginPatterns(List.of(
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://192.168.116.9:*",
|
||||
"http://*.xuqinmin.com",
|
||||
"https://*.xuqinmin.com"
|
||||
));
|
||||
|
||||
@ -9,6 +9,9 @@ import com.xuqm.update.service.StoreSubmissionService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.Map;
|
||||
|
||||
@ -91,8 +94,7 @@ public class AppStoreController {
|
||||
@PathVariable String versionId,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> storeTypes = (List<String>) body.get("storeTypes");
|
||||
List<String> storeTypes = extractStringList(body, "storeTypes");
|
||||
return ResponseEntity.ok(ApiResponse.success(storeService.markSubmitted(versionId, storeTypes)));
|
||||
}
|
||||
|
||||
@ -110,8 +112,7 @@ public class AppStoreController {
|
||||
@PathVariable String versionId,
|
||||
@RequestBody(required = false) Map<String, Object> body) throws Exception {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> storeTypes = body != null ? (List<String>) body.get("storeTypes") : null;
|
||||
List<String> storeTypes = body != null ? extractStringList(body, "storeTypes") : null;
|
||||
String submitMode = body != null ? (String) body.get("submitMode") : null;
|
||||
String scheduledAtText = body != null ? (String) body.get("scheduledPublishAt") : null;
|
||||
Boolean autoPublishAfterReview = body != null && body.get("autoPublishAfterReview") != null
|
||||
@ -145,23 +146,44 @@ public class AppStoreController {
|
||||
@PathVariable String versionId,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
|
||||
String storeType = (String) body.get("storeType");
|
||||
AppVersionEntity.StoreReviewState state =
|
||||
AppVersionEntity.StoreReviewState.valueOf((String) body.get("state"));
|
||||
String storeType = body.get("storeType") instanceof String s ? s : null;
|
||||
if (storeType == null || storeType.isBlank()) throw new IllegalArgumentException("storeType 不能为空");
|
||||
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();
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
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) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> config = new com.fasterxml.jackson.databind.ObjectMapper()
|
||||
.readValue(configJson, Map.class);
|
||||
Map<String, Object> config = OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
|
||||
String webhookUrl = config.get("webhookUrl") == null ? "" : config.get("webhookUrl").toString().trim();
|
||||
if (!webhookUrl.isBlank()) {
|
||||
connectivityValidationService.validateCallbackUrl(webhookUrl, "审核通知");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e);
|
||||
}
|
||||
@ -172,16 +194,12 @@ public class AppStoreController {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
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;
|
||||
OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
|
||||
} catch (Exception 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 java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@ -139,7 +140,11 @@ public class AppVersionController {
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||
try {
|
||||
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601(如 2026-05-01T10:00:00)");
|
||||
}
|
||||
}
|
||||
entity.setWebhookUrl(webhookUrl);
|
||||
entity.setStoreSubmitTargets(storeSubmitTargets);
|
||||
@ -206,7 +211,11 @@ public class AppVersionController {
|
||||
} else {
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||
try {
|
||||
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601(如 2026-05-01T10:00:00)");
|
||||
}
|
||||
}
|
||||
}
|
||||
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.update.entity.AppPublishConfigEntity;
|
||||
import com.xuqm.update.service.ConnectivityValidationService;
|
||||
import com.xuqm.update.service.PublishConfigService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -15,12 +14,9 @@ import java.util.Map;
|
||||
public class PublishConfigController {
|
||||
|
||||
private final PublishConfigService publishConfigService;
|
||||
private final ConnectivityValidationService connectivityValidationService;
|
||||
|
||||
public PublishConfigController(PublishConfigService publishConfigService,
|
||||
ConnectivityValidationService connectivityValidationService) {
|
||||
public PublishConfigController(PublishConfigService publishConfigService) {
|
||||
this.publishConfigService = publishConfigService;
|
||||
this.connectivityValidationService = connectivityValidationService;
|
||||
}
|
||||
|
||||
@GetMapping("/publish/config")
|
||||
@ -64,11 +60,15 @@ public class PublishConfigController {
|
||||
}
|
||||
String selectCallback = body.get("graySelectCallbackUrl") == null ? "" : body.get("graySelectCallbackUrl").toString().trim();
|
||||
String syncCallback = body.get("grayDirectorySyncCallbackUrl") == null ? "" : body.get("grayDirectorySyncCallbackUrl").toString().trim();
|
||||
if (!selectCallback.isBlank()) {
|
||||
connectivityValidationService.validateCallbackUrl(selectCallback, "灰度成员选择回调");
|
||||
if (!selectCallback.isBlank() && !isHttpUrl(selectCallback)) {
|
||||
throw new IllegalArgumentException("graySelectCallbackUrl must start with http:// or https://");
|
||||
}
|
||||
if (!syncCallback.isBlank()) {
|
||||
connectivityValidationService.validateCallbackUrl(syncCallback, "灰度成员同步回调");
|
||||
if (!syncCallback.isBlank() && !isHttpUrl(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()) {
|
||||
return;
|
||||
}
|
||||
String normalized = url.trim();
|
||||
if (!(normalized.startsWith("http://") || normalized.startsWith("https://"))) {
|
||||
throw new IllegalArgumentException(label + " must start with http:// or https://");
|
||||
}
|
||||
try {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("probe", true);
|
||||
@ -31,7 +35,7 @@ public class ConnectivityValidationService {
|
||||
body.put("timestamp", System.currentTimeMillis());
|
||||
String payload = objectMapper.writeValueAsString(body);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.uri(URI.create(normalized))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Xuqm-Connectivity-Check", "true")
|
||||
@ -43,7 +47,7 @@ public class ConnectivityValidationService {
|
||||
throw new IllegalStateException("connectivity check failed with HTTP " + status);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("cannot connect to " + label + ": " + url, e);
|
||||
throw new IllegalArgumentException("cannot connect to " + label + ": " + normalized, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户