Add license service and tenant integration
这个提交包含在:
父节点
b24e3669cb
当前提交
acfc2cbfbe
@ -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
|
||||
|
||||
|
||||
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
@ -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: '构建后是否自动部署到生产服务器')
|
||||
}
|
||||
|
||||
77
license-service/pom.xml
普通文件
77
license-service/pom.xml
普通文件
@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.xuqm</groupId>
|
||||
<artifactId>xuqmgroup-server-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>license-service</artifactId>
|
||||
<name>license-service</name>
|
||||
<description>Standalone PAD license server: device registration, token verification, company management</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xuqm</groupId>
|
||||
<artifactId>common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<ApiResponse<Void>> handleBusinessException(BusinessException e) {
|
||||
return ResponseEntity.status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.badRequest("Invalid request"));
|
||||
}
|
||||
}
|
||||
@ -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<ApiResponse<List<CompanyEntity>>> listCompanies() {
|
||||
return ResponseEntity.ok(ApiResponse.success(companyService.listAll()));
|
||||
}
|
||||
|
||||
@PostMapping("/companies")
|
||||
public ResponseEntity<ApiResponse<CompanyEntity>> 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<ApiResponse<Map<String, Object>>> getCompany(@PathVariable String id) {
|
||||
CompanyEntity company = companyService.getById(id);
|
||||
List<DeviceEntity> devices = deviceService.listByCompany(id);
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("company", company);
|
||||
data.put("devices", devices);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
}
|
||||
|
||||
@PutMapping("/companies/{id}")
|
||||
public ResponseEntity<ApiResponse<CompanyEntity>> 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<ApiResponse<Void>> deleteCompany(@PathVariable String id) {
|
||||
companyService.delete(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@DeleteMapping("/devices/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> revokeDevice(@PathVariable String id) {
|
||||
deviceService.revoke(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@PutMapping("/devices/{id}/reactivate")
|
||||
public ResponseEntity<ApiResponse<Void>> 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
|
||||
) {}
|
||||
}
|
||||
@ -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<ApiResponse<Map<String, Object>>> 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<String, Object> 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<ApiResponse<List<DeviceEntity>>> 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<ApiResponse<CompanyEntity>> 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<ApiResponse<DeviceEntity>> 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
|
||||
) {}
|
||||
}
|
||||
@ -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<ApiResponse<Map<String, Object>>> register(@Valid @RequestBody RegisterRequest req) {
|
||||
DeviceService.RegisterResult result = deviceService.register(
|
||||
req.companyId(),
|
||||
req.deviceId(),
|
||||
req.deviceName(),
|
||||
req.deviceModel(),
|
||||
req.deviceVendor(),
|
||||
req.osVersion());
|
||||
Map<String, Object> 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<ApiResponse<Map<String, Object>>> verify(@Valid @RequestBody VerifyRequest req) {
|
||||
DeviceService.VerifyResult result = deviceService.verify(req.companyId(), req.deviceId(), req.token());
|
||||
Map<String, Object> 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
|
||||
) {}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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<CompanyEntity, String> {
|
||||
List<CompanyEntity> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@ -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<DeviceEntity, String> {
|
||||
Optional<DeviceEntity> findByDeviceId(String deviceId);
|
||||
List<DeviceEntity> findByCompanyIdOrderByRegisteredAtDesc(String companyId);
|
||||
long countByCompanyIdAndIsActiveTrue(String companyId);
|
||||
}
|
||||
@ -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<CompanyEntity> 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;
|
||||
}
|
||||
}
|
||||
@ -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<DeviceEntity> findByDeviceId(String deviceId) {
|
||||
return deviceRepository.findByDeviceId(deviceId);
|
||||
}
|
||||
|
||||
public List<DeviceEntity> 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<DeviceEntity> 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) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
1
pom.xml
1
pom.xml
@ -20,6 +20,7 @@
|
||||
<module>update-service</module>
|
||||
<module>demo-service</module>
|
||||
<module>file-service</module>
|
||||
<module>license-service</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
@ -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<FeatureServiceEntity> 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);
|
||||
}
|
||||
}
|
||||
@ -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<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
||||
boolean licenseEnabled = featureServiceManager.listByApp(appKey).stream()
|
||||
.anyMatch(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE && service.isEnabled());
|
||||
if (!licenseEnabled) {
|
||||
throw new BusinessException(400, "License 服务未开通");
|
||||
}
|
||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||
payload.put("appKey", app.getAppKey());
|
||||
payload.put("appName", app.getName());
|
||||
payload.put("packageName", app.getPackageName());
|
||||
payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||
payload.put("issuedAt", java.time.Instant.now().toString());
|
||||
String encrypted = LicenseFileCrypto.encrypt(new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(payload).toString());
|
||||
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(filename, java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||
.body(encrypted.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String value) {
|
||||
String baseUrl = value == null || value.isBlank() ? "https://auto.dev.xuqinmin.com/" : value.trim();
|
||||
return baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String value) {
|
||||
String name = value == null || value.isBlank() ? "license" : value.trim();
|
||||
return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<FeatureServiceEntity> 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<Map<String, Object>> listDevices(String appKey) {
|
||||
try {
|
||||
ResponseEntity<String> 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<String, Object> 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<String> callInternal(String path, HttpMethod method, Object body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-Internal-Token", internalToken);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Object> entity = new HttpEntity<>(body, headers);
|
||||
return restTemplate.exchange(licenseBaseUrl + path, method, entity, String.class);
|
||||
}
|
||||
}
|
||||
@ -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,8 +150,8 @@ public class SdkAppProvisioningService {
|
||||
feature.setPlatform(platform);
|
||||
feature.setServiceType(serviceType);
|
||||
feature.setEnabled(true);
|
||||
feature.setConfig(serviceType == FeatureServiceEntity.ServiceType.UPDATE
|
||||
? switch (platform) {
|
||||
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();
|
||||
@ -160,7 +161,10 @@ public class SdkAppProvisioningService {
|
||||
case HARMONY -> """
|
||||
{"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""}
|
||||
""".trim();
|
||||
} : null);
|
||||
};
|
||||
case LICENSE -> "{\"maxDevices\":1}";
|
||||
default -> null;
|
||||
});
|
||||
feature.setCreatedAt(LocalDateTime.now());
|
||||
return featureServiceRepository.save(feature);
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户