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;
}
public static String androidDeviceWeatherKey(String cityName) {
return "biz:android:device:weather:" + cityName;
}
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type";

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_INITIAL_BALANCE = "meeting.points.initial_balance";
public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode";
public static final String LICENSE_TEMP_DEFAULT_COUNT = "license.temp.default.count";
public static final String LICENSE_TEMP_DEFAULT_EXPIRE_MONTHS = "license.temp.default.expire.months";
public static final String LICENSE_DEFAULT_PRODUCT_CODE = "license.default.product.code";
public static final String DEVICE_WEATHER_QWEATHER_BASE_URL = "device.weather.qweather.base_url";
public static final String DEVICE_WEATHER_QWEATHER_KEY = "device.weather.qweather.key";
}

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,
platform
);
androidDeviceBindingService.recordLogin(
deviceId.trim(),
response.getCurrentTenantId(),
response.getUser().getUserId()
);
}
return ApiResponse.ok(response);
}

View File

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

View File

@ -1,9 +1,11 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceHomeStatsVO;
import com.imeeting.dto.android.AndroidDeviceRegisterRequest;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceHomeService;
import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
@ -16,6 +18,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@ -27,8 +31,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@Slf4j
public class AndroidDeviceController {
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
private final AndroidAuthService androidAuthService;
private final AndroidDeviceRegistrationService androidDeviceRegistrationService;
private final AndroidDeviceHomeService androidDeviceHomeService;
@Operation(summary = "设备自注册")
@ApiResponses({
@ -42,14 +49,46 @@ public class AndroidDeviceController {
@Anonymous
public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request,
@RequestBody(required = false) AndroidDeviceRegisterRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command);
if (command == null) {
throw new IllegalArgumentException("注册请求不能为空");
}
String tenantCode = resolveTenantCode(request, command);
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command, "tenantCode", tenantCode);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false);
AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register(
tenantCode,
authContext.getDeviceId(),
command == null ? null : command.getDeviceName(),
command == null ? authContext.getPlatform() : command.getTerminalType(),
command == null ? authContext.getAppVersion() : command.getTerminalVersion()
command.getDeviceName(),
command.getTerminalType() == null ? authContext.getPlatform() : command.getTerminalType(),
command.getTerminalVersion() == null ? authContext.getAppVersion() : command.getTerminalVersion()
);
return ApiResponse.ok(response);
}
@Operation(summary = "查询设备首页统计")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回设备首页统计信息",
content = @Content(schema = @Schema(implementation = AndroidDeviceHomeStatsVO.class))
)
})
@GetMapping("/home")
@Anonymous
public ApiResponse<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 lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@ -58,6 +59,18 @@ public class DeviceManagementController {
return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser()));
}
@Operation(summary = "删除设备并解绑授权")
@DeleteMapping("/{id}")
public ApiResponse<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() {
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
@Schema(description = "Android设备注册请求")
public class AndroidDeviceRegisterRequest {
@Schema(description = "租户编码", requiredMode = Schema.RequiredMode.REQUIRED)
private String tenantCode;
@Schema(description = "设备名称")
private String deviceName;

View File

@ -20,4 +20,7 @@ public class AndroidDeviceRegisterResponse {
@Schema(description = "是否已被用户占用")
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停用")
private Integer status;
@Schema(description = "设备所在天气城市")
private String weatherCityName;
}

View File

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

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

View File

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

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.Select;
import java.time.LocalDateTime;
@Mapper
public interface MeetingMapper extends BaseMapper<Meeting> {
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0")
Meeting selectByIdIgnoreTenant(@Param("id") Long id);
@InterceptorIgnore(tenantLine = "true")
@Select("""
<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;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.ScreenSaver;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ScreenSaverMapper extends BaseMapper<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, 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 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;
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);
}

View File

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

View File

@ -1,7 +1,9 @@
package com.imeeting.service.android.impl;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.mapper.DeviceLoginLogMapper;
import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.DeviceLoginLogEntity;
import com.imeeting.service.android.AndroidDeviceBindingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -14,6 +16,7 @@ import java.util.Objects;
@RequiredArgsConstructor
public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService {
private final DeviceInfoMapper deviceInfoMapper;
private final DeviceLoginLogMapper deviceLoginLogMapper;
@Override
public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) {
@ -61,6 +64,20 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
}
@Override
public void recordLogin(String deviceCode, Long tenantId, Long userId) {
if (!StringUtils.hasText(deviceCode) || tenantId == null || userId == null) {
return;
}
DeviceLoginLogEntity entity = new DeviceLoginLogEntity();
entity.setTenantId(tenantId);
entity.setStatus(1);
entity.setDeviceCode(deviceCode.trim());
entity.setUserId(userId);
entity.setLoginAt(LocalDateTime.now());
deviceLoginLogMapper.insert(entity);
}
private String normalize(String value) {
if (!StringUtils.hasText(value)) {
return null;

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

View File

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

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
public void triggerQueuedAsrScheduling() {
scheduleQueuedAsrTasks();
taskSecurityContextRunner.callAsPlatformAdmin(() -> {
scheduleQueuedAsrTasks();
return null;
});
}
@Override

View File

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

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_USER = "USER";
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 MIN_DISPLAY_DURATION_SEC = 3;
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);
List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, 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));
@ -249,6 +251,16 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
selected.addAll(effectivePlatformItems);
selected.addAll(userItems);
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(
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) {
if (dto == 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.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.support.TaskSecurityContextRunner;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
@ -18,30 +19,34 @@ import java.util.List;
public class AndroidPushMessageRetryTask {
private final AndroidPushMessageService androidPushMessageService;
private final AndroidGatewayPushService androidGatewayPushService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}")
public void retryPendingMessages() {
List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages();
for (AndroidPushMessage message : pendingMessages) {
if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) {
androidPushMessageService.markExpired(message.getId());
continue;
taskSecurityContextRunner.callAsPlatformAdmin(() -> {
List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages();
for (AndroidPushMessage message : pendingMessages) {
if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) {
androidPushMessageService.markExpired(message.getId());
continue;
}
PushMessage pushMessage = PushMessage.newBuilder()
.setMessageId(message.getMessageId())
.setTimestamp(System.currentTimeMillis())
.setType(message.getMessageType())
.setTitle(resolveMessageTitle(message))
.setContent(message.getPayload() == null ? "" : message.getPayload())
.setNeedAck(true)
.build();
int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage);
if (pushed > 0) {
androidPushMessageService.markPushed(message.getId());
log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true",
message.getMessageId(), message.getDeviceCode());
}
}
PushMessage pushMessage = PushMessage.newBuilder()
.setMessageId(message.getMessageId())
.setTimestamp(System.currentTimeMillis())
.setType(message.getMessageType())
.setTitle(resolveMessageTitle(message))
.setContent(message.getPayload() == null ? "" : message.getPayload())
.setNeedAck(true)
.build();
int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage);
if (pushed > 0) {
androidPushMessageService.markPushed(message.getId());
log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true",
message.getMessageId(), message.getDeviceCode());
}
}
return null;
});
}
private String resolveMessageTitle(AndroidPushMessage message) {

View File

@ -11,14 +11,14 @@ logging:
com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl: debug
spring:
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_db}
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.53.199:5432/imeeting_dev}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres}
data:
redis:
host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
host: ${SPRING_DATA_REDIS_HOST:10.100.53.199}
port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:}
password: ${SPRING_DATA_REDIS_PASSWORD:unis@123}
database: ${SPRING_DATA_REDIS_DATABASE:15}
mybatis-plus:
@ -31,10 +31,10 @@ unisbase:
internal-auth:
secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret}
app:
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}}
server-base-url: ${APP_SERVER_BASE_URL:http://10.100.52.13:${server.port}}
upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/}
imeeting:
h5:
base-url: ${IMEETING_H5_BASE_URL:http://127.0.0.1:3000}
base-url: ${IMEETING_H5_BASE_URL:http://10.100.52.13:3000}
audio:
ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe

View File

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

View File

@ -1,43 +1,20 @@
//package com.imeeting.service;
//
//import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
//
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.api.extension.ExtendWith;
//import org.mockito.InjectMocks;
//import org.mockito.Mock;
//import org.mockito.junit.jupiter.MockitoExtension;
//
//import java.util.Collections;
//import java.util.List;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.mockito.ArgumentMatchers.any;
//import static org.mockito.Mockito.when;
//
//@ExtendWith(MockitoExtension.class)
//public class DictItemServiceTest {
//
// @Mock
// private SysDictItemMapper dictItemMapper;
//
// @InjectMocks
// private SysDictItemServiceImpl dictItemService;
//
// @Test
// void testGetItemsByTypeCode() {
// String typeCode = "gender";
// SysDictItem item = new SysDictItem();
// item.setTypeCode(typeCode);
// item.setItemLabel("Male");
// item.setItemValue("1");
// item.setStatus(1);
// item.setSortOrder(1);
//
// when(dictItemMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(item));
//
// List<SysDictItem> result = dictItemService.getItemsByTypeCode(typeCode);
// assertEquals(1, result.size());
// assertEquals("Male", result.get(0).getItemLabel());
// }
//}
package com.imeeting.service;
import com.imeeting.service.biz.LicenseService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("dev")
public class DictItemServiceTest {
@Autowired
private LicenseService licenseService;
@Test
public void main(){
licenseService.initializeTemporaryLicenses(1L);
}
}

View File

@ -61,6 +61,30 @@ class ScreenSaverServiceImplTest {
assertEquals(List.of(22, 22), result.getItems().stream().map(ScreenSaverAdminVO::getDisplayDurationSec).toList());
}
@Test
void getActiveSelectionShouldFallbackToPlatformTenantItemsWhenTenantSelectionIsEmpty() {
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);
ScreenSaverUserSettingsMapper userSettingsMapper = mock(ScreenSaverUserSettingsMapper.class);
SysUserMapper sysUserMapper = mock(SysUserMapper.class);
when(userConfigMapper.selectList(any())).thenReturn(List.of());
when(userSettingsMapper.selectOne(any())).thenReturn(userSettings(77L, 22));
when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of());
ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, userSettingsMapper, sysUserMapper));
doReturn(List.of())
.doReturn(List.of())
.when(service).list(any(LambdaQueryWrapper.class));
doReturn(List.of(screenSaver(301L, "PLATFORM", null, 1, 1)))
.when(service).listGlobalFallbackPlatformItems();
ScreenSaverSelectionResult result = service.getActiveSelection(77L);
assertEquals("PLATFORM", result.getSourceScope());
assertEquals(22, result.getDisplayDurationSec());
assertEquals(List.of(301L), result.getItems().stream().map(ScreenSaverAdminVO::getId).toList());
assertEquals(List.of(1), result.getItems().stream().map(ScreenSaverAdminVO::getStatus).toList());
}
@Test
void listForAdminShouldApplyCurrentUserStatusFilter() {
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);

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;
}
export async function deleteManagedDevice(id: number) {
const resp = await http.delete(`/api/admin/devices/${id}`);
return resp.data.data as boolean;
}
export async function resetManagedDeviceStats(id: number) {
const resp = await http.post(`/api/admin/devices/${id}/reset`);
return resp.data.data as boolean;
}
export async function listUserRoles(userId: number) {
const resp = await http.get(`/sys/api/users/${userId}/roles`);
return resp.data.data as number[];

View File

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

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 { CheckCircleOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UserOutlined } from "@ant-design/icons";
import { CheckCircleOutlined, DeleteOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UndoOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { kickManagedDevice, listManagedDevices, updateManagedDevice } from "@/api";
import { deleteManagedDevice, kickManagedDevice, listManagedDevices, resetManagedDeviceStats, updateManagedDevice } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable";
@ -17,6 +17,7 @@ const { Text } = Typography;
type DeviceFormValues = {
deviceName?: string;
status: number;
weatherCityName?: string;
};
export default function Devices() {
@ -82,7 +83,8 @@ export default function Devices() {
setEditing(record);
form.setFieldsValue({
deviceName: record.deviceName,
status: record.status ?? 1
status: record.status ?? 1,
weatherCityName: record.weatherCityName
});
setOpen(true);
};
@ -96,7 +98,8 @@ export default function Devices() {
try {
await updateManagedDevice(editing.deviceId, {
deviceName: values.deviceName,
status: values.status
status: values.status,
weatherCityName: values.weatherCityName
});
message.success(t("devicesExt.operationSucceeded"));
setOpen(false);
@ -112,6 +115,18 @@ export default function Devices() {
await loadData();
};
const remove = async (record: DeviceInfo) => {
await deleteManagedDevice(record.deviceId);
message.success(t("devicesExt.deleteSucceeded"));
await loadData();
};
const resetStats = async (record: DeviceInfo) => {
await resetManagedDeviceStats(record.deviceId);
message.success(t("devicesExt.resetStatsSucceeded"));
await loadData();
};
const handlePageChange = (page: number, pageSize: number) => {
setPagination({ current: page, pageSize });
};
@ -187,6 +202,22 @@ export default function Devices() {
</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"),
dataIndex: "status",
@ -209,7 +240,7 @@ export default function Devices() {
{
title: t("common.action"),
key: "action",
width: 140,
width: 220,
fixed: "right" as const,
render: (_value: unknown, record: DeviceInfo) => (
<Space>
@ -226,6 +257,25 @@ export default function Devices() {
/>
</Popconfirm>
) : 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>
)
}
@ -334,6 +384,9 @@ export default function Devices() {
<Form.Item label={t("devices.deviceName")} name="deviceName">
<Input placeholder={t("devicesExt.deviceNamePlaceholder")} />
</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}>
<Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} />
</Form.Item>

View File

@ -21,6 +21,7 @@ const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"))
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement"));
const LicenseManagement = lazy(() => import("@/pages/business/LicenseManagement"));
import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => {
@ -65,6 +66,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/prompts", label: "总结模板", element: <LazyPage><PromptTemplates /></LazyPage>, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <LazyPage><AiModels /></LazyPage>, perm: "menu:aimodel" },
{ 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: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" },
{ 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;
online?: boolean;
lastOnlineAt?: string;
statsResetAt?: string;
weatherCityName?: string;
username?: string;
displayName?: string;
}
export interface SysDictType extends BaseEntity {