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