From acfc2cbfbeb0ce3f137ef2087a13b3075f0da5e1 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 15 May 2026 21:00:24 +0800 Subject: [PATCH] Add license service and tenant integration --- Dockerfile | 1 + Jenkinsfile | 2 +- license-service/pom.xml | 77 +++++++++ .../license/LicenseServiceApplication.java | 15 ++ .../xuqm/license/config/SecurityConfig.java | 64 +++++++ .../controller/GlobalExceptionHandler.java | 22 +++ .../controller/LicenseAdminController.java | 88 ++++++++++ .../controller/LicenseInternalController.java | 104 +++++++++++ .../controller/LicensePublicController.java | 70 ++++++++ .../xuqm/license/entity/CompanyEntity.java | 67 ++++++++ .../com/xuqm/license/entity/DeviceEntity.java | 91 ++++++++++ .../license/repository/CompanyRepository.java | 12 ++ .../license/repository/DeviceRepository.java | 15 ++ .../xuqm/license/service/CompanyService.java | 115 +++++++++++++ .../xuqm/license/service/DeviceService.java | 161 ++++++++++++++++++ .../license/service/LicenseAuthService.java | 48 ++++++ .../src/main/resources/application.yml | 35 ++++ pom.xml | 1 + .../tenant/config/LicenseMigrationRunner.java | 131 ++++++++++++++ .../xuqm/tenant/controller/AppController.java | 42 +++++ .../controller/FeatureServiceController.java | 1 + .../tenant/entity/FeatureServiceEntity.java | 2 +- .../tenant/service/FeatureServiceManager.java | 9 +- .../tenant/service/LicenseFileCrypto.java | 55 ++++++ .../tenant/service/LicenseServiceClient.java | 72 ++++++++ .../service/SdkAppProvisioningService.java | 30 ++-- .../src/main/resources/application.yml | 9 + 27 files changed, 1323 insertions(+), 16 deletions(-) create mode 100644 license-service/pom.xml create mode 100644 license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java create mode 100644 license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java create mode 100644 license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java create mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java create mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java create mode 100644 license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java create mode 100644 license-service/src/main/java/com/xuqm/license/entity/CompanyEntity.java create mode 100644 license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java create mode 100644 license-service/src/main/java/com/xuqm/license/repository/CompanyRepository.java create mode 100644 license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java create mode 100644 license-service/src/main/java/com/xuqm/license/service/CompanyService.java create mode 100644 license-service/src/main/java/com/xuqm/license/service/DeviceService.java create mode 100644 license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java create mode 100644 license-service/src/main/resources/application.yml create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/config/LicenseMigrationRunner.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java diff --git a/Dockerfile b/Dockerfile index ce9ffdb..423fe3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ COPY push-service ./push-service COPY update-service ./update-service COPY demo-service ./demo-service COPY file-service ./file-service +COPY license-service ./license-service RUN mvn -U -s /workspace/maven-settings.xml -pl ${SERVICE_MODULE} -am -DskipTests package diff --git a/Jenkinsfile b/Jenkinsfile index c931e72..ccff036 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,7 @@ pipeline { 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', 'license-service'], description: '要构建的服务模块') string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag(如 v1.2.3 或 latest)') booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器') } diff --git a/license-service/pom.xml b/license-service/pom.xml new file mode 100644 index 0000000..d82af97 --- /dev/null +++ b/license-service/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + com.xuqm + xuqmgroup-server-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + license-service + license-service + Standalone PAD license server: device registration, token verification, company management + + + + com.xuqm + common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java b/license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java new file mode 100644 index 0000000..b225b7d --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/LicenseServiceApplication.java @@ -0,0 +1,15 @@ +package com.xuqm.license; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@ComponentScan(basePackages = {"com.xuqm.license", "com.xuqm.common"}) +@EnableAsync +public class LicenseServiceApplication { + public static void main(String[] args) { + SpringApplication.run(LicenseServiceApplication.class, args); + } +} diff --git a/license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java b/license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java new file mode 100644 index 0000000..2772dd4 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.xuqm.license.config; + +import com.xuqm.common.security.JwtAuthFilter; +import com.xuqm.common.security.JwtUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtUtil jwtUtil; + + public SecurityConfig(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> {}) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/license/register", "/api/license/verify").permitAll() + .requestMatchers("/api/license/internal/**", "/actuator/health", "/actuator/info").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "http://127.0.0.1:*", + "http://*.xuqinmin.com", + "https://*.xuqinmin.com" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Location")); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java b/license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..00de2a0 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/controller/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package com.xuqm.license.controller; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException e) { + return ResponseEntity.status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + return ResponseEntity.badRequest().body(ApiResponse.badRequest("Invalid request")); + } +} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java b/license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java new file mode 100644 index 0000000..7c1a4d0 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/controller/LicenseAdminController.java @@ -0,0 +1,88 @@ +package com.xuqm.license.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.license.entity.CompanyEntity; +import com.xuqm.license.entity.DeviceEntity; +import com.xuqm.license.service.CompanyService; +import com.xuqm.license.service.DeviceService; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/license/admin") +public class LicenseAdminController { + + private final CompanyService companyService; + private final DeviceService deviceService; + + public LicenseAdminController(CompanyService companyService, DeviceService deviceService) { + this.companyService = companyService; + this.deviceService = deviceService; + } + + @GetMapping("/companies") + public ResponseEntity>> listCompanies() { + return ResponseEntity.ok(ApiResponse.success(companyService.listAll())); + } + + @PostMapping("/companies") + public ResponseEntity> createCompany(@RequestBody CreateCompanyRequest req) { + CompanyEntity company = companyService.create(req.name(), req.maxDevices(), req.expiresAt(), req.remark()); + return ResponseEntity.ok(ApiResponse.success(company)); + } + + @GetMapping("/companies/{id}") + public ResponseEntity>> getCompany(@PathVariable String id) { + CompanyEntity company = companyService.getById(id); + List devices = deviceService.listByCompany(id); + Map data = new java.util.LinkedHashMap<>(); + data.put("company", company); + data.put("devices", devices); + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @PutMapping("/companies/{id}") + public ResponseEntity> updateCompany(@PathVariable String id, @RequestBody UpdateCompanyRequest req) { + CompanyEntity company = companyService.update(id, req.name(), req.maxDevices(), req.expiresAt(), req.isActive(), req.remark()); + return ResponseEntity.ok(ApiResponse.success(company)); + } + + @DeleteMapping("/companies/{id}") + public ResponseEntity> deleteCompany(@PathVariable String id) { + companyService.delete(id); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @DeleteMapping("/devices/{id}") + public ResponseEntity> revokeDevice(@PathVariable String id) { + deviceService.revoke(id); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PutMapping("/devices/{id}/reactivate") + public ResponseEntity> reactivateDevice(@PathVariable String id) { + deviceService.reactivate(id); + return ResponseEntity.ok(ApiResponse.ok()); + } + + public record CreateCompanyRequest( + @NotBlank String name, + @NotNull Integer maxDevices, + LocalDateTime expiresAt, + String remark + ) {} + + public record UpdateCompanyRequest( + String name, + Integer maxDevices, + LocalDateTime expiresAt, + Boolean isActive, + String remark + ) {} +} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java b/license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java new file mode 100644 index 0000000..f7bf969 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/controller/LicenseInternalController.java @@ -0,0 +1,104 @@ +package com.xuqm.license.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.license.entity.CompanyEntity; +import com.xuqm.license.entity.DeviceEntity; +import com.xuqm.license.service.CompanyService; +import com.xuqm.license.service.DeviceService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/license/internal") +public class LicenseInternalController { + + private final CompanyService companyService; + private final DeviceService deviceService; + + @Value("${license.internal-token:xuqm-license-internal-token}") + private String internalToken; + + public LicenseInternalController(CompanyService companyService, DeviceService deviceService) { + this.companyService = companyService; + this.deviceService = deviceService; + } + + @GetMapping("/companies/{appKey}/status") + public ResponseEntity>> getCompanyStatus( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @PathVariable String appKey) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + try { + CompanyEntity company = companyService.getById(appKey); + Map data = Map.of( + "exists", true, + "active", companyService.isCompanyValid(company), + "maxDevices", company.getMaxDevices(), + "registeredDevices", company.getRegisteredDevices(), + "expiresAt", company.getExpiresAt() + ); + return ResponseEntity.ok(ApiResponse.success(data)); + } catch (Exception e) { + return ResponseEntity.ok(ApiResponse.success(Map.of("exists", false))); + } + } + + @GetMapping("/companies/{appKey}/devices") + public ResponseEntity>> listDevicesByCompany( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @PathVariable String appKey) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return ResponseEntity.ok(ApiResponse.success(deviceService.listByCompany(appKey))); + } + + @PostMapping("/companies") + public ResponseEntity> upsertCompany( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestBody UpsertCompanyRequest req) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + CompanyEntity company = companyService.upsert( + req.id(), + req.name(), + req.maxDevices(), + req.expiresAt(), + req.isActive(), + req.remark()); + return ResponseEntity.ok(ApiResponse.success(company)); + } + + @GetMapping("/devices/{deviceId}") + public ResponseEntity> getDevice( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @PathVariable String deviceId) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return deviceService.findByDeviceId(deviceId) + .map(d -> ResponseEntity.ok(ApiResponse.success(d))) + .orElse(ResponseEntity.ok(ApiResponse.error(404, "Device not found"))); + } + + private boolean isAllowed(String token) { + return token != null && internalToken.equals(token); + } + + public record UpsertCompanyRequest( + String id, + String name, + Integer maxDevices, + LocalDateTime expiresAt, + Boolean isActive, + String remark + ) {} +} diff --git a/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java b/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java new file mode 100644 index 0000000..4de8b17 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/controller/LicensePublicController.java @@ -0,0 +1,70 @@ +package com.xuqm.license.controller; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.xuqm.common.model.ApiResponse; +import com.xuqm.license.service.DeviceService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/license") +public class LicensePublicController { + + private final DeviceService deviceService; + + public LicensePublicController(DeviceService deviceService) { + this.deviceService = deviceService; + } + + @PostMapping("/register") + public ResponseEntity>> register(@Valid @RequestBody RegisterRequest req) { + DeviceService.RegisterResult result = deviceService.register( + req.companyId(), + req.deviceId(), + req.deviceName(), + req.deviceModel(), + req.deviceVendor(), + req.osVersion()); + Map data = new java.util.LinkedHashMap<>(); + data.put("success", result.success()); + data.put("token", result.token()); + if (result.message() != null) { + data.put("message", result.message()); + } + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @PostMapping("/verify") + public ResponseEntity>> verify(@Valid @RequestBody VerifyRequest req) { + DeviceService.VerifyResult result = deviceService.verify(req.companyId(), req.deviceId(), req.token()); + Map data = new java.util.LinkedHashMap<>(); + data.put("valid", result.valid()); + if (result.error() != null) { + data.put("error", result.error()); + } + return ResponseEntity.ok(ApiResponse.success(data)); + } + + public record RegisterRequest( + @NotBlank @JsonProperty("companyId") @JsonAlias("company_id") String companyId, + @NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, + @JsonProperty("deviceName") @JsonAlias("device_name") String deviceName, + @JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel, + @JsonProperty("deviceVendor") @JsonAlias("device_vendor") String deviceVendor, + @JsonProperty("osVersion") @JsonAlias("os_version") String osVersion + ) {} + + public record VerifyRequest( + @NotBlank @JsonProperty("companyId") @JsonAlias("company_id") String companyId, + @NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId, + @NotBlank String token + ) {} +} diff --git a/license-service/src/main/java/com/xuqm/license/entity/CompanyEntity.java b/license-service/src/main/java/com/xuqm/license/entity/CompanyEntity.java new file mode 100644 index 0000000..bf421b0 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/entity/CompanyEntity.java @@ -0,0 +1,67 @@ +package com.xuqm.license.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "companies") +public class CompanyEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(nullable = false, length = 255) + private String name; + + @Column(nullable = false, name = "max_devices") + private Integer maxDevices = 1; + + @Column(nullable = false, name = "registered_devices") + private Integer registeredDevices = 0; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(nullable = false, name = "is_active") + private Boolean isActive = true; + + @Column(length = 500) + private String remark; + + @Column(nullable = false, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false, name = "updated_at") + private LocalDateTime updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getMaxDevices() { return maxDevices; } + public void setMaxDevices(Integer maxDevices) { this.maxDevices = maxDevices; } + + public Integer getRegisteredDevices() { return registeredDevices; } + public void setRegisteredDevices(Integer registeredDevices) { this.registeredDevices = registeredDevices; } + + public LocalDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean isActive) { this.isActive = isActive; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java b/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java new file mode 100644 index 0000000..4bc9e59 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/entity/DeviceEntity.java @@ -0,0 +1,91 @@ +package com.xuqm.license.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "devices") +public class DeviceEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(nullable = false, name = "company_id", length = 36) + private String companyId; + + @Column(nullable = false, name = "device_id", length = 255, unique = true) + private String deviceId; + + @Column(name = "device_name", length = 255) + private String deviceName; + + @Column(name = "device_model", length = 255) + private String deviceModel; + + @Column(name = "device_vendor", length = 255) + private String deviceVendor; + + @Column(name = "os_version", length = 255) + private String osVersion; + + @Column(nullable = false, name = "token_hash", length = 512) + private String tokenHash; + + @Column(nullable = false, name = "registered_at", updatable = false) + private LocalDateTime registeredAt; + + @Column(name = "last_verified_at") + private LocalDateTime lastVerifiedAt; + + @Column(nullable = false, name = "is_active") + private Boolean isActive = true; + + @Column(nullable = false, name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false, name = "updated_at") + private LocalDateTime updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getCompanyId() { return companyId; } + public void setCompanyId(String companyId) { this.companyId = companyId; } + + public String getDeviceId() { return deviceId; } + public void setDeviceId(String deviceId) { this.deviceId = deviceId; } + + public String getDeviceName() { return deviceName; } + public void setDeviceName(String deviceName) { this.deviceName = deviceName; } + + public String getDeviceModel() { return deviceModel; } + public void setDeviceModel(String deviceModel) { this.deviceModel = deviceModel; } + + public String getDeviceVendor() { return deviceVendor; } + public void setDeviceVendor(String deviceVendor) { this.deviceVendor = deviceVendor; } + + public String getOsVersion() { return osVersion; } + public void setOsVersion(String osVersion) { this.osVersion = osVersion; } + + public String getTokenHash() { return tokenHash; } + public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } + + public LocalDateTime getRegisteredAt() { return registeredAt; } + public void setRegisteredAt(LocalDateTime registeredAt) { this.registeredAt = registeredAt; } + + public LocalDateTime getLastVerifiedAt() { return lastVerifiedAt; } + public void setLastVerifiedAt(LocalDateTime lastVerifiedAt) { this.lastVerifiedAt = lastVerifiedAt; } + + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean isActive) { this.isActive = isActive; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/license-service/src/main/java/com/xuqm/license/repository/CompanyRepository.java b/license-service/src/main/java/com/xuqm/license/repository/CompanyRepository.java new file mode 100644 index 0000000..08cb9c5 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/repository/CompanyRepository.java @@ -0,0 +1,12 @@ +package com.xuqm.license.repository; + +import com.xuqm.license.entity.CompanyEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CompanyRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); +} diff --git a/license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java b/license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java new file mode 100644 index 0000000..59741e6 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/repository/DeviceRepository.java @@ -0,0 +1,15 @@ +package com.xuqm.license.repository; + +import com.xuqm.license.entity.DeviceEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeviceRepository extends JpaRepository { + Optional findByDeviceId(String deviceId); + List findByCompanyIdOrderByRegisteredAtDesc(String companyId); + long countByCompanyIdAndIsActiveTrue(String companyId); +} diff --git a/license-service/src/main/java/com/xuqm/license/service/CompanyService.java b/license-service/src/main/java/com/xuqm/license/service/CompanyService.java new file mode 100644 index 0000000..d19cacf --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/service/CompanyService.java @@ -0,0 +1,115 @@ +package com.xuqm.license.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.license.entity.CompanyEntity; +import com.xuqm.license.repository.CompanyRepository; +import com.xuqm.license.repository.DeviceRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class CompanyService { + + private final CompanyRepository companyRepository; + private final DeviceRepository deviceRepository; + + public CompanyService(CompanyRepository companyRepository, DeviceRepository deviceRepository) { + this.companyRepository = companyRepository; + this.deviceRepository = deviceRepository; + } + + public List listAll() { + return companyRepository.findAllByOrderByCreatedAtDesc(); + } + + public CompanyEntity getById(String id) { + return companyRepository.findById(id) + .orElseThrow(() -> new BusinessException(404, "Company not found")); + } + + @Transactional + public CompanyEntity create(String name, Integer maxDevices, LocalDateTime expiresAt, String remark) { + return createWithId(UUID.randomUUID().toString(), name, maxDevices, expiresAt, remark); + } + + @Transactional + public CompanyEntity createWithId(String id, String name, Integer maxDevices, LocalDateTime expiresAt, String remark) { + CompanyEntity company = new CompanyEntity(); + company.setId(id); + company.setName(name); + company.setMaxDevices(maxDevices != null ? maxDevices : 1); + company.setRegisteredDevices(0); + company.setExpiresAt(expiresAt); + company.setIsActive(true); + company.setRemark(remark); + company.setCreatedAt(LocalDateTime.now()); + company.setUpdatedAt(LocalDateTime.now()); + return companyRepository.save(company); + } + + @Transactional + public CompanyEntity upsert(String id, String name, Integer maxDevices, LocalDateTime expiresAt, Boolean isActive, String remark) { + return companyRepository.findById(id) + .map(company -> update(id, name, maxDevices, expiresAt, isActive, remark)) + .orElseGet(() -> { + CompanyEntity created = createWithId(id, name, maxDevices, expiresAt, remark); + if (isActive != null) { + created.setIsActive(isActive); + created.setUpdatedAt(LocalDateTime.now()); + return companyRepository.save(created); + } + return created; + }); + } + + @Transactional + public CompanyEntity update(String id, String name, Integer maxDevices, LocalDateTime expiresAt, Boolean isActive, String remark) { + CompanyEntity company = getById(id); + if (name != null) company.setName(name); + if (maxDevices != null) company.setMaxDevices(maxDevices); + if (expiresAt != null) company.setExpiresAt(expiresAt); + if (isActive != null) company.setIsActive(isActive); + if (remark != null) company.setRemark(remark); + company.setUpdatedAt(LocalDateTime.now()); + return companyRepository.save(company); + } + + @Transactional + public void delete(String id) { + CompanyEntity company = getById(id); + deviceRepository.deleteAll(deviceRepository.findByCompanyIdOrderByRegisteredAtDesc(id)); + companyRepository.delete(company); + } + + @Transactional + public void incrementRegisteredDevices(String companyId) { + CompanyEntity company = getById(companyId); + company.setRegisteredDevices(company.getRegisteredDevices() + 1); + company.setUpdatedAt(LocalDateTime.now()); + companyRepository.save(company); + } + + @Transactional + public void decrementRegisteredDevices(String companyId) { + CompanyEntity company = getById(companyId); + if (company.getRegisteredDevices() > 0) { + company.setRegisteredDevices(company.getRegisteredDevices() - 1); + company.setUpdatedAt(LocalDateTime.now()); + companyRepository.save(company); + } + } + + public boolean isCompanyValid(CompanyEntity company) { + if (company == null || !Boolean.TRUE.equals(company.getIsActive())) { + return false; + } + if (company.getExpiresAt() != null && LocalDateTime.now().isAfter(company.getExpiresAt())) { + return false; + } + return true; + } +} diff --git a/license-service/src/main/java/com/xuqm/license/service/DeviceService.java b/license-service/src/main/java/com/xuqm/license/service/DeviceService.java new file mode 100644 index 0000000..2ecf01b --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/service/DeviceService.java @@ -0,0 +1,161 @@ +package com.xuqm.license.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.license.entity.CompanyEntity; +import com.xuqm.license.entity.DeviceEntity; +import com.xuqm.license.repository.DeviceRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class DeviceService { + + private final DeviceRepository deviceRepository; + private final CompanyService companyService; + private final LicenseAuthService licenseAuthService; + + public DeviceService(DeviceRepository deviceRepository, CompanyService companyService, LicenseAuthService licenseAuthService) { + this.deviceRepository = deviceRepository; + this.companyService = companyService; + this.licenseAuthService = licenseAuthService; + } + + public Optional findByDeviceId(String deviceId) { + return deviceRepository.findByDeviceId(deviceId); + } + + public List listByCompany(String companyId) { + return deviceRepository.findByCompanyIdOrderByRegisteredAtDesc(companyId); + } + + @Transactional + public RegisterResult register(String companyId, String deviceId, String deviceName, + String deviceModel, String deviceVendor, String osVersion) { + // Check if device already registered + Optional existingOpt = findByDeviceId(deviceId); + if (existingOpt.isPresent()) { + DeviceEntity existing = existingOpt.get(); + if (!Boolean.TRUE.equals(existing.getIsActive())) { + throw new BusinessException(403, "Device has been deactivated"); + } + // Re-issue token + String token = licenseAuthService.generateToken(companyId, deviceId, existing.getId()); + String tokenHash = hashToken(token); + existing.setDeviceName(firstNonBlank(deviceName, existing.getDeviceName())); + existing.setDeviceModel(firstNonBlank(deviceModel, existing.getDeviceModel())); + existing.setDeviceVendor(firstNonBlank(deviceVendor, existing.getDeviceVendor())); + existing.setOsVersion(firstNonBlank(osVersion, existing.getOsVersion())); + existing.setTokenHash(tokenHash); + existing.setLastVerifiedAt(LocalDateTime.now()); + existing.setUpdatedAt(LocalDateTime.now()); + deviceRepository.save(existing); + return new RegisterResult(true, token, "Re-registered"); + } + + // Validate company + CompanyEntity company = companyService.getById(companyId); + if (!companyService.isCompanyValid(company)) { + throw new BusinessException(403, "Company license is inactive or expired"); + } + if (company.getRegisteredDevices() >= company.getMaxDevices()) { + throw new BusinessException(403, "Device limit reached. Max allowed: " + company.getMaxDevices()); + } + + // Create device record + String recordId = UUID.randomUUID().toString(); + String token = licenseAuthService.generateToken(companyId, deviceId, recordId); + String tokenHash = hashToken(token); + + DeviceEntity device = new DeviceEntity(); + device.setId(recordId); + device.setCompanyId(companyId); + device.setDeviceId(deviceId); + device.setDeviceName(deviceName); + device.setDeviceModel(deviceModel); + device.setDeviceVendor(deviceVendor); + device.setOsVersion(osVersion); + device.setTokenHash(tokenHash); + device.setRegisteredAt(LocalDateTime.now()); + device.setIsActive(true); + device.setCreatedAt(LocalDateTime.now()); + device.setUpdatedAt(LocalDateTime.now()); + deviceRepository.save(device); + + companyService.incrementRegisteredDevices(companyId); + + return new RegisterResult(true, token, null); + } + + @Transactional + public VerifyResult verify(String companyId, String deviceId, String token) { + if (!licenseAuthService.verifyTokenPayload(token, companyId, deviceId)) { + return new VerifyResult(false, "Token mismatch"); + } + + DeviceEntity device = findByDeviceId(deviceId).orElse(null); + if (device == null || !Boolean.TRUE.equals(device.getIsActive())) { + return new VerifyResult(false, "Device not found or deactivated"); + } + + if (!hashToken(token).equals(device.getTokenHash())) { + return new VerifyResult(false, "Token revoked"); + } + + CompanyEntity company = companyService.getById(companyId); + if (!companyService.isCompanyValid(company)) { + return new VerifyResult(false, "Company license inactive or expired"); + } + + device.setLastVerifiedAt(LocalDateTime.now()); + device.setUpdatedAt(LocalDateTime.now()); + deviceRepository.save(device); + + return new VerifyResult(true, null); + } + + @Transactional + public void revoke(String deviceId) { + DeviceEntity device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new BusinessException(404, "Device not found")); + device.setIsActive(false); + device.setUpdatedAt(LocalDateTime.now()); + deviceRepository.save(device); + companyService.decrementRegisteredDevices(device.getCompanyId()); + } + + @Transactional + public void reactivate(String deviceId) { + DeviceEntity device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new BusinessException(404, "Device not found")); + device.setIsActive(true); + device.setUpdatedAt(LocalDateTime.now()); + deviceRepository.save(device); + companyService.incrementRegisteredDevices(device.getCompanyId()); + } + + public static String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + + private static String firstNonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + public record RegisterResult(boolean success, String token, String message) {} + public record VerifyResult(boolean valid, String error) {} +} diff --git a/license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java b/license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java new file mode 100644 index 0000000..b011672 --- /dev/null +++ b/license-service/src/main/java/com/xuqm/license/service/LicenseAuthService.java @@ -0,0 +1,48 @@ +package com.xuqm.license.service; + +import com.xuqm.common.security.JwtUtil; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class LicenseAuthService { + + private final JwtUtil jwtUtil; + + public LicenseAuthService(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + public String generateToken(String companyId, String deviceId, String recordId) { + return jwtUtil.generate(deviceId, Map.of( + "companyId", companyId, + "deviceId", deviceId, + "recordId", recordId + )); + } + + public boolean verifyTokenPayload(String token, String companyId, String deviceId) { + try { + var claims = jwtUtil.parse(token); + String claimCompanyId = firstNonNull(claims.get("companyId", String.class), claims.get("company_id", String.class)); + String claimDeviceId = firstNonNull(claims.get("deviceId", String.class), claims.get("device_id", String.class)); + return companyId.equals(claimCompanyId) && deviceId.equals(claimDeviceId); + } catch (Exception e) { + return false; + } + } + + public String getRecordIdFromToken(String token) { + try { + var claims = jwtUtil.parse(token); + return firstNonNull(claims.get("recordId", String.class), claims.get("record_id", String.class)); + } catch (Exception e) { + return null; + } + } + + private static String firstNonNull(String value, String fallback) { + return value != null ? value : fallback; + } +} diff --git a/license-service/src/main/resources/application.yml b/license-service/src/main/resources/application.yml new file mode 100644 index 0000000..9e90ec7 --- /dev/null +++ b/license-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: 8085 + +spring: + application: + name: license-service + datasource: + url: jdbc:mysql://39.107.53.187:3306/pad_license?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true + username: xuqm + password: Xuqm@2026 + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + connection-timeout: 30000 + idle-timeout: 300000 + max-lifetime: 900000 + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + jackson: + time-zone: UTC + serialization: + write-dates-as-timestamps: false + +jwt: + secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} + expiration: 3153600000000 + +license: + internal-token: ${LICENSE_INTERNAL_TOKEN:xuqm-license-internal-token} diff --git a/pom.xml b/pom.xml index accf092..bfa0a96 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ update-service demo-service file-service + license-service diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/LicenseMigrationRunner.java b/tenant-service/src/main/java/com/xuqm/tenant/config/LicenseMigrationRunner.java new file mode 100644 index 0000000..6d10df5 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/LicenseMigrationRunner.java @@ -0,0 +1,131 @@ +package com.xuqm.tenant.config; + +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; +import com.xuqm.tenant.entity.TenantEntity; +import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.FeatureServiceRepository; +import com.xuqm.tenant.repository.TenantRepository; +import com.xuqm.tenant.service.LicenseServiceClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +@Component +public class LicenseMigrationRunner implements ApplicationRunner { + + private final LicenseServiceClient licenseClient; + private final AppRepository appRepository; + private final FeatureServiceRepository featureServiceRepository; + private final TenantRepository tenantRepository; + + @Value("${license.migration.enabled:true}") + private boolean migrationEnabled; + + @Value("${license.migration.app-name:临床知识库}") + private String migrationAppName; + + private static final SecureRandom RANDOM = new SecureRandom(); + + public LicenseMigrationRunner(LicenseServiceClient licenseClient, + AppRepository appRepository, + FeatureServiceRepository featureServiceRepository, + TenantRepository tenantRepository) { + this.licenseClient = licenseClient; + this.appRepository = appRepository; + this.featureServiceRepository = featureServiceRepository; + this.tenantRepository = tenantRepository; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!migrationEnabled) { + return; + } + + // 检查是否已有 LICENSE 类型的 FeatureService,如果有说明已经迁移过 + List allServices = featureServiceRepository.findAll(); + boolean hasLicense = allServices.stream() + .anyMatch(s -> s.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE); + if (hasLicense) { + return; + } + + // 获取系统租户(第一个创建的租户) + TenantEntity systemTenant = tenantRepository.findFirstByOrderByCreatedAtAsc() + .orElse(null); + if (systemTenant == null) { + return; + } + + // 目前只有一个公司 f713d051-0fbe-4f2d-bec9-bf7b96fc9ce4 + // company_id 直接作为 appKey + String companyId = "f713d051-0fbe-4f2d-bec9-bf7b96fc9ce4"; + + // 检查是否已存在该 appKey 的应用 + if (appRepository.findByAppKey(companyId).isPresent()) { + // 应用已存在,只需确保 LICENSE 服务已开通 + ensureLicenseFeatureService(companyId); + return; + } + + // 创建应用,appKey = company_id(直接复用) + AppEntity app = new AppEntity(); + app.setId(UUID.randomUUID().toString()); + app.setTenantId(systemTenant.getId()); + app.setName(migrationAppName); + app.setPackageName("com.xuqm.clinical"); + app.setAppKey(companyId); // 直接复用 company_id 作为 appKey + app.setAppSecret(generateSecret()); + app.setCreatedAt(LocalDateTime.now()); + appRepository.save(app); + + // 自动开通 LICENSE 服务(所有平台) + ensureLicenseFeatureService(companyId); + + // 自动开通 FILE 服务(与创建应用时一致) + for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { + FeatureServiceEntity entity = new FeatureServiceEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppKey(companyId); + entity.setPlatform(platform); + entity.setServiceType(FeatureServiceEntity.ServiceType.FILE); + entity.setEnabled(true); + entity.setCreatedAt(LocalDateTime.now()); + featureServiceRepository.save(entity); + } + } + + private void ensureLicenseFeatureService(String appKey) { + for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { + featureServiceRepository + .findByAppKeyAndPlatformAndServiceType(appKey, platform, FeatureServiceEntity.ServiceType.LICENSE) + .orElseGet(() -> { + FeatureServiceEntity feature = new FeatureServiceEntity(); + feature.setId(UUID.randomUUID().toString()); + feature.setAppKey(appKey); + feature.setPlatform(platform); + feature.setServiceType(FeatureServiceEntity.ServiceType.LICENSE); + feature.setEnabled(true); + feature.setConfig("{\"maxDevices\":1}"); + feature.setCreatedAt(LocalDateTime.now()); + return featureServiceRepository.save(feature); + }); + } + } + + private String generateSecret() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java index 2b84dca..2bb8104 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -1,6 +1,7 @@ package com.xuqm.tenant.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.common.exception.BusinessException; import com.xuqm.tenant.dto.CreateAppRequest; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; @@ -10,8 +11,13 @@ import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.AppUserClient; import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.FeatureServiceManager; +import com.xuqm.tenant.service.LicenseFileCrypto; import com.xuqm.tenant.service.OperationLogService; import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -38,6 +44,9 @@ public class AppController { private final FeatureServiceManager featureServiceManager; private final AppUserClient appUserClient; + @Value("${license.public-base-url:https://auto.dev.xuqinmin.com/}") + private String licensePublicBaseUrl; + public AppController(AppService appService, EmailService emailService, OperationLogService operationLogService, TenantRepository tenantRepository, @@ -154,4 +163,37 @@ public class AppController { : appUserClient.listImUsers(appKey, keyword, page, size); return ResponseEntity.ok(ApiResponse.success(result)); } + + @GetMapping("/{appKey}/license-file") + public ResponseEntity 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 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]+", "_"); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index 6bd3b15..3e9a8c4 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -114,6 +114,7 @@ public class FeatureServiceController { platform, req == null ? null : req.pushConfig()); case FILE -> featureServiceManager.buildFileConfig(appKey, platform); + case LICENSE -> "{\"maxDevices\":1}"; }; FeatureServiceEntity saved = featureServiceManager.updateConfig( appKey, platform, serviceType, config); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java index 472272e..65c1318 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java @@ -20,7 +20,7 @@ public class FeatureServiceEntity { private static final SecureRandom RANDOM = new SecureRandom(); public enum Platform { ANDROID, IOS, HARMONY } - public enum ServiceType { IM, PUSH, UPDATE, FILE } + public enum ServiceType { IM, PUSH, UPDATE, FILE, LICENSE } @Id private String id; diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index 57d1176..03ef522 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -26,15 +26,18 @@ public class FeatureServiceManager { private final ServiceActivationRequestRepository requestRepository; private final AppRepository appRepository; private final ObjectMapper objectMapper; + private final LicenseServiceClient licenseServiceClient; public FeatureServiceManager(FeatureServiceRepository repository, ServiceActivationRequestRepository requestRepository, AppRepository appRepository, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + LicenseServiceClient licenseServiceClient) { this.repository = repository; this.requestRepository = requestRepository; this.appRepository = appRepository; this.objectMapper = objectMapper; + this.licenseServiceClient = licenseServiceClient; } public List listByApp(String appKey) { @@ -148,6 +151,10 @@ public class FeatureServiceManager { services.forEach(service -> service.setEnabled(true)); repository.saveAll(services); } + if (req.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE) { + appRepository.findByAppKey(normalizedAppId).ifPresent(app -> + licenseServiceClient.syncCompany(app.getAppKey(), app.getName(), 1)); + } return req; } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java b/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java new file mode 100644 index 0000000..94b3e6e --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseFileCrypto.java @@ -0,0 +1,55 @@ +package com.xuqm.tenant.service; + +import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +public final class LicenseFileCrypto { + + private static final String MAGIC = "XUQM-LICENSE-V1"; + private static final String PASSPHRASE = "xuqm-license-file-v1.2026.internal"; + private static final int SALT_BYTES = 16; + private static final int IV_BYTES = 12; + private static final int KEY_BITS = 256; + private static final int ITERATIONS = 120_000; + private static final int GCM_TAG_BITS = 128; + private static final SecureRandom RANDOM = new SecureRandom(); + + private LicenseFileCrypto() { + } + + public static String encrypt(String plainText) { + try { + byte[] salt = randomBytes(SALT_BYTES); + byte[] iv = randomBytes(IV_BYTES); + SecretKeySpec key = deriveKey(salt); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return String.join(".", + MAGIC, + Base64.getUrlEncoder().withoutPadding().encodeToString(salt), + Base64.getUrlEncoder().withoutPadding().encodeToString(iv), + Base64.getUrlEncoder().withoutPadding().encodeToString(cipherText)); + } catch (Exception e) { + throw new IllegalStateException("Failed to encrypt license file", e); + } + } + + private static SecretKeySpec deriveKey(byte[] salt) throws Exception { + PBEKeySpec spec = new PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS); + byte[] encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + return new SecretKeySpec(encoded, "AES"); + } + + private static byte[] randomBytes(int size) { + byte[] bytes = new byte[size]; + RANDOM.nextBytes(bytes); + return bytes; + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java b/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java new file mode 100644 index 0000000..ad4c168 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/LicenseServiceClient.java @@ -0,0 +1,72 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.common.model.ApiResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +@Service +public class LicenseServiceClient { + + @Value("${license.service.base-url:http://license-service:8085}") + private String licenseBaseUrl; + + @Value("${license.internal-token:xuqm-license-internal-token}") + private String internalToken; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + public boolean isCompanyExists(String appKey) { + try { + ResponseEntity response = callInternal("/api/license/internal/companies/" + appKey + "/status", HttpMethod.GET, null); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + JsonNode node = objectMapper.readTree(response.getBody()); + return node.path("data").path("exists").asBoolean(false); + } + } catch (Exception e) { + // ignore + } + return false; + } + + public List> listDevices(String appKey) { + try { + ResponseEntity response = callInternal("/api/license/internal/companies/" + appKey + "/devices", HttpMethod.GET, null); + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + JsonNode node = objectMapper.readTree(response.getBody()); + return objectMapper.convertValue(node.path("data"), new com.fasterxml.jackson.core.type.TypeReference<>() {}); + } + } catch (Exception e) { + // ignore + } + return List.of(); + } + + public void syncCompany(String appKey, String name, Integer maxDevices) { + try { + Map body = Map.of( + "id", appKey, + "name", name, + "maxDevices", maxDevices != null ? maxDevices : 1 + ); + callInternal("/api/license/internal/companies", HttpMethod.POST, body); + } catch (Exception e) { + // ignore + } + } + + private ResponseEntity callInternal(String path, HttpMethod method, Object body) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(body, headers); + return restTemplate.exchange(licenseBaseUrl + path, method, entity, String.class); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java index a891ad4..6caf44f 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java @@ -140,7 +140,8 @@ public class SdkAppProvisioningService { FeatureServiceEntity.Platform.HARMONY)) { for (FeatureServiceEntity.ServiceType serviceType : List.of( FeatureServiceEntity.ServiceType.PUSH, - FeatureServiceEntity.ServiceType.UPDATE)) { + FeatureServiceEntity.ServiceType.UPDATE, + FeatureServiceEntity.ServiceType.LICENSE)) { featureServiceRepository.findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, serviceType) .orElseGet(() -> { FeatureServiceEntity feature = new FeatureServiceEntity(); @@ -149,18 +150,21 @@ public class SdkAppProvisioningService { feature.setPlatform(platform); feature.setServiceType(serviceType); feature.setEnabled(true); - feature.setConfig(serviceType == FeatureServiceEntity.ServiceType.UPDATE - ? switch (platform) { - case ANDROID -> """ - {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} - """.trim(); - case IOS -> """ - {"defaultStoreTargets":["APP_STORE"],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} - """.trim(); - case HARMONY -> """ - {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} - """.trim(); - } : null); + feature.setConfig(switch (serviceType) { + case UPDATE -> switch (platform) { + case ANDROID -> """ + {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} + """.trim(); + case IOS -> """ + {"defaultStoreTargets":["APP_STORE"],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} + """.trim(); + case HARMONY -> """ + {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} + """.trim(); + }; + case LICENSE -> "{\"maxDevices\":1}"; + default -> null; + }); feature.setCreatedAt(LocalDateTime.now()); return featureServiceRepository.save(feature); }); diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index dc60a7c..3f64018 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -57,6 +57,15 @@ jwt: secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} expiration: 3153600000000 +license: + service: + base-url: ${LICENSE_SERVICE_BASE_URL:http://license-service:8085} + public-base-url: ${LICENSE_PUBLIC_BASE_URL:https://auto.dev.xuqinmin.com/} + internal-token: ${LICENSE_INTERNAL_TOKEN:xuqm-license-internal-token} + migration: + enabled: true + app-name: 临床知识库 + captcha: expire-seconds: 300