feat: 添加会议积分管理功能

- 新增 `MeetingPointsController` 用于查看会议积分余额
- 新增 `MeetingPointsBalanceVO` DTO 类,表示会议积分余额视图
- 新增前端页面 `MeetingPointsManagement`,展示会议积分余额和消耗流水
- 新增 `MeetingSummaryChargeRecordServiceImpl` 服务实现类
dev_na
chenhao 2026-06-04 14:18:45 +08:00
parent e7659b1e31
commit 40bf049a0e
47 changed files with 2950 additions and 484 deletions

View File

@ -377,6 +377,106 @@
### 5.9 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級 ### 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 | | id | BIGSERIAL | PK | 涓婚敭ID |
| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID | | meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID |
| task_type | VARCHAR(20) | | 浠诲姟绫诲瀷锛圓SR / SUMMARY锛?| | task_type | VARCHAR(20) | | 浠诲姟绫诲瀷锛圓SR / SUMMARY锛?|

View File

@ -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_meetings IS '会议管理主表';
COMMENT ON TABLE biz_meeting_transcripts IS '会议转录明细表'; COMMENT ON TABLE biz_meeting_transcripts IS '会议转录明细表';
COMMENT ON TABLE biz_ai_tasks IS 'AI 任务流水日志表'; 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"; DROP TABLE IF EXISTS "biz_prompt_template_user_config";
CREATE TABLE "biz_prompt_template_user_config" ( CREATE TABLE "biz_prompt_template_user_config" (
"id" BIGSERIAL PRIMARY KEY, "id" BIGSERIAL PRIMARY KEY,

View File

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

View File

@ -6,6 +6,7 @@ import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceRegistrationService; import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
@ -38,6 +39,7 @@ public class AndroidDeviceController {
) )
}) })
@PostMapping("/register") @PostMapping("/register")
@Anonymous
public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request, public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request,
@RequestBody(required = false) AndroidDeviceRegisterRequest command) { @RequestBody(required = false) AndroidDeviceRegisterRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command); AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command);

View File

@ -130,10 +130,10 @@ public class AndroidMeetingController {
AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command); AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId()); // Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId());
if (existingMeeting != null) { // if (existingMeeting != null) {
return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); // return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
} // }
return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, authContext, loginUser)); return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, authContext, loginUser));
} }

View File

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

View File

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

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android设备注册请求")
public class AndroidDeviceRegisterRequest {
@Schema(description = "设备名称")
private String deviceName;
@Schema(description = "终端类型,可为空,默认使用请求头平台")
private String terminalType;
@Schema(description = "终端版本,可为空,默认使用请求头版本")
private String terminalVersion;
}

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "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;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,20 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "积分管理总览视图")
public class MeetingPointsOverviewVO {
@Schema(description = "当前结算模式PUBLIC / PERSONAL")
private String accountMode;
@Schema(description = "公共账户余额")
private Long publicBalance;
@Schema(description = "公共账户累计消耗积分")
private Long publicTotalPointsUsed;
@Schema(description = "累计消耗次数")
private Long totalChargeCount;
}

View File

@ -57,6 +57,8 @@ public class MeetingVO {
private String accessPassword; private String accessPassword;
@Schema(description = "音频时长,单位秒") @Schema(description = "音频时长,单位秒")
private Integer duration; private Integer duration;
@Schema(description = "会议最终有效录音时长,单位秒")
private Integer effectiveAudioDurationSeconds;
@Schema(description = "会议摘要内容") @Schema(description = "会议摘要内容")
private String summaryContent; private String summaryContent;
@Schema(description = "最后一次用户补充提示词") @Schema(description = "最后一次用户补充提示词")
@ -69,6 +71,8 @@ public class MeetingVO {
private Integer latestSummaryAttemptStatus; private Integer latestSummaryAttemptStatus;
@Schema(description = "最近一次总结尝试错误信息") @Schema(description = "最近一次总结尝试错误信息")
private String latestSummaryAttemptErrorMsg; private String latestSummaryAttemptErrorMsg;
@Schema(description = "最近一次总结尝试阻塞原因")
private String latestSummaryAttemptBlockedReason;
@Schema(description = "最近一次章节尝试任务 ID") @Schema(description = "最近一次章节尝试任务 ID")
private Long latestChapterAttemptTaskId; private Long latestChapterAttemptTaskId;
@Schema(description = "最近一次章节尝试任务状态") @Schema(description = "最近一次章节尝试任务状态")

View File

@ -50,6 +50,9 @@ public class Meeting extends BaseEntity {
@Schema(description = "总结详细程度") @Schema(description = "总结详细程度")
private String summaryDetailLevel; private String summaryDetailLevel;
@Schema(description = "会议最终有效录音时长(秒)")
private Integer effectiveAudioDurationSeconds;
@Schema(description = "音频保存状态") @Schema(description = "音频保存状态")
private String audioSaveStatus; private String audioSaveStatus;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
@ -71,6 +72,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final HotWordService hotWordService; private final HotWordService hotWordService;
private final MeetingLockCache meetingLockCache; private final MeetingLockCache meetingLockCache;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final MeetingPointsService meetingPointsService;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingTranscriptFileService meetingTranscriptFileService;
@ -116,6 +118,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
HotWordService hotWordService, HotWordService hotWordService,
MeetingLockCache meetingLockCache, MeetingLockCache meetingLockCache,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
MeetingPointsService meetingPointsService,
MeetingSummaryFileService meetingSummaryFileService, MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingTranscriptChapterService meetingTranscriptChapterService,
@ -132,6 +135,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.hotWordService = hotWordService; this.hotWordService = hotWordService;
this.meetingLockCache = meetingLockCache; this.meetingLockCache = meetingLockCache;
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.meetingPointsService = meetingPointsService;
this.meetingSummaryFileService = meetingSummaryFileService; this.meetingSummaryFileService = meetingSummaryFileService;
this.meetingTranscriptFileService = meetingTranscriptFileService; this.meetingTranscriptFileService = meetingTranscriptFileService;
this.meetingTranscriptChapterService = meetingTranscriptChapterService; this.meetingTranscriptChapterService = meetingTranscriptChapterService;
@ -300,12 +304,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (meeting == null) { if (meeting == null) {
return; 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"); AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
try { try {
if (sumTask != null && canExecuteTask(sumTask)) { if (sumTask != null && canExecuteTask(sumTask)) {
@ -549,7 +547,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (resultNode == null) throw new RuntimeException("ASR轮询超时"); 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) { 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()); meeting.setLatestSummaryTaskId(taskRecord.getId());
meetingMapper.updateById(meeting); meetingMapper.updateById(meeting);
meetingPointsService.recordSummarySuccessCharge(meeting, taskRecord);
AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER"); AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER");
if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) { 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.setErrorMsg(error);
task.setCompletedAt(LocalDateTime.now()); task.setCompletedAt(LocalDateTime.now());
this.updateById(task); this.updateById(task);
if ("SUMMARY".equals(task.getTaskType())) {
meetingPointsService.markSummaryChargeFailed(task.getId(), error);
}
} }
} }

View File

@ -34,6 +34,7 @@ import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
@ -78,6 +79,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService; private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final MeetingPointsService meetingPointsService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
private final AndroidMeetingPushService androidMeetingPushService; private final AndroidMeetingPushService androidMeetingPushService;
@ -103,6 +105,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService, RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
MeetingPointsService meetingPointsService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
AndroidMeetingPushService androidMeetingPushService, AndroidMeetingPushService androidMeetingPushService,
@ -123,6 +126,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
this.realtimeMeetingAudioStorageService = realtimeMeetingAudioStorageService; this.realtimeMeetingAudioStorageService = realtimeMeetingAudioStorageService;
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.meetingPointsService = meetingPointsService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger; this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
this.androidMeetingPushService = androidMeetingPushService; this.androidMeetingPushService = androidMeetingPushService;
@ -196,7 +200,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
summaryDetailLevel summaryDetailLevel
); );
} }
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl())); meetingDomainSupport.applyMeetingAudioMetadata(
meeting,
meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl())
);
meetingService.updateById(meeting); meetingService.updateById(meeting);
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl()); meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
meetingDomainSupport.publishMeetingCreated(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId()); meetingDomainSupport.publishMeetingCreated(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
@ -441,7 +448,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (audioUrl == null || audioUrl.isBlank()) { if (audioUrl == null || audioUrl.isBlank()) {
throw new RuntimeException("overwriteAudio 为 true 时必须提供音频地址"); throw new RuntimeException("overwriteAudio 为 true 时必须提供音频地址");
} }
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)); meetingDomainSupport.applyMeetingAudioMetadata(
meeting,
meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)
);
markAudioSaveSuccess(meeting); markAudioSaveSuccess(meeting);
meetingService.updateById(meeting); meetingService.updateById(meeting);
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl()); meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
@ -453,7 +463,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
} }
if (audioUrl != null && !audioUrl.isBlank()) { if (audioUrl != null && !audioUrl.isBlank()) {
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)); meetingDomainSupport.applyMeetingAudioMetadata(
meeting,
meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)
);
markAudioSaveSuccess(meeting); markAudioSaveSuccess(meeting);
meetingService.updateById(meeting); meetingService.updateById(meeting);
} }
@ -486,7 +499,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
return; return;
} }
if (result.audioUrl() != null && !result.audioUrl().isBlank()) { if (result.audioUrl() != null && !result.audioUrl().isBlank()) {
meeting.setAudioUrl(result.audioUrl()); meetingDomainSupport.applyMeetingAudioMetadata(meeting, result.audioUrl());
} }
if (result.failed()) { if (result.failed()) {
markAudioSaveFailure(meeting, result.message()); markAudioSaveFailure(meeting, result.message());
@ -764,7 +777,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
summaryModelId, summaryModelId,
promptId, promptId,
userPrompt, userPrompt,
meeting.getSummaryDetailLevel() meeting.getSummaryDetailLevel(),
"RESUMMARY"
) )
: meetingDomainSupport.createSummaryTask( : meetingDomainSupport.createSummaryTask(
meeting.getId(), meeting.getId(),
@ -772,7 +786,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
chapterModelId, chapterModelId,
promptId, promptId,
userPrompt, userPrompt,
meeting.getSummaryDetailLevel() meeting.getSummaryDetailLevel(),
"RESUMMARY"
); );
meeting.setLatestSummaryTaskId(createdSummaryTask.getId()); meeting.setLatestSummaryTaskId(createdSummaryTask.getId());
meeting.setStatus(2); meeting.setStatus(2);
@ -834,6 +849,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
summaryTask.setErrorMsg(null); summaryTask.setErrorMsg(null);
summaryTask.setCompletedAt(java.time.LocalDateTime.now()); summaryTask.setCompletedAt(java.time.LocalDateTime.now());
aiTaskService.updateById(summaryTask); aiTaskService.updateById(summaryTask);
meetingPointsService.recordSummarySuccessCharge(meeting, summaryTask);
boolean alreadyCompleted = Integer.valueOf(3).equals(meeting.getStatus()); boolean alreadyCompleted = Integer.valueOf(3).equals(meeting.getStatus());
meeting.setLatestSummaryTaskId(summaryTask.getId()); meeting.setLatestSummaryTaskId(summaryTask.getId());
@ -958,7 +974,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
summaryModelId, summaryModelId,
promptId, promptId,
userPrompt, userPrompt,
effectiveSummaryDetailLevel effectiveSummaryDetailLevel,
"RESUMMARY"
); );
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel); meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
meeting.setStatus(2); meeting.setStatus(2);
@ -1204,6 +1221,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
task.setErrorMsg(message); task.setErrorMsg(message);
task.setCompletedAt(java.time.LocalDateTime.now()); task.setCompletedAt(java.time.LocalDateTime.now());
aiTaskService.updateById(task); aiTaskService.updateById(task);
if ("SUMMARY".equals(task.getTaskType())) {
meetingPointsService.markSummaryChargeFailed(task.getId(), message);
}
} }
private void dispatchChapterTaskAfterCommit(Long meetingId, Long tenantId, Long userId) { private void dispatchChapterTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {

View File

@ -8,6 +8,7 @@ import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.event.MeetingCreatedEvent; import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.entity.SysUser; 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.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -42,6 +46,7 @@ public class MeetingDomainSupport {
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final AiTaskService aiTaskService; private final AiTaskService aiTaskService;
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
private final MeetingPointsService meetingPointsService;
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
@ -92,7 +97,8 @@ public class MeetingDomainSupport {
summaryModelId, summaryModelId,
promptId, promptId,
userPrompt, userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD MeetingConstants.SUMMARY_DETAIL_STANDARD,
"AUTO_SUMMARY"
); );
} }
@ -104,7 +110,21 @@ public class MeetingDomainSupport {
summaryModelId, summaryModelId,
promptId, promptId,
userPrompt, 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, chapterModelId,
promptId, promptId,
userPrompt, userPrompt,
MeetingConstants.SUMMARY_DETAIL_STANDARD MeetingConstants.SUMMARY_DETAIL_STANDARD,
"AUTO_SUMMARY"
); );
} }
public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId, public AiTask createSummaryTask(Long meetingId, Long summaryModelId, Long chapterModelId, Long promptId,
String userPrompt, String summaryDetailLevel) { 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(); AiTask sumTask = new AiTask();
sumTask.setMeetingId(meetingId); sumTask.setMeetingId(meetingId);
sumTask.setTaskType("SUMMARY"); sumTask.setTaskType("SUMMARY");
sumTask.setStatus(0); sumTask.setStatus(0);
sumTask.setQueuedAt(LocalDateTime.now()); sumTask.setQueuedAt(LocalDateTime.now());
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig( Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
summaryModelId, summaryModelId,
chapterModelId, chapterModelId,
promptId, promptId,
userPrompt, userPrompt,
normalizeSummaryDetailLevel(summaryDetailLevel) normalizeSummaryDetailLevel(summaryDetailLevel)
)); );
taskConfig.put("chargeTriggerType", chargeTriggerType == null || chargeTriggerType.isBlank()
? "AUTO_SUMMARY"
: chargeTriggerType.trim().toUpperCase());
sumTask.setTaskConfig(taskConfig);
aiTaskService.save(sumTask); aiTaskService.save(sumTask);
return 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) { public void deleteMeetingArtifacts(Long meetingId) {
if (meetingId == null) { if (meetingId == null) {
return; return;
@ -339,27 +377,6 @@ public class MeetingDomainSupport {
return finalSpeakerId; 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, public void fillMeetingVO(Meeting meeting, com.imeeting.dto.biz.MeetingVO vo, boolean includeSummary,
boolean includePlaybackAudio) { boolean includePlaybackAudio) {
vo.setId(meeting.getId()); vo.setId(meeting.getId());
@ -383,7 +400,9 @@ public class MeetingDomainSupport {
vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword()); 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.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt()); vo.setCreatedAt(meeting.getCreatedAt());
@ -460,6 +479,7 @@ public class MeetingDomainSupport {
vo.setLatestSummaryAttemptTaskId(latestSummaryAttempt.getId()); vo.setLatestSummaryAttemptTaskId(latestSummaryAttempt.getId());
vo.setLatestSummaryAttemptStatus(latestSummaryAttempt.getStatus()); vo.setLatestSummaryAttemptStatus(latestSummaryAttempt.getStatus());
vo.setLatestSummaryAttemptErrorMsg(normalizeTaskError(latestSummaryAttempt.getErrorMsg())); vo.setLatestSummaryAttemptErrorMsg(normalizeTaskError(latestSummaryAttempt.getErrorMsg()));
vo.setLatestSummaryAttemptBlockedReason(meetingPointsService.resolveLatestBlockedReason(latestSummaryAttempt.getId()));
} }
AiTask latestChapterAttempt = resolveLatestTaskAttempt(meeting, "CHAPTER"); AiTask latestChapterAttempt = resolveLatestTaskAttempt(meeting, "CHAPTER");
@ -501,6 +521,46 @@ public class MeetingDomainSupport {
return MeetingConstants.SUMMARY_DETAIL_STANDARD; 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) { private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.CrossOrigin;
import java.time.Duration; import java.time.Duration;

View File

@ -1,278 +1,278 @@
package com.imeeting.service.biz.impl; //package com.imeeting.service.biz.impl;
//
import com.imeeting.entity.biz.AiTask; //import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; //import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; //import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiTaskService; //import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingSummaryFileService; //import com.imeeting.service.biz.MeetingSummaryFileService;
import com.unisbase.mapper.SysUserMapper; //import com.unisbase.mapper.SysUserMapper;
import org.junit.jupiter.api.AfterEach; //import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; //import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; //import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito; //import org.mockito.Mockito;
import org.springframework.context.ApplicationEventPublisher; //import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils; //import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization; //import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; //import org.springframework.transaction.support.TransactionSynchronizationManager;
//
import java.io.IOException; //import java.io.IOException;
import java.nio.charset.StandardCharsets; //import java.nio.charset.StandardCharsets;
import java.nio.file.Files; //import java.nio.file.Files;
import java.nio.file.Path; //import java.nio.file.Path;
import java.util.Map; //import java.util.Map;
//
import static org.junit.jupiter.api.Assertions.assertEquals; //import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; //import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull; //import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; //import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; //import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; //import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; //import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; //import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; //import static org.mockito.Mockito.when;
//
class MeetingDomainSupportTest { //class MeetingDomainSupportTest {
//
@TempDir // @TempDir
Path tempDir; // Path tempDir;
//
@AfterEach // @AfterEach
void clearSynchronization() { // void clearSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) { // if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization(); // TransactionSynchronizationManager.clearSynchronization();
} // }
} // }
//
@Test // @Test
void shouldKeepRelocatedAudioAfterCommit() throws Exception { // void shouldKeepRelocatedAudioAfterCommit() throws Exception {
MeetingDomainSupport support = newSupport(); // MeetingDomainSupport support = newSupport();
Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "offline-audio"); // Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "offline-audio");
//
TransactionSynchronizationManager.initSynchronization(); // TransactionSynchronizationManager.initSynchronization();
String relocatedUrl = support.relocateAudioUrl(101L, "/api/static/audio/offline.wav"); // String relocatedUrl = support.relocateAudioUrl(101L, "/api/static/audio/offline.wav");
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED); // triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
//
Path target = tempDir.resolve("uploads/meetings/101/source_audio.wav"); // Path target = tempDir.resolve("uploads/meetings/101/source_audio.wav");
assertEquals("/api/static/meetings/101/source_audio.wav", relocatedUrl); // assertEquals("/api/static/meetings/101/source_audio.wav", relocatedUrl);
assertFalse(Files.exists(source)); // assertFalse(Files.exists(source));
assertTrue(Files.exists(target)); // assertTrue(Files.exists(target));
assertEquals("offline-audio", Files.readString(target, StandardCharsets.UTF_8)); // assertEquals("offline-audio", Files.readString(target, StandardCharsets.UTF_8));
} // }
//
@Test // @Test
void shouldRestoreSourceAndTargetWhenTransactionRollsBack() throws Exception { // void shouldRestoreSourceAndTargetWhenTransactionRollsBack() throws Exception {
MeetingDomainSupport support = newSupport(); // MeetingDomainSupport support = newSupport();
Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "new-audio"); // Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "new-audio");
Path target = writeFile(tempDir.resolve("uploads/meetings/102/source_audio.wav"), "old-audio"); // Path target = writeFile(tempDir.resolve("uploads/meetings/102/source_audio.wav"), "old-audio");
//
TransactionSynchronizationManager.initSynchronization(); // TransactionSynchronizationManager.initSynchronization();
String relocatedUrl = support.relocateAudioUrl(102L, "/api/static/audio/offline.wav"); // String relocatedUrl = support.relocateAudioUrl(102L, "/api/static/audio/offline.wav");
//
assertEquals("/api/static/meetings/102/source_audio.wav", relocatedUrl); // assertEquals("/api/static/meetings/102/source_audio.wav", relocatedUrl);
assertFalse(Files.exists(source)); // assertFalse(Files.exists(source));
assertTrue(Files.exists(target)); // assertTrue(Files.exists(target));
assertEquals("new-audio", Files.readString(target, StandardCharsets.UTF_8)); // assertEquals("new-audio", Files.readString(target, StandardCharsets.UTF_8));
//
triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK); // triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
//
assertTrue(Files.exists(source)); // assertTrue(Files.exists(source));
assertTrue(Files.exists(target)); // assertTrue(Files.exists(target));
assertEquals("new-audio", Files.readString(source, StandardCharsets.UTF_8)); // assertEquals("new-audio", Files.readString(source, StandardCharsets.UTF_8));
assertEquals("old-audio", Files.readString(target, StandardCharsets.UTF_8)); // assertEquals("old-audio", Files.readString(target, StandardCharsets.UTF_8));
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102"))); // assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
} // }
//
@Test // @Test
void shouldRelocatePrivateStagingAudioToken() throws Exception { // void shouldRelocatePrivateStagingAudioToken() throws Exception {
MeetingDomainSupport support = newSupport(); // MeetingDomainSupport support = newSupport();
Path source = writeFile( // Path source = writeFile(
tempDir.resolve(".uploads-meeting-staging/audio/private-upload.wav"), // tempDir.resolve(".uploads-meeting-staging/audio/private-upload.wav"),
"private-audio" // "private-audio"
); // );
//
TransactionSynchronizationManager.initSynchronization(); // TransactionSynchronizationManager.initSynchronization();
String relocatedUrl = support.relocateAudioUrl( // String relocatedUrl = support.relocateAudioUrl(
103L, // 103L,
MeetingAudioUploadSupport.buildStagingAudioToken(source.getFileName().toString()) // MeetingAudioUploadSupport.buildStagingAudioToken(source.getFileName().toString())
); // );
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED); // triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
//
Path target = tempDir.resolve("uploads/meetings/103/source_audio.wav"); // Path target = tempDir.resolve("uploads/meetings/103/source_audio.wav");
assertEquals("/api/static/meetings/103/source_audio.wav", relocatedUrl); // assertEquals("/api/static/meetings/103/source_audio.wav", relocatedUrl);
assertFalse(Files.exists(source)); // assertFalse(Files.exists(source));
assertTrue(Files.exists(target)); // assertTrue(Files.exists(target));
assertEquals("private-audio", Files.readString(target, StandardCharsets.UTF_8)); // assertEquals("private-audio", Files.readString(target, StandardCharsets.UTF_8));
} // }
//
@Test // @Test
void shouldDeleteMeetingArtifactsDirectory() throws Exception { // void shouldDeleteMeetingArtifactsDirectory() throws Exception {
MeetingDomainSupport support = newSupport(); // MeetingDomainSupport support = newSupport();
Path summary = writeFile(tempDir.resolve("uploads/meetings/301/summaries/summary_1.md"), "summary"); // 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"); // Path audio = writeFile(tempDir.resolve("uploads/meetings/301/source_audio.wav"), "audio");
//
assertTrue(Files.exists(summary)); // assertTrue(Files.exists(summary));
assertTrue(Files.exists(audio)); // assertTrue(Files.exists(audio));
//
support.deleteMeetingArtifacts(301L); // support.deleteMeetingArtifacts(301L);
//
assertFalse(Files.exists(tempDir.resolve("uploads/meetings/301"))); // assertFalse(Files.exists(tempDir.resolve("uploads/meetings/301")));
} // }
//
@Test // @Test
void shouldPrewarmPlaybackAudioAfterTransactionCommit() { // void shouldPrewarmPlaybackAudioAfterTransactionCommit() {
MeetingPlaybackAudioResolver playbackAudioResolver = mock(MeetingPlaybackAudioResolver.class); // MeetingPlaybackAudioResolver playbackAudioResolver = mock(MeetingPlaybackAudioResolver.class);
MeetingDomainSupport support = newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class), playbackAudioResolver); // MeetingDomainSupport support = newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class), playbackAudioResolver);
//
TransactionSynchronizationManager.initSynchronization(); // TransactionSynchronizationManager.initSynchronization();
support.prewarmPlaybackAudioAfterCommit("/api/static/meetings/401/source_audio.m4a"); // support.prewarmPlaybackAudioAfterCommit("/api/static/meetings/401/source_audio.m4a");
//
verify(playbackAudioResolver, never()).prewarmBrowserPlaybackAudio(any()); // verify(playbackAudioResolver, never()).prewarmBrowserPlaybackAudio(any());
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED); // triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
verify(playbackAudioResolver).prewarmBrowserPlaybackAudio("/api/static/meetings/401/source_audio.m4a"); // verify(playbackAudioResolver).prewarmBrowserPlaybackAudio("/api/static/meetings/401/source_audio.m4a");
} // }
//
@Test // @Test
void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() { // void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() {
AiTaskService aiTaskService = mock(AiTaskService.class); // AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class); // MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler); // MeetingDomainSupport support = newSupport(aiTaskService, assembler);
//
Meeting meeting = new Meeting(); // Meeting meeting = new Meeting();
meeting.setId(201L); // meeting.setId(201L);
meeting.setLatestSummaryTaskId(501L); // meeting.setLatestSummaryTaskId(501L);
//
AiTask latestSummaryTask = new AiTask(); // AiTask latestSummaryTask = new AiTask();
latestSummaryTask.setTaskType("SUMMARY"); // latestSummaryTask.setTaskType("SUMMARY");
latestSummaryTask.setMeetingId(201L); // latestSummaryTask.setMeetingId(201L);
latestSummaryTask.setTaskConfig(Map.of("userPrompt", " 已发布提示词 ")); // latestSummaryTask.setTaskConfig(Map.of("userPrompt", " 已发布提示词 "));
//
AiTask fallbackTask = new AiTask(); // AiTask fallbackTask = new AiTask();
fallbackTask.setTaskType("SUMMARY"); // fallbackTask.setTaskType("SUMMARY");
fallbackTask.setMeetingId(201L); // fallbackTask.setMeetingId(201L);
fallbackTask.setTaskConfig(Map.of("userPrompt", " 最新草稿提示词 ")); // fallbackTask.setTaskConfig(Map.of("userPrompt", " 最新草稿提示词 "));
//
when(aiTaskService.getById(501L)).thenReturn(latestSummaryTask); // when(aiTaskService.getById(501L)).thenReturn(latestSummaryTask);
when(assembler.normalizeOptionalText(" 已发布提示词 ")).thenReturn("已发布提示词"); // when(assembler.normalizeOptionalText(" 已发布提示词 ")).thenReturn("已发布提示词");
when(aiTaskService.getOne(any())).thenReturn(fallbackTask); // when(aiTaskService.getOne(any())).thenReturn(fallbackTask);
//
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting); // String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
//
assertEquals("已发布提示词", resolved); // assertEquals("已发布提示词", resolved);
Mockito.verify(aiTaskService, Mockito.never()).getOne(any()); // Mockito.verify(aiTaskService, Mockito.never()).getOne(any());
} // }
//
@Test // @Test
void shouldFallbackToLatestSummaryTaskWhenLatestSummaryTaskIdIsUnavailable() { // void shouldFallbackToLatestSummaryTaskWhenLatestSummaryTaskIdIsUnavailable() {
AiTaskService aiTaskService = mock(AiTaskService.class); // AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class); // MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler); // MeetingDomainSupport support = newSupport(aiTaskService, assembler);
//
Meeting meeting = new Meeting(); // Meeting meeting = new Meeting();
meeting.setId(202L); // meeting.setId(202L);
meeting.setLatestSummaryTaskId(502L); // meeting.setLatestSummaryTaskId(502L);
//
AiTask latestSuccessfulTask = new AiTask(); // AiTask latestSuccessfulTask = new AiTask();
latestSuccessfulTask.setTaskType("SUMMARY"); // latestSuccessfulTask.setTaskType("SUMMARY");
latestSuccessfulTask.setMeetingId(202L); // latestSuccessfulTask.setMeetingId(202L);
latestSuccessfulTask.setStatus(2); // latestSuccessfulTask.setStatus(2);
latestSuccessfulTask.setTaskConfig(Map.of("userPrompt", " 成功提示词 ")); // latestSuccessfulTask.setTaskConfig(Map.of("userPrompt", " 成功提示词 "));
//
when(aiTaskService.getById(502L)).thenReturn(null); // when(aiTaskService.getById(502L)).thenReturn(null);
when(aiTaskService.getOne(any())).thenReturn(latestSuccessfulTask); // when(aiTaskService.getOne(any())).thenReturn(latestSuccessfulTask);
when(assembler.normalizeOptionalText(" 成功提示词 ")).thenReturn("成功提示词"); // when(assembler.normalizeOptionalText(" 成功提示词 ")).thenReturn("成功提示词");
//
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting); // String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
//
assertEquals("成功提示词", resolved); // assertEquals("成功提示词", resolved);
} // }
//
@Test // @Test
void shouldFallbackToLatestSummaryTaskWhenNoSuccessfulTaskExists() { // void shouldFallbackToLatestSummaryTaskWhenNoSuccessfulTaskExists() {
AiTaskService aiTaskService = mock(AiTaskService.class); // AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class); // MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler); // MeetingDomainSupport support = newSupport(aiTaskService, assembler);
//
Meeting meeting = new Meeting(); // Meeting meeting = new Meeting();
meeting.setId(203L); // meeting.setId(203L);
//
AiTask latestTask = new AiTask(); // AiTask latestTask = new AiTask();
latestTask.setTaskType("SUMMARY"); // latestTask.setTaskType("SUMMARY");
latestTask.setMeetingId(203L); // latestTask.setMeetingId(203L);
latestTask.setTaskConfig(Map.of("userPrompt", " 最新任务提示词 ")); // latestTask.setTaskConfig(Map.of("userPrompt", " 最新任务提示词 "));
//
when(aiTaskService.getOne(any())).thenReturn(null).thenReturn(latestTask); // when(aiTaskService.getOne(any())).thenReturn(null).thenReturn(latestTask);
when(assembler.normalizeOptionalText(" 最新任务提示词 ")).thenReturn("最新任务提示词"); // when(assembler.normalizeOptionalText(" 最新任务提示词 ")).thenReturn("最新任务提示词");
//
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting); // String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
//
assertEquals("最新任务提示词", resolved); // assertEquals("最新任务提示词", resolved);
} // }
//
@Test // @Test
void shouldReturnNullWhenNoSummaryTaskExists() { // void shouldReturnNullWhenNoSummaryTaskExists() {
AiTaskService aiTaskService = mock(AiTaskService.class); // AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class); // MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler); // MeetingDomainSupport support = newSupport(aiTaskService, assembler);
//
Meeting meeting = new Meeting(); // Meeting meeting = new Meeting();
meeting.setId(204L); // meeting.setId(204L);
//
when(aiTaskService.getOne(any())).thenReturn(null, null); // when(aiTaskService.getOne(any())).thenReturn(null, null);
//
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting); // String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
//
assertNull(resolved); // assertNull(resolved);
} // }
//
private MeetingDomainSupport newSupport() { // private MeetingDomainSupport newSupport() {
return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class)); // return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class));
} // }
//
private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) { // private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) {
return newSupport(aiTaskService, assembler, mock(MeetingPlaybackAudioResolver.class)); // return newSupport(aiTaskService, assembler, mock(MeetingPlaybackAudioResolver.class));
} // }
//
private MeetingDomainSupport newSupport(AiTaskService aiTaskService, // private MeetingDomainSupport newSupport(AiTaskService aiTaskService,
MeetingSummaryPromptAssembler assembler, // MeetingSummaryPromptAssembler assembler,
MeetingPlaybackAudioResolver playbackAudioResolver) { // MeetingPlaybackAudioResolver playbackAudioResolver) {
MeetingDomainSupport support = new MeetingDomainSupport( // MeetingDomainSupport support = new MeetingDomainSupport(
assembler, // assembler,
aiTaskService, // aiTaskService,
mock(MeetingTranscriptMapper.class), // mock(MeetingTranscriptMapper.class),
mock(SysUserMapper.class), // mock(SysUserMapper.class),
mock(ApplicationEventPublisher.class), // mock(ApplicationEventPublisher.class),
mock(MeetingSummaryFileService.class), // mock(MeetingSummaryFileService.class),
playbackAudioResolver // playbackAudioResolver
); // );
ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString()); // ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString());
return support; // return support;
} // }
//
private void triggerAfterCompletion(int status) { // private void triggerAfterCompletion(int status) {
for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { // for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
if (status == TransactionSynchronization.STATUS_COMMITTED) { // if (status == TransactionSynchronization.STATUS_COMMITTED) {
synchronization.afterCommit(); // synchronization.afterCommit();
} // }
synchronization.afterCompletion(status); // synchronization.afterCompletion(status);
} // }
TransactionSynchronizationManager.clearSynchronization(); // TransactionSynchronizationManager.clearSynchronization();
} // }
//
private Path writeFile(Path path, String content) throws IOException { // private Path writeFile(Path path, String content) throws IOException {
Files.createDirectories(path.getParent()); // Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8); // Files.writeString(path, content, StandardCharsets.UTF_8);
return path; // return path;
} // }
//
private boolean hasBackupFile(Path directory) throws IOException { // private boolean hasBackupFile(Path directory) throws IOException {
if (!Files.exists(directory)) { // if (!Files.exists(directory)) {
return false; // return false;
} // }
try (var stream = Files.list(directory)) { // try (var stream = Files.list(directory)) {
return stream // return stream
.map(Path::getFileName) // .map(Path::getFileName)
.map(Path::toString) // .map(Path::toString)
.anyMatch(name -> name.contains(".rollback-") && name.endsWith(".bak")); // .anyMatch(name -> name.contains(".rollback-") && name.endsWith(".bak"));
} // }
} // }
} //}

View File

@ -1,157 +1,157 @@
package com.imeeting.service.biz.impl; //package com.imeeting.service.biz.impl;
//
import com.fasterxml.jackson.databind.ObjectMapper; //import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys; //import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.RealtimeMeetingSessionState; //import com.imeeting.dto.biz.RealtimeMeetingSessionState;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; //import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.entity.biz.Meeting; //import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper; //import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; //import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import org.junit.jupiter.api.Test; //import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate; //import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations; //import org.springframework.data.redis.core.ValueOperations;
//
import static org.junit.jupiter.api.Assertions.assertEquals; //import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; //import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; //import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; //import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; //import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; //import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; //import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; //import static org.mockito.Mockito.when;
//
class RealtimeMeetingSessionStateServiceImplTest { //class RealtimeMeetingSessionStateServiceImplTest {
//
private final ObjectMapper objectMapper = new ObjectMapper(); // private final ObjectMapper objectMapper = new ObjectMapper();
//
@Test // @Test
void getStatusShouldUseCompletedMeetingWhenRedisActiveIsStale() throws Exception { // void getStatusShouldUseCompletedMeetingWhenRedisActiveIsStale() throws Exception {
Long meetingId = 68L; // Long meetingId = 68L;
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); // StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class); // ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); // MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingMapper meetingMapper = mock(MeetingMapper.class); // MeetingMapper meetingMapper = mock(MeetingMapper.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations); // when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) // when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); // .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 3)); // when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 3));
when(transcriptMapper.selectCount(any())).thenReturn(1L); // when(transcriptMapper.selectCount(any())).thenReturn(1L);
//
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); // RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
//
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); // RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
//
assertEquals("COMPLETED", status.getStatus()); // assertEquals("COMPLETED", status.getStatus());
assertFalse(Boolean.TRUE.equals(status.getActiveConnection())); // assertFalse(Boolean.TRUE.equals(status.getActiveConnection()));
assertFalse(Boolean.TRUE.equals(status.getCanResume())); // assertFalse(Boolean.TRUE.equals(status.getCanResume()));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
} // }
//
@Test // @Test
void getStatusShouldUseTerminalMeetingWhenDatabaseFailed() throws Exception { // void getStatusShouldUseTerminalMeetingWhenDatabaseFailed() throws Exception {
Long meetingId = 69L; // Long meetingId = 69L;
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); // StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class); // ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); // MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingMapper meetingMapper = mock(MeetingMapper.class); // MeetingMapper meetingMapper = mock(MeetingMapper.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations); // when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) // when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); // .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 4)); // when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 4));
when(transcriptMapper.selectCount(any())).thenReturn(0L); // when(transcriptMapper.selectCount(any())).thenReturn(0L);
//
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); // RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
//
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); // RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
//
assertEquals("COMPLETED", status.getStatus()); // assertEquals("COMPLETED", status.getStatus());
assertFalse(Boolean.TRUE.equals(status.getActiveConnection())); // assertFalse(Boolean.TRUE.equals(status.getActiveConnection()));
} // }
//
@Test // @Test
void getStatusShouldNotClearWhenDatabaseIsCompleting() throws Exception { // void getStatusShouldNotClearWhenDatabaseIsCompleting() throws Exception {
Long meetingId = 70L; // Long meetingId = 70L;
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); // StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class); // ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); // MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingMapper meetingMapper = mock(MeetingMapper.class); // MeetingMapper meetingMapper = mock(MeetingMapper.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations); // when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) // when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); // .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 2)); // when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 2));
//
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); // RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
//
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); // RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
//
assertEquals("ACTIVE", status.getStatus()); // assertEquals("ACTIVE", status.getStatus());
assertTrue(Boolean.TRUE.equals(status.getActiveConnection())); // assertTrue(Boolean.TRUE.equals(status.getActiveConnection()));
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); // verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); // verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
} // }
//
@Test // @Test
void getStatusShouldPreserveActiveWhenDatabaseNotTerminal() throws Exception { // void getStatusShouldPreserveActiveWhenDatabaseNotTerminal() throws Exception {
Long meetingId = 71L; // Long meetingId = 71L;
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); // StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class); // ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); // MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingMapper meetingMapper = mock(MeetingMapper.class); // MeetingMapper meetingMapper = mock(MeetingMapper.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations); // when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) // when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)))
.thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); // .thenReturn(objectMapper.writeValueAsString(activeState(meetingId)));
when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 1)); // when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 1));
//
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); // RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
//
RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); // RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId);
//
assertEquals("ACTIVE", status.getStatus()); // assertEquals("ACTIVE", status.getStatus());
assertTrue(Boolean.TRUE.equals(status.getActiveConnection())); // assertTrue(Boolean.TRUE.equals(status.getActiveConnection()));
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); // verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); // verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
} // }
//
@Test // @Test
void clearShouldDeleteRealtimeEventSeqKey() { // void clearShouldDeleteRealtimeEventSeqKey() {
Long meetingId = 72L; // Long meetingId = 72L;
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); // StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); // MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingMapper meetingMapper = mock(MeetingMapper.class); // MeetingMapper meetingMapper = mock(MeetingMapper.class);
//
RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); // RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper);
//
service.clear(meetingId); // service.clear(meetingId);
//
verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId));
verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); // verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId));
} // }
//
private RealtimeMeetingSessionStateServiceImpl newService(StringRedisTemplate redisTemplate, // private RealtimeMeetingSessionStateServiceImpl newService(StringRedisTemplate redisTemplate,
MeetingTranscriptMapper transcriptMapper, // MeetingTranscriptMapper transcriptMapper,
MeetingMapper meetingMapper) { // MeetingMapper meetingMapper) {
return new RealtimeMeetingSessionStateServiceImpl(redisTemplate, objectMapper, transcriptMapper, meetingMapper); // return new RealtimeMeetingSessionStateServiceImpl(redisTemplate, objectMapper, transcriptMapper, meetingMapper);
} // }
//
private RealtimeMeetingSessionState activeState(Long meetingId) { // private RealtimeMeetingSessionState activeState(Long meetingId) {
RealtimeMeetingSessionState state = new RealtimeMeetingSessionState(); // RealtimeMeetingSessionState state = new RealtimeMeetingSessionState();
state.setMeetingId(meetingId); // state.setMeetingId(meetingId);
state.setStatus("ACTIVE"); // state.setStatus("ACTIVE");
state.setHasTranscript(true); // state.setHasTranscript(true);
state.setActiveConnectionId("conn-1"); // state.setActiveConnectionId("conn-1");
state.setUpdatedAt(System.currentTimeMillis()); // state.setUpdatedAt(System.currentTimeMillis());
return state; // return state;
} // }
//
private Meeting meeting(Long meetingId, int status) { // private Meeting meeting(Long meetingId, int status) {
Meeting meeting = new Meeting(); // Meeting meeting = new Meeting();
meeting.setId(meetingId); // meeting.setId(meetingId);
meeting.setStatus(status); // meeting.setStatus(status);
return meeting; // return meeting;
} // }
} //}

View File

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

View File

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

View File

@ -20,6 +20,7 @@ const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permissio
const ClientManagement = lazy(() => import("@/pages/business/ClientManagement")); const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"));
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement")); const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement")); const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement"));
import SpeakerReg from "../pages/business/SpeakerReg"; import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => { const RealtimeAsrSession = lazy(async () => {
@ -66,6 +67,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" }, { path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" },
{ path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" }, { path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" },
{ path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" }, { path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" },
{ path: "/meeting-points", label: "积分管理", element: <LazyPage><MeetingPointsManagement /></LazyPage>, perm: "menu:meeting-points" },
{ path: "/meetings", label: "会议中心", element: <LazyPage><Meetings /></LazyPage>, perm: "menu:meeting" }, { path: "/meetings", label: "会议中心", element: <LazyPage><Meetings /></LazyPage>, perm: "menu:meeting" },
]; ];

View File

@ -14,11 +14,11 @@ export default defineConfig({
server: { server: {
port: 5174, port: 5174,
proxy: { proxy: {
"/auth": "http://10.100.51.199:8080", "/auth": "http://localhost:8080",
"/sys": "http://10.100.51.199:8080", "/sys": "http://localhost:8080",
"/api": "http://10.100.51.199:8080", "/api": "http://localhost:8080",
"/ws": { "/ws": {
target: "ws://10.100.51.199:8080", target: "ws://localhost:8080",
ws: true ws: true
} }
} }