From 1cce0aeabb8ced758ee1d6c7926ee75fc9e6aa7b Mon Sep 17 00:00:00 2001 From: chenhao Date: Tue, 9 Jun 2026 17:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=A0=81=E7=AE=A1=E7=90=86=E5=92=8C=E8=AE=BE=E5=A4=87=E8=87=AA?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `AndroidTenantProviderConfig` 配置类,提供租户解析逻辑 - 更新 `AndroidAuthService` 接口,添加 `authenticateHttp` 方法的可选参数 - 新增前端授权码管理页面 `LicenseManagement`,支持授权码列表展示和导入 - 新增 `AndroidDeviceRegistrationServiceImpl` 的 `register` 方法,支持设备自注册并验证租户代码 - 更新 `AndroidAuthServiceImpl`,在认证过程中应用授权信息 - 更新相关 DTO 和接口,支持新的授权和设备注册逻辑 --- .../com/imeeting/common/LicenseConstants.java | 9 + .../java/com/imeeting/common/RedisKeys.java | 4 + .../com/imeeting/common/SysParamKeys.java | 5 + .../config/AndroidTenantProviderConfig.java | 116 +++++++ .../android/AndroidAuthController.java | 5 + .../android/AndroidClientController.java | 2 + .../android/AndroidDeviceController.java | 47 ++- .../biz/DeviceManagementController.java | 13 + .../biz/LicenseManagementController.java | 45 +++ .../dto/android/AndroidDeviceHomeStatsVO.java | 36 +++ .../android/AndroidDeviceHomeWeatherVO.java | 18 ++ .../android/AndroidDeviceRegisterRequest.java | 3 + .../AndroidDeviceRegisterResponse.java | 3 + .../AndroidDeviceWeatherCacheValue.java | 10 + .../dto/biz/DeviceAdminUpdateCommand.java | 3 + .../imeeting/dto/biz/DeviceOnlineAdminVO.java | 6 + .../dto/biz/LicenseImportResultVO.java | 23 ++ .../imeeting/dto/biz/LicenseImportRow.java | 20 ++ .../java/com/imeeting/dto/biz/LicenseVO.java | 49 +++ .../imeeting/entity/biz/DeviceInfoEntity.java | 4 + .../entity/biz/DeviceLoginLogEntity.java | 31 ++ .../imeeting/entity/biz/LicenseEntity.java | 55 ++++ .../com/imeeting/enums/LicenseStatusEnum.java | 19 ++ .../com/imeeting/enums/LicenseTypeEnum.java | 17 ++ .../grpc/push/AndroidPushGrpcService.java | 45 +-- .../com/imeeting/mapper/DeviceInfoMapper.java | 2 + .../imeeting/mapper/DeviceLoginLogMapper.java | 31 ++ .../com/imeeting/mapper/LicenseMapper.java | 223 ++++++++++++++ .../imeeting/mapper/biz/MeetingMapper.java | 36 +++ .../mapper/biz/ScreenSaverMapper.java | 18 ++ .../service/android/AndroidAuthService.java | 2 + .../android/AndroidDeviceBindingService.java | 2 + .../android/AndroidDeviceHomeService.java | 9 + .../AndroidDeviceRegistrationService.java | 2 +- .../android/impl/AndroidAuthServiceImpl.java | 58 +++- .../impl/AndroidDeviceBindingServiceImpl.java | 17 ++ .../impl/AndroidDeviceHomeServiceImpl.java | 246 +++++++++++++++ .../AndroidDeviceRegistrationServiceImpl.java | 54 +++- .../biz/DeviceOnlineManagementService.java | 4 + .../imeeting/service/biz/LicenseService.java | 26 ++ .../service/biz/impl/AiTaskServiceImpl.java | 5 +- .../DeviceOnlineManagementServiceImpl.java | 25 +- .../service/biz/impl/LicenseServiceImpl.java | 285 ++++++++++++++++++ .../biz/impl/ScreenSaverServiceImpl.java | 21 +- .../TenantManagementServicePrimaryImpl.java | 73 +++++ .../task/AndroidPushMessageRetryTask.java | 45 +-- .../src/main/resources/application-dev.yml | 10 +- backend/src/main/resources/application.yml | 1 + .../imeeting/service/DictItemServiceTest.java | 63 ++-- .../biz/impl/ScreenSaverServiceImplTest.java | 24 ++ frontend/src/api/business/license.ts | 40 +++ frontend/src/api/index.ts | 10 + frontend/src/locales/en-US.json | 8 +- .../src/pages/business/LicenseManagement.tsx | 243 +++++++++++++++ frontend/src/pages/devices/index.tsx | 63 +++- frontend/src/routes/routes.tsx | 2 + frontend/src/types/index.ts | 4 + 57 files changed, 2095 insertions(+), 145 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/common/LicenseConstants.java create mode 100644 backend/src/main/java/com/imeeting/config/AndroidTenantProviderConfig.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/LicenseManagementController.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeStatsVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeWeatherVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/AndroidDeviceWeatherCacheValue.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/LicenseImportResultVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/LicenseImportRow.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/LicenseVO.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/DeviceLoginLogEntity.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/LicenseEntity.java create mode 100644 backend/src/main/java/com/imeeting/enums/LicenseStatusEnum.java create mode 100644 backend/src/main/java/com/imeeting/enums/LicenseTypeEnum.java create mode 100644 backend/src/main/java/com/imeeting/mapper/DeviceLoginLogMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/LicenseMapper.java create mode 100644 backend/src/main/java/com/imeeting/service/android/AndroidDeviceHomeService.java create mode 100644 backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/LicenseService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/LicenseServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java create mode 100644 frontend/src/api/business/license.ts create mode 100644 frontend/src/pages/business/LicenseManagement.tsx diff --git a/backend/src/main/java/com/imeeting/common/LicenseConstants.java b/backend/src/main/java/com/imeeting/common/LicenseConstants.java new file mode 100644 index 0000000..da4e3d2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/common/LicenseConstants.java @@ -0,0 +1,9 @@ +package com.imeeting.common; + +public final class LicenseConstants { + private LicenseConstants() { + } + + public static final String TEMP_SERIAL_PREFIX = "SN"; + public static final String IMPORT_BATCH_PREFIX = "LIC"; +} diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index 5bf4f8c..4cb6545 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -123,6 +123,10 @@ public final class RedisKeys { return "biz:meeting:android:draft:" + meetingId; } + public static String androidDeviceWeatherKey(String cityName) { + return "biz:android:device:weather:" + cityName; + } + public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER"; public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_TYPE = "type"; diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index 277be3f..c3fbf1a 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -21,4 +21,9 @@ public final class SysParamKeys { public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio"; public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance"; public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode"; + public static final String LICENSE_TEMP_DEFAULT_COUNT = "license.temp.default.count"; + public static final String LICENSE_TEMP_DEFAULT_EXPIRE_MONTHS = "license.temp.default.expire.months"; + public static final String LICENSE_DEFAULT_PRODUCT_CODE = "license.default.product.code"; + public static final String DEVICE_WEATHER_QWEATHER_BASE_URL = "device.weather.qweather.base_url"; + public static final String DEVICE_WEATHER_QWEATHER_KEY = "device.weather.qweather.key"; } diff --git a/backend/src/main/java/com/imeeting/config/AndroidTenantProviderConfig.java b/backend/src/main/java/com/imeeting/config/AndroidTenantProviderConfig.java new file mode 100644 index 0000000..149a5cf --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/AndroidTenantProviderConfig.java @@ -0,0 +1,116 @@ +package com.imeeting.config; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.unisbase.config.properties.UnisBaseProperties; +import com.unisbase.entity.SysTenant; +import com.unisbase.mapper.SysTenantMapper; +import com.unisbase.security.SpringSecurityTenantProvider; +import com.unisbase.spi.UnisBaseTenantProvider; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Configuration +public class AndroidTenantProviderConfig { + + private static final String TENANT_ID_HEADER = "X-Tenant-Id"; + private static final String TENANT_CODE_HEADER = "X-Tenant-Code"; + + @Bean + public UnisBaseTenantProvider unisBaseTenantProvider(UnisBaseProperties properties, + ObjectProvider sysTenantMapperProvider) { + return new AndroidTenantProvider(properties, sysTenantMapperProvider); + } + + private static final class AndroidTenantProvider extends SpringSecurityTenantProvider { + private final UnisBaseProperties properties; + private final ObjectProvider sysTenantMapperProvider; + + private AndroidTenantProvider(UnisBaseProperties properties, ObjectProvider sysTenantMapperProvider) { + super(properties); + this.properties = properties; + this.sysTenantMapperProvider = sysTenantMapperProvider; + } + + @Override + public Long getCurrentTenantId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof com.unisbase.security.LoginUser user && user.getTenantId() != null) { + return user.getTenantId(); + } + + Long tenantId = parseTenantIdHeader(); + if (tenantId != null) { + return tenantId; + } + + String tenantCode = header(TENANT_CODE_HEADER); + if (StringUtils.hasText(tenantCode)) { + Long resolvedTenantId = resolveTenantIdByCode(tenantCode.trim()); + if (resolvedTenantId != null) { + return resolvedTenantId; + } + } + + if (isSingleTenantMode()) { + Long defaultTenantId = properties.getTenant().getDefaultTenantId(); + return defaultTenantId == null || defaultTenantId <= 0 ? 1L : defaultTenantId; + } + return 0L; + } + + private Long parseTenantIdHeader() { + String value = header(TENANT_ID_HEADER); + if (!StringUtils.hasText(value)) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException ex) { + return null; + } + } + + private Long resolveTenantIdByCode(String tenantCode) { + SysTenantMapper sysTenantMapper = sysTenantMapperProvider.getIfAvailable(); + if (sysTenantMapper == null) { + return null; + } + try { + SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper() + .eq(SysTenant::getTenantCode, tenantCode) + .eq(SysTenant::getIsDeleted, 0) + .last("LIMIT 1")); + return tenant == null ? null : tenant.getId(); + } catch (Exception ex) { + return null; + } + } + + private String header(String name) { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return null; + } + HttpServletRequest request = attributes.getRequest(); + return request == null ? null : request.getHeader(name); + } + + private boolean isSingleTenantMode() { + if (properties == null || properties.getTenant() == null) { + return false; + } + String mode = properties.getTenant().getMode(); + if (mode != null && !mode.isBlank()) { + return "single".equalsIgnoreCase(mode.trim()); + } + return !properties.getTenant().isEnabled(); + } + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java index 018a05f..e3a1506 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java @@ -67,6 +67,11 @@ public class AndroidAuthController { appVersion, platform ); + androidDeviceBindingService.recordLogin( + deviceId.trim(), + response.getCurrentTenantId(), + response.getUser().getUserId() + ); } return ApiResponse.ok(response); } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java index e51d19b..ac45e27 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java @@ -5,6 +5,7 @@ import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.entity.biz.ClientDownload; import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.biz.ClientDownloadService; +import com.unisbase.annotation.Anonymous; import com.unisbase.common.ApiResponse; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -38,6 +39,7 @@ public class AndroidClientController { ) }) @GetMapping("/latest/by-platform") + @Anonymous public ApiResponse latestByPlatform(HttpServletRequest request, @RequestParam(value = "platform_code", required = false) String platformCode, @RequestParam(value = "platform_type", required = false) String platformType, diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java index a7606df..480b1b8 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java @@ -1,9 +1,11 @@ package com.imeeting.controller.android; import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidDeviceHomeStatsVO; import com.imeeting.dto.android.AndroidDeviceRegisterRequest; import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidDeviceHomeService; import com.imeeting.service.android.AndroidDeviceRegistrationService; import com.imeeting.support.AndroidRequestLogHelper; import com.unisbase.annotation.Anonymous; @@ -16,6 +18,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,8 +31,11 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @Slf4j public class AndroidDeviceController { + private static final String TENANT_CODE_HEADER = "X-Tenant-Code"; + private final AndroidAuthService androidAuthService; private final AndroidDeviceRegistrationService androidDeviceRegistrationService; + private final AndroidDeviceHomeService androidDeviceHomeService; @Operation(summary = "设备自注册") @ApiResponses({ @@ -42,14 +49,46 @@ public class AndroidDeviceController { @Anonymous public ApiResponse register(HttpServletRequest request, @RequestBody(required = false) AndroidDeviceRegisterRequest command) { - AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command); + if (command == null) { + throw new IllegalArgumentException("注册请求不能为空"); + } + String tenantCode = resolveTenantCode(request, command); + AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command, "tenantCode", tenantCode); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false); AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register( + tenantCode, authContext.getDeviceId(), - command == null ? null : command.getDeviceName(), - command == null ? authContext.getPlatform() : command.getTerminalType(), - command == null ? authContext.getAppVersion() : command.getTerminalVersion() + command.getDeviceName(), + command.getTerminalType() == null ? authContext.getPlatform() : command.getTerminalType(), + command.getTerminalVersion() == null ? authContext.getAppVersion() : command.getTerminalVersion() ); return ApiResponse.ok(response); } + + @Operation(summary = "查询设备首页统计") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回设备首页统计信息", + content = @Content(schema = @Schema(implementation = AndroidDeviceHomeStatsVO.class)) + ) + }) + @GetMapping("/home") + @Anonymous + public ApiResponse home(HttpServletRequest request) { + AndroidRequestLogHelper.logRequest(log, "Android设备", "查询设备首页统计"); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, true, true); + return ApiResponse.ok(androidDeviceHomeService.getHomeStats(authContext)); + } + + private String resolveTenantCode(HttpServletRequest request, AndroidDeviceRegisterRequest command) { + if (command != null && StringUtils.hasText(command.getTenantCode())) { + return command.getTenantCode().trim(); + } + String tenantCodeFromHeader = request == null ? null : request.getHeader(TENANT_CODE_HEADER); + if (StringUtils.hasText(tenantCodeFromHeader)) { + return tenantCodeFromHeader.trim(); + } + throw new IllegalArgumentException("tenantCode不能为空"); + } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/DeviceManagementController.java b/backend/src/main/java/com/imeeting/controller/biz/DeviceManagementController.java index 3427943..8e9e02b 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/DeviceManagementController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/DeviceManagementController.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -58,6 +59,18 @@ public class DeviceManagementController { return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser())); } + @Operation(summary = "删除设备并解绑授权") + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + return ApiResponse.ok(deviceOnlineManagementService.delete(id, currentLoginUser())); + } + + @Operation(summary = "重置设备首页统计") + @PostMapping("/{id}/reset") + public ApiResponse reset(@PathVariable Long id) { + return ApiResponse.ok(deviceOnlineManagementService.resetStats(id, currentLoginUser())); + } + private LoginUser currentLoginUser() { return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } diff --git a/backend/src/main/java/com/imeeting/controller/biz/LicenseManagementController.java b/backend/src/main/java/com/imeeting/controller/biz/LicenseManagementController.java new file mode 100644 index 0000000..f32c11e --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/LicenseManagementController.java @@ -0,0 +1,45 @@ +package com.imeeting.controller.biz; + +import com.imeeting.dto.biz.LicenseImportResultVO; +import com.imeeting.dto.biz.LicenseVO; +import com.imeeting.service.biz.LicenseService; +import com.unisbase.common.ApiResponse; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Tag(name = "租户授权管理") +@RestController +@RequestMapping("/api/admin/licenses") +@RequiredArgsConstructor +public class LicenseManagementController { + + private final LicenseService licenseService; + + @Operation(summary = "查询当前租户授权列表") + @GetMapping + public ApiResponse> list() { + return ApiResponse.ok(licenseService.listCurrentTenantLicenses(currentLoginUser())); + } + + @Operation(summary = "导入当前租户正式授权") + @PostMapping("/import") + public ApiResponse importLicenses(@RequestParam("file") MultipartFile file) throws IOException { + return ApiResponse.ok(licenseService.importFormalLicenses(file, currentLoginUser())); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeStatsVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeStatsVO.java new file mode 100644 index 0000000..33842e1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeStatsVO.java @@ -0,0 +1,36 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "Android 设备首页统计响应") +public class AndroidDeviceHomeStatsVO { + + @Schema(description = "设备名称") + private String deviceName; + + @Schema(description = "授权类型:1-临时,2-正式") + private Integer licenseType; + + @Schema(description = "剩余分钟数") + private Long remainingMinutes; + + @Schema(description = "会议数量") + private Long meetingCount; + + @Schema(description = "会议总时长,单位分钟") + private Long meetingDurationMinutes; + + @Schema(description = "登录用户数") + private Long loginUserCount; + + @Schema(description = "H5 地址") + private String h5BaseUrl; + + @Schema(description = "天气信息") + private AndroidDeviceHomeWeatherVO weather; + + @Schema(description = "是否已登录") + private Boolean loggedIn; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeWeatherVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeWeatherVO.java new file mode 100644 index 0000000..26b7d90 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceHomeWeatherVO.java @@ -0,0 +1,18 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "Android 设备首页天气信息") +public class AndroidDeviceHomeWeatherVO { + + @Schema(description = "城市名称") + private String cityName; + + @Schema(description = "天气文本") + private String text; + + @Schema(description = "温度,单位摄氏度") + private String temperature; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterRequest.java b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterRequest.java index 3f7d151..e3ec972 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterRequest.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterRequest.java @@ -6,6 +6,9 @@ import lombok.Data; @Data @Schema(description = "Android设备注册请求") public class AndroidDeviceRegisterRequest { + @Schema(description = "租户编码", requiredMode = Schema.RequiredMode.REQUIRED) + private String tenantCode; + @Schema(description = "设备名称") private String deviceName; diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterResponse.java b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterResponse.java index 9ea45dd..823d6cc 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceRegisterResponse.java @@ -20,4 +20,7 @@ public class AndroidDeviceRegisterResponse { @Schema(description = "是否已被用户占用") private Boolean occupied; + + @Schema(description = "授权类型:1-临时,2-正式") + private Integer licenseType; } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceWeatherCacheValue.java b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceWeatherCacheValue.java new file mode 100644 index 0000000..7de419c --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidDeviceWeatherCacheValue.java @@ -0,0 +1,10 @@ +package com.imeeting.dto.android; + +import lombok.Data; + +@Data +public class AndroidDeviceWeatherCacheValue { + private String cityName; + private String text; + private String temperature; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/DeviceAdminUpdateCommand.java b/backend/src/main/java/com/imeeting/dto/biz/DeviceAdminUpdateCommand.java index 41dee98..e1f0a13 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/DeviceAdminUpdateCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/DeviceAdminUpdateCommand.java @@ -12,4 +12,7 @@ public class DeviceAdminUpdateCommand { @Schema(description = "状态:1启用,0停用") private Integer status; + + @Schema(description = "设备所在天气城市") + private String weatherCityName; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/DeviceOnlineAdminVO.java b/backend/src/main/java/com/imeeting/dto/biz/DeviceOnlineAdminVO.java index d363461..be37a73 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/DeviceOnlineAdminVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/DeviceOnlineAdminVO.java @@ -39,6 +39,12 @@ public class DeviceOnlineAdminVO { @Schema(description = "最后一次在线时间") private LocalDateTime lastOnlineAt; + @Schema(description = "统计重置时间") + private LocalDateTime statsResetAt; + + @Schema(description = "设备天气城市") + private String weatherCityName; + @Schema(description = "状态:1启用,0停用") private Integer status; diff --git a/backend/src/main/java/com/imeeting/dto/biz/LicenseImportResultVO.java b/backend/src/main/java/com/imeeting/dto/biz/LicenseImportResultVO.java new file mode 100644 index 0000000..e4bb571 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/LicenseImportResultVO.java @@ -0,0 +1,23 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "正式授权导入结果") +public class LicenseImportResultVO { + @Schema(description = "导入批次号") + private String importBatchNo; + + @Schema(description = "导入总数") + private Integer totalCount; + + @Schema(description = "替换中的设备数量") + private Integer replacedCount; + + @Schema(description = "新增未使用正式授权数量") + private Integer unusedFormalCount; + + @Schema(description = "失效的临时授权数量") + private Integer invalidatedTempCount; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/LicenseImportRow.java b/backend/src/main/java/com/imeeting/dto/biz/LicenseImportRow.java new file mode 100644 index 0000000..ce55b79 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/LicenseImportRow.java @@ -0,0 +1,20 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "正式授权导入行") +public class LicenseImportRow { + @Schema(description = "授权序列号") + private String licenseSerial; + + @Schema(description = "授权码") + private String licenseCode; + + @Schema(description = "产品编码") + private String productCode; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/LicenseVO.java b/backend/src/main/java/com/imeeting/dto/biz/LicenseVO.java new file mode 100644 index 0000000..a8c5151 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/LicenseVO.java @@ -0,0 +1,49 @@ +package com.imeeting.dto.biz; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "授权列表视图") +public class LicenseVO { + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "租户ID") + private Long tenantId; + + @Schema(description = "授权序列号") + private String licenseSerial; + + @Schema(description = "授权码") + private String licenseCode; + + @Schema(description = "授权类型") + private Integer licenseType; + + @Schema(description = "授权状态") + private Integer licenseStatus; + + @Schema(description = "产品编码") + private String productCode; + + @Schema(description = "绑定设备编码") + private String deviceCode; + + @Schema(description = "绑定时间") + private LocalDateTime bindTime; + + @Schema(description = "过期时间") + private LocalDateTime expireTime; + + @Schema(description = "导入批次号") + private String importBatchNo; + + @Schema(description = "导入时间") + private LocalDateTime importTime; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/DeviceInfoEntity.java b/backend/src/main/java/com/imeeting/entity/biz/DeviceInfoEntity.java index 10c89ba..8f89730 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/DeviceInfoEntity.java +++ b/backend/src/main/java/com/imeeting/entity/biz/DeviceInfoEntity.java @@ -28,4 +28,8 @@ public class DeviceInfoEntity extends BaseEntity { private String terminalVersion; private LocalDateTime lastOnlineAt; + + private LocalDateTime statsResetAt; + + private String weatherCityName; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/DeviceLoginLogEntity.java b/backend/src/main/java/com/imeeting/entity/biz/DeviceLoginLogEntity.java new file mode 100644 index 0000000..c99cbe9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/DeviceLoginLogEntity.java @@ -0,0 +1,31 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.unisbase.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("biz_device_login_log") +@Schema(description = "设备登录日志实体") +public class DeviceLoginLogEntity extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "设备编码") + private String deviceCode; + + @Schema(description = "登录用户ID") + private Long userId; + + @Schema(description = "登录时间") + private LocalDateTime loginAt; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/LicenseEntity.java b/backend/src/main/java/com/imeeting/entity/biz/LicenseEntity.java new file mode 100644 index 0000000..8f50af8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/LicenseEntity.java @@ -0,0 +1,55 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.unisbase.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("biz_license") +@Schema(description = "设备授权实体") +public class LicenseEntity extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "授权序列号") + private String licenseSerial; + + @Schema(description = "授权码") + private String licenseCode; + + @Schema(description = "授权类型:1-临时,2-正式") + private Integer licenseType; + + @Schema(description = "授权状态:1-未使用,2-使用中,3-已过期,4-已失效") + private Integer licenseStatus; + + @Schema(description = "产品BOM编码") + private String productCode; + + @Schema(description = "绑定设备编码") + private String deviceCode; + + @Schema(description = "绑定时间") + private LocalDateTime bindTime; + + @Schema(description = "过期时间") + private LocalDateTime expireTime; + + @Schema(description = "导入批次号") + private String importBatchNo; + + @Schema(description = "导入时间") + private LocalDateTime importTime; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/enums/LicenseStatusEnum.java b/backend/src/main/java/com/imeeting/enums/LicenseStatusEnum.java new file mode 100644 index 0000000..4b608a6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/enums/LicenseStatusEnum.java @@ -0,0 +1,19 @@ +package com.imeeting.enums; + +import lombok.Getter; + +@Getter +public enum LicenseStatusEnum { + UNUSED(1, "未使用"), + IN_USE(2, "使用中"), + EXPIRED(3, "已过期"), + INVALID(4, "已失效"); + + private final int code; + private final String desc; + + LicenseStatusEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } +} diff --git a/backend/src/main/java/com/imeeting/enums/LicenseTypeEnum.java b/backend/src/main/java/com/imeeting/enums/LicenseTypeEnum.java new file mode 100644 index 0000000..93191ea --- /dev/null +++ b/backend/src/main/java/com/imeeting/enums/LicenseTypeEnum.java @@ -0,0 +1,17 @@ +package com.imeeting.enums; + +import lombok.Getter; + +@Getter +public enum LicenseTypeEnum { + TEMPORARY(1, "临时"), + FORMAL(2, "正式"); + + private final int code; + private final String desc; + + LicenseTypeEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } +} diff --git a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java index 4893475..3b66704 100644 --- a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java +++ b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java @@ -3,9 +3,9 @@ package com.imeeting.grpc.push; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.service.android.AndroidAuthService; -import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidGatewayPushService; +import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.biz.DeviceOnlineManagementService; import com.unisbase.common.exception.BusinessException; import io.grpc.BindableService; @@ -116,8 +116,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase request.getAppVersion(), resolvePlatform(request.getPlatform()) ); - authContext.setUserId(parseNullableLong(request.getUserId(), "user_id")); - authContext.setTenantId(parseNullableLong(request.getTenantId(), "tenant_id")); AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId()); connectionId = sessionState.getConnectionId(); deviceId = sessionState.getDeviceId(); @@ -181,30 +179,15 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase return; } if (!request.getConnectionId().isBlank() && !request.getConnectionId().equals(connectionId)) { - log.info(buildLog("gRPC确认拒绝", - "ACK连接ID与当前活动连接不一致,请求连接ID=" + request.getConnectionId() + ",当前连接ID=" + connectionId, - deviceId, - appVersion, - platform)); sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false, deviceId, appVersion, platform); return; } if (!request.getDeviceId().isBlank() && !request.getDeviceId().equals(deviceId)) { - log.info(buildLog("gRPC确认拒绝", - "ACK设备ID与当前活动设备不一致,请求设备ID=" + request.getDeviceId() + ",当前设备ID=" + deviceId, - deviceId, - appVersion, - platform)); sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false, deviceId, appVersion, platform); return; } - log.info(buildLog("gRPC消息确认", - "收到客户端ACK确认,消息ID=" + safe(request.getMessageId()) + ",连接ID=" + connectionId, - deviceId, - appVersion, - platform)); androidPushMessageService.ack(request.getMessageId(), deviceId); } @@ -212,11 +195,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase if (connected) { return true; } - log.info(buildLog("gRPC请求拒绝", - "连接尚未建立即发送后续消息", - deviceId, - appVersion, - platform)); sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false, deviceId, appVersion, platform); return false; @@ -230,11 +208,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase androidGatewayPushService.unregister(connectionId); androidDeviceSessionService.closeSession(connectionId); deviceOnlineManagementService.recordDisconnected(deviceId, state == null ? null : state.getLastSeenAt()); - log.info(buildLog("gRPC连接关闭", - "Android推送连接已关闭,连接ID=" + connectionId, - deviceId, - state == null ? appVersion : state.getAppVersion(), - state == null ? platform : state.getPlatform())); connectionId = null; deviceId = null; appVersion = null; @@ -251,11 +224,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase String deviceId, String appVersion, String platform) { - log.info(buildLog("gRPC错误响应", - "向客户端返回错误,错误码=" + safe(code) + ",原因=" + safe(message), - deviceId, - appVersion, - platform)); responseObserver.onNext(ServerMessage.newBuilder() .setError(ErrorEvent.newBuilder() .setCode(code) @@ -286,15 +254,4 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase case PLATFORM_UNKNOWN, UNRECOGNIZED -> "android"; }; } - - private Long parseNullableLong(String value, String fieldName) { - if (value == null || value.isBlank()) { - return null; - } - try { - return Long.parseLong(value.trim()); - } catch (NumberFormatException ex) { - throw new RuntimeException("Invalid " + fieldName); - } - } } diff --git a/backend/src/main/java/com/imeeting/mapper/DeviceInfoMapper.java b/backend/src/main/java/com/imeeting/mapper/DeviceInfoMapper.java index c22b3f2..559a268 100644 --- a/backend/src/main/java/com/imeeting/mapper/DeviceInfoMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/DeviceInfoMapper.java @@ -87,6 +87,8 @@ public interface DeviceInfoMapper extends BaseMapper { d.terminal_type AS terminalType, d.terminal_version AS terminalVersion, d.last_online_at AS lastOnlineAt, + d.stats_reset_at AS statsResetAt, + d.weather_city_name AS weatherCityName, d.status AS status, d.created_at AS createdAt, d.updated_at AS updatedAt, diff --git a/backend/src/main/java/com/imeeting/mapper/DeviceLoginLogMapper.java b/backend/src/main/java/com/imeeting/mapper/DeviceLoginLogMapper.java new file mode 100644 index 0000000..c823b71 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/DeviceLoginLogMapper.java @@ -0,0 +1,31 @@ +package com.imeeting.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.DeviceLoginLogEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; + +@Mapper +public interface DeviceLoginLogMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + Long countDistinctUsersSince(@Param("tenantId") Long tenantId, + @Param("deviceCode") String deviceCode, + @Param("resetAt") LocalDateTime resetAt); +} diff --git a/backend/src/main/java/com/imeeting/mapper/LicenseMapper.java b/backend/src/main/java/com/imeeting/mapper/LicenseMapper.java new file mode 100644 index 0000000..54e78eb --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/LicenseMapper.java @@ -0,0 +1,223 @@ +package com.imeeting.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.dto.biz.LicenseVO; +import com.imeeting.entity.biz.LicenseEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface LicenseMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE device_code = #{deviceCode} + AND is_deleted = 0 + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """) + LicenseEntity selectByDeviceCodeIgnoreTenant(@Param("deviceCode") String deviceCode); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND device_code = #{deviceCode} + AND license_status = 2 + AND is_deleted = 0 + AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP) + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """) + LicenseEntity selectValidBoundByTenantAndDeviceIgnoreTenant(@Param("tenantId") Long tenantId, + @Param("deviceCode") String deviceCode); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND license_code = #{licenseCode} + AND is_deleted = 0 + LIMIT 1 + """) + LicenseEntity selectByTenantAndCodeIgnoreTenant(@Param("tenantId") Long tenantId, @Param("licenseCode") String licenseCode); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE license_serial = #{licenseSerial} + AND is_deleted = 0 + LIMIT 1 + """) + LicenseEntity selectBySerialIgnoreTenant(@Param("licenseSerial") String licenseSerial); + + @InterceptorIgnore(tenantLine = "true") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + @Select("SELECT nextval('biz_license_temp_serial_seq')") + Long nextTempSerialValue(); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND license_status = 2 + AND device_code = #{deviceCode} + AND is_deleted = 0 + LIMIT 1 + """) + LicenseEntity selectActiveByTenantAndDeviceIgnoreTenant(@Param("tenantId") Long tenantId, @Param("deviceCode") String deviceCode); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND license_status = 2 + AND device_code IS NOT NULL + AND license_type = 1 + AND is_deleted = 0 + ORDER BY bind_time ASC NULLS LAST, id ASC + """) + List selectBoundTemporaryLicensesForReplace(@Param("tenantId") Long tenantId); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND license_status = 1 + AND license_type = 2 + AND import_batch_no = #{importBatchNo} + AND is_deleted = 0 + ORDER BY created_at ASC, id ASC + """) + List selectUnusedFormalLicenses(@Param("tenantId") Long tenantId, @Param("importBatchNo") String importBatchNo); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND license_status = 1 + AND is_deleted = 0 + AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP) + ORDER BY license_type ASC, created_at ASC, id ASC + LIMIT 1 + """) + LicenseEntity selectFirstAssignableLicense(@Param("tenantId") Long tenantId); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_license + WHERE tenant_id = #{tenantId} + AND license_status = 2 + AND device_code IS NOT NULL + AND is_deleted = 0 + ORDER BY bind_time ASC NULLS LAST, id ASC + """) + List selectBoundLicenses(@Param("tenantId") Long tenantId); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT + id, + tenant_id AS tenantId, + license_serial AS licenseSerial, + license_code AS licenseCode, + license_type AS licenseType, + license_status AS licenseStatus, + product_code AS productCode, + device_code AS deviceCode, + bind_time AS bindTime, + expire_time AS expireTime, + import_batch_no AS importBatchNo, + import_time AS importTime, + remark + FROM biz_license + WHERE tenant_id = #{tenantId} + AND is_deleted = 0 + ORDER BY updated_at DESC, id DESC + """) + List selectListByTenant(@Param("tenantId") Long tenantId); + + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE biz_license + SET license_status = 3, + device_code = NULL, + bind_time = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE is_deleted = 0 + AND license_status IN (1, 2) + AND expire_time IS NOT NULL + AND expire_time <= CURRENT_TIMESTAMP + """) + int expireDueLicenses(); + + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE biz_license + SET device_code = NULL, + bind_time = NULL, + license_status = #{licenseStatus}, + updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + AND is_deleted = 0 + """) + int clearBindingById(@Param("id") Long id, @Param("licenseStatus") Integer licenseStatus); + + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE biz_license + SET device_code = #{deviceCode}, + bind_time = #{bindTime}, + license_status = #{licenseStatus}, + updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + AND is_deleted = 0 + """) + int bindLicenseById(@Param("id") Long id, + @Param("deviceCode") String deviceCode, + @Param("bindTime") LocalDateTime bindTime, + @Param("licenseStatus") Integer licenseStatus); + + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE biz_license + SET license_status = 4, + device_code = NULL, + bind_time = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = #{tenantId} + AND license_type = 1 + AND is_deleted = 0 + AND license_status IN (1, 2, 3) + """) + int invalidateAllTemporaryLicenses(@Param("tenantId") Long tenantId); + + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE biz_license + SET license_status = 4, + device_code = NULL, + bind_time = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} + AND is_deleted = 0 + """) + int invalidateById(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java index f232dba..ea57538 100644 --- a/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java @@ -7,9 +7,45 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; + @Mapper public interface MeetingMapper extends BaseMapper { @InterceptorIgnore(tenantLine = "true") @Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0") Meeting selectByIdIgnoreTenant(@Param("id") Long id); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + Long countByDeviceSince(@Param("tenantId") Long tenantId, + @Param("deviceCode") String deviceCode, + @Param("resetAt") LocalDateTime resetAt); + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + Long sumMeetingDurationSecondsByDeviceSince(@Param("tenantId") Long tenantId, + @Param("deviceCode") String deviceCode, + @Param("resetAt") LocalDateTime resetAt); } diff --git a/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java index 0fc78a5..653197a 100644 --- a/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java @@ -1,9 +1,27 @@ package com.imeeting.mapper.biz; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.imeeting.entity.biz.ScreenSaver; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; @Mapper public interface ScreenSaverMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * + FROM biz_screen_savers + WHERE tenant_id = #{tenantId} + AND scope_type = 'PLATFORM' + AND status = 1 + AND owner_user_id IS NULL + AND is_deleted = 0 + ORDER BY sort_order ASC, id DESC + """) + List selectActivePlatformByTenantIgnoreTenant(@Param("tenantId") Long tenantId); } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java b/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java index 20a1db8..6f719d5 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java @@ -9,4 +9,6 @@ public interface AndroidAuthService { AndroidAuthContext authenticateHttp(HttpServletRequest request); AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered); + + AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken); } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java index d44fed8..4a75c92 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java @@ -6,4 +6,6 @@ public interface AndroidDeviceBindingService { void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId); void unbindPrivateDevice(String deviceCode); + + void recordLogin(String deviceCode, Long tenantId, Long userId); } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidDeviceHomeService.java b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceHomeService.java new file mode 100644 index 0000000..369c43b --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceHomeService.java @@ -0,0 +1,9 @@ +package com.imeeting.service.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidDeviceHomeStatsVO; + +public interface AndroidDeviceHomeService { + + AndroidDeviceHomeStatsVO getHomeStats(AndroidAuthContext authContext); +} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidDeviceRegistrationService.java b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceRegistrationService.java index d7c3a34..b92dfab 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidDeviceRegistrationService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceRegistrationService.java @@ -3,7 +3,7 @@ package com.imeeting.service.android; import com.imeeting.dto.android.AndroidDeviceRegisterResponse; public interface AndroidDeviceRegistrationService { - AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion); + AndroidDeviceRegisterResponse register(String tenantCode, String deviceCode, String deviceName, String terminalType, String terminalVersion); void requireRegistered(String deviceCode); } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java index 6818664..1b51f4f 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java @@ -3,9 +3,11 @@ package com.imeeting.service.android.impl; import com.imeeting.config.grpc.AndroidGrpcAuthProperties; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.entity.biz.DeviceInfoEntity; +import com.imeeting.entity.biz.LicenseEntity; import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidDeviceBindingService; +import com.imeeting.service.biz.LicenseService; import com.unisbase.common.exception.BusinessException; import com.unisbase.dto.InternalAuthCheckResponse; import com.unisbase.security.LoginUser; @@ -34,27 +36,34 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { private final TokenValidationService tokenValidationService; private final DeviceInfoMapper deviceInfoMapper; private final AndroidDeviceBindingService androidDeviceBindingService; + private final LicenseService licenseService; @Override public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { if (properties.isEnabled() && !properties.isAllowAnonymous()) { throw new RuntimeException("Android gRPC push does not allow anonymous access"); } + LicenseEntity license = licenseService.requireValidBoundLicense(deviceId); DeviceInfoEntity device = requireRegisteredDevice(deviceId); assertDeviceEnabled(device); AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); context.setUserId(device.getUserId()); - context.setTenantId(device.getTenantId()); + context.setTenantId(license.getTenantId()); return context; } @Override public AndroidAuthContext authenticateHttp(HttpServletRequest request) { - return authenticateHttp(request, true); + return authenticateHttp(request, true, false); } @Override public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) { + return authenticateHttp(request, requireRegistered, false); + } + + @Override + public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken) { LoginUser loginUser = currentLoginUser(); String resolvedToken = resolveHttpToken(request); String deviceId = firstHeader(request, HEADER_DEVICE_ID); @@ -63,12 +72,17 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { String platform = request.getHeader(HEADER_PLATFORM); requireAndroidHttpHeaders(deviceId, appVersion, platform); - log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}",deviceId,appVersion,platform); + log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}", deviceId, appVersion, platform); + DeviceInfoEntity device = requireRegistered ? requireRegisteredDevice(deviceId) : findDevice(deviceId); assertDeviceEnabled(device); + LicenseEntity license = requireRegistered ? licenseService.requireValidBoundLicense(deviceId) : null; + if (loginUser != null) { - androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId()); - return buildContext("USER_JWT", false, + if (!allowOptionalToken) { + androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId()); + } + AndroidAuthContext context = buildContext("USER_JWT", false, deviceId, appId, appVersion, @@ -77,12 +91,15 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { null, null, loginUser); + return applyLicenseContext(context, license, allowOptionalToken); } if (StringUtils.hasText(resolvedToken)) { InternalAuthCheckResponse authResult = validateToken(resolvedToken); - androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId()); - return buildContext("USER_JWT", false, + if (requireRegistered && !allowOptionalToken) { + androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId()); + } + AndroidAuthContext context = buildContext("USER_JWT", false, deviceId, appId, appVersion, @@ -91,9 +108,11 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { null, authResult, null); + return applyLicenseContext(context, license, allowOptionalToken); } + if (properties.isAllowAnonymous()) { - return buildContext("NONE", true, + AndroidAuthContext context = buildContext("NONE", true, deviceId, appId, appVersion, @@ -102,10 +121,33 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { null, null, null); + return applyLicenseContext(context, license, allowOptionalToken); } throw new RuntimeException("Missing Android HTTP access token"); } + private AndroidAuthContext applyLicenseContext(AndroidAuthContext context, LicenseEntity license, boolean allowOptionalToken) { + if (context == null) { + return null; + } + if (license == null) { + return context; + } + Long currentTenantId = context.getTenantId(); + context.setTenantId(license.getTenantId()); + if (allowOptionalToken && context.getUserId() != null && currentTenantId != null && !currentTenantId.equals(license.getTenantId())) { + context.setAnonymous(true); + context.setAuthMode("NONE"); + context.setUserId(null); + context.setUsername(null); + context.setDisplayName(null); + context.setPlatformAdmin(null); + context.setTenantAdmin(null); + context.setPermissions(null); + } + return context; + } + private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String appId, String appVersion, String platform, String accessToken, String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java index 5ee7997..ff54a8e 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java @@ -1,7 +1,9 @@ package com.imeeting.service.android.impl; import com.imeeting.mapper.DeviceInfoMapper; +import com.imeeting.mapper.DeviceLoginLogMapper; import com.imeeting.entity.biz.DeviceInfoEntity; +import com.imeeting.entity.biz.DeviceLoginLogEntity; import com.imeeting.service.android.AndroidDeviceBindingService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,6 +16,7 @@ import java.util.Objects; @RequiredArgsConstructor public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService { private final DeviceInfoMapper deviceInfoMapper; + private final DeviceLoginLogMapper deviceLoginLogMapper; @Override public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) { @@ -61,6 +64,20 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); } + @Override + public void recordLogin(String deviceCode, Long tenantId, Long userId) { + if (!StringUtils.hasText(deviceCode) || tenantId == null || userId == null) { + return; + } + DeviceLoginLogEntity entity = new DeviceLoginLogEntity(); + entity.setTenantId(tenantId); + entity.setStatus(1); + entity.setDeviceCode(deviceCode.trim()); + entity.setUserId(userId); + entity.setLoginAt(LocalDateTime.now()); + deviceLoginLogMapper.insert(entity); + } + private String normalize(String value) { if (!StringUtils.hasText(value)) { return null; diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java new file mode 100644 index 0000000..502d638 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java @@ -0,0 +1,246 @@ +package com.imeeting.service.android.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.RedisKeys; +import com.imeeting.common.SysParamKeys; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidDeviceHomeStatsVO; +import com.imeeting.dto.android.AndroidDeviceHomeWeatherVO; +import com.imeeting.dto.android.AndroidDeviceWeatherCacheValue; +import com.imeeting.dto.biz.MeetingPointsBalanceVO; +import com.imeeting.entity.biz.DeviceInfoEntity; +import com.imeeting.entity.biz.LicenseEntity; +import com.imeeting.mapper.DeviceInfoMapper; +import com.imeeting.mapper.DeviceLoginLogMapper; +import com.imeeting.mapper.LicenseMapper; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.service.android.AndroidDeviceHomeService; +import com.imeeting.service.biz.MeetingPointsService; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.zip.GZIPInputStream; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AndroidDeviceHomeServiceImpl implements AndroidDeviceHomeService { + + private static final Duration WEATHER_CACHE_TTL = Duration.ofHours(1); + private static final Duration WEATHER_CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration WEATHER_READ_TIMEOUT = Duration.ofSeconds(8); + private static final String DEFAULT_QWEATHER_BASE_URL = "https://devapi.qweather.com"; + + private final DeviceInfoMapper deviceInfoMapper; + private final LicenseMapper licenseMapper; + private final MeetingMapper meetingMapper; + private final DeviceLoginLogMapper deviceLoginLogMapper; + private final MeetingPointsService meetingPointsService; + private final RedisSupport redisSupport; + private final com.unisbase.service.SysParamService sysParamService; + private final ObjectMapper objectMapper; + + @Value("${imeeting.h5.base-url:}") + private String h5BaseUrl; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(WEATHER_CONNECT_TIMEOUT) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + @Override + public AndroidDeviceHomeStatsVO getHomeStats(AndroidAuthContext authContext) { + if (authContext == null || !StringUtils.hasText(authContext.getDeviceId())) { + throw new RuntimeException("设备上下文不存在"); + } + DeviceInfoEntity device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(authContext.getDeviceId()); + if (device == null) { + throw new RuntimeException("设备未注册,请先完成设备注册"); + } + + Long tenantId = authContext.getTenantId(); + LicenseEntity license = licenseMapper.selectValidBoundByTenantAndDeviceIgnoreTenant(tenantId, authContext.getDeviceId()); + if (license == null) { + throw new RuntimeException("设备未绑定有效授权"); + } + + LocalDateTime resetAt = device.getStatsResetAt(); + Long meetingCount = defaultLong(meetingMapper.countByDeviceSince(tenantId, authContext.getDeviceId(), resetAt)); + Long totalSeconds = defaultLong(meetingMapper.sumMeetingDurationSecondsByDeviceSince(tenantId, authContext.getDeviceId(), resetAt)); + Long loginUserCount = defaultLong(deviceLoginLogMapper.countDistinctUsersSince(tenantId, authContext.getDeviceId(), resetAt)); + + AndroidDeviceHomeStatsVO vo = new AndroidDeviceHomeStatsVO(); + vo.setDeviceName(device.getDeviceName()); + vo.setLicenseType(license.getLicenseType()); + vo.setMeetingCount(meetingCount); + vo.setMeetingDurationMinutes(toCeilMinutes(totalSeconds)); + vo.setLoginUserCount(loginUserCount); + vo.setH5BaseUrl(trimToNull(h5BaseUrl)); + vo.setRemainingMinutes(calculateRemainingMinutes(tenantId, authContext.getUserId(), authContext.isAnonymous())); + vo.setWeather(resolveWeather(device.getWeatherCityName())); + vo.setLoggedIn(!authContext.isAnonymous() && authContext.getUserId() != null); + return vo; + } + + private Long calculateRemainingMinutes(Long tenantId, Long userId, boolean anonymous) { + if (tenantId == null) { + return 0L; + } + MeetingPointsBalanceVO balance = meetingPointsService.getBalanceView(tenantId, anonymous ? null : userId); + long totalPoints = defaultLong(balance.getPublicBalance()); + if (!anonymous && userId != null) { + totalPoints += defaultLong(balance.getPersonalBalance()); + } + int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1); + int costPerUnit = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_COST_PER_UNIT, "1"), 1); + return costPerUnit <= 0 ? 0L : (totalPoints * unitMinutes) / costPerUnit; + } + + private AndroidDeviceHomeWeatherVO resolveWeather(String cityName) { + if (!StringUtils.hasText(cityName)) { + return null; + } + String normalizedCity = cityName.trim(); + AndroidDeviceWeatherCacheValue cached = redisSupport.getJsonQuietly(RedisKeys.androidDeviceWeatherKey(normalizedCity), AndroidDeviceWeatherCacheValue.class); + if (cached != null) { + return toWeatherVO(cached); + } + + String apiKey = sysParamService.getCachedParamValue(SysParamKeys.DEVICE_WEATHER_QWEATHER_KEY, ""); + String baseUrl = normalizeBaseUrl(sysParamService.getCachedParamValue( + SysParamKeys.DEVICE_WEATHER_QWEATHER_BASE_URL, + DEFAULT_QWEATHER_BASE_URL + )); + if (!StringUtils.hasText(apiKey) || !StringUtils.hasText(baseUrl)) { + return null; + } + try { + String location = URLEncoder.encode(normalizedCity, StandardCharsets.UTF_8); + String url = baseUrl + "/geo/v2/city/lookup?location=" + location + "&key=" + apiKey.trim(); + HttpRequest cityRequest = HttpRequest.newBuilder(URI.create(url)) + .timeout(WEATHER_READ_TIMEOUT) + .header("Accept-Encoding", "gzip") + .GET() + .build(); + HttpResponse cityResponse = httpClient.send(cityRequest, HttpResponse.BodyHandlers.ofByteArray()); + String locationId = parseLocationId(readResponseBody(cityResponse)); + if (!StringUtils.hasText(locationId)) { + return null; + } + + String weatherUrl = baseUrl + "/v7/weather/now?location=" + locationId + "&key=" + apiKey.trim(); + HttpRequest weatherRequest = HttpRequest.newBuilder(URI.create(weatherUrl)) + .timeout(WEATHER_READ_TIMEOUT) + .header("Accept-Encoding", "gzip") + .GET() + .build(); + HttpResponse weatherResponse = httpClient.send(weatherRequest, HttpResponse.BodyHandlers.ofByteArray()); + AndroidDeviceWeatherCacheValue value = parseWeatherValue(normalizedCity, readResponseBody(weatherResponse)); + if (value == null) { + return null; + } + redisSupport.setJson(RedisKeys.androidDeviceWeatherKey(normalizedCity), value, WEATHER_CACHE_TTL); + return toWeatherVO(value); + } catch (Exception ex) { + log.warn("查询设备天气失败, cityName={}", normalizedCity, ex); + return null; + } + } + + private String parseLocationId(String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + JsonNode location = root.path("location"); + if (!location.isArray() || location.isEmpty()) { + return null; + } + JsonNode first = location.get(0); + String id = first.path("id").asText(null); + return StringUtils.hasText(id) ? id.trim() : null; + } + + private AndroidDeviceWeatherCacheValue parseWeatherValue(String cityName, String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + JsonNode now = root.path("now"); + if (now.isMissingNode() || now.isNull()) { + return null; + } + AndroidDeviceWeatherCacheValue value = new AndroidDeviceWeatherCacheValue(); + value.setCityName(cityName); + value.setText(trimToNull(now.path("text").asText(null))); + value.setTemperature(trimToNull(now.path("temp").asText(null))); + return value; + } + + private AndroidDeviceHomeWeatherVO toWeatherVO(AndroidDeviceWeatherCacheValue cached) { + AndroidDeviceHomeWeatherVO vo = new AndroidDeviceHomeWeatherVO(); + vo.setCityName(cached.getCityName()); + vo.setText(cached.getText()); + vo.setTemperature(cached.getTemperature()); + return vo; + } + + private long defaultLong(Long value) { + return value == null ? 0L : value; + } + + private long toCeilMinutes(long durationSeconds) { + if (durationSeconds <= 0L) { + return 0L; + } + return (long) Math.ceil(durationSeconds / 60.0d); + } + + private int positiveInt(String value, int defaultValue) { + try { + int resolved = Integer.parseInt(String.valueOf(value).trim()); + return resolved > 0 ? resolved : defaultValue; + } catch (Exception ex) { + return defaultValue; + } + } + + private String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private String normalizeBaseUrl(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + String normalized = value.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private String readResponseBody(HttpResponse response) throws Exception { + if (response == null || response.body() == null) { + return ""; + } + String contentEncoding = response.headers().firstValue("Content-Encoding").orElse(""); + byte[] payload = response.body(); + if (!StringUtils.hasText(contentEncoding) || !contentEncoding.toLowerCase(Locale.ROOT).contains("gzip")) { + return new String(payload, StandardCharsets.UTF_8); + } + try (InputStream inputStream = new GZIPInputStream(new ByteArrayInputStream(payload))) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java index 3adc638..0012499 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java @@ -1,11 +1,18 @@ package com.imeeting.service.android.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.entity.biz.DeviceInfoEntity; +import com.imeeting.entity.biz.LicenseEntity; import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.service.android.AndroidDeviceRegistrationService; +import com.imeeting.service.biz.LicenseService; +import com.unisbase.common.exception.BusinessException; +import com.unisbase.entity.SysTenant; +import com.unisbase.mapper.SysTenantMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDateTime; @@ -14,35 +21,49 @@ import java.time.LocalDateTime; @RequiredArgsConstructor public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegistrationService { private final DeviceInfoMapper deviceInfoMapper; + private final SysTenantMapper sysTenantMapper; + private final LicenseService licenseService; @Override - public AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion) { - if (!StringUtils.hasText(deviceCode)) { - throw new RuntimeException("deviceId不能为空"); + @Transactional(rollbackFor = Exception.class) + public AndroidDeviceRegisterResponse register(String tenantCode, String deviceCode, String deviceName, String terminalType, String terminalVersion) { + if (!StringUtils.hasText(tenantCode)) { + throw new BusinessException("400", "tenantCode不能为空"); } + if (!StringUtils.hasText(deviceCode)) { + throw new BusinessException("400", "deviceId不能为空"); + } + SysTenant tenant = requireTenant(tenantCode.trim()); String normalizedDeviceCode = deviceCode.trim(); + licenseService.validateDeviceCanRegisterToTenant(normalizedDeviceCode, tenant.getId()); + DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(normalizedDeviceCode); if (existing == null) { existing = new DeviceInfoEntity(); existing.setDeviceCode(normalizedDeviceCode); - existing.setDeviceName(normalize(deviceName)); - existing.setTerminalType(normalizeTerminalType(terminalType)); - existing.setTerminalVersion(normalize(terminalVersion)); - existing.setLastOnlineAt(LocalDateTime.now()); existing.setStatus(1); + } + existing.setTenantId(tenant.getId()); + existing.setDeviceName(normalize(deviceName)); + existing.setTerminalType(normalizeTerminalType(terminalType)); + existing.setTerminalVersion(normalize(terminalVersion)); + existing.setLastOnlineAt(LocalDateTime.now()); + + if (existing.getDeviceId() == null) { deviceInfoMapper.insert(existing); } else { - existing.setDeviceName(normalize(deviceName)); - existing.setTerminalType(normalizeTerminalType(terminalType)); - existing.setTerminalVersion(normalize(terminalVersion)); - deviceInfoMapper.updateBaseInfoByIdIgnoreTenant(existing); + deviceInfoMapper.updateById(existing); } + + LicenseEntity license = licenseService.allocateForDeviceRegistration(tenant.getId(), tenant.getTenantCode(), normalizedDeviceCode); + AndroidDeviceRegisterResponse response = new AndroidDeviceRegisterResponse(); response.setDeviceCode(existing.getDeviceCode()); response.setDeviceName(existing.getDeviceName()); response.setTerminalType(existing.getTerminalType()); response.setTerminalVersion(existing.getTerminalVersion()); response.setOccupied(existing.getUserId() != null); + response.setLicenseType(license.getLicenseType()); return response; } @@ -53,6 +74,17 @@ public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegist } } + private SysTenant requireTenant(String tenantCode) { + SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper() + .eq(SysTenant::getTenantCode, tenantCode) + .eq(SysTenant::getIsDeleted, 0) + .last("LIMIT 1")); + if (tenant == null || tenant.getId() == null) { + throw new BusinessException("400", "租户不存在"); + } + return tenant; + } + private String normalize(String value) { if (!StringUtils.hasText(value)) { return null; diff --git a/backend/src/main/java/com/imeeting/service/biz/DeviceOnlineManagementService.java b/backend/src/main/java/com/imeeting/service/biz/DeviceOnlineManagementService.java index aceb337..c274edb 100644 --- a/backend/src/main/java/com/imeeting/service/biz/DeviceOnlineManagementService.java +++ b/backend/src/main/java/com/imeeting/service/biz/DeviceOnlineManagementService.java @@ -18,4 +18,8 @@ public interface DeviceOnlineManagementService { DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser); boolean kick(Long id, LoginUser loginUser); + + boolean delete(Long id, LoginUser loginUser); + + boolean resetStats(Long id, LoginUser loginUser); } diff --git a/backend/src/main/java/com/imeeting/service/biz/LicenseService.java b/backend/src/main/java/com/imeeting/service/biz/LicenseService.java new file mode 100644 index 0000000..114f70c --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/LicenseService.java @@ -0,0 +1,26 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.LicenseImportResultVO; +import com.imeeting.dto.biz.LicenseVO; +import com.imeeting.entity.biz.LicenseEntity; +import com.unisbase.security.LoginUser; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +public interface LicenseService { + void initializeTemporaryLicenses(Long tenantId); + + LicenseEntity allocateForDeviceRegistration(Long tenantId, String tenantCode, String deviceCode); + + LicenseEntity requireValidBoundLicense(String deviceCode); + + void validateDeviceCanRegisterToTenant(String deviceCode, Long tenantId); + + void unbindDeviceLicense(String deviceCode); + + LicenseImportResultVO importFormalLicenses(MultipartFile file, LoginUser loginUser) throws IOException; + + List listCurrentTenantLicenses(LoginUser loginUser); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 808e069..5ef02cb 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -159,7 +159,10 @@ public class AiTaskServiceImpl extends ServiceImpl impleme @Override public void triggerQueuedAsrScheduling() { - scheduleQueuedAsrTasks(); + taskSecurityContextRunner.callAsPlatformAdmin(() -> { + scheduleQueuedAsrTasks(); + return null; + }); } @Override diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java index 56a0746..5c18d61 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java @@ -9,9 +9,11 @@ import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.biz.DeviceOnlineManagementService; +import com.imeeting.service.biz.LicenseService; import com.unisbase.security.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.Instant; @@ -26,6 +28,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement private final DeviceInfoMapper deviceInfoMapper; private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidGatewayPushService androidGatewayPushService; + private final LicenseService licenseService; @Override public void recordConnected(AndroidAuthContext authContext) { @@ -43,7 +46,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement existing.setLastOnlineAt(now); existing.setUserId(authContext.getUserId()); existing.setTenantId(authContext.getTenantId()); - deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); + deviceInfoMapper.updateById(existing); } @Override @@ -78,6 +81,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement public DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser) { DeviceInfoEntity existing = requireVisibleDevice(id, loginUser); existing.setDeviceName(normalize(command.getDeviceName())); + existing.setWeatherCityName(normalize(command.getWeatherCityName())); boolean disableAfterUpdate = command.getStatus() != null && command.getStatus() == 0; if (command.getStatus() != null) { existing.setStatus(command.getStatus()); @@ -99,6 +103,25 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement return true; } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean delete(Long id, LoginUser loginUser) { + DeviceInfoEntity existing = requireVisibleDevice(id, loginUser); + disconnectDevice(existing.getDeviceCode()); + licenseService.unbindDeviceLicense(existing.getDeviceCode()); + existing.setTenantId(null); + existing.setUserId(null); + deviceInfoMapper.updateById(existing); + return deviceInfoMapper.deleteById(existing.getDeviceId()) > 0; + } + + @Override + public boolean resetStats(Long id, LoginUser loginUser) { + DeviceInfoEntity existing = requireVisibleDevice(id, loginUser); + existing.setStatsResetAt(LocalDateTime.now()); + return deviceInfoMapper.updateById(existing) > 0; + } + private DeviceInfoEntity requireVisibleDevice(Long id, LoginUser loginUser) { DeviceInfoEntity existing = deviceInfoMapper.selectByIdIgnoreTenant(id); if (existing == null) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/LicenseServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/LicenseServiceImpl.java new file mode 100644 index 0000000..5ea79c1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/LicenseServiceImpl.java @@ -0,0 +1,285 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.common.LicenseConstants; +import com.imeeting.common.SysParamKeys; +import com.imeeting.dto.biz.LicenseImportResultVO; +import com.imeeting.dto.biz.LicenseImportRow; +import com.imeeting.dto.biz.LicenseVO; +import com.imeeting.entity.biz.LicenseEntity; +import com.imeeting.enums.LicenseStatusEnum; +import com.imeeting.enums.LicenseTypeEnum; +import com.imeeting.mapper.LicenseMapper; +import com.imeeting.service.biz.LicenseService; +import com.unisbase.common.exception.BusinessException; +import com.unisbase.security.LoginUser; +import com.unisbase.service.SysParamService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +public class LicenseServiceImpl extends ServiceImpl implements LicenseService { + + private static final DateTimeFormatter TEMP_SERIAL_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter IMPORT_BATCH_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final SysParamService sysParamService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void initializeTemporaryLicenses(Long tenantId) { + if (tenantId == null) { + return; + } + int count = parsePositiveInt(sysParamService.getParamValue(SysParamKeys.LICENSE_TEMP_DEFAULT_COUNT, "0")); + if (count <= 0) { + return; + } + int expireMonths = Math.max(1, parsePositiveInt(sysParamService.getParamValue(SysParamKeys.LICENSE_TEMP_DEFAULT_EXPIRE_MONTHS, "3"))); + String productCode = normalize(sysParamService.getParamValue(SysParamKeys.LICENSE_DEFAULT_PRODUCT_CODE, "")); + if (!StringUtils.hasText(productCode)) { + throw new BusinessException("500", "未配置临时授权产品编码"); + } + LocalDateTime expireTime = LocalDateTime.now().plusMonths(expireMonths); + List entities = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + String serial = buildTemporarySerial(productCode); + LicenseEntity entity = new LicenseEntity(); + entity.setTenantId(tenantId); + entity.setLicenseSerial(serial); + entity.setLicenseCode(serial); + entity.setLicenseType(LicenseTypeEnum.TEMPORARY.getCode()); + entity.setLicenseStatus(LicenseStatusEnum.UNUSED.getCode()); + entity.setProductCode(productCode); + entity.setExpireTime(expireTime); + entities.add(entity); + } + saveBatch(entities); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LicenseEntity allocateForDeviceRegistration(Long tenantId, String tenantCode, String deviceCode) { + expireDueLicenses(); + LicenseEntity bound = baseMapper.selectActiveByTenantAndDeviceIgnoreTenant(tenantId, deviceCode); + if (bound != null) { + return bound; + } + LicenseEntity license = baseMapper.selectFirstAssignableLicense(tenantId); + if (license == null) { + throw new BusinessException("402", "无可用授权码"); + } + LocalDateTime now = LocalDateTime.now(); + baseMapper.bindLicenseById(license.getId(), deviceCode, now, LicenseStatusEnum.IN_USE.getCode()); + license.setDeviceCode(deviceCode); + license.setBindTime(now); + license.setLicenseStatus(LicenseStatusEnum.IN_USE.getCode()); + return license; + } + + @Override + public LicenseEntity requireValidBoundLicense(String deviceCode) { + expireDueLicenses(); + LicenseEntity license = baseMapper.selectByDeviceCodeIgnoreTenant(deviceCode); + if (license == null || license.getLicenseStatus() == null || license.getLicenseStatus() != LicenseStatusEnum.IN_USE.getCode()) { + throw new BusinessException("403", "设备未绑定有效授权"); + } + if (license.getExpireTime() != null && !license.getExpireTime().isAfter(LocalDateTime.now())) { + baseMapper.clearBindingById(license.getId(), LicenseStatusEnum.EXPIRED.getCode()); + throw new BusinessException("403", "设备授权已过期"); + } + return license; + } + + @Override + public void validateDeviceCanRegisterToTenant(String deviceCode, Long tenantId) { + if (!StringUtils.hasText(deviceCode) || tenantId == null) { + return; + } + LicenseEntity existing = baseMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); + if (existing == null) { + return; + } + if (!tenantId.equals(existing.getTenantId())) { + throw new BusinessException("403", "该设备已绑定,无法重复绑定"); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unbindDeviceLicense(String deviceCode) { + if (!StringUtils.hasText(deviceCode)) { + return; + } + expireDueLicenses(); + LicenseEntity license = baseMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); + if (license == null) { + return; + } + int targetStatus = isExpired(license) ? LicenseStatusEnum.EXPIRED.getCode() : LicenseStatusEnum.UNUSED.getCode(); + baseMapper.clearBindingById(license.getId(), targetStatus); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LicenseImportResultVO importFormalLicenses(MultipartFile file, LoginUser loginUser) throws IOException { + Long tenantId = currentTenantId(loginUser); + List rows = parseImportRows(file); + if (rows.isEmpty()) { + throw new BusinessException("400", "导入文件不能为空"); + } + expireDueLicenses(); + + List boundTempLicenses = baseMapper.selectBoundTemporaryLicensesForReplace(tenantId); + if (rows.size() < boundTempLicenses.size()) { + throw new BusinessException("400", "正式授权数量不足,无法完成替换"); + } + validateImportRows(tenantId, rows); + + String importBatchNo = buildImportBatchNo(); + LocalDateTime now = LocalDateTime.now(); + List imported = new ArrayList<>(rows.size()); + for (LicenseImportRow row : rows) { + LicenseEntity entity = new LicenseEntity(); + entity.setTenantId(tenantId); + entity.setLicenseSerial(row.getLicenseSerial()); + entity.setLicenseCode(row.getLicenseCode()); + entity.setProductCode(normalize(row.getProductCode())); + entity.setRemark(normalize(row.getRemark())); + entity.setLicenseType(LicenseTypeEnum.FORMAL.getCode()); + entity.setLicenseStatus(LicenseStatusEnum.UNUSED.getCode()); + entity.setImportBatchNo(importBatchNo); + entity.setImportTime(now); + imported.add(entity); + } + saveBatch(imported); + + List unusedFormalLicenses = baseMapper.selectUnusedFormalLicenses(tenantId, importBatchNo); + for (int i = 0; i < boundTempLicenses.size(); i++) { + LicenseEntity temp = boundTempLicenses.get(i); + LicenseEntity formal = unusedFormalLicenses.get(i); + baseMapper.bindLicenseById(formal.getId(), temp.getDeviceCode(), now, LicenseStatusEnum.IN_USE.getCode()); + baseMapper.invalidateById(temp.getId()); + } + int invalidatedTempCount = baseMapper.invalidateAllTemporaryLicenses(tenantId); + + LicenseImportResultVO result = new LicenseImportResultVO(); + result.setImportBatchNo(importBatchNo); + result.setTotalCount(imported.size()); + result.setReplacedCount(boundTempLicenses.size()); + result.setUnusedFormalCount(imported.size() - boundTempLicenses.size()); + result.setInvalidatedTempCount(invalidatedTempCount); + return result; + } + + @Override + public List listCurrentTenantLicenses(LoginUser loginUser) { + return baseMapper.selectListByTenant(currentTenantId(loginUser)); + } + + private void validateImportRows(Long tenantId, List rows) { + for (LicenseImportRow row : rows) { + if (!StringUtils.hasText(row.getLicenseSerial()) || !StringUtils.hasText(row.getLicenseCode())) { + throw new BusinessException("400", "导入文件缺少授权序列号或授权码"); + } + if (baseMapper.selectBySerialIgnoreTenant(row.getLicenseSerial()) != null) { + throw new BusinessException("400", "授权序列号已存在:" + row.getLicenseSerial()); + } + if (baseMapper.selectByTenantAndCodeIgnoreTenant(tenantId, row.getLicenseCode()) != null) { + throw new BusinessException("400", "授权码已存在:" + row.getLicenseCode()); + } + } + } + + private List parseImportRows(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new BusinessException("400", "导入文件不能为空"); + } + List rows = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + String line; + int lineNo = 0; + while ((line = reader.readLine()) != null) { + lineNo++; + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + if (lineNo == 1 && trimmed.toLowerCase(Locale.ROOT).startsWith("license_serial")) { + continue; + } + String[] parts = trimmed.split(",", -1); + if (parts.length < 2) { + throw new BusinessException("400", "第" + lineNo + "行格式错误"); + } + LicenseImportRow row = new LicenseImportRow(); + row.setLicenseSerial(normalize(parts[0])); + row.setLicenseCode(normalize(parts[1])); + row.setProductCode(parts.length > 2 ? normalize(parts[2]) : null); + row.setRemark(parts.length > 3 ? normalize(parts[3]) : null); + rows.add(row); + } + } + return rows; + } + + private String buildTemporarySerial(String productCode) { + Long sequence = baseMapper.nextTempSerialValue(); + if (sequence == null) { + throw new BusinessException("500", "临时授权序列号生成失败"); + } + return LicenseConstants.TEMP_SERIAL_PREFIX + + productCode + + LocalDate.now().format(TEMP_SERIAL_DATE) + + String.format("%08d", sequence); + } + + private String buildImportBatchNo() { + return LicenseConstants.IMPORT_BATCH_PREFIX + LocalDateTime.now().format(IMPORT_BATCH_TIME); + } + + private void expireDueLicenses() { + baseMapper.expireDueLicenses(); + } + + private boolean isExpired(LicenseEntity license) { + return license.getExpireTime() != null && !license.getExpireTime().isAfter(LocalDateTime.now()); + } + + private String normalize(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private int parsePositiveInt(String value) { + if (!StringUtils.hasText(value)) { + return 0; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + return 0; + } + } + + private Long currentTenantId(LoginUser loginUser) { + if (loginUser == null || loginUser.getTenantId() == null) { + throw new BusinessException("403", "缺少租户上下文"); + } + return loginUser.getTenantId(); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java index e766fa9..018f43f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java @@ -52,6 +52,7 @@ public class ScreenSaverServiceImpl extends ServiceImpl platformItems = listActiveByScope(SCOPE_PLATFORM, null); if (userId == null) { - return new ScreenSaverSelectionResult(SCOPE_PLATFORM, displayDurationSec, toAdminVOs(platformItems, Map.of(), displayDurationSec)); + List selectedPlatformItems = platformItems.isEmpty() ? listGlobalFallbackPlatformItems() : platformItems; + return new ScreenSaverSelectionResult(SCOPE_PLATFORM, displayDurationSec, toAdminVOs(selectedPlatformItems, Map.of(), displayDurationSec)); } Map userStatusMap = queryUserStatusMap(tenantId, userId, extractPlatformIds(platformItems)); @@ -249,6 +251,16 @@ public class ScreenSaverServiceImpl extends ServiceImpl fallbackPlatformItems = listGlobalFallbackPlatformItems(); + if (!fallbackPlatformItems.isEmpty()) { + return new ScreenSaverSelectionResult( + SCOPE_PLATFORM, + displayDurationSec, + toAdminVOs(fallbackPlatformItems, Map.of(), displayDurationSec) + ); + } + } return new ScreenSaverSelectionResult( resolveSourceScope(effectivePlatformItems, userItems), @@ -257,6 +269,13 @@ public class ScreenSaverServiceImpl extends ServiceImpl listGlobalFallbackPlatformItems() { + if (baseMapper == null) { + return List.of(); + } + return baseMapper.selectActivePlatformByTenantIgnoreTenant(PLATFORM_TENANT_ID); + } + private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) { if (dto == null) { return null; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java new file mode 100644 index 0000000..17416b1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java @@ -0,0 +1,73 @@ +package com.imeeting.service.biz.impl; + +import com.unisbase.dto.CreateTenantDTO; +import com.unisbase.dto.PageResult; +import com.unisbase.dto.SysTenantDTO; +import com.unisbase.service.SysTenantService; +import com.unisbase.service.TenantManagementService; +import com.unisbase.service.TenantModeService; +import com.imeeting.service.biz.LicenseService; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Primary +public class TenantManagementServicePrimaryImpl implements TenantManagementService { + + private final SysTenantService sysTenantService; + private final TenantModeService tenantModeService; + private final LicenseService licenseService; + + public TenantManagementServicePrimaryImpl(SysTenantService sysTenantService, + TenantModeService tenantModeService, + LicenseService licenseService) { + this.sysTenantService = sysTenantService; + this.tenantModeService = tenantModeService; + this.licenseService = licenseService; + } + + @Override + public PageResult> listTenants(Integer current, Integer size, String name, String code) { + if (tenantModeService.isSingleTenantMode()) { + SysTenantDTO defaultTenant = sysTenantService.findById(tenantModeService.getDefaultTenantId()); + PageResult> result = new PageResult<>(); + result.setRecords(defaultTenant == null ? List.of() : List.of(defaultTenant)); + result.setTotal(defaultTenant == null ? 0 : 1); + return result; + } + return sysTenantService.page(current, size, name, code); + } + + @Override + public SysTenantDTO getTenant(Long tenantId) { + return sysTenantService.findById(tenantId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createTenant(CreateTenantDTO tenant) { + tenantModeService.assertTenantLifecycleAllowed(); + Long tenantId = sysTenantService.createTenantWithAdmin(tenant); + licenseService.initializeTemporaryLicenses(tenantId); + return tenantId; + } + + @Override + public boolean updateTenant(Long tenantId, SysTenantDTO tenant) { + tenantModeService.assertDefaultTenantCanBeUpdated(tenantId, tenant == null ? null : tenant.getStatus()); + if (tenant == null) { + throw new IllegalArgumentException("租户信息不能为空"); + } + tenant.setId(tenantId); + return sysTenantService.update(tenant); + } + + @Override + public boolean deleteTenant(Long tenantId) { + tenantModeService.assertTenantLifecycleAllowed(); + return sysTenantService.deleteById(tenantId); + } +} diff --git a/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java b/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java index 2a12a45..228d101 100644 --- a/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java +++ b/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java @@ -4,6 +4,7 @@ import com.imeeting.entity.biz.AndroidPushMessage; import com.imeeting.grpc.push.PushMessage; import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidPushMessageService; +import com.imeeting.support.TaskSecurityContextRunner; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -18,30 +19,34 @@ import java.util.List; public class AndroidPushMessageRetryTask { private final AndroidPushMessageService androidPushMessageService; private final AndroidGatewayPushService androidGatewayPushService; + private final TaskSecurityContextRunner taskSecurityContextRunner; @Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}") public void retryPendingMessages() { - List pendingMessages = androidPushMessageService.listPendingMeetingPushMessages(); - for (AndroidPushMessage message : pendingMessages) { - if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) { - androidPushMessageService.markExpired(message.getId()); - continue; + taskSecurityContextRunner.callAsPlatformAdmin(() -> { + List pendingMessages = androidPushMessageService.listPendingMeetingPushMessages(); + for (AndroidPushMessage message : pendingMessages) { + if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) { + androidPushMessageService.markExpired(message.getId()); + continue; + } + PushMessage pushMessage = PushMessage.newBuilder() + .setMessageId(message.getMessageId()) + .setTimestamp(System.currentTimeMillis()) + .setType(message.getMessageType()) + .setTitle(resolveMessageTitle(message)) + .setContent(message.getPayload() == null ? "" : message.getPayload()) + .setNeedAck(true) + .build(); + int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage); + if (pushed > 0) { + androidPushMessageService.markPushed(message.getId()); + log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true", + message.getMessageId(), message.getDeviceCode()); + } } - PushMessage pushMessage = PushMessage.newBuilder() - .setMessageId(message.getMessageId()) - .setTimestamp(System.currentTimeMillis()) - .setType(message.getMessageType()) - .setTitle(resolveMessageTitle(message)) - .setContent(message.getPayload() == null ? "" : message.getPayload()) - .setNeedAck(true) - .build(); - int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage); - if (pushed > 0) { - androidPushMessageService.markPushed(message.getId()); - log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true", - message.getMessageId(), message.getDeviceCode()); - } - } + return null; + }); } private String resolveMessageTitle(AndroidPushMessage message) { diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 921b8ca..5e0deed 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -11,14 +11,14 @@ logging: com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl: debug spring: datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_db} + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.53.199:5432/imeeting_dev} username: ${SPRING_DATASOURCE_USERNAME:postgres} password: ${SPRING_DATASOURCE_PASSWORD:postgres} data: redis: - host: ${SPRING_DATA_REDIS_HOST:127.0.0.1} + host: ${SPRING_DATA_REDIS_HOST:10.100.53.199} port: ${SPRING_DATA_REDIS_PORT:6379} - password: ${SPRING_DATA_REDIS_PASSWORD:} + password: ${SPRING_DATA_REDIS_PASSWORD:unis@123} database: ${SPRING_DATA_REDIS_DATABASE:15} mybatis-plus: @@ -31,10 +31,10 @@ unisbase: internal-auth: secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret} app: - server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}} + server-base-url: ${APP_SERVER_BASE_URL:http://10.100.52.13:${server.port}} upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/} imeeting: h5: - base-url: ${IMEETING_H5_BASE_URL:http://127.0.0.1:3000} + base-url: ${IMEETING_H5_BASE_URL:http://10.100.52.13:3000} audio: ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 6019711..d3cae72 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -63,6 +63,7 @@ unisbase: - /api/auth/** - /api/static/** - /api/public/meetings/** + - /api/android/devices/home - /api/android/auth/login - /api/android/auth/refresh - /api/clients/latest/by-platform diff --git a/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java b/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java index 2431f69..c39fd1a 100644 --- a/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java +++ b/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java @@ -1,43 +1,20 @@ -//package com.imeeting.service; -// -//import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -// -//import org.junit.jupiter.api.Test; -//import org.junit.jupiter.api.extension.ExtendWith; -//import org.mockito.InjectMocks; -//import org.mockito.Mock; -//import org.mockito.junit.jupiter.MockitoExtension; -// -//import java.util.Collections; -//import java.util.List; -// -//import static org.junit.jupiter.api.Assertions.assertEquals; -//import static org.mockito.ArgumentMatchers.any; -//import static org.mockito.Mockito.when; -// -//@ExtendWith(MockitoExtension.class) -//public class DictItemServiceTest { -// -// @Mock -// private SysDictItemMapper dictItemMapper; -// -// @InjectMocks -// private SysDictItemServiceImpl dictItemService; -// -// @Test -// void testGetItemsByTypeCode() { -// String typeCode = "gender"; -// SysDictItem item = new SysDictItem(); -// item.setTypeCode(typeCode); -// item.setItemLabel("Male"); -// item.setItemValue("1"); -// item.setStatus(1); -// item.setSortOrder(1); -// -// when(dictItemMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(item)); -// -// List result = dictItemService.getItemsByTypeCode(typeCode); -// assertEquals(1, result.size()); -// assertEquals("Male", result.get(0).getItemLabel()); -// } -//} +package com.imeeting.service; + +import com.imeeting.service.biz.LicenseService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("dev") + +public class DictItemServiceTest { + + @Autowired + private LicenseService licenseService; + @Test + public void main(){ + licenseService.initializeTemporaryLicenses(1L); + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java index e8e0500..e1972f2 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java @@ -61,6 +61,30 @@ class ScreenSaverServiceImplTest { assertEquals(List.of(22, 22), result.getItems().stream().map(ScreenSaverAdminVO::getDisplayDurationSec).toList()); } + @Test + void getActiveSelectionShouldFallbackToPlatformTenantItemsWhenTenantSelectionIsEmpty() { + ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); + ScreenSaverUserSettingsMapper userSettingsMapper = mock(ScreenSaverUserSettingsMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + when(userConfigMapper.selectList(any())).thenReturn(List.of()); + when(userSettingsMapper.selectOne(any())).thenReturn(userSettings(77L, 22)); + when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of()); + + ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, userSettingsMapper, sysUserMapper)); + doReturn(List.of()) + .doReturn(List.of()) + .when(service).list(any(LambdaQueryWrapper.class)); + doReturn(List.of(screenSaver(301L, "PLATFORM", null, 1, 1))) + .when(service).listGlobalFallbackPlatformItems(); + + ScreenSaverSelectionResult result = service.getActiveSelection(77L); + + assertEquals("PLATFORM", result.getSourceScope()); + assertEquals(22, result.getDisplayDurationSec()); + assertEquals(List.of(301L), result.getItems().stream().map(ScreenSaverAdminVO::getId).toList()); + assertEquals(List.of(1), result.getItems().stream().map(ScreenSaverAdminVO::getStatus).toList()); + } + @Test void listForAdminShouldApplyCurrentUserStatusFilter() { ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); diff --git a/frontend/src/api/business/license.ts b/frontend/src/api/business/license.ts new file mode 100644 index 0000000..182c004 --- /dev/null +++ b/frontend/src/api/business/license.ts @@ -0,0 +1,40 @@ +import http from "../http"; + +export interface LicenseVO { + id: number; + tenantId: number; + licenseSerial: string; + licenseCode: string; + licenseType: number; + licenseStatus: number; + productCode?: string; + deviceCode?: string; + bindTime?: string; + expireTime?: string; + importBatchNo?: string; + importTime?: string; + remark?: string; +} + +export interface LicenseImportResultVO { + importBatchNo: string; + totalCount: number; + replacedCount: number; + unusedFormalCount: number; + invalidatedTempCount: number; +} + +export async function listLicenses() { + const resp = await http.get("/api/admin/licenses"); + return resp.data.data as LicenseVO[]; +} + +export async function importLicenses(file: File) { + const formData = new FormData(); + formData.append("file", file); + const resp = await http.post("/api/admin/licenses/import", formData, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: 600000 + }); + return resp.data.data as LicenseImportResultVO; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b89445c..c814e1a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -174,6 +174,16 @@ export async function kickManagedDevice(id: number) { return resp.data.data as boolean; } +export async function deleteManagedDevice(id: number) { + const resp = await http.delete(`/api/admin/devices/${id}`); + return resp.data.data as boolean; +} + +export async function resetManagedDeviceStats(id: number) { + const resp = await http.post(`/api/admin/devices/${id}/reset`); + return resp.data.data as boolean; +} + export async function listUserRoles(userId: number) { const resp = await http.get(`/sys/api/users/${userId}/roles`); return resp.data.data as number[]; diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 05e2721..c907107 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -350,7 +350,12 @@ "kickDevice": "Kick device", "kickDeviceConfirm": "Kick this device offline?", "kickSucceeded": "Device has been kicked offline", + "resetStats": "Reset stats", + "resetStatsConfirm": "Reset this device's homepage statistics?", + "resetStatsSucceeded": "Device statistics reset", "deleteDevice": "Delete this device?", + "weatherCityName": "Weather City", + "statsResetAt": "Stats Reset At", "drawerTitleCreate": "New Device", "drawerTitleEdit": "Edit Device", "owner": "Owner", @@ -358,7 +363,8 @@ "searchSelectUser": "Search and select a user", "deviceCodeRequired": "Enter the device code", "deviceCodePlaceholder": "Enter a unique device code", - "deviceNamePlaceholder": "Example: Meeting Room A Recorder" + "deviceNamePlaceholder": "Example: Meeting Room A Recorder", + "weatherCityNamePlaceholder": "Example: Shenzhen" }, "dashboardExt": { "processing": "Processing", diff --git a/frontend/src/pages/business/LicenseManagement.tsx b/frontend/src/pages/business/LicenseManagement.tsx new file mode 100644 index 0000000..46bf6f6 --- /dev/null +++ b/frontend/src/pages/business/LicenseManagement.tsx @@ -0,0 +1,243 @@ +import { App, Button, Card, Col, Empty, Input, Row, Space, Table, Tag, Typography, Upload } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { CheckCircleOutlined, ClockCircleOutlined, KeyOutlined, LinkOutlined, ReloadOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import PageContainer from "@/components/shared/PageContainer"; +import AppPagination from "@/components/shared/AppPagination"; +import { importLicenses, listLicenses, type LicenseImportResultVO, type LicenseVO } from "@/api/business/license"; + +const { Text } = Typography; + +function resolveLicenseTypeTag(type?: number) { + if (type === 1) return 临时; + if (type === 2) return 正式; + return 未知; +} + +function resolveLicenseStatusTag(status?: number) { + switch (status) { + case 1: + return 未使用; + case 2: + return 使用中; + case 3: + return 已过期; + case 4: + return 已失效; + default: + return 未知; + } +} + +function formatDateTime(value?: string) { + return value ? value.replace("T", " ").substring(0, 19) : "-"; +} + +export default function LicenseManagement() { + const { message } = App.useApp(); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [records, setRecords] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const result = await listLicenses(); + setRecords(result || []); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const filteredRecords = useMemo(() => { + const keyword = searchValue.trim().toLowerCase(); + if (!keyword) { + return records; + } + return records.filter((item) => + [ + item.licenseSerial, + item.licenseCode, + item.productCode, + item.deviceCode, + item.importBatchNo, + item.remark, + ].some((field) => String(field || "").toLowerCase().includes(keyword)) + ); + }, [records, searchValue]); + + const pagedRecords = useMemo(() => { + const start = (page - 1) * pageSize; + return filteredRecords.slice(start, start + pageSize); + }, [filteredRecords, page, pageSize]); + + useEffect(() => { + setPage(1); + }, [searchValue]); + + const stats = useMemo(() => ({ + total: records.length, + using: records.filter((item) => item.licenseStatus === 2).length, + formal: records.filter((item) => item.licenseType === 2).length, + available: records.filter((item) => item.licenseStatus === 1).length, + }), [records]); + + const handleImport = async (file: File) => { + setUploading(true); + try { + const result: LicenseImportResultVO = await importLicenses(file); + message.success(`导入完成:${result.totalCount} 条,替换 ${result.replacedCount} 条`); + await loadData(); + } finally { + setUploading(false); + } + }; + + const columns: ColumnsType = [ + { + title: "授权标识", + key: "license", + width: 320, + render: (_, record) => ( + + {record.licenseSerial} + {/*{record.licenseCode}*/} + + ), + }, + { + title: "类型", + dataIndex: "licenseType", + key: "licenseType", + width: 100, + render: (value) => resolveLicenseTypeTag(value), + }, + { + title: "状态", + dataIndex: "licenseStatus", + key: "licenseStatus", + width: 100, + render: (value) => resolveLicenseStatusTag(value), + }, + { + title: "绑定设备", + dataIndex: "deviceCode", + key: "deviceCode", + width: 240, + render: (value) => {value || "-"}, + }, + { + title: "过期时间", + dataIndex: "expireTime", + key: "expireTime", + width: 200, + render: (value) => {formatDateTime(value)}, + }, + ]; + + return ( + + + {/* { void handleImport(file as File); return Upload.LIST_IGNORE; }}>*/} + {/* */} + {/**/} + + } + toolbar={ + } + allowClear + style={{ width: 360 }} + value={searchValue} + onChange={(event) => setSearchValue(event.target.value)} + /> + } + > + + + + + +
+
授权总数
+
{stats.total}
+
+
+
+ + + + + +
+
使用中
+
{stats.using}
+
+
+
+ + + + + +
+
正式授权
+
{stats.formal}
+
+
+
+ + + + + +
+
可分配
+
{stats.available}
+
+
+
+ +
+ + +
+ }} + /> + + { + setPage(nextPage); + setPageSize(nextSize); + }} + /> + + + ); +} diff --git a/frontend/src/pages/devices/index.tsx b/frontend/src/pages/devices/index.tsx index 35f64e4..8494ba8 100644 --- a/frontend/src/pages/devices/index.tsx +++ b/frontend/src/pages/devices/index.tsx @@ -1,8 +1,8 @@ import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Tag, Typography, message } from "antd"; -import { CheckCircleOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UserOutlined } from "@ant-design/icons"; +import { CheckCircleOutlined, DeleteOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UndoOutlined, UserOutlined } from "@ant-design/icons"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { kickManagedDevice, listManagedDevices, updateManagedDevice } from "@/api"; +import { deleteManagedDevice, kickManagedDevice, listManagedDevices, resetManagedDeviceStats, updateManagedDevice } from "@/api"; import PageHeader from "@/components/shared/PageHeader"; import PageContainer from "@/components/shared/PageContainer"; import ListTable from "@/components/shared/ListTable/ListTable"; @@ -17,6 +17,7 @@ const { Text } = Typography; type DeviceFormValues = { deviceName?: string; status: number; + weatherCityName?: string; }; export default function Devices() { @@ -82,7 +83,8 @@ export default function Devices() { setEditing(record); form.setFieldsValue({ deviceName: record.deviceName, - status: record.status ?? 1 + status: record.status ?? 1, + weatherCityName: record.weatherCityName }); setOpen(true); }; @@ -96,7 +98,8 @@ export default function Devices() { try { await updateManagedDevice(editing.deviceId, { deviceName: values.deviceName, - status: values.status + status: values.status, + weatherCityName: values.weatherCityName }); message.success(t("devicesExt.operationSucceeded")); setOpen(false); @@ -112,6 +115,18 @@ export default function Devices() { await loadData(); }; + const remove = async (record: DeviceInfo) => { + await deleteManagedDevice(record.deviceId); + message.success(t("devicesExt.deleteSucceeded")); + await loadData(); + }; + + const resetStats = async (record: DeviceInfo) => { + await resetManagedDeviceStats(record.deviceId); + message.success(t("devicesExt.resetStatsSucceeded")); + await loadData(); + }; + const handlePageChange = (page: number, pageSize: number) => { setPagination({ current: page, pageSize }); }; @@ -187,6 +202,22 @@ export default function Devices() { ) }, + { + title: t("devicesExt.statsResetAt"), + dataIndex: "statsResetAt", + width: 180, + render: (text: string) => ( + + {text ? text.replace("T", " ").substring(0, 19) : "-"} + + ) + }, + { + title: t("devicesExt.weatherCityName"), + dataIndex: "weatherCityName", + width: 140, + render: (text: string) => {text || "-"} + }, { title: t("common.status"), dataIndex: "status", @@ -209,7 +240,7 @@ export default function Devices() { { title: t("common.action"), key: "action", - width: 140, + width: 220, fixed: "right" as const, render: (_value: unknown, record: DeviceInfo) => ( @@ -226,6 +257,25 @@ export default function Devices() { /> ) : null} + {can("device:update") ? ( + resetStats(record)}> +