diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index 5198604..17b122a 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -385,19 +385,7 @@ - 类型:`INTEGER` - 说明:会议最终有效录音时长(秒),作为会议统计与积分计费统一口径。 -### 6.2 `biz_meeting_user_stats` -- 用途:按用户维护会议时长统计与总结触发统计账户。 -- 关键字段: - - `tenant_id` - - `user_id` - - `total_meeting_duration_seconds` - - `total_meeting_duration_minutes` - - `total_summary_charge_count` - - `total_summary_attempt_count` -- 关键索引: - - `uk_biz_meeting_user_stats_tenant_user` - -### 6.3 `biz_meeting_points_accounts` +### 6.2 `biz_meeting_points_accounts` - 用途:当前版本按租户维护统一积分余额与累计消耗。 - 关键字段: - `tenant_id` @@ -411,7 +399,7 @@ - 关键索引: - `uk_biz_meeting_points_accounts_tenant_user` -### 6.4 `biz_meeting_summary_charge_records` +### 6.3 `biz_meeting_summary_charge_records` - 用途:每次 SUMMARY 任务保留一条计费快照记录,并按 ASR / LLM 成功节点累计实际扣费。 - 关键字段: - `meeting_id` diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index 3482b50..758ef17 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -512,26 +512,6 @@ ALTER TABLE biz_meetings COMMENT ON COLUMN biz_meetings.effective_audio_duration_seconds IS '会议最终有效录音时长(秒),用于统计与计费口径'; -DROP TABLE IF EXISTS biz_meeting_user_stats CASCADE; -CREATE TABLE biz_meeting_user_stats ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL DEFAULT 0, - status INTEGER NOT NULL DEFAULT 1, - user_id BIGINT NOT NULL, - total_meeting_duration_seconds BIGINT NOT NULL DEFAULT 0, - total_meeting_duration_minutes BIGINT NOT NULL DEFAULT 0, - total_summary_charge_count BIGINT NOT NULL DEFAULT 0, - total_summary_attempt_count BIGINT NOT NULL DEFAULT 0, - is_deleted SMALLINT NOT NULL DEFAULT 0, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE UNIQUE INDEX uk_biz_meeting_user_stats_tenant_user - ON biz_meeting_user_stats (tenant_id, user_id); - -COMMENT ON TABLE biz_meeting_user_stats IS '会议用户时长统计账户表'; - DROP TABLE IF EXISTS biz_meeting_points_accounts CASCADE; CREATE TABLE biz_meeting_points_accounts ( id BIGSERIAL PRIMARY KEY, diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index e726bf4..784ac2e 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -18,7 +18,7 @@ public final class SysParamKeys { public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled"; /** ASR 任务最大并发数。 */ 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_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"; /** 积分拆分时分配给 LLM 的比例。 */ 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 / PERSONAL / BOTH。 */ 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"; /** 临时授权默认有效期,单位月。 */ diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java index 768a441..3ad8743 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java @@ -61,13 +61,6 @@ public class AndroidAuthController { androidDeviceRegistrationService.requireRegistered(deviceId.trim()); TokenResponse response = authService.login(request, true); 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( deviceId.trim(), response.getCurrentTenantId(), diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index 1e17951..2bbe7c5 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -378,17 +378,21 @@ public class AndroidMeetingController { public ApiResponse config(HttpServletRequest request) { AndroidRequestLogHelper.logRequest(log, "Android会议", "获取会议配置接口"); 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(); PageResult> promptTemplateList = promptTemplateService.pageTemplates( 1, 1000, null, null, - loginUser.getTenantId(), - loginUser.getUserId(), - loginUser.getIsPlatformAdmin(), - loginUser.getIsTenantAdmin() + tenantId, + userId, + isPlatformAdmin, + isTenantAdmin ); List enabledTemplates = promptTemplateList.getRecords() == null ? List.of() @@ -396,7 +400,7 @@ public class AndroidMeetingController { .filter(item -> Integer.valueOf(1).equals(item.getStatus())) .toList(); resultVo.setTemplateList(enabledTemplates); - PageResult> modelList = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId()); + PageResult> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId); List enabledModels = modelList.getRecords() == null ? List.of() : modelList.getRecords().stream() diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingPointsController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingPointsController.java index 280cc97..a08b572 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingPointsController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingPointsController.java @@ -1,6 +1,7 @@ package com.imeeting.controller.biz; import com.imeeting.dto.biz.MeetingPointsBalanceVO; +import com.imeeting.dto.biz.MeetingPointsTransferRequest; import com.imeeting.service.biz.MeetingPointsService; import com.unisbase.common.ApiResponse; 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.core.context.SecurityContextHolder; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -32,15 +35,37 @@ public class MeetingPointsController { return ApiResponse.ok(meetingPointsService.getBalanceView(loginUser.getTenantId(), targetUserId)); } + @Operation(summary = "从公共账户分配积分给个人账户") + @PostMapping("/transfer") + @PreAuthorize("isAuthenticated()") + public ApiResponse 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) { if (requestedUserId == null || requestedUserId.equals(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()); if (!isAdmin) { - throw new RuntimeException("无权查看其他用户积分余额"); + throw new RuntimeException("无权限执行该操作"); } - return requestedUserId; } private LoginUser currentLoginUser() { diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java index 840030c..a7fdf2c 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java @@ -12,8 +12,11 @@ public class MeetingPointsBalanceVO { @Schema(description = "目标用户ID") private Long userId; - @Schema(description = "当前优先扣费账户类型:PUBLIC / PERSONAL") - private String preferredAccountMode; + @Schema(description = "当前扣费模式:PUBLIC / PERSONAL / BOTH") + private String accountMode; + + @Schema(description = "当前扣费优先级:PERSONAL_FIRST / PUBLIC_FIRST") + private String chargePriority; @Schema(description = "公共账户余额") private Long publicBalance; @@ -26,4 +29,7 @@ public class MeetingPointsBalanceVO { @Schema(description = "个人账户累计消耗积分") private Long personalTotalPointsUsed; + + @Schema(description = "当前模式下可用总积分") + private Long totalAvailableBalance; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerDetailVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerDetailVO.java index f97118e..c7323e9 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerDetailVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerDetailVO.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Data @Schema(description = "积分流水详情") @@ -26,13 +27,13 @@ public class MeetingPointsLedgerDetailVO { @Schema(description = "消耗归属用户名") private String ownerUserName; - @Schema(description = "账户类型:PUBLIC / PERSONAL") + @Schema(description = "当前流水账户类型:PUBLIC / PERSONAL") private String chargeAccountType; - @Schema(description = "账户用户ID") + @Schema(description = "当前流水账户用户ID") private Long chargeAccountUserId; - @Schema(description = "消耗类型") + @Schema(description = "积分类型") private String pointsType; @Schema(description = "消耗积分,展示为正数") @@ -100,4 +101,7 @@ public class MeetingPointsLedgerDetailVO { @Schema(description = "记录创建时间") private LocalDateTime createdAt; + + @Schema(description = "本次总结的扣费分摊明细") + private List chargeItems; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerListItemVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerListItemVO.java index f74c2b9..e3d5727 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerListItemVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsLedgerListItemVO.java @@ -32,7 +32,7 @@ public class MeetingPointsLedgerListItemVO { @Schema(description = "账户类型:PUBLIC / PERSONAL") private String chargeAccountType; - @Schema(description = "消耗类型:ASR / LLM / INIT / RECHARGE") + @Schema(description = "消耗类型:ASR / LLM") private String pointsType; @Schema(description = "消耗积分,展示为正数") diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java index 71aa112..69decac 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java @@ -6,15 +6,27 @@ import lombok.Data; @Data @Schema(description = "积分管理总览视图") public class MeetingPointsOverviewVO { - @Schema(description = "当前结算模式:PUBLIC / PERSONAL") + @Schema(description = "当前扣费模式:PUBLIC / PERSONAL / BOTH") private String accountMode; + @Schema(description = "当前扣费优先级:PERSONAL_FIRST / PUBLIC_FIRST") + private String chargePriority; + @Schema(description = "公共账户余额") private Long publicBalance; @Schema(description = "公共账户累计消耗积分") private Long publicTotalPointsUsed; + @Schema(description = "个人账户余额汇总") + private Long personalBalance; + + @Schema(description = "个人账户累计消耗积分汇总") + private Long personalTotalPointsUsed; + + @Schema(description = "当前模式下可用总积分") + private Long totalAvailableBalance; + @Schema(description = "累计消耗次数") private Long totalChargeCount; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingUserStats.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingUserStats.java deleted file mode 100644 index 534859a..0000000 --- a/backend/src/main/java/com/imeeting/entity/biz/MeetingUserStats.java +++ /dev/null @@ -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; -} diff --git a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java index 3b66704..b916dcd 100644 --- a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java +++ b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java @@ -114,14 +114,16 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase AndroidAuthContext authContext = androidAuthService.authenticateGrpc( request.getDeviceId(), request.getAppVersion(), - resolvePlatform(request.getPlatform()) + resolvePlatform(request.getPlatform()), + request.getUserId(), + request.getTenantId() ); + deviceOnlineManagementService.recordConnected(authContext); AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId()); connectionId = sessionState.getConnectionId(); deviceId = sessionState.getDeviceId(); appVersion = authContext.getAppVersion(); platform = authContext.getPlatform(); - deviceOnlineManagementService.recordConnected(authContext); connected = true; String replacedConnectionId = androidGatewayPushService.register( connectionId, diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingUserStatsMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingUserStatsMapper.java deleted file mode 100644 index b4770d0..0000000 --- a/backend/src/main/java/com/imeeting/mapper/biz/MeetingUserStatsMapper.java +++ /dev/null @@ -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 { -} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java b/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java index 6f719d5..31d41e9 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java @@ -4,7 +4,7 @@ import com.imeeting.dto.android.AndroidAuthContext; import jakarta.servlet.http.HttpServletRequest; 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); diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java index 1b51f4f..7c6482d 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java @@ -25,259 +25,282 @@ import org.springframework.util.StringUtils; @Slf4j public class AndroidAuthServiceImpl implements AndroidAuthService { - 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_VERSION = "X-Android-App-Version"; - private static final String HEADER_PLATFORM = "X-Android-Platform"; - private static final String HEADER_AUTHORIZATION = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; + 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_VERSION = "X-Android-App-Version"; + private static final String HEADER_PLATFORM = "X-Android-Platform"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; - private final AndroidGrpcAuthProperties properties; - private final TokenValidationService tokenValidationService; - private final DeviceInfoMapper deviceInfoMapper; - private final AndroidDeviceBindingService androidDeviceBindingService; - private final LicenseService licenseService; + private final AndroidGrpcAuthProperties properties; + private final TokenValidationService tokenValidationService; + private final DeviceInfoMapper deviceInfoMapper; + private final AndroidDeviceBindingService androidDeviceBindingService; + private final LicenseService licenseService; - @Override - public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { - if (properties.isEnabled() && !properties.isAllowAnonymous()) { - throw new RuntimeException("Android gRPC push does not allow anonymous access"); - } - LicenseEntity license = licenseService.requireValidBoundLicense(deviceId); - DeviceInfoEntity device = requireRegisteredDevice(deviceId); - assertDeviceEnabled(device); - AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); - context.setUserId(device.getUserId()); - context.setTenantId(license.getTenantId()); - return context; + @Override + public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform, String userId, String tenantId) { + if (properties.isEnabled() && !properties.isAllowAnonymous()) { + throw new RuntimeException("Android gRPC push does not allow anonymous access"); + } + LicenseEntity license = licenseService.requireValidBoundLicense(deviceId); + DeviceInfoEntity device = requireRegisteredDevice(deviceId); + assertDeviceEnabled(device); + Long requestedUserId = parseOptionalLong(userId, "Android gRPC userId"); + Long requestedTenantId = parseOptionalLong(tenantId, "Android gRPC tenantId"); + boolean anonymous = requestedUserId == null; + 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 - public AndroidAuthContext authenticateHttp(HttpServletRequest request) { - return authenticateHttp(request, true, false); + 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); } - @Override - public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) { - return authenticateHttp(request, requireRegistered, false); + 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"); + } - @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); - } - - 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"); + private AndroidAuthContext applyLicenseContext(AndroidAuthContext context, LicenseEntity license, boolean allowOptionalToken) { + if (context == null) { + return null; } - - private AndroidAuthContext applyLicenseContext(AndroidAuthContext context, LicenseEntity license, boolean allowOptionalToken) { - if (context == null) { - return null; - } - if (license == null) { - return context; - } - Long currentTenantId = context.getTenantId(); - context.setTenantId(license.getTenantId()); - if (allowOptionalToken && context.getUserId() != null && currentTenantId != null && !currentTenantId.equals(license.getTenantId())) { - context.setAnonymous(true); - context.setAuthMode("NONE"); - context.setUserId(null); - context.setUsername(null); - context.setDisplayName(null); - context.setPlatformAdmin(null); - context.setTenantAdmin(null); - context.setPermissions(null); - } - return context; + if (license == null) { + return context; } - - private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, - String appId, String appVersion, String platform, String accessToken, - String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { - String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId; - if (!StringUtils.hasText(resolvedDeviceId)) { - throw new RuntimeException("Missing Android deviceId"); - } - 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; + Long currentTenantId = context.getTenantId(); + context.setTenantId(license.getTenantId()); + if (allowOptionalToken && context.getUserId() != null && currentTenantId != null && !currentTenantId.equals(license.getTenantId())) { + context.setAnonymous(true); + context.setAuthMode("NONE"); + context.setUserId(null); + context.setUsername(null); + context.setDisplayName(null); + context.setPlatformAdmin(null); + context.setTenantAdmin(null); + context.setPermissions(null); } + return context; + } - private void applyIdentity(AndroidAuthContext context, InternalAuthCheckResponse authResult, LoginUser loginUser) { - if (loginUser != null) { - context.setUserId(loginUser.getUserId()); - context.setTenantId(loginUser.getTenantId()); - context.setUsername(loginUser.getUsername()); - context.setDisplayName(loginUser.getDisplayName()); - 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()); + private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, + String appId, String appVersion, String platform, String accessToken, + String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { + String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId; + if (!StringUtils.hasText(resolvedDeviceId)) { + throw new RuntimeException("Missing Android deviceId"); } + 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) { - String resolvedToken = normalizeToken(token); - if (!StringUtils.hasText(resolvedToken)) { - throw new RuntimeException("Missing Android access token"); - } - 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 void applyIdentity(AndroidAuthContext context, InternalAuthCheckResponse authResult, LoginUser loginUser) { + if (loginUser != null) { + context.setUserId(loginUser.getUserId()); + context.setTenantId(loginUser.getTenantId()); + context.setUsername(loginUser.getUsername()); + context.setDisplayName(loginUser.getDisplayName()); + 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()); + } - private String resolveHttpToken(HttpServletRequest request) { - String authorization = request.getHeader(HEADER_AUTHORIZATION); - if (!StringUtils.hasText(authorization)) { - return null; - } - if (!authorization.startsWith(BEARER_PREFIX)) { - throw new RuntimeException("Android HTTP access token format is invalid"); - } - return authorization.substring(BEARER_PREFIX.length()).trim(); + private InternalAuthCheckResponse validateToken(String token) { + String resolvedToken = normalizeToken(token); + if (!StringUtils.hasText(resolvedToken)) { + throw new RuntimeException("Missing Android access token"); } + 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) { - for (String name : names) { - String value = request.getHeader(name); - if (StringUtils.hasText(value)) { - return value.trim(); - } - } - return null; + private Long parseOptionalLong(String value, String fieldName) { + if (!StringUtils.hasText(value)) { + 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) { - if (!StringUtils.hasText(deviceId)) { - throw new RuntimeException("Missing Android device_id"); - } - 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 String resolveHttpToken(HttpServletRequest request) { + String authorization = request.getHeader(HEADER_AUTHORIZATION); + if (!StringUtils.hasText(authorization)) { + return null; } + 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) { - if (device != null && device.getStatus() != null && device.getStatus() == 0) { - throw new BusinessException("403", "设备被禁用"); - } + private String firstHeader(HttpServletRequest request, String... names) { + for (String name : names) { + String value = request.getHeader(name); + if (StringUtils.hasText(value)) { + return value.trim(); + } } + return null; + } - private DeviceInfoEntity requireRegisteredDevice(String deviceId) { - DeviceInfoEntity device = findDevice(deviceId); - if (device == null) { - throw new RuntimeException("设备未注册,请先调用设备注册接口"); - } - return device; + private void requireAndroidHttpHeaders(String deviceId, String appVersion, String platform) { + if (!StringUtils.hasText(deviceId)) { + throw new RuntimeException("Missing Android device_id"); } + 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) { - if (!StringUtils.hasText(deviceId)) { - return null; - } - return deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId.trim()); + private void assertDeviceEnabled(DeviceInfoEntity device) { + if (device != null && device.getStatus() != null && device.getStatus() == 0) { + throw new BusinessException("403", "设备被禁用"); } + } - 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 DeviceInfoEntity requireRegisteredDevice(String deviceId) { + DeviceInfoEntity device = findDevice(deviceId); + if (device == null) { + throw new RuntimeException("设备未注册,请先调用设备注册接口"); } + return device; + } - 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; + private DeviceInfoEntity findDevice(String deviceId) { + if (!StringUtils.hasText(deviceId)) { + return null; } + 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; + } } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java index ff54a8e..b5bcc5b 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java @@ -1,9 +1,9 @@ package com.imeeting.service.android.impl; -import com.imeeting.mapper.DeviceInfoMapper; -import com.imeeting.mapper.DeviceLoginLogMapper; import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceLoginLogEntity; +import com.imeeting.mapper.DeviceInfoMapper; +import com.imeeting.mapper.DeviceLoginLogMapper; import com.imeeting.service.android.AndroidDeviceBindingService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -42,11 +42,8 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ throw new RuntimeException("设备登录态无效"); } DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); - if (existing == null || existing.getUserId() == null || existing.getTenantId() == null) { - throw new RuntimeException("设备未登录,请先完成设备登录"); - } - if (!Objects.equals(existing.getUserId(), userId) || !Objects.equals(existing.getTenantId(), tenantId)) { - throw new RuntimeException("当前设备已被其他用户占用,请使用当前登录用户或重新登录占用设备"); + if (existing == null) { + throw new RuntimeException("设备未注册"); } } @@ -60,7 +57,7 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ return; } existing.setUserId(null); - existing.setTenantId(null); +// existing.setTenantId(null); deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java index 502d638..485dee5 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceHomeServiceImpl.java @@ -103,10 +103,7 @@ public class AndroidDeviceHomeServiceImpl implements AndroidDeviceHomeService { return 0L; } MeetingPointsBalanceVO balance = meetingPointsService.getBalanceView(tenantId, anonymous ? null : userId); - long totalPoints = defaultLong(balance.getPublicBalance()); - if (!anonymous && userId != null) { - totalPoints += defaultLong(balance.getPersonalBalance()); - } + long totalPoints = defaultLong(balance.getTotalAvailableBalance()); int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1); int costPerUnit = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_COST_PER_UNIT, "1"), 1); return costPerUnit <= 0 ? 0L : (totalPoints * unitMinutes) / costPerUnit; diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java index 6f2a9be..40ad0a5 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingPointsService.java @@ -1,12 +1,16 @@ package com.imeeting.service.biz; +import com.imeeting.dto.biz.MeetingPointsBalanceVO; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; -import com.imeeting.dto.biz.MeetingPointsBalanceVO; public interface MeetingPointsService { long UNIFIED_ACCOUNT_USER_ID = 0L; + void initializeTenantPointsAccount(Long tenantId); + + void transferPublicPointsToUser(Long tenantId, Long targetUserId, Long points, String remark); + void recordAsrSuccessCharge(Meeting meeting, AiTask asrTask); void recordSummarySuccessCharge(Meeting meeting, AiTask summaryTask); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingUserStatsService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingUserStatsService.java deleted file mode 100644 index 3edd23f..0000000 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingUserStatsService.java +++ /dev/null @@ -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 { -} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java index 5c18d61..613da76 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/DeviceOnlineManagementServiceImpl.java @@ -6,6 +6,7 @@ import com.imeeting.dto.biz.DeviceAdminUpdateCommand; import com.imeeting.dto.biz.DeviceOnlineAdminVO; import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.mapper.DeviceInfoMapper; +import com.imeeting.service.android.AndroidDeviceBindingService; import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.biz.DeviceOnlineManagementService; @@ -28,6 +29,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement private final DeviceInfoMapper deviceInfoMapper; private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidGatewayPushService androidGatewayPushService; + private final AndroidDeviceBindingService androidDeviceBindingService; private final LicenseService licenseService; @Override @@ -40,13 +42,28 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement if (existing == null) { 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(); existing.setTerminalType(normalizeTerminalType(authContext.getPlatform())); existing.setTerminalVersion(normalize(authContext.getAppVersion())); existing.setLastOnlineAt(now); existing.setUserId(authContext.getUserId()); existing.setTenantId(authContext.getTenantId()); - deviceInfoMapper.updateById(existing); + deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); } @Override diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java index 263c2c5..d89e035 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java @@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.imeeting.common.SysParamKeys; +import com.imeeting.dto.biz.MeetingPointsChargeItemVO; import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO; import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO; import com.imeeting.dto.biz.MeetingPointsOverviewVO; @@ -37,7 +38,13 @@ import java.util.stream.Collectors; @Service public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService { 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 String POINTS_TYPE_ASR = "ASR"; + private static final String POINTS_TYPE_LLM = "LLM"; private final MeetingPointsAccountService meetingPointsAccountService; private final MeetingPointsLedgerService meetingPointsLedgerService; @@ -65,33 +72,43 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService @Override public MeetingPointsOverviewVO getOverview(Long tenantId) { - List scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId); - MeetingPointsAccount publicAccount = meetingPointsAccountService.getOne(new LambdaQueryWrapper() - .eq(MeetingPointsAccount::getTenantId, tenantId) - .eq(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID) - .last("LIMIT 1")); + String accountMode = resolveAccountMode(); + String chargePriority = resolveChargePriority(); + MeetingPointsAccount publicAccount = findAccount(tenantId, PUBLIC_ACCOUNT_USER_ID); + long publicBalance = publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance()); + long publicTotalUsed = publicAccount == null ? 0L : defaultLong(publicAccount.getTotalPointsUsed()); + long personalBalance = 0L; + long personalTotalUsed = 0L; + List personalAccounts = meetingPointsAccountService.list(new LambdaQueryWrapper() + .eq(MeetingPointsAccount::getTenantId, tenantId) + .ne(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID)); + for (MeetingPointsAccount account : personalAccounts) { + personalBalance += defaultLong(account.getCurrentBalance()); + personalTotalUsed += defaultLong(account.getTotalPointsUsed()); + } + + List scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId); long totalChargeCount = 0L; - long publicTotalPointsUsed = 0L; - List scopedChargeRecordIds = resolveChargeRecordIdsByOwners(tenantId, scopedOwnerUserIds); - if (scopedChargeRecordIds == null || !scopedChargeRecordIds.isEmpty()) { - LambdaQueryWrapper scopedLedgerWrapper = new LambdaQueryWrapper() - .eq(MeetingPointsLedger::getTenantId, tenantId) - .lt(MeetingPointsLedger::getPointsDelta, 0) - .in(scopedChargeRecordIds != null, MeetingPointsLedger::getChargeRecordId, scopedChargeRecordIds); - totalChargeCount = meetingPointsLedgerService.count(scopedLedgerWrapper); - publicTotalPointsUsed = meetingPointsLedgerService.list(scopedLedgerWrapper) - .stream() - .map(MeetingPointsLedger::getPointsDelta) - .filter(Objects::nonNull) - .mapToLong(value -> Math.abs(value)) - .sum(); + if (scopedOwnerUserIds == null) { + totalChargeCount = meetingSummaryChargeRecordService.count(new LambdaQueryWrapper() + .eq(MeetingSummaryChargeRecord::getTenantId, tenantId) + .gt(MeetingSummaryChargeRecord::getChargedTotalPoints, 0L)); + } else if (!scopedOwnerUserIds.isEmpty()) { + totalChargeCount = meetingSummaryChargeRecordService.count(new LambdaQueryWrapper() + .eq(MeetingSummaryChargeRecord::getTenantId, tenantId) + .in(MeetingSummaryChargeRecord::getUserId, scopedOwnerUserIds) + .gt(MeetingSummaryChargeRecord::getChargedTotalPoints, 0L)); } MeetingPointsOverviewVO vo = new MeetingPointsOverviewVO(); - vo.setAccountMode(resolveAccountMode()); - vo.setPublicBalance(publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance())); - vo.setPublicTotalPointsUsed(publicTotalPointsUsed); + vo.setAccountMode(accountMode); + vo.setChargePriority(chargePriority); + vo.setPublicBalance(publicBalance); + vo.setPublicTotalPointsUsed(publicTotalUsed); + vo.setPersonalBalance(personalBalance); + vo.setPersonalTotalPointsUsed(personalTotalUsed); + vo.setTotalAvailableBalance(resolveVisibleTotalBalance(accountMode, publicBalance, personalBalance)); vo.setTotalChargeCount(totalChargeCount); return vo; } @@ -121,6 +138,7 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(MeetingPointsLedger::getTenantId, tenantId) .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)) .in(filteredChargeRecordIds != null && !filteredChargeRecordIds.isEmpty(), MeetingPointsLedger::getChargeRecordId, filteredChargeRecordIds) .orderByDesc(MeetingPointsLedger::getCreatedAt) @@ -150,7 +168,7 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService item.setSummaryTaskId(ledger.getSummaryTaskId()); item.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId()); item.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId())); - item.setChargeAccountType(chargeRecord == null ? null : chargeRecord.getChargeAccountType()); + item.setChargeAccountType(resolveAccountTypeByLedger(ledger.getUserId())); item.setPointsType(ledger.getPointsType()); item.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta()))); item.setBalanceBefore(ledger.getBalanceBefore()); @@ -185,8 +203,8 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService detail.setSummaryTaskId(ledger.getSummaryTaskId()); detail.setOwnerUserId(chargeRecord == null ? null : chargeRecord.getUserId()); detail.setOwnerUserName(resolveOwnerName(owner, chargeRecord == null ? null : chargeRecord.getUserId())); - detail.setChargeAccountType(chargeRecord == null ? null : chargeRecord.getChargeAccountType()); - detail.setChargeAccountUserId(chargeRecord == null ? null : chargeRecord.getChargeAccountUserId()); + detail.setChargeAccountType(resolveAccountTypeByLedger(ledger.getUserId())); + detail.setChargeAccountUserId(ledger.getUserId()); detail.setPointsType(ledger.getPointsType()); detail.setConsumedPoints(Math.abs(defaultLong(ledger.getPointsDelta()))); detail.setBalanceBefore(ledger.getBalanceBefore()); @@ -210,9 +228,46 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService detail.setAsrChargedAt(chargeRecord == null ? null : chargeRecord.getAsrChargedAt()); detail.setLlmChargedAt(chargeRecord == null ? null : chargeRecord.getLlmChargedAt()); detail.setCreatedAt(ledger.getCreatedAt()); + detail.setChargeItems(buildChargeItems(tenantId, ledger.getChargeRecordId())); return detail; } + private MeetingPointsAccount findAccount(Long tenantId, Long userId) { + if (tenantId == null || userId == null) { + return null; + } + return meetingPointsAccountService.getOne(new LambdaQueryWrapper() + .eq(MeetingPointsAccount::getTenantId, tenantId) + .eq(MeetingPointsAccount::getUserId, userId) + .last("LIMIT 1")); + } + + private List buildChargeItems(Long tenantId, Long chargeRecordId) { + if (chargeRecordId == null) { + return Collections.emptyList(); + } + List ledgers = meetingPointsLedgerService.list(new LambdaQueryWrapper() + .eq(MeetingPointsLedger::getTenantId, tenantId) + .eq(MeetingPointsLedger::getChargeRecordId, chargeRecordId) + .lt(MeetingPointsLedger::getPointsDelta, 0) + .orderByAsc(MeetingPointsLedger::getId)); + List 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 resolveMatchedOwnerIds(Long tenantId, String username, List scopedOwnerUserIds) { if (scopedOwnerUserIds != null && scopedOwnerUserIds.isEmpty()) { return Collections.emptyList(); @@ -337,7 +392,34 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService if (!StringUtils.hasText(configured)) { 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) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java index af482a9..eff4591 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java @@ -8,34 +8,27 @@ import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingPointsAccount; import com.imeeting.entity.biz.MeetingPointsLedger; import com.imeeting.entity.biz.MeetingSummaryChargeRecord; -import com.imeeting.entity.biz.MeetingTranscript; -import com.imeeting.entity.biz.MeetingUserStats; import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingPointsAccountMapper; import com.imeeting.mapper.biz.MeetingSummaryChargeRecordMapper; -import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.biz.MeetingPointsAccountService; import com.imeeting.service.biz.MeetingPointsLedgerService; import com.imeeting.service.biz.MeetingPointsService; import com.imeeting.service.biz.MeetingSummaryChargeRecordService; -import com.imeeting.service.biz.MeetingUserStatsService; import com.unisbase.service.SysParamService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import 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.RoundingMode; -import java.nio.file.Path; -import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Slf4j @Service @@ -45,25 +38,71 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { private static final String TRIGGER_RESUMMARY = "RESUMMARY"; private static final String ACCOUNT_MODE_PUBLIC = "PUBLIC"; private static final String ACCOUNT_MODE_PERSONAL = "PERSONAL"; + private static final String 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_ASR_CHARGED = "ASR_CHARGED"; private static final String STATUS_COMPLETED = "COMPLETED"; private static final String STATUS_FAILED = "FAILED"; private static final String STATUS_DISABLED = "DISABLED"; + private 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 MeetingPointsAccountService pointsAccountService; private final MeetingPointsLedgerService pointsLedgerService; - private final MeetingUserStatsService meetingUserStatsService; - private final MeetingTranscriptMapper transcriptMapper; private final MeetingMapper meetingMapper; private final AiTaskMapper aiTaskMapper; private final MeetingPointsAccountMapper meetingPointsAccountMapper; private final MeetingSummaryChargeRecordMapper meetingSummaryChargeRecordMapper; private final SysParamService sysParamService; - @Value("${unisbase.app.upload-path}") - private String uploadPath; + @Override + @Transactional(rollbackFor = Exception.class) + public void 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 @Transactional(rollbackFor = Exception.class) @@ -87,7 +126,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { ensureMeetingDurationStats(meeting, durationSeconds); MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds); - if (defaultLong(record.getChargedAsrPoints()) > 0) { + if (defaultLong(record.getChargedAsrPoints()) > 0L) { return; } if (!isPointsEnabled()) { @@ -102,26 +141,16 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { return; } - MeetingPointsAccount account = getOrCreateAccount(meeting.getTenantId(), record.getChargeAccountUserId()); - long balanceBefore = defaultLong(account.getCurrentBalance()); - long balanceAfter = balanceBefore - chargeAmount; - - account.setCurrentBalance(balanceAfter); - account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + chargeAmount); - account.setTotalAsrPointsUsed(defaultLong(account.getTotalAsrPointsUsed()) + chargeAmount); - pointsAccountService.updateById(account); - - record.setBalanceBefore(record.getBalanceBefore() == null ? balanceBefore : record.getBalanceBefore()); - record.setBalanceAfter(balanceAfter); - record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + chargeAmount); - record.setChargedAsrPoints(chargeAmount); + ChargeExecutionResult result = executeCharge(meeting, summaryTask, record, POINTS_TYPE_ASR, chargeAmount); + record.setBalanceBefore(record.getBalanceBefore() == null ? result.totalBalanceBefore() : record.getBalanceBefore()); + record.setBalanceAfter(result.totalBalanceAfter()); + record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + result.chargedPoints()); + record.setChargedAsrPoints(result.chargedPoints()); record.setAsrChargedAt(LocalDateTime.now()); record.setChargedAt(LocalDateTime.now()); record.setPointsDelta(-defaultLong(record.getChargedTotalPoints())); record.setSummaryStatus(STATUS_ASR_CHARGED); saveOrUpdateRecord(record); - - saveLedger(meeting, summaryTask, record, "ASR", -chargeAmount, balanceBefore, balanceAfter); } @Override @@ -139,20 +168,17 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { record.setSummaryStatus(STATUS_COMPLETED); record.setLlmChargedAt(LocalDateTime.now()); saveOrUpdateRecord(record); - incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId()); return; } ensureMeetingDurationStats(meeting, durationSeconds); - if (defaultLong(record.getChargedLlmPoints()) > 0) { + if (defaultLong(record.getChargedLlmPoints()) > 0L) { return; } - if (!isPointsEnabled()) { record.setSummaryStatus(STATUS_DISABLED); record.setLlmChargedAt(LocalDateTime.now()); saveOrUpdateRecord(record); - incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId()); return; } @@ -161,33 +187,21 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { record.setSummaryStatus(STATUS_COMPLETED); record.setLlmChargedAt(LocalDateTime.now()); saveOrUpdateRecord(record); - incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId()); return; } - MeetingPointsAccount account = getOrCreateAccount(meeting.getTenantId(), record.getChargeAccountUserId()); - long balanceBefore = defaultLong(account.getCurrentBalance()); - long balanceAfter = balanceBefore - chargeAmount; - - account.setCurrentBalance(balanceAfter); - account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + chargeAmount); - account.setTotalLlmPointsUsed(defaultLong(account.getTotalLlmPointsUsed()) + chargeAmount); - pointsAccountService.updateById(account); - + ChargeExecutionResult result = executeCharge(meeting, summaryTask, record, POINTS_TYPE_LLM, chargeAmount); if (record.getBalanceBefore() == null) { - record.setBalanceBefore(balanceBefore); + record.setBalanceBefore(result.totalBalanceBefore()); } - record.setBalanceAfter(balanceAfter); - record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + chargeAmount); - record.setChargedLlmPoints(chargeAmount); + record.setBalanceAfter(result.totalBalanceAfter()); + record.setChargedTotalPoints(defaultLong(record.getChargedTotalPoints()) + result.chargedPoints()); + record.setChargedLlmPoints(result.chargedPoints()); record.setLlmChargedAt(LocalDateTime.now()); record.setChargedAt(LocalDateTime.now()); record.setPointsDelta(-defaultLong(record.getChargedTotalPoints())); record.setSummaryStatus(STATUS_COMPLETED); saveOrUpdateRecord(record); - - saveLedger(meeting, summaryTask, record, "LLM", -chargeAmount, balanceBefore, balanceAfter); - incrementSummaryChargeCount(meeting.getTenantId(), meeting.getCreatorId()); } @Override @@ -211,22 +225,36 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { @Override public String resolveLatestBlockedReason(Long summaryTaskId) { - return null; + if (summaryTaskId == null) { + return null; + } + MeetingSummaryChargeRecord record = chargeRecordService.getOne(new LambdaQueryWrapper() + .eq(MeetingSummaryChargeRecord::getSummaryTaskId, summaryTaskId) + .last("LIMIT 1")); + return record == null ? null : record.getBlockedReason(); } @Override public MeetingPointsBalanceVO getBalanceView(Long tenantId, Long userId) { - MeetingPointsAccount publicAccount = getOrCreateAccount(tenantId, UNIFIED_ACCOUNT_USER_ID); - MeetingPointsAccount personalAccount = getOrCreateAccount(tenantId, userId); + MeetingPointsAccount publicAccount = findAccount(tenantId, UNIFIED_ACCOUNT_USER_ID); + 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(); vo.setTenantId(tenantId); vo.setUserId(userId); - vo.setPreferredAccountMode(resolveAccountMode()); - vo.setPublicBalance(defaultLong(publicAccount.getCurrentBalance())); - vo.setPublicTotalPointsUsed(defaultLong(publicAccount.getTotalPointsUsed())); - vo.setPersonalBalance(defaultLong(personalAccount.getCurrentBalance())); - vo.setPersonalTotalPointsUsed(defaultLong(personalAccount.getTotalPointsUsed())); + vo.setAccountMode(accountMode); + vo.setChargePriority(chargePriority); + vo.setPublicBalance(publicBalance); + vo.setPublicTotalPointsUsed(publicTotalUsed); + vo.setPersonalBalance(personalBalance); + vo.setPersonalTotalPointsUsed(personalTotalUsed); + vo.setTotalAvailableBalance(resolveVisibleTotalBalance(accountMode, publicBalance, personalBalance)); return vo; } @@ -266,15 +294,15 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { throw ex; } } - incrementSummaryAttemptCount(meeting.getTenantId(), meeting.getCreatorId()); return record; } private void applyChargeSnapshot(MeetingSummaryChargeRecord record, Meeting meeting, String chargeTriggerType, int durationSeconds) { ChargeSnapshot snapshot = buildChargeSnapshot(durationSeconds); - ChargeAccountSnapshot accountSnapshot = resolveChargeAccountSnapshot(meeting); - record.setChargeAccountType(accountSnapshot.accountType()); - record.setChargeAccountUserId(accountSnapshot.accountUserId()); + String accountMode = resolveAccountMode(); + Long ownerUserId = meeting.getCreatorId() == null ? UNIFIED_ACCOUNT_USER_ID : meeting.getCreatorId(); + record.setChargeAccountType(accountMode); + record.setChargeAccountUserId(ACCOUNT_MODE_PERSONAL.equals(accountMode) ? ownerUserId : UNIFIED_ACCOUNT_USER_ID); record.setAudioDurationSeconds(durationSeconds); record.setChargedMinutes(snapshot.chargedMinutes()); 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 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() + .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 resolveChargeTargets(Long tenantId, Long ownerUserId) { + Long personalUserId = ownerUserId == null ? UNIFIED_ACCOUNT_USER_ID : ownerUserId; + String accountMode = resolveAccountMode(); + String chargePriority = resolveChargePriority(); + List 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) { int chargedMinutes = toChargedMinutes(durationSeconds); int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1); @@ -316,113 +514,6 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { meeting.setEffectiveAudioDurationSeconds(durationSeconds); meetingMapper.updateById(meeting); } - long deltaSeconds = (long) durationSeconds - (previousDuration == null ? 0L : previousDuration.longValue()); - if (deltaSeconds == 0L) { - return; - } - MeetingUserStats stats = getOrCreateUserStats(meeting.getTenantId(), meeting.getCreatorId()); - stats.setTotalMeetingDurationSeconds(defaultLong(stats.getTotalMeetingDurationSeconds()) + deltaSeconds); - stats.setTotalMeetingDurationMinutes(toCeilMinutes(defaultLong(stats.getTotalMeetingDurationSeconds()))); - meetingUserStatsService.updateById(stats); - } - - private void incrementSummaryAttemptCount(Long tenantId, Long userId) { - if (userId == null) { - return; - } - MeetingUserStats stats = getOrCreateUserStats(tenantId, userId); - stats.setTotalSummaryAttemptCount(defaultLong(stats.getTotalSummaryAttemptCount()) + 1L); - meetingUserStatsService.updateById(stats); - } - - private void incrementSummaryChargeCount(Long tenantId, Long userId) { - if (userId == null) { - return; - } - MeetingUserStats stats = getOrCreateUserStats(tenantId, userId); - stats.setTotalSummaryChargeCount(defaultLong(stats.getTotalSummaryChargeCount()) + 1L); - meetingUserStatsService.updateById(stats); - } - - private MeetingUserStats getOrCreateUserStats(Long tenantId, Long userId) { - MeetingUserStats stats = meetingUserStatsService.getOne(new LambdaQueryWrapper() - .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) { @@ -465,38 +556,48 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { 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() { 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; } String normalized = configured.trim().toUpperCase(); - if (ACCOUNT_MODE_PERSONAL.equals(normalized)) { - return ACCOUNT_MODE_PERSONAL; + if (ACCOUNT_MODE_PERSONAL.equals(normalized) || ACCOUNT_MODE_BOTH.equals(normalized)) { + return normalized; } return ACCOUNT_MODE_PUBLIC; } - private ChargeAccountSnapshot resolveChargeAccountSnapshot(Meeting meeting) { - String accountMode = resolveAccountMode(); - if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) { - Long ownerUserId = meeting.getCreatorId() == null ? UNIFIED_ACCOUNT_USER_ID : meeting.getCreatorId(); - return new ChargeAccountSnapshot(ACCOUNT_MODE_PERSONAL, ownerUserId); + private String resolveChargePriority() { + String configured = sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_CHARGE_PRIORITY, CHARGE_PRIORITY_PERSONAL_FIRST); + if (!StringUtils.hasText(configured)) { + return CHARGE_PRIORITY_PERSONAL_FIRST; } - 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) { return (int) Math.ceil(durationSeconds / 60.0d); } - private long toCeilMinutes(long durationSeconds) { - if (durationSeconds <= 0L) { - return 0L; - } - return (long) Math.ceil(durationSeconds / 60.0d); - } - private int positiveInt(String rawValue, int defaultValue) { try { int value = Integer.parseInt(String.valueOf(rawValue).trim()); @@ -535,7 +636,10 @@ public class MeetingPointsServiceImpl implements MeetingPointsService { 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( diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUserStatsServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUserStatsServiceImpl.java deleted file mode 100644 index 2d8f0a0..0000000 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUserStatsServiceImpl.java +++ /dev/null @@ -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 implements MeetingUserStatsService { -} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java index 17416b1..00e87fa 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java @@ -1,12 +1,13 @@ 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.PageResult; import com.unisbase.dto.SysTenantDTO; import com.unisbase.service.SysTenantService; import com.unisbase.service.TenantManagementService; import com.unisbase.service.TenantModeService; -import com.imeeting.service.biz.LicenseService; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,13 +21,16 @@ public class TenantManagementServicePrimaryImpl implements TenantManagementServi private final SysTenantService sysTenantService; private final TenantModeService tenantModeService; private final LicenseService licenseService; + private final MeetingPointsService meetingPointsService; public TenantManagementServicePrimaryImpl(SysTenantService sysTenantService, TenantModeService tenantModeService, - LicenseService licenseService) { + LicenseService licenseService, + MeetingPointsService meetingPointsService) { this.sysTenantService = sysTenantService; this.tenantModeService = tenantModeService; this.licenseService = licenseService; + this.meetingPointsService = meetingPointsService; } @Override @@ -52,6 +56,7 @@ public class TenantManagementServicePrimaryImpl implements TenantManagementServi tenantModeService.assertTenantLifecycleAllowed(); Long tenantId = sysTenantService.createTenantWithAdmin(tenant); licenseService.initializeTemporaryLicenses(tenantId); + meetingPointsService.initializeTenantPointsAccount(tenantId); return tenantId; } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d3cae72..35c843f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -51,6 +51,7 @@ unisbase: - biz_prompt_templates - biz_meeting_transcript_chapter_versions - biz_meeting_transcript_chapters + - biz_android_push_message - biz_client_downloads - biz_external_apps security: diff --git a/frontend/src/api/business/meetingPoints.ts b/frontend/src/api/business/meetingPoints.ts index 91a9afc..4f5bb1b 100644 --- a/frontend/src/api/business/meetingPoints.ts +++ b/frontend/src/api/business/meetingPoints.ts @@ -2,11 +2,26 @@ import http from "../http"; export interface MeetingPointsOverviewVO { accountMode: string; + chargePriority: string; publicBalance: number; publicTotalPointsUsed: number; + personalBalance: number; + personalTotalPointsUsed: number; + totalAvailableBalance: 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 { id: number; tenantId: number; @@ -16,7 +31,7 @@ export interface MeetingPointsLedgerListItemVO { ownerUserId?: number; ownerUserName?: string; chargeAccountType?: string; - pointsType: "ASR" | "LLM" | "INIT" | "RECHARGE"; + pointsType: "ASR" | "LLM"; consumedPoints: number; balanceBefore?: number; balanceAfter?: number; @@ -56,6 +71,7 @@ export interface MeetingPointsLedgerDetailVO { asrChargedAt?: string; llmChargedAt?: string; createdAt?: string; + chargeItems?: MeetingPointsChargeItemVO[]; } 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}`); 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; +} diff --git a/frontend/src/pages/business/MeetingPointsManagement.tsx b/frontend/src/pages/business/MeetingPointsManagement.tsx index d152136..10d735d 100644 --- a/frontend/src/pages/business/MeetingPointsManagement.tsx +++ b/frontend/src/pages/business/MeetingPointsManagement.tsx @@ -1,5 +1,23 @@ -import { Alert, Button, Card, Col, Descriptions, Input, Modal, Row, Select, Space, Statistic, Tag, Typography, message } from "antd"; -import { EyeOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; +import { EyeOutlined, PlusOutlined, 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 PageContainer from "@/components/shared/PageContainer"; import ListTable from "@/components/shared/ListTable/ListTable"; @@ -8,10 +26,13 @@ import { getMeetingPointsLedgerDetail, getMeetingPointsLedgerPage, getMeetingPointsOverview, + transferMeetingPoints, + type MeetingPointsChargeItemVO, type MeetingPointsLedgerDetailVO, type MeetingPointsLedgerListItemVO, type MeetingPointsOverviewVO, } from "@/api/business/meetingPoints"; +import type { SysUser } from "@/types"; const { Text } = Typography; @@ -22,45 +43,39 @@ const POINTS_TYPE_OPTIONS = [ ]; 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) { - if (value === "ASR") { - return "转录"; - } - if (value === "LLM") { - return "总结"; - } - if (value === "INIT") { - return "初始化"; - } - if (value === "RECHARGE") { - return "充值"; - } + if (value === "ASR") return "转录"; + if (value === "LLM") return "总结"; + if (value === "TRANSFER_OUT") return "转出"; + if (value === "TRANSFER_IN") return "转入"; + if (value === "INIT") return "初始化"; return value || "-"; } function getPointsTypeColor(value?: string) { - if (value === "ASR") { - return "blue"; - } - if (value === "LLM") { - return "purple"; - } - if (value === "RECHARGE") { - return "green"; - } + if (value === "ASR") return "blue"; + if (value === "LLM") return "purple"; + if (value === "TRANSFER_IN") return "green"; + if (value === "TRANSFER_OUT") return "orange"; return "default"; } function getChargeTriggerLabel(value?: string) { - if (value === "RESUMMARY") { - return "重新总结"; - } - if (value === "AUTO_SUMMARY") { - return "自动总结"; - } + if (value === "RESUMMARY") return "重新总结"; + if (value === "AUTO_SUMMARY") return "自动总结"; return "-"; } @@ -72,22 +87,31 @@ export default function MeetingPointsManagement() { const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false); + const [transferLoading, setTransferLoading] = useState(false); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [detailOpen, setDetailOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); const [detail, setDetail] = useState(null); + const [users, setUsers] = useState([]); const [params, setParams] = useState({ current: 1, size: 20, username: "", pointsType: "", }); + const [transferForm] = Form.useForm(); const loadOverview = async () => { const data = await getMeetingPointsOverview(); setOverview(data); }; + const loadUsers = async () => { + const data = await listUsers(); + setUsers(data || []); + }; + const loadPage = async (nextParams = params) => { setLoading(true); try { @@ -100,8 +124,7 @@ export default function MeetingPointsManagement() { }; useEffect(() => { - void loadOverview(); - void loadPage(); + void Promise.all([loadOverview(), loadPage(), loadUsers()]); }, []); const handleSearch = () => { @@ -122,7 +145,7 @@ export default function MeetingPointsManagement() { }; const handleRefresh = async () => { - await Promise.all([loadOverview(), loadPage()]); + await Promise.all([loadOverview(), loadPage(), loadUsers()]); 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( () => [ { - title: "用户名", + title: "用户", dataIndex: "ownerUserName", key: "ownerUserName", width: 140, render: (value: string) => {value || "-"}, }, + { + title: "扣费账户", + dataIndex: "chargeAccountType", + key: "chargeAccountType", + width: 120, + render: (value: string) => {getAccountTypeLabel(value)}, + }, { title: "消耗类型", dataIndex: "pointsType", @@ -196,13 +240,55 @@ export default function MeetingPointsManagement() { [], ); + const chargeItemColumns = useMemo( + () => [ + { + title: "阶段", + dataIndex: "chargeStage", + key: "chargeStage", + render: (value: string) => {getPointsTypeLabel(value)}, + }, + { + 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 ( - {/*当前结算模式:{getAccountModeLabel(overview?.accountMode)}*/} + 当前模式:{getAccountModeLabel(overview?.accountMode)} + 优先级:{getChargePriorityLabel(overview?.chargePriority)} + @@ -231,21 +317,20 @@ export default function MeetingPointsManagement() { } > - - {/**/} - {/* */} - {/* */} - {/* */} - {/**/} - + - + + + + + + @@ -255,15 +340,32 @@ export default function MeetingPointsManagement() { - + + + + + + + + + + + + + +
@@ -280,7 +382,7 @@ export default function MeetingPointsManagement() {
{ setDetailOpen(false); @@ -297,36 +399,72 @@ export default function MeetingPointsManagement() { 关闭 , ]} - width={720} + width={900} confirmLoading={detailLoading} > {detail && ( - - {detail.ownerUserName || "-"} - {/*{getAccountModeLabel(detail.chargeAccountType)}*/} - {getPointsTypeLabel(detail.pointsType)} - {detail.consumedPoints ?? 0} - {formatDateTime(detail.createdAt)} - {detail.meetingTitle || "-"} - {getChargeTriggerLabel(detail.chargeTriggerType)} - {detail.audioDurationSeconds ?? "-"} - {detail.chargedMinutes ?? "-"} - {/*{detail.billingUnits ?? "-"}*/} - {/*{detail.unitMinutesSnapshot ?? "-"}*/} - {/*{detail.costPerUnitSnapshot ?? "-"}*/} - {/*{detail.totalPoints ?? 0}*/} - {/*{detail.chargedTotalPoints ?? 0}*/} - {/*{detail.asrPoints ?? 0}*/} - {/*{detail.chargedAsrPoints ?? 0}*/} - {/*{detail.llmPoints ?? 0}*/} - {/*{detail.chargedLlmPoints ?? 0}*/} - {detail.balanceBefore ?? "-"} - {detail.balanceAfter ?? "-"} - {/*{detail.summaryStatus || "-"}*/} - {/*{detail.failureReason || "-"}*/} - + + + {detail.ownerUserName || "-"} + {getAccountTypeLabel(detail.chargeAccountType)} + {getPointsTypeLabel(detail.pointsType)} + {detail.consumedPoints ?? 0} + {formatDateTime(detail.createdAt)} + {detail.meetingTitle || "-"} + {getChargeTriggerLabel(detail.chargeTriggerType)} + {detail.audioDurationSeconds ?? "-"} + {detail.chargedMinutes ?? "-"} + {detail.totalPoints ?? 0} + {detail.chargedTotalPoints ?? 0} + {detail.balanceBefore ?? "-"} + {detail.balanceAfter ?? "-"} + + + + + rowKey="id" + size="small" + pagination={false} + columns={chargeItemColumns as never} + dataSource={detail.chargeItems || []} + /> + + )} + + { + setTransferOpen(false); + transferForm.resetFields(); + }} + onOk={() => void handleTransferSubmit()} + confirmLoading={transferLoading} + > +
+ + + +
+
); }