<think>我们需要设计一个套餐暂停功能,允许用户临时冻结会员资格,暂停期间不享受会员权益,也不计入会员有效期。暂停结束后自动恢复会员资格,并顺延有效期。
设计要点:
1. 用户界面:提供暂停申请、恢复、查看暂停记录等功能
2. 业务规则:
- 暂停次数限制(如每年最多暂停3次)
- 每次暂停时长限制(如7-30天)
- 暂停期间不扣费,不计算有效期
- 暂停结束后,会员有效期顺延暂停天数
3. 状态管理:订阅状态增加“暂停中”
4. 定时任务:处理暂停结束和恢复
数据库设计:
在订阅表中增加暂停相关字段,并创建暂停记录表。
步骤:
1. 用户申请暂停,选择暂停天数(在允许范围内)
2. 验证是否符合暂停条件(如次数限制、当前状态等)
3. 创建暂停记录,更新订阅状态为“暂停中”,并设置暂停结束时间
4. 暂停结束时间到达时,系统自动恢复会员资格,并更新订阅有效期
下面我们详细实现:</think>### 套餐暂停功能设计与实现
套餐暂停功能允许用户临时冻结会员资格,暂停期间不享受会员权益,也不计入会员有效期。以下是完整的实现方案:
#### 系统架构设计
```
暂停请求 → 暂停验证引擎 → 暂停执行服务 → 状态管理服务
↑ ↓ ↓ ↓
用户界面 规则检查 有效期调整 定时恢复任务
```
#### 核心数据库设计
```sql
-- 订阅表新增暂停相关字段
ALTER TABLE subscriptions ADD COLUMN (
is_paused BOOLEAN DEFAULT false COMMENT '是否暂停中',
pause_start_date DATE COMMENT '暂停开始日期',
pause_end_date DATE COMMENT '暂停结束日期',
pause_days_used INT DEFAULT 0 COMMENT '已使用暂停天数',
pause_days_remaining INT DEFAULT 0 COMMENT '剩余暂停天数',
pause_reason VARCHAR(255) COMMENT '暂停原因'
);
-- 暂停记录表
CREATE TABLE pause_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
subscription_id BIGINT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
requested_days INT NOT NULL COMMENT '请求暂停天数',
actual_days INT NOT NULL COMMENT '实际暂停天数',
status ENUM('ACTIVE', 'COMPLETED', 'CANCELLED') DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);
-- 暂停策略表
CREATE TABLE pause_policies (
plan_id BIGINT NOT NULL,
max_pause_days_per_year INT DEFAULT 30 COMMENT '每年最大暂停天数',
max_pause_instances INT DEFAULT 2 COMMENT '每年最大暂停次数',
min_days_before_pause INT DEFAULT 7 COMMENT '使用套餐后至少多少天才能暂停',
advance_notice_days INT DEFAULT 3 COMMENT '提前通知天数',
PRIMARY KEY (plan_id)
);
```
#### 1. 暂停策略服务
```java
@Service
public class PausePolicyService {
private final PausePolicyRepository policyRepo;
// 获取套餐的暂停策略
public PausePolicy getPolicyForPlan(Long planId) {
return policyRepo.findById(planId)
.orElseGet(() -> new PausePolicy(planId)); // 返回默认策略
}
// 验证暂停请求是否合规
public void validatePauseRequest(Subscription sub, int requestedDays) {
PausePolicy policy = getPolicyForPlan(sub.getPlanId());
LocalDate today = LocalDate.now();
// 检查是否在套餐有效期内
if (today.isBefore(sub.getStartDate()) || today.isAfter(sub.getEndDate())) {
throw new InvalidPauseException("套餐不在有效期内");
}
// 检查最短使用时间要求
long daysUsed = ChronoUnit.DAYS.between(sub.getStartDate(), today);
if (daysUsed < policy.getMinDaysBeforePause()) {
throw new InvalidPauseException("使用套餐不足" + policy.getMinDaysBeforePause() + "天,无法暂停");
}
// 检查年度暂停次数限制
int pausesThisYear = pauseRecordRepo.countPausesThisYear(sub.getId());
if (pausesThisYear >= policy.getMaxPauseInstances()) {
throw new InvalidPauseException("已达到年度最大暂停次数");
}
// 检查剩余暂停天数
int remainingDays = policy.getMaxPauseDaysPerYear() -
pauseRecordRepo.getUsedPauseDaysThisYear(sub.getId());
if (requestedDays > remainingDays) {
throw new InvalidPauseException("请求天数超过剩余可用暂停天数");
}
}
}
```
#### 2. 暂停执行服务
```java
@Service
@Transactional
public class PauseExecutionService {
private final SubscriptionRepository subRepo;
private final PauseRecordRepository pauseRecordRepo;
private final BenefitService benefitService;
private final NotificationService notificationService;
// 处理暂停请求
public PauseRecord processPauseRequest(Long subscriptionId, int days, String reason) {
Subscription sub = subRepo.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));
// 验证请求
PausePolicyService.validatePauseRequest(sub, days);
// 创建暂停记录
PauseRecord record = new PauseRecord();
record.setSubscriptionId(subscriptionId);
record.setStartDate(LocalDate.now());
record.setEndDate(LocalDate.now().plusDays(days));
record.setRequestedDays(days);
record.setActualDays(days);
record.setStatus("ACTIVE");
pauseRecordRepo.save(record);
// 更新订阅状态
sub.setPaused(true);
sub.setPauseStartDate(LocalDate.now());
sub.setPauseEndDate(record.getEndDate());
sub.setPauseDaysUsed(0);
sub.setPauseDaysRemaining(days);
sub.setPauseReason(reason);
subRepo.save(sub);
// 暂停用户权益
benefitService.suspendBenefits(sub.getUserId());
// 发送通知
notificationService.sendPauseConfirmation(sub, record);
// 调度恢复任务
scheduleResumption(record);
return record;
}
// 提前恢复套餐
public void resumeSubscriptionEarly(Long subscriptionId) {
Subscription sub = subRepo.findById(subscriptionId)
.orElseThrow(() -> new SubscriptionNotFoundException(subscriptionId));
if (!sub.isPaused()) {
throw new InvalidResumeException("套餐未处于暂停状态");
}
// 计算实际暂停天数
long actualPauseDays = ChronoUnit.DAYS.between(
sub.getPauseStartDate(), LocalDate.now());
// 更新订阅
sub.setPaused(false);
sub.setPauseDaysUsed((int) actualPauseDays);
sub.setPauseDaysRemaining(0);
subRepo.save(sub);
// 更新暂停记录
PauseRecord record = pauseRecordRepo.findActiveBySubscription(subscriptionId);
record.setActualDays((int) actualPauseDays);
record.setStatus("COMPLETED");
pauseRecordRepo.save(record);
// 恢复用户权益
benefitService.restoreBenefits(sub.getUserId());
// 调整套餐有效期
adjustSubscriptionEndDate(sub, actualPauseDays);
// 发送通知
notificationService.sendEarlyResumeNotification(sub, record);
}
// 调整套餐有效期
private void adjustSubscriptionEndDate(Subscription sub, long pauseDays) {
LocalDate newEndDate = sub.getEndDate().plusDays(pauseDays);
sub.setEndDate(newEndDate);
subRepo.save(sub);
}
}
```
#### 3. 定时恢复服务
```java
@Service
public class SubscriptionResumptionService {
private final SubscriptionRepository subRepo;
private final PauseRecordRepository pauseRecordRepo;
private final BenefitService benefitService;
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void processScheduledResumptions() {
LocalDate today = LocalDate.now();
// 查找需要恢复的订阅
List<Subscription> subscriptionsToResume = subRepo.findSubscriptionsToResume(today);
for (Subscription sub : subscriptionsToResume) {
try {
resumeSubscription(sub);
} catch (Exception e) {
logger.error("恢复订阅失败: {}", sub.getId(), e);
handleResumeFailure(sub, e);
}
}
}
private void resumeSubscription(Subscription sub) {
// 计算实际暂停天数
long actualPauseDays = ChronoUnit.DAYS.between(
sub.getPauseStartDate(), LocalDate.now());
// 更新订阅状态
sub.setPaused(false);
sub.setPauseDaysUsed((int) actualPauseDays);
sub.setPauseDaysRemaining(0);
subRepo.save(sub);
// 更新暂停记录
PauseRecord record = pauseRecordRepo.findActiveBySubscription(sub.getId());
record.setActualDays((int) actualPauseDays);
record.setStatus("COMPLETED");
pauseRecordRepo.save(record);
// 恢复用户权益
benefitService.restoreBenefits(sub.getUserId());
// 调整套餐有效期
adjustSubscriptionEndDate(sub, actualPauseDays);
// 发送通知
notificationService.sendResumptionNotification(sub, record);
}
private void adjustSubscriptionEndDate(Subscription sub, long pauseDays) {
LocalDate newEndDate = sub.getEndDate().plusDays(pauseDays);
sub.setEndDate(newEndDate);
subRepo.save(sub);
}
private void handleResumeFailure(Subscription sub, Exception e) {
// 重试机制:使用指数退避策略
int retryCount = sub.getResumeRetryCount() != null ? sub.getResumeRetryCount() : 0;
if (retryCount < 3) {
// 计划重试
Duration delay = Duration.ofMinutes((long) Math.pow(2, retryCount));
resumptionRetryQueue.scheduleRetry(sub.getId(), delay);
// 更新重试计数
sub.setResumeRetryCount(retryCount + 1);
subRepo.save(sub);
} else {
// 超过最大重试次数,标记为失败
sub.setPauseStatus("RESUME_FAILED");
subRepo.save(sub);
// 通知管理员
adminNotificationService.notifyResumeFailure(sub, e);
}
}
}
```
#### 4. 权益管理服务
```java
@Service
public class BenefitManagementService {
private final UserAccessService accessService;
private final DownloadService downloadService;
private final ContentService contentService;
// 暂停时冻结权益
public void suspendBenefits(Long userId) {
// 限制内容访问
accessService.disablePremiumAccess(userId);
// 暂停离线下载
downloadService.pauseActiveDownloads(userId);
// 限制设备数量
accessService.reduceConcurrentDevices(userId, 1);
// 清除缓存
contentService.clearUserCache(userId);
// 更新用户状态
userService.updateUserStatus(userId, UserStatus.PAUSED);
}
// 恢复时重新激活权益
public void restoreBenefits(Long userId) {
// 恢复内容访问
accessService.restorePremiumAccess(userId);
// 恢复离线下载
downloadService.resumePausedDownloads(userId);
// 恢复设备限制
accessService.restoreDeviceLimit(userId);
// 刷新用户缓存
contentService.refreshUserCache(userId);
// 更新用户状态
userService.updateUserStatus(userId, UserStatus.ACTIVE);
}
}
```
### 关键设计要点
#### 1. 暂停状态机管理
```java
public class PauseStateMachine {
private static final Map<PauseState, Map<PauseEvent, PauseState>> TRANSITIONS = Map.of(
PauseState.ACTIVE, Map.of(
PauseEvent.EARLY_RESUME, PauseState.COMPLETED,
PauseEvent.SCHEDULED_RESUME, PauseState.COMPLETED,
PauseEvent.CANCEL, PauseState.CANCELLED
),
PauseState.COMPLETED, Map.of(
PauseEvent.REVERT, PauseState.ACTIVE
),
PauseState.CANCELLED, Map.of(
PauseEvent.REVERT, PauseState.ACTIVE
)
);
public PauseState transition(PauseState current, PauseEvent event) {
Map<PauseEvent, PauseState> events = TRANSITIONS.get(current);
if (events == null || !events.containsKey(event)) {
throw new IllegalStateException("无效状态转换: " + current + " -> " + event);
}
return events.get(event);
}
}
enum PauseState { ACTIVE, COMPLETED, CANCELLED }
enum PauseEvent { EARLY_RESUME, SCHEDULED_RESUME, CANCEL, REVERT }
```
#### 2. 有效期调整算法
```java
public class ValidityAdjustmentService {
// 计算暂停后的新有效期
public LocalDate calculateNewEndDate(Subscription sub, long pauseDays) {
MembershipPlan plan = planRepo.findById(sub.getPlanId())
.orElseThrow(() -> new PlanNotFoundException(sub.getPlanId()));
// 根据套餐类型调整有效期
switch (plan.getBillingCycle()) {
case MONTHLY:
return adjustMonthlyPlan(sub, pauseDays);
case ANNUAL:
return adjustAnnualPlan(sub, pauseDays);
case LIFETIME:
// 终身会员无需调整
return sub.getEndDate();
default:
// 默认按天计算
return sub.getEndDate().plusDays(pauseDays);
}
}
private LocalDate adjustMonthlyPlan(Subscription sub, long pauseDays) {
// 计算完整的计费周期
long fullCycles = pauseDays / 30;
long remainingDays = pauseDays % 30;
// 每月按30天计算
LocalDate newEndDate = sub.getEndDate()
.plusMonths(fullCycles)
.plusDays(remainingDays);
return newEndDate;
}
private LocalDate adjustAnnualPlan(Subscription sub, long pauseDays) {
// 每年按365天计算
long fullYears = pauseDays / 365;
long remainingDays = pauseDays % 365;
LocalDate newEndDate = sub.getEndDate()
.plusYears(fullYears)
.plusDays(remainingDays);
return newEndDate;
}
}
```
#### 3. 暂停冲突检测
```java
public class PauseConflictDetector {
public void checkForConflicts(Subscription sub) {
// 检查是否有未完成的订单
if (orderService.hasPendingOrders(sub.getUserId())) {
throw new PauseConflictException("存在未完成的订单,无法暂停套餐");
}
// 检查是否在活动促销期内
if (promotionService.isInPromotionPeriod(sub)) {
throw new PauseConflictException("促销期内无法暂停套餐");
}
// 检查是否有未使用的权益
if (benefitService.hasPendingBenefits(sub.getUserId())) {
throw new PauseConflictException("存在未使用的权益,无法暂停套餐");
}
// 检查账户状态
if (userService.isAccountLocked(sub.getUserId())) {
throw new PauseConflictException("账户被锁定,无法暂停套餐");
}
}
}
```
### API接口设计
#### 用户接口
```java
@RestController
@RequestMapping("/api/subscription")
public class SubscriptionController {
// 申请套餐暂停
@PostMapping("/{subscriptionId}/pause")
public PauseRecord requestPause(
@PathVariable Long subscriptionId,
@RequestBody PauseRequest request) {
return pauseService.processPauseRequest(
subscriptionId,
request.getDays(),
request.getReason()
);
}
// 提前恢复套餐
@PostMapping("/{subscriptionId}/resume")
public ResponseEntity<?> resumeSubscription(
@PathVariable Long subscriptionId) {
pauseService.resumeSubscriptionEarly(subscriptionId);
return ResponseEntity.ok().build();
}
// 获取暂停历史
@GetMapping("/{subscriptionId}/pause-history")
public List<PauseRecord> getPauseHistory(
@PathVariable Long subscriptionId) {
return pauseRecordRepo.findBySubscriptionId(subscriptionId);
}
// 获取可用的暂停天数
@GetMapping("/{subscriptionId}/available-pause-days")
public PauseAvailability getAvailablePauseDays(
@PathVariable Long subscriptionId) {
Subscription sub = subRepo.findById(subscriptionId).orElseThrow();
PausePolicy policy = policyService.getPolicyForPlan(sub.getPlanId());
int usedDays = pauseRecordRepo.getUsedPauseDaysThisYear(subscriptionId);
int remainingDays = policy.getMaxPauseDaysPerYear() - usedDays;
return new PauseAvailability(
remainingDays,
policy.getMaxPauseDaysPerYear(),
policy.getMaxPauseInstances() - pauseRecordRepo.countPausesThisYear(subscriptionId)
);
}
}
```
#### 管理接口
```java
@RestController
@RequestMapping("/admin/subscriptions")
public class SubscriptionAdminController {
// 强制暂停套餐(管理员操作)
@PostMapping("/{subscriptionId}/force-pause")
public PauseRecord forcePause(
@PathVariable Long subscriptionId,
@RequestBody ForcePauseRequest request) {
// 验证管理员权限
if (!authService.isAdmin()) {
throw new AccessDeniedException("需要管理员权限");
}
// 执行暂停
return pauseService.processPauseRequest(
subscriptionId,
request.getDays(),
request.getReason()
);
}
// 强制恢复套餐(管理员操作)
@PostMapping("/{subscriptionId}/force-resume")
public ResponseEntity<?> forceResume(
@PathVariable Long subscriptionId) {
// 验证管理员权限
if (!authService.isAdmin()) {
throw new AccessDeniedException("需要管理员权限");
}
pauseService.resumeSubscriptionEarly(subscriptionId);
return ResponseEntity.ok().build();
}
}
```
### 安全与合规设计
#### 1. 暂停确认流程
```java
public class PauseConfirmationService {
public void sendConfirmation(Subscription sub, PauseRecord record) {
// 生成唯一确认令牌
String token = generateConfirmationToken(sub.getId());
// 存储到缓存(有效期24小时)
redisTemplate.opsForValue().set(
"pause:confirmation:" + sub.getId(),
token,
24, TimeUnit.HOURS
);
// 发送确认邮件
emailService.sendPauseConfirmation(
sub.getUser().getEmail(),
token,
record
);
}
public boolean validateConfirmation(Long subscriptionId, String token) {
String storedToken = redisTemplate.opsForValue().get(
"pause:confirmation:" + subscriptionId);
return token != null && token.equals(storedToken);
}
}
```
#### 2. 数据保留策略
```java
public class PauseDataRetention {
// 清理旧的暂停记录
@Scheduled(cron = "0 0 4 * * ?") // 每天凌晨4点执行
public void cleanupOldPauseRecords() {
LocalDate cutoff = LocalDate.now().minusYears(2);
List<PauseRecord> oldRecords = pauseRecordRepo.findCompletedBefore(cutoff);
pauseRecordRepo.deleteAll(oldRecords);
}
}
```
### 性能优化方案
#### 1. 批量暂停处理
```java
public class BatchPauseProcessor {
@Transactional
public void processBatchPauses(List<Long> subscriptionIds, int days, String reason) {
// 分批次处理(每批50个)
Lists.partition(subscriptionIds, 50).forEach(batch -> {
batch.parallelStream().forEach(subId -> {
try {
pauseService.processPauseRequest(subId, days, reason);
} catch (Exception e) {
logger.error("批量暂停失败: {}", subId, e);
}
});
});
}
}
```
#### 2. 暂停状态缓存
```java
public class PauseStatusCache {
private final Cache<Long, PauseStatus> statusCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
public PauseStatus getPauseStatus(Long subscriptionId) {
return statusCache.get(subscriptionId, key -> {
Subscription sub = subRepo.findById(key).orElseThrow();
return new PauseStatus(
sub.isPaused(),
sub.getPauseStartDate(),
sub.getPauseEndDate(),
sub.getPauseDaysRemaining()
);
});
}
}
```
---
###