Compare commits

..

3 Commits

Author SHA1 Message Date
chenhao 1cce0aeabb feat: 添加授权码管理和设备自注册功能
- 新增 `AndroidTenantProviderConfig` 配置类,提供租户解析逻辑
- 更新 `AndroidAuthService` 接口,添加 `authenticateHttp` 方法的可选参数
- 新增前端授权码管理页面 `LicenseManagement`,支持授权码列表展示和导入
- 新增 `AndroidDeviceRegistrationServiceImpl` 的 `register` 方法,支持设备自注册并验证租户代码
- 更新 `AndroidAuthServiceImpl`,在认证过程中应用授权信息
- 更新相关 DTO 和接口,支持新的授权和设备注册逻辑
2026-06-09 17:09:45 +08:00
chenhao d47a66febd fix: 修正 ffmpeg 路径和音频处理逻辑
- 更新 `application-dev.yml` 中的 `ffmpeg-path` 配置
- 在 `SpeakerServiceImpl` 中添加 `StandardCharsets.UTF_8` 编码
- 优化 `LegacyMeetingAdapterServiceImpl` 和 `MeetingDomainSupport` 中的音频处理逻辑
2026-06-09 09:22:09 +08:00
chenhao e1e321a86d feat: 添加会议进度通知和离线会议冲突处理
- 在 `MeetingProgressServiceImpl` 中添加 `notifyUnifiedStatusChangedIfNeeded` 方法,用于通知会议状态变更
- 新增 `ExistingOfflineMeetingException` 和 `AndroidOfflineMeetingConflictVO` 类,用于处理离线会议冲突
- 更新前端 `Meeting.ts` 和 `MeetingPreview.tsx`,添加统一会议状态字段和访问校验逻辑
- 新增 `AndroidPublicLoginConfirmPayload` 和 `AndroidUnifiedMeetingStatusRequest` DTO 类,用于公有设备登录确认和统一会议状态查询
2026-06-08 16:19:40 +08:00
86 changed files with 4200 additions and 493 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,13 @@
package com.imeeting.common.exception;
import lombok.Getter;
@Getter
public class ExistingOfflineMeetingException extends RuntimeException {
private final Long meetingId;
public ExistingOfflineMeetingException(Long meetingId) {
super("有未结束会议");
this.meetingId = meetingId;
}
}

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

@ -9,6 +9,8 @@ import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO;
import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
@ -17,8 +19,10 @@ import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate;
@ -93,6 +97,7 @@ public class AndroidMeetingController {
private final SysDictItemService dictItemService;
private final SysParamService paramService;
private final MeetingProgressService meetingProgressService;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
@Autowired
public AndroidMeetingController(AndroidAuthService androidAuthService,
@ -108,7 +113,8 @@ public class AndroidMeetingController {
AiModelService aiModelService,
SysDictItemService dictItemService,
SysParamService paramService,
MeetingProgressService meetingProgressService) {
MeetingProgressService meetingProgressService,
MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.androidAuthService = androidAuthService;
this.androidChunkUploadService = androidChunkUploadService;
this.legacyMeetingAdapterService = legacyMeetingAdapterService;
@ -123,6 +129,7 @@ public class AndroidMeetingController {
this.aiModelService = aiModelService;
this.paramService = paramService;
this.dictItemService = dictItemService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService;
}
@Operation(summary = "创建Android离线会议")
@ -280,6 +287,41 @@ public class AndroidMeetingController {
return new ApiResponse<>(result.getCode(), result.getMessage(), result.getData());
}
@Operation(summary = "查询Android会议统一状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回统一状态及可选内容",
content = @Content(schema = @Schema(implementation = AndroidUnifiedMeetingStatusResponse.class))
)
})
@PostMapping("/{meetingId}/status")
@Anonymous
public ApiResponse<AndroidUnifiedMeetingStatusResponse> getUnifiedStatus(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) AndroidUnifiedMeetingStatusRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "查询会议统一状态",
"meetingId", meetingId,
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser);
UnifiedMeetingStatusVO status = meetingUnifiedStatusService.resolve(meetingId);
boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript());
boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary());
List<MeetingTranscriptVO> transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null;
String summaryContent = includeSummary ? meetingQueryService.getDetailIgnoreTenant(meetingId).getSummaryContent() : null;
return ApiResponse.ok(AndroidUnifiedMeetingStatusResponse.builder()
.meetingId(meetingId)
.status(status)
.meeting(meeting)
.includesTranscript(includeTranscript)
.transcripts(transcripts)
.includesSummary(includeSummary)
.summaryContent(summaryContent)
.build());
}
@Operation(summary = "更新Android会议访问密码")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(

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

@ -31,6 +31,7 @@ import com.imeeting.service.biz.MeetingExportService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
@ -87,6 +88,7 @@ public class MeetingController {
private final MeetingProgressService meetingProgressService;
private final SysParamService sysParamService;
private final AiTaskService aiTaskService;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
@Value("${imeeting.h5.base-url:}")
private String h5BaseUrl;
@ -103,7 +105,8 @@ public class MeetingController {
MeetingAudioUploadSupport meetingAudioUploadSupport,
MeetingProgressService meetingProgressService,
SysParamService sysParamService,
AiTaskService aiTaskService) {
AiTaskService aiTaskService,
MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.meetingQueryService = meetingQueryService;
this.meetingCommandService = meetingCommandService;
this.meetingAccessService = meetingAccessService;
@ -116,6 +119,7 @@ public class MeetingController {
this.meetingProgressService = meetingProgressService;
this.sysParamService = sysParamService;
this.aiTaskService = aiTaskService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService;
}
@Operation(summary = "查询会议处理进度")
@ -139,7 +143,9 @@ public class MeetingController {
return ApiResponse.ok(Map.of("percent", 5, "message", "识别中,等待进度刷新..."));
}
}
return ApiResponse.ok(progress);
Map<String, Object> payload = new LinkedHashMap<>(progress);
payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id));
return ApiResponse.ok(payload);
}
@ -172,7 +178,9 @@ public class MeetingController {
progress = Map.of("percent", 5, "message", "识别中,等待进度刷新...");
}
}
result.put(id, progress);
Map<String, Object> payload = new LinkedHashMap<>(progress);
payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id));
result.put(id, payload);
} catch (RuntimeException ignored) {
// Ignore inaccessible meetings in batch mode.
}

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

@ -0,0 +1,13 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
@Schema(description = "Android 离线会议冲突信息")
public class AndroidOfflineMeetingConflictVO {
@Schema(description = "未结束会议ID")
private Long meetingId;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.android;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 离线会议结束请求")
public class AndroidOfflineMeetingFinishRequest {
@JsonProperty("finish_stage")
@Schema(description = "结束阶段PRE_END / UPLOAD_FINISHED")
private String finishStage;
@JsonProperty("total_chunks")
@Schema(description = "总分片数finish_stage=UPLOAD_FINISHED 时必填")
private Integer totalChunks;
}

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "公有设备扫码登录确认消息载荷")
public class AndroidPublicLoginConfirmPayload {
@Schema(description = "扫码会话ID")
private String sessionId;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "显示名称")
private String displayName;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "公有设备扫码会话结果")
public class AndroidPublicMeetingSessionResultVO {
@Schema(description = "返回模式QR_CODE / PENDING_MESSAGE")
private String mode;
@Schema(description = "二维码信息,仅 mode=QR_CODE 时返回")
private AndroidPublicMeetingSessionVO qrCode;
@Schema(description = "待处理消息,仅 mode=PENDING_MESSAGE 时返回")
private AndroidPushMessageVO message;
}

View File

@ -0,0 +1,26 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 推送消息视图")
public class AndroidPushMessageVO {
@Schema(description = "消息ID")
private String messageId;
@Schema(description = "消息时间戳,毫秒")
private Long timestamp;
@Schema(description = "消息类型")
private String type;
@Schema(description = "消息标题")
private String title;
@Schema(description = "消息内容")
private String content;
@Schema(description = "是否需要ACK")
private Boolean needAck;
}

View File

@ -0,0 +1,14 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 统一会议状态查询请求")
public class AndroidUnifiedMeetingStatusRequest {
@Schema(description = "是否附带转录内容,默认 false")
private Boolean includeTranscript;
@Schema(description = "是否附带总结内容,默认 false")
private Boolean includeSummary;
}

View File

@ -0,0 +1,36 @@
package com.imeeting.dto.android;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
@Schema(description = "Android 统一会议状态响应")
public class AndroidUnifiedMeetingStatusResponse {
@Schema(description = "会议ID")
private Long meetingId;
@Schema(description = "统一状态")
private UnifiedMeetingStatusVO status;
@Schema(description = "会议基础信息")
private MeetingVO meeting;
@Schema(description = "是否附带转录内容")
private Boolean includesTranscript;
@Schema(description = "转录内容,可选返回")
private List<MeetingTranscriptVO> transcripts;
@Schema(description = "是否附带总结内容")
private Boolean includesSummary;
@Schema(description = "总结内容,可选返回")
private String summaryContent;
}

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

@ -0,0 +1,25 @@
package com.imeeting.dto.biz;
import lombok.Getter;
@Getter
public enum UnifiedMeetingStatusStage {
WAITING_UPLOAD("WAITING_UPLOAD", "待上传录音文件", false),
INITIALIZING("INITIALIZING", "数据初始化", false),
TRANSCRIBING("TRANSCRIBING", "转译音频", false),
SUMMARIZING("SUMMARIZING", "生成总结", false),
COMPLETED("COMPLETED", "处理完成", true),
FAILED_INITIALIZING("FAILED_INITIALIZING", "数据初始化失败", true),
FAILED_TRANSCRIBING("FAILED_TRANSCRIBING", "转译音频失败", true),
FAILED_SUMMARIZING("FAILED_SUMMARIZING", "生成总结失败", true);
private final String code;
private final String text;
private final boolean terminal;
UnifiedMeetingStatusStage(String code, String text, boolean terminal) {
this.code = code;
this.text = text;
this.terminal = terminal;
}
}

View File

@ -0,0 +1,20 @@
package com.imeeting.dto.biz;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class UnifiedMeetingStatusVO {
private Long meetingId;
private String statusCode;
private String statusText;
private Integer percent;
private String message;
private Integer eta;
private String failedStageCode;
private String failedStageText;
private Boolean canViewTranscript;
private Boolean canViewAiChapters;
private Boolean canViewSummary;
}

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

@ -6,7 +6,8 @@ import lombok.Getter;
public enum MeetingPushTypeEnum {
PUBLIC_MEETING_LOGIN_CONFIRM("PUBLIC_MEETING_LOGIN_CONFIRM", "公有设备扫码登录确认消息"),
MEETING_PENDING("MEETING_PENDING", "待开始会议通知"),
MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知");
MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"),
MEETING_STATUS_CHANGED("MEETING_STATUS_CHANGED", "会议状态变更通知");
private final String code;
private final String 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

@ -8,4 +8,6 @@ public interface AndroidMeetingPushService {
void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload);
void pushMeetingCompleted(Long meetingId);
void pushMeetingStatusChanged(Long meetingId, String statusCode);
}

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

@ -2,18 +2,16 @@ package com.imeeting.service.android.impl;
import cn.hutool.json.JSONUtil;
import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingPushTypeEnum;
import com.imeeting.grpc.push.PushMessage;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.biz.MeetingQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@ -26,13 +24,15 @@ import java.util.UUID;
@Service
public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService {
private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@Autowired
@Lazy
private MeetingQueryService meetingQueryService;
@Autowired
private AndroidGatewayPushService androidGatewayPushService;
@Autowired
private AndroidPushMessageService androidPushMessageService;
private MeetingMapper meetingMapper;
@Autowired
private AndroidGatewayPushService androidGatewayPushService;
@Autowired
private AndroidPushMessageService androidPushMessageService;
@Value("${imeeting.android.push.pending-expire-minutes:30}")
private long pendingExpireMinutes;
@ -42,7 +42,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
if (meetingId == null || deviceId == null || deviceId.isBlank()) {
return;
}
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) {
return;
}
@ -89,7 +89,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
if (meetingId == null) {
return;
}
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) {
return;
}
@ -106,29 +106,51 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed);
}
private String resolvePendingTitle(MeetingVO meeting) {
@Override
public void pushMeetingStatusChanged(Long meetingId, String statusCode) {
if (meetingId == null || statusCode == null || statusCode.isBlank()) {
return;
}
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null || meeting.getSourceDeviceCode() == null || meeting.getSourceDeviceCode().isBlank()) {
return;
}
PushMessage message = PushMessage.newBuilder()
.setMessageId("meeting_status_changed:" + meetingId + ":" + UUID.randomUUID())
.setTimestamp(System.currentTimeMillis())
.setType(MeetingPushTypeEnum.MEETING_STATUS_CHANGED.getCode())
.setTitle("会议状态已更新")
.setContent(buildStatusChangedContent(meetingId, statusCode))
.setNeedAck(false)
.build();
int pushed = androidGatewayPushService.pushToDevice(meeting.getSourceDeviceCode(), message);
log.info("Android meeting status change push finished, meetingId={}, deviceId={}, statusCode={}, pushedConnections={}",
meetingId, meeting.getSourceDeviceCode(), statusCode, pushed);
}
private String resolvePendingTitle(Meeting meeting) {
String title = meeting.getTitle();
if (title != null && !title.isBlank()) {
return "待开始会议: " + title.trim();
return "待开始会议 " + title.trim();
}
LocalDateTime meetingTime = meeting.getMeetingTime();
return meetingTime == null
? "待开始会议"
: "待开始会议: " + TITLE_TIME_FORMATTER.format(meetingTime);
: "待开始会议 " + TITLE_TIME_FORMATTER.format(meetingTime);
}
private String resolveCompletedTitle(MeetingVO meeting) {
private String resolveCompletedTitle(Meeting meeting) {
String title = meeting.getTitle();
if (title != null && !title.isBlank()) {
return "会议已完成: " + title.trim();
return "会议已完成 " + title.trim();
}
LocalDateTime meetingTime = meeting.getMeetingTime();
return meetingTime == null
? "会议已完成"
: "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime);
: "会议已完成 " + TITLE_TIME_FORMATTER.format(meetingTime);
}
private String buildPendingContent(MeetingVO meeting) {
private String buildPendingContent(Meeting meeting) {
Map<String, Object> result = new HashMap<>();
result.put("meetingId", meeting.getId());
result.put("title", meeting.getTitle());
@ -139,9 +161,16 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
return JSONUtil.toJsonStr(result);
}
private String buildCompletedContent(MeetingVO meeting) {
private String buildCompletedContent(Meeting meeting) {
Map<String, Object> result = new HashMap<>();
result.put("meetingId", meeting.getId());
return JSONUtil.toJsonStr(result);
}
private String buildStatusChangedContent(Long meetingId, String statusCode) {
Map<String, Object> result = new HashMap<>();
result.put("meetingId", meetingId);
result.put("statusCode", statusCode);
return JSONUtil.toJsonStr(result);
}
}

View File

@ -178,19 +178,19 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String stagingUrl = storeStagingAudio(audioFile);
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meeting.setAudioUrl(relocatedUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
meeting.setStatus(1);
meetingService.updateById(meeting);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
meeting.setStatus(1);
meetingService.updateById(meeting);
resetOrCreateAsrTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile);
resetOrCreateSummaryTask(meetingId, profile);
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
});
resetOrCreateAsrTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile);
resetOrCreateSummaryTask(meetingId, profile);
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
});
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
}
@ -248,7 +248,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String stagingUrl = storeStagingAudio(audioFile);
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meeting.setAudioUrl(relocatedUrl);
meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);

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

@ -0,0 +1,11 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
public interface MeetingUnifiedStatusService {
UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot);
UnifiedMeetingStatusVO resolve(Long meetingId);
}

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

@ -1387,7 +1387,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
command.getUseSpkId(),
null,
null,
command.getEnableTextRefine(),
command.getEnableTextRefine() == null || command.getEnableTextRefine(),
null,
command.getHotWordGroupId(),
command.getHotWords()

View File

@ -24,6 +24,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import java.io.File;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@ -35,6 +37,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
import java.util.stream.Collectors;
@ -55,6 +58,9 @@ public class MeetingDomainSupport {
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Value("${imeeting.audio.ffmpeg-path:ffmpeg}")
private String ffmpegPath;
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
String audioUrl, String meetingType, String meetingSource,
Long tenantId, Long creatorId, String creatorName,
@ -526,11 +532,11 @@ public class MeetingDomainSupport {
}
private Integer resolveAudioDurationSecondsByUrl(String audioUrl) {
Path audioPath = resolvePublicAudioPath(audioUrl);
if (audioPath == null) {
return null;
}
try {
Path audioPath = resolvePublicAudioPath(audioUrl);
if (audioPath == null) {
return null;
}
File file = audioPath.toFile();
if (!file.exists()) {
return null;
@ -544,9 +550,70 @@ public class MeetingDomainSupport {
return (int) Math.ceil(frameLength / frameRate);
}
} catch (Exception ex) {
log.warn("Failed to resolve audio duration from audioUrl={}, skip effective duration update", audioUrl, ex);
log.warn("AudioSystem failed to resolve audio duration from audioUrl={}, fallback to ffprobe", audioUrl, ex);
}
return resolveAudioDurationSecondsByFfprobe(audioPath);
}
private Integer resolveAudioDurationSecondsByFfprobe(Path audioPath) {
if (audioPath == null || !Files.exists(audioPath)) {
return null;
}
List<String> command = List.of(
resolveFfprobePath(),
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
audioPath.toString()
);
try {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
byte[] output;
try (InputStream processStream = process.getInputStream()) {
output = processStream.readAllBytes();
}
if (!process.waitFor(30, TimeUnit.SECONDS)) {
process.destroyForcibly();
return null;
}
if (process.exitValue() != 0) {
return null;
}
String raw = new String(output, StandardCharsets.UTF_8).trim();
if (raw.isBlank()) {
return null;
}
double duration = Double.parseDouble(raw);
return duration > 0 ? (int) Math.ceil(duration) : null;
} catch (Exception ex) {
log.warn("ffprobe failed to resolve audio duration from path={}", audioPath, ex);
return null;
}
}
private String resolveFfprobePath() {
if (ffmpegPath == null || ffmpegPath.isBlank()) {
return "ffprobe";
}
String trimmed = ffmpegPath.trim();
try {
Path ffmpeg = Paths.get(trimmed);
Path fileName = ffmpeg.getFileName();
if (fileName != null) {
String normalizedName = fileName.toString().toLowerCase();
if ("ffmpeg".equals(normalizedName) || "ffmpeg.exe".equals(normalizedName)) {
Path sibling = ffmpeg.resolveSibling(normalizedName.endsWith(".exe") ? "ffprobe.exe" : "ffprobe");
if (Files.exists(sibling)) {
return sibling.toString();
}
}
}
} catch (Exception ex) {
log.debug("Failed to derive ffprobe path from ffmpegPath={}", ffmpegPath, ex);
}
return "ffprobe";
}
private Path resolvePublicAudioPath(String audioUrl) {

View File

@ -4,11 +4,15 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingProgressStage;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.support.redis.MeetingProgressCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -29,6 +33,8 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
private final AiTaskMapper aiTaskMapper;
private final MeetingProgressCache meetingProgressCache;
private final ObjectMapper objectMapper;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
private final AndroidMeetingPushService androidMeetingPushService;
@Override
public void clear(Long meetingId) {
@ -116,6 +122,60 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
return;
}
meetingProgressCache.saveSnapshot(snapshot);
notifyUnifiedStatusChangedIfNeeded(snapshot.getMeetingId(), existing, snapshot);
}
private void notifyUnifiedStatusChangedIfNeeded(Long meetingId,
MeetingProgressSnapshot existing,
MeetingProgressSnapshot candidate) {
if (meetingId == null || candidate == null) {
return;
}
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) {
return;
}
MeetingVO meetingVO = toMeetingVO(meeting);
UnifiedMeetingStatusVO currentStatus = meetingUnifiedStatusService.resolve(meetingVO, candidate);
if (currentStatus == null || currentStatus.getStatusCode() == null || currentStatus.getStatusCode().isBlank()) {
return;
}
UnifiedMeetingStatusVO previousStatus = existing == null ? null : meetingUnifiedStatusService.resolve(meetingVO, existing);
if (previousStatus != null && currentStatus.getStatusCode().equals(previousStatus.getStatusCode())) {
return;
}
androidMeetingPushService.pushMeetingStatusChanged(meetingId, currentStatus.getStatusCode());
}
private MeetingVO toMeetingVO(Meeting meeting) {
if (meeting == null) {
return null;
}
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setParticipants(meeting.getParticipants());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource());
vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword());
vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds());
vo.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt());
return vo;
}
private MeetingProgressSnapshot buildSnapshot(Long meetingId,

View File

@ -64,7 +64,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
profile.setResolvedEnablePunctuation(enablePunctuation != null ? enablePunctuation : Boolean.TRUE);
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine));
profile.setResolvedEnableTextRefine(enableTextRefine==null|| enableTextRefine);
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
profile.setResolvedHotWords(resolveHotWords(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords));
return profile;

View File

@ -0,0 +1,281 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusStage;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptChapterVersionMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.support.redis.MeetingProgressCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusService {
private final MeetingMapper meetingMapper;
private final AiTaskMapper aiTaskMapper;
private final MeetingTranscriptMapper meetingTranscriptMapper;
private final MeetingTranscriptChapterVersionMapper chapterVersionMapper;
private final MeetingProgressCache meetingProgressCache;
@Override
public UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot) {
if (meeting == null || meeting.getId() == null) {
return null;
}
UnifiedMeetingStatusStage stage = resolveStage(meeting, snapshot);
UnifiedMeetingStatusStage failedStage = resolveFailedStage(meeting);
boolean failed = failedStage != null;
UnifiedMeetingStatusStage effectiveStage = failed ? failedStage : stage;
return UnifiedMeetingStatusVO.builder()
.meetingId(meeting.getId())
.statusCode(effectiveStage.getCode())
.statusText(effectiveStage.getText())
.percent(resolvePercent(snapshot, effectiveStage))
.message(resolveMessage(meeting, snapshot, effectiveStage))
.eta(snapshot == null ? null : snapshot.getEta())
.failedStageCode(failedStage == null ? null : failedStage.getCode())
.failedStageText(failedStage == null ? null : failedStage.getText())
.canViewTranscript(canViewTranscript(meeting.getId()))
.canViewAiChapters(canViewAiChapters(meeting.getId()))
.canViewSummary(canViewSummary(meeting))
.build();
}
@Override
public UnifiedMeetingStatusVO resolve(Long meetingId) {
if (meetingId == null) {
return null;
}
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) {
return null;
}
return resolve(toMeetingVO(meeting), meetingProgressCache.getSnapshot(meetingId));
}
private MeetingUnifiedStageContext buildStageContext(Long meetingId, MeetingProgressSnapshot snapshot) {
AiTask latestAsr = findLatestTask(meetingId, "ASR");
AiTask latestChapter = findLatestTask(meetingId, "CHAPTER");
AiTask latestSummary = findLatestTask(meetingId, "SUMMARY");
return new MeetingUnifiedStageContext(latestAsr, latestChapter, latestSummary, snapshot);
}
private UnifiedMeetingStatusStage resolveStage(MeetingVO meeting, MeetingProgressSnapshot snapshot) {
if (meeting == null) {
return UnifiedMeetingStatusStage.INITIALIZING;
}
if (Integer.valueOf(3).equals(meeting.getStatus())) {
return UnifiedMeetingStatusStage.COMPLETED;
}
UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot);
if (stageFromSnapshot != null) {
return stageFromSnapshot;
}
MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot);
if (isAndroidOfflineMeetingWaitingUpload(meeting)) {
return UnifiedMeetingStatusStage.WAITING_UPLOAD;
}
if (isTranscribing(context)) {
return UnifiedMeetingStatusStage.TRANSCRIBING;
}
if (isSummarizing(context)) {
return UnifiedMeetingStatusStage.SUMMARIZING;
}
return UnifiedMeetingStatusStage.INITIALIZING;
}
private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot) {
if (snapshot == null || snapshot.getStage() == null || snapshot.getStage().isBlank()) {
return null;
}
return switch (snapshot.getStage()) {
case "failed" -> null;
case "completed" -> UnifiedMeetingStatusStage.COMPLETED;
case "summary_running", "chapter_running" -> UnifiedMeetingStatusStage.SUMMARIZING;
case "asr_running", "asr_completed", "asr_submitted" -> UnifiedMeetingStatusStage.TRANSCRIBING;
case "queued" -> UnifiedMeetingStatusStage.INITIALIZING;
default -> null;
};
}
private UnifiedMeetingStatusStage resolveFailedStage(MeetingVO meeting) {
if (meeting == null || !Integer.valueOf(4).equals(meeting.getStatus())) {
return null;
}
AiTask summaryTask = findLatestTask(meeting.getId(), "SUMMARY");
if (isTaskFailed(summaryTask)) {
return UnifiedMeetingStatusStage.FAILED_SUMMARIZING;
}
AiTask chapterTask = findLatestTask(meeting.getId(), "CHAPTER");
if (isTaskFailed(chapterTask)) {
return UnifiedMeetingStatusStage.FAILED_SUMMARIZING;
}
AiTask asrTask = findLatestTask(meeting.getId(), "ASR");
if (isTaskFailed(asrTask)) {
return UnifiedMeetingStatusStage.FAILED_TRANSCRIBING;
}
return UnifiedMeetingStatusStage.FAILED_INITIALIZING;
}
private boolean isAndroidOfflineMeetingWaitingUpload(MeetingVO meeting) {
return meeting != null
&& MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType())
&& MeetingConstants.SOURCE_ANDROID.equalsIgnoreCase(meeting.getMeetingSource())
&& !MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(meeting.getOfflineRecordingStatus());
}
private boolean isSummarizing(MeetingUnifiedStageContext context) {
return isTaskRunning(context.summaryTask())
|| isTaskRunning(context.chapterTask())
|| isTaskCompleted(context.chapterTask())
|| isTaskCompleted(context.summaryTask());
}
private boolean isTranscribing(MeetingUnifiedStageContext context) {
if (isTaskRunningOrQueued(context.summaryTask()) || isTaskRunningOrQueued(context.chapterTask())) {
return false;
}
return isTaskRunningOrQueued(context.asrTask()) || isTaskCompleted(context.asrTask());
}
private Integer resolvePercent(MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) {
if (snapshot != null && snapshot.getPercent() != null) {
return snapshot.getPercent();
}
return switch (stage) {
case WAITING_UPLOAD -> 0;
case INITIALIZING -> 5;
case TRANSCRIBING -> 50;
case SUMMARIZING -> 90;
case COMPLETED -> 100;
case FAILED_INITIALIZING, FAILED_TRANSCRIBING, FAILED_SUMMARIZING -> -1;
};
}
private String resolveMessage(MeetingVO meeting, MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) {
if (snapshot != null && snapshot.getMessage() != null && !snapshot.getMessage().isBlank() && !Objects.equals(snapshot.getMessage(), "Waiting...")) {
return snapshot.getMessage();
}
if (stage == UnifiedMeetingStatusStage.FAILED_INITIALIZING
|| stage == UnifiedMeetingStatusStage.FAILED_TRANSCRIBING
|| stage == UnifiedMeetingStatusStage.FAILED_SUMMARIZING) {
return resolveFailureMessage(meeting);
}
return switch (stage) {
case WAITING_UPLOAD -> "待上传录音文件";
case INITIALIZING -> "数据初始化";
case TRANSCRIBING -> "转译音频";
case SUMMARIZING -> "生成总结";
case COMPLETED -> "处理完成";
case FAILED_INITIALIZING -> "数据初始化失败";
case FAILED_TRANSCRIBING -> "转译音频失败";
case FAILED_SUMMARIZING -> "生成总结失败";
};
}
private String resolveFailureMessage(MeetingVO meeting) {
if (meeting == null) {
return "处理失败";
}
if (meeting.getLatestSummaryAttemptErrorMsg() != null && !meeting.getLatestSummaryAttemptErrorMsg().isBlank()) {
return meeting.getLatestSummaryAttemptErrorMsg();
}
if (meeting.getLatestChapterAttemptErrorMsg() != null && !meeting.getLatestChapterAttemptErrorMsg().isBlank()) {
return meeting.getLatestChapterAttemptErrorMsg();
}
return "处理失败";
}
private boolean canViewTranscript(Long meetingId) {
return meetingId != null && meetingTranscriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)) > 0;
}
private boolean canViewAiChapters(Long meetingId) {
return meetingId != null && chapterVersionMapper.selectCount(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
.eq(MeetingTranscriptChapterVersion::getStatus, 2)) > 0;
}
private boolean canViewSummary(MeetingVO meeting) {
return meeting != null && meeting.getSummaryContent() != null && !meeting.getSummaryContent().isBlank();
}
private AiTask findLatestTask(Long meetingId, String taskType) {
return aiTaskMapper.selectOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, taskType)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private boolean isTaskRunningOrQueued(AiTask task) {
return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus()));
}
private boolean isTaskRunning(AiTask task) {
return task != null && Integer.valueOf(1).equals(task.getStatus());
}
private boolean isTaskCompleted(AiTask task) {
return task != null && Integer.valueOf(2).equals(task.getStatus());
}
private boolean isTaskFailed(AiTask task) {
return task != null && Integer.valueOf(3).equals(task.getStatus());
}
private MeetingVO toMeetingVO(Meeting meeting) {
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setParticipants(meeting.getParticipants());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource());
vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword());
vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds());
vo.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt());
return vo;
}
private record MeetingUnifiedStageContext(AiTask asrTask,
AiTask chapterTask,
AiTask summaryTask,
MeetingProgressSnapshot snapshot) {
}
}

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

@ -24,6 +24,7 @@ import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@ -51,6 +52,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
private final AiModelService aiModelService;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
@ -245,12 +247,13 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
if (speaker.getUserId() != null) {
body.put("user_id", String.valueOf(speaker.getUserId()));
}
body.put("file_url", buildFileUrl(speaker.getVoicePath()));
body.put("audio_address", buildFileUrl(speaker.getVoicePath()));
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body),
StandardCharsets.UTF_8));
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) {
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());

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

@ -480,6 +480,19 @@ export interface MeetingProgress {
eta?: number;
queueAheadCount?: number;
queuedAt?: string;
unifiedStatus?: {
meetingId: number;
statusCode: string;
statusText: string;
percent?: number;
message?: string;
eta?: number;
failedStageCode?: string;
failedStageText?: string;
canViewTranscript?: boolean;
canViewAiChapters?: boolean;
canViewSummary?: boolean;
};
}
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {

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,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
import {
@ -241,7 +241,7 @@ const extractSection = (markdown: string, aliases: string[]) => {
const parseBulletList = (content?: string | null) =>
splitLines(content)
.map((line) => line.replace(/^[-*•\s]+/, '').replace(/^\d+[.)]\s*/, '').trim())
.map((line) => line.replace(/^[-*鈥s]+/, '').replace(/^\d+[.)]\s*/, '').trim())
.filter(Boolean);
const parseOverviewSection = (markdown: string) =>
@ -250,7 +250,7 @@ const parseOverviewSection = (markdown: string) =>
const parseKeywordsSection = (markdown: string, tags: string) => {
const section = extractSection(markdown, ['关键词', '关键字', '标签']);
const fromSection = parseBulletList(section)
.flatMap((line) => line.split(/[、]/))
.flatMap((line) => line.split(/[,/]/))
.map((item) => item.trim())
.filter(Boolean);
@ -371,8 +371,27 @@ type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal
const meetingProgressTerminalRefreshCache = new Map<number, string>();
const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>();
const DETAIL_STAGE_STEP_ITEMS = [
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
{ code: 'SUMMARIZING', label: '生成总结', hint: '完成 AI 内容处理' },
{ code: 'COMPLETED', label: '处理完成', hint: '已全部完成' },
] as const;
const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => {
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
if (unifiedStatusCode?.startsWith('FAILED_') || unifiedStatusCode === 'COMPLETED') {
return 'terminal';
}
if (unifiedStatusCode === 'SUMMARIZING') {
return 'summary';
}
if (unifiedStatusCode === 'TRANSCRIBING') {
return 'asr';
}
if (unifiedStatusCode === 'INITIALIZING' || unifiedStatusCode === 'WAITING_UPLOAD') {
return 'queued';
}
const percent = progress?.percent ?? 0;
if (percent < 0 || percent >= 100) {
return 'terminal';
@ -467,6 +486,9 @@ const MeetingProgressDisplay: React.FC<{
const percent = progress?.percent || 0;
const isError = percent < 0;
const unifiedStatusText = progress?.unifiedStatus?.statusText;
const unifiedStatusMessage = progress?.unifiedStatus?.message;
const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中');
const formatEta = (seconds?: number) => {
if (!seconds || seconds <= 0) return '计算中';
@ -497,7 +519,7 @@ const MeetingProgressDisplay: React.FC<{
}}
>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{progress?.message || '正在生成版总结...'}
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在生成新结...'}
</Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`}
@ -543,10 +565,10 @@ const MeetingProgressDisplay: React.FC<{
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
{progress?.message || '正在分析内容...'}
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在分析内容...'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{isError ? '--' : formatEta(progress?.eta)}
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
</div>
@ -580,7 +602,7 @@ const MeetingProgressDisplay: React.FC<{
/>
<div style={{ marginTop: 32 }}>
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
{progress?.message || '正在准备计算资源...'}
{unifiedStatusMessage || unifiedStatusText || progress?.message || '正在准备计算资源...'}
</Text>
<Text type="secondary"></Text>
</div>
@ -606,7 +628,7 @@ const MeetingProgressDisplay: React.FC<{
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '已中断' : '正常'}
{isError ? '失败' : '正常'}
</Title>
</Space>
</Col>
@ -616,6 +638,358 @@ const MeetingProgressDisplay: React.FC<{
);
};
const DETAIL_STAGE_STEP_DISPLAY_ITEMS = [
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
{ code: 'SUMMARIZING', label: '生成总结', hint: '生成总结与 AI 目录' },
{ code: 'COMPLETED', label: '处理完成', hint: '全部任务处理完成' },
] as const;
const UnifiedMeetingProgressDisplay: React.FC<{
progress: MeetingProgress | null;
compact?: boolean;
inline?: boolean;
}> = ({ progress, compact, inline }) => {
const percent = progress?.percent || 0;
const isError = percent < 0;
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
const unifiedStatusText = progress?.unifiedStatus?.statusText;
const unifiedStatusMessage = progress?.unifiedStatus?.message;
const failedStageCode = progress?.unifiedStatus?.failedStageCode;
const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中');
const formatEta = (seconds?: number) => {
if (!seconds || seconds <= 0) return '计算中';
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`;
};
const resolveStageCode = () => {
if (failedStageCode) {
return failedStageCode;
}
if (unifiedStatusCode === 'WAITING_UPLOAD' || unifiedStatusCode === 'INITIALIZING') {
return 'INITIALIZING';
}
if (unifiedStatusCode === 'TRANSCRIBING') {
return 'TRANSCRIBING';
}
if (unifiedStatusCode === 'SUMMARIZING') {
return 'SUMMARIZING';
}
if (unifiedStatusCode === 'COMPLETED') {
return 'COMPLETED';
}
const phase = resolveProgressPhase(progress);
if (phase === 'queued') {
return 'INITIALIZING';
}
if (phase === 'asr') {
return 'TRANSCRIBING';
}
if (phase === 'chapter' || phase === 'summary') {
return 'SUMMARIZING';
}
return 'COMPLETED';
};
const currentStageCode = resolveStageCode();
const currentStageIndex = Math.max(0, DETAIL_STAGE_STEP_DISPLAY_ITEMS.findIndex((item) => item.code === currentStageCode));
const isFailedStage = unifiedStatusCode?.startsWith('FAILED_') || isError;
const isCompletedStage = unifiedStatusCode === 'COMPLETED' || percent === 100;
const progressText = isError ? '--' : `${Math.max(percent, 0)}%`;
const currentStageLabel = DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText;
const currentStageDisplay = isFailedStage ? `${currentStageLabel}失败` : currentStageLabel;
const helperText = unifiedStatusMessage || progress?.message || (isError ? '当前阶段执行失败,请稍后重试。' : '阶段状态已与安卓端统一。');
const renderStageTimeline = (options?: {
compact?: boolean;
inline?: boolean;
}) => {
const compactMode = options?.compact;
const inlineMode = options?.inline;
const containerStyle: React.CSSProperties = inlineMode
? {
marginTop: 10,
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
}
: compactMode
? {
marginTop: 18,
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
width: '100%',
maxWidth: 560,
}
: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
};
return (
<div style={containerStyle}>
{DETAIL_STAGE_STEP_DISPLAY_ITEMS.map((item, index) => {
const isCurrent = index === currentStageIndex;
const isDone = isCompletedStage || (!isFailedStage && index < currentStageIndex);
const isFailed = isFailedStage && isCurrent;
const dotBg = isFailed ? '#ff4d4f' : isDone ? '#52c41a' : isCurrent ? '#4f46e5' : '#d9d9d9';
const textColor = isFailed ? '#cf1322' : isDone || isCurrent ? '#1f1f1f' : '#8c8c8c';
const cardPadding = inlineMode ? '8px 6px' : compactMode ? '10px 8px' : '0';
const cardRadius = inlineMode || compactMode ? 12 : 0;
const cardBorder = inlineMode || compactMode
? `1px solid ${isFailed ? '#ffccc7' : isCurrent ? '#c7d2fe' : '#e5e7eb'}`
: 'none';
const cardBackground = inlineMode || compactMode
? isFailed ? '#fff2f0' : isCurrent ? '#eef2ff' : isDone ? '#f6ffed' : '#fafafa'
: 'transparent';
return (
<React.Fragment key={item.code}>
<div
style={{
flex: 1,
minWidth: 0,
padding: cardPadding,
borderRadius: cardRadius,
border: cardBorder,
background: cardBackground,
textAlign: 'center',
}}
>
<div
style={{
width: compactMode || inlineMode ? 28 : 36,
height: compactMode || inlineMode ? 28 : 36,
margin: `0 auto ${compactMode || inlineMode ? 8 : 12}px`,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: dotBg,
color: '#fff',
fontWeight: 700,
boxShadow: isCurrent ? `0 0 0 ${compactMode || inlineMode ? 4 : 6}px ${isFailed ? 'rgba(255,77,79,0.12)' : 'rgba(79,70,229,0.12)'}` : 'none',
}}
>
{isDone ? '√' : index + 1}
</div>
<div
style={{
fontSize: compactMode || inlineMode ? 12 : 15,
fontWeight: 600,
color: textColor,
marginBottom: compactMode || inlineMode ? 0 : 6,
}}
>
{isFailed && item.code === currentStageCode ? `${item.label}失败` : item.label}
</div>
{!compactMode && !inlineMode ? (
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.6 }}>
{item.hint}
</Text>
) : null}
</div>
{!inlineMode && !compactMode && index < DETAIL_STAGE_STEP_DISPLAY_ITEMS.length - 1 ? (
<div
style={{
flex: 1,
height: 2,
marginTop: 17,
background: (!isFailedStage && index < currentStageIndex) || isCompletedStage ? '#52c41a' : '#e5e7eb',
}}
/>
) : null}
</React.Fragment>
);
})}
</div>
);
};
if (inline) {
return (
<div
style={{
marginTop: 12,
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
background: isError ? '#fff2f0' : '#f7f8ff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 8,
}}
>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{helperText}
</Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`}
</Text>
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
size="small"
style={{ marginBottom: 6 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
{renderStageTimeline({ inline: true })}
</div>
);
}
if (compact) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
}}
>
<Title level={4} style={{ marginBottom: 24 }}>
{primaryStatusText}
</Title>
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
size={140}
strokeWidth={10}
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
{helperText}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
{renderStageTimeline({ compact: true })}
</div>
</div>
);
}
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: 16,
padding: 40,
}}
>
<div style={{ width: '100%', maxWidth: 760, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 8 }}>
{primaryStatusText}
</Title>
<Text type="secondary" style={{ fontSize: 15 }}>
{helperText}
</Text>
<div
style={{
marginTop: 28,
marginBottom: 32,
padding: '20px 24px',
borderRadius: 20,
background: isError ? 'linear-gradient(135deg, #fff2f0, #fff7f6)' : 'linear-gradient(135deg, #f5f7ff, #faf8ff)',
border: `1px solid ${isError ? '#ffccc7' : '#d9ddff'}`,
}}
>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
<div
style={{
marginTop: 6,
fontSize: 40,
lineHeight: 1,
fontWeight: 700,
color: isError ? '#cf1322' : '#4f46e5',
}}
>
{progressText}
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
style={{ marginTop: 18, marginBottom: 12 }}
/>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
{renderStageTimeline()}
<Divider style={{ margin: '32px 0 24px' }} />
<Row gutter={24}>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>
{DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '失败' : isCompletedStage ? '已完成' : '处理中'}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: '#4f46e5' }}>
{[
progress?.unifiedStatus?.canViewTranscript ? '转录' : null,
progress?.unifiedStatus?.canViewAiChapters ? '目录' : null,
progress?.unifiedStatus?.canViewSummary ? '总结' : null,
].filter(Boolean).join(' / ') || '暂无'}
</Title>
</Space>
</Col>
</Row>
<div style={{ marginTop: 20 }}>
<Text type="secondary"></Text>
</div>
</div>
</div>
);
};
const SpeakerEditor: React.FC<{
meetingId: number;
speakerId: string;
@ -890,16 +1264,18 @@ const MeetingDetail: React.FC = () => {
const fetchData = useCallback(async (meetingId: number) => {
try {
const [detailRes, transcriptRes, chapterRes, shareConfigRes] = await Promise.all([
const [detailRes, transcriptRes, chapterRes, shareConfigRes, progressRes] = await Promise.all([
getMeetingDetail(meetingId),
getTranscripts(meetingId),
getMeetingChapters(meetingId),
getMeetingShareConfig().catch(() => null),
getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null),
]);
setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []);
setMeetingChapters(chapterRes.data.data || []);
setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || '');
setGenerationProgress(progressRes?.data?.data || null);
} catch (error) {
console.error(error);
} finally {
@ -2138,12 +2514,19 @@ const MeetingDetail: React.FC = () => {
<div className="meeting-detail-workspace">
{meeting.status === 0 || meeting.status === 1 ? (
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
/>
<>
<div style={{ display: 'none' }}>
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
/>
</div>
<UnifiedMeetingProgressDisplay
progress={generationProgress}
/>
</>
) : (
<>
<Row gutter={24} style={{ height: '100%' }}>
@ -2179,13 +2562,21 @@ const MeetingDetail: React.FC = () => {
)}
<Card className="left-flow-card summary-panel" variant="borderless">
{meeting.status === 2 && !hasSummaryContent ? (
<div className="summary-progress-shell">
{(meeting.status === 1 || meeting.status === 2) && (
<div style={{ display: 'none' }}>
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
/>
</div>
)}
{meeting.status === 2 && !hasSummaryContent ? (
<div className="summary-progress-shell">
<UnifiedMeetingProgressDisplay
progress={generationProgress}
compact
/>
</div>
@ -2201,11 +2592,8 @@ const MeetingDetail: React.FC = () => {
<div>
<div>{summaryPanelNotice.description}</div>
{meeting.status === 2 ? (
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
<UnifiedMeetingProgressDisplay
progress={generationProgress}
inline
/>
) : null}
@ -2551,13 +2939,13 @@ const MeetingDetail: React.FC = () => {
background: rgba(95, 81, 255, 0.25);
transform: translateY(-1px);
}
/* 当转录行处于活动状态(紫色背景)时,调整高亮样式以保持可读性 */
/* 当转录行处于活动状态时,调整高亮样式以保持可读性 */
.ant-list-item.transcript-row.active .highlight-text {
background: rgba(255, 255, 255, 0.2);
border-bottom-color: #fff;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
animation: none; /* 活动行内不需要脉冲,避免视觉混乱 */
animation: none; /* 活动行内不需要闪烁,避免视觉混乱 */
}
.summary-keyword-link {
color: #5f51ff;

View File

@ -9,14 +9,22 @@
--text-main: #1a1f36;
--text-secondary: #6e7695;
--card-shadow: 0 10px 30px rgba(127, 139, 186, 0.08);
min-height: 100vh;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-app);
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
}
/* Glassmorphism Header - Now redundant but kept for safety if needed */
.meeting-preview-header {
display: none;
}
/* Main Container */
.meeting-preview-container {
flex: 1;
overflow-y: auto;
@ -29,6 +37,7 @@
padding: 48px 24px;
}
/* Hero Section */
.meeting-preview-top-hero {
display: flex;
gap: 24px;
@ -75,11 +84,17 @@
font-size: 13px;
font-weight: 700;
}
.meeting-preview-status-tag.is-complete { background: #e6f4ea; color: #1e8e3e; }
.meeting-preview-status-tag.is-processing { background: #e8f0fe; color: #1a73e8; }
.meeting-preview-status-tag.is-warning { background: #fff4e5; color: #b76e00; }
.meeting-preview-hero-id {
font-size: 13px;
color: var(--text-secondary);
font-family: monospace;
}
/* Collapsible Info */
.meeting-preview-collapsible-section {
background: var(--bg-surface);
border-radius: 20px;
@ -118,7 +133,7 @@
}
.meeting-preview-collapsible-content.is-expanded {
max-height: 420px;
max-height: 400px;
}
.meeting-preview-metrics-grid {
@ -128,6 +143,18 @@
gap: 24px;
}
@media (max-width: 992px) {
.meeting-preview-metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.meeting-preview-metrics-grid {
grid-template-columns: 1fr;
}
}
.metric-item {
display: flex;
flex-direction: column;
@ -167,36 +194,7 @@
font-weight: 600;
}
.meeting-preview-share-settings {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 20px;
margin-bottom: 24px;
padding: 20px 24px;
box-shadow: var(--card-shadow);
}
.meeting-preview-share-settings-title {
font-size: 18px;
font-weight: 800;
margin-bottom: 4px;
}
.meeting-preview-share-settings-desc {
color: var(--text-secondary);
margin-bottom: 16px;
}
.meeting-preview-share-settings-row {
display: flex;
gap: 12px;
align-items: center;
}
.meeting-preview-share-settings-row .ant-input-affix-wrapper {
border-radius: 14px;
}
/* Share Bar */
.meeting-preview-share-bar {
display: flex;
gap: 16px;
@ -224,6 +222,7 @@
background: white !important;
}
/* Tabs and Content */
.meeting-preview-content-card {
background: var(--bg-surface);
border-radius: 24px;
@ -298,6 +297,7 @@
font-size: 14px;
}
/* Catalog List & Timeline */
.meeting-preview-catalog-list {
display: flex;
flex-direction: column;
@ -415,6 +415,12 @@
visibility: visible;
}
.meeting-preview-catalog-item-link:hover {
background: rgba(95, 81, 255, 0.15);
transform: translateY(-1px);
}
/* --- Transcription Original Styles Overhaul --- */
.meeting-preview-transcript-list {
display: flex;
flex-direction: column;
@ -504,6 +510,7 @@
word-wrap: break-word;
}
/* --- Floating Audio Player Overhaul --- */
.meeting-preview-audio-player-inline {
position: fixed;
bottom: 24px;
@ -511,6 +518,7 @@
transform: translateX(-50%);
width: calc(100% - 32px);
max-width: 720px;
height: auto;
min-height: 80px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px) saturate(180%);
@ -520,7 +528,9 @@
padding: 12px 24px;
border-radius: 28px;
z-index: 1000;
box-shadow: 0 25px 50px -12px rgba(95, 81, 255, 0.25), 0 0 0 1px rgba(95, 81, 255, 0.05);
box-shadow:
0 25px 50px -12px rgba(95, 81, 255, 0.25),
0 0 0 1px rgba(95, 81, 255, 0.05);
}
.audio-player-content {
@ -528,6 +538,7 @@
display: flex;
align-items: center;
gap: 16px;
flex-wrap: nowrap;
}
.audio-play-btn {
@ -543,6 +554,12 @@
font-size: 20px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.audio-play-btn:hover {
transform: scale(1.05);
box-shadow: 0 8px 15px rgba(95, 81, 255, 0.3);
}
.audio-progress-container {
@ -554,7 +571,7 @@
}
.audio-time {
font-family: "JetBrains Mono", monospace;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
@ -590,8 +607,253 @@
cursor: pointer;
color: var(--primary-blue);
flex-shrink: 0;
transition: all 0.2s;
}
.audio-speed-btn:hover {
background: #eef1ff;
}
/* --- Password Gate Overhaul --- */
.is-password-gate {
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
.password-gate-background {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
}
.bg-blob {
position: absolute;
filter: blur(80px);
opacity: 0.4;
border-radius: 50%;
animation: blob-float 20s infinite alternate cubic-bezier(0.45, 0.05, 0.55, 0.95);
}
.bg-blob-1 {
width: 500px;
height: 500px;
background: #5f51ff;
top: -100px;
left: -100px;
}
.bg-blob-2 {
width: 400px;
height: 400px;
background: #6c8cff;
bottom: -50px;
right: -50px;
animation-delay: -5s;
}
.bg-blob-3 {
width: 300px;
height: 300px;
background: #8e84ff;
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes blob-float {
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
33% { transform: translate(30px, 50px) scale(1.1) rotate(10deg); }
66% { transform: translate(-20px, 20px) scale(0.9) rotate(-10deg); }
100% { transform: translate(0, 0) scale(1) rotate(0deg); }
}
.is-password-gate .meeting-preview-shell {
width: 100%;
max-width: 480px;
padding: 24px;
z-index: 10;
margin: 0;
}
.meeting-preview-password-card {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(32px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 32px;
padding: 48px 40px;
box-shadow:
0 40px 100px -20px rgba(95, 81, 255, 0.15),
0 0 0 1px rgba(95, 81, 255, 0.05);
display: flex;
flex-direction: column;
gap: 32px;
}
.password-card-header {
text-align: center;
}
.password-icon-wrapper {
width: 64px;
height: 64px;
background: var(--primary-gradient);
color: white;
font-size: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20px;
margin: 0 auto 24px;
box-shadow: 0 12px 24px rgba(95, 81, 255, 0.25);
}
.password-card-title {
font-size: 24px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.password-card-subtitle {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.6;
max-width: 320px;
margin: 0 auto;
}
.password-card-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.modern-password-input {
border-radius: 16px !important;
border: 2px solid rgba(228, 232, 245, 0.8) !important;
padding: 12px 16px !important;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
background: white !important;
}
.modern-password-input:focus,
.modern-password-input-focused {
border-color: var(--primary-blue) !important;
box-shadow: 0 0 0 4px rgba(95, 81, 255, 0.1) !important;
}
.password-submit-btn {
height: 56px !important;
border-radius: 16px !important;
font-size: 16px !important;
font-weight: 700 !important;
letter-spacing: 0.02em;
background: var(--primary-gradient) !important;
border: none !important;
box-shadow: 0 12px 24px rgba(95, 81, 255, 0.2) !important;
transition: all 0.3s ease !important;
}
.password-submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 16px 32px rgba(95, 81, 255, 0.3) !important;
}
.password-error-message {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
.password-gate-footer {
position: absolute;
bottom: 32px;
z-index: 10;
}
.footer-disclaimer {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
opacity: 0.7;
}
@media (max-width: 480px) {
.meeting-preview-password-card {
padding: 40px 24px;
border-radius: 24px;
}
}
/* --- Mobile Optimizations --- */
@media (max-width: 768px) {
.meeting-preview-transcript-list {
gap: 16px;
}
.meeting-preview-transcript-item {
gap: 12px;
padding: 10px;
}
.meeting-preview-transcript-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 14px;
}
.meeting-preview-transcript-text {
font-size: 15px;
}
/* Fix audio player deformation on mobile */
.meeting-preview-audio-player-inline {
padding: 10px 16px;
bottom: 16px;
border-radius: 22px;
}
.audio-player-content {
gap: 10px;
}
.audio-play-btn {
width: 40px;
height: 40px;
border-radius: 12px;
font-size: 18px;
}
.audio-time {
display: block;
font-size: 10px;
}
.audio-progress-container {
gap: 0;
}
.audio-speed-btn {
width: 38px;
height: 32px;
font-size: 11px;
}
}
/* Utils */
.meeting-preview-footer {
margin-top: 64px;
padding-bottom: 64px;
@ -610,57 +872,107 @@
border-radius: 99px;
}
.meeting-preview-loading,
.meeting-preview-empty {
padding-top: 32px;
}
.meeting-preview-password-card {
max-width: 480px;
margin: 0 auto;
}
@media (max-width: 992px) {
.meeting-preview-metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.meeting-preview-shell { padding: 32px 16px; }
.meeting-preview-hero-title { font-size: 24px; }
.meeting-preview-metrics-grid { grid-template-columns: 1fr; gap: 16px; }
.meeting-preview-share-bar,
.meeting-preview-share-settings-row {
flex-direction: column;
}
.meeting-preview-share-bar .ant-btn,
.meeting-preview-share-settings-row .ant-btn,
.meeting-preview-share-settings-row .ant-input-affix-wrapper {
width: 100%;
}
.meeting-preview-tabs-container {
padding: 8px 12px 0;
}
.meeting-preview-tabs-container .ant-tabs-nav::before {
inset-inline: 0;
}
.meeting-preview-tabs-container .ant-tabs-nav-list {
width: 100%;
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr));
transform: none !important;
}
.meeting-preview-tabs-container .ant-tabs-tab {
margin: 0 !important;
padding: 14px 8px !important;
justify-content: center;
text-align: center;
min-width: 0;
}
.meeting-preview-tabs-container .ant-tabs-tab .ant-tabs-tab-btn {
width: 100%;
font-size: 14px;
line-height: 1.2;
white-space: nowrap;
overflow: visible;
text-overflow: clip;
}
.meeting-preview-tabs-container .ant-tabs-nav-operations {
display: none !important;
}
.meeting-preview-tab-content { padding: 20px; }
.meeting-preview-transcript-list { gap: 16px; }
.meeting-preview-transcript-item { gap: 12px; padding: 10px; }
.meeting-preview-transcript-avatar { width: 36px; height: 36px; border-radius: 10px; font-size: 14px; }
.meeting-preview-transcript-text { font-size: 15px; }
.meeting-preview-audio-player-inline { padding: 10px 16px; bottom: 16px; border-radius: 22px; }
.audio-player-content { gap: 10px; }
.audio-play-btn { width: 40px; height: 40px; border-radius: 12px; font-size: 18px; }
.audio-time { font-size: 10px; }
.audio-speed-btn { width: 38px; height: 32px; font-size: 11px; }
}
.meeting-preview-speaker-card {
background: #f8faff;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid #eef1f9;
}
.meeting-preview-speaker-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.meeting-preview-speaker-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--primary-gradient);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.meeting-preview-speaker-name {
font-weight: 700;
font-size: 15px;
}
.meeting-preview-speaker-role {
font-size: 12px;
color: var(--text-secondary);
}
.meeting-preview-keypoint {
display: flex;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--border-color);
margin-bottom: 12px;
}
.meeting-preview-keypoint-index {
font-size: 18px;
font-weight: 800;
color: #dee2e6;
}
.meeting-preview-todo {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
}
.meeting-preview-todo-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-blue);
margin-top: 6px;
}

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Empty, Input, Tabs, message } from "antd";
import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd";
import { useParams, useSearchParams } from "react-router-dom";
import {
AudioOutlined,
CalendarOutlined,
@ -7,57 +8,53 @@ import {
ClockCircleOutlined,
CopyOutlined,
FileTextOutlined,
LinkOutlined,
LockOutlined,
PauseOutlined,
RobotOutlined,
ShareAltOutlined,
TeamOutlined,
UpOutlined,
UserOutlined,
DownOutlined,
UpOutlined,
LinkOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import ReactMarkdown from "react-markdown";
import {
getMeetingPreviewAccess,
getPublicMeetingPreview,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
} from "@/api/meeting";
import { buildMeetingAnalysis } from "@/components/preview/meetingAnalysis";
import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types";
import "./MeetingPreviewView.css";
type MeetingChapterVO,
type MeetingTranscriptVO,
type MeetingVO,
} from "../../api/business/meeting";
import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
type PreviewPageTab = "summary" | "catalog" | "transcript";
type ChapterTranscriptLink = {
key: string;
title: string;
timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
};
interface MeetingPreviewViewProps {
meeting: MeetingVO;
transcripts: MeetingTranscriptVO[];
meetingChapters: MeetingChapterVO[];
shareUrl: string;
editableShare?: boolean;
sharePasswordDraft?: string;
shareSaving?: boolean;
onSharePasswordDraftChange?: (value: string) => void;
onSaveSharePassword?: () => void;
onCopyShareLink?: () => void;
}
const TEXT = {
statusTranscribing: "转写中",
statusSummarizing: "总结中",
statusCompleted: "已完成",
statusPending: "待处理",
hintTranscribing: "会议内容仍在整理中,预览会持续补全。",
hintSummarizing: "AI 正在生成会议总结,已完成内容会优先展示。",
hintCompleted: "会议纪要、分析和转录内容已生成完成。",
hintPending: "当前会议尚未生成完整内容,请稍后重试。",
missingMeetingId: "未提供会议编号",
loadFailed: "会议预览加载失败",
noMeetingData: "未找到会议数据",
previewLabel: "会议预览",
untitledMeeting: "未命名会议",
meetingTime: "会议时间",
hostCreator: "主持/创建",
participantsCount: "参会人数",
tagsCount: "标签数量",
notSet: "未设置",
notFilled: "未填写",
pageSummary: "AI 纪要",
pageCatalog: "AI 目录",
pageTranscript: "转录原文",
@ -66,30 +63,67 @@ const TEXT = {
shareCopied: "预览链接已复制",
shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接",
shareFailed: "分享失败,请先复制链接",
accessCheck: "访问校验",
passwordRequired: "该会议需要访问密码",
passwordHint: "请输入会议的 访问密码 后继续访问预览内容。",
passwordPlaceholder: "请输入 访问密码",
openPreview: "进入预览",
invalidPassword: "访问密码错误",
basicInfo: "基本信息",
meetingTime: "会议时间",
hostCreator: "主持/创建",
participantsCount: "参会人数",
meetingOverview: "会议概况",
creator: "创建人",
host: "主持人",
createdAt: "创建时间",
audioStatus: "音频状态",
participants: "人",
tags: "会议标签",
aiAnalysis: "AI 目录",
analysis: "会议分析",
previewExtra: "预览页仅读展示",
audioPlaybackWarning: "音频保存失败,可能影响回放。",
summaryOverview: "全文概要",
summaryEmpty: "暂无概要内容",
analysisChapters: "章节",
analysisSpeakers: "发言人",
analysisKeyPoints: "关键要点",
analysisTodos: "待办事项",
noChapterAnalysis: "暂无章节分析",
noSpeakerAnalysis: "暂无发言人分析",
noKeyPoints: "暂无关键要点",
noTodos: "暂无待办事项",
chapterFallback: "章节",
speakerFallback: "发言人",
speakerSummary: "发言概述",
keyPointFallback: "要点",
noChapterSummary: "暂无章节描述",
noSpeakerSummary: "暂无发言总结",
noKeyPointSummary: "暂无要点说明",
summarySection: "会议纪要",
fullSummary: "完整纪要",
noSummary: "暂无会议纪要",
noCatalog: "暂无 AI 目录",
noTranscript: "暂无转录内容",
transcriptSection: "会议转录",
transcriptTitle: "逐段转录",
noDuration: "暂无时长",
audioUnavailable: "音频文件不可用,仅展示转录内容。",
transcriptTitle: "逐段转录",
keywordSection: "关键词",
noTranscript: "暂无转录内容",
unknownSpeaker: "未知发言人",
disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度",
shareText: "我向你分享了一个会议预览链接",
audioSaved: "已保存",
audioSaveFailed: "保存失败",
audioUploaded: "已上传",
audioNotSaved: "未保存",
linkToTranscript: "关联原文",
shareSettings: "分享访问设置",
shareSettingsHint: "当前登录用户可直接查看,访问密码仅对分享出去的 H5 预览链接生效。",
saveSharePassword: "保存访问密码",
passwordPlaceholder: "为空表示取消访问密码",
disclaimer: "智能内容由用户会议内容与 AI 模型生成,我们不对内容准确性和完整性做任何保证。",
noCatalog: "暂无 AI 目录",
};
const STATUS_META: Record<number, { label: string; className: string }> = {
1: { label: TEXT.statusTranscribing, className: "is-processing" },
2: { label: TEXT.statusSummarizing, className: "is-processing" },
3: { label: TEXT.statusCompleted, className: "is-complete" },
type ChapterTranscriptLink = {
key: string;
title: string;
timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
};
function parseChapterTimeToMs(value?: string) {
@ -110,6 +144,24 @@ function parseChapterTimeToMs(value?: string) {
return totalSeconds * 1000;
}
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing },
2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing },
3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted },
};
function formatDurationRange(startTime?: number, endTime?: number) {
const format = (milliseconds?: number) => {
const safeMs = Math.max(0, milliseconds || 0);
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
function splitDisplayItems(value?: string) {
return (value || "")
.split(",")
@ -140,35 +192,24 @@ async function copyText(text: string) {
document.body.removeChild(textarea);
}
function formatDurationRange(startTime?: number, endTime?: number) {
const format = (milliseconds?: number) => {
const safeMs = Math.max(0, milliseconds || 0);
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
export default function MeetingPreviewView({
meeting,
transcripts,
meetingChapters,
shareUrl,
editableShare = false,
sharePasswordDraft = "",
shareSaving = false,
onSharePasswordDraftChange,
onSaveSharePassword,
onCopyShareLink,
}: MeetingPreviewViewProps) {
export default function MeetingPreview() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const audioRef = useRef<HTMLAudioElement | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const audioPlaybackErrorShownRef = useRef<string | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("speakers");
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
const [passwordRequired, setPasswordRequired] = useState(false);
const [passwordVerified, setPasswordVerified] = useState(false);
const [accessPassword, setAccessPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
@ -176,23 +217,117 @@ export default function MeetingPreviewView({
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
);
const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]);
useEffect(() => {
if (typeof window === "undefined") return;
let mounted = true;
const load = async () => {
if (!id) {
setError(TEXT.missingMeetingId);
setLoading(false);
return;
}
setLoading(true);
setError("");
setMeeting(null);
setTranscripts([]);
setMeetingChapters([]);
setPasswordRequired(false);
setPasswordVerified(false);
setAccessPassword(presetAccessPassword);
setPasswordError("");
try {
const meetingId = Number(id);
const accessRes = await getMeetingPreviewAccess(meetingId);
if (!mounted) {
return;
}
const requiresPassword = !!accessRes.data.data.passwordRequired;
setPasswordRequired(requiresPassword);
if (requiresPassword) {
if (!presetAccessPassword) {
setLoading(false);
return;
}
try {
const previewRes = await getPublicMeetingPreview(meetingId, presetAccessPassword);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
return;
} catch (requestError: any) {
if (!mounted) {
return;
}
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
setPasswordVerified(false);
setLoading(false);
return;
}
}
const previewRes = await getPublicMeetingPreview(meetingId);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
if (!mounted) {
return;
}
setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed);
} finally {
if (mounted) {
setLoading(false);
}
}
};
load();
return () => {
mounted = false;
};
}, [id, presetAccessPassword]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 767px)");
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
const handleChange = (event: MediaQueryListEvent) => {
setIsMobile(event.matches);
};
setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
);
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
const transcriptSpeakers = useMemo(() => {
const speakers = transcripts
@ -207,7 +342,9 @@ export default function MeetingPreviewView({
const statusMeta = STATUS_META[meeting?.status || 0] || {
label: TEXT.statusPending,
className: "is-warning",
hint: TEXT.hintPending,
};
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
const participantCountValue =
isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length;
@ -216,11 +353,11 @@ export default function MeetingPreviewView({
const last = transcripts[transcripts.length - 1];
return last.endTime || 0;
}
return meeting.duration || 0;
}, [meeting.duration, transcripts]);
return 0;
}, [transcripts]);
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
const transcriptIdToIndex = new Map(transcripts.map((item) => [item.id, transcripts.indexOf(item)]));
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
const sourceChapters: MeetingChapterVO[] = meetingChapters.length
? meetingChapters
: analysis.chapters.map((item) => ({
@ -232,13 +369,13 @@ export default function MeetingPreviewView({
let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds
.map((item: number) => Number(item))
.filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item))
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
: [];
if (sourceTranscriptIds.length) {
matchedTranscripts = sourceTranscriptIds
.map((item: number) => transcripts[transcriptIdToIndex.get(item)!])
.map((item) => transcripts[transcriptIdToIndex.get(item)!])
.filter(Boolean);
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
@ -254,12 +391,12 @@ export default function MeetingPreviewView({
.find((item): item is number => item !== null && startMs !== null && item > startMs);
if (startMs !== null) {
const firstTranscriptIndex = transcripts.findIndex((item) => (item.endTime || 0) > startMs);
const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
if (firstTranscriptIndex >= 0) {
const lastTranscriptIndex =
nextChapterStartMs === undefined
? transcripts.length
: transcripts.findIndex((item) => (item.startTime || 0) >= nextChapterStartMs);
: transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
matchedTranscripts = transcripts.slice(
firstTranscriptIndex,
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
@ -280,14 +417,24 @@ export default function MeetingPreviewView({
}, [analysis.chapters, meetingChapters, transcripts]);
useEffect(() => {
if (!activeTranscriptId) return;
if (!activeTranscriptId) {
return;
}
const target = transcriptItemRefs.current[activeTranscriptId];
if (!target) return;
if (!target) {
return;
}
// 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡
target.scrollIntoView({ behavior: "smooth", block: "center" });
}, [activeTranscriptId]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) return;
if (!audioRef.current) {
return;
}
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
audioRef.current.play().catch(() => {});
};
@ -300,9 +447,13 @@ export default function MeetingPreviewView({
setLinkedChapterKey(link.key);
setActiveTranscriptId(link.firstTranscriptId);
// 自动跳转并播放音频
if (audioRef.current && link.firstTranscriptStartTime !== null) {
audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000);
audioRef.current.play().catch(() => {});
audioRef.current.play().catch(() => {
// 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放
// 但由于这是由用户点击目录项触发的,通常会被允许
});
}
}
};
@ -331,17 +482,18 @@ export default function MeetingPreviewView({
};
const formatPlayerTime = (seconds: number) => {
if (!seconds || Number.isNaN(seconds)) return "00:00";
if (!seconds || isNaN(seconds)) return '00:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const handleAudioTimeUpdate = () => {
if (!audioRef.current) return;
const currentSeconds = audioRef.current.currentTime;
setAudioCurrentTime(currentSeconds);
// Also update duration if it's available now
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
setAudioDuration(audioRef.current.duration);
}
@ -356,44 +508,55 @@ export default function MeetingPreviewView({
setActiveTranscriptId(currentItem?.id || null);
};
const handleAudioLoadedMetadata = () => {
if (!audioRef.current) return;
setAudioDuration(audioRef.current.duration || 0);
const handleAudioEnded = () => {
setAudioPlaying(false);
};
const handleAudioPlay = () => setAudioPlaying(true);
const handleAudioPause = () => setAudioPlaying(false);
const handleAudioEnded = () => setAudioPlaying(false);
const handleAudioLoadedMetadata = () => {
if (audioRef.current) {
setAudioDuration(audioRef.current.duration);
}
};
const handleAudioError = async () => {
const handleAudioError = () => {
const currentAudioUrl = playbackAudioUrl || "";
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
return;
}
const normalizedUrl = currentAudioUrl.split("#")[0]?.split("?")[0]?.toLowerCase() || "";
const isM4a = normalizedUrl.endsWith(".m4a");
message.warning(
isM4a
? "当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav或下载到本地播放。"
: TEXT.audioUnavailable,
);
audioPlaybackErrorShownRef.current = currentAudioUrl;
try {
const retryResp = await getPublicMeetingPreview(meeting.id);
const retryUrl = resolveMeetingPlaybackAudioUrl(retryResp.data.data.meeting);
if (retryUrl && retryUrl !== currentAudioUrl && audioRef.current) {
audioRef.current.src = retryUrl;
audioRef.current.load();
audioPlaybackErrorShownRef.current = null;
return;
}
} catch {
// ignore retry failure
}
message.warning(meeting.audioSaveMessage || TEXT.audioUnavailable);
setAudioPlaying(false);
};
const handleCopyLink = async () => {
if (onCopyShareLink) {
await onCopyShareLink();
const handlePasswordSubmit = async () => {
if (!id) {
return;
}
setLoading(true);
setPasswordError("");
try {
const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim());
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
try {
await copyText(shareUrl);
message.success(TEXT.shareCopied);
@ -406,8 +569,8 @@ export default function MeetingPreviewView({
try {
if (navigator.share) {
await navigator.share({
title: meeting?.title || "会议预览",
text: "我向你分享了一个会议预览链接",
title: meeting?.title || TEXT.previewLabel,
text: TEXT.shareText,
url: shareUrl,
});
return;
@ -420,26 +583,136 @@ export default function MeetingPreviewView({
}
};
if (loading && (!passwordRequired || passwordVerified)) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-loading">
<div className="meeting-preview-card meeting-preview-hero">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
</div>
);
}
if (passwordRequired && !passwordVerified) {
return (
<div className="meeting-preview-page is-password-gate">
<div className="password-gate-background">
<div className="bg-blob bg-blob-1" />
<div className="bg-blob bg-blob-2" />
<div className="bg-blob bg-blob-3" />
</div>
<div className="meeting-preview-shell">
<div className="meeting-preview-password-card">
<div className="password-card-header">
<div className="password-icon-wrapper">
<LockOutlined />
</div>
<h1 className="password-card-title">{TEXT.passwordRequired}</h1>
<p className="password-card-subtitle">{TEXT.passwordHint}</p>
</div>
<div className="password-card-form">
<div className="password-input-wrapper">
<Input.Password
size="large"
value={accessPassword}
placeholder={TEXT.passwordPlaceholder}
onChange={(event) => setAccessPassword(event.target.value)}
onPressEnter={handlePasswordSubmit}
prefix={<LockOutlined style={{ color: "var(--text-secondary)" }} />}
className="modern-password-input"
/>
</div>
<Button
type="primary"
size="large"
onClick={handlePasswordSubmit}
loading={loading}
disabled={!accessPassword.trim()}
className="password-submit-btn"
block
>
{TEXT.openPreview}
</Button>
</div>
{passwordError && (
<div className="password-error-message">
<Alert type="error" showIcon message={passwordError} />
</div>
)}
</div>
</div>
<div className="password-gate-footer">
<div className="footer-disclaimer">
<RobotOutlined />
<span>Secure Access Powered by iMeeting AI</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Result status="error" title={TEXT.loadFailed} subTitle={error} />
</div>
</div>
</div>
);
}
if (!meeting) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Empty description={TEXT.noMeetingData} />
</div>
</div>
</div>
);
}
const summaryTabContent = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-section">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-summary-box">
<div className="meeting-preview-summary-section-title">{TEXT.keywordSection}</div>
<div className="meeting-preview-record-tags">
{keywords.length ? (
keywords.map((item) => (
<div key={item} className="meeting-preview-record-tag">
<span>#{item}</span>
</div>
))
) : (
<span className="meeting-preview-keywords-empty"></span>
)}
<div className="meeting-preview-summary-section">
<div className="meeting-preview-summary-section-title"></div>
<div className="meeting-preview-record-tags">
{keywords.length ? (
keywords.map((item) => (
<div key={item} className="meeting-preview-record-tag">
<span>#{item}</span>
</div>
))
) : (
<span className="meeting-preview-keywords-empty"></span>
)}
</div>
</div>
</div>
<div className="meeting-preview-markdown">
{meeting.summaryContent ? <ReactMarkdown>{meeting.summaryContent}</ReactMarkdown> : <Empty description={TEXT.noSummary} />}
{meeting.summaryContent ? (
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
) : (
<Empty description={TEXT.noSummary} />
)}
</div>
</section>
</div>
@ -447,19 +720,32 @@ export default function MeetingPreviewView({
const catalogTabContent = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-section">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
{/*<div className="meeting-preview-section-kicker">*/}
{/* <RobotOutlined />*/}
{/* {TEXT.aiAnalysis}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.pageCatalog}</h2>
</div>
</div>
<div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (
<div
key={chapter.key}
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? "active" : ""}`}
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? 'active' : ''}`}
>
<div className="meeting-preview-catalog-timeline-axis">
<div className="meeting-preview-catalog-timeline-dot" />
<div className="meeting-preview-catalog-timeline-line" />
</div>
<div className="meeting-preview-catalog-item-card" onClick={() => handleLocateChapterTranscript(index)}>
<div
className="meeting-preview-catalog-item-card"
onClick={() => handleLocateChapterTranscript(index)}
>
<div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div>
<div className="meeting-preview-catalog-item-title-row">
<div className="meeting-preview-catalog-item-title">{chapter.title}</div>
@ -487,9 +773,13 @@ export default function MeetingPreviewView({
const transcriptTabContent = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-section">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
{/*<div className="meeting-preview-section-kicker">*/}
{/* <AudioOutlined />*/}
{/* {TEXT.transcriptSection}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
</div>
<div className="meeting-preview-section-extra">
@ -499,7 +789,12 @@ export default function MeetingPreviewView({
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert className="meeting-preview-alert" type="warning" showIcon message={meeting.audioSaveMessage || TEXT.audioUnavailable} />
<Alert
className="meeting-preview-alert"
type="warning"
showIcon
message={meeting.audioSaveMessage || TEXT.audioUnavailable}
/>
) : null}
<div className="meeting-preview-transcript-list">
@ -508,6 +803,7 @@ export default function MeetingPreviewView({
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
const isLinked = linkedTranscriptIds.includes(item.id);
const isActive = activeTranscriptId === item.id;
return (
<div
key={item.id}
@ -517,7 +813,7 @@ export default function MeetingPreviewView({
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
onClick={() => {
handleTranscriptSeek(item);
setLinkedTranscriptIds([]);
setLinkedTranscriptIds([]); // Clear linked highlight on manual seek
setLinkedChapterKey(null);
}}
>
@ -534,7 +830,9 @@ export default function MeetingPreviewView({
{formatDurationRange(item.startTime, item.endTime)}
</span>
</div>
<div className="meeting-preview-transcript-text">{item.content || TEXT.noTranscript}</div>
<div className="meeting-preview-transcript-text">
{item.content || TEXT.noTranscript}
</div>
</div>
</div>
);
@ -563,101 +861,104 @@ export default function MeetingPreviewView({
<div className={`meeting-preview-page ${isMobile ? "is-mobile" : "is-desktop"}`}>
<div className="meeting-preview-container">
<div className="meeting-preview-shell">
{/* Header Title Section */}
<div className="meeting-preview-top-hero">
<div className="meeting-preview-hero-logo">
<RobotOutlined />
</div>
<div className="meeting-preview-hero-content">
<h1 className="meeting-preview-hero-title">{meeting.title || "未命名会议"}</h1>
<h1 className="meeting-preview-hero-title">{meeting.title || TEXT.untitledMeeting}</h1>
<div className="meeting-preview-hero-meta">
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>{statusMeta.label}</span>
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>
{statusMeta.label}
</span>
{/*<span className="meeting-preview-hero-id">ID: {meeting.id}</span>*/}
</div>
</div>
</div>
{/* Collapsible Basic Info Section */}
<div className="meeting-preview-collapsible-section">
<div className="meeting-preview-collapsible-trigger" onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}>
<div
className="meeting-preview-collapsible-trigger"
onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}
>
<div className="trigger-left">
<FileTextOutlined />
<span>{TEXT.basicInfo}</span>
</div>
<div className="trigger-right">{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}</div>
<div className="trigger-right">
{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}
</div>
</div>
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? "is-expanded" : ""}`}>
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? 'is-expanded' : ''}`}>
<div className="meeting-preview-metrics-grid">
<div className="metric-item">
<div className="metric-label">{TEXT.meetingTime}</div>
<div className="metric-value">
<CalendarOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} />
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : "未设置"}
<CalendarOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.hostCreator}</div>
<div className="metric-value">
<UserOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} />
{meeting.creatorName || "未设置"}
<UserOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.creatorName || TEXT.notSet}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.participantsCount}</div>
<div className="metric-value">
<TeamOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} />
{participantCountValue}
<TeamOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{participantCountValue} {TEXT.participants}
</div>
</div>
<div className="metric-item">
<div className="metric-label"></div>
<div className="metric-value">
<ClockCircleOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} />
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : "未设置"}
<ClockCircleOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : TEXT.notSet}
</div>
</div>
{tags.length > 0 ? (
{tags.length > 0 && (
<div className="metric-item metric-item-full">
<div className="metric-label">{TEXT.tags}</div>
<div className="metric-tags">
{tags.map((tag) => (
<span key={tag} className="metric-tag">
#{tag}
</span>
{tags.map(tag => (
<span key={tag} className="metric-tag">#{tag}</span>
))}
</div>
</div>
) : null}
)}
</div>
</div>
</div>
{editableShare ? (
<div className="meeting-preview-share-settings">
<div className="meeting-preview-share-settings-title">{TEXT.shareSettings}</div>
<div className="meeting-preview-share-settings-desc">{TEXT.shareSettingsHint}</div>
<div className="meeting-preview-share-settings-row">
<Input.Password
value={sharePasswordDraft}
placeholder={TEXT.passwordPlaceholder}
prefix={<LockOutlined />}
onChange={(event) => onSharePasswordDraftChange?.(event.target.value)}
/>
<Button type="primary" loading={shareSaving} onClick={onSaveSharePassword}>
{TEXT.saveSharePassword}
</Button>
</div>
</div>
) : null}
{/* Sharing Buttons Bar */}
<div className="meeting-preview-share-bar">
<Button type="primary" size="large" icon={<ShareAltOutlined />} onClick={handleShareNow} className="share-btn-primary">
<Button
type="primary"
size="large"
icon={<ShareAltOutlined />}
onClick={handleShareNow}
className="share-btn-primary"
>
{TEXT.shareNow}
</Button>
<Button size="large" icon={<CopyOutlined />} onClick={handleCopyLink} className="share-btn-ghost">
<Button
size="large"
icon={<CopyOutlined />}
onClick={handleCopyLink}
className="share-btn-ghost"
>
{TEXT.copyLink}
</Button>
</div>
<div className="meeting-preview-layout-full">
{/* Main Content Area */}
<main className="meeting-preview-main">
<div className="meeting-preview-content-card">
<div className="meeting-preview-tabs-container">
@ -690,8 +991,12 @@ export default function MeetingPreviewView({
</div>
</div>
{playbackAudioUrl ? (
<div className="meeting-preview-audio-player-inline" style={{ display: pageTab === "transcript" ? "flex" : "none" }}>
{/* Floating Audio Player - Permanent mount, visibility controlled */}
{playbackAudioUrl && (
<div
className="meeting-preview-audio-player-inline"
style={{ display: pageTab === "transcript" ? "flex" : "none" }}
>
<audio
ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate}
@ -729,7 +1034,7 @@ export default function MeetingPreviewView({
</button>
</div>
</div>
) : null}
)}
</div>
);
}

View File

@ -1,4 +1,4 @@
import {
import {
AppstoreOutlined,
AudioOutlined,
CalendarOutlined,
@ -76,11 +76,11 @@ const ALL_STATUS_FILTER = "all";
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
const MEETING_STATUS_FILTER_OPTIONS = [
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "识别中", value: "1", color: "#1890ff", bgColor: "#e6f7ff" },
{ label: "总结", value: "2", color: "#faad14", bgColor: "#fff7e6" },
{ label: "完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" },
{ label: "失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" },
{ label: "数据初始化", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "转译音频", value: "1", color: "#1890ff", bgColor: "#e6f7ff" },
{ label: "生成总结", value: "2", color: "#faad14", bgColor: "#fff7e6" },
{ label: "处理完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" },
{ label: "处理失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" },
] as const;
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
@ -122,12 +122,34 @@ const shouldPollMeetingCard = (item: MeetingVO) =>
|| item.realtimeSessionStatus === "ACTIVE"
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
const getUnifiedStatusCode = (progress: MeetingProgress | null | undefined) =>
progress?.unifiedStatus?.statusCode;
const getEffectiveStatus = (item: MeetingVO, progress: MeetingProgress | null) => {
const unifiedStatusCode = getUnifiedStatusCode(progress);
if (unifiedStatusCode === "WAITING_UPLOAD") {
return 8;
}
if (unifiedStatusCode === "INITIALIZING") {
return 0;
}
if (unifiedStatusCode === "TRANSCRIBING") {
return 1;
}
if (unifiedStatusCode === "SUMMARIZING") {
return 2;
}
if (unifiedStatusCode === "COMPLETED") {
return 3;
}
if (unifiedStatusCode?.startsWith("FAILED_")) {
return 4;
}
if (hasLatestGenerationFailure(item)) {
return 4;
}
const status = item.displayStatus ?? item.status;
// 如果是排队中但已有进度,则视为识别中
// 如果处于初始化中但已经有进度,则视为转译音频
if (status === 0 && progress && progress.percent > 0) {
return 1;
}
@ -176,16 +198,20 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
const effectiveStatus = getEffectiveStatus(meeting, progress);
const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
2: { text: "总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: <SyncOutlined spin /> },
3: { text: "完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: <CheckOutlined /> },
4: { text: "失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: <InfoCircleOutlined /> },
0: { text: "数据初始化", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
1: { text: "转译音频", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
2: { text: "生成总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: <SyncOutlined spin /> },
3: { text: "处理完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: <CheckOutlined /> },
4: { text: "处理失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: <InfoCircleOutlined /> },
5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: <PauseCircleOutlined /> },
6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: <SyncOutlined spin /> },
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
8: { text: "待上传录音文件", color: "#13a8a8", bgColor: "rgba(19, 168, 168, 0.1)", icon: <CloudUploadOutlined /> },
};
const config = statusConfig[effectiveStatus] || statusConfig[0];
const displayConfig = progress?.unifiedStatus?.statusText
? { ...config, text: progress.unifiedStatus.statusText }
: config;
const isProcessing = shouldTrackGenerationProgress(meeting);
const percent = isProcessing ? progress?.percent || 0 : 0;
@ -198,9 +224,9 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
borderRadius: "8px",
fontSize: "12px",
fontWeight: 700,
color: config.color,
background: config.bgColor,
border: `1px solid ${config.color}20`,
color: displayConfig.color,
background: displayConfig.bgColor,
border: `1px solid ${displayConfig.color}20`,
gap: "4px",
position: "relative",
overflow: "hidden"
@ -214,16 +240,16 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
bottom: 0,
height: "2px",
width: `${percent}%`,
background: config.color,
background: displayConfig.color,
transition: "width 0.4s ease-out",
boxShadow: `0 0 8px ${config.color}`
boxShadow: `0 0 8px ${displayConfig.color}`
}}
/>
)}
<span style={{ display: "flex", alignItems: "center" }}>
{isProcessing ? <SyncOutlined spin style={{ fontSize: 11 }} /> : config.icon}
{isProcessing ? <SyncOutlined spin style={{ fontSize: 11 }} /> : displayConfig.icon}
</span>
<span>{config.text}</span>
<span>{displayConfig.text}</span>
{isProcessing && <span style={{ opacity: 0.8, fontSize: "11px", fontWeight: 500 }}>{percent}%</span>}
</div>
);
@ -248,7 +274,7 @@ const MeetingCardItem: React.FC<{
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
const crossPlatformHint = `${getRealtimeSourceLabel(item)}继续`;
const crossPlatformHint = ` ${getRealtimeSourceLabel(item)} 继续`;
const canRetry = canRetryQueuedMeeting(item, progress);
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
@ -686,7 +712,7 @@ const Meetings: React.FC = () => {
return;
}
if (!canControlRealtimeFromCurrentPlatform(meeting)) {
message.info("该实时会议需在" + getRealtimeSourceLabel(meeting) + "继续,当前仅支持查看详情");
message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`);
navigate("/meetings/" + meeting.id);
return;
}
@ -736,7 +762,7 @@ const Meetings: React.FC = () => {
render: (text: string) => <Text type="secondary">{text || "未知"}</Text>,
},
{
title: "来源",
title: "鏉ユ簮",
dataIndex: "meetingSource",
key: "meetingSource",
width: 80,
@ -746,10 +772,10 @@ const Meetings: React.FC = () => {
title: "参会人",
dataIndex: "participants",
key: "participants",
render: (text: string) => <Text type="secondary" ellipsis style={{ maxWidth: 180 }}>{text || "无参人员"}</Text>,
render: (text: string) => <Text type="secondary" ellipsis style={{ maxWidth: 180 }}>{text || "无参人员"}</Text>,
},
{
title: "操作",
title: "鎿嶄綔",
key: "action",
width: 220,
render: (_: unknown, record: MeetingVO) => (
@ -779,11 +805,11 @@ const Meetings: React.FC = () => {
];
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
0: { text: "排队中", color: "#8c8c8c", bgColor: "#f5f5f5" },
1: { text: "识别中", color: "#1890ff", bgColor: "#e6f7ff" },
2: { text: "总结", color: "#faad14", bgColor: "#fff7e6" },
3: { text: "完成", color: "#52c41a", bgColor: "#f6ffed" },
4: { text: "失败", color: "#ff4d4f", bgColor: "#fff1f0" },
0: { text: "数据初始化", color: "#8c8c8c", bgColor: "#f5f5f5" },
1: { text: "转译音频", color: "#1890ff", bgColor: "#e6f7ff" },
2: { text: "生成总结", color: "#faad14", bgColor: "#fff7e6" },
3: { text: "处理完成", color: "#52c41a", bgColor: "#f6ffed" },
4: { text: "处理失败", color: "#ff4d4f", bgColor: "#fff1f0" },
5: { text: "会议暂停", color: "#d48806", bgColor: "#fff7e6" },
6: { text: "实时进行中", color: "#1677ff", bgColor: "#e6f4ff" },
7: { text: "待开始", color: "#595959", bgColor: "#f5f5f5" },

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

@ -6,6 +6,7 @@ import { menuRoutes,extraRoutes } from "./routes";
const Login = lazy(() => import("@/pages/auth/login"));
const ResetPassword = lazy(() => import("@/pages/auth/reset-password"));
const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview"));
const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate"));
function RouteFallback() {
@ -70,6 +71,10 @@ export default function AppRoutes() {
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/meetings/:id/preview"
element={<MeetingPreview />}
/>
<Route
path="/public-device-meetings/:sessionId/create"
element={

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 {