diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index 784ac2e..4fb8bf2 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -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"; /** 临时授权默认有效期,单位月。 */ diff --git a/backend/src/main/java/com/imeeting/controller/biz/TenantMeetingPointsSettingController.java b/backend/src/main/java/com/imeeting/controller/biz/TenantMeetingPointsSettingController.java new file mode 100644 index 0000000..7c12bbf --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/TenantMeetingPointsSettingController.java @@ -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>> 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 getCurrentSetting() { + LoginUser loginUser = currentLoginUser(); + ensureAdmin(loginUser); + return ApiResponse.ok(tenantMeetingPointsManagementService.getCurrentTenantSetting(loginUser.getTenantId())); + } + + @Operation(summary = "更新租户积分余额校验开关") + @PutMapping("/{tenantId}/balance-check") + @PreAuthorize("isAuthenticated()") + public ApiResponse 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(); + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java index a7fdf2c..5f9fe8d 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsBalanceVO.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java index e982d8a..f3d900e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsOverviewVO.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsPersonalAccountVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsPersonalAccountVO.java new file mode 100644 index 0000000..9fd0890 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingPointsPersonalAccountVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/TenantMeetingPointsSettingVO.java b/backend/src/main/java/com/imeeting/dto/biz/TenantMeetingPointsSettingVO.java new file mode 100644 index 0000000..786c180 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/TenantMeetingPointsSettingVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/UpdateTenantMeetingPointsBalanceCheckCommand.java b/backend/src/main/java/com/imeeting/dto/biz/UpdateTenantMeetingPointsBalanceCheckCommand.java new file mode 100644 index 0000000..5be0d25 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/UpdateTenantMeetingPointsBalanceCheckCommand.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingPointsLedger.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingPointsLedger.java index b8ef663..2dbac07 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/MeetingPointsLedger.java +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingPointsLedger.java @@ -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; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingSummaryChargeRecord.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingSummaryChargeRecord.java index 3bcfeae..2d9d6ac 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/MeetingSummaryChargeRecord.java +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingSummaryChargeRecord.java @@ -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; diff --git a/backend/src/main/java/com/imeeting/entity/biz/TenantMeetingPointsSetting.java b/backend/src/main/java/com/imeeting/entity/biz/TenantMeetingPointsSetting.java new file mode 100644 index 0000000..8b1c395 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/TenantMeetingPointsSetting.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/TenantMeetingPointsSettingMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/TenantMeetingPointsSettingMapper.java new file mode 100644 index 0000000..f8e61a8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/TenantMeetingPointsSettingMapper.java @@ -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 { + @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); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/TenantMeetingPointsManagementService.java b/backend/src/main/java/com/imeeting/service/biz/TenantMeetingPointsManagementService.java new file mode 100644 index 0000000..191a9a9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/TenantMeetingPointsManagementService.java @@ -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> 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); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/TenantMeetingPointsSettingService.java b/backend/src/main/java/com/imeeting/service/biz/TenantMeetingPointsSettingService.java new file mode 100644 index 0000000..2f22cac --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/TenantMeetingPointsSettingService.java @@ -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 { + void initializeTenantSetting(Long tenantId); + + boolean isBalanceCheckEnabled(Long tenantId); + + TenantMeetingPointsSetting getByTenantId(Long tenantId); + + TenantMeetingPointsSetting getByTenantIdForUpdate(Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java index e4131b6..84dbcda 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsQueryServiceImpl.java @@ -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); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java index f344766..0c87a54 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPointsServiceImpl.java @@ -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 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 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 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() { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java index 00e87fa..9db3efa 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/TenantManagementServicePrimaryImpl.java @@ -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> 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> 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("当前为单租户模式,不允许禁用默认租户"); + } + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/TenantMeetingPointsManagementServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/TenantMeetingPointsManagementServiceImpl.java new file mode 100644 index 0000000..2465418 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/TenantMeetingPointsManagementServiceImpl.java @@ -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> pageSettings(Integer current, + Integer size, + String tenantName, + String tenantCode, + Boolean balanceCheckEnabled) { + PageResult> tenantPage = sysTenantService.page(current, size, tenantName, tenantCode); + List tenants = tenantPage == null || tenantPage.getRecords() == null + ? Collections.emptyList() + : tenantPage.getRecords(); + List tenantIds = tenants.stream() + .map(SysTenantDTO::getId) + .filter(Objects::nonNull) + .toList(); + Map settingMap = loadSettingMap(tenantIds); + Map publicAccountMap = loadPublicAccountMap(tenantIds); + + List 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> 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 loadSettingMap(List tenantIds) { + if (tenantIds == null || tenantIds.isEmpty()) { + return Collections.emptyMap(); + } + return tenantMeetingPointsSettingService.list(new LambdaQueryWrapper() + .in(TenantMeetingPointsSetting::getTenantId, tenantIds)) + .stream() + .collect(Collectors.toMap(TenantMeetingPointsSetting::getTenantId, item -> item, (left, right) -> left, HashMap::new)); + } + + private Map loadPublicAccountMap(List tenantIds) { + if (tenantIds == null || tenantIds.isEmpty()) { + return Collections.emptyMap(); + } + return meetingPointsAccountService.list(new LambdaQueryWrapper() + .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() + .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; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/TenantMeetingPointsSettingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/TenantMeetingPointsSettingServiceImpl.java new file mode 100644 index 0000000..3d6cd4f --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/TenantMeetingPointsSettingServiceImpl.java @@ -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 + 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() + .eq(TenantMeetingPointsSetting::getTenantId, tenantId) + .last("LIMIT 1")); + } + + @Override + public TenantMeetingPointsSetting getByTenantIdForUpdate(Long tenantId) { + if (tenantId == null) { + return null; + } + return tenantMeetingPointsSettingMapper.selectForUpdate(tenantId); + } +} diff --git a/frontend/src/api/business/meetingPoints.ts b/frontend/src/api/business/meetingPoints.ts index c735060..ab7588b 100644 --- a/frontend/src/api/business/meetingPoints.ts +++ b/frontend/src/api/business/meetingPoints.ts @@ -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; +} diff --git a/frontend/src/pages/business/MeetingPointsManagement.tsx b/frontend/src/pages/business/MeetingPointsManagement.tsx index ece57e1..0484405 100644 --- a/frontend/src/pages/business/MeetingPointsManagement.tsx +++ b/frontend/src/pages/business/MeetingPointsManagement.tsx @@ -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(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([]); const [total, setTotal] = useState(0); - const [detailOpen, setDetailOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); - const [detail, setDetail] = useState(null); const [users, setUsers] = useState([]); const [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) => {formatDateTime(value)}, }, - { - title: "操作", - key: "action", - width: 88, - fixed: "right" as const, - render: (_: unknown, record: MeetingPointsLedgerListItemVO) => ( - - ), - }, ]; const personalAccountColumns = [ @@ -415,43 +386,8 @@ export default function MeetingPointsManagement() { }, ]; - const chargeItemColumns = [ - { - title: "阶段", - dataIndex: "chargeStage", - key: "chargeStage", - render: (value: string) => {getPointsTypeLabel(value)}, - }, - { - title: "扣费账户", - dataIndex: "accountType", - key: "accountType", - render: (value: string) => getAccountTypeLabel(value), - }, - { - title: "账户用户ID", - dataIndex: "accountUserId", - key: "accountUserId", - }, - { - title: "扣费积分", - dataIndex: "chargedPoints", - key: "chargedPoints", - }, - { - title: "扣费前余额", - dataIndex: "balanceBefore", - key: "balanceBefore", - }, - { - title: "扣费后余额", - dataIndex: "balanceAfter", - key: "balanceAfter", - }, - ]; - const ledgerTableContent = ( -
+
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} />
@@ -479,14 +415,14 @@ export default function MeetingPointsManagement() { ); const personalAccountTableContent = ( -
+
rowKey="userId" columns={personalAccountColumns} dataSource={pagedPersonalAccounts} totalCount={personalAccountRows.length} - scroll={{ x: 1200,y: 400 }} + scroll={{ x: 900, y: 400 }} pagination={false} />
@@ -506,7 +442,7 @@ export default function MeetingPointsManagement() { return ( @@ -564,12 +500,16 @@ export default function MeetingPointsManagement() { 优先级:{getChargePriorityLabel(overview?.chargePriority)} + + {isUnlimitedBalanceMode ? "无限余额模式" : "校验余额模式"} + {isAdmin ? "管理员视角" : "当前用户视角"}
+ {summaryCards.map((item) => ( @@ -586,26 +526,22 @@ export default function MeetingPointsManagement() { {isPersonalOnly ? ( - +
+ + 积分流水 + +
+ {ledgerTableContent}
) : (
+
+ + 积分流水 + + +
{ledgerTableContent}
@@ -627,61 +563,6 @@ export default function MeetingPointsManagement() {
)} - - - - { - setDetailOpen(false); - setDetail(null); - }} - footer={[ - , - ]} - width={900} - confirmLoading={detailLoading} - > - {detail && ( - - - {detail.ownerUserName || "-"} - {getAccountTypeLabel(detail.chargeAccountType)} - {getPointsTypeLabel(detail.pointsType)} - {detail.consumedPoints ?? 0} - {formatDateTime(detail.createdAt)} - {detail.meetingTitle || "-"} - {getChargeTriggerLabel(detail.chargeTriggerType)} - {detail.audioDurationSeconds ?? "-"} - {detail.chargedMinutes ?? "-"} - {detail.totalPoints ?? 0} - {detail.chargedTotalPoints ?? 0} - {detail.balanceBefore ?? "-"} - {detail.balanceAfter ?? "-"} - - - - - rowKey="id" - size="small" - pagination={false} - columns={chargeItemColumns} - dataSource={detail.chargeItems || []} - /> - - - )} - - 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: , perm: "menu:external-apps" }, { path: "/screen-savers", label: "屏保管理", element: , perm: "menu:screen-savers" }, { path: "/meeting-points", label: "积分管理", element: , perm: "menu:meeting-points" }, + { path: "/tenant-meeting-points", label: "租户积分校验", element: , perm: "menu:tenant-meeting-points" }, { path: "/meetings", label: "会议中心", element: , perm: "menu:meeting" }, ];