feat(pin): 重构置顶逻辑并抽离PkPinService

- 将UserController中置顶/取消置顶逻辑下沉到PkPinService,统一事务与异常处理
- UserDao新增原子增减积分方法,避免并发扣减问题
- VVTools抽取SECONDS_PER_HOUR常量并修复向上取整计算
- 新增EpochSecondProvider等接口与实现,为后续测试提供时钟桩
- 补充PkPinServiceImplTests单元测试,覆盖置顶成功、积分不足、重复取消等场景
This commit is contained in:
2026-02-26 21:53:06 +08:00
parent c985d14181
commit 994d71a10c
10 changed files with 353 additions and 71 deletions

View File

@@ -0,0 +1,6 @@
package vvpkassistant.Tools;
public interface EpochSecondProvider {
long nowEpochSecond();
}

View File

@@ -0,0 +1,12 @@
package vvpkassistant.Tools;
import org.springframework.stereotype.Component;
@Component
public class SystemEpochSecondProvider implements EpochSecondProvider {
@Override
public long nowEpochSecond() {
return VVTools.currentTimeStamp();
}
}

View File

@@ -17,6 +17,8 @@ import java.util.Map;
************************/
public class VVTools {
private static final long SECONDS_PER_HOUR = 3600L;
// 获取当前时间戳
public static long currentTimeStamp() {
long timeStamp = Calendar.getInstance().getTimeInMillis() / 1000;
@@ -117,12 +119,12 @@ public class VVTools {
public static long calculateHoursRound(long expireTime, long currentTime) {
if (expireTime <= currentTime) return 0;
long diffSeconds = expireTime - currentTime;
return diffSeconds / 3600;
return (diffSeconds + SECONDS_PER_HOUR - 1) / SECONDS_PER_HOUR;
}
// 返还积分用不足1小时忽略
public static long calculateHoursFloor(long expireTime, long currentTime) {
if (expireTime <= currentTime) return 0;
return (expireTime - currentTime) / 3600;
return (expireTime - currentTime) / SECONDS_PER_HOUR;
}
}

View File

@@ -8,6 +8,21 @@ import vvpkassistant.User.model.UserModel;
@Mapper
public interface UserDao extends BaseMapper<UserModel> {
// 原子扣减积分:当 points >= cost 时扣减,返回受影响行数(1=成功0=积分不足/用户不存在)
default int decreasePointsIfEnough(Integer userId, int cost) {
return update(null, Wrappers.<UserModel>lambdaUpdate()
.eq(UserModel::getId, userId)
.ge(UserModel::getPoints, cost)
.setSql("points = points - " + cost));
}
// 原子增加积分:返回受影响行数(1=成功0=用户不存在)
default int increasePoints(Integer userId, int amount) {
return update(null, Wrappers.<UserModel>lambdaUpdate()
.eq(UserModel::getId, userId)
.setSql("points = points + " + amount));
}
// 根据用户的手机号查询用户
default UserModel queryWithPhoneNumber(String phoneNumber) {
return selectOne(Wrappers.<UserModel>lambdaQuery()

View File

@@ -0,0 +1,6 @@
package vvpkassistant.config;
public interface FunctionConfigProvider {
String getValue(String functionName);
}

View File

@@ -0,0 +1,12 @@
package vvpkassistant.config;
import org.springframework.stereotype.Component;
@Component
public class HolderBackedFunctionConfigProvider implements FunctionConfigProvider {
@Override
public String getValue(String functionName) {
return FunctionConfigHolder.getValue(functionName);
}
}

View File

@@ -6,7 +6,6 @@ import org.springframework.web.bind.annotation.*;
import vvpkassistant.CoinRecords.CoinRecords;
import vvpkassistant.CoinRecords.CoinRecordsDao;
import vvpkassistant.Data.ResponseData;
import vvpkassistant.Data.ResponseInfo;
import vvpkassistant.Data.WxChatParam;
import vvpkassistant.User.mapper.UserDao;
import vvpkassistant.User.mapper.SignInRecordDao;
@@ -35,6 +34,7 @@ import vvpkassistant.pk.mapper.PkRecordDao;
import vvpkassistant.pk.model.PkInfoModel;
import vvpkassistant.pk.model.PkRecordDetail;
import vvpkassistant.pk.mapper.PkRecordDetailDao;
import vvpkassistant.pk.service.PkPinService;
import javax.annotation.Resource;
import java.util.HashMap;
@@ -76,6 +76,9 @@ public class UserController {
@Resource
private MailService mailService;
@Resource
private PkPinService pkPinService;
// 配置用户信息
@PostMapping("inputUserInfo")
public ResponseData<Object> inputUserInfo(@RequestBody UserInputUserInfoDTO param) {
@@ -305,81 +308,23 @@ public class UserController {
// 置顶文章
@PostMapping("pinToTop")
public ResponseData<Object> pinToTop(@RequestBody UserPinToTopDTO request) {
// 文章id
Integer articleId = request.getArticleId();
PkInfoModel pkInfoModel = pkInfoDao.selectById(articleId);
Integer userId = pkInfoModel.getSenderId();
// 到期时间戳
Integer pinExpireTime = request.getPinExpireTime();
long currentTimeStamp = VVTools.currentTimeStamp();
long hour = VVTools.calculateHoursRound(pinExpireTime, currentTimeStamp);
String coin = FunctionConfigHolder.getValue("置顶扣除积分");
int totalCoin = (int) (Integer.parseInt(coin) * hour);
UserModel userModel = userDao.selectById(userId);
if (userModel != null) {
// 扣除积分 更新数据
Integer points = userModel.getPoints();
if (points - totalCoin > 0) {
userModel.setPoints(userModel.getPoints() - totalCoin);
userDao.updateById(userModel);
// 设置置顶到期时间
pkInfoModel.setPinExpireTime(pinExpireTime);
// 设置创建置顶的时间
pkInfoModel.setPinCreateTime((int) VVTools.currentTimeStamp());
// 更新pk文章数据
int i = pkInfoDao.updateById(pkInfoModel);
if (i == 1) {
String info = String.format("置顶成功,扣除%d积分",totalCoin);
// 增加积分变动记录
CoinRecords coinRecords = new CoinRecords("置顶扣除积分",userId,totalCoin, (int) VVTools.currentTimeStamp(),0);
coinRecordsDao.insert(coinRecords);
// 返回给前端数据
return ResponseData.success(info);
}else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
}else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR,String.format("积分不足,需要%d积分",totalCoin));
}
}else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR,"用户不存在");
if (request == null || request.getArticleId() == null || request.getPinExpireTime() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数不能为空");
}
int operatorUserId = Integer.parseInt(StpUtil.getLoginId().toString());
String info = pkPinService.pinToTop(operatorUserId, request.getArticleId(), request.getPinExpireTime());
return ResponseData.success(info);
}
// 取消置顶
@PostMapping("cancelPin")
public ResponseData<Object> cancelPin(@RequestBody UserCancelPinDTO request) {
Integer articleId = request.getArticleId();
PkInfoModel pkInfoModel = pkInfoDao.selectById(articleId);
Integer pinExpireTime = pkInfoModel.getPinExpireTime();
long hour = VVTools.calculateHoursFloor(pinExpireTime, VVTools.currentTimeStamp());
String coin = FunctionConfigHolder.getValue("置顶扣除积分");
// 计算总积分。用于返还给用户
int totalCoin = (int) (Integer.parseInt(coin) * hour);
// 获取用户对象
UserModel userModel = userDao.selectById(pkInfoModel.getSenderId());
Integer points = userModel.getPoints();
// 返还用户积分
userModel.setPoints(points + totalCoin);
// 更新数据库
userDao.updateById(userModel);
// 重置置顶时间
pkInfoModel.setPinExpireTime(0);
pkInfoModel.setPinCreateTime(0);
int i = pkInfoDao.updateById(pkInfoModel);
if (i == 1) {
// 添加积分更变相关记录
CoinRecords coinRecords = new CoinRecords("取消置顶返还积分",pkInfoModel.getSenderId(),totalCoin, (int) VVTools.currentTimeStamp(),1);
coinRecordsDao.insert(coinRecords);
return ResponseData.success(String.format("操作成功,返还%d积分",totalCoin));
}else {
return ResponseData.error(ResponseInfo.ERROR.getCode(),null);
if (request == null || request.getArticleId() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数不能为空");
}
int operatorUserId = Integer.parseInt(StpUtil.getLoginId().toString());
String info = pkPinService.cancelPin(operatorUserId, request.getArticleId());
return ResponseData.success(info);
}

View File

@@ -0,0 +1,8 @@
package vvpkassistant.pk.service;
public interface PkPinService {
String pinToTop(int operatorUserId, int articleId, int pinExpireTime);
String cancelPin(int operatorUserId, int articleId);
}

View File

@@ -0,0 +1,167 @@
package vvpkassistant.pk.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vvpkassistant.CoinRecords.CoinRecords;
import vvpkassistant.CoinRecords.CoinRecordsDao;
import vvpkassistant.Tools.EpochSecondProvider;
import vvpkassistant.Tools.VVTools;
import vvpkassistant.User.mapper.UserDao;
import vvpkassistant.User.model.UserModel;
import vvpkassistant.common.ErrorCode;
import vvpkassistant.config.FunctionConfigProvider;
import vvpkassistant.exception.BusinessException;
import vvpkassistant.pk.mapper.PkInfoDao;
import vvpkassistant.pk.model.PkInfoModel;
@Service
public class PkPinServiceImpl implements PkPinService {
private static final String PIN_COIN_CONFIG_NAME = "置顶扣除积分";
private static final int COIN_RECORD_ADD = 1;
private static final int COIN_RECORD_DEDUCT = 0;
private final PkInfoDao pkInfoDao;
private final UserDao userDao;
private final CoinRecordsDao coinRecordsDao;
private final FunctionConfigProvider functionConfigProvider;
private final EpochSecondProvider epochSecondProvider;
public PkPinServiceImpl(
PkInfoDao pkInfoDao,
UserDao userDao,
CoinRecordsDao coinRecordsDao,
FunctionConfigProvider functionConfigProvider,
EpochSecondProvider epochSecondProvider
) {
this.pkInfoDao = pkInfoDao;
this.userDao = userDao;
this.coinRecordsDao = coinRecordsDao;
this.functionConfigProvider = functionConfigProvider;
this.epochSecondProvider = epochSecondProvider;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String pinToTop(int operatorUserId, int articleId, int pinExpireTime) {
long now = epochSecondProvider.nowEpochSecond();
ensureExpireTimeValid(pinExpireTime, now);
PkInfoModel pkInfoModel = pkInfoDao.selectById(articleId);
if (pkInfoModel == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "该信息不存在");
}
ensureOwner(operatorUserId, pkInfoModel.getSenderId());
int costPerHour = getPinCoinPerHour();
long hours = VVTools.calculateHoursRound(pinExpireTime, now);
int totalCoin = toIntCoin(multiplyCoin(costPerHour, hours, "置顶时长过长"), "置顶时长过长");
ensureDeductPoints(operatorUserId, totalCoin);
updatePinTime(pkInfoModel, pinExpireTime, (int) now);
insertCoinRecord("置顶扣除积分", operatorUserId, totalCoin, (int) now, COIN_RECORD_DEDUCT);
return String.format("置顶成功,扣除%d积分", totalCoin);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String cancelPin(int operatorUserId, int articleId) {
long now = epochSecondProvider.nowEpochSecond();
PkInfoModel pkInfoModel = pkInfoDao.selectById(articleId);
if (pkInfoModel == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "该信息不存在");
}
ensureOwner(operatorUserId, pkInfoModel.getSenderId());
int costPerHour = getPinCoinPerHour();
int pinExpireTime = pkInfoModel.getPinExpireTime() == null ? 0 : pkInfoModel.getPinExpireTime();
long hours = VVTools.calculateHoursFloor(pinExpireTime, now);
int refundCoin = toIntCoin(multiplyCoin(costPerHour, hours, "返还积分计算溢出"), "返还积分计算溢出");
if (refundCoin > 0) {
int updated = userDao.increasePoints(operatorUserId, refundCoin);
if (updated != 1) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "用户不存在");
}
}
pkInfoModel.setPinExpireTime(0);
pkInfoModel.setPinCreateTime(0);
if (pkInfoDao.updateById(pkInfoModel) != 1) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
insertCoinRecord("取消置顶返还积分", operatorUserId, refundCoin, (int) now, COIN_RECORD_ADD);
return String.format("操作成功,返还%d积分", refundCoin);
}
private static void ensureExpireTimeValid(int pinExpireTime, long now) {
if (pinExpireTime <= now) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "置顶到期时间必须大于当前时间");
}
}
private static void ensureOwner(int operatorUserId, Integer senderId) {
if (senderId == null || senderId != operatorUserId) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "无权限操作");
}
}
private int getPinCoinPerHour() {
String value = functionConfigProvider.getValue(PIN_COIN_CONFIG_NAME);
if (value == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "未配置置顶扣除积分");
}
int coin;
try {
coin = Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "置顶扣除积分配置错误");
}
if (coin <= 0) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "置顶扣除积分配置错误");
}
return coin;
}
private static long multiplyCoin(int costPerHour, long hours, String overflowMessage) {
try {
return Math.multiplyExact((long) costPerHour, hours);
} catch (ArithmeticException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, overflowMessage);
}
}
private static int toIntCoin(long coin, String overflowMessage) {
if (coin > Integer.MAX_VALUE) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, overflowMessage);
}
return (int) coin;
}
private void ensureDeductPoints(int operatorUserId, int totalCoin) {
int updated = userDao.decreasePointsIfEnough(operatorUserId, totalCoin);
if (updated == 1) {
return;
}
UserModel userModel = userDao.selectById(operatorUserId);
if (userModel == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "用户不存在");
}
throw new BusinessException(ErrorCode.SYSTEM_ERROR, String.format("积分不足,需要%d积分", totalCoin));
}
private void updatePinTime(PkInfoModel pkInfoModel, int pinExpireTime, int now) {
pkInfoModel.setPinExpireTime(pinExpireTime);
pkInfoModel.setPinCreateTime(now);
if (pkInfoDao.updateById(pkInfoModel) != 1) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
}
private void insertCoinRecord(String desc, int userId, int coin, int now, int type) {
coinRecordsDao.insert(new CoinRecords(desc, userId, coin, now, type));
}
}

View File

@@ -0,0 +1,109 @@
package vvpkassistant.pk.service;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vvpkassistant.CoinRecords.CoinRecordsDao;
import vvpkassistant.Tools.EpochSecondProvider;
import vvpkassistant.User.mapper.UserDao;
import vvpkassistant.User.model.UserModel;
import vvpkassistant.common.ErrorCode;
import vvpkassistant.config.FunctionConfigProvider;
import vvpkassistant.exception.BusinessException;
import vvpkassistant.pk.mapper.PkInfoDao;
import vvpkassistant.pk.model.PkInfoModel;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class PkPinServiceImplTests {
@Test
void shouldFailWhenExpireTimeNotInFuture() {
PkPinServiceImpl service = newServiceWithNow(100L);
BusinessException ex = Assertions.assertThrows(BusinessException.class,
() -> service.pinToTop(1, 10, 100));
Assertions.assertEquals(ErrorCode.PARAMS_ERROR.getCode(), ex.getCode());
}
@Test
void shouldFailWhenPointsNotEnoughEvenIfLessThanOneHour() {
long now = 1_000L;
PkInfoDao pkInfoDao = mock(PkInfoDao.class);
UserDao userDao = mock(UserDao.class);
CoinRecordsDao coinRecordsDao = mock(CoinRecordsDao.class);
FunctionConfigProvider functionConfigProvider = mock(FunctionConfigProvider.class);
EpochSecondProvider epochSecondProvider = () -> now;
when(functionConfigProvider.getValue("置顶扣除积分")).thenReturn("10");
PkInfoModel pkInfoModel = new PkInfoModel();
pkInfoModel.setId(10);
pkInfoModel.setSenderId(1);
when(pkInfoDao.selectById(10)).thenReturn(pkInfoModel);
when(userDao.decreasePointsIfEnough(1, 10)).thenReturn(0);
UserModel userModel = new UserModel();
userModel.setId(1);
userModel.setPoints(9);
when(userDao.selectById(1)).thenReturn(userModel);
PkPinServiceImpl service = new PkPinServiceImpl(
pkInfoDao, userDao, coinRecordsDao, functionConfigProvider, epochSecondProvider
);
int pinExpireTime = (int) (now + 1);
BusinessException ex = Assertions.assertThrows(BusinessException.class,
() -> service.pinToTop(1, 10, pinExpireTime));
Assertions.assertTrue(ex.getMessage().contains("积分不足"));
verify(pkInfoDao, never()).updateById(any());
verify(coinRecordsDao, never()).insert(any());
}
@Test
void shouldSucceedAndChargeOneHourWhenJustOneSecond() {
long now = 2_000L;
PkInfoDao pkInfoDao = mock(PkInfoDao.class);
UserDao userDao = mock(UserDao.class);
CoinRecordsDao coinRecordsDao = mock(CoinRecordsDao.class);
FunctionConfigProvider functionConfigProvider = mock(FunctionConfigProvider.class);
EpochSecondProvider epochSecondProvider = () -> now;
when(functionConfigProvider.getValue("置顶扣除积分")).thenReturn("10");
PkInfoModel pkInfoModel = new PkInfoModel();
pkInfoModel.setId(10);
pkInfoModel.setSenderId(1);
when(pkInfoDao.selectById(10)).thenReturn(pkInfoModel);
when(userDao.decreasePointsIfEnough(1, 10)).thenReturn(1);
when(pkInfoDao.updateById(any())).thenReturn(1);
when(coinRecordsDao.insert(any())).thenReturn(1);
PkPinServiceImpl service = new PkPinServiceImpl(
pkInfoDao, userDao, coinRecordsDao, functionConfigProvider, epochSecondProvider
);
int pinExpireTime = (int) (now + 1);
String msg = service.pinToTop(1, 10, pinExpireTime);
Assertions.assertTrue(msg.contains("扣除10积分"));
ArgumentCaptor<PkInfoModel> captor = ArgumentCaptor.forClass(PkInfoModel.class);
verify(pkInfoDao).updateById(captor.capture());
Assertions.assertEquals(Integer.valueOf(pinExpireTime), captor.getValue().getPinExpireTime());
Assertions.assertEquals(Integer.valueOf((int) now), captor.getValue().getPinCreateTime());
}
private static PkPinServiceImpl newServiceWithNow(long now) {
PkInfoDao pkInfoDao = mock(PkInfoDao.class);
UserDao userDao = mock(UserDao.class);
CoinRecordsDao coinRecordsDao = mock(CoinRecordsDao.class);
FunctionConfigProvider functionConfigProvider = mock(FunctionConfigProvider.class);
EpochSecondProvider epochSecondProvider = () -> now;
return new PkPinServiceImpl(pkInfoDao, userDao, coinRecordsDao, functionConfigProvider, epochSecondProvider);
}
}