refactor: 移除会议用户统计相关代码并更新设备认证逻辑
- 移除 `MeetingUserStats` 相关实体、服务和映射器 - 更新 `AndroidPushGrpcService` 和 `AndroidAuthServiceImpl`,添加 `userId` 和 `tenantId` 参数 - 优化 `AndroidDeviceHomeServiceImpl` 中的积分计算逻辑 - 在 `MeetingPointsController` 中新增 `transfer` 方法,支持从公共账户分配积分给个人账户 - 更新 `MeetingPointsLedgerListItemVO` 中的 `pointsType` 描述 - 优化 `AndroidDeviceBindingServiceImpl` 中的设备登录态验证逻辑dev_na
parent
1813b90c6a
commit
11c3ee1b09
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
/** 临时授权默认有效期,单位月。 */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -378,17 +378,21 @@ public class AndroidMeetingController {
|
|||
public ApiResponse<AndroidMeetingConfigVo> 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<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates(
|
||||
1,
|
||||
1000,
|
||||
null,
|
||||
null,
|
||||
loginUser.getTenantId(),
|
||||
loginUser.getUserId(),
|
||||
loginUser.getIsPlatformAdmin(),
|
||||
loginUser.getIsTenantAdmin()
|
||||
tenantId,
|
||||
userId,
|
||||
isPlatformAdmin,
|
||||
isTenantAdmin
|
||||
);
|
||||
List<PromptTemplateVO> 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<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
|
||||
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId);
|
||||
List<AiModelVO> enabledModels = modelList.getRecords() == null
|
||||
? List.of()
|
||||
: modelList.getRecords().stream()
|
||||
|
|
|
|||
|
|
@ -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<Boolean> transferPublicPoints(@RequestBody MeetingPointsTransferRequest request) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
ensureAdmin(loginUser);
|
||||
if (request == null) {
|
||||
throw new RuntimeException("分配请求不能为空");
|
||||
}
|
||||
meetingPointsService.transferPublicPointsToUser(
|
||||
loginUser.getTenantId(),
|
||||
request.getTargetUserId(),
|
||||
request.getPoints(),
|
||||
request.getRemark()
|
||||
);
|
||||
return ApiResponse.ok(Boolean.TRUE);
|
||||
}
|
||||
|
||||
private Long resolveTargetUserId(LoginUser loginUser, Long requestedUserId) {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MeetingPointsChargeItemVO> chargeItems;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "消耗积分,展示为正数")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MeetingUserStatsMapper extends BaseMapper<MeetingUserStats> {
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package com.imeeting.service.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
|
||||
public interface MeetingUserStatsService extends IService<MeetingUserStats> {
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
|
||||
MeetingPointsAccount publicAccount = meetingPointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>()
|
||||
.eq(MeetingPointsAccount::getTenantId, tenantId)
|
||||
.eq(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID)
|
||||
.last("LIMIT 1"));
|
||||
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<MeetingPointsAccount> personalAccounts = meetingPointsAccountService.list(new LambdaQueryWrapper<MeetingPointsAccount>()
|
||||
.eq(MeetingPointsAccount::getTenantId, tenantId)
|
||||
.ne(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID));
|
||||
for (MeetingPointsAccount account : personalAccounts) {
|
||||
personalBalance += defaultLong(account.getCurrentBalance());
|
||||
personalTotalUsed += defaultLong(account.getTotalPointsUsed());
|
||||
}
|
||||
|
||||
List<Long> scopedOwnerUserIds = resolveScopedOwnerUserIds(tenantId);
|
||||
long totalChargeCount = 0L;
|
||||
long publicTotalPointsUsed = 0L;
|
||||
List<Long> scopedChargeRecordIds = resolveChargeRecordIdsByOwners(tenantId, scopedOwnerUserIds);
|
||||
if (scopedChargeRecordIds == null || !scopedChargeRecordIds.isEmpty()) {
|
||||
LambdaQueryWrapper<MeetingPointsLedger> scopedLedgerWrapper = new LambdaQueryWrapper<MeetingPointsLedger>()
|
||||
.eq(MeetingPointsLedger::getTenantId, tenantId)
|
||||
.lt(MeetingPointsLedger::getPointsDelta, 0)
|
||||
.in(scopedChargeRecordIds != null, MeetingPointsLedger::getChargeRecordId, scopedChargeRecordIds);
|
||||
totalChargeCount = meetingPointsLedgerService.count(scopedLedgerWrapper);
|
||||
publicTotalPointsUsed = meetingPointsLedgerService.list(scopedLedgerWrapper)
|
||||
.stream()
|
||||
.map(MeetingPointsLedger::getPointsDelta)
|
||||
.filter(Objects::nonNull)
|
||||
.mapToLong(value -> Math.abs(value))
|
||||
.sum();
|
||||
if (scopedOwnerUserIds == null) {
|
||||
totalChargeCount = meetingSummaryChargeRecordService.count(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
|
||||
.eq(MeetingSummaryChargeRecord::getTenantId, tenantId)
|
||||
.gt(MeetingSummaryChargeRecord::getChargedTotalPoints, 0L));
|
||||
} else if (!scopedOwnerUserIds.isEmpty()) {
|
||||
totalChargeCount = meetingSummaryChargeRecordService.count(new LambdaQueryWrapper<MeetingSummaryChargeRecord>()
|
||||
.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<MeetingPointsLedger> wrapper = new LambdaQueryWrapper<MeetingPointsLedger>()
|
||||
.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<MeetingPointsAccount>()
|
||||
.eq(MeetingPointsAccount::getTenantId, tenantId)
|
||||
.eq(MeetingPointsAccount::getUserId, userId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private List<MeetingPointsChargeItemVO> buildChargeItems(Long tenantId, Long chargeRecordId) {
|
||||
if (chargeRecordId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<MeetingPointsLedger> ledgers = meetingPointsLedgerService.list(new LambdaQueryWrapper<MeetingPointsLedger>()
|
||||
.eq(MeetingPointsLedger::getTenantId, tenantId)
|
||||
.eq(MeetingPointsLedger::getChargeRecordId, chargeRecordId)
|
||||
.lt(MeetingPointsLedger::getPointsDelta, 0)
|
||||
.orderByAsc(MeetingPointsLedger::getId));
|
||||
List<MeetingPointsChargeItemVO> items = new ArrayList<>();
|
||||
int order = 1;
|
||||
for (MeetingPointsLedger ledger : ledgers) {
|
||||
MeetingPointsChargeItemVO item = new MeetingPointsChargeItemVO();
|
||||
item.setId(ledger.getId());
|
||||
item.setChargeStage(ledger.getPointsType());
|
||||
item.setAccountType(resolveAccountTypeByLedger(ledger.getUserId()));
|
||||
item.setAccountUserId(ledger.getUserId());
|
||||
item.setPriorityOrder(order++);
|
||||
item.setChargedPoints(Math.abs(defaultLong(ledger.getPointsDelta())));
|
||||
item.setBalanceBefore(ledger.getBalanceBefore());
|
||||
item.setBalanceAfter(ledger.getBalanceAfter());
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private List<Long> resolveMatchedOwnerIds(Long tenantId, String username, List<Long> scopedOwnerUserIds) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<MeetingSummaryChargeRecord>()
|
||||
.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<ChargeTarget> chargeTargets = resolveChargeTargets(meeting.getTenantId(), record.getUserId());
|
||||
long totalBalanceBefore = 0L;
|
||||
for (ChargeTarget target : chargeTargets) {
|
||||
totalBalanceBefore += defaultLong(target.account().getCurrentBalance());
|
||||
}
|
||||
if (isBalanceEnforced() && totalBalanceBefore < chargeAmount) {
|
||||
record.setBlockedReason("INSUFFICIENT_POINTS");
|
||||
saveOrUpdateRecord(record);
|
||||
throw new RuntimeException("积分余额不足");
|
||||
}
|
||||
|
||||
long remaining = chargeAmount;
|
||||
for (int i = 0; i < chargeTargets.size(); i++) {
|
||||
ChargeTarget target = chargeTargets.get(i);
|
||||
MeetingPointsAccount account = target.account();
|
||||
long currentBalance = defaultLong(account.getCurrentBalance());
|
||||
boolean lastTarget = i == chargeTargets.size() - 1;
|
||||
long deducted = calculateDeductedPoints(currentBalance, remaining, lastTarget);
|
||||
if (deducted <= 0L) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long balanceAfter = currentBalance - deducted;
|
||||
account.setCurrentBalance(balanceAfter);
|
||||
account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + deducted);
|
||||
if (POINTS_TYPE_ASR.equals(pointsType)) {
|
||||
account.setTotalAsrPointsUsed(defaultLong(account.getTotalAsrPointsUsed()) + deducted);
|
||||
} else if (POINTS_TYPE_LLM.equals(pointsType)) {
|
||||
account.setTotalLlmPointsUsed(defaultLong(account.getTotalLlmPointsUsed()) + deducted);
|
||||
}
|
||||
pointsAccountService.updateById(account);
|
||||
|
||||
saveChargeLedger(meeting, summaryTask, record, target.accountUserId(), pointsType, -deducted, currentBalance, balanceAfter);
|
||||
remaining -= deducted;
|
||||
if (remaining <= 0L) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining > 0L) {
|
||||
record.setBlockedReason("INSUFFICIENT_POINTS");
|
||||
saveOrUpdateRecord(record);
|
||||
throw new RuntimeException("积分扣费失败,未能完成完整扣减");
|
||||
}
|
||||
return new ChargeExecutionResult(chargeAmount, totalBalanceBefore, totalBalanceBefore - chargeAmount);
|
||||
}
|
||||
|
||||
private long calculateDeductedPoints(long currentBalance, long remaining, boolean lastTarget) {
|
||||
if (remaining <= 0L) {
|
||||
return 0L;
|
||||
}
|
||||
if (isBalanceEnforced()) {
|
||||
return Math.min(Math.max(currentBalance, 0L), remaining);
|
||||
}
|
||||
if (lastTarget) {
|
||||
return remaining;
|
||||
}
|
||||
return Math.min(Math.max(currentBalance, 0L), remaining);
|
||||
}
|
||||
|
||||
private void saveChargeLedger(Meeting meeting, AiTask summaryTask, MeetingSummaryChargeRecord record, Long accountUserId,
|
||||
String pointsType, long pointsDelta, long balanceBefore, long balanceAfter) {
|
||||
if (pointsDelta == 0L) {
|
||||
return;
|
||||
}
|
||||
MeetingPointsLedger ledger = new MeetingPointsLedger();
|
||||
ledger.setTenantId(meeting.getTenantId());
|
||||
ledger.setStatus(1);
|
||||
ledger.setUserId(accountUserId);
|
||||
ledger.setMeetingId(meeting.getId());
|
||||
ledger.setSummaryTaskId(summaryTask.getId());
|
||||
ledger.setChargeRecordId(record.getId());
|
||||
ledger.setPointsDelta(pointsDelta);
|
||||
ledger.setPointsType(pointsType);
|
||||
ledger.setBalanceBefore(balanceBefore);
|
||||
ledger.setBalanceAfter(balanceAfter);
|
||||
ledger.setRemark(TRIGGER_RESUMMARY.equals(record.getChargeTriggerType())
|
||||
? "重新总结成功后扣减"
|
||||
: "总结任务成功后扣减");
|
||||
pointsLedgerService.save(ledger);
|
||||
}
|
||||
|
||||
private void saveTransferLedger(Long tenantId, Long accountUserId, String pointsType, long pointsDelta,
|
||||
long balanceBefore, long balanceAfter, String remark) {
|
||||
MeetingPointsLedger ledger = new MeetingPointsLedger();
|
||||
ledger.setTenantId(tenantId);
|
||||
ledger.setStatus(1);
|
||||
ledger.setUserId(accountUserId);
|
||||
ledger.setPointsDelta(pointsDelta);
|
||||
ledger.setPointsType(pointsType);
|
||||
ledger.setBalanceBefore(balanceBefore);
|
||||
ledger.setBalanceAfter(balanceAfter);
|
||||
ledger.setRemark(remark);
|
||||
pointsLedgerService.save(ledger);
|
||||
}
|
||||
|
||||
private MeetingPointsAccount findAccount(Long tenantId, Long userId) {
|
||||
if (tenantId == null || userId == null) {
|
||||
return null;
|
||||
}
|
||||
return pointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>()
|
||||
.eq(MeetingPointsAccount::getTenantId, tenantId)
|
||||
.eq(MeetingPointsAccount::getUserId, userId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private MeetingPointsAccount getOrCreateAccountForMutation(Long tenantId, Long userId, long initialBalance, boolean createInitLedger) {
|
||||
MeetingPointsAccount account = meetingPointsAccountMapper.selectForUpdate(tenantId, userId);
|
||||
if (account != null) {
|
||||
return account;
|
||||
}
|
||||
account = new MeetingPointsAccount();
|
||||
account.setTenantId(tenantId);
|
||||
account.setStatus(1);
|
||||
account.setUserId(userId);
|
||||
account.setCurrentBalance(initialBalance);
|
||||
account.setTotalPointsUsed(0L);
|
||||
account.setTotalAsrPointsUsed(0L);
|
||||
account.setTotalLlmPointsUsed(0L);
|
||||
try {
|
||||
pointsAccountService.save(account);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
account = meetingPointsAccountMapper.selectForUpdate(tenantId, userId);
|
||||
if (account == null) {
|
||||
throw ex;
|
||||
}
|
||||
return account;
|
||||
}
|
||||
if (createInitLedger && initialBalance > 0L) {
|
||||
saveTransferLedger(tenantId, userId, POINTS_TYPE_INIT, initialBalance, 0L, initialBalance, "公共积分账户初始化发放");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private List<ChargeTarget> resolveChargeTargets(Long tenantId, Long ownerUserId) {
|
||||
Long personalUserId = ownerUserId == null ? UNIFIED_ACCOUNT_USER_ID : ownerUserId;
|
||||
String accountMode = resolveAccountMode();
|
||||
String chargePriority = resolveChargePriority();
|
||||
List<ChargeTarget> targets = new ArrayList<>();
|
||||
if (ACCOUNT_MODE_PUBLIC.equals(accountMode)) {
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
|
||||
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
|
||||
return targets;
|
||||
}
|
||||
if (ACCOUNT_MODE_PERSONAL.equals(accountMode)) {
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId,
|
||||
getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false)));
|
||||
return targets;
|
||||
}
|
||||
if (personalUserId.equals(UNIFIED_ACCOUNT_USER_ID)) {
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
|
||||
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
|
||||
return targets;
|
||||
}
|
||||
if (CHARGE_PRIORITY_PUBLIC_FIRST.equals(chargePriority)) {
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
|
||||
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId,
|
||||
getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false)));
|
||||
return targets;
|
||||
}
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PERSONAL, personalUserId,
|
||||
getOrCreateAccountForMutation(tenantId, personalUserId, 0L, false)));
|
||||
targets.add(new ChargeTarget(ACCOUNT_MODE_PUBLIC, UNIFIED_ACCOUNT_USER_ID,
|
||||
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, 0L, false)));
|
||||
return targets;
|
||||
}
|
||||
|
||||
private ChargeSnapshot buildChargeSnapshot(int durationSeconds) {
|
||||
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<MeetingUserStats>()
|
||||
.eq(MeetingUserStats::getTenantId, tenantId)
|
||||
.eq(MeetingUserStats::getUserId, userId)
|
||||
.last("LIMIT 1"));
|
||||
if (stats != null) {
|
||||
return stats;
|
||||
}
|
||||
stats = new MeetingUserStats();
|
||||
stats.setTenantId(tenantId);
|
||||
stats.setStatus(1);
|
||||
stats.setUserId(userId);
|
||||
stats.setTotalMeetingDurationSeconds(0L);
|
||||
stats.setTotalMeetingDurationMinutes(0L);
|
||||
stats.setTotalSummaryChargeCount(0L);
|
||||
stats.setTotalSummaryAttemptCount(0L);
|
||||
meetingUserStatsService.save(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
private MeetingPointsAccount getOrCreateAccount(Long tenantId, Long userId) {
|
||||
Long effectiveUserId = userId == null ? UNIFIED_ACCOUNT_USER_ID : userId;
|
||||
MeetingPointsAccount account = meetingPointsAccountMapper.selectForUpdate(tenantId, effectiveUserId);
|
||||
if (account != null) {
|
||||
return account;
|
||||
}
|
||||
account = new MeetingPointsAccount();
|
||||
account.setTenantId(tenantId);
|
||||
account.setStatus(1);
|
||||
account.setUserId(effectiveUserId);
|
||||
long initialBalance = nonNegativeLong(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_INITIAL_BALANCE, "0"), 0L);
|
||||
account.setCurrentBalance(initialBalance);
|
||||
account.setTotalPointsUsed(0L);
|
||||
account.setTotalAsrPointsUsed(0L);
|
||||
account.setTotalLlmPointsUsed(0L);
|
||||
try {
|
||||
pointsAccountService.save(account);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
account = meetingPointsAccountMapper.selectForUpdate(tenantId, effectiveUserId);
|
||||
if (account == null) {
|
||||
throw ex;
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
if (initialBalance > 0L) {
|
||||
MeetingPointsLedger initLedger = new MeetingPointsLedger();
|
||||
initLedger.setTenantId(tenantId);
|
||||
initLedger.setStatus(1);
|
||||
initLedger.setUserId(effectiveUserId);
|
||||
initLedger.setPointsDelta(initialBalance);
|
||||
initLedger.setPointsType("INIT");
|
||||
initLedger.setBalanceBefore(0L);
|
||||
initLedger.setBalanceAfter(initialBalance);
|
||||
initLedger.setRemark(UNIFIED_ACCOUNT_USER_ID == effectiveUserId ? "公共积分账户初始化发放" : "个人积分账户初始化发放");
|
||||
pointsLedgerService.save(initLedger);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private void saveLedger(Meeting meeting, AiTask summaryTask, MeetingSummaryChargeRecord record,
|
||||
String pointsType, long pointsDelta, long balanceBefore, long balanceAfter) {
|
||||
if (pointsDelta == 0L) {
|
||||
return;
|
||||
}
|
||||
MeetingPointsLedger ledger = new MeetingPointsLedger();
|
||||
ledger.setTenantId(meeting.getTenantId());
|
||||
ledger.setStatus(1);
|
||||
ledger.setUserId(record.getChargeAccountUserId());
|
||||
ledger.setMeetingId(meeting.getId());
|
||||
ledger.setSummaryTaskId(summaryTask.getId());
|
||||
ledger.setChargeRecordId(record.getId());
|
||||
ledger.setPointsDelta(pointsDelta);
|
||||
ledger.setPointsType(pointsType);
|
||||
ledger.setBalanceBefore(balanceBefore);
|
||||
ledger.setBalanceAfter(balanceAfter);
|
||||
ledger.setRemark(TRIGGER_RESUMMARY.equals(record.getChargeTriggerType())
|
||||
? "重新总结成功后扣减" : "任务成功后按阶段扣减");
|
||||
pointsLedgerService.save(ledger);
|
||||
}
|
||||
|
||||
private void saveOrUpdateRecord(MeetingSummaryChargeRecord record) {
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.biz.MeetingUserStats;
|
||||
import com.imeeting.mapper.biz.MeetingUserStatsMapper;
|
||||
import com.imeeting.service.biz.MeetingUserStatsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MeetingUserStatsServiceImpl extends ServiceImpl<MeetingUserStatsMapper, MeetingUserStats> implements MeetingUserStatsService {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MeetingPointsOverviewVO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [transferLoading, setTransferLoading] = useState(false);
|
||||
const [records, setRecords] = useState<MeetingPointsLedgerListItemVO[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<MeetingPointsLedgerDetailVO | null>(null);
|
||||
const [users, setUsers] = useState<SysUser[]>([]);
|
||||
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) => <Text strong>{value || "-"}</Text>,
|
||||
},
|
||||
{
|
||||
title: "扣费账户",
|
||||
dataIndex: "chargeAccountType",
|
||||
key: "chargeAccountType",
|
||||
width: 120,
|
||||
render: (value: string) => <Tag>{getAccountTypeLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "消耗类型",
|
||||
dataIndex: "pointsType",
|
||||
|
|
@ -196,13 +240,55 @@ export default function MeetingPointsManagement() {
|
|||
[],
|
||||
);
|
||||
|
||||
const chargeItemColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "阶段",
|
||||
dataIndex: "chargeStage",
|
||||
key: "chargeStage",
|
||||
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "扣费账户",
|
||||
dataIndex: "accountType",
|
||||
key: "accountType",
|
||||
render: (value: string) => getAccountTypeLabel(value),
|
||||
},
|
||||
{
|
||||
title: "账户用户ID",
|
||||
dataIndex: "accountUserId",
|
||||
key: "accountUserId",
|
||||
},
|
||||
{
|
||||
title: "扣费积分",
|
||||
dataIndex: "chargedPoints",
|
||||
key: "chargedPoints",
|
||||
},
|
||||
{
|
||||
title: "扣费前余额",
|
||||
dataIndex: "balanceBefore",
|
||||
key: "balanceBefore",
|
||||
},
|
||||
{
|
||||
title: "扣费后余额",
|
||||
dataIndex: "balanceAfter",
|
||||
key: "balanceAfter",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="积分管理"
|
||||
subtitle="当前页面展示余额与消耗流水"
|
||||
subtitle="查看公共账户、个人账户和总结扣费流水"
|
||||
headerExtra={
|
||||
<Space>
|
||||
{/*<Tag color="processing">当前结算模式:{getAccountModeLabel(overview?.accountMode)}</Tag>*/}
|
||||
<Tag color="processing">当前模式:{getAccountModeLabel(overview?.accountMode)}</Tag>
|
||||
<Tag color="blue">优先级:{getChargePriorityLabel(overview?.chargePriority)}</Tag>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setTransferOpen(true)}>
|
||||
分配积分
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
|
|
@ -231,21 +317,20 @@ export default function MeetingPointsManagement() {
|
|||
</Space>
|
||||
}
|
||||
>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{/*<Col xs={24} md={6}>*/}
|
||||
{/* <Card>*/}
|
||||
{/* <Statistic title="当前结算账户" value={getAccountModeLabel(overview?.accountMode)} />*/}
|
||||
{/* </Card>*/}
|
||||
{/*</Col>*/}
|
||||
<Col xs={24} md={6}>
|
||||
<Card>
|
||||
<Statistic title="剩余总积分" value={overview?.publicBalance ?? 0} />
|
||||
<Statistic title="当前模式可用总积分" value={overview?.totalAvailableBalance ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card>
|
||||
<Statistic title="累计消耗总积分" value={overview?.publicTotalPointsUsed ?? 0} />
|
||||
<Statistic title="公共账户余额" value={overview?.publicBalance ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card>
|
||||
<Statistic title="个人账户余额汇总" value={overview?.personalBalance ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
|
|
@ -255,15 +340,32 @@ export default function MeetingPointsManagement() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, display: "flex", flexDirection: "column", minHeight: 0 } }}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
<Statistic title="公共账户累计消耗积分" value={overview?.publicTotalPointsUsed ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
<Statistic title="个人账户累计消耗积分汇总" value={overview?.personalTotalPointsUsed ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
className="app-page__content-card"
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
styles={{ body: { padding: 0, display: "flex", flexDirection: "column", minHeight: 0 } }}
|
||||
>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
|
||||
<ListTable
|
||||
rowKey="id"
|
||||
columns={columns as any}
|
||||
columns={columns as never}
|
||||
dataSource={records}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
scroll={{ y: "calc(100vh - 430px)", x: 1100 }}
|
||||
scroll={{ y: "calc(100vh - 470px)", x: 1200 }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -280,7 +382,7 @@ export default function MeetingPointsManagement() {
|
|||
</Card>
|
||||
|
||||
<Modal
|
||||
title="消耗详情"
|
||||
title="积分流水详情"
|
||||
open={detailOpen}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false);
|
||||
|
|
@ -297,36 +399,72 @@ export default function MeetingPointsManagement() {
|
|||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={720}
|
||||
width={900}
|
||||
confirmLoading={detailLoading}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="用户名">{detail.ownerUserName || "-"}</Descriptions.Item>
|
||||
{/*<Descriptions.Item label="扣费账户">{getAccountModeLabel(detail.chargeAccountType)}</Descriptions.Item>*/}
|
||||
<Descriptions.Item label="消耗类型">{getPointsTypeLabel(detail.pointsType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗积分">{detail.consumedPoints ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗时间">{formatDateTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="会议标题">{detail.meetingTitle || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="触发类型">{getChargeTriggerLabel(detail.chargeTriggerType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="录音时长(秒)">{detail.audioDurationSeconds ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="计费分钟数">{detail.chargedMinutes ?? "-"}</Descriptions.Item>
|
||||
{/*<Descriptions.Item label="计费单位数">{detail.billingUnits ?? "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="单位分钟数">{detail.unitMinutesSnapshot ?? "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="单位价格">{detail.costPerUnitSnapshot ?? "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="应计总积分">{detail.totalPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="已扣总积分">{detail.chargedTotalPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="应计 ASR 积分">{detail.asrPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="已扣 ASR 积分">{detail.chargedAsrPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="应计 LLM 积分">{detail.llmPoints ?? 0}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="已扣 LLM 积分">{detail.chargedLlmPoints ?? 0}</Descriptions.Item>*/}
|
||||
<Descriptions.Item label="扣费前余额">{detail.balanceBefore ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="扣费后余额">{detail.balanceAfter ?? "-"}</Descriptions.Item>
|
||||
{/*<Descriptions.Item label="记录状态">{detail.summaryStatus || "-"}</Descriptions.Item>*/}
|
||||
{/*<Descriptions.Item label="失败原因">{detail.failureReason || "-"}</Descriptions.Item>*/}
|
||||
</Descriptions>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="用户">{detail.ownerUserName || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前流水账户">{getAccountTypeLabel(detail.chargeAccountType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗类型">{getPointsTypeLabel(detail.pointsType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗积分">{detail.consumedPoints ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗时间">{formatDateTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="会议标题">{detail.meetingTitle || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="触发类型">{getChargeTriggerLabel(detail.chargeTriggerType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="录音时长(秒)">{detail.audioDurationSeconds ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="计费分钟数">{detail.chargedMinutes ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="应计总积分">{detail.totalPoints ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="已扣总积分">{detail.chargedTotalPoints ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="扣费前余额">{detail.balanceBefore ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="扣费后余额">{detail.balanceAfter ?? "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Card title="本次总结扣费明细" size="small">
|
||||
<Table<MeetingPointsChargeItemVO>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={chargeItemColumns as never}
|
||||
dataSource={detail.chargeItems || []}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="从公共账户分配积分"
|
||||
open={transferOpen}
|
||||
onCancel={() => {
|
||||
setTransferOpen(false);
|
||||
transferForm.resetFields();
|
||||
}}
|
||||
onOk={() => void handleTransferSubmit()}
|
||||
confirmLoading={transferLoading}
|
||||
>
|
||||
<Form form={transferForm} layout="vertical">
|
||||
<Form.Item name="targetUserId" label="目标用户" rules={[{ required: true, message: "请选择目标用户" }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="请选择用户"
|
||||
options={users
|
||||
.filter((user) => user.userId && user.userId > 0)
|
||||
.map((user) => ({
|
||||
label: `${user.displayName || user.username} (#${user.userId})`,
|
||||
value: user.userId,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="points" label="分配积分" rules={[{ required: true, message: "请输入分配积分" }]}>
|
||||
<InputNumber min={1} precision={0} style={{ width: "100%" }} placeholder="请输入正整数积分" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input placeholder="可选,默认记为管理员从公共账户分配积分" maxLength={200} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue