feat: 添加租户积分校验设置和前端页面优化

- 在 `MeetingPointsServiceImpl` 中新增租户积分校验逻辑,支持无限余额模式
- 更新 `MeetingPointsManagement` 页面,移除不必要的组件和样式,优化统计卡片和表格布局
- 新增 `TenantMeetingPointsSettings` 页面,用于管理租户积分校验设置
dev_na
chenhao 2026-06-11 17:10:10 +08:00
parent e330edc965
commit 6e4d10427a
21 changed files with 883 additions and 237 deletions

View File

@ -44,8 +44,6 @@ public final class SysParamKeys {
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";
/** 临时授权默认有效期,单位月。 */

View File

@ -0,0 +1,103 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.TenantMeetingPointsSettingVO;
import com.imeeting.dto.biz.UpdateTenantMeetingPointsBalanceCheckCommand;
import com.imeeting.service.biz.TenantMeetingPointsManagementService;
import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
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;
import java.util.List;
@Tag(name = "租户积分余额校验")
@RestController
@RequestMapping("/api/biz/tenant-meeting-points/settings")
public class TenantMeetingPointsSettingController {
private final TenantMeetingPointsManagementService tenantMeetingPointsManagementService;
public TenantMeetingPointsSettingController(TenantMeetingPointsManagementService tenantMeetingPointsManagementService) {
this.tenantMeetingPointsManagementService = tenantMeetingPointsManagementService;
}
@Operation(summary = "分页查询租户积分余额校验配置")
@GetMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<TenantMeetingPointsSettingVO>>> pageSettings(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(required = false) String tenantName,
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Boolean balanceCheckEnabled) {
LoginUser loginUser = currentLoginUser();
ensurePlatformAdmin(loginUser);
return ApiResponse.ok(tenantMeetingPointsManagementService.pageSettings(current, size, tenantName, tenantCode, balanceCheckEnabled));
}
@Operation(summary = "获取当前租户积分余额校验配置")
@GetMapping("/current")
@PreAuthorize("isAuthenticated()")
public ApiResponse<TenantMeetingPointsSettingVO> getCurrentSetting() {
LoginUser loginUser = currentLoginUser();
ensureAdmin(loginUser);
return ApiResponse.ok(tenantMeetingPointsManagementService.getCurrentTenantSetting(loginUser.getTenantId()));
}
@Operation(summary = "更新租户积分余额校验开关")
@PutMapping("/{tenantId}/balance-check")
@PreAuthorize("isAuthenticated()")
public ApiResponse<TenantMeetingPointsSettingVO> updateBalanceCheck(@PathVariable Long tenantId,
@Valid @RequestBody UpdateTenantMeetingPointsBalanceCheckCommand command) {
LoginUser loginUser = currentLoginUser();
ensureCanManageTenant(loginUser, tenantId);
if (command == null || command.getBalanceCheckEnabled() == null) {
throw new RuntimeException("余额校验开关不能为空");
}
return ApiResponse.ok(tenantMeetingPointsManagementService.updateBalanceCheck(
tenantId,
command.getBalanceCheckEnabled(),
command.getRemark(),
loginUser.getUserId(),
loginUser.getDisplayName()
));
}
private void ensureCanManageTenant(LoginUser loginUser, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return;
}
if (!Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
throw new RuntimeException("无权限执行该操作");
}
if (tenantId == null || !tenantId.equals(loginUser.getTenantId())) {
throw new RuntimeException("租户管理员只能操作当前租户");
}
}
private void ensureAdmin(LoginUser loginUser) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
throw new RuntimeException("无权限执行该操作");
}
}
private void ensurePlatformAdmin(LoginUser loginUser) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
throw new RuntimeException("仅平台管理员可查看租户列表");
}
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -18,6 +18,9 @@ public class MeetingPointsBalanceVO {
@Schema(description = "当前扣费优先级PERSONAL_FIRST / PUBLIC_FIRST")
private String chargePriority;
@Schema(description = "是否启用余额校验")
private Boolean balanceCheckEnabled;
@Schema(description = "公共账户余额")
private Long publicBalance;

View File

@ -14,6 +14,9 @@ public class MeetingPointsOverviewVO {
@Schema(description = "当前扣费优先级PERSONAL_FIRST / PUBLIC_FIRST")
private String chargePriority;
@Schema(description = "是否启用余额校验")
private Boolean balanceCheckEnabled;
@Schema(description = "公共账户余额")
private Long publicBalance;

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "个人积分账户概览")
public class MeetingPointsPersonalAccountVO {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "展示名称")
private String displayName;
@Schema(description = "当前积分余额")
private Long currentBalance;
@Schema(description = "累计消耗积分")
private Long totalPointsUsed;
}

View File

@ -0,0 +1,40 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "租户积分余额校验配置视图")
public class TenantMeetingPointsSettingVO {
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "租户编码")
private String tenantCode;
@Schema(description = "租户名称")
private String tenantName;
@Schema(description = "是否启用余额校验")
private Boolean balanceCheckEnabled;
@Schema(description = "是否处于无限余额模式")
private Boolean unlimitedBalanceMode;
@Schema(description = "公共账户账面余额")
private Long publicBalance;
@Schema(description = "公共账户累计消耗积分")
private Long publicTotalPointsUsed;
@Schema(description = "最近一次切换时间")
private LocalDateTime lastSwitchAt;
@Schema(description = "最近一次切换操作人名称")
private String lastSwitchByName;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,16 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "更新租户积分余额校验开关请求")
public class UpdateTenantMeetingPointsBalanceCheckCommand {
@NotNull(message = "余额校验开关不能为空")
@Schema(description = "是否启用余额校验true-启用false-关闭", requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean balanceCheckEnabled;
@Schema(description = "备注")
private String remark;
}

View File

@ -41,6 +41,9 @@ public class MeetingPointsLedger extends BaseEntity {
@Schema(description = "变动后余额")
private Long balanceAfter;
@Schema(description = "余额校验快照1-校验余额0-无限余额模式")
private Integer balanceCheckEnabledSnapshot;
@Schema(description = "备注")
private String remark;
}

View File

@ -91,6 +91,9 @@ public class MeetingSummaryChargeRecord extends BaseEntity {
@Schema(description = "积分模式是否开启")
private Integer pointsModeEnabled;
@Schema(description = "余额校验快照1-校验余额0-无限余额模式")
private Integer balanceCheckEnabledSnapshot;
@Schema(description = "阻塞原因")
private String blockedReason;

View File

@ -0,0 +1,36 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.unisbase.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "租户积分余额校验配置实体")
@TableName("biz_meeting_points_tenant_settings")
public class TenantMeetingPointsSetting extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "是否启用余额校验1-启用0-关闭")
private Integer balanceCheckEnabled;
@Schema(description = "最近一次切换时间")
private LocalDateTime lastSwitchAt;
@Schema(description = "最近一次切换操作人ID")
private Long lastSwitchBy;
@Schema(description = "最近一次切换操作人名称")
private String lastSwitchByName;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,20 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.TenantMeetingPointsSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface TenantMeetingPointsSettingMapper extends BaseMapper<TenantMeetingPointsSetting> {
@Select("""
SELECT *
FROM biz_meeting_points_tenant_settings
WHERE tenant_id = #{tenantId}
AND is_deleted = 0
LIMIT 1
FOR UPDATE
""")
TenantMeetingPointsSetting selectForUpdate(@Param("tenantId") Long tenantId);
}

View File

@ -0,0 +1,22 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.TenantMeetingPointsSettingVO;
import com.unisbase.dto.PageResult;
import java.util.List;
public interface TenantMeetingPointsManagementService {
PageResult<List<TenantMeetingPointsSettingVO>> pageSettings(Integer current,
Integer size,
String tenantName,
String tenantCode,
Boolean balanceCheckEnabled);
TenantMeetingPointsSettingVO getCurrentTenantSetting(Long tenantId);
TenantMeetingPointsSettingVO updateBalanceCheck(Long tenantId,
boolean balanceCheckEnabled,
String remark,
Long operatorUserId,
String operatorName);
}

View File

@ -0,0 +1,14 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.entity.biz.TenantMeetingPointsSetting;
public interface TenantMeetingPointsSettingService extends IService<TenantMeetingPointsSetting> {
void initializeTenantSetting(Long tenantId);
boolean isBalanceCheckEnabled(Long tenantId);
TenantMeetingPointsSetting getByTenantId(Long tenantId);
TenantMeetingPointsSetting getByTenantIdForUpdate(Long tenantId);
}

View File

@ -17,6 +17,7 @@ import com.imeeting.service.biz.MeetingPointsLedgerService;
import com.imeeting.service.biz.MeetingPointsQueryService;
import com.imeeting.service.biz.MeetingSummaryChargeRecordService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.TenantMeetingPointsSettingService;
import com.unisbase.dto.DataScopeRuleDTO;
import com.unisbase.dto.PageResult;
import com.unisbase.entity.SysUser;
@ -54,6 +55,7 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
private final SysUserMapper sysUserMapper;
private final DataScopeService dataScopeService;
private final SysParamService sysParamService;
private final TenantMeetingPointsSettingService tenantMeetingPointsSettingService;
public MeetingPointsQueryServiceImpl(MeetingPointsAccountService meetingPointsAccountService,
MeetingPointsLedgerService meetingPointsLedgerService,
@ -61,7 +63,8 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
MeetingService meetingService,
SysUserMapper sysUserMapper,
DataScopeService dataScopeService,
SysParamService sysParamService) {
SysParamService sysParamService,
TenantMeetingPointsSettingService tenantMeetingPointsSettingService) {
this.meetingPointsAccountService = meetingPointsAccountService;
this.meetingPointsLedgerService = meetingPointsLedgerService;
this.meetingSummaryChargeRecordService = meetingSummaryChargeRecordService;
@ -69,12 +72,14 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
this.sysUserMapper = sysUserMapper;
this.dataScopeService = dataScopeService;
this.sysParamService = sysParamService;
this.tenantMeetingPointsSettingService = tenantMeetingPointsSettingService;
}
@Override
public MeetingPointsOverviewVO getOverview(Long tenantId, Long userId, boolean isAdmin) {
String accountMode = resolveAccountMode();
String chargePriority = resolveChargePriority();
boolean balanceCheckEnabled = tenantMeetingPointsSettingService.isBalanceCheckEnabled(tenantId);
MeetingPointsAccount publicAccount = findAccount(tenantId, PUBLIC_ACCOUNT_USER_ID);
long publicBalance = publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance());
long publicTotalUsed = publicAccount == null ? 0L : defaultLong(publicAccount.getTotalPointsUsed());
@ -111,6 +116,7 @@ public class MeetingPointsQueryServiceImpl implements MeetingPointsQueryService
MeetingPointsOverviewVO vo = new MeetingPointsOverviewVO();
vo.setAccountMode(accountMode);
vo.setChargePriority(chargePriority);
vo.setBalanceCheckEnabled(balanceCheckEnabled);
vo.setPublicBalance(publicBalance);
vo.setPublicTotalPointsUsed(publicTotalUsed);
vo.setPersonalBalance(personalBalance);

View File

@ -16,6 +16,7 @@ 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.TenantMeetingPointsSettingService;
import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -28,7 +29,9 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@ -42,6 +45,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
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_BLOCKED = "BLOCKED";
private static final String STATUS_ASR_CHARGED = "ASR_CHARGED";
private static final String STATUS_COMPLETED = "COMPLETED";
private static final String STATUS_FAILED = "FAILED";
@ -51,6 +55,8 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
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 static final String BLOCKED_REASON_INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS";
private static final String TASK_CONFIG_BALANCE_CHECK_ENABLED_SNAPSHOT = "balanceCheckEnabledSnapshot";
private final MeetingSummaryChargeRecordService chargeRecordService;
private final MeetingPointsAccountService pointsAccountService;
@ -60,6 +66,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
private final MeetingPointsAccountMapper meetingPointsAccountMapper;
private final MeetingSummaryChargeRecordMapper meetingSummaryChargeRecordMapper;
private final SysParamService sysParamService;
private final TenantMeetingPointsSettingService tenantMeetingPointsSettingService;
@Override
@Transactional(rollbackFor = Exception.class)
@ -67,7 +74,10 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
if (tenantId == null) {
return;
}
long initialBalance = nonNegativeLong(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_INITIAL_BALANCE, "0"), 0L);
long initialBalance = nonNegativeLong(
sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_INITIAL_BALANCE, "0"),
0L
);
getOrCreateAccountForMutation(tenantId, UNIFIED_ACCOUNT_USER_ID, initialBalance, true);
}
@ -77,6 +87,9 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
if (tenantId == null) {
throw new RuntimeException("缺少租户信息");
}
if (!isBalanceCheckEnabled(tenantId)) {
throw new RuntimeException("无限余额模式下不允许分配积分");
}
if (targetUserId == null || targetUserId <= 0L) {
throw new RuntimeException("目标用户不能为空");
}
@ -128,7 +141,14 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
}
ensureMeetingDurationStats(meeting, durationSeconds);
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds);
boolean balanceCheckEnabledSnapshot = resolveTaskBalanceCheckSnapshot(asrTask, meeting.getTenantId());
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(
meeting,
summaryTask,
chargeTriggerType,
durationSeconds,
balanceCheckEnabledSnapshot
);
if (defaultLong(record.getChargedAsrPoints()) > 0L) {
return;
}
@ -165,7 +185,14 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
String chargeTriggerType = resolveChargeTriggerType(summaryTask);
Integer durationSeconds = resolveEffectiveAudioDurationSeconds(meeting);
int safeDurationSeconds = durationSeconds == null || durationSeconds <= 0 ? 0 : durationSeconds;
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, safeDurationSeconds);
boolean balanceCheckEnabledSnapshot = resolveTaskBalanceCheckSnapshot(summaryTask, meeting.getTenantId());
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(
meeting,
summaryTask,
chargeTriggerType,
safeDurationSeconds,
balanceCheckEnabledSnapshot
);
if (durationSeconds == null || durationSeconds <= 0) {
record.setFailureReason("无法解析有效录音时长,未记录积分扣减");
record.setSummaryStatus(STATUS_COMPLETED);
@ -253,6 +280,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
vo.setUserId(userId);
vo.setAccountMode(accountMode);
vo.setChargePriority(chargePriority);
vo.setBalanceCheckEnabled(isBalanceCheckEnabled(tenantId));
vo.setPublicBalance(publicBalance);
vo.setPublicTotalPointsUsed(publicTotalUsed);
vo.setPersonalBalance(personalBalance);
@ -264,20 +292,34 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
@Override
@Transactional(rollbackFor = Exception.class)
public void assertSufficientPointsBeforeAsrSubmit(Meeting meeting, AiTask asrTask) {
if (!shouldEnforceBalance() || meeting == null || asrTask == null || meeting.getId() == null) {
if (meeting == null || asrTask == null || meeting.getId() == null) {
return;
}
boolean balanceCheckEnabledSnapshot = resolveOrPersistTaskBalanceCheckSnapshot(asrTask, meeting.getTenantId());
if (!shouldEnforceBalance(balanceCheckEnabledSnapshot)) {
return;
}
Integer durationSeconds = resolveEffectiveAudioDurationSeconds(meeting);
if (durationSeconds == null || durationSeconds <= 0) {
throw new RuntimeException("无法解析录音时长,不能校验积分余额");
}
ensureSufficientPoints(meeting, null, buildChargeSnapshot(durationSeconds).asrPoints(), "ASR_SUBMIT");
ensureSufficientPoints(
meeting,
null,
buildChargeSnapshot(durationSeconds).asrPoints(),
"ASR_SUBMIT",
balanceCheckEnabledSnapshot
);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void assertSufficientPointsBeforeSummarySubmit(Meeting meeting, AiTask summaryTask) {
if (!shouldEnforceBalance() || meeting == null || summaryTask == null || meeting.getId() == null) {
if (meeting == null || summaryTask == null || meeting.getId() == null) {
return;
}
boolean balanceCheckEnabledSnapshot = resolveOrPersistTaskBalanceCheckSnapshot(summaryTask, meeting.getTenantId());
if (!shouldEnforceBalance(balanceCheckEnabledSnapshot)) {
return;
}
String chargeTriggerType = resolveChargeTriggerType(summaryTask);
@ -285,18 +327,33 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
if (durationSeconds == null || durationSeconds <= 0) {
throw new RuntimeException("无法解析录音时长,不能校验积分余额");
}
ensureSufficientPoints(meeting, summaryTask, chargeTriggerType, durationSeconds, "SUMMARY_SUBMIT");
ensureSufficientPoints(
meeting,
summaryTask,
chargeTriggerType,
durationSeconds,
"SUMMARY_SUBMIT",
balanceCheckEnabledSnapshot
);
}
private MeetingSummaryChargeRecord getOrCreateChargeRecord(Meeting meeting, AiTask summaryTask, String chargeTriggerType, int durationSeconds) {
private MeetingSummaryChargeRecord getOrCreateChargeRecord(Meeting meeting,
AiTask summaryTask,
String chargeTriggerType,
int durationSeconds,
boolean balanceCheckEnabledSnapshot) {
MeetingSummaryChargeRecord record = meetingSummaryChargeRecordMapper.selectForUpdateBySummaryTaskId(summaryTask.getId());
if (record != null) {
if (record.getBalanceCheckEnabledSnapshot() == null) {
record.setBalanceCheckEnabledSnapshot(toSnapshotFlag(balanceCheckEnabledSnapshot));
}
if ((record.getAudioDurationSeconds() == null || record.getAudioDurationSeconds() <= 0) && durationSeconds > 0) {
applyChargeSnapshot(record, meeting, chargeTriggerType, durationSeconds);
saveOrUpdateRecord(record);
}
return record;
}
record = new MeetingSummaryChargeRecord();
record.setTenantId(meeting.getTenantId());
record.setStatus(1);
@ -305,6 +362,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
record.setUserId(meeting.getCreatorId());
record.setChargeTriggerType(chargeTriggerType);
record.setPointsModeEnabled(isPointsEnabled() ? 1 : 0);
record.setBalanceCheckEnabledSnapshot(toSnapshotFlag(balanceCheckEnabledSnapshot));
record.setChargedTotalPoints(0L);
record.setChargedAsrPoints(0L);
record.setChargedLlmPoints(0L);
@ -331,8 +389,19 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
AiTask summaryTask,
String chargeTriggerType,
int durationSeconds,
String submitStage) {
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(meeting, summaryTask, chargeTriggerType, durationSeconds);
String submitStage,
boolean balanceCheckEnabledSnapshot) {
MeetingSummaryChargeRecord record = getOrCreateChargeRecord(
meeting,
summaryTask,
chargeTriggerType,
durationSeconds,
balanceCheckEnabledSnapshot
);
if (!shouldEnforceBalance(isBalanceCheckEnabled(record))) {
clearBlockedReason(record);
return;
}
long requiredPoints = defaultLong(record.getTotalPoints()) - defaultLong(record.getChargedTotalPoints());
if (requiredPoints <= 0L) {
clearBlockedReason(record);
@ -340,12 +409,12 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
}
long availableBalance = resolveAvailableBalanceForCheck(meeting.getTenantId(), record.getUserId());
if (availableBalance < requiredPoints) {
record.setBlockedReason("INSUFFICIENT_POINTS");
record.setBlockedReason(BLOCKED_REASON_INSUFFICIENT_POINTS);
record.setFailureReason("INSUFFICIENT_POINTS at " + submitStage + ", required="
+ requiredPoints + ", available=" + availableBalance);
record.setBalanceBefore(availableBalance);
record.setBalanceAfter(availableBalance);
record.setSummaryStatus("BLOCKED");
record.setSummaryStatus(STATUS_BLOCKED);
saveOrUpdateRecord(record);
throw new RuntimeException("积分余额不足");
}
@ -355,7 +424,12 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
private void ensureSufficientPoints(Meeting meeting,
MeetingSummaryChargeRecord record,
long requiredPoints,
String submitStage) {
String submitStage,
boolean balanceCheckEnabledSnapshot) {
if (!shouldEnforceBalance(balanceCheckEnabledSnapshot)) {
clearBlockedReason(record);
return;
}
if (requiredPoints <= 0L) {
clearBlockedReason(record);
return;
@ -364,12 +438,12 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
long availableBalance = resolveAvailableBalanceForCheck(meeting.getTenantId(), ownerUserId);
if (availableBalance < requiredPoints) {
if (record != null) {
record.setBlockedReason("INSUFFICIENT_POINTS");
record.setBlockedReason(BLOCKED_REASON_INSUFFICIENT_POINTS);
record.setFailureReason("INSUFFICIENT_POINTS at " + submitStage + ", required="
+ requiredPoints + ", available=" + availableBalance);
record.setBalanceBefore(availableBalance);
record.setBalanceAfter(availableBalance);
record.setSummaryStatus("BLOCKED");
record.setSummaryStatus(STATUS_BLOCKED);
saveOrUpdateRecord(record);
}
throw new RuntimeException("积分余额不足");
@ -381,7 +455,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
if (record == null || record.getId() == null) {
return;
}
if (record.getBlockedReason() != null || "BLOCKED".equals(record.getSummaryStatus())) {
if (record.getBlockedReason() != null || STATUS_BLOCKED.equals(record.getSummaryStatus())) {
record.setBlockedReason(null);
record.setFailureReason(null);
record.setSummaryStatus(isPointsEnabled() ? STATUS_PENDING : STATUS_DISABLED);
@ -406,45 +480,59 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
record.setTotalPoints(snapshot.llmPoints());
record.setAsrPoints(0L);
record.setLlmPoints(snapshot.llmPoints());
} else {
record.setTotalPoints(snapshot.totalPoints());
record.setAsrPoints(snapshot.asrPoints());
record.setLlmPoints(snapshot.llmPoints());
return;
}
record.setTotalPoints(snapshot.totalPoints());
record.setAsrPoints(snapshot.asrPoints());
record.setLlmPoints(snapshot.llmPoints());
}
private ChargeExecutionResult executeCharge(Meeting meeting, AiTask summaryTask, MeetingSummaryChargeRecord record,
String pointsType, long chargeAmount) {
private ChargeExecutionResult executeCharge(Meeting meeting,
AiTask summaryTask,
MeetingSummaryChargeRecord record,
String pointsType,
long chargeAmount) {
List<ChargeTarget> chargeTargets = resolveChargeTargets(meeting.getTenantId(), record.getUserId());
if (chargeTargets.isEmpty()) {
record.setBlockedReason(BLOCKED_REASON_INSUFFICIENT_POINTS);
saveOrUpdateRecord(record);
throw new RuntimeException("积分账户不存在或不可用");
}
long totalBalanceBefore = 0L;
for (ChargeTarget target : chargeTargets) {
totalBalanceBefore += defaultLong(target.account().getCurrentBalance());
}
if (shouldEnforceBalance() && totalBalanceBefore < chargeAmount) {
record.setBlockedReason("INSUFFICIENT_POINTS");
boolean balanceCheckEnabledSnapshot = isBalanceCheckEnabled(record);
if (shouldEnforceBalance(balanceCheckEnabledSnapshot) && totalBalanceBefore < chargeAmount) {
record.setBlockedReason(BLOCKED_REASON_INSUFFICIENT_POINTS);
saveOrUpdateRecord(record);
throw new RuntimeException("积分余额不足");
}
long remaining = chargeAmount;
for (int i = 0; i < chargeTargets.size(); i++) {
ChargeTarget target = chargeTargets.get(i);
if (!balanceCheckEnabledSnapshot) {
ChargeTarget target = chargeTargets.get(0);
MeetingPointsAccount account = target.account();
long currentBalance = defaultLong(account.getCurrentBalance());
boolean lastTarget = i == chargeTargets.size() - 1;
long deducted = calculateDeductedPoints(currentBalance, remaining, lastTarget);
increaseConsumedPoints(account, pointsType, chargeAmount);
pointsAccountService.updateById(account);
saveChargeLedger(meeting, summaryTask, record, target.accountUserId(), pointsType, -chargeAmount, currentBalance, currentBalance);
return new ChargeExecutionResult(chargeAmount, totalBalanceBefore, totalBalanceBefore);
}
long remaining = chargeAmount;
for (ChargeTarget target : chargeTargets) {
MeetingPointsAccount account = target.account();
long currentBalance = defaultLong(account.getCurrentBalance());
long deducted = Math.min(Math.max(currentBalance, 0L), remaining);
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);
}
increaseConsumedPoints(account, pointsType, deducted);
pointsAccountService.updateById(account);
saveChargeLedger(meeting, summaryTask, record, target.accountUserId(), pointsType, -deducted, currentBalance, balanceAfter);
@ -455,28 +543,21 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
}
if (remaining > 0L) {
record.setBlockedReason("INSUFFICIENT_POINTS");
record.setBlockedReason(BLOCKED_REASON_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 (shouldEnforceBalance()) {
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) {
private void saveChargeLedger(Meeting meeting,
AiTask summaryTask,
MeetingSummaryChargeRecord record,
Long accountUserId,
String pointsType,
long pointsDelta,
long balanceBefore,
long balanceAfter) {
if (pointsDelta == 0L) {
return;
}
@ -491,14 +572,18 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
ledger.setPointsType(pointsType);
ledger.setBalanceBefore(balanceBefore);
ledger.setBalanceAfter(balanceAfter);
ledger.setRemark(TRIGGER_RESUMMARY.equals(record.getChargeTriggerType())
? "重新总结成功后扣减"
: "总结任务成功后扣减");
ledger.setBalanceCheckEnabledSnapshot(record.getBalanceCheckEnabledSnapshot());
ledger.setRemark(buildChargeLedgerRemark(record));
pointsLedgerService.save(ledger);
}
private void saveTransferLedger(Long tenantId, Long accountUserId, String pointsType, long pointsDelta,
long balanceBefore, long balanceAfter, String remark) {
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);
@ -507,6 +592,7 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
ledger.setPointsType(pointsType);
ledger.setBalanceBefore(balanceBefore);
ledger.setBalanceAfter(balanceAfter);
ledger.setBalanceCheckEnabledSnapshot(toSnapshotFlag(isBalanceCheckEnabled(tenantId)));
ledger.setRemark(remark);
pointsLedgerService.save(ledger);
}
@ -573,6 +659,15 @@ public class MeetingPointsServiceImpl implements MeetingPointsService {
return account;
}
private void increaseConsumedPoints(MeetingPointsAccount account, String pointsType, long points) {
account.setTotalPointsUsed(defaultLong(account.getTotalPointsUsed()) + points);
if (POINTS_TYPE_ASR.equals(pointsType)) {
account.setTotalAsrPointsUsed(defaultLong(account.getTotalAsrPointsUsed()) + points);
} else if (POINTS_TYPE_LLM.equals(pointsType)) {
account.setTotalLlmPointsUsed(defaultLong(account.getTotalLlmPointsUsed()) + points);
}
}
private List<ChargeTarget> resolveChargeTargets(Long tenantId, Long ownerUserId) {
Long personalUserId = ownerUserId == null ? UNIFIED_ACCOUNT_USER_ID : ownerUserId;
String accountMode = resolveAccountMode();
@ -672,12 +767,68 @@ 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, "true"));
private boolean isBalanceCheckEnabled(Long tenantId) {
return tenantMeetingPointsSettingService.isBalanceCheckEnabled(tenantId);
}
private boolean shouldEnforceBalance() {
return isPointsEnabled() && isBalanceEnforced();
private boolean isBalanceCheckEnabled(MeetingSummaryChargeRecord record) {
return record == null || !Integer.valueOf(0).equals(record.getBalanceCheckEnabledSnapshot());
}
private boolean shouldEnforceBalance(boolean balanceCheckEnabledSnapshot) {
return isPointsEnabled() && balanceCheckEnabledSnapshot;
}
private boolean resolveOrPersistTaskBalanceCheckSnapshot(AiTask task, Long tenantId) {
boolean snapshot = resolveTaskBalanceCheckSnapshot(task, tenantId);
Map<String, Object> taskConfig = task.getTaskConfig() == null ? new HashMap<>() : new HashMap<>(task.getTaskConfig());
if (!taskConfig.containsKey(TASK_CONFIG_BALANCE_CHECK_ENABLED_SNAPSHOT)) {
taskConfig.put(TASK_CONFIG_BALANCE_CHECK_ENABLED_SNAPSHOT, snapshot);
task.setTaskConfig(taskConfig);
if (task.getId() != null) {
aiTaskMapper.updateById(task);
}
}
return snapshot;
}
private boolean resolveTaskBalanceCheckSnapshot(AiTask task, Long tenantId) {
if (task != null && task.getTaskConfig() != null && task.getTaskConfig().containsKey(TASK_CONFIG_BALANCE_CHECK_ENABLED_SNAPSHOT)) {
return parseBooleanFlag(task.getTaskConfig().get(TASK_CONFIG_BALANCE_CHECK_ENABLED_SNAPSHOT), true);
}
return isBalanceCheckEnabled(tenantId);
}
private boolean parseBooleanFlag(Object rawValue, boolean defaultValue) {
if (rawValue == null) {
return defaultValue;
}
if (rawValue instanceof Boolean booleanValue) {
return booleanValue;
}
String normalized = String.valueOf(rawValue).trim().toLowerCase();
if ("1".equals(normalized) || "true".equals(normalized)) {
return true;
}
if ("0".equals(normalized) || "false".equals(normalized)) {
return false;
}
return defaultValue;
}
private int toSnapshotFlag(boolean balanceCheckEnabledSnapshot) {
return balanceCheckEnabledSnapshot ? 1 : 0;
}
private String buildChargeLedgerRemark(MeetingSummaryChargeRecord record) {
if (!isBalanceCheckEnabled(record)) {
return TRIGGER_RESUMMARY.equals(record.getChargeTriggerType())
? "重新总结成功后记录消耗,未扣减余额"
: "总结任务成功后记录消耗,未扣减余额";
}
return TRIGGER_RESUMMARY.equals(record.getChargeTriggerType())
? "重新总结成功后扣减积分"
: "总结任务成功后扣减积分";
}
private String resolveAccountMode() {

View File

@ -2,12 +2,13 @@ package com.imeeting.service.biz.impl;
import com.imeeting.service.biz.LicenseService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.TenantMeetingPointsSettingService;
import com.unisbase.config.properties.UnisBaseProperties;
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 org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -17,26 +18,30 @@ import java.util.List;
@Service
@Primary
public class TenantManagementServicePrimaryImpl implements TenantManagementService {
private static final long DEFAULT_TENANT_ID = 1L;
private final SysTenantService sysTenantService;
private final TenantModeService tenantModeService;
private final UnisBaseProperties unisBaseProperties;
private final LicenseService licenseService;
private final MeetingPointsService meetingPointsService;
private final TenantMeetingPointsSettingService tenantMeetingPointsSettingService;
public TenantManagementServicePrimaryImpl(SysTenantService sysTenantService,
TenantModeService tenantModeService,
UnisBaseProperties unisBaseProperties,
LicenseService licenseService,
MeetingPointsService meetingPointsService) {
MeetingPointsService meetingPointsService,
TenantMeetingPointsSettingService tenantMeetingPointsSettingService) {
this.sysTenantService = sysTenantService;
this.tenantModeService = tenantModeService;
this.unisBaseProperties = unisBaseProperties;
this.licenseService = licenseService;
this.meetingPointsService = meetingPointsService;
this.tenantMeetingPointsSettingService = tenantMeetingPointsSettingService;
}
@Override
public PageResult<List<SysTenantDTO>> listTenants(Integer current, Integer size, String name, String code) {
if (tenantModeService.isSingleTenantMode()) {
SysTenantDTO defaultTenant = sysTenantService.findById(tenantModeService.getDefaultTenantId());
if (isSingleTenantMode()) {
SysTenantDTO defaultTenant = sysTenantService.findById(getDefaultTenantId());
PageResult<List<SysTenantDTO>> result = new PageResult<>();
result.setRecords(defaultTenant == null ? List.of() : List.of(defaultTenant));
result.setTotal(defaultTenant == null ? 0 : 1);
@ -53,16 +58,17 @@ public class TenantManagementServicePrimaryImpl implements TenantManagementServi
@Override
@Transactional(rollbackFor = Exception.class)
public Long createTenant(CreateTenantDTO tenant) {
tenantModeService.assertTenantLifecycleAllowed();
assertTenantLifecycleAllowed();
Long tenantId = sysTenantService.createTenantWithAdmin(tenant);
licenseService.initializeTemporaryLicenses(tenantId);
meetingPointsService.initializeTenantPointsAccount(tenantId);
tenantMeetingPointsSettingService.initializeTenantSetting(tenantId);
return tenantId;
}
@Override
public boolean updateTenant(Long tenantId, SysTenantDTO tenant) {
tenantModeService.assertDefaultTenantCanBeUpdated(tenantId, tenant == null ? null : tenant.getStatus());
assertDefaultTenantCanBeUpdated(tenantId, tenant == null ? null : tenant.getStatus());
if (tenant == null) {
throw new IllegalArgumentException("租户信息不能为空");
}
@ -72,7 +78,45 @@ public class TenantManagementServicePrimaryImpl implements TenantManagementServi
@Override
public boolean deleteTenant(Long tenantId) {
tenantModeService.assertTenantLifecycleAllowed();
assertTenantLifecycleAllowed();
return sysTenantService.deleteById(tenantId);
}
private boolean isSingleTenantMode() {
if (unisBaseProperties == null || unisBaseProperties.getTenant() == null) {
return false;
}
String mode = unisBaseProperties.getTenant().getMode();
if (mode != null && !mode.isBlank()) {
return "single".equalsIgnoreCase(mode.trim());
}
return !unisBaseProperties.getTenant().isEnabled();
}
private Long getDefaultTenantId() {
if (unisBaseProperties == null || unisBaseProperties.getTenant() == null) {
return DEFAULT_TENANT_ID;
}
Long configured = unisBaseProperties.getTenant().getDefaultTenantId();
return configured == null || configured <= 0 ? DEFAULT_TENANT_ID : configured;
}
private void assertTenantLifecycleAllowed() {
if (isSingleTenantMode()) {
throw new IllegalArgumentException("当前为单租户模式,不支持租户生命周期操作");
}
}
private void assertDefaultTenantCanBeUpdated(Long tenantId, Integer status) {
if (!isSingleTenantMode()) {
return;
}
Long defaultTenantId = getDefaultTenantId();
if (!defaultTenantId.equals(tenantId)) {
throw new IllegalArgumentException("当前为单租户模式,只允许维护默认租户");
}
if (status != null && status != 1) {
throw new IllegalArgumentException("当前为单租户模式,不允许禁用默认租户");
}
}
}

View File

@ -0,0 +1,175 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.biz.TenantMeetingPointsSettingVO;
import com.imeeting.entity.biz.MeetingPointsAccount;
import com.imeeting.entity.biz.TenantMeetingPointsSetting;
import com.imeeting.service.biz.MeetingPointsAccountService;
import com.imeeting.service.biz.TenantMeetingPointsManagementService;
import com.imeeting.service.biz.TenantMeetingPointsSettingService;
import com.unisbase.dto.PageResult;
import com.unisbase.dto.SysTenantDTO;
import com.unisbase.service.SysTenantService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class TenantMeetingPointsManagementServiceImpl implements TenantMeetingPointsManagementService {
private static final long PUBLIC_ACCOUNT_USER_ID = 0L;
private final SysTenantService sysTenantService;
private final TenantMeetingPointsSettingService tenantMeetingPointsSettingService;
private final MeetingPointsAccountService meetingPointsAccountService;
public TenantMeetingPointsManagementServiceImpl(SysTenantService sysTenantService,
TenantMeetingPointsSettingService tenantMeetingPointsSettingService,
MeetingPointsAccountService meetingPointsAccountService) {
this.sysTenantService = sysTenantService;
this.tenantMeetingPointsSettingService = tenantMeetingPointsSettingService;
this.meetingPointsAccountService = meetingPointsAccountService;
}
@Override
public PageResult<List<TenantMeetingPointsSettingVO>> pageSettings(Integer current,
Integer size,
String tenantName,
String tenantCode,
Boolean balanceCheckEnabled) {
PageResult<List<SysTenantDTO>> tenantPage = sysTenantService.page(current, size, tenantName, tenantCode);
List<SysTenantDTO> tenants = tenantPage == null || tenantPage.getRecords() == null
? Collections.emptyList()
: tenantPage.getRecords();
List<Long> tenantIds = tenants.stream()
.map(SysTenantDTO::getId)
.filter(Objects::nonNull)
.toList();
Map<Long, TenantMeetingPointsSetting> settingMap = loadSettingMap(tenantIds);
Map<Long, MeetingPointsAccount> publicAccountMap = loadPublicAccountMap(tenantIds);
List<TenantMeetingPointsSettingVO> records = tenants.stream()
.map(tenant -> toSettingVO(tenant, settingMap.get(tenant.getId()), publicAccountMap.get(tenant.getId())))
.filter(item -> balanceCheckEnabled == null || Objects.equals(item.getBalanceCheckEnabled(), balanceCheckEnabled))
.toList();
PageResult<List<TenantMeetingPointsSettingVO>> result = new PageResult<>();
result.setTotal(balanceCheckEnabled == null ? tenantPage.getTotal() : records.size());
result.setRecords(records);
return result;
}
@Override
public TenantMeetingPointsSettingVO getCurrentTenantSetting(Long tenantId) {
if (tenantId == null) {
throw new RuntimeException("租户不能为空");
}
SysTenantDTO tenant = sysTenantService.findById(tenantId);
if (tenant == null) {
throw new RuntimeException("租户不存在");
}
TenantMeetingPointsSetting setting = tenantMeetingPointsSettingService.getByTenantId(tenantId);
MeetingPointsAccount publicAccount = findPublicAccount(tenantId);
return toSettingVO(tenant, setting, publicAccount);
}
@Override
@Transactional(rollbackFor = Exception.class)
public TenantMeetingPointsSettingVO updateBalanceCheck(Long tenantId,
boolean balanceCheckEnabled,
String remark,
Long operatorUserId,
String operatorName) {
if (tenantId == null) {
throw new RuntimeException("租户不能为空");
}
SysTenantDTO tenant = sysTenantService.findById(tenantId);
if (tenant == null) {
throw new RuntimeException("租户不存在");
}
TenantMeetingPointsSetting setting = tenantMeetingPointsSettingService.getByTenantIdForUpdate(tenantId);
if (setting == null) {
tenantMeetingPointsSettingService.initializeTenantSetting(tenantId);
setting = tenantMeetingPointsSettingService.getByTenantIdForUpdate(tenantId);
}
if (setting == null) {
throw new RuntimeException("租户积分配置初始化失败");
}
setting.setStatus(1);
setting.setBalanceCheckEnabled(balanceCheckEnabled ? 1 : 0);
setting.setLastSwitchAt(LocalDateTime.now());
setting.setLastSwitchBy(operatorUserId);
setting.setLastSwitchByName(StringUtils.hasText(operatorName) ? operatorName.trim() : null);
setting.setRemark(StringUtils.hasText(remark) ? truncate(remark.trim(), 500) : null);
tenantMeetingPointsSettingService.updateById(setting);
MeetingPointsAccount publicAccount = findPublicAccount(tenantId);
return toSettingVO(tenant, setting, publicAccount);
}
private Map<Long, TenantMeetingPointsSetting> loadSettingMap(List<Long> tenantIds) {
if (tenantIds == null || tenantIds.isEmpty()) {
return Collections.emptyMap();
}
return tenantMeetingPointsSettingService.list(new LambdaQueryWrapper<TenantMeetingPointsSetting>()
.in(TenantMeetingPointsSetting::getTenantId, tenantIds))
.stream()
.collect(Collectors.toMap(TenantMeetingPointsSetting::getTenantId, item -> item, (left, right) -> left, HashMap::new));
}
private Map<Long, MeetingPointsAccount> loadPublicAccountMap(List<Long> tenantIds) {
if (tenantIds == null || tenantIds.isEmpty()) {
return Collections.emptyMap();
}
return meetingPointsAccountService.list(new LambdaQueryWrapper<MeetingPointsAccount>()
.in(MeetingPointsAccount::getTenantId, tenantIds)
.eq(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID))
.stream()
.collect(Collectors.toMap(MeetingPointsAccount::getTenantId, item -> item, (left, right) -> left, HashMap::new));
}
private MeetingPointsAccount findPublicAccount(Long tenantId) {
return meetingPointsAccountService.getOne(new LambdaQueryWrapper<MeetingPointsAccount>()
.eq(MeetingPointsAccount::getTenantId, tenantId)
.eq(MeetingPointsAccount::getUserId, PUBLIC_ACCOUNT_USER_ID)
.last("LIMIT 1"));
}
private TenantMeetingPointsSettingVO toSettingVO(SysTenantDTO tenant,
TenantMeetingPointsSetting setting,
MeetingPointsAccount publicAccount) {
TenantMeetingPointsSettingVO vo = new TenantMeetingPointsSettingVO();
vo.setTenantId(tenant == null ? null : tenant.getId());
vo.setTenantCode(tenant == null ? null : tenant.getTenantCode());
vo.setTenantName(tenant == null ? null : tenant.getTenantName());
boolean balanceCheckEnabled = setting == null || !Integer.valueOf(0).equals(setting.getBalanceCheckEnabled());
vo.setBalanceCheckEnabled(balanceCheckEnabled);
vo.setUnlimitedBalanceMode(!balanceCheckEnabled);
vo.setPublicBalance(publicAccount == null ? 0L : defaultLong(publicAccount.getCurrentBalance()));
vo.setPublicTotalPointsUsed(publicAccount == null ? 0L : defaultLong(publicAccount.getTotalPointsUsed()));
vo.setLastSwitchAt(setting == null ? null : setting.getLastSwitchAt());
vo.setLastSwitchByName(setting == null ? null : setting.getLastSwitchByName());
vo.setRemark(setting == null ? null : setting.getRemark());
return vo;
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
private long defaultLong(Long value) {
return value == null ? 0L : value;
}
}

View File

@ -0,0 +1,65 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.entity.biz.TenantMeetingPointsSetting;
import com.imeeting.mapper.biz.TenantMeetingPointsSettingMapper;
import com.imeeting.service.biz.TenantMeetingPointsSettingService;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class TenantMeetingPointsSettingServiceImpl
extends ServiceImpl<TenantMeetingPointsSettingMapper, TenantMeetingPointsSetting>
implements TenantMeetingPointsSettingService {
private final TenantMeetingPointsSettingMapper tenantMeetingPointsSettingMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void initializeTenantSetting(Long tenantId) {
if (tenantId == null) {
return;
}
if (tenantMeetingPointsSettingMapper.selectForUpdate(tenantId) != null) {
return;
}
TenantMeetingPointsSetting entity = new TenantMeetingPointsSetting();
entity.setTenantId(tenantId);
entity.setStatus(1);
entity.setBalanceCheckEnabled(1);
entity.setIsDeleted(0);
try {
save(entity);
} catch (DuplicateKeyException ignored) {
// 并发创建租户配置时直接复用已存在记录即可。
}
}
@Override
public boolean isBalanceCheckEnabled(Long tenantId) {
TenantMeetingPointsSetting entity = getByTenantId(tenantId);
return entity == null || !Integer.valueOf(0).equals(entity.getBalanceCheckEnabled());
}
@Override
public TenantMeetingPointsSetting getByTenantId(Long tenantId) {
if (tenantId == null) {
return null;
}
return getOne(new LambdaQueryWrapper<TenantMeetingPointsSetting>()
.eq(TenantMeetingPointsSetting::getTenantId, tenantId)
.last("LIMIT 1"));
}
@Override
public TenantMeetingPointsSetting getByTenantIdForUpdate(Long tenantId) {
if (tenantId == null) {
return null;
}
return tenantMeetingPointsSettingMapper.selectForUpdate(tenantId);
}
}

View File

@ -3,6 +3,7 @@ import http from "../http";
export interface MeetingPointsOverviewVO {
accountMode: string;
chargePriority: string;
balanceCheckEnabled: boolean;
publicBalance: number;
publicTotalPointsUsed: number;
personalBalance: number;
@ -84,6 +85,19 @@ export interface MeetingPointsLedgerDetailVO {
chargeItems?: MeetingPointsChargeItemVO[];
}
export interface TenantMeetingPointsSettingVO {
tenantId: number;
tenantCode?: string;
tenantName?: string;
balanceCheckEnabled: boolean;
unlimitedBalanceMode: boolean;
publicBalance: number;
publicTotalPointsUsed: number;
lastSwitchAt?: string;
lastSwitchByName?: string;
remark?: string;
}
export async function getMeetingPointsOverview() {
const resp = await http.get("/api/biz/meeting-points/management/overview");
return resp.data.data as MeetingPointsOverviewVO;
@ -112,3 +126,27 @@ export async function transferMeetingPoints(payload: {
const resp = await http.post("/api/biz/meeting-points/transfer", payload);
return resp.data.data as boolean;
}
export async function pageTenantMeetingPointsSettings(params: {
current: number;
size: number;
tenantName?: string;
tenantCode?: string;
balanceCheckEnabled?: boolean;
}) {
const resp = await http.get("/api/biz/tenant-meeting-points/settings", { params });
return resp.data.data as { records: TenantMeetingPointsSettingVO[]; total: number };
}
export async function getCurrentTenantMeetingPointsSetting() {
const resp = await http.get("/api/biz/tenant-meeting-points/settings/current");
return resp.data.data as TenantMeetingPointsSettingVO;
}
export async function updateTenantMeetingPointsBalanceCheck(tenantId: number, payload: {
balanceCheckEnabled: boolean;
remark?: string;
}) {
const resp = await http.put(`/api/biz/tenant-meeting-points/settings/${tenantId}/balance-check`, payload);
return resp.data.data as TenantMeetingPointsSettingVO;
}

View File

@ -1,10 +1,9 @@
import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { listUsers } from "@/api";
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
@ -14,8 +13,6 @@ import {
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from "antd";
@ -24,12 +21,9 @@ import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import {
getMeetingPointsLedgerDetail,
getMeetingPointsLedgerPage,
getMeetingPointsOverview,
transferMeetingPoints,
type MeetingPointsChargeItemVO,
type MeetingPointsLedgerDetailVO,
type MeetingPointsLedgerListItemVO,
type MeetingPointsOverviewVO,
type MeetingPointsPersonalAccountVO,
@ -99,30 +93,41 @@ function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
const isPersonalOnly = overview.accountMode === ACCOUNT_MODE_PERSONAL;
const showPublicSummary = !isPersonalOnly || isAdmin;
const showPersonalSummary = !isPublicOnly;
const isUnlimitedBalanceMode = overview.balanceCheckEnabled === false;
const cards: Array<{
key: string;
title: string;
value: number;
accent: string;
value: number | string;
note: string;
}> = [];
}> = [
{
key: "available-balance",
title: "当前可用额度",
value: isUnlimitedBalanceMode ? "无限" : (overview.totalAvailableBalance ?? 0),
note: isUnlimitedBalanceMode ? "关闭余额校验后只记录消耗和流水" : "按当前账户模式计算的可用额度",
},
{
key: "charge-count",
title: "累计消耗次数",
value: overview.totalChargeCount ?? 0,
note: "已发生扣费的总结记录数",
},
];
if (showPublicSummary) {
cards.push(
cards.unshift(
{
key: "public-balance",
title: "公共账户余额",
value: overview.publicBalance ?? 0,
accent: "rgba(24, 144, 255, 0.08)",
note: "用于统一分配和公共扣费",
note: "当前账面公共余额",
},
{
key: "public-used",
title: "公共账户累计消耗",
value: overview.publicTotalPointsUsed ?? 0,
accent: "rgba(82, 196, 26, 0.08)",
note: "公共账户历史累计扣减",
note: "公共账户历史累计消耗",
},
);
}
@ -133,41 +138,28 @@ function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
key: "personal-balance",
title: isAdmin ? "个人账户余额汇总" : "个人账户余额",
value: overview.personalBalance ?? 0,
accent: "rgba(114, 46, 209, 0.08)",
note: isAdmin ? "管理员视角下的个人账户汇总" : "当前账号可用积分",
note: isAdmin ? "管理员视角下的个人账户余额汇总" : "当前账号账面余额",
},
{
key: "personal-used",
title: isAdmin ? "个人账户累计消耗汇总" : "个人账户累计消耗",
value: overview.personalTotalPointsUsed ?? 0,
accent: "rgba(250, 140, 22, 0.08)",
note: isAdmin ? "管理员视角下的个人账户消耗汇总" : "当前账号历史累计扣减",
note: isAdmin ? "管理员视角下的个人账户累计消耗" : "当前账号历史累计消耗",
},
);
}
cards.push({
key: "charge-count",
title: "累计消耗次数",
value: overview.totalChargeCount ?? 0,
accent: "rgba(38, 38, 38, 0.06)",
note: "已发生扣费的总结记录数",
});
return cards;
}
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 [usersLoading, setUsersLoading] = 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 [contentTab, setContentTab] = useState("ledger");
const [personalAccountPagination, setPersonalAccountPagination] = useState({
@ -185,7 +177,8 @@ export default function MeetingPointsManagement() {
const isAdmin = Boolean(overview?.admin);
const isPublicOnly = overview?.accountMode === ACCOUNT_MODE_PUBLIC;
const isPersonalOnly = overview?.accountMode === ACCOUNT_MODE_PERSONAL;
const showTransferButton = isAdmin && !isPublicOnly;
const isUnlimitedBalanceMode = overview?.balanceCheckEnabled === false;
const showTransferButton = isAdmin && !isPublicOnly && !isUnlimitedBalanceMode;
const showPersonalAccountSection = Boolean(overview) && !isPublicOnly;
const summaryCards = useMemo(() => buildSummaryCards(overview), [overview]);
@ -279,17 +272,6 @@ export default function MeetingPointsManagement() {
message.success("已刷新积分数据");
};
const handleOpenDetail = async (ledgerId: number) => {
setDetailLoading(true);
setDetailOpen(true);
try {
const data = await getMeetingPointsLedgerDetail(ledgerId);
setDetail(data);
} finally {
setDetailLoading(false);
}
};
const handleOpenTransfer = async () => {
setTransferOpen(true);
if (!users.length) {
@ -361,17 +343,6 @@ export default function MeetingPointsManagement() {
width: 180,
render: (value: string) => <Text>{formatDateTime(value)}</Text>,
},
{
title: "操作",
key: "action",
width: 88,
fixed: "right" as const,
render: (_: unknown, record: MeetingPointsLedgerListItemVO) => (
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => void handleOpenDetail(record.id)}>
</Button>
),
},
];
const personalAccountColumns = [
@ -415,43 +386,8 @@ export default function MeetingPointsManagement() {
},
];
const chargeItemColumns = [
{
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",
},
];
const ledgerTableContent = (
<div style={{ display: "flex", flexDirection: "column" , height:"400px"}}>
<div style={{ display: "flex", flexDirection: "column", height: "400px" }}>
<div className="app-page__table-wrap" style={{ overflow: "auto", padding: "0 24px" }}>
<ListTable<MeetingPointsLedgerListItemVO>
rowKey="id"
@ -459,7 +395,7 @@ export default function MeetingPointsManagement() {
dataSource={records}
loading={loading}
totalCount={total}
scroll={{ x: 1200,y: 400 }}
scroll={{ x: 1100, y: 400 }}
pagination={false}
/>
</div>
@ -479,14 +415,14 @@ export default function MeetingPointsManagement() {
);
const personalAccountTableContent = (
<div style={{ display: "flex", flexDirection: "column",height:'400px' }}>
<div style={{ display: "flex", flexDirection: "column", height: "400px" }}>
<div className="app-page__table-wrap" style={{ overflow: "auto", padding: "0 24px" }}>
<ListTable<MeetingPointsPersonalAccountVO>
rowKey="userId"
columns={personalAccountColumns}
dataSource={pagedPersonalAccounts}
totalCount={personalAccountRows.length}
scroll={{ x: 1200,y: 400 }}
scroll={{ x: 900, y: 400 }}
pagination={false}
/>
</div>
@ -506,7 +442,7 @@ export default function MeetingPointsManagement() {
return (
<PageContainer
title="积分管理"
subtitle="根据当前扣费模式查看公共账户、个人账户和会议积分消耗轨迹"
subtitle="查看当前租户下的积分账面余额、累计消耗和会议消耗记录"
style={{ height: "auto" }}
headerExtra={
<Space>
@ -564,12 +500,16 @@ export default function MeetingPointsManagement() {
<Tag color="blue" bordered={false}>
{getChargePriorityLabel(overview?.chargePriority)}
</Tag>
<Tag color={isUnlimitedBalanceMode ? "volcano" : "green"} bordered={false}>
{isUnlimitedBalanceMode ? "无限余额模式" : "校验余额模式"}
</Tag>
<Tag color={isAdmin ? "gold" : "default"} bordered={false}>
{isAdmin ? "管理员视角" : "当前用户视角"}
</Tag>
</Space>
</div>
<Row gutter={[40, 16]}>
{summaryCards.map((item) => (
<Col key={item.key}>
@ -586,26 +526,22 @@ export default function MeetingPointsManagement() {
{isPersonalOnly ? (
<Card className="app-page__content-card" styles={{ body: { padding: 0 } }}>
<Tabs
activeKey={contentTab}
onChange={setContentTab}
items={[
{
key: "ledger",
label: "积分明细",
children: ledgerTableContent,
},
{
key: "personal-account",
label: "个人账户",
children: personalAccountTableContent,
},
]}
/>
<div style={{ padding: "20px 24px 8px" }}>
<Text strong style={{ fontSize: 16 }}>
</Text>
</div>
{ledgerTableContent}
</Card>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Card className="app-page__content-card" styles={{ body: { padding: 0 } }}>
<div style={{ padding: "20px 24px 8px" }}>
<Text strong style={{ fontSize: 16 }}>
</Text>
</div>
{ledgerTableContent}
</Card>
@ -627,61 +563,6 @@ export default function MeetingPointsManagement() {
</div>
)}
<Modal
title="积分流水详情"
open={detailOpen}
onCancel={() => {
setDetailOpen(false);
setDetail(null);
}}
footer={[
<Button
key="close"
onClick={() => {
setDetailOpen(false);
setDetail(null);
}}
>
</Button>,
]}
width={900}
confirmLoading={detailLoading}
>
{detail && (
<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}
dataSource={detail.chargeItems || []}
/>
</Card>
</Space>
)}
</Modal>
<Modal
title="从公共账户分配积分"
open={transferOpen}

View File

@ -21,6 +21,7 @@ const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"))
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement"));
const TenantMeetingPointsSettings = lazy(() => import("@/pages/business/TenantMeetingPointsSettings"));
const LicenseManagement = lazy(() => import("@/pages/business/LicenseManagement"));
import SpeakerReg from "../pages/business/SpeakerReg";
@ -70,6 +71,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" },
{ path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" },
{ path: "/meeting-points", label: "积分管理", element: <LazyPage><MeetingPointsManagement /></LazyPage>, perm: "menu:meeting-points" },
{ path: "/tenant-meeting-points", label: "租户积分校验", element: <LazyPage><TenantMeetingPointsSettings /></LazyPage>, perm: "menu:tenant-meeting-points" },
{ path: "/meetings", label: "会议中心", element: <LazyPage><Meetings /></LazyPage>, perm: "menu:meeting" },
];