比较提交

..

2 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
1a0ef7d886 support multi-app device registration per device
- Add composite unique constraint (app_key, device_id) on DeviceEntity
- Remove global unique constraint from device_id column
- Update DeviceRepository: findByAppKeyAndDeviceId returns Optional,
  findByDeviceId returns List for multi-app lookups
- Update DeviceService.register/verify to scope lookups by appKey
  so same physical device can register independently for each app
- Update LicenseInternalController.getDevice to return list

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:47:17 +08:00
XuqmGroup
843ed69f3c license: fix device re-register appKey update, add license file parser
- DeviceService.register(): update appKey when device switches to a different app
  and adjust registered device counters for old/new appKey
- LicenseAdminController: fix updateAppLicense parameter count mismatch
- AppController: add POST /api/apps/license/parse endpoint for license file decryption
- SecurityCenterView: add License file parser UI with upload and paste support
- appApi: add parseLicenseFile() method

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:37:46 +08:00
共有 7 个文件被更改,包括 52 次插入12 次删除

0
common/NODE 普通文件
查看文件

查看文件

@ -51,7 +51,7 @@ public class LicenseAdminController {
} }
} }
AppLicenseEntity updated = appLicenseService.update( AppLicenseEntity updated = appLicenseService.update(
appKey, null, req.maxDevices(), newExpiresAt, clearExpiresAt, req.isActive(), req.remark()); appKey, null, req.maxDevices(), newExpiresAt, clearExpiresAt, req.isActive(), req.remark(), null, null, null);
return ResponseEntity.ok(ApiResponse.success(updated)); return ResponseEntity.ok(ApiResponse.success(updated));
} }

查看文件

@ -81,15 +81,17 @@ public class LicenseInternalController {
} }
@GetMapping("/devices/{deviceId}") @GetMapping("/devices/{deviceId}")
public ResponseEntity<ApiResponse<DeviceEntity>> getDevice( public ResponseEntity<ApiResponse<List<DeviceEntity>>> getDevice(
@RequestHeader(value = "X-Internal-Token", required = false) String token, @RequestHeader(value = "X-Internal-Token", required = false) String token,
@PathVariable String deviceId) { @PathVariable String deviceId) {
if (!isAllowed(token)) { if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
} }
return deviceService.findByDeviceId(deviceId) List<DeviceEntity> devices = deviceService.findByDeviceId(deviceId);
.map(d -> ResponseEntity.ok(ApiResponse.success(d))) if (devices.isEmpty()) {
.orElse(ResponseEntity.ok(ApiResponse.error(404, "Device not found"))); return ResponseEntity.ok(ApiResponse.error(404, "Device not found"));
}
return ResponseEntity.ok(ApiResponse.success(devices));
} }
private boolean isAllowed(String token) { private boolean isAllowed(String token) {

查看文件

@ -4,10 +4,12 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "devices") @Table(name = "devices",
uniqueConstraints = @UniqueConstraint(name = "uk_app_key_device_id", columnNames = {"app_key", "device_id"}))
public class DeviceEntity { public class DeviceEntity {
@Id @Id

查看文件

@ -9,7 +9,8 @@ import java.util.Optional;
@Repository @Repository
public interface DeviceRepository extends JpaRepository<DeviceEntity, String> { public interface DeviceRepository extends JpaRepository<DeviceEntity, String> {
Optional<DeviceEntity> findByDeviceId(String deviceId); Optional<DeviceEntity> findByAppKeyAndDeviceId(String appKey, String deviceId);
List<DeviceEntity> findByDeviceId(String deviceId);
List<DeviceEntity> findByAppKeyOrderByRegisteredAtDesc(String appKey); List<DeviceEntity> findByAppKeyOrderByRegisteredAtDesc(String appKey);
long countByAppKeyAndIsActiveTrue(String appKey); long countByAppKeyAndIsActiveTrue(String appKey);
} }

查看文件

@ -34,7 +34,7 @@ public class DeviceService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public Optional<DeviceEntity> findByDeviceId(String deviceId) { public List<DeviceEntity> findByDeviceId(String deviceId) {
return deviceRepository.findByDeviceId(deviceId); return deviceRepository.findByDeviceId(deviceId);
} }
@ -48,8 +48,8 @@ public class DeviceService {
JsonNode userInfo) { JsonNode userInfo) {
validatePackageName(appKey, packageName); validatePackageName(appKey, packageName);
// Check if device already registered // Check if device already registered for this app
Optional<DeviceEntity> existingOpt = findByDeviceId(deviceId); Optional<DeviceEntity> existingOpt = deviceRepository.findByAppKeyAndDeviceId(appKey, deviceId);
if (existingOpt.isPresent()) { if (existingOpt.isPresent()) {
DeviceEntity existing = existingOpt.get(); DeviceEntity existing = existingOpt.get();
if (!Boolean.TRUE.equals(existing.getIsActive())) { if (!Boolean.TRUE.equals(existing.getIsActive())) {
@ -58,8 +58,14 @@ public class DeviceService {
// Re-issue token // Re-issue token
String token = licenseAuthService.generateToken(appKey, deviceId, existing.getId()); String token = licenseAuthService.generateToken(appKey, deviceId, existing.getId());
String tokenHash = hashToken(token); String tokenHash = hashToken(token);
if (existing.getAppKey() == null || existing.getAppKey().isBlank()) { String oldAppKey = existing.getAppKey();
if (oldAppKey == null || oldAppKey.isBlank()) {
existing.setAppKey(appKey); existing.setAppKey(appKey);
} else if (!oldAppKey.equals(appKey)) {
// Device switched to a different app: update appKey and adjust counters
existing.setAppKey(appKey);
appLicenseService.decrementRegisteredDevices(oldAppKey);
appLicenseService.incrementRegisteredDevices(appKey);
} }
existing.setDeviceName(firstNonBlank(deviceName, existing.getDeviceName())); existing.setDeviceName(firstNonBlank(deviceName, existing.getDeviceName()));
existing.setDeviceModel(firstNonBlank(deviceModel, existing.getDeviceModel())); existing.setDeviceModel(firstNonBlank(deviceModel, existing.getDeviceModel()));
@ -120,7 +126,7 @@ public class DeviceService {
return new VerifyResult(false, "Token mismatch"); return new VerifyResult(false, "Token mismatch");
} }
DeviceEntity device = findByDeviceId(deviceId).orElse(null); DeviceEntity device = deviceRepository.findByAppKeyAndDeviceId(appKey, deviceId).orElse(null);
if (device == null || !Boolean.TRUE.equals(device.getIsActive())) { if (device == null || !Boolean.TRUE.equals(device.getIsActive())) {
return new VerifyResult(false, "Device not found or deactivated"); return new VerifyResult(false, "Device not found or deactivated");
} }

查看文件

@ -2,6 +2,7 @@ package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.LicenseFileCrypto;
import com.xuqm.tenant.dto.CreateAppRequest; import com.xuqm.tenant.dto.CreateAppRequest;
import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.FeatureServiceEntity;
@ -174,6 +175,34 @@ public class AppController {
.body(encrypted.getBytes(java.nio.charset.StandardCharsets.UTF_8)); .body(encrypted.getBytes(java.nio.charset.StandardCharsets.UTF_8));
} }
/**
* Parse an uploaded license file and return its decrypted contents.
* Used by the security center to verify license file information.
*/
@PostMapping("/license/parse")
public ResponseEntity<ApiResponse<Map<String, Object>>> parseLicenseFile(@RequestBody Map<String, String> body) {
String content = body.get("content");
if (content == null || content.isBlank()) {
throw new BusinessException("License file content is required");
}
try {
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(content.trim());
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("appKey", payload.appKey());
data.put("appName", payload.appName());
data.put("packageName", payload.packageName());
data.put("iosBundleId", payload.iosBundleId());
data.put("harmonyBundleName", payload.harmonyBundleName());
data.put("baseUrl", payload.baseUrl());
data.put("serverUrl", payload.serverUrl());
return ResponseEntity.ok(ApiResponse.success(data));
} catch (IllegalArgumentException e) {
throw new BusinessException("Invalid license file: " + e.getMessage());
} catch (Exception e) {
throw new BusinessException("Failed to parse license file: " + e.getMessage());
}
}
private static String sanitizeFileName(String value) { private static String sanitizeFileName(String value) {
String name = value == null || value.isBlank() ? "license" : value.trim(); String name = value == null || value.isBlank() ? "license" : value.trim();
return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");