feat: 添加授权码管理和设备自注册功能

- 新增 `AndroidTenantProviderConfig` 配置类,提供租户解析逻辑
- 更新 `AndroidAuthService` 接口,添加 `authenticateHttp` 方法的可选参数
- 新增前端授权码管理页面 `LicenseManagement`,支持授权码列表展示和导入
- 新增 `AndroidDeviceRegistrationServiceImpl` 的 `register` 方法,支持设备自注册并验证租户代码
- 更新 `AndroidAuthServiceImpl`,在认证过程中应用授权信息
- 更新相关 DTO 和接口,支持新的授权和设备注册逻辑
dev_na
chenhao 2026-06-09 17:09:45 +08:00
parent d47a66febd
commit 1cce0aeabb
57 changed files with 2095 additions and 145 deletions

View File

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

View File

@ -123,6 +123,10 @@ public final class RedisKeys {
return "biz:meeting:android:draft:" + meetingId; 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 CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type"; public static final String SYS_PARAM_FIELD_TYPE = "type";

View File

@ -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_LLM_RATIO = "meeting.points.llm_ratio";
public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance"; 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 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";
} }

View File

@ -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<SysTenantMapper> sysTenantMapperProvider) {
return new AndroidTenantProvider(properties, sysTenantMapperProvider);
}
private static final class AndroidTenantProvider extends SpringSecurityTenantProvider {
private final UnisBaseProperties properties;
private final ObjectProvider<SysTenantMapper> sysTenantMapperProvider;
private AndroidTenantProvider(UnisBaseProperties properties, ObjectProvider<SysTenantMapper> 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<SysTenant>()
.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();
}
}
}

View File

@ -67,6 +67,11 @@ public class AndroidAuthController {
appVersion, appVersion,
platform platform
); );
androidDeviceBindingService.recordLogin(
deviceId.trim(),
response.getCurrentTenantId(),
response.getUser().getUserId()
);
} }
return ApiResponse.ok(response); return ApiResponse.ok(response);
} }

View File

@ -5,6 +5,7 @@ import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.entity.biz.ClientDownload; import com.imeeting.entity.biz.ClientDownload;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.ClientDownloadService; import com.imeeting.service.biz.ClientDownloadService;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@ -38,6 +39,7 @@ public class AndroidClientController {
) )
}) })
@GetMapping("/latest/by-platform") @GetMapping("/latest/by-platform")
@Anonymous
public ApiResponse<ClientDownload> latestByPlatform(HttpServletRequest request, public ApiResponse<ClientDownload> latestByPlatform(HttpServletRequest request,
@RequestParam(value = "platform_code", required = false) String platformCode, @RequestParam(value = "platform_code", required = false) String platformCode,
@RequestParam(value = "platform_type", required = false) String platformType, @RequestParam(value = "platform_type", required = false) String platformType,

View File

@ -1,9 +1,11 @@
package com.imeeting.controller.android; package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceHomeStatsVO;
import com.imeeting.dto.android.AndroidDeviceRegisterRequest; import com.imeeting.dto.android.AndroidDeviceRegisterRequest;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceHomeService;
import com.imeeting.service.android.AndroidDeviceRegistrationService; import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous; import com.unisbase.annotation.Anonymous;
@ -16,6 +18,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -27,8 +31,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class AndroidDeviceController { public class AndroidDeviceController {
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
private final AndroidAuthService androidAuthService; private final AndroidAuthService androidAuthService;
private final AndroidDeviceRegistrationService androidDeviceRegistrationService; private final AndroidDeviceRegistrationService androidDeviceRegistrationService;
private final AndroidDeviceHomeService androidDeviceHomeService;
@Operation(summary = "设备自注册") @Operation(summary = "设备自注册")
@ApiResponses({ @ApiResponses({
@ -42,14 +49,46 @@ public class AndroidDeviceController {
@Anonymous @Anonymous
public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request, public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request,
@RequestBody(required = false) AndroidDeviceRegisterRequest command) { @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); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false);
AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register( AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register(
tenantCode,
authContext.getDeviceId(), authContext.getDeviceId(),
command == null ? null : command.getDeviceName(), command.getDeviceName(),
command == null ? authContext.getPlatform() : command.getTerminalType(), command.getTerminalType() == null ? authContext.getPlatform() : command.getTerminalType(),
command == null ? authContext.getAppVersion() : command.getTerminalVersion() command.getTerminalVersion() == null ? authContext.getAppVersion() : command.getTerminalVersion()
); );
return ApiResponse.ok(response); 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<AndroidDeviceHomeStatsVO> 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不能为空");
}
} }

View File

@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -58,6 +59,18 @@ public class DeviceManagementController {
return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser())); return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser()));
} }
@Operation(summary = "删除设备并解绑授权")
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.delete(id, currentLoginUser()));
}
@Operation(summary = "重置设备首页统计")
@PostMapping("/{id}/reset")
public ApiResponse<Boolean> reset(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.resetStats(id, currentLoginUser()));
}
private LoginUser currentLoginUser() { private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
} }

View File

@ -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<LicenseVO>> list() {
return ApiResponse.ok(licenseService.listCurrentTenantLicenses(currentLoginUser()));
}
@Operation(summary = "导入当前租户正式授权")
@PostMapping("/import")
public ApiResponse<LicenseImportResultVO> importLicenses(@RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.ok(licenseService.importFormalLicenses(file, currentLoginUser()));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

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

View File

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

View File

@ -6,6 +6,9 @@ import lombok.Data;
@Data @Data
@Schema(description = "Android设备注册请求") @Schema(description = "Android设备注册请求")
public class AndroidDeviceRegisterRequest { public class AndroidDeviceRegisterRequest {
@Schema(description = "租户编码", requiredMode = Schema.RequiredMode.REQUIRED)
private String tenantCode;
@Schema(description = "设备名称") @Schema(description = "设备名称")
private String deviceName; private String deviceName;

View File

@ -20,4 +20,7 @@ public class AndroidDeviceRegisterResponse {
@Schema(description = "是否已被用户占用") @Schema(description = "是否已被用户占用")
private Boolean occupied; private Boolean occupied;
@Schema(description = "授权类型1-临时2-正式")
private Integer licenseType;
} }

View File

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

View File

@ -12,4 +12,7 @@ public class DeviceAdminUpdateCommand {
@Schema(description = "状态1启用0停用") @Schema(description = "状态1启用0停用")
private Integer status; private Integer status;
@Schema(description = "设备所在天气城市")
private String weatherCityName;
} }

View File

@ -39,6 +39,12 @@ public class DeviceOnlineAdminVO {
@Schema(description = "最后一次在线时间") @Schema(description = "最后一次在线时间")
private LocalDateTime lastOnlineAt; private LocalDateTime lastOnlineAt;
@Schema(description = "统计重置时间")
private LocalDateTime statsResetAt;
@Schema(description = "设备天气城市")
private String weatherCityName;
@Schema(description = "状态1启用0停用") @Schema(description = "状态1启用0停用")
private Integer status; private Integer status;

View File

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

View File

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

View File

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

View File

@ -28,4 +28,8 @@ public class DeviceInfoEntity extends BaseEntity {
private String terminalVersion; private String terminalVersion;
private LocalDateTime lastOnlineAt; private LocalDateTime lastOnlineAt;
private LocalDateTime statsResetAt;
private String weatherCityName;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,9 @@ package com.imeeting.grpc.push;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.dto.android.AndroidDeviceSessionState;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.biz.DeviceOnlineManagementService; import com.imeeting.service.biz.DeviceOnlineManagementService;
import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.BusinessException;
import io.grpc.BindableService; import io.grpc.BindableService;
@ -116,8 +116,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
request.getAppVersion(), request.getAppVersion(),
resolvePlatform(request.getPlatform()) resolvePlatform(request.getPlatform())
); );
authContext.setUserId(parseNullableLong(request.getUserId(), "user_id"));
authContext.setTenantId(parseNullableLong(request.getTenantId(), "tenant_id"));
AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId()); AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId());
connectionId = sessionState.getConnectionId(); connectionId = sessionState.getConnectionId();
deviceId = sessionState.getDeviceId(); deviceId = sessionState.getDeviceId();
@ -181,30 +179,15 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
return; return;
} }
if (!request.getConnectionId().isBlank() && !request.getConnectionId().equals(connectionId)) { 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, sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false,
deviceId, appVersion, platform); deviceId, appVersion, platform);
return; return;
} }
if (!request.getDeviceId().isBlank() && !request.getDeviceId().equals(deviceId)) { 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, sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false,
deviceId, appVersion, platform); deviceId, appVersion, platform);
return; return;
} }
log.info(buildLog("gRPC消息确认",
"收到客户端ACK确认消息ID=" + safe(request.getMessageId()) + "连接ID=" + connectionId,
deviceId,
appVersion,
platform));
androidPushMessageService.ack(request.getMessageId(), deviceId); androidPushMessageService.ack(request.getMessageId(), deviceId);
} }
@ -212,11 +195,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
if (connected) { if (connected) {
return true; return true;
} }
log.info(buildLog("gRPC请求拒绝",
"连接尚未建立即发送后续消息",
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false, sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false,
deviceId, appVersion, platform); deviceId, appVersion, platform);
return false; return false;
@ -230,11 +208,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
androidGatewayPushService.unregister(connectionId); androidGatewayPushService.unregister(connectionId);
androidDeviceSessionService.closeSession(connectionId); androidDeviceSessionService.closeSession(connectionId);
deviceOnlineManagementService.recordDisconnected(deviceId, state == null ? null : state.getLastSeenAt()); 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; connectionId = null;
deviceId = null; deviceId = null;
appVersion = null; appVersion = null;
@ -251,11 +224,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
String deviceId, String deviceId,
String appVersion, String appVersion,
String platform) { String platform) {
log.info(buildLog("gRPC错误响应",
"向客户端返回错误,错误码=" + safe(code) + ",原因=" + safe(message),
deviceId,
appVersion,
platform));
responseObserver.onNext(ServerMessage.newBuilder() responseObserver.onNext(ServerMessage.newBuilder()
.setError(ErrorEvent.newBuilder() .setError(ErrorEvent.newBuilder()
.setCode(code) .setCode(code)
@ -286,15 +254,4 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
case PLATFORM_UNKNOWN, UNRECOGNIZED -> "android"; 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);
}
}
} }

View File

@ -87,6 +87,8 @@ public interface DeviceInfoMapper extends BaseMapper<DeviceInfoEntity> {
d.terminal_type AS terminalType, d.terminal_type AS terminalType,
d.terminal_version AS terminalVersion, d.terminal_version AS terminalVersion,
d.last_online_at AS lastOnlineAt, d.last_online_at AS lastOnlineAt,
d.stats_reset_at AS statsResetAt,
d.weather_city_name AS weatherCityName,
d.status AS status, d.status AS status,
d.created_at AS createdAt, d.created_at AS createdAt,
d.updated_at AS updatedAt, d.updated_at AS updatedAt,

View File

@ -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<DeviceLoginLogEntity> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(DISTINCT user_id)
FROM biz_device_login_log
WHERE tenant_id = #{tenantId}
AND device_code = #{deviceCode}
AND is_deleted = 0
<if test="resetAt != null">
AND login_at &gt;= #{resetAt}
</if>
</script>
""")
Long countDistinctUsersSince(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode,
@Param("resetAt") LocalDateTime resetAt);
}

View File

@ -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<LicenseEntity> {
@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<LicenseEntity> 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<LicenseEntity> 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<LicenseEntity> 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<LicenseVO> 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);
}

View File

@ -7,9 +7,45 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface MeetingMapper extends BaseMapper<Meeting> { public interface MeetingMapper extends BaseMapper<Meeting> {
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0") @Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0")
Meeting selectByIdIgnoreTenant(@Param("id") Long id); Meeting selectByIdIgnoreTenant(@Param("id") Long id);
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(1)
FROM biz_meetings
WHERE tenant_id = #{tenantId}
AND source_device_code = #{deviceCode}
AND is_deleted = 0
<if test="resetAt != null">
AND created_at &gt;= #{resetAt}
</if>
</script>
""")
Long countByDeviceSince(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode,
@Param("resetAt") LocalDateTime resetAt);
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COALESCE(SUM(COALESCE(effective_audio_duration_seconds, 0)), 0)
FROM biz_meetings
WHERE tenant_id = #{tenantId}
AND source_device_code = #{deviceCode}
AND is_deleted = 0
<if test="resetAt != null">
AND created_at &gt;= #{resetAt}
</if>
</script>
""")
Long sumMeetingDurationSecondsByDeviceSince(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode,
@Param("resetAt") LocalDateTime resetAt);
} }

View File

@ -1,9 +1,27 @@
package com.imeeting.mapper.biz; package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.ScreenSaver; import com.imeeting.entity.biz.ScreenSaver;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface ScreenSaverMapper extends BaseMapper<ScreenSaver> { public interface ScreenSaverMapper extends BaseMapper<ScreenSaver> {
@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<ScreenSaver> selectActivePlatformByTenantIgnoreTenant(@Param("tenantId") Long tenantId);
} }

View File

@ -9,4 +9,6 @@ public interface AndroidAuthService {
AndroidAuthContext authenticateHttp(HttpServletRequest request); AndroidAuthContext authenticateHttp(HttpServletRequest request);
AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered); AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered);
AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken);
} }

View File

@ -6,4 +6,6 @@ public interface AndroidDeviceBindingService {
void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId); void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId);
void unbindPrivateDevice(String deviceCode); void unbindPrivateDevice(String deviceCode);
void recordLogin(String deviceCode, Long tenantId, Long userId);
} }

View File

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

View File

@ -3,7 +3,7 @@ package com.imeeting.service.android;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
public interface AndroidDeviceRegistrationService { 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); void requireRegistered(String deviceCode);
} }

View File

@ -3,9 +3,11 @@ package com.imeeting.service.android.impl;
import com.imeeting.config.grpc.AndroidGrpcAuthProperties; import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.LicenseEntity;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceBindingService; import com.imeeting.service.android.AndroidDeviceBindingService;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.BusinessException;
import com.unisbase.dto.InternalAuthCheckResponse; import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
@ -34,27 +36,34 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
private final TokenValidationService tokenValidationService; private final TokenValidationService tokenValidationService;
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceBindingService androidDeviceBindingService; private final AndroidDeviceBindingService androidDeviceBindingService;
private final LicenseService licenseService;
@Override @Override
public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) {
if (properties.isEnabled() && !properties.isAllowAnonymous()) { if (properties.isEnabled() && !properties.isAllowAnonymous()) {
throw new RuntimeException("Android gRPC push does not allow anonymous access"); throw new RuntimeException("Android gRPC push does not allow anonymous access");
} }
LicenseEntity license = licenseService.requireValidBoundLicense(deviceId);
DeviceInfoEntity device = requireRegisteredDevice(deviceId); DeviceInfoEntity device = requireRegisteredDevice(deviceId);
assertDeviceEnabled(device); assertDeviceEnabled(device);
AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null);
context.setUserId(device.getUserId()); context.setUserId(device.getUserId());
context.setTenantId(device.getTenantId()); context.setTenantId(license.getTenantId());
return context; return context;
} }
@Override @Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request) { public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
return authenticateHttp(request, true); return authenticateHttp(request, true, false);
} }
@Override @Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) { 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(); LoginUser loginUser = currentLoginUser();
String resolvedToken = resolveHttpToken(request); String resolvedToken = resolveHttpToken(request);
String deviceId = firstHeader(request, HEADER_DEVICE_ID); String deviceId = firstHeader(request, HEADER_DEVICE_ID);
@ -63,12 +72,17 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
String platform = request.getHeader(HEADER_PLATFORM); String platform = request.getHeader(HEADER_PLATFORM);
requireAndroidHttpHeaders(deviceId, appVersion, 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); DeviceInfoEntity device = requireRegistered ? requireRegisteredDevice(deviceId) : findDevice(deviceId);
assertDeviceEnabled(device); assertDeviceEnabled(device);
LicenseEntity license = requireRegistered ? licenseService.requireValidBoundLicense(deviceId) : null;
if (loginUser != null) { if (loginUser != null) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId()); if (!allowOptionalToken) {
return buildContext("USER_JWT", false, androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId, deviceId,
appId, appId,
appVersion, appVersion,
@ -77,12 +91,15 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
null, null,
null, null,
loginUser); loginUser);
return applyLicenseContext(context, license, allowOptionalToken);
} }
if (StringUtils.hasText(resolvedToken)) { if (StringUtils.hasText(resolvedToken)) {
InternalAuthCheckResponse authResult = validateToken(resolvedToken); InternalAuthCheckResponse authResult = validateToken(resolvedToken);
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId()); if (requireRegistered && !allowOptionalToken) {
return buildContext("USER_JWT", false, androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId, deviceId,
appId, appId,
appVersion, appVersion,
@ -91,9 +108,11 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
null, null,
authResult, authResult,
null); null);
return applyLicenseContext(context, license, allowOptionalToken);
} }
if (properties.isAllowAnonymous()) { if (properties.isAllowAnonymous()) {
return buildContext("NONE", true, AndroidAuthContext context = buildContext("NONE", true,
deviceId, deviceId,
appId, appId,
appVersion, appVersion,
@ -102,10 +121,33 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
null, null,
null, null,
null); null);
return applyLicenseContext(context, license, allowOptionalToken);
} }
throw new RuntimeException("Missing Android HTTP access token"); 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, private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId,
String appId, String appVersion, String platform, String accessToken, String appId, String appVersion, String platform, String accessToken,
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {

View File

@ -1,7 +1,9 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.mapper.DeviceLoginLogMapper;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.DeviceLoginLogEntity;
import com.imeeting.service.android.AndroidDeviceBindingService; import com.imeeting.service.android.AndroidDeviceBindingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -14,6 +16,7 @@ import java.util.Objects;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService { public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService {
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final DeviceLoginLogMapper deviceLoginLogMapper;
@Override @Override
public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) { 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); 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) { private String normalize(String value) {
if (!StringUtils.hasText(value)) { if (!StringUtils.hasText(value)) {
return null; return null;

View File

@ -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<byte[]> 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<byte[]> 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<byte[]> 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);
}
}
}

View File

@ -1,11 +1,18 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.LicenseEntity;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidDeviceRegistrationService; 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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -14,35 +21,49 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegistrationService { public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegistrationService {
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final SysTenantMapper sysTenantMapper;
private final LicenseService licenseService;
@Override @Override
public AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion) { @Transactional(rollbackFor = Exception.class)
if (!StringUtils.hasText(deviceCode)) { public AndroidDeviceRegisterResponse register(String tenantCode, String deviceCode, String deviceName, String terminalType, String terminalVersion) {
throw new RuntimeException("deviceId不能为空"); 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(); String normalizedDeviceCode = deviceCode.trim();
licenseService.validateDeviceCanRegisterToTenant(normalizedDeviceCode, tenant.getId());
DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(normalizedDeviceCode); DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(normalizedDeviceCode);
if (existing == null) { if (existing == null) {
existing = new DeviceInfoEntity(); existing = new DeviceInfoEntity();
existing.setDeviceCode(normalizedDeviceCode); existing.setDeviceCode(normalizedDeviceCode);
existing.setDeviceName(normalize(deviceName));
existing.setTerminalType(normalizeTerminalType(terminalType));
existing.setTerminalVersion(normalize(terminalVersion));
existing.setLastOnlineAt(LocalDateTime.now());
existing.setStatus(1); 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); deviceInfoMapper.insert(existing);
} else { } else {
existing.setDeviceName(normalize(deviceName)); deviceInfoMapper.updateById(existing);
existing.setTerminalType(normalizeTerminalType(terminalType));
existing.setTerminalVersion(normalize(terminalVersion));
deviceInfoMapper.updateBaseInfoByIdIgnoreTenant(existing);
} }
LicenseEntity license = licenseService.allocateForDeviceRegistration(tenant.getId(), tenant.getTenantCode(), normalizedDeviceCode);
AndroidDeviceRegisterResponse response = new AndroidDeviceRegisterResponse(); AndroidDeviceRegisterResponse response = new AndroidDeviceRegisterResponse();
response.setDeviceCode(existing.getDeviceCode()); response.setDeviceCode(existing.getDeviceCode());
response.setDeviceName(existing.getDeviceName()); response.setDeviceName(existing.getDeviceName());
response.setTerminalType(existing.getTerminalType()); response.setTerminalType(existing.getTerminalType());
response.setTerminalVersion(existing.getTerminalVersion()); response.setTerminalVersion(existing.getTerminalVersion());
response.setOccupied(existing.getUserId() != null); response.setOccupied(existing.getUserId() != null);
response.setLicenseType(license.getLicenseType());
return response; return response;
} }
@ -53,6 +74,17 @@ public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegist
} }
} }
private SysTenant requireTenant(String tenantCode) {
SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper<SysTenant>()
.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) { private String normalize(String value) {
if (!StringUtils.hasText(value)) { if (!StringUtils.hasText(value)) {
return null; return null;

View File

@ -18,4 +18,8 @@ public interface DeviceOnlineManagementService {
DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser); DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser);
boolean kick(Long id, LoginUser loginUser); boolean kick(Long id, LoginUser loginUser);
boolean delete(Long id, LoginUser loginUser);
boolean resetStats(Long id, LoginUser loginUser);
} }

View File

@ -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<LicenseVO> listCurrentTenantLicenses(LoginUser loginUser);
}

View File

@ -159,7 +159,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
@Override @Override
public void triggerQueuedAsrScheduling() { public void triggerQueuedAsrScheduling() {
scheduleQueuedAsrTasks(); taskSecurityContextRunner.callAsPlatformAdmin(() -> {
scheduleQueuedAsrTasks();
return null;
});
} }
@Override @Override

View File

@ -9,9 +9,11 @@ import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.biz.DeviceOnlineManagementService; import com.imeeting.service.biz.DeviceOnlineManagementService;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.Instant; import java.time.Instant;
@ -26,6 +28,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidDeviceSessionService androidDeviceSessionService;
private final AndroidGatewayPushService androidGatewayPushService; private final AndroidGatewayPushService androidGatewayPushService;
private final LicenseService licenseService;
@Override @Override
public void recordConnected(AndroidAuthContext authContext) { public void recordConnected(AndroidAuthContext authContext) {
@ -43,7 +46,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
existing.setLastOnlineAt(now); existing.setLastOnlineAt(now);
existing.setUserId(authContext.getUserId()); existing.setUserId(authContext.getUserId());
existing.setTenantId(authContext.getTenantId()); existing.setTenantId(authContext.getTenantId());
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); deviceInfoMapper.updateById(existing);
} }
@Override @Override
@ -78,6 +81,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
public DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser) { public DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser) {
DeviceInfoEntity existing = requireVisibleDevice(id, loginUser); DeviceInfoEntity existing = requireVisibleDevice(id, loginUser);
existing.setDeviceName(normalize(command.getDeviceName())); existing.setDeviceName(normalize(command.getDeviceName()));
existing.setWeatherCityName(normalize(command.getWeatherCityName()));
boolean disableAfterUpdate = command.getStatus() != null && command.getStatus() == 0; boolean disableAfterUpdate = command.getStatus() != null && command.getStatus() == 0;
if (command.getStatus() != null) { if (command.getStatus() != null) {
existing.setStatus(command.getStatus()); existing.setStatus(command.getStatus());
@ -99,6 +103,25 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
return true; 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) { private DeviceInfoEntity requireVisibleDevice(Long id, LoginUser loginUser) {
DeviceInfoEntity existing = deviceInfoMapper.selectByIdIgnoreTenant(id); DeviceInfoEntity existing = deviceInfoMapper.selectByIdIgnoreTenant(id);
if (existing == null) { if (existing == null) {

View File

@ -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<LicenseMapper, LicenseEntity> 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<LicenseEntity> 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<LicenseImportRow> rows = parseImportRows(file);
if (rows.isEmpty()) {
throw new BusinessException("400", "导入文件不能为空");
}
expireDueLicenses();
List<LicenseEntity> boundTempLicenses = baseMapper.selectBoundTemporaryLicensesForReplace(tenantId);
if (rows.size() < boundTempLicenses.size()) {
throw new BusinessException("400", "正式授权数量不足,无法完成替换");
}
validateImportRows(tenantId, rows);
String importBatchNo = buildImportBatchNo();
LocalDateTime now = LocalDateTime.now();
List<LicenseEntity> 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<LicenseEntity> 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<LicenseVO> listCurrentTenantLicenses(LoginUser loginUser) {
return baseMapper.selectListByTenant(currentTenantId(loginUser));
}
private void validateImportRows(Long tenantId, List<LicenseImportRow> 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<LicenseImportRow> parseImportRows(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new BusinessException("400", "导入文件不能为空");
}
List<LicenseImportRow> 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();
}
}

View File

@ -52,6 +52,7 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
private static final String SCOPE_PLATFORM = "PLATFORM"; private static final String SCOPE_PLATFORM = "PLATFORM";
private static final String SCOPE_USER = "USER"; private static final String SCOPE_USER = "USER";
private static final String SCOPE_MIXED = "MIXED"; private static final String SCOPE_MIXED = "MIXED";
private static final long PLATFORM_TENANT_ID = 0L;
private static final int DEFAULT_DISPLAY_DURATION_SEC = 15; private static final int DEFAULT_DISPLAY_DURATION_SEC = 15;
private static final int MIN_DISPLAY_DURATION_SEC = 3; private static final int MIN_DISPLAY_DURATION_SEC = 3;
private static final int MAX_DISPLAY_DURATION_SEC = 3600; private static final int MAX_DISPLAY_DURATION_SEC = 3600;
@ -236,7 +237,8 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
Integer displayDurationSec = resolveDisplayDurationSec(tenantId, userId); Integer displayDurationSec = resolveDisplayDurationSec(tenantId, userId);
List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, null); List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, null);
if (userId == null) { if (userId == null) {
return new ScreenSaverSelectionResult(SCOPE_PLATFORM, displayDurationSec, toAdminVOs(platformItems, Map.of(), displayDurationSec)); List<ScreenSaver> selectedPlatformItems = platformItems.isEmpty() ? listGlobalFallbackPlatformItems() : platformItems;
return new ScreenSaverSelectionResult(SCOPE_PLATFORM, displayDurationSec, toAdminVOs(selectedPlatformItems, Map.of(), displayDurationSec));
} }
Map<Long, Integer> userStatusMap = queryUserStatusMap(tenantId, userId, extractPlatformIds(platformItems)); Map<Long, Integer> userStatusMap = queryUserStatusMap(tenantId, userId, extractPlatformIds(platformItems));
@ -249,6 +251,16 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
selected.addAll(effectivePlatformItems); selected.addAll(effectivePlatformItems);
selected.addAll(userItems); selected.addAll(userItems);
selected.sort(SCREEN_SAVER_ORDER); selected.sort(SCREEN_SAVER_ORDER);
if (selected.isEmpty()) {
List<ScreenSaver> fallbackPlatformItems = listGlobalFallbackPlatformItems();
if (!fallbackPlatformItems.isEmpty()) {
return new ScreenSaverSelectionResult(
SCOPE_PLATFORM,
displayDurationSec,
toAdminVOs(fallbackPlatformItems, Map.of(), displayDurationSec)
);
}
}
return new ScreenSaverSelectionResult( return new ScreenSaverSelectionResult(
resolveSourceScope(effectivePlatformItems, userItems), resolveSourceScope(effectivePlatformItems, userItems),
@ -257,6 +269,13 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
); );
} }
protected List<ScreenSaver> listGlobalFallbackPlatformItems() {
if (baseMapper == null) {
return List.of();
}
return baseMapper.selectActivePlatformByTenantIgnoreTenant(PLATFORM_TENANT_ID);
}
private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) { private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) {
if (dto == null) { if (dto == null) {
return null; return null;

View File

@ -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<List<SysTenantDTO>> listTenants(Integer current, Integer size, String name, String code) {
if (tenantModeService.isSingleTenantMode()) {
SysTenantDTO defaultTenant = sysTenantService.findById(tenantModeService.getDefaultTenantId());
PageResult<List<SysTenantDTO>> 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);
}
}

View File

@ -4,6 +4,7 @@ import com.imeeting.entity.biz.AndroidPushMessage;
import com.imeeting.grpc.push.PushMessage; import com.imeeting.grpc.push.PushMessage;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.support.TaskSecurityContextRunner;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -18,30 +19,34 @@ import java.util.List;
public class AndroidPushMessageRetryTask { public class AndroidPushMessageRetryTask {
private final AndroidPushMessageService androidPushMessageService; private final AndroidPushMessageService androidPushMessageService;
private final AndroidGatewayPushService androidGatewayPushService; private final AndroidGatewayPushService androidGatewayPushService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}") @Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}")
public void retryPendingMessages() { public void retryPendingMessages() {
List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages(); taskSecurityContextRunner.callAsPlatformAdmin(() -> {
for (AndroidPushMessage message : pendingMessages) { List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages();
if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) { for (AndroidPushMessage message : pendingMessages) {
androidPushMessageService.markExpired(message.getId()); if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) {
continue; 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() return null;
.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());
}
}
} }
private String resolveMessageTitle(AndroidPushMessage message) { private String resolveMessageTitle(AndroidPushMessage message) {

View File

@ -11,14 +11,14 @@ logging:
com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl: debug com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl: debug
spring: spring:
datasource: 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} username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres} password: ${SPRING_DATASOURCE_PASSWORD:postgres}
data: data:
redis: 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} port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:} password: ${SPRING_DATA_REDIS_PASSWORD:unis@123}
database: ${SPRING_DATA_REDIS_DATABASE:15} database: ${SPRING_DATA_REDIS_DATABASE:15}
mybatis-plus: mybatis-plus:
@ -31,10 +31,10 @@ unisbase:
internal-auth: internal-auth:
secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret} secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret}
app: 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/} upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/}
imeeting: imeeting:
h5: 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: audio:
ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe

View File

@ -63,6 +63,7 @@ unisbase:
- /api/auth/** - /api/auth/**
- /api/static/** - /api/static/**
- /api/public/meetings/** - /api/public/meetings/**
- /api/android/devices/home
- /api/android/auth/login - /api/android/auth/login
- /api/android/auth/refresh - /api/android/auth/refresh
- /api/clients/latest/by-platform - /api/clients/latest/by-platform

View File

@ -1,43 +1,20 @@
//package com.imeeting.service; package com.imeeting.service;
//
//import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.service.biz.LicenseService;
// import org.junit.jupiter.api.Test;
//import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired;
//import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest;
//import org.mockito.InjectMocks; import org.springframework.test.context.ActiveProfiles;
//import org.mockito.Mock;
//import org.mockito.junit.jupiter.MockitoExtension; @SpringBootTest
// @ActiveProfiles("dev")
//import java.util.Collections;
//import java.util.List; public class DictItemServiceTest {
//
//import static org.junit.jupiter.api.Assertions.assertEquals; @Autowired
//import static org.mockito.ArgumentMatchers.any; private LicenseService licenseService;
//import static org.mockito.Mockito.when; @Test
// public void main(){
//@ExtendWith(MockitoExtension.class) licenseService.initializeTemporaryLicenses(1L);
//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<SysDictItem> result = dictItemService.getItemsByTypeCode(typeCode);
// assertEquals(1, result.size());
// assertEquals("Male", result.get(0).getItemLabel());
// }
//}

View File

@ -61,6 +61,30 @@ class ScreenSaverServiceImplTest {
assertEquals(List.of(22, 22), result.getItems().stream().map(ScreenSaverAdminVO::getDisplayDurationSec).toList()); 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 @Test
void listForAdminShouldApplyCurrentUserStatusFilter() { void listForAdminShouldApplyCurrentUserStatusFilter() {
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);

View File

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

View File

@ -174,6 +174,16 @@ export async function kickManagedDevice(id: number) {
return resp.data.data as boolean; 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) { export async function listUserRoles(userId: number) {
const resp = await http.get(`/sys/api/users/${userId}/roles`); const resp = await http.get(`/sys/api/users/${userId}/roles`);
return resp.data.data as number[]; return resp.data.data as number[];

View File

@ -350,7 +350,12 @@
"kickDevice": "Kick device", "kickDevice": "Kick device",
"kickDeviceConfirm": "Kick this device offline?", "kickDeviceConfirm": "Kick this device offline?",
"kickSucceeded": "Device has been kicked 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?", "deleteDevice": "Delete this device?",
"weatherCityName": "Weather City",
"statsResetAt": "Stats Reset At",
"drawerTitleCreate": "New Device", "drawerTitleCreate": "New Device",
"drawerTitleEdit": "Edit Device", "drawerTitleEdit": "Edit Device",
"owner": "Owner", "owner": "Owner",
@ -358,7 +363,8 @@
"searchSelectUser": "Search and select a user", "searchSelectUser": "Search and select a user",
"deviceCodeRequired": "Enter the device code", "deviceCodeRequired": "Enter the device code",
"deviceCodePlaceholder": "Enter a unique device code", "deviceCodePlaceholder": "Enter a unique device code",
"deviceNamePlaceholder": "Example: Meeting Room A Recorder" "deviceNamePlaceholder": "Example: Meeting Room A Recorder",
"weatherCityNamePlaceholder": "Example: Shenzhen"
}, },
"dashboardExt": { "dashboardExt": {
"processing": "Processing", "processing": "Processing",

View File

@ -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 <Tag color="gold"></Tag>;
if (type === 2) return <Tag color="blue"></Tag>;
return <Tag></Tag>;
}
function resolveLicenseStatusTag(status?: number) {
switch (status) {
case 1:
return <Tag>使</Tag>;
case 2:
return <Tag color="green">使</Tag>;
case 3:
return <Tag color="red"></Tag>;
case 4:
return <Tag></Tag>;
default:
return <Tag></Tag>;
}
}
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<LicenseVO[]>([]);
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<LicenseVO> = [
{
title: "授权标识",
key: "license",
width: 320,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong className="tabular-nums">{record.licenseSerial}</Text>
{/*<Text type="secondary" className="tabular-nums">{record.licenseCode}</Text>*/}
</Space>
),
},
{
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) => <Text className="tabular-nums">{value || "-"}</Text>,
},
{
title: "过期时间",
dataIndex: "expireTime",
key: "expireTime",
width: 200,
render: (value) => <Text type="secondary">{formatDateTime(value)}</Text>,
},
];
return (
<PageContainer
title="授权码管理"
subtitle="查看当前租户授权池"
headerExtra={
<Space size={12}>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
{/*<Upload showUploadList={false} beforeUpload={(file) => { void handleImport(file as File); return Upload.LIST_IGNORE; }}>*/}
{/* <Button type="primary" icon={<UploadOutlined />} loading={uploading}>*/}
{/* 导入正式授权*/}
{/* </Button>*/}
{/*</Upload>*/}
</Space>
}
toolbar={
<Input
placeholder="搜索序列号、授权码、设备编码"
prefix={<SearchOutlined />}
allowClear
style={{ width: 360 }}
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
}
>
<Row gutter={24} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<KeyOutlined style={{ color: "#1677ff", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}></div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.total}</div>
</div>
</Space>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<LinkOutlined style={{ color: "#52c41a", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}>使</div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.using}</div>
</div>
</Space>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<CheckCircleOutlined style={{ color: "#722ed1", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}></div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.formal}</div>
</div>
</Space>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<ClockCircleOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}></div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.available}</div>
</div>
</Space>
</Card>
</Col>
</Row>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<div className="app-page__table-wrap flex-1 min-h-0" style={{ padding: "0 24px", overflow: "auto" }}>
<Table
rowKey="id"
columns={columns}
dataSource={pagedRecords}
loading={loading}
pagination={false}
scroll={{ x: 1000, y: "100%" }}
locale={{ emptyText: <Empty description="暂无授权数据" /> }}
/>
</div>
<AppPagination
current={page}
pageSize={pageSize}
total={filteredRecords.length}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</Card>
</PageContainer>
);
}

View File

@ -1,8 +1,8 @@
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Tag, Typography, message } from "antd"; 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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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 PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer"; import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable"; import ListTable from "@/components/shared/ListTable/ListTable";
@ -17,6 +17,7 @@ const { Text } = Typography;
type DeviceFormValues = { type DeviceFormValues = {
deviceName?: string; deviceName?: string;
status: number; status: number;
weatherCityName?: string;
}; };
export default function Devices() { export default function Devices() {
@ -82,7 +83,8 @@ export default function Devices() {
setEditing(record); setEditing(record);
form.setFieldsValue({ form.setFieldsValue({
deviceName: record.deviceName, deviceName: record.deviceName,
status: record.status ?? 1 status: record.status ?? 1,
weatherCityName: record.weatherCityName
}); });
setOpen(true); setOpen(true);
}; };
@ -96,7 +98,8 @@ export default function Devices() {
try { try {
await updateManagedDevice(editing.deviceId, { await updateManagedDevice(editing.deviceId, {
deviceName: values.deviceName, deviceName: values.deviceName,
status: values.status status: values.status,
weatherCityName: values.weatherCityName
}); });
message.success(t("devicesExt.operationSucceeded")); message.success(t("devicesExt.operationSucceeded"));
setOpen(false); setOpen(false);
@ -112,6 +115,18 @@ export default function Devices() {
await loadData(); 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) => { const handlePageChange = (page: number, pageSize: number) => {
setPagination({ current: page, pageSize }); setPagination({ current: page, pageSize });
}; };
@ -187,6 +202,22 @@ export default function Devices() {
</Text> </Text>
) )
}, },
{
title: t("devicesExt.statsResetAt"),
dataIndex: "statsResetAt",
width: 180,
render: (text: string) => (
<Text type="secondary" className="tabular-nums">
{text ? text.replace("T", " ").substring(0, 19) : "-"}
</Text>
)
},
{
title: t("devicesExt.weatherCityName"),
dataIndex: "weatherCityName",
width: 140,
render: (text: string) => <Text>{text || "-"}</Text>
},
{ {
title: t("common.status"), title: t("common.status"),
dataIndex: "status", dataIndex: "status",
@ -209,7 +240,7 @@ export default function Devices() {
{ {
title: t("common.action"), title: t("common.action"),
key: "action", key: "action",
width: 140, width: 220,
fixed: "right" as const, fixed: "right" as const,
render: (_value: unknown, record: DeviceInfo) => ( render: (_value: unknown, record: DeviceInfo) => (
<Space> <Space>
@ -226,6 +257,25 @@ export default function Devices() {
/> />
</Popconfirm> </Popconfirm>
) : null} ) : null}
{can("device:update") ? (
<Popconfirm title={t("devicesExt.resetStatsConfirm")} onConfirm={() => resetStats(record)}>
<Button
type="text"
icon={<UndoOutlined aria-hidden="true" />}
aria-label={t("devicesExt.resetStats")}
/>
</Popconfirm>
) : null}
{can("device:update") ? (
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record)}>
<Button
type="text"
danger
icon={<DeleteOutlined aria-hidden="true" />}
aria-label={t("devicesExt.deleteDevice")}
/>
</Popconfirm>
) : null}
</Space> </Space>
) )
} }
@ -334,6 +384,9 @@ export default function Devices() {
<Form.Item label={t("devices.deviceName")} name="deviceName"> <Form.Item label={t("devices.deviceName")} name="deviceName">
<Input placeholder={t("devicesExt.deviceNamePlaceholder")} /> <Input placeholder={t("devicesExt.deviceNamePlaceholder")} />
</Form.Item> </Form.Item>
<Form.Item label={t("devicesExt.weatherCityName")} name="weatherCityName">
<Input placeholder={t("devicesExt.weatherCityNamePlaceholder")} />
</Form.Item>
<Form.Item label={t("common.status")} name="status" initialValue={1}> <Form.Item label={t("common.status")} name="status" initialValue={1}>
<Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} /> <Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} />
</Form.Item> </Form.Item>

View File

@ -21,6 +21,7 @@ const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"))
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement")); const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement")); const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement")); const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement"));
const LicenseManagement = lazy(() => import("@/pages/business/LicenseManagement"));
import SpeakerReg from "../pages/business/SpeakerReg"; import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => { const RealtimeAsrSession = lazy(async () => {
@ -65,6 +66,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/prompts", label: "总结模板", element: <LazyPage><PromptTemplates /></LazyPage>, perm: "menu:prompt" }, { path: "/prompts", label: "总结模板", element: <LazyPage><PromptTemplates /></LazyPage>, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <LazyPage><AiModels /></LazyPage>, perm: "menu:aimodel" }, { path: "/aimodels", label: "模型配置", element: <LazyPage><AiModels /></LazyPage>, perm: "menu:aimodel" },
{ path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" }, { path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" },
{ path: "/licenses", label: "授权码管理", element: <LazyPage><LicenseManagement /></LazyPage>, perm: "menu:licenses" },
{ path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" }, { path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" },
{ path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" }, { path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" },
{ path: "/meeting-points", label: "积分管理", element: <LazyPage><MeetingPointsManagement /></LazyPage>, perm: "menu:meeting-points" }, { path: "/meeting-points", label: "积分管理", element: <LazyPage><MeetingPointsManagement /></LazyPage>, perm: "menu:meeting-points" },

View File

@ -92,6 +92,10 @@ export interface DeviceInfo extends BaseEntity {
terminalVersion?: string; terminalVersion?: string;
online?: boolean; online?: boolean;
lastOnlineAt?: string; lastOnlineAt?: string;
statsResetAt?: string;
weatherCityName?: string;
username?: string;
displayName?: string;
} }
export interface SysDictType extends BaseEntity { export interface SysDictType extends BaseEntity {