refactor: 移除会议用户统计相关代码并更新设备认证逻辑

- 移除 `MeetingUserStats` 相关实体、服务和映射器
- 更新 `AndroidPushGrpcService` 和 `AndroidAuthServiceImpl`,添加 `userId` 和 `tenantId` 参数
- 优化 `AndroidDeviceHomeServiceImpl` 中的积分计算逻辑
- 在 `MeetingPointsController` 中新增 `transfer` 方法,支持从公共账户分配积分给个人账户
- 更新 `MeetingPointsLedgerListItemVO` 中的 `pointsType` 描述
- 优化 `AndroidDeviceBindingServiceImpl` 中的设备登录态验证逻辑
dev_na
chenhao 2026-06-10 15:25:58 +08:00
parent 1813b90c6a
commit 11c3ee1b09
27 changed files with 1000 additions and 650 deletions

View File

@ -385,19 +385,7 @@
- 类型:`INTEGER` - 类型:`INTEGER`
- 说明:会议最终有效录音时长(秒),作为会议统计与积分计费统一口径。 - 说明:会议最终有效录音时长(秒),作为会议统计与积分计费统一口径。
### 6.2 `biz_meeting_user_stats` ### 6.2 `biz_meeting_points_accounts`
- 用途:按用户维护会议时长统计与总结触发统计账户。
- 关键字段:
- `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` - `tenant_id`
@ -411,7 +399,7 @@
- 关键索引: - 关键索引:
- `uk_biz_meeting_points_accounts_tenant_user` - `uk_biz_meeting_points_accounts_tenant_user`
### 6.4 `biz_meeting_summary_charge_records` ### 6.3 `biz_meeting_summary_charge_records`
- 用途:每次 SUMMARY 任务保留一条计费快照记录,并按 ASR / LLM 成功节点累计实际扣费。 - 用途:每次 SUMMARY 任务保留一条计费快照记录,并按 ASR / LLM 成功节点累计实际扣费。
- 关键字段: - 关键字段:
- `meeting_id` - `meeting_id`

View File

@ -512,26 +512,6 @@ ALTER TABLE biz_meetings
COMMENT ON COLUMN biz_meetings.effective_audio_duration_seconds IS '会议最终有效录音时长(秒),用于统计与计费口径'; 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; DROP TABLE IF EXISTS biz_meeting_points_accounts CASCADE;
CREATE TABLE biz_meeting_points_accounts ( CREATE TABLE biz_meeting_points_accounts (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,

View File

@ -18,7 +18,7 @@ public final class SysParamKeys {
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled"; public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
/** ASR 任务最大并发数。 */ /** ASR 任务最大并发数。 */
public static final String MEETING_ASR_MAX_CONCURRENT = "meeting.asr.max_concurrent"; public static final String MEETING_ASR_MAX_CONCURRENT = "meeting.asr.max_concurrent";
/** 会议暂停最时长,单位秒。 */ /** 会议暂停最时长,单位秒。 */
public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration"; public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration";
/** 单场会议最大时长,单位分钟。 */ /** 单场会议最大时长,单位分钟。 */
public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration"; public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration";
@ -38,10 +38,14 @@ public final class SysParamKeys {
public static final String MEETING_POINTS_ASR_RATIO = "meeting.points.asr_ratio"; public static final String MEETING_POINTS_ASR_RATIO = "meeting.points.asr_ratio";
/** 积分拆分时分配给 LLM 的比例。 */ /** 积分拆分时分配给 LLM 的比例。 */
public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio"; public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio";
/** 新建积分账户时的初始积分余额。 */ /** 租户初始化公共积分账户时的初始积分余额。 */
public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance"; public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance";
/** 会议积分账户模式,如公共账户或个人账户。 */ /** 会议积分账户模式PUBLIC / PERSONAL / BOTH。 */
public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode"; public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode";
/** 会议积分扣费优先级PERSONAL_FIRST / PUBLIC_FIRST。 */
public static final String MEETING_POINTS_CHARGE_PRIORITY = "meeting.points.charge_priority";
/** 会议积分是否启用余额校验。 */
public static final String MEETING_POINTS_ENFORCE_BALANCE = "meeting.points.enforce_balance";
/** 临时授权默认下发数量。 */ /** 临时授权默认下发数量。 */
public static final String LICENSE_TEMP_DEFAULT_COUNT = "license.temp.default.count"; public static final String LICENSE_TEMP_DEFAULT_COUNT = "license.temp.default.count";
/** 临时授权默认有效期,单位月。 */ /** 临时授权默认有效期,单位月。 */

View File

@ -61,13 +61,6 @@ public class AndroidAuthController {
androidDeviceRegistrationService.requireRegistered(deviceId.trim()); androidDeviceRegistrationService.requireRegistered(deviceId.trim());
TokenResponse response = authService.login(request, true); TokenResponse response = authService.login(request, true);
if (response != null && response.getUser() != null && response.getCurrentTenantId() != null && StringUtils.hasText(deviceId)) { if (response != null && response.getUser() != null && response.getCurrentTenantId() != null && StringUtils.hasText(deviceId)) {
androidDeviceBindingService.bindPrivateDevice(
deviceId.trim(),
response.getCurrentTenantId(),
response.getUser().getUserId(),
appVersion,
platform
);
androidDeviceBindingService.recordLogin( androidDeviceBindingService.recordLogin(
deviceId.trim(), deviceId.trim(),
response.getCurrentTenantId(), response.getCurrentTenantId(),

View File

@ -378,17 +378,21 @@ public class AndroidMeetingController {
public ApiResponse<AndroidMeetingConfigVo> config(HttpServletRequest request) { public ApiResponse<AndroidMeetingConfigVo> config(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "获取会议配置接口"); AndroidRequestLogHelper.logRequest(log, "Android会议", "获取会议配置接口");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = AndroidLoginUserSupport.toLoginUser(authContext);
Long tenantId = loginUser != null ? loginUser.getTenantId() : authContext.getTenantId();
Long userId = loginUser != null ? loginUser.getUserId() : null;
boolean isPlatformAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
boolean isTenantAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
AndroidMeetingConfigVo resultVo = new AndroidMeetingConfigVo(); AndroidMeetingConfigVo resultVo = new AndroidMeetingConfigVo();
PageResult<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates( PageResult<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates(
1, 1,
1000, 1000,
null, null,
null, null,
loginUser.getTenantId(), tenantId,
loginUser.getUserId(), userId,
loginUser.getIsPlatformAdmin(), isPlatformAdmin,
loginUser.getIsTenantAdmin() isTenantAdmin
); );
List<PromptTemplateVO> enabledTemplates = promptTemplateList.getRecords() == null List<PromptTemplateVO> enabledTemplates = promptTemplateList.getRecords() == null
? List.of() ? List.of()
@ -396,7 +400,7 @@ public class AndroidMeetingController {
.filter(item -> Integer.valueOf(1).equals(item.getStatus())) .filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList(); .toList();
resultVo.setTemplateList(enabledTemplates); resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId()); PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId);
List<AiModelVO> enabledModels = modelList.getRecords() == null List<AiModelVO> enabledModels = modelList.getRecords() == null
? List.of() ? List.of()
: modelList.getRecords().stream() : modelList.getRecords().stream()

View File

@ -1,6 +1,7 @@
package com.imeeting.controller.biz; package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingPointsBalanceVO; import com.imeeting.dto.biz.MeetingPointsBalanceVO;
import com.imeeting.dto.biz.MeetingPointsTransferRequest;
import com.imeeting.service.biz.MeetingPointsService; import com.imeeting.service.biz.MeetingPointsService;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
@ -9,6 +10,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -32,15 +35,37 @@ public class MeetingPointsController {
return ApiResponse.ok(meetingPointsService.getBalanceView(loginUser.getTenantId(), targetUserId)); return ApiResponse.ok(meetingPointsService.getBalanceView(loginUser.getTenantId(), targetUserId));
} }
@Operation(summary = "从公共账户分配积分给个人账户")
@PostMapping("/transfer")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> transferPublicPoints(@RequestBody MeetingPointsTransferRequest request) {
LoginUser loginUser = currentLoginUser();
ensureAdmin(loginUser);
if (request == null) {
throw new RuntimeException("分配请求不能为空");
}
meetingPointsService.transferPublicPointsToUser(
loginUser.getTenantId(),
request.getTargetUserId(),
request.getPoints(),
request.getRemark()
);
return ApiResponse.ok(Boolean.TRUE);
}
private Long resolveTargetUserId(LoginUser loginUser, Long requestedUserId) { private Long resolveTargetUserId(LoginUser loginUser, Long requestedUserId) {
if (requestedUserId == null || requestedUserId.equals(loginUser.getUserId())) { if (requestedUserId == null || requestedUserId.equals(loginUser.getUserId())) {
return loginUser.getUserId(); return loginUser.getUserId();
} }
ensureAdmin(loginUser);
return requestedUserId;
}
private void ensureAdmin(LoginUser loginUser) {
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
if (!isAdmin) { if (!isAdmin) {
throw new RuntimeException("无权查看其他用户积分余额"); throw new RuntimeException("无权限执行该操作");
} }
return requestedUserId;
} }
private LoginUser currentLoginUser() { private LoginUser currentLoginUser() {

View File

@ -12,8 +12,11 @@ public class MeetingPointsBalanceVO {
@Schema(description = "目标用户ID") @Schema(description = "目标用户ID")
private Long userId; private Long userId;
@Schema(description = "当前优先扣费账户类型PUBLIC / PERSONAL") @Schema(description = "当前扣费模式PUBLIC / PERSONAL / BOTH")
private String preferredAccountMode; private String accountMode;
@Schema(description = "当前扣费优先级PERSONAL_FIRST / PUBLIC_FIRST")
private String chargePriority;
@Schema(description = "公共账户余额") @Schema(description = "公共账户余额")
private Long publicBalance; private Long publicBalance;
@ -26,4 +29,7 @@ public class MeetingPointsBalanceVO {
@Schema(description = "个人账户累计消耗积分") @Schema(description = "个人账户累计消耗积分")
private Long personalTotalPointsUsed; private Long personalTotalPointsUsed;
@Schema(description = "当前模式下可用总积分")
private Long totalAvailableBalance;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Data @Data
@Schema(description = "积分流水详情") @Schema(description = "积分流水详情")
@ -26,13 +27,13 @@ public class MeetingPointsLedgerDetailVO {
@Schema(description = "消耗归属用户名") @Schema(description = "消耗归属用户名")
private String ownerUserName; private String ownerUserName;
@Schema(description = "账户类型PUBLIC / PERSONAL") @Schema(description = "当前流水账户类型PUBLIC / PERSONAL")
private String chargeAccountType; private String chargeAccountType;
@Schema(description = "账户用户ID") @Schema(description = "当前流水账户用户ID")
private Long chargeAccountUserId; private Long chargeAccountUserId;
@Schema(description = "消耗类型") @Schema(description = "积分类型")
private String pointsType; private String pointsType;
@Schema(description = "消耗积分,展示为正数") @Schema(description = "消耗积分,展示为正数")
@ -100,4 +101,7 @@ public class MeetingPointsLedgerDetailVO {
@Schema(description = "记录创建时间") @Schema(description = "记录创建时间")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Schema(description = "本次总结的扣费分摊明细")
private List<MeetingPointsChargeItemVO> chargeItems;
} }

View File

@ -32,7 +32,7 @@ public class MeetingPointsLedgerListItemVO {
@Schema(description = "账户类型PUBLIC / PERSONAL") @Schema(description = "账户类型PUBLIC / PERSONAL")
private String chargeAccountType; private String chargeAccountType;
@Schema(description = "消耗类型ASR / LLM / INIT / RECHARGE") @Schema(description = "消耗类型ASR / LLM")
private String pointsType; private String pointsType;
@Schema(description = "消耗积分,展示为正数") @Schema(description = "消耗积分,展示为正数")

View File

@ -6,15 +6,27 @@ import lombok.Data;
@Data @Data
@Schema(description = "积分管理总览视图") @Schema(description = "积分管理总览视图")
public class MeetingPointsOverviewVO { public class MeetingPointsOverviewVO {
@Schema(description = "当前结算模式PUBLIC / PERSONAL") @Schema(description = "当前扣费模式PUBLIC / PERSONAL / BOTH")
private String accountMode; private String accountMode;
@Schema(description = "当前扣费优先级PERSONAL_FIRST / PUBLIC_FIRST")
private String chargePriority;
@Schema(description = "公共账户余额") @Schema(description = "公共账户余额")
private Long publicBalance; private Long publicBalance;
@Schema(description = "公共账户累计消耗积分") @Schema(description = "公共账户累计消耗积分")
private Long publicTotalPointsUsed; private Long publicTotalPointsUsed;
@Schema(description = "个人账户余额汇总")
private Long personalBalance;
@Schema(description = "个人账户累计消耗积分汇总")
private Long personalTotalPointsUsed;
@Schema(description = "当前模式下可用总积分")
private Long totalAvailableBalance;
@Schema(description = "累计消耗次数") @Schema(description = "累计消耗次数")
private Long totalChargeCount; private Long totalChargeCount;
} }

View File

@ -1,34 +0,0 @@
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

@ -114,14 +114,16 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
AndroidAuthContext authContext = androidAuthService.authenticateGrpc( AndroidAuthContext authContext = androidAuthService.authenticateGrpc(
request.getDeviceId(), request.getDeviceId(),
request.getAppVersion(), request.getAppVersion(),
resolvePlatform(request.getPlatform()) resolvePlatform(request.getPlatform()),
request.getUserId(),
request.getTenantId()
); );
deviceOnlineManagementService.recordConnected(authContext);
AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId()); AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId());
connectionId = sessionState.getConnectionId(); connectionId = sessionState.getConnectionId();
deviceId = sessionState.getDeviceId(); deviceId = sessionState.getDeviceId();
appVersion = authContext.getAppVersion(); appVersion = authContext.getAppVersion();
platform = authContext.getPlatform(); platform = authContext.getPlatform();
deviceOnlineManagementService.recordConnected(authContext);
connected = true; connected = true;
String replacedConnectionId = androidGatewayPushService.register( String replacedConnectionId = androidGatewayPushService.register(
connectionId, connectionId,

View File

@ -1,9 +0,0 @@
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

@ -4,7 +4,7 @@ import com.imeeting.dto.android.AndroidAuthContext;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
public interface AndroidAuthService { public interface AndroidAuthService {
AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform); AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform, String userId, String tenantId);
AndroidAuthContext authenticateHttp(HttpServletRequest request); AndroidAuthContext authenticateHttp(HttpServletRequest request);

View File

@ -25,259 +25,282 @@ import org.springframework.util.StringUtils;
@Slf4j @Slf4j
public class AndroidAuthServiceImpl implements AndroidAuthService { public class AndroidAuthServiceImpl implements AndroidAuthService {
private static final String HEADER_DEVICE_ID = "X-Android-Device-Id"; private static final String HEADER_DEVICE_ID = "X-Android-Device-Id";
private static final String HEADER_APP_ID = "X-Android-App-Id"; private static final String HEADER_APP_ID = "X-Android-App-Id";
private static final String HEADER_APP_VERSION = "X-Android-App-Version"; private static final String HEADER_APP_VERSION = "X-Android-App-Version";
private static final String HEADER_PLATFORM = "X-Android-Platform"; private static final String HEADER_PLATFORM = "X-Android-Platform";
private static final String HEADER_AUTHORIZATION = "Authorization"; private static final String HEADER_AUTHORIZATION = "Authorization";
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
private final AndroidGrpcAuthProperties properties; private final AndroidGrpcAuthProperties properties;
private final TokenValidationService tokenValidationService; private final TokenValidationService tokenValidationService;
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceBindingService androidDeviceBindingService; private final AndroidDeviceBindingService androidDeviceBindingService;
private final LicenseService licenseService; private final LicenseService licenseService;
@Override @Override
public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform, String userId, String tenantId) {
if (properties.isEnabled() && !properties.isAllowAnonymous()) { if (properties.isEnabled() && !properties.isAllowAnonymous()) {
throw new RuntimeException("Android gRPC push does not allow anonymous access"); throw new RuntimeException("Android gRPC push does not allow anonymous access");
} }
LicenseEntity license = licenseService.requireValidBoundLicense(deviceId); LicenseEntity license = licenseService.requireValidBoundLicense(deviceId);
DeviceInfoEntity device = requireRegisteredDevice(deviceId); DeviceInfoEntity device = requireRegisteredDevice(deviceId);
assertDeviceEnabled(device); assertDeviceEnabled(device);
AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); Long requestedUserId = parseOptionalLong(userId, "Android gRPC userId");
context.setUserId(device.getUserId()); Long requestedTenantId = parseOptionalLong(tenantId, "Android gRPC tenantId");
context.setTenantId(license.getTenantId()); boolean anonymous = requestedUserId == null;
return context; AndroidAuthContext context = buildContext(anonymous ? "NONE" : "GRPC_USER", anonymous, deviceId, null, appVersion, platform, null, null, null, null);
Long resolvedTenantId = requestedTenantId != null ? requestedTenantId : license.getTenantId();
if (requestedUserId != null) {
if (requestedTenantId != null && !requestedTenantId.equals(license.getTenantId())) {
throw new RuntimeException("登录租户与授权租户不一致,无法绑定grpc");
}
context.setUserId(requestedUserId);
context.setTenantId(resolvedTenantId);
return context;
}
context.setUserId(null);
context.setTenantId(resolvedTenantId);
return context;
}
@Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
return authenticateHttp(request, true, false);
}
@Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) {
return authenticateHttp(request, requireRegistered, false);
}
@Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken) {
LoginUser loginUser = currentLoginUser();
String resolvedToken = resolveHttpToken(request);
String deviceId = firstHeader(request, HEADER_DEVICE_ID);
String appId = request.getHeader(HEADER_APP_ID);
String appVersion = firstHeader(request, HEADER_APP_VERSION);
String platform = request.getHeader(HEADER_PLATFORM);
requireAndroidHttpHeaders(deviceId, appVersion, platform);
log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}", deviceId, appVersion, platform);
DeviceInfoEntity device = requireRegistered ? requireRegisteredDevice(deviceId) : findDevice(deviceId);
assertDeviceEnabled(device);
LicenseEntity license = requireRegistered ? licenseService.requireValidBoundLicense(deviceId) : null;
if (loginUser != null) {
if (!allowOptionalToken) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId,
appId,
appVersion,
platform,
resolvedToken,
null,
null,
loginUser);
return applyLicenseContext(context, license, allowOptionalToken);
} }
@Override if (StringUtils.hasText(resolvedToken)) {
public AndroidAuthContext authenticateHttp(HttpServletRequest request) { InternalAuthCheckResponse authResult = validateToken(resolvedToken);
return authenticateHttp(request, true, false); if (requireRegistered && !allowOptionalToken) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId,
appId,
appVersion,
platform,
resolvedToken,
null,
authResult,
null);
return applyLicenseContext(context, license, allowOptionalToken);
} }
@Override if (properties.isAllowAnonymous()) {
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) { AndroidAuthContext context = buildContext("NONE", true,
return authenticateHttp(request, requireRegistered, false); deviceId,
appId,
appVersion,
platform,
null,
null,
null,
null);
return applyLicenseContext(context, license, allowOptionalToken);
} }
throw new RuntimeException("Missing Android HTTP access token");
}
@Override private AndroidAuthContext applyLicenseContext(AndroidAuthContext context, LicenseEntity license, boolean allowOptionalToken) {
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken) { if (context == null) {
LoginUser loginUser = currentLoginUser(); return null;
String resolvedToken = resolveHttpToken(request);
String deviceId = firstHeader(request, HEADER_DEVICE_ID);
String appId = request.getHeader(HEADER_APP_ID);
String appVersion = firstHeader(request, HEADER_APP_VERSION);
String platform = request.getHeader(HEADER_PLATFORM);
requireAndroidHttpHeaders(deviceId, appVersion, platform);
log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}", deviceId, appVersion, platform);
DeviceInfoEntity device = requireRegistered ? requireRegisteredDevice(deviceId) : findDevice(deviceId);
assertDeviceEnabled(device);
LicenseEntity license = requireRegistered ? licenseService.requireValidBoundLicense(deviceId) : null;
if (loginUser != null) {
if (!allowOptionalToken) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId,
appId,
appVersion,
platform,
resolvedToken,
null,
null,
loginUser);
return applyLicenseContext(context, license, allowOptionalToken);
}
if (StringUtils.hasText(resolvedToken)) {
InternalAuthCheckResponse authResult = validateToken(resolvedToken);
if (requireRegistered && !allowOptionalToken) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId,
appId,
appVersion,
platform,
resolvedToken,
null,
authResult,
null);
return applyLicenseContext(context, license, allowOptionalToken);
}
if (properties.isAllowAnonymous()) {
AndroidAuthContext context = buildContext("NONE", true,
deviceId,
appId,
appVersion,
platform,
null,
null,
null,
null);
return applyLicenseContext(context, license, allowOptionalToken);
}
throw new RuntimeException("Missing Android HTTP access token");
} }
if (license == null) {
private AndroidAuthContext applyLicenseContext(AndroidAuthContext context, LicenseEntity license, boolean allowOptionalToken) { return context;
if (context == null) {
return null;
}
if (license == null) {
return context;
}
Long currentTenantId = context.getTenantId();
context.setTenantId(license.getTenantId());
if (allowOptionalToken && context.getUserId() != null && currentTenantId != null && !currentTenantId.equals(license.getTenantId())) {
context.setAnonymous(true);
context.setAuthMode("NONE");
context.setUserId(null);
context.setUsername(null);
context.setDisplayName(null);
context.setPlatformAdmin(null);
context.setTenantAdmin(null);
context.setPermissions(null);
}
return context;
} }
Long currentTenantId = context.getTenantId();
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, context.setTenantId(license.getTenantId());
String appId, String appVersion, String platform, String accessToken, if (allowOptionalToken && context.getUserId() != null && currentTenantId != null && !currentTenantId.equals(license.getTenantId())) {
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { context.setAnonymous(true);
String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId; context.setAuthMode("NONE");
if (!StringUtils.hasText(resolvedDeviceId)) { context.setUserId(null);
throw new RuntimeException("Missing Android deviceId"); context.setUsername(null);
} context.setDisplayName(null);
AndroidAuthContext context = new AndroidAuthContext(); context.setPlatformAdmin(null);
context.setAuthMode(authMode); context.setTenantAdmin(null);
context.setAnonymous(anonymous); context.setPermissions(null);
context.setDeviceId(resolvedDeviceId.trim());
context.setAppId(StringUtils.hasText(appId) ? appId.trim() : null);
context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null);
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
context.setAccessToken(StringUtils.hasText(accessToken) ? accessToken.trim() : null);
applyIdentity(context, authResult, loginUser);
return context;
} }
return context;
}
private void applyIdentity(AndroidAuthContext context, InternalAuthCheckResponse authResult, LoginUser loginUser) { private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId,
if (loginUser != null) { String appId, String appVersion, String platform, String accessToken,
context.setUserId(loginUser.getUserId()); String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {
context.setTenantId(loginUser.getTenantId()); String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId;
context.setUsername(loginUser.getUsername()); if (!StringUtils.hasText(resolvedDeviceId)) {
context.setDisplayName(loginUser.getDisplayName()); throw new RuntimeException("Missing Android deviceId");
context.setPlatformAdmin(Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()));
context.setTenantAdmin(Boolean.TRUE.equals(loginUser.getIsTenantAdmin()));
context.setPermissions(loginUser.getPermissions());
return;
}
if (authResult == null) {
return;
}
context.setUserId(authResult.getUserId());
context.setTenantId(authResult.getTenantId());
context.setUsername(authResult.getUsername());
context.setDisplayName(authResult.getUsername());
context.setPlatformAdmin(Boolean.TRUE.equals(authResult.getPlatformAdmin()));
context.setTenantAdmin(Boolean.TRUE.equals(authResult.getTenantAdmin()));
context.setPermissions(authResult.getPermissions());
} }
AndroidAuthContext context = new AndroidAuthContext();
context.setAuthMode(authMode);
context.setAnonymous(anonymous);
context.setDeviceId(resolvedDeviceId.trim());
context.setAppId(StringUtils.hasText(appId) ? appId.trim() : null);
context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null);
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
context.setAccessToken(StringUtils.hasText(accessToken) ? accessToken.trim() : null);
applyIdentity(context, authResult, loginUser);
return context;
}
private InternalAuthCheckResponse validateToken(String token) { private void applyIdentity(AndroidAuthContext context, InternalAuthCheckResponse authResult, LoginUser loginUser) {
String resolvedToken = normalizeToken(token); if (loginUser != null) {
if (!StringUtils.hasText(resolvedToken)) { context.setUserId(loginUser.getUserId());
throw new RuntimeException("Missing Android access token"); context.setTenantId(loginUser.getTenantId());
} context.setUsername(loginUser.getUsername());
InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(resolvedToken); context.setDisplayName(loginUser.getDisplayName());
if (authResult == null || !authResult.isValid()) { context.setPlatformAdmin(Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()));
throw new RuntimeException(authResult == null || !StringUtils.hasText(authResult.getMessage()) ? "Android access token is invalid" : authResult.getMessage()); context.setTenantAdmin(Boolean.TRUE.equals(loginUser.getIsTenantAdmin()));
} context.setPermissions(loginUser.getPermissions());
if (authResult.getUserId() == null || authResult.getTenantId() == null) { return;
throw new RuntimeException("Android access token is missing user or tenant context");
}
return authResult;
} }
if (authResult == null) {
return;
}
context.setUserId(authResult.getUserId());
context.setTenantId(authResult.getTenantId());
context.setUsername(authResult.getUsername());
context.setDisplayName(authResult.getUsername());
context.setPlatformAdmin(Boolean.TRUE.equals(authResult.getPlatformAdmin()));
context.setTenantAdmin(Boolean.TRUE.equals(authResult.getTenantAdmin()));
context.setPermissions(authResult.getPermissions());
}
private String resolveHttpToken(HttpServletRequest request) { private InternalAuthCheckResponse validateToken(String token) {
String authorization = request.getHeader(HEADER_AUTHORIZATION); String resolvedToken = normalizeToken(token);
if (!StringUtils.hasText(authorization)) { if (!StringUtils.hasText(resolvedToken)) {
return null; throw new RuntimeException("Missing Android access token");
}
if (!authorization.startsWith(BEARER_PREFIX)) {
throw new RuntimeException("Android HTTP access token format is invalid");
}
return authorization.substring(BEARER_PREFIX.length()).trim();
} }
InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(resolvedToken);
if (authResult == null || !authResult.isValid()) {
throw new RuntimeException(authResult == null || !StringUtils.hasText(authResult.getMessage()) ? "Android access token is invalid" : authResult.getMessage());
}
if (authResult.getUserId() == null || authResult.getTenantId() == null) {
throw new RuntimeException("Android access token is missing user or tenant context");
}
return authResult;
}
private String firstHeader(HttpServletRequest request, String... names) { private Long parseOptionalLong(String value, String fieldName) {
for (String name : names) { if (!StringUtils.hasText(value)) {
String value = request.getHeader(name); return null;
if (StringUtils.hasText(value)) {
return value.trim();
}
}
return null;
} }
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ex) {
throw new RuntimeException(fieldName + " format is invalid");
}
}
private void requireAndroidHttpHeaders(String deviceId, String appVersion, String platform) { private String resolveHttpToken(HttpServletRequest request) {
if (!StringUtils.hasText(deviceId)) { String authorization = request.getHeader(HEADER_AUTHORIZATION);
throw new RuntimeException("Missing Android device_id"); if (!StringUtils.hasText(authorization)) {
} return null;
if (!StringUtils.hasText(appVersion)) {
throw new RuntimeException("Missing X-Android-App-Version header");
}
if (!StringUtils.hasText(platform)) {
throw new RuntimeException("Missing X-Android-Platform header");
}
} }
if (!authorization.startsWith(BEARER_PREFIX)) {
throw new RuntimeException("Android HTTP access token format is invalid");
}
return authorization.substring(BEARER_PREFIX.length()).trim();
}
private void assertDeviceEnabled(DeviceInfoEntity device) { private String firstHeader(HttpServletRequest request, String... names) {
if (device != null && device.getStatus() != null && device.getStatus() == 0) { for (String name : names) {
throw new BusinessException("403", "设备被禁用"); String value = request.getHeader(name);
} if (StringUtils.hasText(value)) {
return value.trim();
}
} }
return null;
}
private DeviceInfoEntity requireRegisteredDevice(String deviceId) { private void requireAndroidHttpHeaders(String deviceId, String appVersion, String platform) {
DeviceInfoEntity device = findDevice(deviceId); if (!StringUtils.hasText(deviceId)) {
if (device == null) { throw new RuntimeException("Missing Android device_id");
throw new RuntimeException("设备未注册,请先调用设备注册接口");
}
return device;
} }
if (!StringUtils.hasText(appVersion)) {
throw new RuntimeException("Missing X-Android-App-Version header");
}
if (!StringUtils.hasText(platform)) {
throw new RuntimeException("Missing X-Android-Platform header");
}
}
private DeviceInfoEntity findDevice(String deviceId) { private void assertDeviceEnabled(DeviceInfoEntity device) {
if (!StringUtils.hasText(deviceId)) { if (device != null && device.getStatus() != null && device.getStatus() == 0) {
return null; throw new BusinessException("403", "设备被禁用");
}
return deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId.trim());
} }
}
private String normalizeToken(String token) { private DeviceInfoEntity requireRegisteredDevice(String deviceId) {
if (!StringUtils.hasText(token)) { DeviceInfoEntity device = findDevice(deviceId);
return null; if (device == null) {
} throw new RuntimeException("设备未注册,请先调用设备注册接口");
String resolved = token.trim();
if (resolved.startsWith(BEARER_PREFIX)) {
resolved = resolved.substring(BEARER_PREFIX.length()).trim();
}
return resolved;
} }
return device;
}
private LoginUser currentLoginUser() { private DeviceInfoEntity findDevice(String deviceId) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (!StringUtils.hasText(deviceId)) {
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { return null;
return null;
}
if (loginUser.getUserId() == null || loginUser.getTenantId() == null) {
return null;
}
return loginUser;
} }
return deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId.trim());
}
private String normalizeToken(String token) {
if (!StringUtils.hasText(token)) {
return null;
}
String resolved = token.trim();
if (resolved.startsWith(BEARER_PREFIX)) {
resolved = resolved.substring(BEARER_PREFIX.length()).trim();
}
return resolved;
}
private LoginUser currentLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
return null;
}
if (loginUser.getUserId() == null || loginUser.getTenantId() == null) {
return null;
}
return loginUser;
}
} }

View File

@ -1,9 +1,9 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.mapper.DeviceLoginLogMapper;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.DeviceLoginLogEntity; import com.imeeting.entity.biz.DeviceLoginLogEntity;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.mapper.DeviceLoginLogMapper;
import com.imeeting.service.android.AndroidDeviceBindingService; import com.imeeting.service.android.AndroidDeviceBindingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -42,11 +42,8 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ
throw new RuntimeException("设备登录态无效"); throw new RuntimeException("设备登录态无效");
} }
DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim());
if (existing == null || existing.getUserId() == null || existing.getTenantId() == null) { if (existing == null) {
throw new RuntimeException("设备未登录,请先完成设备登录"); throw new RuntimeException("设备未注册");
}
if (!Objects.equals(existing.getUserId(), userId) || !Objects.equals(existing.getTenantId(), tenantId)) {
throw new RuntimeException("当前设备已被其他用户占用,请使用当前登录用户或重新登录占用设备");
} }
} }
@ -60,7 +57,7 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ
return; return;
} }
existing.setUserId(null); existing.setUserId(null);
existing.setTenantId(null); // existing.setTenantId(null);
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
} }

View File

@ -103,10 +103,7 @@ public class AndroidDeviceHomeServiceImpl implements AndroidDeviceHomeService {
return 0L; return 0L;
} }
MeetingPointsBalanceVO balance = meetingPointsService.getBalanceView(tenantId, anonymous ? null : userId); MeetingPointsBalanceVO balance = meetingPointsService.getBalanceView(tenantId, anonymous ? null : userId);
long totalPoints = defaultLong(balance.getPublicBalance()); long totalPoints = defaultLong(balance.getTotalAvailableBalance());
if (!anonymous && userId != null) {
totalPoints += defaultLong(balance.getPersonalBalance());
}
int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1); 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 costPerUnit = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_COST_PER_UNIT, "1"), 1);
return costPerUnit <= 0 ? 0L : (totalPoints * unitMinutes) / costPerUnit; return costPerUnit <= 0 ? 0L : (totalPoints * unitMinutes) / costPerUnit;

View File

@ -1,12 +1,16 @@
package com.imeeting.service.biz; package com.imeeting.service.biz;
import com.imeeting.dto.biz.MeetingPointsBalanceVO;
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.dto.biz.MeetingPointsBalanceVO;
public interface MeetingPointsService { public interface MeetingPointsService {
long UNIFIED_ACCOUNT_USER_ID = 0L; long UNIFIED_ACCOUNT_USER_ID = 0L;
void initializeTenantPointsAccount(Long tenantId);
void transferPublicPointsToUser(Long tenantId, Long targetUserId, Long points, String remark);
void recordAsrSuccessCharge(Meeting meeting, AiTask asrTask); void recordAsrSuccessCharge(Meeting meeting, AiTask asrTask);
void recordSummarySuccessCharge(Meeting meeting, AiTask summaryTask); void recordSummarySuccessCharge(Meeting meeting, AiTask summaryTask);

View File

@ -1,7 +0,0 @@
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

@ -6,6 +6,7 @@ import com.imeeting.dto.biz.DeviceAdminUpdateCommand;
import com.imeeting.dto.biz.DeviceOnlineAdminVO; import com.imeeting.dto.biz.DeviceOnlineAdminVO;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidDeviceBindingService;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.biz.DeviceOnlineManagementService; import com.imeeting.service.biz.DeviceOnlineManagementService;
@ -28,6 +29,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidDeviceSessionService androidDeviceSessionService;
private final AndroidGatewayPushService androidGatewayPushService; private final AndroidGatewayPushService androidGatewayPushService;
private final AndroidDeviceBindingService androidDeviceBindingService;
private final LicenseService licenseService; private final LicenseService licenseService;
@Override @Override
@ -40,13 +42,28 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
if (existing == null) { if (existing == null) {
return; return;
} }
if (authContext.getUserId() != null) {
androidDeviceBindingService.validatePrivateDeviceAccess(
deviceCode,
authContext.getTenantId(),
authContext.getUserId()
);
androidDeviceBindingService.bindPrivateDevice(
deviceCode,
authContext.getTenantId(),
authContext.getUserId(),
authContext.getAppVersion(),
authContext.getPlatform()
);
return;
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
existing.setTerminalType(normalizeTerminalType(authContext.getPlatform())); existing.setTerminalType(normalizeTerminalType(authContext.getPlatform()));
existing.setTerminalVersion(normalize(authContext.getAppVersion())); existing.setTerminalVersion(normalize(authContext.getAppVersion()));
existing.setLastOnlineAt(now); existing.setLastOnlineAt(now);
existing.setUserId(authContext.getUserId()); existing.setUserId(authContext.getUserId());
existing.setTenantId(authContext.getTenantId()); existing.setTenantId(authContext.getTenantId());
deviceInfoMapper.updateById(existing); deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
} }
@Override @Override

View File

@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.MeetingPointsChargeItemVO;
import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO; import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO;
import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO; import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO;
import com.imeeting.dto.biz.MeetingPointsOverviewVO; import com.imeeting.dto.biz.MeetingPointsOverviewVO;
@ -37,7 +38,13 @@ import java.util.stream.Collectors;
@Service @Service
public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService { public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService {
private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC"; private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC";
private static final String ACCOUNT_MODE_PERSONAL = "PERSONAL";
private static final String ACCOUNT_MODE_BOTH = "BOTH";
private static final String CHARGE_PRIORITY_PUBLIC_FIRST = "PUBLIC_FIRST";
private static final String CHARGE_PRIORITY_PERSONAL_FIRST = "PERSONAL_FIRST";
private static final long PUBLIC_ACCOUNT_USER_ID = 0L; private static final long PUBLIC_ACCOUNT_USER_ID = 0L;
private static final String POINTS_TYPE_ASR = "ASR";
private static final String POINTS_TYPE_LLM = "LLM";
private final MeetingPointsAccountService meetingPointsAccountService; private final MeetingPointsAccountService meetingPointsAccountService;
private final MeetingPointsLedgerService meetingPointsLedgerService; private final MeetingPointsLedgerService meetingPointsLedgerService;
@ -65,33 +72,43 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
@Override @Override
public MeetingPointsOverviewVO getOverview(Long tenantId) { public MeetingPointsOverviewVO getOverview(Long tenantId) {
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId); String accountMode = resolveAccountMode();
MeetingPointsAccount publicAccount = meetingPointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>() String chargePriority = resolveChargePriority();
.eq(MeetingPointsAccount::getTenantId, tenantId) MeetingPointsAccount publicAccount = findAccount(tenantId, PUBLIC_ACCOUNT_USER_ID);
.eq(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID) long publicBalance = publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance());
.last("LIMIT 1")); long publicTotalUsed = publicAccount == null ? 0L : defaultLong(publicAccount.getTotalPointsUsed());
long personalBalance = 0L;
long personalTotalUsed = 0L;
List<MeetingPointsAccount> personalAccounts = meetingPointsAccountService.list(new LambdaQueryWrapper<MeetingPointsAccount>()
.eq(MeetingPointsAccount::getTenantId, tenantId)
.ne(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID));
for (MeetingPointsAccount account : personalAccounts) {
personalBalance += defaultLong(account.getCurrentBalance());
personalTotalUsed += defaultLong(account.getTotalPointsUsed());
}
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
long totalChargeCount = 0L; long totalChargeCount = 0L;
long publicTotalPointsUsed = 0L; if (scopedOwnerUserIds == null) {
List<Long> scopedChargeRecordIds = resolveChargeRecordIdsByOwners(tenantId, scopedOwnerUserIds); totalChargeCount = meetingSummaryChargeRecordService.count(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
if (scopedChargeRecordIds == null || !scopedChargeRecordIds.isEmpty()) { .eq(MeetingSummaryChargeRecord::getTenantId, tenantId)
LambdaQueryWrapper<MeetingPointsLedger> scopedLedgerWrapper = new LambdaQueryWrapper<MeetingPointsLedger>() .gt(MeetingSummaryChargeRecord::getChargedTotalPoints, 0L));
.eq(MeetingPointsLedger::getTenantId, tenantId) } else if (!scopedOwnerUserIds.isEmpty()) {
.lt(MeetingPointsLedger::getPointsDelta, 0) totalChargeCount = meetingSummaryChargeRecordService.count(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
.in(scopedChargeRecordIds != null, MeetingPointsLedger::getChargeRecordId, scopedChargeRecordIds); .eq(MeetingSummaryChargeRecord::getTenantId, tenantId)
totalChargeCount = meetingPointsLedgerService.count(scopedLedgerWrapper); .in(MeetingSummaryChargeRecord::getUserId, scopedOwnerUserIds)
publicTotalPointsUsed = meetingPointsLedgerService.list(scopedLedgerWrapper) .gt(MeetingSummaryChargeRecord::getChargedTotalPoints, 0L));
.stream()
.map(MeetingPointsLedger::getPointsDelta)
.filter(Objects::nonNull)
.mapToLong(value -> Math.abs(value))
.sum();
} }
MeetingPointsOverviewVO vo = new MeetingPointsOverviewVO(); MeetingPointsOverviewVO vo = new MeetingPointsOverviewVO();
vo.setAccountMode(resolveAccountMode()); vo.setAccountMode(accountMode);
vo.setPublicBalance(publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance())); vo.setChargePriority(chargePriority);
vo.setPublicTotalPointsUsed(publicTotalPointsUsed); vo.setPublicBalance(publicBalance);
vo.setPublicTotalPointsUsed(publicTotalUsed);
vo.setPersonalBalance(personalBalance);
vo.setPersonalTotalPointsUsed(personalTotalUsed);
vo.setTotalAvailableBalance(resolveVisibleTotalBalance(accountMode, publicBalance, personalBalance));
vo.setTotalChargeCount(totalChargeCount); vo.setTotalChargeCount(totalChargeCount);
return vo; return vo;
} }
@ -121,6 +138,7 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
LambdaQueryWrapper<MeetingPointsLedger> wrapper = new LambdaQueryWrapper<MeetingPointsLedger>() LambdaQueryWrapper<MeetingPointsLedger> wrapper = new LambdaQueryWrapper<MeetingPointsLedger>()
.eq(MeetingPointsLedger::getTenantId, tenantId) .eq(MeetingPointsLedger::getTenantId, tenantId)
.lt(MeetingPointsLedger::getPointsDelta, 0) .lt(MeetingPointsLedger::getPointsDelta, 0)
.in(!StringUtils.hasText(pointsType), MeetingPointsLedger::getPointsType, List.of(POINTS_TYPE_ASR, POINTS_TYPE_LLM))
.eq(StringUtils.hasText(pointsType), MeetingPointsLedger::getPointsType, pointsType == null ? null : pointsType.trim().toUpperCase(Locale.ROOT)) .eq(StringUtils.hasText(pointsType), MeetingPointsLedger::getPointsType, pointsType == null ? null : pointsType.trim().toUpperCase(Locale.ROOT))
.in(filteredChargeRecordIds != null && !filteredChargeRecordIds.isEmpty(), MeetingPointsLedger::getChargeRecordId, filteredChargeRecordIds) .in(filteredChargeRecordIds != null && !filteredChargeRecordIds.isEmpty(), MeetingPointsLedger::getChargeRecordId, filteredChargeRecordIds)
.orderByDesc(MeetingPointsLedger::getCreatedAt) .orderByDesc(MeetingPointsLedger::getCreatedAt)
@ -150,7 +168,7 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
item.setSummaryTaskId(ledger.getSummaryTaskId()); item.setSummaryTaskId(ledger.getSummaryTaskId());
item.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId()); item.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId());
item.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId())); item.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId()));
item.setChargeAccountType(chargeRecord == null ? null : chargeRecord.getChargeAccountType()); item.setChargeAccountType(resolveAccountTypeByLedger(ledger.getUserId()));
item.setPointsType(ledger.getPointsType()); item.setPointsType(ledger.getPointsType());
item.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta()))); item.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta())));
item.setBalanceBefore(ledger.getBalanceBefore()); item.setBalanceBefore(ledger.getBalanceBefore());
@ -185,8 +203,8 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
detail.setSummaryTaskId(ledger.getSummaryTaskId()); detail.setSummaryTaskId(ledger.getSummaryTaskId());
detail.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId()); detail.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId());
detail.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId())); detail.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId()));
detail.setChargeAccountType(chargeRecord == null ? null : chargeRecord.getChargeAccountType()); detail.setChargeAccountType(resolveAccountTypeByLedger(ledger.getUserId()));
detail.setChargeAccountUserId(chargeRecord == null ? null : chargeRecord.getChargeAccountUserId()); detail.setChargeAccountUserId(ledger.getUserId());
detail.setPointsType(ledger.getPointsType()); detail.setPointsType(ledger.getPointsType());
detail.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta()))); detail.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta())));
detail.setBalanceBefore(ledger.getBalanceBefore()); detail.setBalanceBefore(ledger.getBalanceBefore());
@ -210,9 +228,46 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
detail.setAsrChargedAt(chargeRecord == null ? null : chargeRecord.getAsrChargedAt()); detail.setAsrChargedAt(chargeRecord == null ? null : chargeRecord.getAsrChargedAt());
detail.setLlmChargedAt(chargeRecord == null ? null : chargeRecord.getLlmChargedAt()); detail.setLlmChargedAt(chargeRecord == null ? null : chargeRecord.getLlmChargedAt());
detail.setCreatedAt(ledger.getCreatedAt()); detail.setCreatedAt(ledger.getCreatedAt());
detail.setChargeItems(buildChargeItems(tenantId, ledger.getChargeRecordId()));
return detail; return detail;
} }
private MeetingPointsAccount findAccount(Long tenantId, Long userId) {
if (tenantId == null || userId == null) {
return null;
}
return meetingPointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>()
.eq(MeetingPointsAccount::getTenantId, tenantId)
.eq(MeetingPointsAccount::getUserId, userId)
.last("LIMIT 1"));
}
private List<MeetingPointsChargeItemVO> buildChargeItems(Long tenantId, Long chargeRecordId) {
if (chargeRecordId == null) {
return Collections.emptyList();
}
List<MeetingPointsLedger> ledgers = meetingPointsLedgerService.list(new LambdaQueryWrapper<MeetingPointsLedger>()
.eq(MeetingPointsLedger::getTenantId, tenantId)
.eq(MeetingPointsLedger::getChargeRecordId, chargeRecordId)
.lt(MeetingPointsLedger::getPointsDelta, 0)
.orderByAsc(MeetingPointsLedger::getId));
List<MeetingPointsChargeItemVO> items = new ArrayList<>();
int order = 1;
for (MeetingPointsLedger ledger : ledgers) {
MeetingPointsChargeItemVO item = new MeetingPointsChargeItemVO();
item.setId(ledger.getId());
item.setChargeStage(ledger.getPointsType());
item.setAccountType(resolveAccountTypeByLedger(ledger.getUserId()));
item.setAccountUserId(ledger.getUserId());
item.setPriorityOrder(order++);
item.setChargedPoints(Math.abs(defaultLong(ledger.getPointsDelta())));
item.setBalanceBefore(ledger.getBalanceBefore());
item.setBalanceAfter(ledger.getBalanceAfter());
items.add(item);
}
return items;
}
private List<Long> resolveMatchedOwnerIds(Long tenantId, String username, List<Long> scopedOwnerUserIds) { private List<Long> resolveMatchedOwnerIds(Long tenantId, String username, List<Long> scopedOwnerUserIds) {
if (scopedOwnerUserIds != null && scopedOwnerUserIds.isEmpty()) { if (scopedOwnerUserIds != null && scopedOwnerUserIds.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
@ -337,7 +392,34 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
if (!StringUtils.hasText(configured)) { if (!StringUtils.hasText(configured)) {
return ACCOUNT_MODE_PUBLIC; return ACCOUNT_MODE_PUBLIC;
} }
return configured.trim().toUpperCase(Locale.ROOT); String normalized = configured.trim().toUpperCase(Locale.ROOT);
if (ACCOUNT_MODE_PERSONAL.equals(normalized) || ACCOUNT_MODE_BOTH.equals(normalized)) {
return normalized;
}
return ACCOUNT_MODE_PUBLIC;
}
private String resolveChargePriority() {
String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_CHARGE_PRIORITY, CHARGE_PRIORITY_PUBLIC_FIRST);
if (!StringUtils.hasText(configured)) {
return CHARGE_PRIORITY_PUBLIC_FIRST;
}
String normalized = configured.trim().toUpperCase(Locale.ROOT);
return CHARGE_PRIORITY_PUBLIC_FIRST.equals(normalized) ? CHARGE_PRIORITY_PUBLIC_FIRST : CHARGE_PRIORITY_PERSONAL_FIRST;
}
private String resolveAccountTypeByLedger(Long accountUserId) {
return accountUserId != null && accountUserId == PUBLIC_ACCOUNT_USER_ID ? ACCOUNT_MODE_PUBLIC : ACCOUNT_MODE_PERSONAL;
}
private long resolveVisibleTotalBalance(String accountMode, long publicBalance, long personalBalance) {
if (ACCOUNT_MODE_PUBLIC.equals(accountMode)) {
return publicBalance;
}
if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) {
return personalBalance;
}
return publicBalance + personalBalance;
} }
private long defaultLong(Long value) { private long defaultLong(Long value) {

View File

@ -8,34 +8,27 @@ import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingPointsAccount; import com.imeeting.entity.biz.MeetingPointsAccount;
import com.imeeting.entity.biz.MeetingPointsLedger; import com.imeeting.entity.biz.MeetingPointsLedger;
import com.imeeting.entity.biz.MeetingSummaryChargeRecord; 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.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingPointsAccountMapper; import com.imeeting.mapper.biz.MeetingPointsAccountMapper;
import com.imeeting.mapper.biz.MeetingSummaryChargeRecordMapper; import com.imeeting.mapper.biz.MeetingSummaryChargeRecordMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.MeetingPointsAccountService; import com.imeeting.service.biz.MeetingPointsAccountService;
import com.imeeting.service.biz.MeetingPointsLedgerService; import com.imeeting.service.biz.MeetingPointsLedgerService;
import com.imeeting.service.biz.MeetingPointsService; import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingSummaryChargeRecordService; import com.imeeting.service.biz.MeetingSummaryChargeRecordService;
import com.imeeting.service.biz.MeetingUserStatsService;
import com.unisbase.service.SysParamService; import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import java.io.File;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -45,25 +38,71 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
private static final String TRIGGER_RESUMMARY = "RESUMMARY"; private static final String TRIGGER_RESUMMARY = "RESUMMARY";
private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC"; private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC";
private static final String ACCOUNT_MODE_PERSONAL = "PERSONAL"; private static final String ACCOUNT_MODE_PERSONAL = "PERSONAL";
private static final String ACCOUNT_MODE_BOTH = "BOTH";
private static final String CHARGE_PRIORITY_PERSONAL_FIRST = "PERSONAL_FIRST";
private static final String CHARGE_PRIORITY_PUBLIC_FIRST = "PUBLIC_FIRST";
private static final String STATUS_PENDING = "PENDING"; private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_ASR_CHARGED = "ASR_CHARGED"; private static final String STATUS_ASR_CHARGED = "ASR_CHARGED";
private static final String STATUS_COMPLETED = "COMPLETED"; private static final String STATUS_COMPLETED = "COMPLETED";
private static final String STATUS_FAILED = "FAILED"; private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_DISABLED = "DISABLED"; private static final String STATUS_DISABLED = "DISABLED";
private static final String POINTS_TYPE_ASR = "ASR";
private static final String POINTS_TYPE_LLM = "LLM";
private static final String POINTS_TYPE_INIT = "INIT";
private static final String POINTS_TYPE_TRANSFER_OUT = "TRANSFER_OUT";
private static final String POINTS_TYPE_TRANSFER_IN = "TRANSFER_IN";
private final MeetingSummaryChargeRecordService chargeRecordService; private final MeetingSummaryChargeRecordService chargeRecordService;
private final MeetingPointsAccountService pointsAccountService; private final MeetingPointsAccountService pointsAccountService;
private final MeetingPointsLedgerService pointsLedgerService; private final MeetingPointsLedgerService pointsLedgerService;
private final MeetingUserStatsService meetingUserStatsService;
private final MeetingTranscriptMapper transcriptMapper;
private final MeetingMapper meetingMapper; private final MeetingMapper meetingMapper;
private final AiTaskMapper aiTaskMapper; private final AiTaskMapper aiTaskMapper;
private final MeetingPointsAccountMapper meetingPointsAccountMapper; private final MeetingPointsAccountMapper meetingPointsAccountMapper;
private final MeetingSummaryChargeRecordMapper meetingSummaryChargeRecordMapper; private final MeetingSummaryChargeRecordMapper meetingSummaryChargeRecordMapper;
private final SysParamService sysParamService; private final SysParamService sysParamService;
@Value("${unisbase.app.upload-path}") @Override
private String uploadPath; @Transactional(rollbackFor = Exception.class)
public void initializeTenantPointsAccount(Long tenantId) {
if (tenantId == null) {
return;
}
long initialBalance = nonNegativeLong(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_INITIAL_BALANCE, "0"), 0L);
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, initialBalance, true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transferPublicPointsToUser(Long tenantId, Long targetUserId, Long points, String remark) {
if (tenantId == null) {
throw new RuntimeException("缺少租户信息");
}
if (targetUserId == null || targetUserId <= 0L) {
throw new RuntimeException("目标用户不能为空");
}
long safePoints = points == null ? 0L : points;
if (safePoints <= 0L) {
throw new RuntimeException("分配积分必须大于0");
}
MeetingPointsAccount publicAccount = getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false);
long publicBefore = defaultLong(publicAccount.getCurrentBalance());
if (publicBefore < safePoints) {
throw new RuntimeException("公共账户积分不足");
}
MeetingPointsAccount personalAccount = getOrCreateAccountForMutation(tenantId, targetUserId, 0L, false);
long personalBefore = defaultLong(personalAccount.getCurrentBalance());
publicAccount.setCurrentBalance(publicBefore - safePoints);
personalAccount.setCurrentBalance(personalBefore + safePoints);
pointsAccountService.updateById(publicAccount);
pointsAccountService.updateById(personalAccount);
String normalizedRemark = StringUtils.hasText(remark) ? remark.trim() : "管理员从公共账户分配积分";
saveTransferLedger(tenantId, UNIFIED_ACCOUNT_USER_ID, POINTS_TYPE_TRANSFER_OUT, -safePoints, publicBefore, publicBefore - safePoints, normalizedRemark);
saveTransferLedger(tenantId, targetUserId, POINTS_TYPE_TRANSFER_IN, safePoints, personalBefore, personalBefore + safePoints, normalizedRemark);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -87,7 +126,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
ensureMeetingDurationStats(meeting, durationSeconds); ensureMeetingDurationStats(meeting, durationSeconds);
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds); MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds);
if (defaultLong(record.getChargedAsrPoints()) > 0) { if (defaultLong(record.getChargedAsrPoints()) > 0L) {
return; return;
} }
if (!isPointsEnabled()) { if (!isPointsEnabled()) {
@ -102,26 +141,16 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
return; return;
} }
MeetingPointsAccount account = getOrCreateAccount(meeting.getTenantId(), record.getChargeAccountUserId()); ChargeExecutionResult result = executeCharge(meeting, summaryTask, record, POINTS_TYPE_ASR, chargeAmount);
long balanceBefore = defaultLong(account.getCurrentBalance()); record.setBalanceBefore(record.getBalanceBefore() == null ? result.totalBalanceBefore() : record.getBalanceBefore());
long balanceAfter = balanceBefore - chargeAmount; record.setBalanceAfter(result.totalBalanceAfter());
record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + result.chargedPoints());
account.setCurrentBalance(balanceAfter); record.setChargedAsrPoints(result.chargedPoints());
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.setAsrChargedAt(LocalDateTime.now());
record.setChargedAt(LocalDateTime.now()); record.setChargedAt(LocalDateTime.now());
record.setPointsDelta(-defaultLong(record.getChargedTotalPoints())); record.setPointsDelta(-defaultLong(record.getChargedTotalPoints()));
record.setSummaryStatus(STATUS_ASR_CHARGED); record.setSummaryStatus(STATUS_ASR_CHARGED);
saveOrUpdateRecord(record); saveOrUpdateRecord(record);
saveLedger(meeting, summaryTask, record, "ASR", -chargeAmount, balanceBefore, balanceAfter);
} }
@Override @Override
@ -139,20 +168,17 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
record.setSummaryStatus(STATUS_COMPLETED); record.setSummaryStatus(STATUS_COMPLETED);
record.setLlmChargedAt(LocalDateTime.now()); record.setLlmChargedAt(LocalDateTime.now());
saveOrUpdateRecord(record); saveOrUpdateRecord(record);
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
return; return;
} }
ensureMeetingDurationStats(meeting, durationSeconds); ensureMeetingDurationStats(meeting, durationSeconds);
if (defaultLong(record.getChargedLlmPoints()) > 0) { if (defaultLong(record.getChargedLlmPoints()) > 0L) {
return; return;
} }
if (!isPointsEnabled()) { if (!isPointsEnabled()) {
record.setSummaryStatus(STATUS_DISABLED); record.setSummaryStatus(STATUS_DISABLED);
record.setLlmChargedAt(LocalDateTime.now()); record.setLlmChargedAt(LocalDateTime.now());
saveOrUpdateRecord(record); saveOrUpdateRecord(record);
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
return; return;
} }
@ -161,33 +187,21 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
record.setSummaryStatus(STATUS_COMPLETED); record.setSummaryStatus(STATUS_COMPLETED);
record.setLlmChargedAt(LocalDateTime.now()); record.setLlmChargedAt(LocalDateTime.now());
saveOrUpdateRecord(record); saveOrUpdateRecord(record);
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
return; return;
} }
MeetingPointsAccount account = getOrCreateAccount(meeting.getTenantId(), record.getChargeAccountUserId()); ChargeExecutionResult result = executeCharge(meeting, summaryTask, record, POINTS_TYPE_LLM, chargeAmount);
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) { if (record.getBalanceBefore() == null) {
record.setBalanceBefore(balanceBefore); record.setBalanceBefore(result.totalBalanceBefore());
} }
record.setBalanceAfter(balanceAfter); record.setBalanceAfter(result.totalBalanceAfter());
record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + chargeAmount); record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + result.chargedPoints());
record.setChargedLlmPoints(chargeAmount); record.setChargedLlmPoints(result.chargedPoints());
record.setLlmChargedAt(LocalDateTime.now()); record.setLlmChargedAt(LocalDateTime.now());
record.setChargedAt(LocalDateTime.now()); record.setChargedAt(LocalDateTime.now());
record.setPointsDelta(-defaultLong(record.getChargedTotalPoints())); record.setPointsDelta(-defaultLong(record.getChargedTotalPoints()));
record.setSummaryStatus(STATUS_COMPLETED); record.setSummaryStatus(STATUS_COMPLETED);
saveOrUpdateRecord(record); saveOrUpdateRecord(record);
saveLedger(meeting, summaryTask, record, "LLM", -chargeAmount, balanceBefore, balanceAfter);
incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId());
} }
@Override @Override
@ -211,22 +225,36 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
@Override @Override
public String resolveLatestBlockedReason(Long summaryTaskId) { public String resolveLatestBlockedReason(Long summaryTaskId) {
return null; if (summaryTaskId == null) {
return null;
}
MeetingSummaryChargeRecord record = chargeRecordService.getOne(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
.eq(MeetingSummaryChargeRecord::getSummaryTaskId, summaryTaskId)
.last("LIMIT 1"));
return record == null ? null : record.getBlockedReason();
} }
@Override @Override
public MeetingPointsBalanceVO getBalanceView(Long tenantId, Long userId) { public MeetingPointsBalanceVO getBalanceView(Long tenantId, Long userId) {
MeetingPointsAccount publicAccount = getOrCreateAccount(tenantId, UNIFIED_ACCOUNT_USER_ID); MeetingPointsAccount publicAccount = findAccount(tenantId, UNIFIED_ACCOUNT_USER_ID);
MeetingPointsAccount personalAccount = getOrCreateAccount(tenantId, userId); MeetingPointsAccount personalAccount = userId == null ? null : findAccount(tenantId, userId);
long publicBalance = publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance());
long publicTotalUsed = publicAccount == null ? 0L : defaultLong(publicAccount.getTotalPointsUsed());
long personalBalance = personalAccount == null ? 0L : defaultLong(personalAccount.getCurrentBalance());
long personalTotalUsed = personalAccount == null ? 0L : defaultLong(personalAccount.getTotalPointsUsed());
String accountMode = resolveAccountMode();
String chargePriority = resolveChargePriority();
MeetingPointsBalanceVO vo = new MeetingPointsBalanceVO(); MeetingPointsBalanceVO vo = new MeetingPointsBalanceVO();
vo.setTenantId(tenantId); vo.setTenantId(tenantId);
vo.setUserId(userId); vo.setUserId(userId);
vo.setPreferredAccountMode(resolveAccountMode()); vo.setAccountMode(accountMode);
vo.setPublicBalance(defaultLong(publicAccount.getCurrentBalance())); vo.setChargePriority(chargePriority);
vo.setPublicTotalPointsUsed(defaultLong(publicAccount.getTotalPointsUsed())); vo.setPublicBalance(publicBalance);
vo.setPersonalBalance(defaultLong(personalAccount.getCurrentBalance())); vo.setPublicTotalPointsUsed(publicTotalUsed);
vo.setPersonalTotalPointsUsed(defaultLong(personalAccount.getTotalPointsUsed())); vo.setPersonalBalance(personalBalance);
vo.setPersonalTotalPointsUsed(personalTotalUsed);
vo.setTotalAvailableBalance(resolveVisibleTotalBalance(accountMode, publicBalance, personalBalance));
return vo; return vo;
} }
@ -266,15 +294,15 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
throw ex; throw ex;
} }
} }
incrementSummaryAttemptCount(meeting.getTenantId(), meeting.getCreatorId());
return record; return record;
} }
private void applyChargeSnapshot(MeetingSummaryChargeRecord record, Meeting meeting, String chargeTriggerType, int durationSeconds) { private void applyChargeSnapshot(MeetingSummaryChargeRecord record, Meeting meeting, String chargeTriggerType, int durationSeconds) {
ChargeSnapshot snapshot = buildChargeSnapshot(durationSeconds); ChargeSnapshot snapshot = buildChargeSnapshot(durationSeconds);
ChargeAccountSnapshot accountSnapshot = resolveChargeAccountSnapshot(meeting); String accountMode = resolveAccountMode();
record.setChargeAccountType(accountSnapshot.accountType()); Long ownerUserId = meeting.getCreatorId() == null ? UNIFIED_ACCOUNT_USER_ID : meeting.getCreatorId();
record.setChargeAccountUserId(accountSnapshot.accountUserId()); record.setChargeAccountType(accountMode);
record.setChargeAccountUserId(ACCOUNT_MODE_PERSONAL.equals(accountMode) ? ownerUserId : UNIFIED_ACCOUNT_USER_ID);
record.setAudioDurationSeconds(durationSeconds); record.setAudioDurationSeconds(durationSeconds);
record.setChargedMinutes(snapshot.chargedMinutes()); record.setChargedMinutes(snapshot.chargedMinutes());
record.setBillingUnits(snapshot.billingUnits()); record.setBillingUnits(snapshot.billingUnits());
@ -293,6 +321,176 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
} }
} }
private ChargeExecutionResult executeCharge(Meeting meeting, AiTask summaryTask, MeetingSummaryChargeRecord record,
String pointsType, long chargeAmount) {
List<ChargeTarget> chargeTargets = resolveChargeTargets(meeting.getTenantId(), record.getUserId());
long totalBalanceBefore = 0L;
for (ChargeTarget target : chargeTargets) {
totalBalanceBefore += defaultLong(target.account().getCurrentBalance());
}
if (isBalanceEnforced() && totalBalanceBefore < chargeAmount) {
record.setBlockedReason("INSUFFICIENT_POINTS");
saveOrUpdateRecord(record);
throw new RuntimeException("积分余额不足");
}
long remaining = chargeAmount;
for (int i = 0; i < chargeTargets.size(); i++) {
ChargeTarget target = chargeTargets.get(i);
MeetingPointsAccount account = target.account();
long currentBalance = defaultLong(account.getCurrentBalance());
boolean lastTarget = i == chargeTargets.size() - 1;
long deducted = calculateDeductedPoints(currentBalance, remaining, lastTarget);
if (deducted <= 0L) {
continue;
}
long balanceAfter = currentBalance - deducted;
account.setCurrentBalance(balanceAfter);
account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + deducted);
if (POINTS_TYPE_ASR.equals(pointsType)) {
account.setTotalAsrPointsUsed(defaultLong(account.getTotalAsrPointsUsed()) + deducted);
} else if (POINTS_TYPE_LLM.equals(pointsType)) {
account.setTotalLlmPointsUsed(defaultLong(account.getTotalLlmPointsUsed()) + deducted);
}
pointsAccountService.updateById(account);
saveChargeLedger(meeting, summaryTask, record, target.accountUserId(), pointsType, -deducted, currentBalance, balanceAfter);
remaining -= deducted;
if (remaining <= 0L) {
break;
}
}
if (remaining > 0L) {
record.setBlockedReason("INSUFFICIENT_POINTS");
saveOrUpdateRecord(record);
throw new RuntimeException("积分扣费失败,未能完成完整扣减");
}
return new ChargeExecutionResult(chargeAmount, totalBalanceBefore, totalBalanceBefore - chargeAmount);
}
private long calculateDeductedPoints(long currentBalance, long remaining, boolean lastTarget) {
if (remaining <= 0L) {
return 0L;
}
if (isBalanceEnforced()) {
return Math.min(Math.max(currentBalance, 0L), remaining);
}
if (lastTarget) {
return remaining;
}
return Math.min(Math.max(currentBalance, 0L), remaining);
}
private void saveChargeLedger(Meeting meeting, AiTask summaryTask, MeetingSummaryChargeRecord record, Long accountUserId,
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(accountUserId);
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 saveTransferLedger(Long tenantId, Long accountUserId, String pointsType, long pointsDelta,
long balanceBefore, long balanceAfter, String remark) {
MeetingPointsLedger ledger = new MeetingPointsLedger();
ledger.setTenantId(tenantId);
ledger.setStatus(1);
ledger.setUserId(accountUserId);
ledger.setPointsDelta(pointsDelta);
ledger.setPointsType(pointsType);
ledger.setBalanceBefore(balanceBefore);
ledger.setBalanceAfter(balanceAfter);
ledger.setRemark(remark);
pointsLedgerService.save(ledger);
}
private MeetingPointsAccount findAccount(Long tenantId, Long userId) {
if (tenantId == null || userId == null) {
return null;
}
return pointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>()
.eq(MeetingPointsAccount::getTenantId, tenantId)
.eq(MeetingPointsAccount::getUserId, userId)
.last("LIMIT 1"));
}
private MeetingPointsAccount getOrCreateAccountForMutation(Long tenantId, Long userId, long initialBalance, boolean createInitLedger) {
MeetingPointsAccount account = meetingPointsAccountMapper.selectForUpdate(tenantId, userId);
if (account != null) {
return account;
}
account = new MeetingPointsAccount();
account.setTenantId(tenantId);
account.setStatus(1);
account.setUserId(userId);
account.setCurrentBalance(initialBalance);
account.setTotalPointsUsed(0L);
account.setTotalAsrPointsUsed(0L);
account.setTotalLlmPointsUsed(0L);
try {
pointsAccountService.save(account);
} catch (DuplicateKeyException ex) {
account = meetingPointsAccountMapper.selectForUpdate(tenantId, userId);
if (account == null) {
throw ex;
}
return account;
}
if (createInitLedger && initialBalance > 0L) {
saveTransferLedger(tenantId, userId, POINTS_TYPE_INIT, initialBalance, 0L, initialBalance, "公共积分账户初始化发放");
}
return account;
}
private List<ChargeTarget> resolveChargeTargets(Long tenantId, Long ownerUserId) {
Long personalUserId = ownerUserId == null ? UNIFIED_ACCOUNT_USER_ID : ownerUserId;
String accountMode = resolveAccountMode();
String chargePriority = resolveChargePriority();
List<ChargeTarget> targets = new ArrayList<>();
if (ACCOUNT_MODE_PUBLIC.equals(accountMode)) {
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
return targets;
}
if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) {
targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId,
getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false)));
return targets;
}
if (personalUserId.equals(UNIFIED_ACCOUNT_USER_ID)) {
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
return targets;
}
if (CHARGE_PRIORITY_PUBLIC_FIRST.equals(chargePriority)) {
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId,
getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false)));
return targets;
}
targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId,
getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false)));
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
return targets;
}
private ChargeSnapshot buildChargeSnapshot(int durationSeconds) { private ChargeSnapshot buildChargeSnapshot(int durationSeconds) {
int chargedMinutes = toChargedMinutes(durationSeconds); int chargedMinutes = toChargedMinutes(durationSeconds);
int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1); int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1);
@ -316,113 +514,6 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
meeting.setEffectiveAudioDurationSeconds(durationSeconds); meeting.setEffectiveAudioDurationSeconds(durationSeconds);
meetingMapper.updateById(meeting); 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) { private void saveOrUpdateRecord(MeetingSummaryChargeRecord record) {
@ -465,38 +556,48 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
return Boolean.parseBoolean(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ENABLED, "false")); return Boolean.parseBoolean(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ENABLED, "false"));
} }
private boolean isBalanceEnforced() {
return Boolean.parseBoolean(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ENFORCE_BALANCE, "false"));
}
private String resolveAccountMode() { private String resolveAccountMode() {
String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ACCOUNT_MODE, ACCOUNT_MODE_PUBLIC); String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_ACCOUNT_MODE, ACCOUNT_MODE_PUBLIC);
if (configured == null || configured.isBlank()) { if (!StringUtils.hasText(configured)) {
return ACCOUNT_MODE_PUBLIC; return ACCOUNT_MODE_PUBLIC;
} }
String normalized = configured.trim().toUpperCase(); String normalized = configured.trim().toUpperCase();
if (ACCOUNT_MODE_PERSONAL.equals(normalized)) { if (ACCOUNT_MODE_PERSONAL.equals(normalized) || ACCOUNT_MODE_BOTH.equals(normalized)) {
return ACCOUNT_MODE_PERSONAL; return normalized;
} }
return ACCOUNT_MODE_PUBLIC; return ACCOUNT_MODE_PUBLIC;
} }
private ChargeAccountSnapshot resolveChargeAccountSnapshot(Meeting meeting) { private String resolveChargePriority() {
String accountMode = resolveAccountMode(); String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_CHARGE_PRIORITY, CHARGE_PRIORITY_PERSONAL_FIRST);
if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) { if (!StringUtils.hasText(configured)) {
Long ownerUserId = meeting.getCreatorId() == null ? UNIFIED_ACCOUNT_USER_ID : meeting.getCreatorId(); return CHARGE_PRIORITY_PERSONAL_FIRST;
return new ChargeAccountSnapshot(ACCOUNT_MODE_PERSONAL, ownerUserId);
} }
return new ChargeAccountSnapshot(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID); String normalized = configured.trim().toUpperCase();
if (CHARGE_PRIORITY_PUBLIC_FIRST.equals(normalized)) {
return CHARGE_PRIORITY_PUBLIC_FIRST;
}
return CHARGE_PRIORITY_PERSONAL_FIRST;
}
private long resolveVisibleTotalBalance(String accountMode, long publicBalance, long personalBalance) {
if (ACCOUNT_MODE_PUBLIC.equals(accountMode)) {
return publicBalance;
}
if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) {
return personalBalance;
}
return publicBalance + personalBalance;
} }
private int toChargedMinutes(int durationSeconds) { private int toChargedMinutes(int durationSeconds) {
return (int) Math.ceil(durationSeconds / 60.0d); 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) { private int positiveInt(String rawValue, int defaultValue) {
try { try {
int value = Integer.parseInt(String.valueOf(rawValue).trim()); int value = Integer.parseInt(String.valueOf(rawValue).trim());
@ -535,7 +636,10 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
return value.length() <= maxLength ? value : value.substring(0, maxLength); return value.length() <= maxLength ? value : value.substring(0, maxLength);
} }
private record ChargeAccountSnapshot(String accountType, Long accountUserId) { private record ChargeTarget(String accountType, Long accountUserId, MeetingPointsAccount account) {
}
private record ChargeExecutionResult(long chargedPoints, long totalBalanceBefore, long totalBalanceAfter) {
} }
private record ChargeSnapshot( private record ChargeSnapshot(

View File

@ -1,11 +0,0 @@
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

@ -1,12 +1,13 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.imeeting.service.biz.LicenseService;
import com.imeeting.service.biz.MeetingPointsService;
import com.unisbase.dto.CreateTenantDTO; import com.unisbase.dto.CreateTenantDTO;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import com.unisbase.dto.SysTenantDTO; import com.unisbase.dto.SysTenantDTO;
import com.unisbase.service.SysTenantService; import com.unisbase.service.SysTenantService;
import com.unisbase.service.TenantManagementService; import com.unisbase.service.TenantManagementService;
import com.unisbase.service.TenantModeService; import com.unisbase.service.TenantModeService;
import com.imeeting.service.biz.LicenseService;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -20,13 +21,16 @@ public class TenantManagementServicePrimaryImpl implements TenantManagementServi
private final SysTenantService sysTenantService; private final SysTenantService sysTenantService;
private final TenantModeService tenantModeService; private final TenantModeService tenantModeService;
private final LicenseService licenseService; private final LicenseService licenseService;
private final MeetingPointsService meetingPointsService;
public TenantManagementServicePrimaryImpl(SysTenantService sysTenantService, public TenantManagementServicePrimaryImpl(SysTenantService sysTenantService,
TenantModeService tenantModeService, TenantModeService tenantModeService,
LicenseService licenseService) { LicenseService licenseService,
MeetingPointsService meetingPointsService) {
this.sysTenantService = sysTenantService; this.sysTenantService = sysTenantService;
this.tenantModeService = tenantModeService; this.tenantModeService = tenantModeService;
this.licenseService = licenseService; this.licenseService = licenseService;
this.meetingPointsService = meetingPointsService;
} }
@Override @Override
@ -52,6 +56,7 @@ public class TenantManagementServicePrimaryImpl implements TenantManagementServi
tenantModeService.assertTenantLifecycleAllowed(); tenantModeService.assertTenantLifecycleAllowed();
Long tenantId = sysTenantService.createTenantWithAdmin(tenant); Long tenantId = sysTenantService.createTenantWithAdmin(tenant);
licenseService.initializeTemporaryLicenses(tenantId); licenseService.initializeTemporaryLicenses(tenantId);
meetingPointsService.initializeTenantPointsAccount(tenantId);
return tenantId; return tenantId;
} }

View File

@ -51,6 +51,7 @@ unisbase:
- biz_prompt_templates - biz_prompt_templates
- biz_meeting_transcript_chapter_versions - biz_meeting_transcript_chapter_versions
- biz_meeting_transcript_chapters - biz_meeting_transcript_chapters
- biz_android_push_message
- biz_client_downloads - biz_client_downloads
- biz_external_apps - biz_external_apps
security: security:

View File

@ -2,11 +2,26 @@ import http from "../http";
export interface MeetingPointsOverviewVO { export interface MeetingPointsOverviewVO {
accountMode: string; accountMode: string;
chargePriority: string;
publicBalance: number; publicBalance: number;
publicTotalPointsUsed: number; publicTotalPointsUsed: number;
personalBalance: number;
personalTotalPointsUsed: number;
totalAvailableBalance: number;
totalChargeCount: number; totalChargeCount: number;
} }
export interface MeetingPointsChargeItemVO {
id: number;
chargeStage: string;
accountType: string;
accountUserId: number;
priorityOrder: number;
chargedPoints: number;
balanceBefore: number;
balanceAfter: number;
}
export interface MeetingPointsLedgerListItemVO { export interface MeetingPointsLedgerListItemVO {
id: number; id: number;
tenantId: number; tenantId: number;
@ -16,7 +31,7 @@ export interface MeetingPointsLedgerListItemVO {
ownerUserId?: number; ownerUserId?: number;
ownerUserName?: string; ownerUserName?: string;
chargeAccountType?: string; chargeAccountType?: string;
pointsType: "ASR" | "LLM" | "INIT" | "RECHARGE"; pointsType: "ASR" | "LLM";
consumedPoints: number; consumedPoints: number;
balanceBefore?: number; balanceBefore?: number;
balanceAfter?: number; balanceAfter?: number;
@ -56,6 +71,7 @@ export interface MeetingPointsLedgerDetailVO {
asrChargedAt?: string; asrChargedAt?: string;
llmChargedAt?: string; llmChargedAt?: string;
createdAt?: string; createdAt?: string;
chargeItems?: MeetingPointsChargeItemVO[];
} }
export async function getMeetingPointsOverview() { export async function getMeetingPointsOverview() {
@ -77,3 +93,12 @@ export async function getMeetingPointsLedgerDetail(ledgerId: number) {
const resp = await http.get(`/api/biz/meeting-points/management/ledgers/${ledgerId}`); const resp = await http.get(`/api/biz/meeting-points/management/ledgers/${ledgerId}`);
return resp.data.data as MeetingPointsLedgerDetailVO; return resp.data.data as MeetingPointsLedgerDetailVO;
} }
export async function transferMeetingPoints(payload: {
targetUserId: number;
points: number;
remark?: string;
}) {
const resp = await http.post("/api/biz/meeting-points/transfer", payload);
return resp.data.data as boolean;
}

View File

@ -1,5 +1,23 @@
import { Alert, Button, Card, Col, Descriptions, Input, Modal, Row, Select, Space, Statistic, Tag, Typography, message } from "antd"; import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { EyeOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { listUsers } from "@/api";
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
message,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import PageContainer from "@/components/shared/PageContainer"; import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable"; import ListTable from "@/components/shared/ListTable/ListTable";
@ -8,10 +26,13 @@ import {
getMeetingPointsLedgerDetail, getMeetingPointsLedgerDetail,
getMeetingPointsLedgerPage, getMeetingPointsLedgerPage,
getMeetingPointsOverview, getMeetingPointsOverview,
transferMeetingPoints,
type MeetingPointsChargeItemVO,
type MeetingPointsLedgerDetailVO, type MeetingPointsLedgerDetailVO,
type MeetingPointsLedgerListItemVO, type MeetingPointsLedgerListItemVO,
type MeetingPointsOverviewVO, type MeetingPointsOverviewVO,
} from "@/api/business/meetingPoints"; } from "@/api/business/meetingPoints";
import type { SysUser } from "@/types";
const { Text } = Typography; const { Text } = Typography;
@ -22,45 +43,39 @@ const POINTS_TYPE_OPTIONS = [
]; ];
function getAccountModeLabel(mode?: string) { function getAccountModeLabel(mode?: string) {
return mode === "PERSONAL" ? "个人账户" : "公共账户"; if (mode === "PERSONAL") return "个人账户";
if (mode === "BOTH") return "公共和个人共存";
return "公共账户";
}
function getChargePriorityLabel(priority?: string) {
return priority === "PUBLIC_FIRST" ? "公共优先" : "个人优先";
}
function getAccountTypeLabel(type?: string) {
return type === "PERSONAL" ? "个人账户" : "公共账户";
} }
function getPointsTypeLabel(value?: string) { function getPointsTypeLabel(value?: string) {
if (value === "ASR") { if (value === "ASR") return "转录";
return "转录"; if (value === "LLM") return "总结";
} if (value === "TRANSFER_OUT") return "转出";
if (value === "LLM") { if (value === "TRANSFER_IN") return "转入";
return "总结"; if (value === "INIT") return "初始化";
}
if (value === "INIT") {
return "初始化";
}
if (value === "RECHARGE") {
return "充值";
}
return value || "-"; return value || "-";
} }
function getPointsTypeColor(value?: string) { function getPointsTypeColor(value?: string) {
if (value === "ASR") { if (value === "ASR") return "blue";
return "blue"; if (value === "LLM") return "purple";
} if (value === "TRANSFER_IN") return "green";
if (value === "LLM") { if (value === "TRANSFER_OUT") return "orange";
return "purple";
}
if (value === "RECHARGE") {
return "green";
}
return "default"; return "default";
} }
function getChargeTriggerLabel(value?: string) { function getChargeTriggerLabel(value?: string) {
if (value === "RESUMMARY") { if (value === "RESUMMARY") return "重新总结";
return "重新总结"; if (value === "AUTO_SUMMARY") return "自动总结";
}
if (value === "AUTO_SUMMARY") {
return "自动总结";
}
return "-"; return "-";
} }
@ -72,22 +87,31 @@ export default function MeetingPointsManagement() {
const [overview, setOverview] = useState<MeetingPointsOverviewVO | null>(null); const [overview, setOverview] = useState<MeetingPointsOverviewVO | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [transferLoading, setTransferLoading] = useState(false);
const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]); const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [detail, setDetail] = useState<MeetingPointsLedgerDetailVO | null>(null); const [detail, setDetail] = useState<MeetingPointsLedgerDetailVO | null>(null);
const [users, setUsers] = useState<SysUser[]>([]);
const [params, setParams] = useState({ const [params, setParams] = useState({
current: 1, current: 1,
size: 20, size: 20,
username: "", username: "",
pointsType: "", pointsType: "",
}); });
const [transferForm] = Form.useForm();
const loadOverview = async () => { const loadOverview = async () => {
const data = await getMeetingPointsOverview(); const data = await getMeetingPointsOverview();
setOverview(data); setOverview(data);
}; };
const loadUsers = async () => {
const data = await listUsers();
setUsers(data || []);
};
const loadPage = async (nextParams = params) => { const loadPage = async (nextParams = params) => {
setLoading(true); setLoading(true);
try { try {
@ -100,8 +124,7 @@ export default function MeetingPointsManagement() {
}; };
useEffect(() => { useEffect(() => {
void loadOverview(); void Promise.all([loadOverview(), loadPage(), loadUsers()]);
void loadPage();
}, []); }, []);
const handleSearch = () => { const handleSearch = () => {
@ -122,7 +145,7 @@ export default function MeetingPointsManagement() {
}; };
const handleRefresh = async () => { const handleRefresh = async () => {
await Promise.all([loadOverview(), loadPage()]); await Promise.all([loadOverview(), loadPage(), loadUsers()]);
message.success("已刷新积分数据"); message.success("已刷新积分数据");
}; };
@ -137,15 +160,36 @@ export default function MeetingPointsManagement() {
} }
}; };
const handleTransferSubmit = async () => {
const values = await transferForm.validateFields();
setTransferLoading(true);
try {
await transferMeetingPoints(values);
message.success("积分分配成功");
setTransferOpen(false);
transferForm.resetFields();
await Promise.all([loadOverview(), loadPage()]);
} finally {
setTransferLoading(false);
}
};
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
title: "用户名", title: "用户",
dataIndex: "ownerUserName", dataIndex: "ownerUserName",
key: "ownerUserName", key: "ownerUserName",
width: 140, width: 140,
render: (value: string) => <Text strong>{value || "-"}</Text>, render: (value: string) => <Text strong>{value || "-"}</Text>,
}, },
{
title: "扣费账户",
dataIndex: "chargeAccountType",
key: "chargeAccountType",
width: 120,
render: (value: string) => <Tag>{getAccountTypeLabel(value)}</Tag>,
},
{ {
title: "消耗类型", title: "消耗类型",
dataIndex: "pointsType", dataIndex: "pointsType",
@ -196,13 +240,55 @@ export default function MeetingPointsManagement() {
[], [],
); );
const chargeItemColumns = useMemo(
() => [
{
title: "阶段",
dataIndex: "chargeStage",
key: "chargeStage",
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "扣费账户",
dataIndex: "accountType",
key: "accountType",
render: (value: string) => getAccountTypeLabel(value),
},
{
title: "账户用户ID",
dataIndex: "accountUserId",
key: "accountUserId",
},
{
title: "扣费积分",
dataIndex: "chargedPoints",
key: "chargedPoints",
},
{
title: "扣费前余额",
dataIndex: "balanceBefore",
key: "balanceBefore",
},
{
title: "扣费后余额",
dataIndex: "balanceAfter",
key: "balanceAfter",
},
],
[],
);
return ( return (
<PageContainer <PageContainer
title="积分管理" title="积分管理"
subtitle="当前页面展示余额与消耗流水" subtitle="查看公共账户、个人账户和总结扣费流水"
headerExtra={ headerExtra={
<Space> <Space>
{/*<Tag color="processing">当前结算模式:{getAccountModeLabel(overview?.accountMode)}</Tag>*/} <Tag color="processing">{getAccountModeLabel(overview?.accountMode)}</Tag>
<Tag color="blue">{getChargePriorityLabel(overview?.chargePriority)}</Tag>
<Button icon={<PlusOutlined />} onClick={() => setTransferOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}> <Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button> </Button>
@ -231,21 +317,20 @@ export default function MeetingPointsManagement() {
</Space> </Space>
} }
> >
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}> <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}> <Col xs={24} md={6}>
<Card> <Card>
<Statistic title="剩余总积分" value={overview?.publicBalance ?? 0} /> <Statistic title="当前模式可用总积分" value={overview?.totalAvailableBalance ?? 0} />
</Card> </Card>
</Col> </Col>
<Col xs={24} md={6}> <Col xs={24} md={6}>
<Card> <Card>
<Statistic title="累计消耗总积分" value={overview?.publicTotalPointsUsed ?? 0} /> <Statistic title="公共账户余额" value={overview?.publicBalance ?? 0} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="个人账户余额汇总" value={overview?.personalBalance ?? 0} />
</Card> </Card>
</Col> </Col>
<Col xs={24} md={6}> <Col xs={24} md={6}>
@ -255,15 +340,32 @@ export default function MeetingPointsManagement() {
</Col> </Col>
</Row> </Row>
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, display: "flex", flexDirection: "column", minHeight: 0 } }}> <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={12}>
<Card>
<Statistic title="公共账户累计消耗积分" value={overview?.publicTotalPointsUsed ?? 0} />
</Card>
</Col>
<Col xs={24} md={12}>
<Card>
<Statistic title="个人账户累计消耗积分汇总" value={overview?.personalTotalPointsUsed ?? 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" }}> <div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<ListTable <ListTable
rowKey="id" rowKey="id"
columns={columns as any} columns={columns as never}
dataSource={records} dataSource={records}
loading={loading} loading={loading}
totalCount={total} totalCount={total}
scroll={{ y: "calc(100vh - 430px)", x: 1100 }} scroll={{ y: "calc(100vh - 470px)", x: 1200 }}
pagination={false} pagination={false}
/> />
</div> </div>
@ -280,7 +382,7 @@ export default function MeetingPointsManagement() {
</Card> </Card>
<Modal <Modal
title="消耗详情" title="积分流水详情"
open={detailOpen} open={detailOpen}
onCancel={() => { onCancel={() => {
setDetailOpen(false); setDetailOpen(false);
@ -297,36 +399,72 @@ export default function MeetingPointsManagement() {
</Button>, </Button>,
]} ]}
width={720} width={900}
confirmLoading={detailLoading} confirmLoading={detailLoading}
> >
{detail && ( {detail && (
<Descriptions bordered column={1} size="small"> <Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Descriptions.Item label="用户名">{detail.ownerUserName || "-"}</Descriptions.Item> <Descriptions bordered column={2} size="small">
{/*<Descriptions.Item label="扣费账户">{getAccountModeLabel(detail.chargeAccountType)}</Descriptions.Item>*/} <Descriptions.Item label="用户">{detail.ownerUserName || "-"}</Descriptions.Item>
<Descriptions.Item label="消耗类型">{getPointsTypeLabel(detail.pointsType)}</Descriptions.Item> <Descriptions.Item label="当前流水账户">{getAccountTypeLabel(detail.chargeAccountType)}</Descriptions.Item>
<Descriptions.Item label="消耗积分">{detail.consumedPoints ?? 0}</Descriptions.Item> <Descriptions.Item label="消耗类型">{getPointsTypeLabel(detail.pointsType)}</Descriptions.Item>
<Descriptions.Item label="消耗时间">{formatDateTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="消耗积分">{detail.consumedPoints ?? 0}</Descriptions.Item>
<Descriptions.Item label="会议标题">{detail.meetingTitle || "-"}</Descriptions.Item> <Descriptions.Item label="消耗时间">{formatDateTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="触发类型">{getChargeTriggerLabel(detail.chargeTriggerType)}</Descriptions.Item> <Descriptions.Item label="会议标题">{detail.meetingTitle || "-"}</Descriptions.Item>
<Descriptions.Item label="录音时长(秒)">{detail.audioDurationSeconds ?? "-"}</Descriptions.Item> <Descriptions.Item label="触发类型">{getChargeTriggerLabel(detail.chargeTriggerType)}</Descriptions.Item>
<Descriptions.Item label="计费分钟数">{detail.chargedMinutes ?? "-"}</Descriptions.Item> <Descriptions.Item label="录音时长(秒)">{detail.audioDurationSeconds ?? "-"}</Descriptions.Item>
{/*<Descriptions.Item label="计费单位数">{detail.billingUnits ?? "-"}</Descriptions.Item>*/} <Descriptions.Item label="计费分钟数">{detail.chargedMinutes ?? "-"}</Descriptions.Item>
{/*<Descriptions.Item label="单位分钟数">{detail.unitMinutesSnapshot ?? "-"}</Descriptions.Item>*/} <Descriptions.Item label="应计总积分">{detail.totalPoints ?? 0}</Descriptions.Item>
{/*<Descriptions.Item label="单位价格">{detail.costPerUnitSnapshot ?? "-"}</Descriptions.Item>*/} <Descriptions.Item label="已扣总积分">{detail.chargedTotalPoints ?? 0}</Descriptions.Item>
{/*<Descriptions.Item label="应计总积分">{detail.totalPoints ?? 0}</Descriptions.Item>*/} <Descriptions.Item label="扣费前余额">{detail.balanceBefore ?? "-"}</Descriptions.Item>
{/*<Descriptions.Item label="已扣总积分">{detail.chargedTotalPoints ?? 0}</Descriptions.Item>*/} <Descriptions.Item label="扣费后余额">{detail.balanceAfter ?? "-"}</Descriptions.Item>
{/*<Descriptions.Item label="应计 ASR 积分">{detail.asrPoints ?? 0}</Descriptions.Item>*/} </Descriptions>
{/*<Descriptions.Item label="已扣 ASR 积分">{detail.chargedAsrPoints ?? 0}</Descriptions.Item>*/}
{/*<Descriptions.Item label="应计 LLM 积分">{detail.llmPoints ?? 0}</Descriptions.Item>*/} <Card title="本次总结扣费明细" size="small">
{/*<Descriptions.Item label="已扣 LLM 积分">{detail.chargedLlmPoints ?? 0}</Descriptions.Item>*/} <Table<MeetingPointsChargeItemVO>
<Descriptions.Item label="扣费前余额">{detail.balanceBefore ?? "-"}</Descriptions.Item> rowKey="id"
<Descriptions.Item label="扣费后余额">{detail.balanceAfter ?? "-"}</Descriptions.Item> size="small"
{/*<Descriptions.Item label="记录状态">{detail.summaryStatus || "-"}</Descriptions.Item>*/} pagination={false}
{/*<Descriptions.Item label="失败原因">{detail.failureReason || "-"}</Descriptions.Item>*/} columns={chargeItemColumns as never}
</Descriptions> dataSource={detail.chargeItems || []}
/>
</Card>
</Space>
)} )}
</Modal> </Modal>
<Modal
title="从公共账户分配积分"
open={transferOpen}
onCancel={() => {
setTransferOpen(false);
transferForm.resetFields();
}}
onOk={() => void handleTransferSubmit()}
confirmLoading={transferLoading}
>
<Form form={transferForm} layout="vertical">
<Form.Item name="targetUserId" label="目标用户" rules={[{ required: true, message: "请选择目标用户" }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择用户"
options={users
.filter((user) => user.userId && user.userId > 0)
.map((user) => ({
label: `${user.displayName || user.username} (#${user.userId})`,
value: user.userId,
}))}
/>
</Form.Item>
<Form.Item name="points" label="分配积分" rules={[{ required: true, message: "请输入分配积分" }]}>
<InputNumber min={1} precision={0} style={{ width: "100%" }} placeholder="请输入正整数积分" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,默认记为管理员从公共账户分配积分" maxLength={200} />
</Form.Item>
</Form>
</Modal>
</PageContainer> </PageContainer>
); );
} }