Add license service and tenant integration

这个提交包含在:
XuqmGroup 2026-05-15 21:00:24 +08:00
父节点 b24e3669cb
当前提交 acfc2cbfbe
共有 27 个文件被更改,包括 1323 次插入16 次删除

查看文件

@ -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
查看文件

@ -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 普通文件
查看文件

@ -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}

查看文件

@ -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