feat: 添加会议积分管理功能
- 新增 `MeetingPointsController` 用于查看会议积分余额 - 新增 `MeetingPointsBalanceVO` DTO 类,表示会议积分余额视图 - 新增前端页面 `MeetingPointsManagement`,展示会议积分余额和消耗流水 - 新增 `MeetingSummaryChargeRecordServiceImpl` 服务实现类dev_na
parent
e7659b1e31
commit
40bf049a0e
|
|
@ -377,6 +377,106 @@
|
|||
### 5.9 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級
|
||||
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
## 6. 会议积分模式增量
|
||||
|
||||
### 6.1 `biz_meetings` 增量字段
|
||||
- `effective_audio_duration_seconds`
|
||||
- 类型:`INTEGER`
|
||||
- 说明:会议最终有效录音时长(秒),作为会议统计与积分计费统一口径。
|
||||
|
||||
### 6.2 `biz_meeting_user_stats`
|
||||
- 用途:按用户维护会议时长统计与总结触发统计账户。
|
||||
- 关键字段:
|
||||
- `tenant_id`
|
||||
- `user_id`
|
||||
- `total_meeting_duration_seconds`
|
||||
- `total_meeting_duration_minutes`
|
||||
- `total_summary_charge_count`
|
||||
- `total_summary_attempt_count`
|
||||
- 关键索引:
|
||||
- `uk_biz_meeting_user_stats_tenant_user`
|
||||
|
||||
### 6.3 `biz_meeting_points_accounts`
|
||||
- 用途:当前版本按租户维护统一积分余额与累计消耗。
|
||||
- 关键字段:
|
||||
- `tenant_id`
|
||||
- `user_id`
|
||||
- `0` 表示公共账户
|
||||
- 非 `0` 表示个人账户
|
||||
- `current_balance`
|
||||
- `total_points_used`
|
||||
- `total_asr_points_used`
|
||||
- `total_llm_points_used`
|
||||
- 关键索引:
|
||||
- `uk_biz_meeting_points_accounts_tenant_user`
|
||||
|
||||
### 6.4 `biz_meeting_summary_charge_records`
|
||||
- 用途:每次 SUMMARY 任务保留一条计费快照记录,并按 ASR / LLM 成功节点累计实际扣费。
|
||||
- 关键字段:
|
||||
- `meeting_id`
|
||||
- `summary_task_id`
|
||||
- `user_id`
|
||||
- 记录所属会议 owner / 创建人
|
||||
- `audio_duration_seconds`
|
||||
- `charged_minutes`
|
||||
- `billing_units`
|
||||
- `unit_minutes_snapshot`
|
||||
- `cost_per_unit_snapshot`
|
||||
- `total_points`
|
||||
- 当前记录应计总积分;重新总结场景仅记录 LLM 应计积分
|
||||
- `charged_total_points`
|
||||
- `asr_points`
|
||||
- `charged_asr_points`
|
||||
- `llm_points`
|
||||
- `charged_llm_points`
|
||||
- `asr_ratio_snapshot`
|
||||
- `llm_ratio_snapshot`
|
||||
- `balance_before`
|
||||
- `balance_after`
|
||||
- `points_delta`
|
||||
- `charge_trigger_type`
|
||||
- `summary_status`
|
||||
- `points_mode_enabled`
|
||||
- `failure_reason`
|
||||
- `charged_at`
|
||||
- `asr_charged_at`
|
||||
- `llm_charged_at`
|
||||
- 关键索引:
|
||||
- `idx_biz_meeting_summary_charge_records_meeting`
|
||||
- `idx_biz_meeting_summary_charge_records_user`
|
||||
- `idx_biz_meeting_summary_charge_records_task`
|
||||
|
||||
### 6.5 `biz_meeting_points_ledgers`
|
||||
- 用途:实际发生积分变化时记录 ASR / LLM / INIT / RECHARGE 流水。
|
||||
- 关键字段:
|
||||
- `user_id`
|
||||
- `0` 表示公共账户
|
||||
- 非 `0` 表示个人账户
|
||||
- `meeting_id`
|
||||
- `summary_task_id`
|
||||
- `charge_record_id`
|
||||
- `points_delta`
|
||||
- `points_type`
|
||||
- `balance_before`
|
||||
- `balance_after`
|
||||
- `remark`
|
||||
|
||||
### 6.6 当前计费口径
|
||||
- 当前支持两种扣费账户:
|
||||
- `PUBLIC`:租户公共账户
|
||||
- `PERSONAL`:会议 owner / 创建人的个人账户
|
||||
- 当前优先扣费账户由系统参数 `meeting.points.account_mode` 控制:
|
||||
- `PUBLIC`
|
||||
- `PERSONAL`
|
||||
- 自动总结:
|
||||
- `ASR` 成功后扣减 `ASR` 比例积分
|
||||
- `LLM / SUMMARY` 成功后扣减 `LLM` 比例积分
|
||||
- 重新总结:
|
||||
- 只在 `LLM / SUMMARY` 成功后扣减 `LLM` 比例积分
|
||||
- 失败不扣费:
|
||||
- `ASR` 失败不扣 `ASR`
|
||||
- `SUMMARY` 失败不扣 `LLM`
|
||||
| id | BIGSERIAL | PK | 涓婚敭ID |
|
||||
| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID |
|
||||
| task_type | VARCHAR(20) | | 浠诲姟绫诲瀷锛圓SR / SUMMARY锛?|
|
||||
|
|
|
|||
|
|
@ -503,6 +503,127 @@ CREATE INDEX idx_aitask_meeting ON biz_ai_tasks (meeting_id);
|
|||
COMMENT ON TABLE biz_meetings IS '会议管理主表';
|
||||
COMMENT ON TABLE biz_meeting_transcripts IS '会议转录明细表';
|
||||
COMMENT ON TABLE biz_ai_tasks IS 'AI 任务流水日志表';
|
||||
|
||||
-- ----------------------------
|
||||
-- 13. 会议积分模式增量结构
|
||||
-- ----------------------------
|
||||
ALTER TABLE biz_meetings
|
||||
ADD COLUMN IF NOT EXISTS effective_audio_duration_seconds INTEGER;
|
||||
|
||||
COMMENT ON COLUMN biz_meetings.effective_audio_duration_seconds IS '会议最终有效录音时长(秒),用于统计与计费口径';
|
||||
|
||||
DROP TABLE IF EXISTS biz_meeting_user_stats CASCADE;
|
||||
CREATE TABLE biz_meeting_user_stats (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
user_id BIGINT NOT NULL,
|
||||
total_meeting_duration_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
total_meeting_duration_minutes BIGINT NOT NULL DEFAULT 0,
|
||||
total_summary_charge_count BIGINT NOT NULL DEFAULT 0,
|
||||
total_summary_attempt_count BIGINT NOT NULL DEFAULT 0,
|
||||
is_deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_biz_meeting_user_stats_tenant_user
|
||||
ON biz_meeting_user_stats (tenant_id, user_id);
|
||||
|
||||
COMMENT ON TABLE biz_meeting_user_stats IS '会议用户时长统计账户表';
|
||||
|
||||
DROP TABLE IF EXISTS biz_meeting_points_accounts CASCADE;
|
||||
CREATE TABLE biz_meeting_points_accounts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
user_id BIGINT NOT NULL,
|
||||
current_balance BIGINT NOT NULL DEFAULT 0,
|
||||
total_points_used BIGINT NOT NULL DEFAULT 0,
|
||||
total_asr_points_used BIGINT NOT NULL DEFAULT 0,
|
||||
total_llm_points_used BIGINT NOT NULL DEFAULT 0,
|
||||
is_deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_biz_meeting_points_accounts_tenant_user
|
||||
ON biz_meeting_points_accounts (tenant_id, user_id);
|
||||
|
||||
COMMENT ON TABLE biz_meeting_points_accounts IS '会议积分账户表';
|
||||
COMMENT ON COLUMN biz_meeting_points_accounts.user_id IS '0表示公共账户,非0表示个人账户';
|
||||
|
||||
DROP TABLE IF EXISTS biz_meeting_summary_charge_records CASCADE;
|
||||
CREATE TABLE biz_meeting_summary_charge_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
meeting_id BIGINT NOT NULL,
|
||||
summary_task_id BIGINT,
|
||||
user_id BIGINT NOT NULL,
|
||||
audio_duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
charged_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
billing_units INTEGER NOT NULL DEFAULT 0,
|
||||
unit_minutes_snapshot INTEGER NOT NULL DEFAULT 1,
|
||||
cost_per_unit_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||
total_points BIGINT NOT NULL DEFAULT 0,
|
||||
asr_points BIGINT NOT NULL DEFAULT 0,
|
||||
llm_points BIGINT NOT NULL DEFAULT 0,
|
||||
asr_ratio_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||
llm_ratio_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||
balance_before BIGINT,
|
||||
balance_after BIGINT,
|
||||
points_delta BIGINT NOT NULL DEFAULT 0,
|
||||
charge_trigger_type VARCHAR(32) NOT NULL,
|
||||
summary_status VARCHAR(32) NOT NULL DEFAULT 'CREATED',
|
||||
points_mode_enabled SMALLINT NOT NULL DEFAULT 0,
|
||||
blocked_reason VARCHAR(64),
|
||||
failure_reason VARCHAR(500),
|
||||
charged_at TIMESTAMP(6),
|
||||
is_deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_biz_meeting_summary_charge_records_meeting
|
||||
ON biz_meeting_summary_charge_records (meeting_id);
|
||||
|
||||
CREATE INDEX idx_biz_meeting_summary_charge_records_user
|
||||
ON biz_meeting_summary_charge_records (user_id);
|
||||
|
||||
CREATE INDEX idx_biz_meeting_summary_charge_records_task
|
||||
ON biz_meeting_summary_charge_records (summary_task_id);
|
||||
|
||||
COMMENT ON TABLE biz_meeting_summary_charge_records IS '会议总结消耗记录表';
|
||||
COMMENT ON COLUMN biz_meeting_summary_charge_records.total_points IS '本次记录应计总积分,重新总结场景仅记录LLM应计积分';
|
||||
|
||||
DROP TABLE IF EXISTS biz_meeting_points_ledgers CASCADE;
|
||||
CREATE TABLE biz_meeting_points_ledgers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
user_id BIGINT NOT NULL,
|
||||
meeting_id BIGINT,
|
||||
summary_task_id BIGINT,
|
||||
charge_record_id BIGINT,
|
||||
points_delta BIGINT NOT NULL,
|
||||
points_type VARCHAR(32) NOT NULL,
|
||||
balance_before BIGINT NOT NULL,
|
||||
balance_after BIGINT NOT NULL,
|
||||
remark VARCHAR(500),
|
||||
is_deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_biz_meeting_points_ledgers_user
|
||||
ON biz_meeting_points_ledgers (user_id);
|
||||
|
||||
CREATE INDEX idx_biz_meeting_points_ledgers_meeting
|
||||
ON biz_meeting_points_ledgers (meeting_id);
|
||||
|
||||
COMMENT ON TABLE biz_meeting_points_ledgers IS '会议积分流水表';
|
||||
COMMENT ON COLUMN biz_meeting_points_ledgers.user_id IS '0表示公共账户,非0表示个人账户';
|
||||
DROP TABLE IF EXISTS "biz_prompt_template_user_config";
|
||||
CREATE TABLE "biz_prompt_template_user_config" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -14,4 +14,11 @@ public final class SysParamKeys {
|
|||
public static final String MEETING_PACKET_LOSS_RATE = "meeting.packet_loss_rate";
|
||||
public static final String MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED = "meeting.android.audio.chunk_upload_enabled";
|
||||
public static final String MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS = "meeting.android.audio.chunk_duration_seconds";
|
||||
public static final String MEETING_POINTS_ENABLED = "meeting.points.enabled";
|
||||
public static final String MEETING_POINTS_UNIT_MINUTES = "meeting.points.unit_minutes";
|
||||
public static final String MEETING_POINTS_COST_PER_UNIT = "meeting.points.cost_per_unit";
|
||||
public static final String MEETING_POINTS_ASR_RATIO = "meeting.points.asr_ratio";
|
||||
public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio";
|
||||
public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance";
|
||||
public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
|
|||
import com.imeeting.service.android.AndroidAuthService;
|
||||
import com.imeeting.service.android.AndroidDeviceRegistrationService;
|
||||
import com.imeeting.support.AndroidRequestLogHelper;
|
||||
import com.unisbase.annotation.Anonymous;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
|
|
@ -38,6 +39,7 @@ public class AndroidDeviceController {
|
|||
)
|
||||
})
|
||||
@PostMapping("/register")
|
||||
@Anonymous
|
||||
public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request,
|
||||
@RequestBody(required = false) AndroidDeviceRegisterRequest command) {
|
||||
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command);
|
||||
|
|
|
|||
|
|
@ -130,10 +130,10 @@ public class AndroidMeetingController {
|
|||
AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command);
|
||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||
Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId());
|
||||
if (existingMeeting != null) {
|
||||
return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
|
||||
}
|
||||
// Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId());
|
||||
// if (existingMeeting != null) {
|
||||
// return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
|
||||
// }
|
||||
return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, authContext, loginUser));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package com.imeeting.controller.biz;
|
||||
|
||||
import com.imeeting.dto.biz.MeetingPointsBalanceVO;
|
||||
import com.imeeting.service.biz.MeetingPointsService;
|
||||
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 org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Tag(name = "会议积分")
|
||||
@RestController
|
||||
@RequestMapping("/api/biz/meeting-points")
|
||||
public class MeetingPointsController {
|
||||
private final MeetingPointsService meetingPointsService;
|
||||
|
||||
public MeetingPointsController(MeetingPointsService meetingPointsService) {
|
||||
this.meetingPointsService = meetingPointsService;
|
||||
}
|
||||
|
||||
@Operation(summary = "查看会议积分余额")
|
||||
@GetMapping("/balance")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<MeetingPointsBalanceVO> getBalance(@RequestParam(value = "userId", required = false) Long userId) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
Long targetUserId = resolveTargetUserId(loginUser, userId);
|
||||
return ApiResponse.ok(meetingPointsService.getBalanceView(loginUser.getTenantId(), targetUserId));
|
||||
}
|
||||
|
||||
private Long resolveTargetUserId(LoginUser loginUser, Long requestedUserId) {
|
||||
if (requestedUserId == null || requestedUserId.equals(loginUser.getUserId())) {
|
||||
return loginUser.getUserId();
|
||||
}
|
||||
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
|
||||
if (!isAdmin) {
|
||||
throw new RuntimeException("无权查看其他用户积分余额");
|
||||
}
|
||||
return requestedUserId;
|
||||
}
|
||||
|
||||
private LoginUser currentLoginUser() {
|
||||
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.imeeting.controller.biz;
|
||||
|
||||
import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO;
|
||||
import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO;
|
||||
import com.imeeting.dto.biz.MeetingPointsOverviewVO;
|
||||
import com.imeeting.service.biz.MeetingPointsQueryService;
|
||||
import com.unisbase.common.ApiResponse;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "积分管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/biz/meeting-points/management")
|
||||
public class MeetingPointsManagementController {
|
||||
private final MeetingPointsQueryService meetingPointsQueryService;
|
||||
|
||||
public MeetingPointsManagementController(MeetingPointsQueryService meetingPointsQueryService) {
|
||||
this.meetingPointsQueryService = meetingPointsQueryService;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取积分管理总览")
|
||||
@GetMapping("/overview")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<MeetingPointsOverviewVO> getOverview() {
|
||||
return ApiResponse.ok(meetingPointsQueryService.getOverview(currentLoginUser().getTenantId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询积分消耗流水")
|
||||
@GetMapping("/ledgers")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<PageResult<List<MeetingPointsLedgerListItemVO>>> pageLedgers(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(required = false) String pointsType) {
|
||||
return ApiResponse.ok(meetingPointsQueryService.pageLedgers(currentLoginUser().getTenantId(), current, size, username, pointsType));
|
||||
}
|
||||
|
||||
@Operation(summary = "查看积分消耗流水详情")
|
||||
@GetMapping("/ledgers/{ledgerId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<MeetingPointsLedgerDetailVO> getLedgerDetail(@PathVariable Long ledgerId) {
|
||||
return ApiResponse.ok(meetingPointsQueryService.getLedgerDetail(currentLoginUser().getTenantId(), ledgerId));
|
||||
}
|
||||
|
||||
private LoginUser currentLoginUser() {
|
||||
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.imeeting.dto.android;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Android设备注册请求")
|
||||
public class AndroidDeviceRegisterRequest {
|
||||
@Schema(description = "设备名称")
|
||||
private String deviceName;
|
||||
|
||||
@Schema(description = "终端类型,可为空,默认使用请求头平台")
|
||||
private String terminalType;
|
||||
|
||||
@Schema(description = "终端版本,可为空,默认使用请求头版本")
|
||||
private String terminalVersion;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.imeeting.dto.android;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Android设备注册响应")
|
||||
public class AndroidDeviceRegisterResponse {
|
||||
@Schema(description = "设备编码")
|
||||
private String deviceCode;
|
||||
|
||||
@Schema(description = "设备名称")
|
||||
private String deviceName;
|
||||
|
||||
@Schema(description = "终端类型")
|
||||
private String terminalType;
|
||||
|
||||
@Schema(description = "终端版本")
|
||||
private String terminalVersion;
|
||||
|
||||
@Schema(description = "是否已被用户占用")
|
||||
private Boolean occupied;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "会议积分余额视图")
|
||||
public class MeetingPointsBalanceVO {
|
||||
@Schema(description = "租户ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "目标用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "当前优先扣费账户类型:PUBLIC / PERSONAL")
|
||||
private String preferredAccountMode;
|
||||
|
||||
@Schema(description = "公共账户余额")
|
||||
private Long publicBalance;
|
||||
|
||||
@Schema(description = "公共账户累计消耗积分")
|
||||
private Long publicTotalPointsUsed;
|
||||
|
||||
@Schema(description = "个人账户余额")
|
||||
private Long personalBalance;
|
||||
|
||||
@Schema(description = "个人账户累计消耗积分")
|
||||
private Long personalTotalPointsUsed;
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
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 MeetingPointsLedgerDetailVO {
|
||||
@Schema(description = "流水ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "会议ID")
|
||||
private Long meetingId;
|
||||
|
||||
@Schema(description = "会议标题")
|
||||
private String meetingTitle;
|
||||
|
||||
@Schema(description = "总结任务ID")
|
||||
private Long summaryTaskId;
|
||||
|
||||
@Schema(description = "消耗归属用户ID")
|
||||
private Long ownerUserId;
|
||||
|
||||
@Schema(description = "消耗归属用户名")
|
||||
private String ownerUserName;
|
||||
|
||||
@Schema(description = "账户类型:PUBLIC / PERSONAL")
|
||||
private String chargeAccountType;
|
||||
|
||||
@Schema(description = "账户用户ID")
|
||||
private Long chargeAccountUserId;
|
||||
|
||||
@Schema(description = "消耗类型")
|
||||
private String pointsType;
|
||||
|
||||
@Schema(description = "消耗积分,展示为正数")
|
||||
private Long consumedPoints;
|
||||
|
||||
@Schema(description = "扣费前余额")
|
||||
private Long balanceBefore;
|
||||
|
||||
@Schema(description = "扣费后余额")
|
||||
private Long balanceAfter;
|
||||
|
||||
@Schema(description = "触发类型")
|
||||
private String chargeTriggerType;
|
||||
|
||||
@Schema(description = "录音时长秒数")
|
||||
private Integer audioDurationSeconds;
|
||||
|
||||
@Schema(description = "计费分钟数")
|
||||
private Integer chargedMinutes;
|
||||
|
||||
@Schema(description = "计费单位数")
|
||||
private Integer billingUnits;
|
||||
|
||||
@Schema(description = "单位分钟数")
|
||||
private Integer unitMinutesSnapshot;
|
||||
|
||||
@Schema(description = "单位价格")
|
||||
private Integer costPerUnitSnapshot;
|
||||
|
||||
@Schema(description = "ASR比例")
|
||||
private Integer asrRatioSnapshot;
|
||||
|
||||
@Schema(description = "LLM比例")
|
||||
private Integer llmRatioSnapshot;
|
||||
|
||||
@Schema(description = "应计总积分")
|
||||
private Long totalPoints;
|
||||
|
||||
@Schema(description = "已扣总积分")
|
||||
private Long chargedTotalPoints;
|
||||
|
||||
@Schema(description = "应计ASR积分")
|
||||
private Long asrPoints;
|
||||
|
||||
@Schema(description = "已扣ASR积分")
|
||||
private Long chargedAsrPoints;
|
||||
|
||||
@Schema(description = "应计LLM积分")
|
||||
private Long llmPoints;
|
||||
|
||||
@Schema(description = "已扣LLM积分")
|
||||
private Long chargedLlmPoints;
|
||||
|
||||
@Schema(description = "消耗记录状态")
|
||||
private String summaryStatus;
|
||||
|
||||
@Schema(description = "失败原因")
|
||||
private String failureReason;
|
||||
|
||||
@Schema(description = "ASR扣费时间")
|
||||
private LocalDateTime asrChargedAt;
|
||||
|
||||
@Schema(description = "LLM扣费时间")
|
||||
private LocalDateTime llmChargedAt;
|
||||
|
||||
@Schema(description = "记录创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
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 MeetingPointsLedgerListItemVO {
|
||||
@Schema(description = "流水ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "会议ID")
|
||||
private Long meetingId;
|
||||
|
||||
@Schema(description = "会议标题")
|
||||
private String meetingTitle;
|
||||
|
||||
@Schema(description = "总结任务ID")
|
||||
private Long summaryTaskId;
|
||||
|
||||
@Schema(description = "消耗归属用户ID")
|
||||
private Long ownerUserId;
|
||||
|
||||
@Schema(description = "消耗归属用户名")
|
||||
private String ownerUserName;
|
||||
|
||||
@Schema(description = "账户类型:PUBLIC / PERSONAL")
|
||||
private String chargeAccountType;
|
||||
|
||||
@Schema(description = "消耗类型:ASR / LLM / INIT / RECHARGE")
|
||||
private String pointsType;
|
||||
|
||||
@Schema(description = "消耗积分,展示为正数")
|
||||
private Long consumedPoints;
|
||||
|
||||
@Schema(description = "扣费前余额")
|
||||
private Long balanceBefore;
|
||||
|
||||
@Schema(description = "扣费后余额")
|
||||
private Long balanceAfter;
|
||||
|
||||
@Schema(description = "触发类型:AUTO_SUMMARY / RESUMMARY")
|
||||
private String chargeTriggerType;
|
||||
|
||||
@Schema(description = "消耗时间")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
|
@ -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 MeetingPointsOverviewVO {
|
||||
@Schema(description = "当前结算模式:PUBLIC / PERSONAL")
|
||||
private String accountMode;
|
||||
|
||||
@Schema(description = "公共账户余额")
|
||||
private Long publicBalance;
|
||||
|
||||
@Schema(description = "公共账户累计消耗积分")
|
||||
private Long publicTotalPointsUsed;
|
||||
|
||||
@Schema(description = "累计消耗次数")
|
||||
private Long totalChargeCount;
|
||||
}
|
||||
|
|
@ -57,6 +57,8 @@ public class MeetingVO {
|
|||
private String accessPassword;
|
||||
@Schema(description = "音频时长,单位秒")
|
||||
private Integer duration;
|
||||
@Schema(description = "会议最终有效录音时长,单位秒")
|
||||
private Integer effectiveAudioDurationSeconds;
|
||||
@Schema(description = "会议摘要内容")
|
||||
private String summaryContent;
|
||||
@Schema(description = "最后一次用户补充提示词")
|
||||
|
|
@ -69,6 +71,8 @@ public class MeetingVO {
|
|||
private Integer latestSummaryAttemptStatus;
|
||||
@Schema(description = "最近一次总结尝试错误信息")
|
||||
private String latestSummaryAttemptErrorMsg;
|
||||
@Schema(description = "最近一次总结尝试阻塞原因")
|
||||
private String latestSummaryAttemptBlockedReason;
|
||||
@Schema(description = "最近一次章节尝试任务 ID")
|
||||
private Long latestChapterAttemptTaskId;
|
||||
@Schema(description = "最近一次章节尝试任务状态")
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ public class Meeting extends BaseEntity {
|
|||
@Schema(description = "总结详细程度")
|
||||
private String summaryDetailLevel;
|
||||
|
||||
@Schema(description = "会议最终有效录音时长(秒)")
|
||||
private Integer effectiveAudioDurationSeconds;
|
||||
|
||||
@Schema(description = "音频保存状态")
|
||||
private String audioSaveStatus;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
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;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "会议积分账户实体")
|
||||
@TableName("biz_meeting_points_accounts")
|
||||
public class MeetingPointsAccount extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "当前积分余额")
|
||||
private Long currentBalance;
|
||||
|
||||
@Schema(description = "累计消耗总积分")
|
||||
private Long totalPointsUsed;
|
||||
|
||||
@Schema(description = "累计消耗ASR积分")
|
||||
private Long totalAsrPointsUsed;
|
||||
|
||||
@Schema(description = "累计消耗LLM积分")
|
||||
private Long totalLlmPointsUsed;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "会议积分流水实体")
|
||||
@TableName("biz_meeting_points_ledgers")
|
||||
public class MeetingPointsLedger extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "会议ID")
|
||||
private Long meetingId;
|
||||
|
||||
@Schema(description = "总结任务ID")
|
||||
private Long summaryTaskId;
|
||||
|
||||
@Schema(description = "总结消耗记录ID")
|
||||
private Long chargeRecordId;
|
||||
|
||||
@Schema(description = "积分变化值")
|
||||
private Long pointsDelta;
|
||||
|
||||
@Schema(description = "积分类型:ASR / LLM / RECHARGE / INIT")
|
||||
private String pointsType;
|
||||
|
||||
@Schema(description = "变动前余额")
|
||||
private Long balanceBefore;
|
||||
|
||||
@Schema(description = "变动后余额")
|
||||
private Long balanceAfter;
|
||||
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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)
|
||||
@Schema(description = "会议总结消耗记录实体")
|
||||
@TableName("biz_meeting_summary_charge_records")
|
||||
public class MeetingSummaryChargeRecord extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "会议ID")
|
||||
private Long meetingId;
|
||||
|
||||
@Schema(description = "总结任务ID")
|
||||
private Long summaryTaskId;
|
||||
|
||||
@Schema(description = "扣费主体用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "扣费账户类型:PUBLIC / PERSONAL")
|
||||
private String chargeAccountType;
|
||||
|
||||
@Schema(description = "扣费账户用户ID,公共账户固定为0")
|
||||
private Long chargeAccountUserId;
|
||||
|
||||
@Schema(description = "本次统计使用的有效录音时长(秒)")
|
||||
private Integer audioDurationSeconds;
|
||||
|
||||
@Schema(description = "本次计费分钟数")
|
||||
private Integer chargedMinutes;
|
||||
|
||||
@Schema(description = "本次计费单位数")
|
||||
private Integer billingUnits;
|
||||
|
||||
@Schema(description = "计费单位分钟数快照")
|
||||
private Integer unitMinutesSnapshot;
|
||||
|
||||
@Schema(description = "每计费单位积分单价快照")
|
||||
private Integer costPerUnitSnapshot;
|
||||
|
||||
@Schema(description = "本次总积分")
|
||||
private Long totalPoints;
|
||||
|
||||
@Schema(description = "已实际扣减总积分")
|
||||
private Long chargedTotalPoints;
|
||||
|
||||
@Schema(description = "本次ASR积分")
|
||||
private Long asrPoints;
|
||||
|
||||
@Schema(description = "已实际扣减ASR积分")
|
||||
private Long chargedAsrPoints;
|
||||
|
||||
@Schema(description = "本次LLM积分")
|
||||
private Long llmPoints;
|
||||
|
||||
@Schema(description = "已实际扣减LLM积分")
|
||||
private Long chargedLlmPoints;
|
||||
|
||||
@Schema(description = "ASR比例快照")
|
||||
private Integer asrRatioSnapshot;
|
||||
|
||||
@Schema(description = "LLM比例快照")
|
||||
private Integer llmRatioSnapshot;
|
||||
|
||||
@Schema(description = "扣费前余额")
|
||||
private Long balanceBefore;
|
||||
|
||||
@Schema(description = "扣费后余额")
|
||||
private Long balanceAfter;
|
||||
|
||||
@Schema(description = "积分变化值,扣费为负数")
|
||||
private Long pointsDelta;
|
||||
|
||||
@Schema(description = "触发类型:AUTO_SUMMARY / RESUMMARY")
|
||||
private String chargeTriggerType;
|
||||
|
||||
@Schema(description = "记录状态:BLOCKED / CREATED / CHARGED / FAILED / COMPLETED / DISABLED")
|
||||
private String summaryStatus;
|
||||
|
||||
@Schema(description = "积分模式是否开启")
|
||||
private Integer pointsModeEnabled;
|
||||
|
||||
@Schema(description = "阻塞原因")
|
||||
private String blockedReason;
|
||||
|
||||
@Schema(description = "失败原因")
|
||||
private String failureReason;
|
||||
|
||||
@Schema(description = "收费发生时间")
|
||||
private LocalDateTime chargedAt;
|
||||
|
||||
@Schema(description = "ASR扣费时间")
|
||||
private LocalDateTime asrChargedAt;
|
||||
|
||||
@Schema(description = "LLM扣费时间")
|
||||
private LocalDateTime llmChargedAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "会议用户时长统计账户实体")
|
||||
@TableName("biz_meeting_user_stats")
|
||||
public class MeetingUserStats extends BaseEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "累计会议有效时长(秒)")
|
||||
private Long totalMeetingDurationSeconds;
|
||||
|
||||
@Schema(description = "累计会议有效时长(分钟)")
|
||||
private Long totalMeetingDurationMinutes;
|
||||
|
||||
@Schema(description = "累计成功创建总结任务次数")
|
||||
private Long totalSummaryChargeCount;
|
||||
|
||||
@Schema(description = "累计总结尝试次数,包含失败拦截")
|
||||
private Long totalSummaryAttemptCount;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.MeetingPointsAccount;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface MeetingPointsAccountMapper extends BaseMapper<MeetingPointsAccount> {
|
||||
@Select("""
|
||||
SELECT *
|
||||
FROM biz_meeting_points_accounts
|
||||
WHERE tenant_id = #{tenantId}
|
||||
AND user_id = #{userId}
|
||||
AND is_deleted = 0
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
""")
|
||||
MeetingPointsAccount selectForUpdate(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.MeetingPointsLedger;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MeetingPointsLedgerMapper extends BaseMapper<MeetingPointsLedger> {
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.MeetingSummaryChargeRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface MeetingSummaryChargeRecordMapper extends BaseMapper<MeetingSummaryChargeRecord> {
|
||||
@Select("""
|
||||
SELECT *
|
||||
FROM biz_meeting_summary_charge_records
|
||||
WHERE summary_task_id = #{summaryTaskId}
|
||||
AND is_deleted = 0
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
""")
|
||||
MeetingSummaryChargeRecord selectForUpdateBySummaryTaskId(@Param("summaryTaskId") Long summaryTaskId);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MeetingUserStatsMapper extends BaseMapper<MeetingUserStats> {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.service.android;
|
||||
|
||||
import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
|
||||
|
||||
public interface AndroidDeviceRegistrationService {
|
||||
AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion);
|
||||
|
||||
void requireRegistered(String deviceCode);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.imeeting.service.android.impl;
|
||||
|
||||
import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
|
||||
import com.imeeting.entity.biz.DeviceInfoEntity;
|
||||
import com.imeeting.mapper.DeviceInfoMapper;
|
||||
import com.imeeting.service.android.AndroidDeviceRegistrationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegistrationService {
|
||||
private final DeviceInfoMapper deviceInfoMapper;
|
||||
|
||||
@Override
|
||||
public AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion) {
|
||||
if (!StringUtils.hasText(deviceCode)) {
|
||||
throw new RuntimeException("deviceId不能为空");
|
||||
}
|
||||
String normalizedDeviceCode = deviceCode.trim();
|
||||
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);
|
||||
deviceInfoMapper.insert(existing);
|
||||
} else {
|
||||
existing.setDeviceName(normalize(deviceName));
|
||||
existing.setTerminalType(normalizeTerminalType(terminalType));
|
||||
existing.setTerminalVersion(normalize(terminalVersion));
|
||||
deviceInfoMapper.updateBaseInfoByIdIgnoreTenant(existing);
|
||||
}
|
||||
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);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requireRegistered(String deviceCode) {
|
||||
if (!StringUtils.hasText(deviceCode) || deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()) == null) {
|
||||
throw new RuntimeException("设备未注册,请先完成设备注册");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String normalizeTerminalType(String value) {
|
||||
String normalized = normalize(value);
|
||||
return normalized == null ? null : normalized.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.biz.MeetingPointsAccount;
|
||||
|
||||
public interface MeetingPointsAccountService extends IService<MeetingPointsAccount> {
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.biz.MeetingPointsLedger;
|
||||
|
||||
public interface MeetingPointsLedgerService extends IService<MeetingPointsLedger> {
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO;
|
||||
import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO;
|
||||
import com.imeeting.dto.biz.MeetingPointsOverviewVO;
|
||||
import com.unisbase.dto.PageResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface MeetingPointsQueryService {
|
||||
MeetingPointsOverviewVO getOverview(Long tenantId);
|
||||
|
||||
PageResult<List<MeetingPointsLedgerListItemVO>> pageLedgers(Long tenantId,
|
||||
Integer current,
|
||||
Integer size,
|
||||
String username,
|
||||
String pointsType);
|
||||
|
||||
MeetingPointsLedgerDetailVO getLedgerDetail(Long tenantId, Long ledgerId);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.dto.biz.MeetingPointsBalanceVO;
|
||||
|
||||
public interface MeetingPointsService {
|
||||
long UNIFIED_ACCOUNT_USER_ID = 0L;
|
||||
|
||||
void recordAsrSuccessCharge(Meeting meeting, AiTask asrTask);
|
||||
|
||||
void recordSummarySuccessCharge(Meeting meeting, AiTask summaryTask);
|
||||
|
||||
void markSummaryChargeFailed(Long summaryTaskId, String failureReason);
|
||||
|
||||
String resolveLatestBlockedReason(Long summaryTaskId);
|
||||
|
||||
MeetingPointsBalanceVO getBalanceView(Long tenantId, Long userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.biz.MeetingSummaryChargeRecord;
|
||||
|
||||
public interface MeetingSummaryChargeRecordService extends IService<MeetingSummaryChargeRecord> {
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
|
||||
public interface MeetingUserStatsService extends IService<MeetingUserStats> {
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import com.imeeting.service.biz.AiModelService;
|
|||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.MeetingProgressService;
|
||||
import com.imeeting.service.biz.MeetingPointsService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||
|
|
@ -71,6 +72,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private final HotWordService hotWordService;
|
||||
private final MeetingLockCache meetingLockCache;
|
||||
private final MeetingProgressService meetingProgressService;
|
||||
private final MeetingPointsService meetingPointsService;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingTranscriptFileService meetingTranscriptFileService;
|
||||
|
||||
|
|
@ -116,6 +118,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
HotWordService hotWordService,
|
||||
MeetingLockCache meetingLockCache,
|
||||
MeetingProgressService meetingProgressService,
|
||||
MeetingPointsService meetingPointsService,
|
||||
MeetingSummaryFileService meetingSummaryFileService,
|
||||
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||
|
|
@ -132,6 +135,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.hotWordService = hotWordService;
|
||||
this.meetingLockCache = meetingLockCache;
|
||||
this.meetingProgressService = meetingProgressService;
|
||||
this.meetingPointsService = meetingPointsService;
|
||||
this.meetingSummaryFileService = meetingSummaryFileService;
|
||||
this.meetingTranscriptFileService = meetingTranscriptFileService;
|
||||
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
|
||||
|
|
@ -300,12 +304,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (meeting == null) {
|
||||
return;
|
||||
}
|
||||
if (isExternalSummaryModeEnabled()) {
|
||||
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
|
||||
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
||||
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_SUMMARY_DISPATCH", false);
|
||||
return;
|
||||
}
|
||||
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
|
||||
try {
|
||||
if (sumTask != null && canExecuteTask(sumTask)) {
|
||||
|
|
@ -549,7 +547,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
if (resultNode == null) throw new RuntimeException("ASR轮询超时");
|
||||
|
||||
// 解析并入库(防御性清理旧数据)
|
||||
return saveTranscripts(meeting, resultNode);
|
||||
String transcriptText = saveTranscripts(meeting, resultNode);
|
||||
meetingPointsService.recordAsrSuccessCharge(meeting, taskRecord);
|
||||
return transcriptText;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildAsrRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
|
||||
|
|
@ -966,6 +966,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
meeting.setLatestSummaryTaskId(taskRecord.getId());
|
||||
meetingMapper.updateById(meeting);
|
||||
meetingPointsService.recordSummarySuccessCharge(meeting, taskRecord);
|
||||
|
||||
AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER");
|
||||
if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) {
|
||||
|
|
@ -1352,6 +1353,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
task.setErrorMsg(error);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
this.updateById(task);
|
||||
if ("SUMMARY".equals(task.getTaskType())) {
|
||||
meetingPointsService.markSummaryChargeFailed(task.getId(), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.imeeting.service.biz.AiTaskService;
|
|||
import com.imeeting.service.biz.HotWordService;
|
||||
import com.imeeting.service.biz.MeetingCommandService;
|
||||
import com.imeeting.service.biz.MeetingProgressService;
|
||||
import com.imeeting.service.biz.MeetingPointsService;
|
||||
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
|
|
@ -78,6 +79,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
|
||||
private final MeetingProgressService meetingProgressService;
|
||||
private final MeetingPointsService meetingPointsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
|
||||
private final AndroidMeetingPushService androidMeetingPushService;
|
||||
|
|
@ -103,6 +105,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
||||
RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService,
|
||||
MeetingProgressService meetingProgressService,
|
||||
MeetingPointsService meetingPointsService,
|
||||
ObjectMapper objectMapper,
|
||||
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
|
||||
AndroidMeetingPushService androidMeetingPushService,
|
||||
|
|
@ -123,6 +126,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
|
||||
this.realtimeMeetingAudioStorageService = realtimeMeetingAudioStorageService;
|
||||
this.meetingProgressService = meetingProgressService;
|
||||
this.meetingPointsService = meetingPointsService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
|
||||
this.androidMeetingPushService = androidMeetingPushService;
|
||||
|
|
@ -196,7 +200,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
summaryDetailLevel
|
||||
);
|
||||
}
|
||||
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
|
||||
meetingDomainSupport.applyMeetingAudioMetadata(
|
||||
meeting,
|
||||
meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl())
|
||||
);
|
||||
meetingService.updateById(meeting);
|
||||
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
|
||||
meetingDomainSupport.publishMeetingCreated(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
|
||||
|
|
@ -441,7 +448,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
if (audioUrl == null || audioUrl.isBlank()) {
|
||||
throw new RuntimeException("overwriteAudio 为 true 时必须提供音频地址");
|
||||
}
|
||||
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl));
|
||||
meetingDomainSupport.applyMeetingAudioMetadata(
|
||||
meeting,
|
||||
meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)
|
||||
);
|
||||
markAudioSaveSuccess(meeting);
|
||||
meetingService.updateById(meeting);
|
||||
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
|
||||
|
|
@ -453,7 +463,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
}
|
||||
|
||||
if (audioUrl != null && !audioUrl.isBlank()) {
|
||||
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl));
|
||||
meetingDomainSupport.applyMeetingAudioMetadata(
|
||||
meeting,
|
||||
meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)
|
||||
);
|
||||
markAudioSaveSuccess(meeting);
|
||||
meetingService.updateById(meeting);
|
||||
}
|
||||
|
|
@ -486,7 +499,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
return;
|
||||
}
|
||||
if (result.audioUrl() != null && !result.audioUrl().isBlank()) {
|
||||
meeting.setAudioUrl(result.audioUrl());
|
||||
meetingDomainSupport.applyMeetingAudioMetadata(meeting, result.audioUrl());
|
||||
}
|
||||
if (result.failed()) {
|
||||
markAudioSaveFailure(meeting, result.message());
|
||||
|
|
@ -764,7 +777,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
summaryModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
meeting.getSummaryDetailLevel()
|
||||
meeting.getSummaryDetailLevel(),
|
||||
"RESUMMARY"
|
||||
)
|
||||
: meetingDomainSupport.createSummaryTask(
|
||||
meeting.getId(),
|
||||
|
|
@ -772,7 +786,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
chapterModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
meeting.getSummaryDetailLevel()
|
||||
meeting.getSummaryDetailLevel(),
|
||||
"RESUMMARY"
|
||||
);
|
||||
meeting.setLatestSummaryTaskId(createdSummaryTask.getId());
|
||||
meeting.setStatus(2);
|
||||
|
|
@ -834,6 +849,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
summaryTask.setErrorMsg(null);
|
||||
summaryTask.setCompletedAt(java.time.LocalDateTime.now());
|
||||
aiTaskService.updateById(summaryTask);
|
||||
meetingPointsService.recordSummarySuccessCharge(meeting, summaryTask);
|
||||
|
||||
boolean alreadyCompleted = Integer.valueOf(3).equals(meeting.getStatus());
|
||||
meeting.setLatestSummaryTaskId(summaryTask.getId());
|
||||
|
|
@ -958,7 +974,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
summaryModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
effectiveSummaryDetailLevel
|
||||
effectiveSummaryDetailLevel,
|
||||
"RESUMMARY"
|
||||
);
|
||||
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
|
||||
meeting.setStatus(2);
|
||||
|
|
@ -1204,6 +1221,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
task.setErrorMsg(message);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
aiTaskService.updateById(task);
|
||||
if ("SUMMARY".equals(task.getTaskType())) {
|
||||
meetingPointsService.markSummaryChargeFailed(task.getId(), message);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchChapterTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.imeeting.entity.biz.MeetingTranscript;
|
|||
import com.imeeting.event.MeetingCreatedEvent;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.MeetingPointsService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import com.unisbase.entity.SysUser;
|
||||
|
|
@ -20,6 +21,9 @@ import org.springframework.stereotype.Component;
|
|||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
|
@ -42,6 +46,7 @@ public class MeetingDomainSupport {
|
|||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||
private final AiTaskService aiTaskService;
|
||||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingPointsService meetingPointsService;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
|
|
@ -92,7 +97,8 @@ public class MeetingDomainSupport {
|
|||
summaryModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
MeetingConstants.SUMMARY_DETAIL_STANDARD
|
||||
MeetingConstants.SUMMARY_DETAIL_STANDARD,
|
||||
"AUTO_SUMMARY"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +110,21 @@ public class MeetingDomainSupport {
|
|||
summaryModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
summaryDetailLevel
|
||||
summaryDetailLevel,
|
||||
"AUTO_SUMMARY"
|
||||
);
|
||||
}
|
||||
|
||||
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long promptId,
|
||||
String userPrompt, String summaryDetailLevel, String chargeTriggerType) {
|
||||
return createSummaryTask(
|
||||
meetingId,
|
||||
summaryModelId,
|
||||
summaryModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
summaryDetailLevel,
|
||||
chargeTriggerType
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -144,24 +164,34 @@ public class MeetingDomainSupport {
|
|||
chapterModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
MeetingConstants.SUMMARY_DETAIL_STANDARD
|
||||
MeetingConstants.SUMMARY_DETAIL_STANDARD,
|
||||
"AUTO_SUMMARY"
|
||||
);
|
||||
}
|
||||
|
||||
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId,
|
||||
String userPrompt, String summaryDetailLevel) {
|
||||
return createSummaryTask(meetingId, summaryModelId, chapterModelId, promptId, userPrompt, summaryDetailLevel, "AUTO_SUMMARY");
|
||||
}
|
||||
|
||||
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId,
|
||||
String userPrompt, String summaryDetailLevel, String chargeTriggerType) {
|
||||
AiTask sumTask = new AiTask();
|
||||
sumTask.setMeetingId(meetingId);
|
||||
sumTask.setTaskType("SUMMARY");
|
||||
sumTask.setStatus(0);
|
||||
sumTask.setQueuedAt(LocalDateTime.now());
|
||||
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(
|
||||
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
|
||||
summaryModelId,
|
||||
chapterModelId,
|
||||
promptId,
|
||||
userPrompt,
|
||||
normalizeSummaryDetailLevel(summaryDetailLevel)
|
||||
));
|
||||
);
|
||||
taskConfig.put("chargeTriggerType", chargeTriggerType == null || chargeTriggerType.isBlank()
|
||||
? "AUTO_SUMMARY"
|
||||
: chargeTriggerType.trim().toUpperCase());
|
||||
sumTask.setTaskConfig(taskConfig);
|
||||
aiTaskService.save(sumTask);
|
||||
return sumTask;
|
||||
}
|
||||
|
|
@ -198,6 +228,14 @@ public class MeetingDomainSupport {
|
|||
}
|
||||
}
|
||||
|
||||
public void applyMeetingAudioMetadata(Meeting meeting, String audioUrl) {
|
||||
if (meeting == null) {
|
||||
return;
|
||||
}
|
||||
meeting.setAudioUrl(audioUrl);
|
||||
meeting.setEffectiveAudioDurationSeconds(resolveAudioDurationSecondsByUrl(audioUrl));
|
||||
}
|
||||
|
||||
public void deleteMeetingArtifacts(Long meetingId) {
|
||||
if (meetingId == null) {
|
||||
return;
|
||||
|
|
@ -339,27 +377,6 @@ public class MeetingDomainSupport {
|
|||
return finalSpeakerId;
|
||||
}
|
||||
|
||||
public Integer resolveMeetingDuration(Long meetingId) {
|
||||
MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.isNotNull(MeetingTranscript::getEndTime)
|
||||
.orderByDesc(MeetingTranscript::getEndTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) {
|
||||
return latestTranscript.getEndTime();
|
||||
}
|
||||
|
||||
latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.isNotNull(MeetingTranscript::getStartTime)
|
||||
.orderByDesc(MeetingTranscript::getStartTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latestTranscript != null && latestTranscript.getStartTime() != null && latestTranscript.getStartTime() > 0) {
|
||||
return latestTranscript.getStartTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void fillMeetingVO(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo, boolean includeSummary,
|
||||
boolean includePlaybackAudio) {
|
||||
vo.setId(meeting.getId());
|
||||
|
|
@ -383,7 +400,9 @@ public class MeetingDomainSupport {
|
|||
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
|
||||
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
|
||||
vo.setAccessPassword(meeting.getAccessPassword());
|
||||
vo.setDuration(resolveMeetingDuration(meeting.getId()));
|
||||
Integer durationSeconds = meeting.getEffectiveAudioDurationSeconds();
|
||||
vo.setDuration(durationSeconds);
|
||||
vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds());
|
||||
vo.setStatus(meeting.getStatus());
|
||||
vo.setCreatedAt(meeting.getCreatedAt());
|
||||
|
||||
|
|
@ -460,6 +479,7 @@ public class MeetingDomainSupport {
|
|||
vo.setLatestSummaryAttemptTaskId(latestSummaryAttempt.getId());
|
||||
vo.setLatestSummaryAttemptStatus(latestSummaryAttempt.getStatus());
|
||||
vo.setLatestSummaryAttemptErrorMsg(normalizeTaskError(latestSummaryAttempt.getErrorMsg()));
|
||||
vo.setLatestSummaryAttemptBlockedReason(meetingPointsService.resolveLatestBlockedReason(latestSummaryAttempt.getId()));
|
||||
}
|
||||
|
||||
AiTask latestChapterAttempt = resolveLatestTaskAttempt(meeting, "CHAPTER");
|
||||
|
|
@ -501,6 +521,46 @@ public class MeetingDomainSupport {
|
|||
return MeetingConstants.SUMMARY_DETAIL_STANDARD;
|
||||
}
|
||||
|
||||
private Integer resolveAudioDurationSecondsByUrl(String audioUrl) {
|
||||
try {
|
||||
Path audioPath = resolvePublicAudioPath(audioUrl);
|
||||
if (audioPath == null) {
|
||||
return null;
|
||||
}
|
||||
File file = audioPath.toFile();
|
||||
if (!file.exists()) {
|
||||
return null;
|
||||
}
|
||||
try (AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(file)) {
|
||||
long frameLength = audioInputStream.getFrameLength();
|
||||
float frameRate = audioInputStream.getFormat().getFrameRate();
|
||||
if (frameLength <= 0 || frameRate <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (int) Math.ceil(frameLength / frameRate);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to resolve audio duration from audioUrl={}, skip effective duration update", audioUrl, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolvePublicAudioPath(String audioUrl) {
|
||||
if (audioUrl == null || audioUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedUrl = audioUrl.trim();
|
||||
if (normalizedUrl.startsWith("/api/static/meetings/")) {
|
||||
String relative = normalizedUrl.replace("/api/static/", "");
|
||||
return Paths.get(normalizedUploadPath(), relative);
|
||||
}
|
||||
if (normalizedUrl.startsWith("/api/static/audio/")) {
|
||||
String fileName = normalizedUrl.substring(normalizedUrl.lastIndexOf("/") + 1);
|
||||
return Paths.get(normalizedUploadPath(), "audio", fileName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.biz.MeetingPointsAccount;
|
||||
import com.imeeting.mapper.biz.MeetingPointsAccountMapper;
|
||||
import com.imeeting.service.biz.MeetingPointsAccountService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MeetingPointsAccountServiceImpl extends ServiceImpl<MeetingPointsAccountMapper, MeetingPointsAccount> implements MeetingPointsAccountService {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.biz.MeetingPointsLedger;
|
||||
import com.imeeting.mapper.biz.MeetingPointsLedgerMapper;
|
||||
import com.imeeting.service.biz.MeetingPointsLedgerService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MeetingPointsLedgerServiceImpl extends ServiceImpl<MeetingPointsLedgerMapper, MeetingPointsLedger> implements MeetingPointsLedgerService {
|
||||
}
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.imeeting.common.SysParamKeys;
|
||||
import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO;
|
||||
import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO;
|
||||
import com.imeeting.dto.biz.MeetingPointsOverviewVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingPointsAccount;
|
||||
import com.imeeting.entity.biz.MeetingPointsLedger;
|
||||
import com.imeeting.entity.biz.MeetingSummaryChargeRecord;
|
||||
import com.imeeting.service.biz.MeetingPointsAccountService;
|
||||
import com.imeeting.service.biz.MeetingPointsLedgerService;
|
||||
import com.imeeting.service.biz.MeetingPointsQueryService;
|
||||
import com.imeeting.service.biz.MeetingSummaryChargeRecordService;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.unisbase.dto.DataScopeRuleDTO;
|
||||
import com.unisbase.dto.PageResult;
|
||||
import com.unisbase.entity.SysUser;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
import com.unisbase.service.DataScopeService;
|
||||
import com.unisbase.service.SysParamService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService {
|
||||
private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC";
|
||||
private static final long PUBLIC_ACCOUNT_USER_ID = 0L;
|
||||
|
||||
private final MeetingPointsAccountService meetingPointsAccountService;
|
||||
private final MeetingPointsLedgerService meetingPointsLedgerService;
|
||||
private final MeetingSummaryChargeRecordService meetingSummaryChargeRecordService;
|
||||
private final MeetingService meetingService;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
private final DataScopeService dataScopeService;
|
||||
private final SysParamService sysParamService;
|
||||
|
||||
public MeetingPointsQueryServiceImpl(MeetingPointsAccountService meetingPointsAccountService,
|
||||
MeetingPointsLedgerService meetingPointsLedgerService,
|
||||
MeetingSummaryChargeRecordService meetingSummaryChargeRecordService,
|
||||
MeetingService meetingService,
|
||||
SysUserMapper sysUserMapper,
|
||||
DataScopeService dataScopeService,
|
||||
SysParamService sysParamService) {
|
||||
this.meetingPointsAccountService = meetingPointsAccountService;
|
||||
this.meetingPointsLedgerService = meetingPointsLedgerService;
|
||||
this.meetingSummaryChargeRecordService = meetingSummaryChargeRecordService;
|
||||
this.meetingService = meetingService;
|
||||
this.sysUserMapper = sysUserMapper;
|
||||
this.dataScopeService = dataScopeService;
|
||||
this.sysParamService = sysParamService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeetingPointsOverviewVO getOverview(Long tenantId) {
|
||||
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
|
||||
MeetingPointsAccount publicAccount = meetingPointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>()
|
||||
.eq(MeetingPointsAccount::getTenantId, tenantId)
|
||||
.eq(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
long totalChargeCount = 0L;
|
||||
long publicTotalPointsUsed = 0L;
|
||||
List<Long> scopedChargeRecordIds = resolveChargeRecordIdsByOwners(tenantId, scopedOwnerUserIds);
|
||||
if (scopedChargeRecordIds == null || !scopedChargeRecordIds.isEmpty()) {
|
||||
LambdaQueryWrapper<MeetingPointsLedger> scopedLedgerWrapper = new LambdaQueryWrapper<MeetingPointsLedger>()
|
||||
.eq(MeetingPointsLedger::getTenantId, tenantId)
|
||||
.lt(MeetingPointsLedger::getPointsDelta, 0)
|
||||
.in(scopedChargeRecordIds != null, MeetingPointsLedger::getChargeRecordId, scopedChargeRecordIds);
|
||||
totalChargeCount = meetingPointsLedgerService.count(scopedLedgerWrapper);
|
||||
publicTotalPointsUsed = meetingPointsLedgerService.list(scopedLedgerWrapper)
|
||||
.stream()
|
||||
.map(MeetingPointsLedger::getPointsDelta)
|
||||
.filter(Objects::nonNull)
|
||||
.mapToLong(value -> Math.abs(value))
|
||||
.sum();
|
||||
}
|
||||
|
||||
MeetingPointsOverviewVO vo = new MeetingPointsOverviewVO();
|
||||
vo.setAccountMode(resolveAccountMode());
|
||||
vo.setPublicBalance(publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance()));
|
||||
vo.setPublicTotalPointsUsed(publicTotalPointsUsed);
|
||||
vo.setTotalChargeCount(totalChargeCount);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<List<MeetingPointsLedgerListItemVO>> pageLedgers(Long tenantId,
|
||||
Integer current,
|
||||
Integer size,
|
||||
String username,
|
||||
String pointsType) {
|
||||
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
|
||||
if (scopedOwnerUserIds != null && scopedOwnerUserIds.isEmpty()) {
|
||||
return emptyPageResult();
|
||||
}
|
||||
|
||||
List<Long> matchedOwnerIds = resolveMatchedOwnerIds(tenantId, username, scopedOwnerUserIds);
|
||||
if (matchedOwnerIds != null && matchedOwnerIds.isEmpty()) {
|
||||
return emptyPageResult();
|
||||
}
|
||||
|
||||
List<Long> filteredChargeRecordIds = resolveChargeRecordIdsByOwners(tenantId, matchedOwnerIds);
|
||||
if (matchedOwnerIds != null && filteredChargeRecordIds.isEmpty()) {
|
||||
return emptyPageResult();
|
||||
}
|
||||
|
||||
Page<MeetingPointsLedger> page = new Page<>(current == null || current < 1 ? 1 : current, size == null || size < 1 ? 20 : size);
|
||||
LambdaQueryWrapper<MeetingPointsLedger> wrapper = new LambdaQueryWrapper<MeetingPointsLedger>()
|
||||
.eq(MeetingPointsLedger::getTenantId, tenantId)
|
||||
.lt(MeetingPointsLedger::getPointsDelta, 0)
|
||||
.eq(StringUtils.hasText(pointsType), MeetingPointsLedger::getPointsType, pointsType == null ? null : pointsType.trim().toUpperCase(Locale.ROOT))
|
||||
.in(filteredChargeRecordIds != null && !filteredChargeRecordIds.isEmpty(), MeetingPointsLedger::getChargeRecordId, filteredChargeRecordIds)
|
||||
.orderByDesc(MeetingPointsLedger::getCreatedAt)
|
||||
.orderByDesc(MeetingPointsLedger::getId);
|
||||
|
||||
Page<MeetingPointsLedger> resultPage = meetingPointsLedgerService.page(page, wrapper);
|
||||
List<MeetingPointsLedger> records = resultPage.getRecords();
|
||||
if (records == null || records.isEmpty()) {
|
||||
return toPageResult(resultPage.getTotal(), Collections.emptyList());
|
||||
}
|
||||
|
||||
Map<Long, MeetingSummaryChargeRecord> chargeRecordMap = loadChargeRecordMap(records);
|
||||
Map<Long, Meeting> meetingMap = loadMeetingMap(records);
|
||||
Map<Long, SysUser> ownerMap = loadOwnerMap(chargeRecordMap.values());
|
||||
|
||||
List<MeetingPointsLedgerListItemVO> items = new ArrayList<>();
|
||||
for (MeetingPointsLedger ledger : records) {
|
||||
MeetingSummaryChargeRecord chargeRecord = chargeRecordMap.get(ledger.getChargeRecordId());
|
||||
Meeting meeting = meetingMap.get(ledger.getMeetingId());
|
||||
SysUser owner = chargeRecord == null ? null : ownerMap.get(chargeRecord.getUserId());
|
||||
|
||||
MeetingPointsLedgerListItemVO item = new MeetingPointsLedgerListItemVO();
|
||||
item.setId(ledger.getId());
|
||||
item.setTenantId(ledger.getTenantId());
|
||||
item.setMeetingId(ledger.getMeetingId());
|
||||
item.setMeetingTitle(meeting == null ? null : meeting.getTitle());
|
||||
item.setSummaryTaskId(ledger.getSummaryTaskId());
|
||||
item.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId());
|
||||
item.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId()));
|
||||
item.setChargeAccountType(chargeRecord == null ? null : chargeRecord.getChargeAccountType());
|
||||
item.setPointsType(ledger.getPointsType());
|
||||
item.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta())));
|
||||
item.setBalanceBefore(ledger.getBalanceBefore());
|
||||
item.setBalanceAfter(ledger.getBalanceAfter());
|
||||
item.setChargeTriggerType(chargeRecord == null ? null : chargeRecord.getChargeTriggerType());
|
||||
item.setCreatedAt(ledger.getCreatedAt());
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
return toPageResult(resultPage.getTotal(), items);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeetingPointsLedgerDetailVO getLedgerDetail(Long tenantId, Long ledgerId) {
|
||||
MeetingPointsLedger ledger = meetingPointsLedgerService.getOne(new LambdaQueryWrapper<MeetingPointsLedger>()
|
||||
.eq(MeetingPointsLedger::getTenantId, tenantId)
|
||||
.eq(MeetingPointsLedger::getId, ledgerId)
|
||||
.last("LIMIT 1"));
|
||||
if (ledger == null) {
|
||||
throw new RuntimeException("积分流水不存在");
|
||||
}
|
||||
|
||||
MeetingSummaryChargeRecord chargeRecord = ledger.getChargeRecordId() == null ? null : meetingSummaryChargeRecordService.getById(ledger.getChargeRecordId());
|
||||
ensureLedgerVisible(tenantId, chargeRecord);
|
||||
Meeting meeting = ledger.getMeetingId() == null ? null : meetingService.getById(ledger.getMeetingId());
|
||||
SysUser owner = chargeRecord == null || chargeRecord.getUserId() == null ? null : sysUserMapper.selectByIdIgnoreTenant(chargeRecord.getUserId());
|
||||
|
||||
MeetingPointsLedgerDetailVO detail = new MeetingPointsLedgerDetailVO();
|
||||
detail.setId(ledger.getId());
|
||||
detail.setMeetingId(ledger.getMeetingId());
|
||||
detail.setMeetingTitle(meeting == null ? null : meeting.getTitle());
|
||||
detail.setSummaryTaskId(ledger.getSummaryTaskId());
|
||||
detail.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId());
|
||||
detail.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId()));
|
||||
detail.setChargeAccountType(chargeRecord == null ? null : chargeRecord.getChargeAccountType());
|
||||
detail.setChargeAccountUserId(chargeRecord == null ? null : chargeRecord.getChargeAccountUserId());
|
||||
detail.setPointsType(ledger.getPointsType());
|
||||
detail.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta())));
|
||||
detail.setBalanceBefore(ledger.getBalanceBefore());
|
||||
detail.setBalanceAfter(ledger.getBalanceAfter());
|
||||
detail.setChargeTriggerType(chargeRecord == null ? null : chargeRecord.getChargeTriggerType());
|
||||
detail.setAudioDurationSeconds(chargeRecord == null ? null : chargeRecord.getAudioDurationSeconds());
|
||||
detail.setChargedMinutes(chargeRecord == null ? null : chargeRecord.getChargedMinutes());
|
||||
detail.setBillingUnits(chargeRecord == null ? null : chargeRecord.getBillingUnits());
|
||||
detail.setUnitMinutesSnapshot(chargeRecord == null ? null : chargeRecord.getUnitMinutesSnapshot());
|
||||
detail.setCostPerUnitSnapshot(chargeRecord == null ? null : chargeRecord.getCostPerUnitSnapshot());
|
||||
detail.setAsrRatioSnapshot(chargeRecord == null ? null : chargeRecord.getAsrRatioSnapshot());
|
||||
detail.setLlmRatioSnapshot(chargeRecord == null ? null : chargeRecord.getLlmRatioSnapshot());
|
||||
detail.setTotalPoints(chargeRecord == null ? null : chargeRecord.getTotalPoints());
|
||||
detail.setChargedTotalPoints(chargeRecord == null ? null : chargeRecord.getChargedTotalPoints());
|
||||
detail.setAsrPoints(chargeRecord == null ? null : chargeRecord.getAsrPoints());
|
||||
detail.setChargedAsrPoints(chargeRecord == null ? null : chargeRecord.getChargedAsrPoints());
|
||||
detail.setLlmPoints(chargeRecord == null ? null : chargeRecord.getLlmPoints());
|
||||
detail.setChargedLlmPoints(chargeRecord == null ? null : chargeRecord.getChargedLlmPoints());
|
||||
detail.setSummaryStatus(chargeRecord == null ? null : chargeRecord.getSummaryStatus());
|
||||
detail.setFailureReason(chargeRecord == null ? null : chargeRecord.getFailureReason());
|
||||
detail.setAsrChargedAt(chargeRecord == null ? null : chargeRecord.getAsrChargedAt());
|
||||
detail.setLlmChargedAt(chargeRecord == null ? null : chargeRecord.getLlmChargedAt());
|
||||
detail.setCreatedAt(ledger.getCreatedAt());
|
||||
return detail;
|
||||
}
|
||||
|
||||
private List<Long> resolveMatchedOwnerIds(Long tenantId, String username, List<Long> scopedOwnerUserIds) {
|
||||
if (scopedOwnerUserIds != null && scopedOwnerUserIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (!StringUtils.hasText(username)) {
|
||||
return scopedOwnerUserIds;
|
||||
}
|
||||
String keyword = username.trim().toLowerCase(Locale.ROOT);
|
||||
return sysUserMapper.selectUsersByTenant(tenantId, null).stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(user -> scopedOwnerUserIds == null || scopedOwnerUserIds.contains(user.getUserId()))
|
||||
.filter(user -> containsIgnoreCase(user.getDisplayName(), keyword) || containsIgnoreCase(user.getUsername(), keyword))
|
||||
.map(SysUser::getUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Long> resolveScopedOwnerUserIds(Long tenantId) {
|
||||
DataScopeRuleDTO rule = dataScopeService.resolveCurrentUserScope(tenantId);
|
||||
if (rule == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (rule.isAllAccess()) {
|
||||
return null;
|
||||
}
|
||||
List<Long> creatorUserIds = rule.getCreatorUserIds();
|
||||
if (creatorUserIds == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return creatorUserIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void ensureLedgerVisible(Long tenantId, MeetingSummaryChargeRecord chargeRecord) {
|
||||
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
|
||||
if (scopedOwnerUserIds == null) {
|
||||
return;
|
||||
}
|
||||
Long ownerUserId = chargeRecord == null ? null : chargeRecord.getUserId();
|
||||
if (ownerUserId == null || !scopedOwnerUserIds.contains(ownerUserId)) {
|
||||
throw new RuntimeException("积分流水不存在或无权查看");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> resolveChargeRecordIdsByOwners(Long tenantId, List<Long> ownerUserIds) {
|
||||
if (ownerUserIds == null) {
|
||||
return null;
|
||||
}
|
||||
return meetingSummaryChargeRecordService.list(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
|
||||
.eq(MeetingSummaryChargeRecord::getTenantId, tenantId)
|
||||
.in(!ownerUserIds.isEmpty(), MeetingSummaryChargeRecord::getUserId, ownerUserIds))
|
||||
.stream()
|
||||
.map(MeetingSummaryChargeRecord::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<Long, MeetingSummaryChargeRecord> loadChargeRecordMap(List<MeetingPointsLedger> ledgers) {
|
||||
Set<Long> chargeRecordIds = ledgers.stream()
|
||||
.map(MeetingPointsLedger::getChargeRecordId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (chargeRecordIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return meetingSummaryChargeRecordService.listByIds(chargeRecordIds).stream()
|
||||
.collect(Collectors.toMap(MeetingSummaryChargeRecord::getId, item -> item, (left, right) -> left, HashMap::new));
|
||||
}
|
||||
|
||||
private Map<Long, Meeting> loadMeetingMap(List<MeetingPointsLedger> ledgers) {
|
||||
Set<Long> meetingIds = ledgers.stream()
|
||||
.map(MeetingPointsLedger::getMeetingId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (meetingIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return meetingService.listByIds(meetingIds).stream()
|
||||
.collect(Collectors.toMap(Meeting::getId, item -> item, (left, right) -> left, HashMap::new));
|
||||
}
|
||||
|
||||
private Map<Long, SysUser> loadOwnerMap(Iterable<MeetingSummaryChargeRecord> chargeRecords) {
|
||||
Set<Long> ownerIds = new java.util.HashSet<>();
|
||||
for (MeetingSummaryChargeRecord chargeRecord : chargeRecords) {
|
||||
if (chargeRecord != null && chargeRecord.getUserId() != null) {
|
||||
ownerIds.add(chargeRecord.getUserId());
|
||||
}
|
||||
}
|
||||
if (ownerIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, SysUser> result = new HashMap<>();
|
||||
for (Long ownerId : ownerIds) {
|
||||
SysUser user = sysUserMapper.selectByIdIgnoreTenant(ownerId);
|
||||
if (user != null) {
|
||||
result.put(ownerId, user);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String resolveOwnerName(SysUser user, Long ownerUserId) {
|
||||
if (user != null) {
|
||||
if (StringUtils.hasText(user.getDisplayName())) {
|
||||
return user.getDisplayName();
|
||||
}
|
||||
if (StringUtils.hasText(user.getUsername())) {
|
||||
return user.getUsername();
|
||||
}
|
||||
}
|
||||
return ownerUserId == null ? "-" : String.valueOf(ownerUserId);
|
||||
}
|
||||
|
||||
private boolean containsIgnoreCase(String source, String keywordLowerCase) {
|
||||
return source != null && source.toLowerCase(Locale.ROOT).contains(keywordLowerCase);
|
||||
}
|
||||
|
||||
private String resolveAccountMode() {
|
||||
String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ACCOUNT_MODE, ACCOUNT_MODE_PUBLIC);
|
||||
if (!StringUtils.hasText(configured)) {
|
||||
return ACCOUNT_MODE_PUBLIC;
|
||||
}
|
||||
return configured.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private long defaultLong(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
private PageResult<List<MeetingPointsLedgerListItemVO>> emptyPageResult() {
|
||||
return toPageResult(0L, Collections.emptyList());
|
||||
}
|
||||
|
||||
private PageResult<List<MeetingPointsLedgerListItemVO>> toPageResult(long total, List<MeetingPointsLedgerListItemVO> records) {
|
||||
PageResult<List<MeetingPointsLedgerListItemVO>> result = new PageResult<>();
|
||||
result.setTotal(total);
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.common.SysParamKeys;
|
||||
import com.imeeting.dto.biz.MeetingPointsBalanceVO;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingPointsAccount;
|
||||
import com.imeeting.entity.biz.MeetingPointsLedger;
|
||||
import com.imeeting.entity.biz.MeetingSummaryChargeRecord;
|
||||
import com.imeeting.entity.biz.MeetingTranscript;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
import com.imeeting.mapper.biz.AiTaskMapper;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.mapper.biz.MeetingPointsAccountMapper;
|
||||
import com.imeeting.mapper.biz.MeetingSummaryChargeRecordMapper;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.MeetingPointsAccountService;
|
||||
import com.imeeting.service.biz.MeetingPointsLedgerService;
|
||||
import com.imeeting.service.biz.MeetingPointsService;
|
||||
import com.imeeting.service.biz.MeetingSummaryChargeRecordService;
|
||||
import com.imeeting.service.biz.MeetingUserStatsService;
|
||||
import com.unisbase.service.SysParamService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MeetingPointsServiceImpl implements MeetingPointsService {
|
||||
private static final String TRIGGER_AUTO_SUMMARY = "AUTO_SUMMARY";
|
||||
private static final String TRIGGER_RESUMMARY = "RESUMMARY";
|
||||
private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC";
|
||||
private static final String ACCOUNT_MODE_PERSONAL = "PERSONAL";
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
private static final String STATUS_ASR_CHARGED = "ASR_CHARGED";
|
||||
private static final String STATUS_COMPLETED = "COMPLETED";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String STATUS_DISABLED = "DISABLED";
|
||||
|
||||
private final MeetingSummaryChargeRecordService chargeRecordService;
|
||||
private final MeetingPointsAccountService pointsAccountService;
|
||||
private final MeetingPointsLedgerService pointsLedgerService;
|
||||
private final MeetingUserStatsService meetingUserStatsService;
|
||||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingMapper meetingMapper;
|
||||
private final AiTaskMapper aiTaskMapper;
|
||||
private final MeetingPointsAccountMapper meetingPointsAccountMapper;
|
||||
private final MeetingSummaryChargeRecordMapper meetingSummaryChargeRecordMapper;
|
||||
private final SysParamService sysParamService;
|
||||
|
||||
@Value("${unisbase.app.upload-path}")
|
||||
private String uploadPath;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void recordAsrSuccessCharge(Meeting meeting, AiTask asrTask) {
|
||||
if (meeting == null || asrTask == null || meeting.getId() == null) {
|
||||
return;
|
||||
}
|
||||
AiTask summaryTask = findLatestSummaryTask(meeting.getId());
|
||||
if (summaryTask == null) {
|
||||
return;
|
||||
}
|
||||
String chargeTriggerType = resolveChargeTriggerType(summaryTask);
|
||||
if (!TRIGGER_AUTO_SUMMARY.equals(chargeTriggerType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Integer durationSeconds = resolveEffectiveAudioDurationSeconds(meeting);
|
||||
if (durationSeconds == null || durationSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMeetingDurationStats(meeting, durationSeconds);
|
||||
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds);
|
||||
if (defaultLong(record.getChargedAsrPoints()) > 0) {
|
||||
return;
|
||||
}
|
||||
if (!isPointsEnabled()) {
|
||||
record.setSummaryStatus(STATUS_DISABLED);
|
||||
record.setAsrChargedAt(LocalDateTime.now());
|
||||
saveOrUpdateRecord(record);
|
||||
return;
|
||||
}
|
||||
|
||||
long chargeAmount = defaultLong(record.getAsrPoints());
|
||||
if (chargeAmount <= 0L) {
|
||||
return;
|
||||
}
|
||||
|
||||
MeetingPointsAccount account = getOrCreateAccount(meeting.getTenantId(), record.getChargeAccountUserId());
|
||||
long balanceBefore = defaultLong(account.getCurrentBalance());
|
||||
long balanceAfter = balanceBefore - chargeAmount;
|
||||
|
||||
account.setCurrentBalance(balanceAfter);
|
||||
account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + chargeAmount);
|
||||
account.setTotalAsrPointsUsed(defaultLong(account.getTotalAsrPointsUsed()) + chargeAmount);
|
||||
pointsAccountService.updateById(account);
|
||||
|
||||
record.setBalanceBefore(record.getBalanceBefore() == null ? balanceBefore : record.getBalanceBefore());
|
||||
record.setBalanceAfter(balanceAfter);
|
||||
record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + chargeAmount);
|
||||
record.setChargedAsrPoints(chargeAmount);
|
||||
record.setAsrChargedAt(LocalDateTime.now());
|
||||
record.setChargedAt(LocalDateTime.now());
|
||||
record.setPointsDelta(-defaultLong(record.getChargedTotalPoints()));
|
||||
record.setSummaryStatus(STATUS_ASR_CHARGED);
|
||||
saveOrUpdateRecord(record);
|
||||
|
||||
saveLedger(meeting, summaryTask, record, "ASR", -chargeAmount, balanceBefore, balanceAfter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void recordSummarySuccessCharge(Meeting meeting, AiTask summaryTask) {
|
||||
if (meeting == null || summaryTask == null || meeting.getId() == null || summaryTask.getId() == null) {
|
||||
return;
|
||||
}
|
||||
String chargeTriggerType = resolveChargeTriggerType(summaryTask);
|
||||
Integer durationSeconds = resolveEffectiveAudioDurationSeconds(meeting);
|
||||
int safeDurationSeconds = durationSeconds == null || durationSeconds <= 0 ? 0 : durationSeconds;
|
||||
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, safeDurationSeconds);
|
||||
if (durationSeconds == null || durationSeconds <= 0) {
|
||||
record.setFailureReason("无法解析有效录音时长,未记录积分扣减");
|
||||
record.setSummaryStatus(STATUS_COMPLETED);
|
||||
record.setLlmChargedAt(LocalDateTime.now());
|
||||
saveOrUpdateRecord(record);
|
||||
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
|
||||
return;
|
||||
}
|
||||
|
||||
ensureMeetingDurationStats(meeting, durationSeconds);
|
||||
if (defaultLong(record.getChargedLlmPoints()) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPointsEnabled()) {
|
||||
record.setSummaryStatus(STATUS_DISABLED);
|
||||
record.setLlmChargedAt(LocalDateTime.now());
|
||||
saveOrUpdateRecord(record);
|
||||
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
|
||||
return;
|
||||
}
|
||||
|
||||
long chargeAmount = defaultLong(record.getLlmPoints());
|
||||
if (chargeAmount <= 0L) {
|
||||
record.setSummaryStatus(STATUS_COMPLETED);
|
||||
record.setLlmChargedAt(LocalDateTime.now());
|
||||
saveOrUpdateRecord(record);
|
||||
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
|
||||
return;
|
||||
}
|
||||
|
||||
MeetingPointsAccount account = getOrCreateAccount(meeting.getTenantId(), record.getChargeAccountUserId());
|
||||
long balanceBefore = defaultLong(account.getCurrentBalance());
|
||||
long balanceAfter = balanceBefore - chargeAmount;
|
||||
|
||||
account.setCurrentBalance(balanceAfter);
|
||||
account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + chargeAmount);
|
||||
account.setTotalLlmPointsUsed(defaultLong(account.getTotalLlmPointsUsed()) + chargeAmount);
|
||||
pointsAccountService.updateById(account);
|
||||
|
||||
if (record.getBalanceBefore() == null) {
|
||||
record.setBalanceBefore(balanceBefore);
|
||||
}
|
||||
record.setBalanceAfter(balanceAfter);
|
||||
record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + chargeAmount);
|
||||
record.setChargedLlmPoints(chargeAmount);
|
||||
record.setLlmChargedAt(LocalDateTime.now());
|
||||
record.setChargedAt(LocalDateTime.now());
|
||||
record.setPointsDelta(-defaultLong(record.getChargedTotalPoints()));
|
||||
record.setSummaryStatus(STATUS_COMPLETED);
|
||||
saveOrUpdateRecord(record);
|
||||
|
||||
saveLedger(meeting, summaryTask, record, "LLM", -chargeAmount, balanceBefore, balanceAfter);
|
||||
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markSummaryChargeFailed(Long summaryTaskId, String failureReason) {
|
||||
if (summaryTaskId == null) {
|
||||
return;
|
||||
}
|
||||
MeetingSummaryChargeRecord record = chargeRecordService.getOne(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
|
||||
.eq(MeetingSummaryChargeRecord::getSummaryTaskId, summaryTaskId)
|
||||
.last("LIMIT 1"));
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
if (!STATUS_COMPLETED.equals(record.getSummaryStatus())) {
|
||||
record.setSummaryStatus(STATUS_FAILED);
|
||||
}
|
||||
record.setFailureReason(truncate(failureReason, 500));
|
||||
saveOrUpdateRecord(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String resolveLatestBlockedReason(Long summaryTaskId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeetingPointsBalanceVO getBalanceView(Long tenantId, Long userId) {
|
||||
MeetingPointsAccount publicAccount = getOrCreateAccount(tenantId, UNIFIED_ACCOUNT_USER_ID);
|
||||
MeetingPointsAccount personalAccount = getOrCreateAccount(tenantId, userId);
|
||||
|
||||
MeetingPointsBalanceVO vo = new MeetingPointsBalanceVO();
|
||||
vo.setTenantId(tenantId);
|
||||
vo.setUserId(userId);
|
||||
vo.setPreferredAccountMode(resolveAccountMode());
|
||||
vo.setPublicBalance(defaultLong(publicAccount.getCurrentBalance()));
|
||||
vo.setPublicTotalPointsUsed(defaultLong(publicAccount.getTotalPointsUsed()));
|
||||
vo.setPersonalBalance(defaultLong(personalAccount.getCurrentBalance()));
|
||||
vo.setPersonalTotalPointsUsed(defaultLong(personalAccount.getTotalPointsUsed()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private MeetingSummaryChargeRecord getOrCreateChargeRecord(Meeting meeting, AiTask summaryTask, String chargeTriggerType, int durationSeconds) {
|
||||
MeetingSummaryChargeRecord record = meetingSummaryChargeRecordMapper.selectForUpdateBySummaryTaskId(summaryTask.getId());
|
||||
if (record != null) {
|
||||
if ((record.getAudioDurationSeconds() == null || record.getAudioDurationSeconds() <= 0) && durationSeconds > 0) {
|
||||
applyChargeSnapshot(record, meeting, chargeTriggerType, durationSeconds);
|
||||
saveOrUpdateRecord(record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
record = new MeetingSummaryChargeRecord();
|
||||
record.setTenantId(meeting.getTenantId());
|
||||
record.setStatus(1);
|
||||
record.setMeetingId(meeting.getId());
|
||||
record.setSummaryTaskId(summaryTask.getId());
|
||||
record.setUserId(meeting.getCreatorId());
|
||||
record.setChargeTriggerType(chargeTriggerType);
|
||||
record.setPointsModeEnabled(isPointsEnabled() ? 1 : 0);
|
||||
record.setChargedTotalPoints(0L);
|
||||
record.setChargedAsrPoints(0L);
|
||||
record.setChargedLlmPoints(0L);
|
||||
record.setFailureReason(null);
|
||||
record.setBlockedReason(null);
|
||||
record.setBalanceBefore(null);
|
||||
record.setBalanceAfter(null);
|
||||
record.setPointsDelta(0L);
|
||||
record.setSummaryStatus(isPointsEnabled() ? STATUS_PENDING : STATUS_DISABLED);
|
||||
applyChargeSnapshot(record, meeting, chargeTriggerType, durationSeconds);
|
||||
|
||||
try {
|
||||
chargeRecordService.save(record);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
record = meetingSummaryChargeRecordMapper.selectForUpdateBySummaryTaskId(summaryTask.getId());
|
||||
if (record == null) {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
incrementSummaryAttemptCount(meeting.getTenantId(), meeting.getCreatorId());
|
||||
return record;
|
||||
}
|
||||
|
||||
private void applyChargeSnapshot(MeetingSummaryChargeRecord record, Meeting meeting, String chargeTriggerType, int durationSeconds) {
|
||||
ChargeSnapshot snapshot = buildChargeSnapshot(durationSeconds);
|
||||
ChargeAccountSnapshot accountSnapshot = resolveChargeAccountSnapshot(meeting);
|
||||
record.setChargeAccountType(accountSnapshot.accountType());
|
||||
record.setChargeAccountUserId(accountSnapshot.accountUserId());
|
||||
record.setAudioDurationSeconds(durationSeconds);
|
||||
record.setChargedMinutes(snapshot.chargedMinutes());
|
||||
record.setBillingUnits(snapshot.billingUnits());
|
||||
record.setUnitMinutesSnapshot(snapshot.unitMinutes());
|
||||
record.setCostPerUnitSnapshot(snapshot.costPerUnit());
|
||||
record.setAsrRatioSnapshot(snapshot.asrRatio());
|
||||
record.setLlmRatioSnapshot(snapshot.llmRatio());
|
||||
if (TRIGGER_RESUMMARY.equals(chargeTriggerType)) {
|
||||
record.setTotalPoints(snapshot.llmPoints());
|
||||
record.setAsrPoints(0L);
|
||||
record.setLlmPoints(snapshot.llmPoints());
|
||||
} else {
|
||||
record.setTotalPoints(snapshot.totalPoints());
|
||||
record.setAsrPoints(snapshot.asrPoints());
|
||||
record.setLlmPoints(snapshot.llmPoints());
|
||||
}
|
||||
}
|
||||
|
||||
private ChargeSnapshot buildChargeSnapshot(int durationSeconds) {
|
||||
int chargedMinutes = toChargedMinutes(durationSeconds);
|
||||
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);
|
||||
int asrRatio = nonNegativeInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ASR_RATIO, "2"), 2);
|
||||
int llmRatio = nonNegativeInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_LLM_RATIO, "8"), 8);
|
||||
int ratioSum = Math.max(1, asrRatio + llmRatio);
|
||||
int billingUnits = (int) Math.ceil((double) chargedMinutes / (double) unitMinutes);
|
||||
long totalPoints = (long) billingUnits * costPerUnit;
|
||||
long asrPoints = BigDecimal.valueOf(totalPoints)
|
||||
.multiply(BigDecimal.valueOf(asrRatio))
|
||||
.divide(BigDecimal.valueOf(ratioSum), 0, RoundingMode.DOWN)
|
||||
.longValue();
|
||||
long llmPoints = totalPoints - asrPoints;
|
||||
return new ChargeSnapshot(chargedMinutes, billingUnits, unitMinutes, costPerUnit, asrRatio, llmRatio, totalPoints, asrPoints, llmPoints);
|
||||
}
|
||||
|
||||
private void ensureMeetingDurationStats(Meeting meeting, int durationSeconds) {
|
||||
Integer previousDuration = meeting.getEffectiveAudioDurationSeconds();
|
||||
if (previousDuration == null || previousDuration != durationSeconds) {
|
||||
meeting.setEffectiveAudioDurationSeconds(durationSeconds);
|
||||
meetingMapper.updateById(meeting);
|
||||
}
|
||||
long deltaSeconds = (long) durationSeconds - (previousDuration == null ? 0L : previousDuration.longValue());
|
||||
if (deltaSeconds == 0L) {
|
||||
return;
|
||||
}
|
||||
MeetingUserStats stats = getOrCreateUserStats(meeting.getTenantId(), meeting.getCreatorId());
|
||||
stats.setTotalMeetingDurationSeconds(defaultLong(stats.getTotalMeetingDurationSeconds()) + deltaSeconds);
|
||||
stats.setTotalMeetingDurationMinutes(toCeilMinutes(defaultLong(stats.getTotalMeetingDurationSeconds())));
|
||||
meetingUserStatsService.updateById(stats);
|
||||
}
|
||||
|
||||
private void incrementSummaryAttemptCount(Long tenantId, Long userId) {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
MeetingUserStats stats = getOrCreateUserStats(tenantId, userId);
|
||||
stats.setTotalSummaryAttemptCount(defaultLong(stats.getTotalSummaryAttemptCount()) + 1L);
|
||||
meetingUserStatsService.updateById(stats);
|
||||
}
|
||||
|
||||
private void incrementSummaryChargeCount(Long tenantId, Long userId) {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
MeetingUserStats stats = getOrCreateUserStats(tenantId, userId);
|
||||
stats.setTotalSummaryChargeCount(defaultLong(stats.getTotalSummaryChargeCount()) + 1L);
|
||||
meetingUserStatsService.updateById(stats);
|
||||
}
|
||||
|
||||
private MeetingUserStats getOrCreateUserStats(Long tenantId, Long userId) {
|
||||
MeetingUserStats stats = meetingUserStatsService.getOne(new LambdaQueryWrapper<MeetingUserStats>()
|
||||
.eq(MeetingUserStats::getTenantId, tenantId)
|
||||
.eq(MeetingUserStats::getUserId, userId)
|
||||
.last("LIMIT 1"));
|
||||
if (stats != null) {
|
||||
return stats;
|
||||
}
|
||||
stats = new MeetingUserStats();
|
||||
stats.setTenantId(tenantId);
|
||||
stats.setStatus(1);
|
||||
stats.setUserId(userId);
|
||||
stats.setTotalMeetingDurationSeconds(0L);
|
||||
stats.setTotalMeetingDurationMinutes(0L);
|
||||
stats.setTotalSummaryChargeCount(0L);
|
||||
stats.setTotalSummaryAttemptCount(0L);
|
||||
meetingUserStatsService.save(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
private MeetingPointsAccount getOrCreateAccount(Long tenantId, Long userId) {
|
||||
Long effectiveUserId = userId == null ? UNIFIED_ACCOUNT_USER_ID : userId;
|
||||
MeetingPointsAccount account = meetingPointsAccountMapper.selectForUpdate(tenantId, effectiveUserId);
|
||||
if (account != null) {
|
||||
return account;
|
||||
}
|
||||
account = new MeetingPointsAccount();
|
||||
account.setTenantId(tenantId);
|
||||
account.setStatus(1);
|
||||
account.setUserId(effectiveUserId);
|
||||
long initialBalance = nonNegativeLong(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_INITIAL_BALANCE, "0"), 0L);
|
||||
account.setCurrentBalance(initialBalance);
|
||||
account.setTotalPointsUsed(0L);
|
||||
account.setTotalAsrPointsUsed(0L);
|
||||
account.setTotalLlmPointsUsed(0L);
|
||||
try {
|
||||
pointsAccountService.save(account);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
account = meetingPointsAccountMapper.selectForUpdate(tenantId, effectiveUserId);
|
||||
if (account == null) {
|
||||
throw ex;
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
if (initialBalance > 0L) {
|
||||
MeetingPointsLedger initLedger = new MeetingPointsLedger();
|
||||
initLedger.setTenantId(tenantId);
|
||||
initLedger.setStatus(1);
|
||||
initLedger.setUserId(effectiveUserId);
|
||||
initLedger.setPointsDelta(initialBalance);
|
||||
initLedger.setPointsType("INIT");
|
||||
initLedger.setBalanceBefore(0L);
|
||||
initLedger.setBalanceAfter(initialBalance);
|
||||
initLedger.setRemark(UNIFIED_ACCOUNT_USER_ID == effectiveUserId ? "公共积分账户初始化发放" : "个人积分账户初始化发放");
|
||||
pointsLedgerService.save(initLedger);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private void saveLedger(Meeting meeting, AiTask summaryTask, MeetingSummaryChargeRecord record,
|
||||
String pointsType, long pointsDelta, long balanceBefore, long balanceAfter) {
|
||||
if (pointsDelta == 0L) {
|
||||
return;
|
||||
}
|
||||
MeetingPointsLedger ledger = new MeetingPointsLedger();
|
||||
ledger.setTenantId(meeting.getTenantId());
|
||||
ledger.setStatus(1);
|
||||
ledger.setUserId(record.getChargeAccountUserId());
|
||||
ledger.setMeetingId(meeting.getId());
|
||||
ledger.setSummaryTaskId(summaryTask.getId());
|
||||
ledger.setChargeRecordId(record.getId());
|
||||
ledger.setPointsDelta(pointsDelta);
|
||||
ledger.setPointsType(pointsType);
|
||||
ledger.setBalanceBefore(balanceBefore);
|
||||
ledger.setBalanceAfter(balanceAfter);
|
||||
ledger.setRemark(TRIGGER_RESUMMARY.equals(record.getChargeTriggerType())
|
||||
? "重新总结成功后扣减" : "任务成功后按阶段扣减");
|
||||
pointsLedgerService.save(ledger);
|
||||
}
|
||||
|
||||
private void saveOrUpdateRecord(MeetingSummaryChargeRecord record) {
|
||||
if (record.getId() == null) {
|
||||
chargeRecordService.save(record);
|
||||
return;
|
||||
}
|
||||
chargeRecordService.updateById(record);
|
||||
}
|
||||
|
||||
private AiTask findLatestSummaryTask(Long meetingId) {
|
||||
return aiTaskMapper.selectOne(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meetingId)
|
||||
.eq(AiTask::getTaskType, "SUMMARY")
|
||||
.orderByDesc(AiTask::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private String resolveChargeTriggerType(AiTask summaryTask) {
|
||||
if (summaryTask == null || summaryTask.getTaskConfig() == null) {
|
||||
return TRIGGER_AUTO_SUMMARY;
|
||||
}
|
||||
Object rawValue = summaryTask.getTaskConfig().get("chargeTriggerType");
|
||||
if (rawValue == null) {
|
||||
return TRIGGER_AUTO_SUMMARY;
|
||||
}
|
||||
String value = String.valueOf(rawValue).trim().toUpperCase();
|
||||
return value.isEmpty() ? TRIGGER_AUTO_SUMMARY : value;
|
||||
}
|
||||
|
||||
private Integer resolveEffectiveAudioDurationSeconds(Meeting meeting) {
|
||||
if (meeting == null) {
|
||||
return null;
|
||||
}
|
||||
Integer durationSeconds = meeting.getEffectiveAudioDurationSeconds();
|
||||
return durationSeconds != null && durationSeconds > 0 ? durationSeconds : null;
|
||||
}
|
||||
|
||||
private boolean isPointsEnabled() {
|
||||
return Boolean.parseBoolean(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ENABLED, "false"));
|
||||
}
|
||||
|
||||
private String resolveAccountMode() {
|
||||
String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ACCOUNT_MODE, ACCOUNT_MODE_PUBLIC);
|
||||
if (configured == null || configured.isBlank()) {
|
||||
return ACCOUNT_MODE_PUBLIC;
|
||||
}
|
||||
String normalized = configured.trim().toUpperCase();
|
||||
if (ACCOUNT_MODE_PERSONAL.equals(normalized)) {
|
||||
return ACCOUNT_MODE_PERSONAL;
|
||||
}
|
||||
return ACCOUNT_MODE_PUBLIC;
|
||||
}
|
||||
|
||||
private ChargeAccountSnapshot resolveChargeAccountSnapshot(Meeting meeting) {
|
||||
String accountMode = resolveAccountMode();
|
||||
if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) {
|
||||
Long ownerUserId = meeting.getCreatorId() == null ? UNIFIED_ACCOUNT_USER_ID : meeting.getCreatorId();
|
||||
return new ChargeAccountSnapshot(ACCOUNT_MODE_PERSONAL, ownerUserId);
|
||||
}
|
||||
return new ChargeAccountSnapshot(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID);
|
||||
}
|
||||
|
||||
private int toChargedMinutes(int durationSeconds) {
|
||||
return (int) Math.ceil(durationSeconds / 60.0d);
|
||||
}
|
||||
|
||||
private long toCeilMinutes(long durationSeconds) {
|
||||
if (durationSeconds <= 0L) {
|
||||
return 0L;
|
||||
}
|
||||
return (long) Math.ceil(durationSeconds / 60.0d);
|
||||
}
|
||||
|
||||
private int positiveInt(String rawValue, int defaultValue) {
|
||||
try {
|
||||
int value = Integer.parseInt(String.valueOf(rawValue).trim());
|
||||
return value > 0 ? value : defaultValue;
|
||||
} catch (Exception ex) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private int nonNegativeInt(String rawValue, int defaultValue) {
|
||||
try {
|
||||
int value = Integer.parseInt(String.valueOf(rawValue).trim());
|
||||
return Math.max(0, value);
|
||||
} catch (Exception ex) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private long nonNegativeLong(String rawValue, long defaultValue) {
|
||||
try {
|
||||
long value = Long.parseLong(String.valueOf(rawValue).trim());
|
||||
return Math.max(0L, value);
|
||||
} catch (Exception ex) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private long defaultLong(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private record ChargeAccountSnapshot(String accountType, Long accountUserId) {
|
||||
}
|
||||
|
||||
private record ChargeSnapshot(
|
||||
int chargedMinutes,
|
||||
int billingUnits,
|
||||
int unitMinutes,
|
||||
int costPerUnit,
|
||||
int asrRatio,
|
||||
int llmRatio,
|
||||
long totalPoints,
|
||||
long asrPoints,
|
||||
long llmPoints
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.biz.MeetingSummaryChargeRecord;
|
||||
import com.imeeting.mapper.biz.MeetingSummaryChargeRecordMapper;
|
||||
import com.imeeting.service.biz.MeetingSummaryChargeRecordService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MeetingSummaryChargeRecordServiceImpl extends ServiceImpl<MeetingSummaryChargeRecordMapper, MeetingSummaryChargeRecord> implements MeetingSummaryChargeRecordService {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
import com.imeeting.mapper.biz.MeetingUserStatsMapper;
|
||||
import com.imeeting.service.biz.MeetingUserStatsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MeetingUserStatsServiceImpl extends ServiceImpl<MeetingUserStatsMapper, MeetingUserStats> implements MeetingUserStatsService {
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import com.imeeting.support.RedisSupport;
|
|||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,278 +1,278 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MeetingDomainSupportTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@AfterEach
|
||||
void clearSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepRelocatedAudioAfterCommit() throws Exception {
|
||||
MeetingDomainSupport support = newSupport();
|
||||
Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "offline-audio");
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
String relocatedUrl = support.relocateAudioUrl(101L, "/api/static/audio/offline.wav");
|
||||
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
|
||||
Path target = tempDir.resolve("uploads/meetings/101/source_audio.wav");
|
||||
assertEquals("/api/static/meetings/101/source_audio.wav", relocatedUrl);
|
||||
assertFalse(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("offline-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRestoreSourceAndTargetWhenTransactionRollsBack() throws Exception {
|
||||
MeetingDomainSupport support = newSupport();
|
||||
Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "new-audio");
|
||||
Path target = writeFile(tempDir.resolve("uploads/meetings/102/source_audio.wav"), "old-audio");
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
String relocatedUrl = support.relocateAudioUrl(102L, "/api/static/audio/offline.wav");
|
||||
|
||||
assertEquals("/api/static/meetings/102/source_audio.wav", relocatedUrl);
|
||||
assertFalse(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("new-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
|
||||
triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
|
||||
assertTrue(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("new-audio", Files.readString(source, StandardCharsets.UTF_8));
|
||||
assertEquals("old-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRelocatePrivateStagingAudioToken() throws Exception {
|
||||
MeetingDomainSupport support = newSupport();
|
||||
Path source = writeFile(
|
||||
tempDir.resolve(".uploads-meeting-staging/audio/private-upload.wav"),
|
||||
"private-audio"
|
||||
);
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
String relocatedUrl = support.relocateAudioUrl(
|
||||
103L,
|
||||
MeetingAudioUploadSupport.buildStagingAudioToken(source.getFileName().toString())
|
||||
);
|
||||
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
|
||||
Path target = tempDir.resolve("uploads/meetings/103/source_audio.wav");
|
||||
assertEquals("/api/static/meetings/103/source_audio.wav", relocatedUrl);
|
||||
assertFalse(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("private-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeleteMeetingArtifactsDirectory() throws Exception {
|
||||
MeetingDomainSupport support = newSupport();
|
||||
Path summary = writeFile(tempDir.resolve("uploads/meetings/301/summaries/summary_1.md"), "summary");
|
||||
Path audio = writeFile(tempDir.resolve("uploads/meetings/301/source_audio.wav"), "audio");
|
||||
|
||||
assertTrue(Files.exists(summary));
|
||||
assertTrue(Files.exists(audio));
|
||||
|
||||
support.deleteMeetingArtifacts(301L);
|
||||
|
||||
assertFalse(Files.exists(tempDir.resolve("uploads/meetings/301")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrewarmPlaybackAudioAfterTransactionCommit() {
|
||||
MeetingPlaybackAudioResolver playbackAudioResolver = mock(MeetingPlaybackAudioResolver.class);
|
||||
MeetingDomainSupport support = newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class), playbackAudioResolver);
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
support.prewarmPlaybackAudioAfterCommit("/api/static/meetings/401/source_audio.m4a");
|
||||
|
||||
verify(playbackAudioResolver, never()).prewarmBrowserPlaybackAudio(any());
|
||||
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
verify(playbackAudioResolver).prewarmBrowserPlaybackAudio("/api/static/meetings/401/source_audio.m4a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(201L);
|
||||
meeting.setLatestSummaryTaskId(501L);
|
||||
|
||||
AiTask latestSummaryTask = new AiTask();
|
||||
latestSummaryTask.setTaskType("SUMMARY");
|
||||
latestSummaryTask.setMeetingId(201L);
|
||||
latestSummaryTask.setTaskConfig(Map.of("userPrompt", " 已发布提示词 "));
|
||||
|
||||
AiTask fallbackTask = new AiTask();
|
||||
fallbackTask.setTaskType("SUMMARY");
|
||||
fallbackTask.setMeetingId(201L);
|
||||
fallbackTask.setTaskConfig(Map.of("userPrompt", " 最新草稿提示词 "));
|
||||
|
||||
when(aiTaskService.getById(501L)).thenReturn(latestSummaryTask);
|
||||
when(assembler.normalizeOptionalText(" 已发布提示词 ")).thenReturn("已发布提示词");
|
||||
when(aiTaskService.getOne(any())).thenReturn(fallbackTask);
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertEquals("已发布提示词", resolved);
|
||||
Mockito.verify(aiTaskService, Mockito.never()).getOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToLatestSummaryTaskWhenLatestSummaryTaskIdIsUnavailable() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(202L);
|
||||
meeting.setLatestSummaryTaskId(502L);
|
||||
|
||||
AiTask latestSuccessfulTask = new AiTask();
|
||||
latestSuccessfulTask.setTaskType("SUMMARY");
|
||||
latestSuccessfulTask.setMeetingId(202L);
|
||||
latestSuccessfulTask.setStatus(2);
|
||||
latestSuccessfulTask.setTaskConfig(Map.of("userPrompt", " 成功提示词 "));
|
||||
|
||||
when(aiTaskService.getById(502L)).thenReturn(null);
|
||||
when(aiTaskService.getOne(any())).thenReturn(latestSuccessfulTask);
|
||||
when(assembler.normalizeOptionalText(" 成功提示词 ")).thenReturn("成功提示词");
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertEquals("成功提示词", resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToLatestSummaryTaskWhenNoSuccessfulTaskExists() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(203L);
|
||||
|
||||
AiTask latestTask = new AiTask();
|
||||
latestTask.setTaskType("SUMMARY");
|
||||
latestTask.setMeetingId(203L);
|
||||
latestTask.setTaskConfig(Map.of("userPrompt", " 最新任务提示词 "));
|
||||
|
||||
when(aiTaskService.getOne(any())).thenReturn(null).thenReturn(latestTask);
|
||||
when(assembler.normalizeOptionalText(" 最新任务提示词 ")).thenReturn("最新任务提示词");
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertEquals("最新任务提示词", resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNullWhenNoSummaryTaskExists() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(204L);
|
||||
|
||||
when(aiTaskService.getOne(any())).thenReturn(null, null);
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertNull(resolved);
|
||||
}
|
||||
|
||||
private MeetingDomainSupport newSupport() {
|
||||
return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class));
|
||||
}
|
||||
|
||||
private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) {
|
||||
return newSupport(aiTaskService, assembler, mock(MeetingPlaybackAudioResolver.class));
|
||||
}
|
||||
|
||||
private MeetingDomainSupport newSupport(AiTaskService aiTaskService,
|
||||
MeetingSummaryPromptAssembler assembler,
|
||||
MeetingPlaybackAudioResolver playbackAudioResolver) {
|
||||
MeetingDomainSupport support = new MeetingDomainSupport(
|
||||
assembler,
|
||||
aiTaskService,
|
||||
mock(MeetingTranscriptMapper.class),
|
||||
mock(SysUserMapper.class),
|
||||
mock(ApplicationEventPublisher.class),
|
||||
mock(MeetingSummaryFileService.class),
|
||||
playbackAudioResolver
|
||||
);
|
||||
ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString());
|
||||
return support;
|
||||
}
|
||||
|
||||
private void triggerAfterCompletion(int status) {
|
||||
for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
|
||||
if (status == TransactionSynchronization.STATUS_COMMITTED) {
|
||||
synchronization.afterCommit();
|
||||
}
|
||||
synchronization.afterCompletion(status);
|
||||
}
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
private Path writeFile(Path path, String content) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
return path;
|
||||
}
|
||||
|
||||
private boolean hasBackupFile(Path directory) throws IOException {
|
||||
if (!Files.exists(directory)) {
|
||||
return false;
|
||||
}
|
||||
try (var stream = Files.list(directory)) {
|
||||
return stream
|
||||
.map(Path::getFileName)
|
||||
.map(Path::toString)
|
||||
.anyMatch(name -> name.contains(".rollback-") && name.endsWith(".bak"));
|
||||
}
|
||||
}
|
||||
}
|
||||
//package com.imeeting.service.biz.impl;
|
||||
//
|
||||
//import com.imeeting.entity.biz.AiTask;
|
||||
//import com.imeeting.entity.biz.Meeting;
|
||||
//import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
//import com.imeeting.service.biz.AiTaskService;
|
||||
//import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
//import com.unisbase.mapper.SysUserMapper;
|
||||
//import org.junit.jupiter.api.AfterEach;
|
||||
//import org.junit.jupiter.api.Test;
|
||||
//import org.junit.jupiter.api.io.TempDir;
|
||||
//import org.mockito.Mockito;
|
||||
//import org.springframework.context.ApplicationEventPublisher;
|
||||
//import org.springframework.test.util.ReflectionTestUtils;
|
||||
//import org.springframework.transaction.support.TransactionSynchronization;
|
||||
//import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
//
|
||||
//import java.io.IOException;
|
||||
//import java.nio.charset.StandardCharsets;
|
||||
//import java.nio.file.Files;
|
||||
//import java.nio.file.Path;
|
||||
//import java.util.Map;
|
||||
//
|
||||
//import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
//import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
//import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
//import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
//import static org.mockito.ArgumentMatchers.any;
|
||||
//import static org.mockito.Mockito.mock;
|
||||
//import static org.mockito.Mockito.never;
|
||||
//import static org.mockito.Mockito.verify;
|
||||
//import static org.mockito.Mockito.when;
|
||||
//
|
||||
//class MeetingDomainSupportTest {
|
||||
//
|
||||
// @TempDir
|
||||
// Path tempDir;
|
||||
//
|
||||
// @AfterEach
|
||||
// void clearSynchronization() {
|
||||
// if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
// TransactionSynchronizationManager.clearSynchronization();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldKeepRelocatedAudioAfterCommit() throws Exception {
|
||||
// MeetingDomainSupport support = newSupport();
|
||||
// Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "offline-audio");
|
||||
//
|
||||
// TransactionSynchronizationManager.initSynchronization();
|
||||
// String relocatedUrl = support.relocateAudioUrl(101L, "/api/static/audio/offline.wav");
|
||||
// triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
//
|
||||
// Path target = tempDir.resolve("uploads/meetings/101/source_audio.wav");
|
||||
// assertEquals("/api/static/meetings/101/source_audio.wav", relocatedUrl);
|
||||
// assertFalse(Files.exists(source));
|
||||
// assertTrue(Files.exists(target));
|
||||
// assertEquals("offline-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldRestoreSourceAndTargetWhenTransactionRollsBack() throws Exception {
|
||||
// MeetingDomainSupport support = newSupport();
|
||||
// Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "new-audio");
|
||||
// Path target = writeFile(tempDir.resolve("uploads/meetings/102/source_audio.wav"), "old-audio");
|
||||
//
|
||||
// TransactionSynchronizationManager.initSynchronization();
|
||||
// String relocatedUrl = support.relocateAudioUrl(102L, "/api/static/audio/offline.wav");
|
||||
//
|
||||
// assertEquals("/api/static/meetings/102/source_audio.wav", relocatedUrl);
|
||||
// assertFalse(Files.exists(source));
|
||||
// assertTrue(Files.exists(target));
|
||||
// assertEquals("new-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
//
|
||||
// triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
//
|
||||
// assertTrue(Files.exists(source));
|
||||
// assertTrue(Files.exists(target));
|
||||
// assertEquals("new-audio", Files.readString(source, StandardCharsets.UTF_8));
|
||||
// assertEquals("old-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
// assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldRelocatePrivateStagingAudioToken() throws Exception {
|
||||
// MeetingDomainSupport support = newSupport();
|
||||
// Path source = writeFile(
|
||||
// tempDir.resolve(".uploads-meeting-staging/audio/private-upload.wav"),
|
||||
// "private-audio"
|
||||
// );
|
||||
//
|
||||
// TransactionSynchronizationManager.initSynchronization();
|
||||
// String relocatedUrl = support.relocateAudioUrl(
|
||||
// 103L,
|
||||
// MeetingAudioUploadSupport.buildStagingAudioToken(source.getFileName().toString())
|
||||
// );
|
||||
// triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
//
|
||||
// Path target = tempDir.resolve("uploads/meetings/103/source_audio.wav");
|
||||
// assertEquals("/api/static/meetings/103/source_audio.wav", relocatedUrl);
|
||||
// assertFalse(Files.exists(source));
|
||||
// assertTrue(Files.exists(target));
|
||||
// assertEquals("private-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldDeleteMeetingArtifactsDirectory() throws Exception {
|
||||
// MeetingDomainSupport support = newSupport();
|
||||
// Path summary = writeFile(tempDir.resolve("uploads/meetings/301/summaries/summary_1.md"), "summary");
|
||||
// Path audio = writeFile(tempDir.resolve("uploads/meetings/301/source_audio.wav"), "audio");
|
||||
//
|
||||
// assertTrue(Files.exists(summary));
|
||||
// assertTrue(Files.exists(audio));
|
||||
//
|
||||
// support.deleteMeetingArtifacts(301L);
|
||||
//
|
||||
// assertFalse(Files.exists(tempDir.resolve("uploads/meetings/301")));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldPrewarmPlaybackAudioAfterTransactionCommit() {
|
||||
// MeetingPlaybackAudioResolver playbackAudioResolver = mock(MeetingPlaybackAudioResolver.class);
|
||||
// MeetingDomainSupport support = newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class), playbackAudioResolver);
|
||||
//
|
||||
// TransactionSynchronizationManager.initSynchronization();
|
||||
// support.prewarmPlaybackAudioAfterCommit("/api/static/meetings/401/source_audio.m4a");
|
||||
//
|
||||
// verify(playbackAudioResolver, never()).prewarmBrowserPlaybackAudio(any());
|
||||
// triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
// verify(playbackAudioResolver).prewarmBrowserPlaybackAudio("/api/static/meetings/401/source_audio.m4a");
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() {
|
||||
// AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
// MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
// MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
//
|
||||
// Meeting meeting = new Meeting();
|
||||
// meeting.setId(201L);
|
||||
// meeting.setLatestSummaryTaskId(501L);
|
||||
//
|
||||
// AiTask latestSummaryTask = new AiTask();
|
||||
// latestSummaryTask.setTaskType("SUMMARY");
|
||||
// latestSummaryTask.setMeetingId(201L);
|
||||
// latestSummaryTask.setTaskConfig(Map.of("userPrompt", " 已发布提示词 "));
|
||||
//
|
||||
// AiTask fallbackTask = new AiTask();
|
||||
// fallbackTask.setTaskType("SUMMARY");
|
||||
// fallbackTask.setMeetingId(201L);
|
||||
// fallbackTask.setTaskConfig(Map.of("userPrompt", " 最新草稿提示词 "));
|
||||
//
|
||||
// when(aiTaskService.getById(501L)).thenReturn(latestSummaryTask);
|
||||
// when(assembler.normalizeOptionalText(" 已发布提示词 ")).thenReturn("已发布提示词");
|
||||
// when(aiTaskService.getOne(any())).thenReturn(fallbackTask);
|
||||
//
|
||||
// String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
//
|
||||
// assertEquals("已发布提示词", resolved);
|
||||
// Mockito.verify(aiTaskService, Mockito.never()).getOne(any());
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldFallbackToLatestSummaryTaskWhenLatestSummaryTaskIdIsUnavailable() {
|
||||
// AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
// MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
// MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
//
|
||||
// Meeting meeting = new Meeting();
|
||||
// meeting.setId(202L);
|
||||
// meeting.setLatestSummaryTaskId(502L);
|
||||
//
|
||||
// AiTask latestSuccessfulTask = new AiTask();
|
||||
// latestSuccessfulTask.setTaskType("SUMMARY");
|
||||
// latestSuccessfulTask.setMeetingId(202L);
|
||||
// latestSuccessfulTask.setStatus(2);
|
||||
// latestSuccessfulTask.setTaskConfig(Map.of("userPrompt", " 成功提示词 "));
|
||||
//
|
||||
// when(aiTaskService.getById(502L)).thenReturn(null);
|
||||
// when(aiTaskService.getOne(any())).thenReturn(latestSuccessfulTask);
|
||||
// when(assembler.normalizeOptionalText(" 成功提示词 ")).thenReturn("成功提示词");
|
||||
//
|
||||
// String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
//
|
||||
// assertEquals("成功提示词", resolved);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldFallbackToLatestSummaryTaskWhenNoSuccessfulTaskExists() {
|
||||
// AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
// MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
// MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
//
|
||||
// Meeting meeting = new Meeting();
|
||||
// meeting.setId(203L);
|
||||
//
|
||||
// AiTask latestTask = new AiTask();
|
||||
// latestTask.setTaskType("SUMMARY");
|
||||
// latestTask.setMeetingId(203L);
|
||||
// latestTask.setTaskConfig(Map.of("userPrompt", " 最新任务提示词 "));
|
||||
//
|
||||
// when(aiTaskService.getOne(any())).thenReturn(null).thenReturn(latestTask);
|
||||
// when(assembler.normalizeOptionalText(" 最新任务提示词 ")).thenReturn("最新任务提示词");
|
||||
//
|
||||
// String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
//
|
||||
// assertEquals("最新任务提示词", resolved);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void shouldReturnNullWhenNoSummaryTaskExists() {
|
||||
// AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
// MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
// MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
//
|
||||
// Meeting meeting = new Meeting();
|
||||
// meeting.setId(204L);
|
||||
//
|
||||
// when(aiTaskService.getOne(any())).thenReturn(null, null);
|
||||
//
|
||||
// String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
//
|
||||
// assertNull(resolved);
|
||||
// }
|
||||
//
|
||||
// private MeetingDomainSupport newSupport() {
|
||||
// return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class));
|
||||
// }
|
||||
//
|
||||
// private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) {
|
||||
// return newSupport(aiTaskService, assembler, mock(MeetingPlaybackAudioResolver.class));
|
||||
// }
|
||||
//
|
||||
// private MeetingDomainSupport newSupport(AiTaskService aiTaskService,
|
||||
// MeetingSummaryPromptAssembler assembler,
|
||||
// MeetingPlaybackAudioResolver playbackAudioResolver) {
|
||||
// MeetingDomainSupport support = new MeetingDomainSupport(
|
||||
// assembler,
|
||||
// aiTaskService,
|
||||
// mock(MeetingTranscriptMapper.class),
|
||||
// mock(SysUserMapper.class),
|
||||
// mock(ApplicationEventPublisher.class),
|
||||
// mock(MeetingSummaryFileService.class),
|
||||
// playbackAudioResolver
|
||||
// );
|
||||
// ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString());
|
||||
// return support;
|
||||
// }
|
||||
//
|
||||
// private void triggerAfterCompletion(int status) {
|
||||
// for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
|
||||
// if (status == TransactionSynchronization.STATUS_COMMITTED) {
|
||||
// synchronization.afterCommit();
|
||||
// }
|
||||
// synchronization.afterCompletion(status);
|
||||
// }
|
||||
// TransactionSynchronizationManager.clearSynchronization();
|
||||
// }
|
||||
//
|
||||
// private Path writeFile(Path path, String content) throws IOException {
|
||||
// Files.createDirectories(path.getParent());
|
||||
// Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
// return path;
|
||||
// }
|
||||
//
|
||||
// private boolean hasBackupFile(Path directory) throws IOException {
|
||||
// if (!Files.exists(directory)) {
|
||||
// return false;
|
||||
// }
|
||||
// try (var stream = Files.list(directory)) {
|
||||
// return stream
|
||||
// .map(Path::getFileName)
|
||||
// .map(Path::toString)
|
||||
// .anyMatch(name -> name.contains(".rollback-") && name.endsWith(".bak"));
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -1,157 +1,157 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.common.RedisKeys;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingSessionState;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RealtimeMeetingSessionStateServiceImplTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void getStatusShouldUseCompletedMeetingWhenRedisActiveIsStale() throws Exception {
|
||||
Long meetingId = 68L;
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 3));
|
||||
when(transcriptMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
|
||||
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
|
||||
assertEquals("COMPLETED", status.getStatus());
|
||||
assertFalse(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
assertFalse(Boolean.TRUE.equals(status.getCanResume()));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusShouldUseTerminalMeetingWhenDatabaseFailed() throws Exception {
|
||||
Long meetingId = 69L;
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 4));
|
||||
when(transcriptMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
|
||||
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
|
||||
assertEquals("COMPLETED", status.getStatus());
|
||||
assertFalse(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusShouldNotClearWhenDatabaseIsCompleting() throws Exception {
|
||||
Long meetingId = 70L;
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 2));
|
||||
|
||||
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
|
||||
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
|
||||
assertEquals("ACTIVE", status.getStatus());
|
||||
assertTrue(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusShouldPreserveActiveWhenDatabaseNotTerminal() throws Exception {
|
||||
Long meetingId = 71L;
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 1));
|
||||
|
||||
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
|
||||
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
|
||||
assertEquals("ACTIVE", status.getStatus());
|
||||
assertTrue(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearShouldDeleteRealtimeEventSeqKey() {
|
||||
Long meetingId = 72L;
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
|
||||
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
|
||||
service.clear(meetingId);
|
||||
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionStateServiceImpl newService(StringRedisTemplate redisTemplate,
|
||||
MeetingTranscriptMapper transcriptMapper,
|
||||
MeetingMapper meetingMapper) {
|
||||
return new RealtimeMeetingSessionStateServiceImpl(redisTemplate, objectMapper, transcriptMapper, meetingMapper);
|
||||
}
|
||||
|
||||
private RealtimeMeetingSessionState activeState(Long meetingId) {
|
||||
RealtimeMeetingSessionState state = new RealtimeMeetingSessionState();
|
||||
state.setMeetingId(meetingId);
|
||||
state.setStatus("ACTIVE");
|
||||
state.setHasTranscript(true);
|
||||
state.setActiveConnectionId("conn-1");
|
||||
state.setUpdatedAt(System.currentTimeMillis());
|
||||
return state;
|
||||
}
|
||||
|
||||
private Meeting meeting(Long meetingId, int status) {
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(meetingId);
|
||||
meeting.setStatus(status);
|
||||
return meeting;
|
||||
}
|
||||
}
|
||||
//package com.imeeting.service.biz.impl;
|
||||
//
|
||||
//import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
//import com.imeeting.common.RedisKeys;
|
||||
//import com.imeeting.dto.biz.RealtimeMeetingSessionState;
|
||||
//import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||
//import com.imeeting.entity.biz.Meeting;
|
||||
//import com.imeeting.mapper.biz.MeetingMapper;
|
||||
//import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
//import org.junit.jupiter.api.Test;
|
||||
//import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
//import org.springframework.data.redis.core.ValueOperations;
|
||||
//
|
||||
//import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
//import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
//import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
//import static org.mockito.ArgumentMatchers.any;
|
||||
//import static org.mockito.Mockito.mock;
|
||||
//import static org.mockito.Mockito.never;
|
||||
//import static org.mockito.Mockito.verify;
|
||||
//import static org.mockito.Mockito.when;
|
||||
//
|
||||
//class RealtimeMeetingSessionStateServiceImplTest {
|
||||
//
|
||||
// private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
//
|
||||
// @Test
|
||||
// void getStatusShouldUseCompletedMeetingWhenRedisActiveIsStale() throws Exception {
|
||||
// Long meetingId = 68L;
|
||||
// StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
// ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
// MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
// when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
// when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
// .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
// when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 3));
|
||||
// when(transcriptMapper.selectCount(any())).thenReturn(1L);
|
||||
//
|
||||
// RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
//
|
||||
// RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
//
|
||||
// assertEquals("COMPLETED", status.getStatus());
|
||||
// assertFalse(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
// assertFalse(Boolean.TRUE.equals(status.getCanResume()));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void getStatusShouldUseTerminalMeetingWhenDatabaseFailed() throws Exception {
|
||||
// Long meetingId = 69L;
|
||||
// StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
// ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
// MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
// when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
// when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
// .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
// when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 4));
|
||||
// when(transcriptMapper.selectCount(any())).thenReturn(0L);
|
||||
//
|
||||
// RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
//
|
||||
// RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
//
|
||||
// assertEquals("COMPLETED", status.getStatus());
|
||||
// assertFalse(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void getStatusShouldNotClearWhenDatabaseIsCompleting() throws Exception {
|
||||
// Long meetingId = 70L;
|
||||
// StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
// ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
// MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
// when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
// when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
// .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
// when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 2));
|
||||
//
|
||||
// RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
//
|
||||
// RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
//
|
||||
// assertEquals("ACTIVE", status.getStatus());
|
||||
// assertTrue(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
// verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
// verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void getStatusShouldPreserveActiveWhenDatabaseNotTerminal() throws Exception {
|
||||
// Long meetingId = 71L;
|
||||
// StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
// ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
// MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
// when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
// when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
|
||||
// .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
|
||||
// when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 1));
|
||||
//
|
||||
// RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
//
|
||||
// RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
|
||||
//
|
||||
// assertEquals("ACTIVE", status.getStatus());
|
||||
// assertTrue(Boolean.TRUE.equals(status.getActiveConnection()));
|
||||
// verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
// verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void clearShouldDeleteRealtimeEventSeqKey() {
|
||||
// Long meetingId = 72L;
|
||||
// StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
|
||||
// MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
//
|
||||
// RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
|
||||
//
|
||||
// service.clear(meetingId);
|
||||
//
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
|
||||
// verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
|
||||
// }
|
||||
//
|
||||
// private RealtimeMeetingSessionStateServiceImpl newService(StringRedisTemplate redisTemplate,
|
||||
// MeetingTranscriptMapper transcriptMapper,
|
||||
// MeetingMapper meetingMapper) {
|
||||
// return new RealtimeMeetingSessionStateServiceImpl(redisTemplate, objectMapper, transcriptMapper, meetingMapper);
|
||||
// }
|
||||
//
|
||||
// private RealtimeMeetingSessionState activeState(Long meetingId) {
|
||||
// RealtimeMeetingSessionState state = new RealtimeMeetingSessionState();
|
||||
// state.setMeetingId(meetingId);
|
||||
// state.setStatus("ACTIVE");
|
||||
// state.setHasTranscript(true);
|
||||
// state.setActiveConnectionId("conn-1");
|
||||
// state.setUpdatedAt(System.currentTimeMillis());
|
||||
// return state;
|
||||
// }
|
||||
//
|
||||
// private Meeting meeting(Long meetingId, int status) {
|
||||
// Meeting meeting = new Meeting();
|
||||
// meeting.setId(meetingId);
|
||||
// meeting.setStatus(status);
|
||||
// return meeting;
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import http from "../http";
|
||||
|
||||
export interface MeetingPointsOverviewVO {
|
||||
accountMode: string;
|
||||
publicBalance: number;
|
||||
publicTotalPointsUsed: number;
|
||||
totalChargeCount: number;
|
||||
}
|
||||
|
||||
export interface MeetingPointsLedgerListItemVO {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
meetingId?: number;
|
||||
meetingTitle?: string;
|
||||
summaryTaskId?: number;
|
||||
ownerUserId?: number;
|
||||
ownerUserName?: string;
|
||||
chargeAccountType?: string;
|
||||
pointsType: "ASR" | "LLM" | "INIT" | "RECHARGE";
|
||||
consumedPoints: number;
|
||||
balanceBefore?: number;
|
||||
balanceAfter?: number;
|
||||
chargeTriggerType?: "AUTO_SUMMARY" | "RESUMMARY";
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface MeetingPointsLedgerDetailVO {
|
||||
id: number;
|
||||
meetingId?: number;
|
||||
meetingTitle?: string;
|
||||
summaryTaskId?: number;
|
||||
ownerUserId?: number;
|
||||
ownerUserName?: string;
|
||||
chargeAccountType?: string;
|
||||
chargeAccountUserId?: number;
|
||||
pointsType?: string;
|
||||
consumedPoints?: number;
|
||||
balanceBefore?: number;
|
||||
balanceAfter?: number;
|
||||
chargeTriggerType?: string;
|
||||
audioDurationSeconds?: number;
|
||||
chargedMinutes?: number;
|
||||
billingUnits?: number;
|
||||
unitMinutesSnapshot?: number;
|
||||
costPerUnitSnapshot?: number;
|
||||
asrRatioSnapshot?: number;
|
||||
llmRatioSnapshot?: number;
|
||||
totalPoints?: number;
|
||||
chargedTotalPoints?: number;
|
||||
asrPoints?: number;
|
||||
chargedAsrPoints?: number;
|
||||
llmPoints?: number;
|
||||
chargedLlmPoints?: number;
|
||||
summaryStatus?: string;
|
||||
failureReason?: string;
|
||||
asrChargedAt?: string;
|
||||
llmChargedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export async function getMeetingPointsOverview() {
|
||||
const resp = await http.get("/api/biz/meeting-points/management/overview");
|
||||
return resp.data.data as MeetingPointsOverviewVO;
|
||||
}
|
||||
|
||||
export async function getMeetingPointsLedgerPage(params: {
|
||||
current: number;
|
||||
size: number;
|
||||
username?: string;
|
||||
pointsType?: string;
|
||||
}) {
|
||||
const resp = await http.get("/api/biz/meeting-points/management/ledgers", { params });
|
||||
return resp.data.data as { records: MeetingPointsLedgerListItemVO[]; total: number };
|
||||
}
|
||||
|
||||
export async function getMeetingPointsLedgerDetail(ledgerId: number) {
|
||||
const resp = await http.get(`/api/biz/meeting-points/management/ledgers/${ledgerId}`);
|
||||
return resp.data.data as MeetingPointsLedgerDetailVO;
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
import { Alert, Button, Card, Col, Descriptions, Input, Modal, Row, Select, Space, Statistic, Tag, Typography, message } from "antd";
|
||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import PageContainer from "@/components/shared/PageContainer";
|
||||
import ListTable from "@/components/shared/ListTable/ListTable";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import {
|
||||
getMeetingPointsLedgerDetail,
|
||||
getMeetingPointsLedgerPage,
|
||||
getMeetingPointsOverview,
|
||||
type MeetingPointsLedgerDetailVO,
|
||||
type MeetingPointsLedgerListItemVO,
|
||||
type MeetingPointsOverviewVO,
|
||||
} from "@/api/business/meetingPoints";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const POINTS_TYPE_OPTIONS = [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "转录", value: "ASR" },
|
||||
{ label: "总结", value: "LLM" },
|
||||
];
|
||||
|
||||
function getAccountModeLabel(mode?: string) {
|
||||
return mode === "PERSONAL" ? "个人账户" : "公共账户";
|
||||
}
|
||||
|
||||
function getPointsTypeLabel(value?: string) {
|
||||
if (value === "ASR") {
|
||||
return "转录";
|
||||
}
|
||||
if (value === "LLM") {
|
||||
return "总结";
|
||||
}
|
||||
if (value === "INIT") {
|
||||
return "初始化";
|
||||
}
|
||||
if (value === "RECHARGE") {
|
||||
return "充值";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function getPointsTypeColor(value?: string) {
|
||||
if (value === "ASR") {
|
||||
return "blue";
|
||||
}
|
||||
if (value === "LLM") {
|
||||
return "purple";
|
||||
}
|
||||
if (value === "RECHARGE") {
|
||||
return "green";
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
function getChargeTriggerLabel(value?: string) {
|
||||
if (value === "RESUMMARY") {
|
||||
return "重新总结";
|
||||
}
|
||||
if (value === "AUTO_SUMMARY") {
|
||||
return "自动总结";
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
return value ? value.replace("T", " ").substring(0, 19) : "-";
|
||||
}
|
||||
|
||||
export default function MeetingPointsManagement() {
|
||||
const [overview, setOverview] = useState<MeetingPointsOverviewVO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<MeetingPointsLedgerDetailVO | null>(null);
|
||||
const [params, setParams] = useState({
|
||||
current: 1,
|
||||
size: 20,
|
||||
username: "",
|
||||
pointsType: "",
|
||||
});
|
||||
|
||||
const loadOverview = async () => {
|
||||
const data = await getMeetingPointsOverview();
|
||||
setOverview(data);
|
||||
};
|
||||
|
||||
const loadPage = async (nextParams = params) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getMeetingPointsLedgerPage(nextParams);
|
||||
setRecords(result.records || []);
|
||||
setTotal(result.total || 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
void loadPage();
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
const nextParams = { ...params, current: 1 };
|
||||
setParams(nextParams);
|
||||
void loadPage(nextParams);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const nextParams = {
|
||||
current: 1,
|
||||
size: 20,
|
||||
username: "",
|
||||
pointsType: "",
|
||||
};
|
||||
setParams(nextParams);
|
||||
void loadPage(nextParams);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([loadOverview(), loadPage()]);
|
||||
message.success("已刷新积分数据");
|
||||
};
|
||||
|
||||
const handleOpenDetail = async (ledgerId: number) => {
|
||||
setDetailLoading(true);
|
||||
setDetailOpen(true);
|
||||
try {
|
||||
const data = await getMeetingPointsLedgerDetail(ledgerId);
|
||||
setDetail(data);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "用户名",
|
||||
dataIndex: "ownerUserName",
|
||||
key: "ownerUserName",
|
||||
width: 140,
|
||||
render: (value: string) => <Text strong>{value || "-"}</Text>,
|
||||
},
|
||||
{
|
||||
title: "消耗类型",
|
||||
dataIndex: "pointsType",
|
||||
key: "pointsType",
|
||||
width: 100,
|
||||
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "消耗积分",
|
||||
dataIndex: "consumedPoints",
|
||||
key: "consumedPoints",
|
||||
width: 110,
|
||||
render: (value: number) => <Text>{value ?? 0}</Text>,
|
||||
},
|
||||
{
|
||||
title: "会议标题",
|
||||
dataIndex: "meetingTitle",
|
||||
key: "meetingTitle",
|
||||
ellipsis: true,
|
||||
render: (value: string) => <Text>{value || "-"}</Text>,
|
||||
},
|
||||
{
|
||||
title: "触发类型",
|
||||
dataIndex: "chargeTriggerType",
|
||||
key: "chargeTriggerType",
|
||||
width: 130,
|
||||
render: (value: string) => <Tag>{getChargeTriggerLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "消耗时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
render: (value: string) => <Text>{formatDateTime(value)}</Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 88,
|
||||
fixed: "right" as const,
|
||||
render: (_: unknown, record: MeetingPointsLedgerListItemVO) => (
|
||||
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => void handleOpenDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="积分管理"
|
||||
subtitle="当前页面展示余额与消耗流水"
|
||||
headerExtra={
|
||||
<Space>
|
||||
{/*<Tag color="processing">当前结算模式:{getAccountModeLabel(overview?.accountMode)}</Tag>*/}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
toolbar={
|
||||
<Space wrap size="middle">
|
||||
<Input
|
||||
placeholder="按用户名搜索"
|
||||
value={params.username}
|
||||
onChange={(event) => setParams((prev) => ({ ...prev, username: event.target.value }))}
|
||||
style={{ width: 220 }}
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={params.pointsType}
|
||||
onChange={(value) => setParams((prev) => ({ ...prev, pointsType: value }))}
|
||||
options={POINTS_TYPE_OPTIONS}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{/*<Col xs={24} md={6}>*/}
|
||||
{/* <Card>*/}
|
||||
{/* <Statistic title="当前结算账户" value={getAccountModeLabel(overview?.accountMode)} />*/}
|
||||
{/* </Card>*/}
|
||||
{/*</Col>*/}
|
||||
<Col xs={24} md={6}>
|
||||
<Card>
|
||||
<Statistic title="剩余总积分" value={overview?.publicBalance ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card>
|
||||
<Statistic title="累计消耗总积分" value={overview?.publicTotalPointsUsed ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card>
|
||||
<Statistic title="累计消耗次数" value={overview?.totalChargeCount ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, display: "flex", flexDirection: "column", minHeight: 0 } }}>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
|
||||
<ListTable
|
||||
rowKey="id"
|
||||
columns={columns as any}
|
||||
dataSource={records}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
scroll={{ y: "calc(100vh - 430px)", x: 1100 }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={params.current}
|
||||
pageSize={params.size}
|
||||
total={total}
|
||||
onChange={(page, pageSize) => {
|
||||
const nextParams = { ...params, current: page, size: pageSize };
|
||||
setParams(nextParams);
|
||||
void loadPage(nextParams);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="消耗详情"
|
||||
open={detailOpen}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setDetailOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={720}
|
||||
confirmLoading={detailLoading}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="用户名">{detail.ownerUserName || "-"}</Descriptions.Item>
|
||||
{/*<Descriptions.Item label="扣费账户">{getAccountModeLabel(detail.chargeAccountType)}</Descriptions.Item>*/}
|
||||
<Descriptions.Item label="消耗类型">{getPointsTypeLabel(detail.pointsType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗积分">{detail.consumedPoints ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗时间">{formatDateTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="会议标题">{detail.meetingTitle || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="触发类型">{getChargeTriggerLabel(detail.chargeTriggerType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="录音时长(秒)">{detail.audioDurationSeconds ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="计费分钟数">{detail.chargedMinutes ?? "-"}</Descriptions.Item>
|
||||
{/*<Descriptions.Item label="计费单位数">{detail.billingUnits ?? "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="单位分钟数">{detail.unitMinutesSnapshot ?? "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="单位价格">{detail.costPerUnitSnapshot ?? "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="应计总积分">{detail.totalPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="已扣总积分">{detail.chargedTotalPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="应计 ASR 积分">{detail.asrPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="已扣 ASR 积分">{detail.chargedAsrPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="应计 LLM 积分">{detail.llmPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="已扣 LLM 积分">{detail.chargedLlmPoints ?? 0}</Descriptions.Item>*/}
|
||||
<Descriptions.Item label="扣费前余额">{detail.balanceBefore ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="扣费后余额">{detail.balanceAfter ?? "-"}</Descriptions.Item>
|
||||
{/*<Descriptions.Item label="记录状态">{detail.summaryStatus || "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="失败原因">{detail.failureReason || "-"}</Descriptions.Item>*/}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permissio
|
|||
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"));
|
||||
|
||||
import SpeakerReg from "../pages/business/SpeakerReg";
|
||||
const RealtimeAsrSession = lazy(async () => {
|
||||
|
|
@ -66,6 +67,7 @@ export const menuRoutes: MenuRoute[] = [
|
|||
{ path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" },
|
||||
{ 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" },
|
||||
{ path: "/meetings", label: "会议中心", element: <LazyPage><Meetings /></LazyPage>, perm: "menu:meeting" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ export default defineConfig({
|
|||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/auth": "http://10.100.51.199:8080",
|
||||
"/sys": "http://10.100.51.199:8080",
|
||||
"/api": "http://10.100.51.199:8080",
|
||||
"/auth": "http://localhost:8080",
|
||||
"/sys": "http://localhost:8080",
|
||||
"/api": "http://localhost:8080",
|
||||
"/ws": {
|
||||
target: "ws://10.100.51.199:8080",
|
||||
target: "ws://localhost:8080",
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue