2026-04-21 22:07:29 +08:00
|
|
|
package com.xuqm.tenant.controller;
|
|
|
|
|
|
|
|
|
|
import com.xuqm.common.model.ApiResponse;
|
2026-05-15 21:00:24 +08:00
|
|
|
import com.xuqm.common.exception.BusinessException;
|
2026-04-21 22:07:29 +08:00
|
|
|
import com.xuqm.tenant.dto.CreateAppRequest;
|
|
|
|
|
import com.xuqm.tenant.entity.AppEntity;
|
2026-05-14 23:40:35 +08:00
|
|
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
2026-04-24 20:53:48 +08:00
|
|
|
import com.xuqm.tenant.entity.TenantEntity;
|
|
|
|
|
import com.xuqm.tenant.repository.TenantRepository;
|
2026-04-21 22:07:29 +08:00
|
|
|
import com.xuqm.tenant.service.AppService;
|
2026-05-14 23:40:35 +08:00
|
|
|
import com.xuqm.tenant.service.AppUserClient;
|
2026-04-24 20:53:48 +08:00
|
|
|
import com.xuqm.tenant.service.EmailService;
|
2026-05-14 23:40:35 +08:00
|
|
|
import com.xuqm.tenant.service.FeatureServiceManager;
|
2026-05-15 21:00:24 +08:00
|
|
|
import com.xuqm.tenant.service.LicenseFileCrypto;
|
2026-04-30 09:49:05 +08:00
|
|
|
import com.xuqm.tenant.service.OperationLogService;
|
2026-04-21 22:07:29 +08:00
|
|
|
import jakarta.validation.Valid;
|
2026-05-15 21:00:24 +08:00
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
import org.springframework.http.ContentDisposition;
|
|
|
|
|
import org.springframework.http.HttpHeaders;
|
|
|
|
|
import org.springframework.http.MediaType;
|
2026-04-21 22:07:29 +08:00
|
|
|
import org.springframework.http.ResponseEntity;
|
|
|
|
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
|
|
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
|
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
|
|
|
import org.springframework.web.bind.annotation.PathVariable;
|
|
|
|
|
import org.springframework.web.bind.annotation.PostMapping;
|
|
|
|
|
import org.springframework.web.bind.annotation.PutMapping;
|
|
|
|
|
import org.springframework.web.bind.annotation.RequestBody;
|
|
|
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
2026-04-24 20:53:48 +08:00
|
|
|
import org.springframework.web.bind.annotation.RequestParam;
|
2026-04-21 22:07:29 +08:00
|
|
|
import org.springframework.web.bind.annotation.RestController;
|
|
|
|
|
|
|
|
|
|
import java.util.List;
|
2026-04-24 20:53:48 +08:00
|
|
|
import java.util.Map;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
|
|
|
|
@RestController
|
|
|
|
|
@RequestMapping("/api/apps")
|
|
|
|
|
public class AppController {
|
|
|
|
|
|
|
|
|
|
private final AppService appService;
|
2026-04-24 20:53:48 +08:00
|
|
|
private final EmailService emailService;
|
2026-04-30 09:49:05 +08:00
|
|
|
private final OperationLogService operationLogService;
|
2026-04-24 20:53:48 +08:00
|
|
|
private final TenantRepository tenantRepository;
|
2026-05-14 23:40:35 +08:00
|
|
|
private final FeatureServiceManager featureServiceManager;
|
|
|
|
|
private final AppUserClient appUserClient;
|
2026-04-21 22:07:29 +08:00
|
|
|
|
2026-05-15 21:00:24 +08:00
|
|
|
@Value("${license.public-base-url:https://auto.dev.xuqinmin.com/}")
|
|
|
|
|
private String licensePublicBaseUrl;
|
|
|
|
|
|
2026-04-30 09:49:05 +08:00
|
|
|
public AppController(AppService appService, EmailService emailService,
|
|
|
|
|
OperationLogService operationLogService,
|
2026-05-14 23:40:35 +08:00
|
|
|
TenantRepository tenantRepository,
|
|
|
|
|
FeatureServiceManager featureServiceManager,
|
|
|
|
|
AppUserClient appUserClient) {
|
2026-04-21 22:07:29 +08:00
|
|
|
this.appService = appService;
|
2026-04-24 20:53:48 +08:00
|
|
|
this.emailService = emailService;
|
2026-04-30 09:49:05 +08:00
|
|
|
this.operationLogService = operationLogService;
|
2026-04-24 20:53:48 +08:00
|
|
|
this.tenantRepository = tenantRepository;
|
2026-05-14 23:40:35 +08:00
|
|
|
this.featureServiceManager = featureServiceManager;
|
|
|
|
|
this.appUserClient = appUserClient;
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@GetMapping
|
|
|
|
|
public ResponseEntity<ApiResponse<List<AppEntity>>> list(@AuthenticationPrincipal String tenantId) {
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(appService.listByTenant(tenantId)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:09:22 +08:00
|
|
|
@GetMapping("/{appKey}")
|
|
|
|
|
public ResponseEntity<ApiResponse<AppEntity>> get(@PathVariable String appKey,
|
2026-04-21 22:07:29 +08:00
|
|
|
@AuthenticationPrincipal String tenantId) {
|
2026-05-08 10:09:22 +08:00
|
|
|
return ResponseEntity.ok(ApiResponse.success(appService.getByAppKey(appKey, tenantId)));
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@PostMapping
|
|
|
|
|
public ResponseEntity<ApiResponse<AppEntity>> create(@Valid @RequestBody CreateAppRequest req,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(appService.create(tenantId, req)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:09:22 +08:00
|
|
|
@PutMapping("/{appKey}")
|
|
|
|
|
public ResponseEntity<ApiResponse<AppEntity>> update(@PathVariable String appKey,
|
2026-04-21 22:07:29 +08:00
|
|
|
@Valid @RequestBody CreateAppRequest req,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
2026-05-08 10:09:22 +08:00
|
|
|
return ResponseEntity.ok(ApiResponse.success(appService.update(appKey, tenantId, req)));
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-08 10:09:22 +08:00
|
|
|
@DeleteMapping("/{appKey}")
|
|
|
|
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable String appKey,
|
2026-04-21 22:07:29 +08:00
|
|
|
@AuthenticationPrincipal String tenantId) {
|
2026-05-08 10:09:22 +08:00
|
|
|
appService.delete(appKey, tenantId);
|
2026-04-21 22:07:29 +08:00
|
|
|
return ResponseEntity.ok(ApiResponse.ok());
|
|
|
|
|
}
|
2026-04-24 20:53:48 +08:00
|
|
|
|
|
|
|
|
/** Step 1: send email verification code for secret reveal or reset. */
|
2026-05-08 10:09:22 +08:00
|
|
|
@PostMapping("/{appKey}/request-secret-verify")
|
2026-04-24 20:53:48 +08:00
|
|
|
public ResponseEntity<ApiResponse<Void>> requestSecretVerify(
|
2026-05-08 10:09:22 +08:00
|
|
|
@PathVariable String appKey,
|
2026-04-24 20:53:48 +08:00
|
|
|
@RequestParam String purpose,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
2026-05-08 10:09:22 +08:00
|
|
|
appService.getByAppKey(appKey, tenantId);
|
2026-04-24 20:53:48 +08:00
|
|
|
TenantEntity tenant = tenantRepository.findById(tenantId)
|
|
|
|
|
.orElseThrow(() -> new RuntimeException("Tenant not found"));
|
|
|
|
|
emailService.sendVerificationCode(tenant.getEmail(), purpose);
|
2026-05-08 10:09:22 +08:00
|
|
|
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REQUEST_SECRET_VERIFY", Map.of(
|
2026-04-30 09:49:05 +08:00
|
|
|
"purpose", purpose
|
|
|
|
|
));
|
2026-04-24 20:53:48 +08:00
|
|
|
return ResponseEntity.ok(ApiResponse.ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Step 2a: verify code and return the full appSecret. */
|
2026-05-08 10:09:22 +08:00
|
|
|
@PostMapping("/{appKey}/reveal-secret")
|
2026-04-24 20:53:48 +08:00
|
|
|
public ResponseEntity<ApiResponse<Map<String, String>>> revealSecret(
|
2026-05-08 10:09:22 +08:00
|
|
|
@PathVariable String appKey,
|
2026-04-24 20:53:48 +08:00
|
|
|
@RequestBody Map<String, String> body,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
2026-05-08 10:09:22 +08:00
|
|
|
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
2026-04-24 20:53:48 +08:00
|
|
|
TenantEntity tenant = tenantRepository.findById(tenantId)
|
|
|
|
|
.orElseThrow(() -> new RuntimeException("Tenant not found"));
|
|
|
|
|
emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET");
|
2026-05-08 10:09:22 +08:00
|
|
|
operationLogService.record(tenantId, "APP", "APP_SECRET", appKey, "REVEAL_APP_SECRET", Map.of(
|
2026-04-30 09:49:05 +08:00
|
|
|
"appKey", app.getAppKey()
|
|
|
|
|
));
|
2026-04-24 20:53:48 +08:00
|
|
|
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", app.getAppSecret())));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Step 2b: verify code and regenerate appSecret (old one invalidated immediately). */
|
2026-05-08 10:09:22 +08:00
|
|
|
@PostMapping("/{appKey}/reset-secret")
|
2026-04-24 20:53:48 +08:00
|
|
|
public ResponseEntity<ApiResponse<Map<String, String>>> resetSecret(
|
2026-05-08 10:09:22 +08:00
|
|
|
@PathVariable String appKey,
|
2026-04-24 20:53:48 +08:00
|
|
|
@RequestBody Map<String, String> body,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
2026-05-08 10:09:22 +08:00
|
|
|
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
2026-04-24 20:53:48 +08:00
|
|
|
TenantEntity tenant = tenantRepository.findById(tenantId)
|
|
|
|
|
.orElseThrow(() -> new RuntimeException("Tenant not found"));
|
|
|
|
|
emailService.verify(tenant.getEmail(), body.get("code"), "RESET_SECRET");
|
2026-05-08 10:09:22 +08:00
|
|
|
String newSecret = appService.resetSecret(appKey, tenantId);
|
2026-04-24 20:53:48 +08:00
|
|
|
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", newSecret)));
|
|
|
|
|
}
|
2026-05-14 23:40:35 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List users of an app. Queries IM accounts when IM is enabled, Push accounts otherwise.
|
|
|
|
|
* The ?source=IM|PUSH param overrides auto-detection.
|
|
|
|
|
*/
|
|
|
|
|
@GetMapping("/{appKey}/users")
|
|
|
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
|
|
|
|
@PathVariable String appKey,
|
|
|
|
|
@RequestParam(required = false, defaultValue = "") String keyword,
|
|
|
|
|
@RequestParam(defaultValue = "0") int page,
|
|
|
|
|
@RequestParam(defaultValue = "20") int size,
|
|
|
|
|
@RequestParam(required = false) String source,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
|
|
|
|
appService.getByAppKey(appKey, tenantId);
|
|
|
|
|
List<FeatureServiceEntity> services = featureServiceManager.listByApp(appKey);
|
|
|
|
|
boolean imEnabled = services.stream().anyMatch(s ->
|
|
|
|
|
s.getServiceType() == FeatureServiceEntity.ServiceType.IM && s.isEnabled());
|
|
|
|
|
boolean pushEnabled = services.stream().anyMatch(s ->
|
|
|
|
|
s.getServiceType() == FeatureServiceEntity.ServiceType.PUSH && s.isEnabled());
|
|
|
|
|
String effectiveSource = (source != null && !source.isBlank())
|
|
|
|
|
? source.trim().toUpperCase()
|
|
|
|
|
: (imEnabled ? "IM" : "PUSH");
|
|
|
|
|
Map<String, Object> result = "PUSH".equals(effectiveSource)
|
|
|
|
|
? appUserClient.listPushUsers(appKey, keyword, page, size)
|
|
|
|
|
: appUserClient.listImUsers(appKey, keyword, page, size);
|
|
|
|
|
return ResponseEntity.ok(ApiResponse.success(result));
|
|
|
|
|
}
|
2026-05-15 21:00:24 +08:00
|
|
|
|
|
|
|
|
@GetMapping("/{appKey}/license-file")
|
|
|
|
|
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
|
|
|
|
@AuthenticationPrincipal String tenantId) {
|
|
|
|
|
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
|
|
|
|
boolean licenseEnabled = featureServiceManager.listByApp(appKey).stream()
|
|
|
|
|
.anyMatch(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE && service.isEnabled());
|
|
|
|
|
if (!licenseEnabled) {
|
|
|
|
|
throw new BusinessException(400, "License 服务未开通");
|
|
|
|
|
}
|
|
|
|
|
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
|
|
|
|
payload.put("appKey", app.getAppKey());
|
|
|
|
|
payload.put("appName", app.getName());
|
|
|
|
|
payload.put("packageName", app.getPackageName());
|
|
|
|
|
payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
|
|
|
|
payload.put("issuedAt", java.time.Instant.now().toString());
|
|
|
|
|
String encrypted = LicenseFileCrypto.encrypt(new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(payload).toString());
|
|
|
|
|
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
|
|
|
|
return ResponseEntity.ok()
|
|
|
|
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
|
|
|
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(filename, java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
|
|
|
|
.body(encrypted.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static String normalizeBaseUrl(String value) {
|
|
|
|
|
String baseUrl = value == null || value.isBlank() ? "https://auto.dev.xuqinmin.com/" : value.trim();
|
|
|
|
|
return baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static String sanitizeFileName(String value) {
|
|
|
|
|
String name = value == null || value.isBlank() ? "license" : value.trim();
|
|
|
|
|
return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|