This commit is contained in:
wells
2026-08-05 13:48:48 +08:00
commit 1303bf37dc
80 changed files with 5154 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.tailbet</groupId>
<artifactId>guess-game-backend</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>guess-game-backend</name>
<description>尾数竞猜游戏后端</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.9</mybatis-plus.version>
<spring-cloud.version>2023.0.3</spring-cloud.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Hutool 工具库 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.30</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package com.tailbet;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@MapperScan("com.tailbet.mapper")
public class GuessGameApplication {
public static void main(String[] args) {
SpringApplication.run(GuessGameApplication.class, args);
}
}
@@ -0,0 +1,99 @@
package com.tailbet.config;
import cn.hutool.core.util.StrUtil;
import com.tailbet.model.entity.User;
import com.tailbet.mapper.UserMapper;
import com.tailbet.model.vo.LoginUserVo;
import com.tailbet.model.vo.R;
import com.tailbet.model.vo.UserContext;
import com.tailbet.util.JwtUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 登录认证拦截器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
private final UserMapper userMapper;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 跳过OPTIONS请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String uri = request.getRequestURI();
// 放行登录注册接口
if (uri.contains("/api/user/register") || uri.contains("/api/user/login")) {
return true;
}
String token = extractToken(request);
if (StrUtil.isBlank(token)) {
writeUnauthorizedResponse(response, "未登录");
return false;
}
// 验证token是否过期
if (jwtUtil.isTokenExpired(token)) {
writeUnauthorizedResponse(response, "登录已过期");
return false;
}
// 检查Redis中token是否有效
Long userId = jwtUtil.getUserId(token);
if (userId == null) {
writeUnauthorizedResponse(response, "无效的token");
return false;
}
// 查询用户
User user = userMapper.selectById(userId);
if (user == null || user.getStatus() == 0) {
writeUnauthorizedResponse(response, "用户不存在或已被封禁");
return false;
}
// 设置用户上下文
UserContext.setUser(LoginUserVo.fromUser(user));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理上下文
UserContext.remove();
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws Exception {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
R<?> result = R.fail(401, message);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
@@ -0,0 +1,26 @@
package com.tailbet.config;
import com.tailbet.model.vo.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public R<?> handleRuntimeException(RuntimeException e) {
log.error("业务异常: {}", e.getMessage());
return R.fail(e.getMessage());
}
@ExceptionHandler(Exception.class)
public R<?> handleException(Exception e) {
log.error("系统异常: {}", e.getMessage(), e);
return R.fail("系统错误: " + e.getMessage());
}
}
@@ -0,0 +1,34 @@
package com.tailbet.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* OpenIM配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "openim")
public class OpenIMConfig {
/**
* OpenIM API地址
*/
private String apiUrl;
/**
* OpenIM WebSocket地址
*/
private String wsUrl;
/**
* 管理员账号
*/
private String adminUser;
/**
* 管理员Token
*/
private String adminToken;
}
@@ -0,0 +1,26 @@
package com.tailbet.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置
*/
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
@@ -0,0 +1,17 @@
package com.tailbet.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate配置
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@@ -0,0 +1,36 @@
package com.tailbet.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* WebMvc配置
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebMvcConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/api/user/register", "/api/user/login");
}
}
@@ -0,0 +1,122 @@
package com.tailbet.controller;
import com.tailbet.model.dto.BetDTO;
import com.tailbet.model.dto.GameOperateDTO;
import com.tailbet.model.dto.HistoryQueryDTO;
import com.tailbet.model.dto.SetDigitDTO;
import com.tailbet.model.vo.R;
import com.tailbet.model.vo.UserContext;
import com.tailbet.service.IBetService;
import com.tailbet.service.IGameService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 游戏Controller
*/
@RestController
@RequestMapping("/api/game")
@RequiredArgsConstructor
public class GameController {
private final IGameService gameService;
private final IBetService betService;
/**
* 开始游戏(群主)
*/
@PostMapping("/start")
public R<?> startGame(@RequestBody GameOperateDTO dto) {
try {
Long userId = UserContext.getUserId();
var round = gameService.startGame(dto.getGroupId(), userId);
return R.ok(round);
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 结束接注(群主)
*/
@PostMapping("/end")
public R<?> endBet(@RequestBody GameOperateDTO dto) {
try {
Long userId = UserContext.getUserId();
var round = gameService.endBet(dto.getGroupId(), userId);
return R.ok(round);
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 停止自动连开
*/
@PostMapping("/stop")
public R<?> stopAutoNext(@RequestBody GameOperateDTO dto) {
try {
Long userId = UserContext.getUserId();
gameService.stopAutoNext(dto.getGroupId(), userId);
return R.ok("已停止自动连开");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 下注
*/
@PostMapping("/bet")
public R<?> bet(@RequestBody BetDTO dto) {
try {
Long userId = UserContext.getUserId();
var bet = betService.placeBet(userId, dto.getGroupId(),
dto.getClientMsgId(), dto.getPlayType(), dto.getBetAmount());
if (bet == null) {
return R.ok("假人下注已记录");
}
return R.ok(bet);
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 尾数试算
*/
@GetMapping("/bet/trial")
public R<?> trialDigit(Long groupId, Long roundId) {
try {
List<GameService.DigitTrial> trials = gameService.trialDigit(groupId, roundId);
return R.ok(trials);
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 开奖历史
*/
@GetMapping("/history")
public R<?> history(HistoryQueryDTO query) {
var page = gameService.getHistory(query.getGroupId(), query.getPageNum(), query.getPageSize());
return R.ok(page);
}
/**
* 设置尾数(白名单用户)
*/
@PostMapping("/digit/set")
public R<?> setDigit(@RequestBody SetDigitDTO dto) {
try {
Long userId = UserContext.getUserId();
gameService.setTargetDigit(dto.getGroupId(), dto.getDigit(), userId);
return R.ok("尾数已设置");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
}
@@ -0,0 +1,69 @@
package com.tailbet.controller;
import com.tailbet.model.dto.PointsDTO;
import com.tailbet.model.dto.PullGroupDTO;
import com.tailbet.model.vo.R;
import com.tailbet.model.vo.UserContext;
import com.tailbet.service.IPointsService;
import com.tailbet.service.IGroupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 运营Controller
*/
@RestController
@RequestMapping("/api/ops")
@RequiredArgsConstructor
public class OpsController {
private final IPointsService pointsService;
private final IGroupService groupService;
/**
* 加积分
*/
@PostMapping("/points/add")
public R<?> addPoints(@RequestBody PointsDTO dto) {
try {
Long operatorId = UserContext.getUserId();
pointsService.addPoints(dto.getUserId(), dto.getAmount(),
IPointsService.TYPE_OPS_ADD, null, operatorId, dto.getRemark());
return R.ok("加分成功");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 减积分
*/
@PostMapping("/points/sub")
public R<?> subPoints(@RequestBody PointsDTO dto) {
try {
Long operatorId = UserContext.getUserId();
boolean success = pointsService.subPoints(dto.getUserId(), dto.getAmount(),
IPointsService.TYPE_OPS_SUB, null, operatorId, dto.getRemark());
if (success) {
return R.ok("减分成功");
}
return R.fail("余额不足或用户不存在");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 拉入群
*/
@PostMapping("/group/pull")
public R<?> pullGroup(@RequestBody PullGroupDTO dto) {
try {
Long operatorId = UserContext.getUserId();
groupService.pullUser(dto.getGroupId(), dto.getUserId(), operatorId);
return R.ok("拉群成功");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
}
@@ -0,0 +1,58 @@
package com.tailbet.controller;
import com.tailbet.model.dto.ReceiveRpDTO;
import com.tailbet.model.dto.SendRpDTO;
import com.tailbet.model.vo.R;
import com.tailbet.model.vo.UserContext;
import com.tailbet.service.IRedPacketService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 红包Controller
*/
@RestController
@RequestMapping("/api/rp")
@RequiredArgsConstructor
public class RedPacketController {
private final IRedPacketService redPacketService;
/**
* 发红包
*/
@PostMapping("/send")
public R<?> sendRp(@RequestBody SendRpDTO dto) {
try {
Long userId = UserContext.getUserId();
var rp = redPacketService.sendRp(userId, dto.getGroupId(),
dto.getTotalAmount(), dto.getTotalCount(), dto.getRoundId());
return R.ok(rp);
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 领红包
*/
@PostMapping("/receive")
public R<?> receiveRp(@RequestBody ReceiveRpDTO dto) {
try {
Long userId = UserContext.getUserId();
var recv = redPacketService.receiveRp(dto.getRpId(), userId);
return R.ok(recv);
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 获取红包详情
*/
@GetMapping("/detail")
public R<?> detail(Long rpId) {
var rp = redPacketService.getById(rpId);
return R.ok(rp);
}
}
@@ -0,0 +1,80 @@
package com.tailbet.controller;
import com.tailbet.model.dto.LoginDTO;
import com.tailbet.model.dto.RegisterDTO;
import com.tailbet.model.dto.UpdateUserDTO;
import com.tailbet.model.vo.LoginUserVo;
import com.tailbet.model.vo.R;
import com.tailbet.model.vo.UserContext;
import com.tailbet.service.IUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 用户Controller
*/
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {
private final IUserService userService;
/**
* 注册
*/
@PostMapping("/register")
public R<?> register(@RequestBody RegisterDTO dto) {
try {
userService.register(dto.getUsername(), dto.getPassword());
return R.ok("注册成功");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 登录
*/
@PostMapping("/login")
public R<?> login(@RequestBody LoginDTO dto) {
try {
String token = userService.login(dto.getUsername(), dto.getPassword());
return R.ok("登录成功", java.util.Map.of("token", token));
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 获取当前用户信息
*/
@GetMapping("/info")
public R<?> getInfo() {
LoginUserVo user = UserContext.getUser();
return R.ok(user);
}
/**
* 更新用户信息
*/
@PutMapping("/update")
public R<?> update(@RequestBody UpdateUserDTO dto) {
try {
Long userId = UserContext.getUserId();
userService.updateUserInfo(userId, dto);
return R.ok("更新成功");
} catch (RuntimeException e) {
return R.fail(e.getMessage());
}
}
/**
* 退出登录
*/
@PostMapping("/logout")
public R<?> logout() {
// 可以在这里清理Redis中的token
return R.ok("退出成功");
}
}
+334
View File
@@ -0,0 +1,334 @@
package com.tailbet.job;
import com.tailbet.model.entity.GameRound;
import com.tailbet.model.entity.RedPacket;
import com.tailbet.model.entity.User;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.mapper.RedPacketMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.mapper.DigitWhiteMapper;
import com.tailbet.model.entity.DigitWhite;
import com.tailbet.service.GameService;
import com.tailbet.service.RedPacketService;
import com.tailbet.service.FakeUserService;
import com.tailbet.openim.OpenIMClient;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 游戏定时任务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GameJob {
private final GameRoundMapper gameRoundMapper;
private final RedPacketMapper redPacketMapper;
private final UserMapper userMapper;
private final DigitWhiteMapper digitWhiteMapper;
private final GameService gameService;
private final RedPacketService redPacketService;
private final FakeUserService fakeUserService;
private final OpenIMClient openIMClient;
// 局状态监控(使用内存缓存,实际生产应使用Redis)
private final Map<Long, RoundTask> roundTasks = new ConcurrentHashMap<>();
/**
* 检查红包是否需要托领取(每3秒执行)
*/
@Scheduled(fixedDelay = 3000)
public void checkRedPacketForFakeReceive() {
// 查找进行中的游戏红包
List<RedPacket> ongoingRps = redPacketMapper.selectList(new LambdaQueryWrapper<RedPacket>()
.eq(RedPacket::getStatus, 0)
.eq(RedPacket::getType, RedPacketService.TYPE_GAME)
.isNotNull(RedPacket::getRoundId));
for (RedPacket rp : ongoingRps) {
LocalDateTime createTime = rp.getCreateTime();
// 发出超过5秒未领完,让托领取
if (createTime.plusSeconds(5).isBefore(LocalDateTime.now())) {
// 查找未领取的用户,随机选一个托领取
try {
fakeUserReceiveRemaining(rp);
} catch (Exception e) {
log.error("托领取红包失败: rpId={}, error={}", rp.getId(), e.getMessage());
}
}
}
}
/**
* 假人托领取剩余份额
*/
private void fakeUserReceiveRemaining(RedPacket rp) {
List<User> fakes = fakeUserService.getFakeUsers();
if (fakes.isEmpty()) {
return;
}
// 随机选一个假人
User fake = fakes.get((int) (Math.random() * fakes.size()));
// 检查是否已经领取过
var existing = redPacketMapper.selectList(new LambdaQueryWrapper<RedPacket>()
.eq(RedPacket::getUserId, fake.getId()));
if (!existing.isEmpty()) {
return;
}
try {
redPacketService.receiveRp(rp.getId(), fake.getId());
log.info("假人{}领取红包{}剩余份额", fake.getId(), rp.getId());
} catch (Exception e) {
// 忽略领取失败
}
}
/**
* 检查局超时(每10秒执行)
*/
@Scheduled(fixedDelay = 10000)
public void checkRoundTimeout() {
List<GameRound> ongoingRounds = gameRoundMapper.selectList(new LambdaQueryWrapper<GameRound>()
.eq(GameRound::getStatus, 0));
for (GameRound round : ongoingRounds) {
// 检查是否超过接注时间(默认60秒)
RoundTask task = roundTasks.get(round.getId());
if (task != null && task.endBetTime.isBefore(LocalDateTime.now())) {
// 自动结束接注
try {
gameService.endBet(round.getGroupId(), 0L); // 0L表示系统自动触发
// 触发开始发红包
scheduleStartRp(round.getId(), round.getGroupId(), 20); // 20秒后开始发红包
} catch (Exception e) {
log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage());
}
}
}
}
/**
* 每日凌晨重置活跃假人池
*/
@Scheduled(cron = "0 0 0 * * ?")
public void resetDailyFakeUsers() {
fakeUserService.resetDailyActiveFakes();
}
/**
* 安排开始发红包
*/
public void scheduleStartRp(Long roundId, Long groupId, int delaySeconds) {
RoundTask task = new RoundTask();
task.roundId = roundId;
task.groupId = groupId;
task.startRpTime = LocalDateTime.now().plusSeconds(delaySeconds);
roundTasks.put(roundId, task);
}
/**
* 安排自动结算
*/
public void scheduleSettle(Long roundId, Long groupId, int digit, int delaySeconds) {
// TODO: 使用延迟队列或定时任务实现
log.info("安排{}秒后结算 roundId={}, digit={}", delaySeconds, roundId, digit);
}
/**
* 开始游戏后安排定时器
*/
public void onGameStart(Long roundId, Long groupId, int intervalSeconds) {
RoundTask task = new RoundTask();
task.roundId = roundId;
task.groupId = groupId;
task.endBetTime = LocalDateTime.now().plusSeconds(intervalSeconds);
roundTasks.put(roundId, task);
// 广播开始下注
GameRound round = gameRoundMapper.selectById(roundId);
if (round != null) {
broadcastStartBet(groupId, round.getRoundNo());
}
log.info("游戏开始: roundId={}, {}秒后结束接注", roundId, intervalSeconds);
}
/**
* 结束接注后安排发红包
*/
public void onEndBet(Long roundId, Long groupId) {
// 移除结束接注定时器
roundTasks.remove(roundId);
// 广播结束下注
GameRound round = gameRoundMapper.selectById(roundId);
if (round != null) {
broadcastEndBet(groupId, round.getRoundNo());
}
// 通知白名单用户尾数试算完成
notifyWhiteUsers(roundId);
// 安排20秒后开始发红包
scheduleStartRp(roundId, groupId, 20);
}
/**
* 开始发红包
*/
public void onStartRp(Long roundId, Long groupId) {
GameRound round = gameRoundMapper.selectById(roundId);
if (round == null) {
return;
}
// 广播开始发红包
broadcastStartRp(groupId, round.getRoundNo());
// 安排10秒后如果没人发包则机器人自动发
// TODO: 实现延迟检查逻辑
}
/**
* 机器人自动发包
*/
public void botSendRp(Long roundId, Long groupId) {
GameRound round = gameRoundMapper.selectById(roundId);
if (round == null || round.getTargetDigit() == null) {
return;
}
// 查找机器人账号
User bot = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getIsBot, 1)
.last("LIMIT 1"));
if (bot == null) {
log.error("未找到机器人账号");
return;
}
try {
// 机器人发1元3个包
RedPacket rp = redPacketService.sendRp(bot.getId(), groupId,
new BigDecimal("1"), 3, roundId);
// 设置目标尾数
// TODO: 实现红包尾数控制
log.info("机器人自动发包: roundId={}, rpId={}", roundId, rp.getId());
} catch (Exception e) {
log.error("机器人发包失败: roundId={}, error={}", roundId, e.getMessage());
}
}
/**
* 开奖结算
*/
public void onSettle(Long roundId, Long groupId, int digit) {
try {
gameService.settle(roundId, digit);
// 广播开奖结果
GameRound round = gameRoundMapper.selectById(roundId);
if (round != null) {
broadcastResult(groupId, digit, round.getRoundNo());
}
// 检查是否自动开启下一期
GameRound roundAfterSettle = gameRoundMapper.selectById(roundId);
if (roundAfterSettle != null && roundAfterSettle.getAutoNext() == 1) {
// 5秒后自动开始下一期
scheduleAutoNext(groupId, 5);
}
// 清理任务
roundTasks.remove(roundId);
log.info("局结算完成: roundId={}, digit={}", roundId, digit);
} catch (Exception e) {
log.error("结算失败: roundId={}, error={}", roundId, e.getMessage());
}
}
/**
* 通知白名单用户(尾数试算完成)
*/
private void notifyWhiteUsers(Long roundId) {
List<DigitWhite> whites = digitWhiteMapper.selectList(new LambdaQueryWrapper<DigitWhite>());
for (DigitWhite white : whites) {
User user = userMapper.selectById(white.getUserId());
if (user != null) {
// TODO: 发送推送通知
log.info("通知白名单用户尾数试算完成: userId={}", user.getId());
}
}
}
/**
* 安排自动开始下一期
*/
private void scheduleAutoNext(Long groupId, int delaySeconds) {
// TODO: 实现延迟自动开局
log.info("安排{}秒后自动开始下一期", delaySeconds);
}
/**
* 广播开始下注
*/
private void broadcastStartBet(Long groupId, String roundNo) {
// TODO: 获取群对应的OpenIM群ID并发送
openIMClient.sendGroupMessage(String.valueOf(groupId),
roundNo + "期开始下注");
}
/**
* 广播结束下注
*/
private void broadcastEndBet(Long groupId, String roundNo) {
openIMClient.sendGroupMessage(String.valueOf(groupId),
roundNo + "期结束下注");
}
/**
* 广播开始发红包
*/
private void broadcastStartRp(Long groupId, String roundNo) {
openIMClient.sendGroupMessage(String.valueOf(groupId),
roundNo + "期开始发红包");
}
/**
* 广播开奖结果
*/
private void broadcastResult(Long groupId, int digit, String roundNo) {
String playWinDesc = gameService.getPlayWinDesc(digit);
String message = roundNo + "期开奖\n" +
"尾数: " + digit + "\n" +
"中奖玩法: " + playWinDesc;
openIMClient.sendGroupMessage(String.valueOf(groupId), message);
}
/**
* 局任务信息
*/
private static class RoundTask {
Long roundId;
Long groupId;
LocalDateTime endBetTime;
LocalDateTime startRpTime;
}
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.AuditLog;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 审计日志Mapper
*/
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLog> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.BetOrder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 注单Mapper
*/
@Mapper
public interface BetOrderMapper extends BaseMapper<BetOrder> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.DigitWhite;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 尾数控制白名单Mapper
*/
@Mapper
public interface DigitWhiteMapper extends BaseMapper<DigitWhite> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.DrawRecord;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 开奖记录Mapper
*/
@Mapper
public interface DrawRecordMapper extends BaseMapper<DrawRecord> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.GameRound;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 游戏局Mapper
*/
@Mapper
public interface GameRoundMapper extends BaseMapper<GameRound> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.Group;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 群Mapper
*/
@Mapper
public interface GroupMapper extends BaseMapper<Group> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.GroupMember;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 群成员Mapper
*/
@Mapper
public interface GroupMemberMapper extends BaseMapper<GroupMember> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.PointsFlow;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 积分流水Mapper
*/
@Mapper
public interface PointsFlowMapper extends BaseMapper<PointsFlow> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.RedPacket;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 红包Mapper
*/
@Mapper
public interface RedPacketMapper extends BaseMapper<RedPacket> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.RedPacketRecv;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 红包领取Mapper
*/
@Mapper
public interface RedPacketRecvMapper extends BaseMapper<RedPacketRecv> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.SysConfig;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 系统配置Mapper
*/
@Mapper
public interface SysConfigMapper extends BaseMapper<SysConfig> {
}
@@ -0,0 +1,12 @@
package com.tailbet.mapper;
import com.tailbet.model.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户Mapper
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
@@ -0,0 +1,23 @@
package com.tailbet.model;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;
@Data
public class PageDomain {
/**
* 页码
*/
private Integer pageNum = 1;
/**
* 每页显示数量
*/
private Integer pageSize = 10;
public <T> Page<T> toPage() {
return new Page<>(pageNum, pageSize);
}
}
@@ -0,0 +1,29 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 下注请求
*/
@Data
public class BetDTO {
/**
* 群ID
*/
private Long groupId;
/**
* 客户端消息ID(防重)
*/
private String clientMsgId;
/**
* 玩法:单/双/大/小/数字
*/
private String playType;
/**
* 下注金额
*/
private Integer betAmount;
}
@@ -0,0 +1,14 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 游戏操作请求
*/
@Data
public class GameOperateDTO {
/**
* 群ID
*/
private Long groupId;
}
@@ -0,0 +1,17 @@
package com.tailbet.model.dto;
import com.tailbet.model.PageDomain;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 历史查询分页参数
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class HistoryQueryDTO extends PageDomain {
/**
* 群ID
*/
private Long groupId;
}
@@ -0,0 +1,19 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 用户登录请求
*/
@Data
public class LoginDTO {
/**
* 账号
*/
private String username;
/**
* 密码
*/
private String password;
}
@@ -0,0 +1,49 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* OpenIM回调消息体
*/
@Data
public class OpenIMCallbackDTO {
/**
* 消息ID
*/
private String msgId;
/**
* 发送者用户ID
*/
private String sendId;
/**
* 接收者ID(群ID或用户ID)
*/
private String recvId;
/**
* 消息内容
*/
private String content;
/**
* 消息类型: text, custom, etc
*/
private String msgType;
/**
* 会话类型: 1-单聊, 2-群聊
*/
private Integer sessionType;
/**
* 群ID(群聊时)
*/
private String groupId;
/**
* 客户端平台
*/
private String platform;
}
@@ -0,0 +1,20 @@
package com.tailbet.model.dto;
import lombok.Data;
import java.util.List;
/**
* 分页请求基类
*/
@Data
public class PageParam {
/**
* 页码
*/
private Integer pageNum = 1;
/**
* 每页显示数量
*/
private Integer pageSize = 10;
}
@@ -0,0 +1,24 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 积分操作请求
*/
@Data
public class PointsDTO {
/**
* 用户ID
*/
private Long userId;
/**
* 积分数量
*/
private Long amount;
/**
* 备注
*/
private String remark;
}
@@ -0,0 +1,19 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 拉群请求
*/
@Data
public class PullGroupDTO {
/**
* 用户ID
*/
private Long userId;
/**
* 群ID
*/
private Long groupId;
}
@@ -0,0 +1,14 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 领红包请求
*/
@Data
public class ReceiveRpDTO {
/**
* 红包ID
*/
private Long rpId;
}
@@ -0,0 +1,19 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 用户注册请求
*/
@Data
public class RegisterDTO {
/**
* 账号
*/
private String username;
/**
* 密码
*/
private String password;
}
@@ -0,0 +1,30 @@
package com.tailbet.model.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 发红包请求
*/
@Data
public class SendRpDTO {
/**
* 群ID
*/
private Long groupId;
/**
* 红包总金额
*/
private BigDecimal totalAmount;
/**
* 红包个数
*/
private Integer totalCount;
/**
* 绑定的局ID(可选)
*/
private Long roundId;
}
@@ -0,0 +1,19 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 设置尾数请求
*/
@Data
public class SetDigitDTO {
/**
* 群ID
*/
private Long groupId;
/**
* 目标尾数(0-9)
*/
private Integer digit;
}
@@ -0,0 +1,24 @@
package com.tailbet.model.dto;
import lombok.Data;
/**
* 更新用户信息请求
*/
@Data
public class UpdateUserDTO {
/**
* 昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatarUrl;
/**
* 新密码(可选)
*/
private String password;
}
@@ -0,0 +1,48 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 审计日志表
*/
@Data
@TableName("audit_log")
public class AuditLog {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 群ID
*/
private Long groupId;
/**
* 局ID
*/
private Long roundId;
/**
* 操作用户ID
*/
private Long userId;
/**
* 动作类型
*/
private String action;
/**
* 详情JSON
*/
private String detail;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,74 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 注单表
*/
@Data
@TableName("bet_order")
public class BetOrder {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 群ID
*/
private Long groupId;
/**
* 局ID
*/
private Long roundId;
/**
* 客户端消息ID(防重)
*/
private String clientMsgId;
/**
* 玩法:单/双/大/小/数字
*/
private String playType;
/**
* 下注金额
*/
private Integer betAmount;
/**
* 赔率
*/
private BigDecimal odds;
/**
* 是否假人下注
*/
private Integer isFake;
/**
* 状态:0待结算 1已结算 2未中奖
*/
private Integer status;
/**
* 中奖金额
*/
private Long winAmount;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,28 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 尾数控制白名单表
*/
@Data
@TableName("digit_white")
public class DigitWhite {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,63 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 开奖记录表
*/
@Data
@TableName("draw_record")
public class DrawRecord {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 群ID
*/
private Long groupId;
/**
* 期号
*/
private String roundNo;
/**
* 局ID
*/
private Long roundId;
/**
* 开奖尾数:0-9
*/
private Integer digit;
/**
* 中奖玩法:单,大,数字7
*/
private String playWins;
/**
* 总下注
*/
private Integer totalBet;
/**
* 总派奖
*/
private Long totalAward;
/**
* 庄家利润
*/
private Long profit;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,78 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 游戏局表
*/
@Data
@TableName("game_round")
public class GameRound {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 群ID
*/
private Long groupId;
/**
* 期号:yyyy-MM-dd-xx
*/
private String roundNo;
/**
* 状态:0进行中 1已结束
*/
private Integer status;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 截止下注时间
*/
private LocalDateTime endBetTime;
/**
* 开始发红包时间
*/
private LocalDateTime startRpTime;
/**
* 结束时间
*/
private LocalDateTime finishTime;
/**
* 目标尾数:0-9 null表示待定
*/
private Integer targetDigit;
/**
* 尾数策略:specified指定/manual_auto小赢小输/random随机
*/
private String digitStrategy;
/**
* 自动开启下一期:0否 1是
*/
private Integer autoNext;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,53 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 群表
*/
@Data
@TableName("group")
public class Group {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 群名称
*/
private String name;
/**
* OpenIM群ID
*/
private String openimGroupId;
/**
* 群主用户ID
*/
private Long ownerUserId;
/**
* 接注间隔(秒)
*/
private Integer roundInterval;
/**
* 状态:1启用 0停用
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,48 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 群成员表
*/
@Data
@TableName("group_member")
public class GroupMember {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 群ID
*/
private Long groupId;
/**
* 用户ID
*/
private Long userId;
/**
* 角色:0普通 1管理员 2群主
*/
private Integer role;
/**
* 加入时间
*/
private LocalDateTime joinTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,63 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 积分流水表
*/
@Data
@TableName("points_flow")
public class PointsFlow {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 类型:ops_add/ops_sub/bet/award/rp_send/rp_recv
*/
private String type;
/**
* 变动数值(正负)
*/
private Long amount;
/**
* 变动前余额
*/
private Long balanceBefore;
/**
* 变动后余额
*/
private Long balanceAfter;
/**
* 业务单号
*/
private String bizNo;
/**
* 操作人ID
*/
private Long operatorId;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,84 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 红包表
*/
@Data
@TableName("red_packet")
public class RedPacket {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 群ID
*/
private Long groupId;
/**
* 绑定局ID(null=普通红包)
*/
private Long roundId;
/**
* 发送者ID
*/
private Long userId;
/**
* 类型:game游戏/random随机
*/
private String type;
/**
* 总金额
*/
private BigDecimal totalAmount;
/**
* 总个数
*/
private Integer totalCount;
/**
* 剩余金额
*/
private BigDecimal remainAmount;
/**
* 剩余个数
*/
private Integer remainCount;
/**
* 手气王金额
*/
private BigDecimal luckyAmount;
/**
* 手气王用户ID
*/
private Long luckyUserId;
/**
* 手气王尾数
*/
private Integer luckyDigit;
/**
* 状态:0进行中 1已领完 2已退款
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,44 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 红包领取表
*/
@Data
@TableName("red_packet_recv")
public class RedPacketRecv {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 红包ID
*/
private Long rpId;
/**
* 领取者ID
*/
private Long userId;
/**
* 领取金额
*/
private BigDecimal amount;
/**
* 是否手气王
*/
private Integer isLucky;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,38 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统配置表
*/
@Data
@TableName("sys_config")
public class SysConfig {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 配置键
*/
private String cfgKey;
/**
* 配置值
*/
private String cfgValue;
/**
* 备注
*/
private String remark;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,72 @@
package com.tailbet.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 用户表
*/
@Data
@TableName("user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 账号
*/
private String username;
/**
* 密码(BCrypt)
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatarUrl;
/**
* 积分余额
*/
private Long points;
/**
* 运营标记:0否 1是
*/
private Integer isOps;
/**
* 机器人标记:0否 1是
*/
private Integer isBot;
/**
* 假人托标记:0否 1是
*/
private Integer isFake;
/**
* 加白(尾数控制)标记
*/
private Integer isWhite;
/**
* 绑定的运营用户ID
*/
private Long boundOpsUserId;
/**
* 状态:1正常 0封禁
*/
private Integer status;
}
@@ -0,0 +1,70 @@
package com.tailbet.model.vo;
import com.tailbet.model.entity.User;
import lombok.Data;
/**
* 登录用户信息
*/
@Data
public class LoginUserVo {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 头像
*/
private String avatarUrl;
/**
* 积分
*/
private Long points;
/**
* 是否运营
*/
private Boolean isOps;
/**
* 是否机器人
*/
private Boolean isBot;
/**
* 是否假人
*/
private Boolean isFake;
/**
* 是否加白
*/
private Boolean isWhite;
public static LoginUserVo fromUser(User user) {
if (user == null) return null;
LoginUserVo vo = new LoginUserVo();
vo.setUserId(user.getId());
vo.setUsername(user.getUsername());
vo.setNickname(user.getNickname());
vo.setAvatarUrl(user.getAvatarUrl());
vo.setPoints(user.getPoints());
vo.setIsOps(user.getIsOps() != null && user.getIsOps() == 1);
vo.setIsBot(user.getIsBot() != null && user.getIsBot() == 1);
vo.setIsFake(user.getIsFake() != null && user.getIsFake() == 1);
vo.setIsWhite(user.getIsWhite() != null && user.getIsWhite() == 1);
return vo;
}
}
+56
View File
@@ -0,0 +1,56 @@
package com.tailbet.model.vo;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.io.Serializable;
/**
* 统一返回对象
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class R<T> implements Serializable {
private static final long serialVersionUID = 1L;
/** 成功 */
public static final int SUCCESS = 200;
/** 失败 */
public static final int FAIL = 500;
private int code;
private String msg;
private T data;
public static <T> R<T> ok() {
return new R<>(SUCCESS, "操作成功", null);
}
public static <T> R<T> ok(T data) {
return new R<>(SUCCESS, "操作成功", data);
}
public static <T> R<T> ok(String msg, T data) {
return new R<>(SUCCESS, msg, data);
}
public static <T> R<T> fail() {
return new R<>(FAIL, "操作失败", null);
}
public static <T> R<T> fail(String msg) {
return new R<>(FAIL, msg, null);
}
public static <T> R<T> fail(int code, String msg) {
return new R<>(code, msg, null);
}
public boolean isSuccess() {
return code == SUCCESS;
}
}
@@ -0,0 +1,46 @@
package com.tailbet.model.vo;
/**
* 用户上下文 - ThreadLocal保存当前登录用户
*/
public class UserContext {
private static final ThreadLocal<LoginUserVo> USER = new ThreadLocal<>();
public static void setUser(LoginUserVo user) {
USER.set(user);
}
public static LoginUserVo getUser() {
return USER.get();
}
public static Long getUserId() {
LoginUserVo user = USER.get();
return user != null ? user.getUserId() : null;
}
public static void remove() {
USER.remove();
}
public static boolean isOps() {
LoginUserVo user = USER.get();
return user != null && Boolean.TRUE.equals(user.getIsOps());
}
public static boolean isBot() {
LoginUserVo user = USER.get();
return user != null && Boolean.TRUE.equals(user.getIsBot());
}
public static boolean isFake() {
LoginUserVo user = USER.get();
return user != null && Boolean.TRUE.equals(user.getIsFake());
}
public static boolean isWhite() {
LoginUserVo user = USER.get();
return user != null && Boolean.TRUE.equals(user.getIsWhite());
}
}
@@ -0,0 +1,189 @@
package com.tailbet.openim;
import com.tailbet.model.dto.OpenIMCallbackDTO;
import com.tailbet.model.vo.R;
import com.tailbet.service.BetService;
import com.tailbet.service.GameService;
import com.tailbet.service.RedPacketService;
import com.tailbet.service.GroupService;
import com.tailbet.openim.OpenIMClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* OpenIM回调接口
*/
@Slf4j
@RestController
@RequestMapping("/openim/callback")
@RequiredArgsConstructor
public class OpenIMCallbackController {
private final BetService betService;
private final GameService gameService;
private final RedPacketService redPacketService;
private final GroupService groupService;
private final OpenIMClient openIMClient;
/**
* 接收群消息回调
*/
@PostMapping("/group_msg")
public R<?> onGroupMessage(@RequestBody OpenIMCallbackDTO dto) {
try {
log.info("收到群消息: msgId={}, sendId={}, groupId={}, content={}",
dto.getMsgId(), dto.getSendId(), dto.getGroupId(), dto.getContent());
String content = dto.getContent();
if (content == null || content.isEmpty()) {
return R.ok();
}
// 处理@机器人指令
if (content.contains("@机器人") || content.contains("@robot")) {
handleBotCommand(dto);
}
return R.ok();
} catch (Exception e) {
log.error("处理群消息回调失败: {}", e.getMessage(), e);
return R.fail(e.getMessage());
}
}
/**
* 接收单聊消息回调
*/
@PostMapping("/single_msg")
public R<?> onSingleMessage(@RequestBody OpenIMCallbackDTO dto) {
try {
log.info("收到单聊消息: msgId={}, sendId={}, recvId={}, content={}",
dto.getMsgId(), dto.getSendId(), dto.getRecvId(), dto.getContent());
return R.ok();
} catch (Exception e) {
log.error("处理单聊消息回调失败: {}", e.getMessage(), e);
return R.fail(e.getMessage());
}
}
/**
* 处理机器人指令
*/
private void handleBotCommand(OpenIMCallbackDTO dto) {
String content = dto.getContent();
String groupId = dto.getGroupId();
String sendId = dto.getSendId();
// 解析指令: @机器人 单 100 或 @机器人 大 50
String message = content.replace("@机器人", "").replace("@robot", "").trim();
if (message.startsWith("统计")) {
// 统计指令
handleStatCommand(groupId);
} else {
// 下注指令
handleBetCommand(message, groupId, sendId, dto.getMsgId());
}
}
/**
* 处理下注指令
*/
private void handleBetCommand(String message, String groupId, String sendId, String msgId) {
try {
// 格式: 单 100, 双 50, 大 100, 小 50, 7 20
String[] parts = message.trim().split("\\s+");
if (parts.length != 2) {
sendGroupMessage(groupId, "格式错误,请使用: @机器人 单/双/大/小/数字 金额");
return;
}
String playType = parts[0];
Integer betAmount;
try {
betAmount = Integer.parseInt(parts[1]);
} catch (NumberFormatException e) {
sendGroupMessage(groupId, "金额格式错误");
return;
}
// 验证玩法
if (!isValidPlayType(playType)) {
sendGroupMessage(groupId, "玩法错误,请使用: 单/双/大/小/数字(0-9)");
return;
}
// 获取业务groupId
Long bizGroupId = getBizGroupId(groupId);
if (bizGroupId == null) {
sendGroupMessage(groupId, "群未配置");
return;
}
// 执行下注
Long userId = getBizUserId(sendId);
var bet = betService.placeBet(userId, bizGroupId, msgId, playType, betAmount);
if (bet != null) {
sendGroupMessage(groupId, String.format("已记录: %s %d", playType, betAmount));
}
} catch (RuntimeException e) {
sendGroupMessage(groupId, e.getMessage());
}
}
/**
* 处理统计指令
*/
private void handleStatCommand(String groupId) {
try {
// TODO: 返回统计信息
sendGroupMessage(groupId, "统计功能开发中...");
} catch (Exception e) {
log.error("处理统计指令失败: {}", e.getMessage());
}
}
/**
* 发送群消息
*/
private void sendGroupMessage(String openimGroupId, String message) {
try {
openIMClient.sendGroupMessage(openimGroupId, message);
} catch (Exception e) {
log.error("发送群消息失败: {}", e.getMessage());
}
}
/**
* 验证玩法是否有效
*/
private boolean isValidPlayType(String playType) {
return "".equals(playType) || "".equals(playType)
|| "".equals(playType) || "".equals(playType)
|| playType.matches("[0-9]");
}
/**
* 将OpenIM用户ID转换为业务用户ID
* 这里需要根据实际实现来转换
*/
private Long getBizUserId(String openimUserId) {
try {
return Long.parseLong(openimUserId);
} catch (NumberFormatException e) {
// 如果不是数字,需要通过映射表查询
return null;
}
}
/**
* 将OpenIM群ID转换为业务群ID
*/
private Long getBizGroupId(String openimGroupId) {
// TODO: 通过映射表查询
return 1L;
}
}
@@ -0,0 +1,152 @@
package com.tailbet.openim;
import com.tailbet.config.OpenIMConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* OpenIM客户端
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OpenIMClient {
private final OpenIMConfig config;
private final RestTemplate restTemplate;
/**
* 发送群消息
*/
public void sendGroupMessage(String groupId, String message) {
String url = config.getApiUrl() + "/msg/send_msg";
Map<String, Object> body = new HashMap<>();
body.put("groupId", groupId);
body.put("msg", message);
body.put("msgType", "text");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
log.info("OpenIM群消息发送成功: groupId={}, message={}", groupId, message);
} catch (Exception e) {
log.error("OpenIM群消息发送失败: groupId={}, message={}, error={}",
groupId, message, e.getMessage());
}
}
/**
* 发送单聊消息
*/
public void sendUserMessage(String toUserId, String message) {
String url = config.getApiUrl() + "/msg/send_msg";
Map<String, Object> body = new HashMap<>();
body.put("to", toUserId);
body.put("msg", message);
body.put("msgType", "text");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
log.info("OpenIM单聊消息发送成功: to={}, message={}", toUserId, message);
} catch (Exception e) {
log.error("OpenIM单聊消息发送失败: to={}, message={}, error={}",
toUserId, message, e.getMessage());
}
}
/**
* 创建用户
*/
public boolean createUser(String userId, String nickname) {
String url = config.getApiUrl() + "/user/user_register";
Map<String, Object> body = new HashMap<>();
body.put("userID", userId);
body.put("nickname", nickname);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
return true;
} catch (Exception e) {
log.error("创建OpenIM用户失败: userId={}, error={}", userId, e.getMessage());
return false;
}
}
/**
* 创建群组
*/
public String createGroup(String groupName) {
String url = config.getApiUrl() + "/group/create_group";
Map<String, Object> body = new HashMap<>();
body.put("groupName", groupName);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<Map> response = restTemplate.postForEntity(url, request, Map.class);
if (response.getBody() != null) {
return (String) response.getBody().get("groupID");
}
} catch (Exception e) {
log.error("创建OpenIM群组失败: groupName={}, error={}", groupName, e.getMessage());
}
return null;
}
/**
* 拉用户入群
*/
public boolean inviteUserToGroup(String groupId, String userId) {
String url = config.getApiUrl() + "/group/invite_user_to_group";
Map<String, Object> body = new HashMap<>();
body.put("groupId", groupId);
body.put("userIDs", new String[]{userId});
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("token", config.getAdminToken());
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
try {
restTemplate.postForEntity(url, request, String.class);
return true;
} catch (Exception e) {
log.error("拉用户入群失败: groupId={}, userId={}, error={}",
groupId, userId, e.getMessage());
return false;
}
}
}
@@ -0,0 +1,21 @@
package com.tailbet.service;
import com.tailbet.model.entity.AuditLog;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
/**
* 审计日志Service接口
*/
public interface IAuditService {
/**
* 记录审计日志
*/
void log(Long groupId, Long roundId, Long userId, String action, String detail);
/**
* 分页查询审计日志
*/
Page<AuditLog> getPage(Long groupId, Long roundId, String action,
Integer pageNum, Integer pageSize);
}
@@ -0,0 +1,27 @@
package com.tailbet.service;
import com.tailbet.model.entity.BetOrder;
import java.math.BigDecimal;
/**
* 注单Service接口
*/
public interface IBetService {
/**
* 下注
*/
BetOrder placeBet(Long userId, Long groupId, String clientMsgId,
String playType, Integer betAmount);
/**
* 获取赔率
*/
BigDecimal getOdds(String playType);
/**
* 获取用户本局下注总额
*/
Integer getUserRoundBetAmount(Long userId, Long roundId);
}
@@ -0,0 +1,41 @@
package com.tailbet.service;
import com.tailbet.model.entity.User;
import java.util.List;
/**
* 假人服务接口
*/
public interface IFakeUserService {
/**
* 初始化群假人
*/
void initGroupFakeUsers(Long groupId, int minCount, int maxCount);
/**
* 创建单个假人
*/
User createFakeUser(Long groupId);
/**
* 每日重置活跃假人
*/
void resetDailyActiveFakes();
/**
* 检查用户是否是今日活跃假人
*/
boolean isTodayActiveFake(Long userId);
/**
* 假人下注
*/
void fakeBet(Long groupId);
/**
* 获取假人列表
*/
List<User> getFakeUsers();
}
@@ -0,0 +1,92 @@
package com.tailbet.service;
import com.tailbet.model.entity.GameRound;
import com.tailbet.model.entity.DrawRecord;
import com.tailbet.model.entity.BetOrder;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 游戏Service接口
*/
public interface IGameService {
// 局状态常量
int STATUS_ONGOING = 0;
int STATUS_ENDED = 1;
// 玩法赔率
BigDecimal ODDS_SINGLE_DOUBLE = new BigDecimal("1");
BigDecimal ODDS_SIZE = new BigDecimal("1");
BigDecimal ODDS_DIGIT = new BigDecimal("2");
/**
* 尾数试算结果
*/
@Data
class DigitTrial {
private int digit;
private String playWin;
private int totalBet;
private long totalAward;
private long profit;
}
/**
* 获取群当前进行中的局
*/
GameRound getOngoingRound(Long groupId);
/**
* 生成期号
*/
String generateRoundNo(Long groupId);
/**
* 开始游戏(群主操作)
*/
GameRound startGame(Long groupId, Long userId);
/**
* 结束接注
*/
GameRound endBet(Long groupId, Long userId);
/**
* 停止自动连开
*/
void stopAutoNext(Long groupId, Long userId);
/**
* 设置目标尾数
*/
void setTargetDigit(Long groupId, Integer digit, Long userId);
/**
* 尾数试算
*/
List<DigitTrial> trialDigit(Long groupId, Long roundId);
/**
* 判定是否中奖
*/
boolean isWin(BetOrder bet, int digit);
/**
* 获取中奖玩法描述
*/
String getPlayWinDesc(int digit);
/**
* 开奖结算
*/
void settle(Long roundId, int digit);
/**
* 获取开奖历史
*/
Page<DrawRecord> getHistory(Long groupId, Integer pageNum, Integer pageSize);
}
@@ -0,0 +1,62 @@
package com.tailbet.service;
import com.tailbet.model.entity.Group;
import com.tailbet.model.entity.GroupMember;
import java.util.List;
/**
* 群Service接口
*/
public interface IGroupService {
// 角色
int ROLE_NORMAL = 0;
int ROLE_ADMIN = 1;
int ROLE_OWNER = 2;
/**
* 获取群信息
*/
Group getById(Long groupId);
/**
* 获取用户的群列表
*/
List<Group> getUserGroups(Long userId);
/**
* 拉用户入群
*/
void pullUser(Long groupId, Long userId, Long operatorId);
/**
* 踢出用户
*/
void kickUser(Long groupId, Long targetUserId, Long operatorId);
/**
* 设置管理员
*/
void setAdmin(Long groupId, Long targetUserId, Long operatorId);
/**
* 取消管理员
*/
void removeAdmin(Long groupId, Long targetUserId, Long operatorId);
/**
* 获取群成员
*/
List<GroupMember> getMembers(Long groupId);
/**
* 检查用户是否是群主
*/
boolean isOwner(Long groupId, Long userId);
/**
* 检查用户是否是管理员
*/
boolean isAdmin(Long groupId, Long userId);
}
@@ -0,0 +1,38 @@
package com.tailbet.service;
import com.tailbet.model.entity.PointsFlow;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
/**
* 积分Service接口
*/
public interface IPointsService {
// 积分类型常量
String TYPE_OPS_ADD = "ops_add";
String TYPE_OPS_SUB = "ops_sub";
String TYPE_BET = "bet";
String TYPE_AWARD = "award";
String TYPE_RP_SEND = "rp_send";
String TYPE_RP_RECV = "rp_recv";
/**
* 添加积分
*/
void addPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark);
/**
* 扣除积分
*/
boolean subPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark);
/**
* 获取用户积分
*/
Long getPoints(Long userId);
/**
* 查询积分流水记录
*/
Page<PointsFlow> getFlowPage(Long userId, Integer pageNum, Integer pageSize);
}
@@ -0,0 +1,38 @@
package com.tailbet.service;
/**
* 推送服务接口
*/
public interface IPushService {
// 推送类型常量
String PUSH_TYPE_DIGIT_TRIAL = "digit_trial"; // 尾数试算完成
String PUSH_TYPE_GAME_START = "game_start"; // 游戏开始
String PUSH_TYPE_GAME_END = "game_end"; // 游戏结束
String PUSH_TYPE_RESULT = "result"; // 开奖结果
/**
* 推送尾数试算完成通知给白名单用户
*/
void pushDigitTrialComplete(Long groupId, Long roundId);
/**
* 推送游戏开始通知
*/
void pushGameStart(Long groupId, String roundNo);
/**
* 推送开奖结果通知
*/
void pushGameResult(Long groupId, String roundNo, int digit, String playWins);
/**
* 发送单用户推送
*/
void sendPush(PushService.PushMessage message);
/**
* 广播推送消息给群成员
*/
void broadcastToGroup(Long groupId, PushService.PushMessage message);
}
@@ -0,0 +1,59 @@
package com.tailbet.service;
import com.tailbet.model.entity.RedPacket;
import com.tailbet.model.entity.RedPacketRecv;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.math.BigDecimal;
import java.util.List;
/**
* 红包Service接口
*/
public interface IRedPacketService {
// 红包状态
int STATUS_ONGOING = 0;
int STATUS_FINISHED = 1;
int STATUS_REFUND = 2;
// 红包类型
String TYPE_GAME = "game";
String TYPE_RANDOM = "random";
/**
* 发红包
*/
RedPacket sendRp(Long userId, Long groupId, BigDecimal totalAmount,
Integer totalCount, Long roundId);
/**
* 领红包
*/
RedPacketRecv receiveRp(Long rpId, Long userId);
/**
* 计算领取金额
*/
BigDecimal calculateReceiveAmount(RedPacket rp);
/**
* 获取红包详情
*/
RedPacket getById(Long rpId);
/**
* 获取红包领取列表
*/
List<RedPacketRecv> getReceives(Long rpId);
/**
* 查询红包记录
*/
Page<RedPacket> getRecords(Long userId, Integer pageNum, Integer pageSize);
/**
* 查找进行中的游戏红包
*/
RedPacket getOngoingGameRp(Long groupId);
}
@@ -0,0 +1,30 @@
package com.tailbet.service;
import com.tailbet.model.entity.User;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* 用户Service接口
*/
public interface IUserService extends IService<User> {
/**
* 根据用户名查询用户
*/
User getByUsername(String username);
/**
* 注册用户
*/
User register(String username, String password);
/**
* 用户登录
*/
String login(String username, String password);
/**
* 获取当前登录用户
*/
User getCurrentUser();
}
@@ -0,0 +1,59 @@
package com.tailbet.service.impl;
import com.tailbet.model.entity.AuditLog;
import com.tailbet.mapper.AuditLogMapper;
import com.tailbet.service.IAuditService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* 审计日志Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class AuditServiceImpl implements IAuditService {
private final AuditLogMapper auditLogMapper;
/**
* 记录审计日志
*/
@Override
public void log(Long groupId, Long roundId, Long userId, String action, String detail) {
AuditLog auditLog = new AuditLog();
auditLog.setGroupId(groupId);
auditLog.setRoundId(roundId);
auditLog.setUserId(userId);
auditLog.setAction(action);
auditLog.setDetail(detail);
auditLog.setCreateTime(LocalDateTime.now());
auditLogMapper.insert(auditLog);
}
/**
* 分页查询审计日志
*/
@Override
public Page<AuditLog> getPage(Long groupId, Long roundId, String action,
Integer pageNum, Integer pageSize) {
Page<AuditLog> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<AuditLog> wrapper = new LambdaQueryWrapper<>();
if (groupId != null) {
wrapper.eq(AuditLog::getGroupId, groupId);
}
if (roundId != null) {
wrapper.eq(AuditLog::getRoundId, roundId);
}
if (action != null) {
wrapper.eq(AuditLog::getAction, action);
}
wrapper.orderByDesc(AuditLog::getCreateTime);
return auditLogMapper.selectPage(page, wrapper);
}
}
@@ -0,0 +1,153 @@
package com.tailbet.service.impl;
import com.tailbet.model.entity.BetOrder;
import com.tailbet.model.entity.User;
import com.tailbet.model.entity.GameRound;
import com.tailbet.mapper.BetOrderMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.service.IBetService;
import com.tailbet.service.IGameService;
import com.tailbet.service.IPointsService;
import com.tailbet.service.IAuditService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 注单Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class BetServiceImpl implements IBetService {
private final BetOrderMapper betOrderMapper;
private final UserMapper userMapper;
private final GameRoundMapper gameRoundMapper;
private final IPointsService pointsService;
private final IAuditService auditService;
/**
* 下注
*/
@Override
@Transactional
public BetOrder placeBet(Long userId, Long groupId, String clientMsgId,
String playType, Integer betAmount) {
// 防重检查
if (clientMsgId != null) {
BetOrder existing = betOrderMapper.selectOne(new LambdaQueryWrapper<BetOrder>()
.eq(BetOrder::getClientMsgId, clientMsgId));
if (existing != null) {
throw new RuntimeException("重复下注");
}
}
// 检查用户
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (user.getIsFake() != null && user.getIsFake() == 1) {
// 假人下注只记录审计日志,不产生订单
auditService.log(groupId, null, userId, "FAKE_BET",
String.format("{\"playType\":\"%s\",\"amount\":%d}", playType, betAmount));
return null;
}
// 检查进行中的局
GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
.eq(GameRound::getGroupId, groupId)
.eq(GameRound::getStatus, 0));
if (round == null) {
throw new RuntimeException("当前没有进行中的局");
}
// 检查余额
if (user.getPoints() < betAmount) {
throw new RuntimeException("余额不足");
}
// 检查限额
if (betAmount < 1 || betAmount > 2000) {
throw new RuntimeException("单笔下注金额需在1-2000之间");
}
// 检查单局累计
Integer roundTotal = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
.eq(BetOrder::getUserId, userId)
.eq(BetOrder::getRoundId, round.getId())
.eq(BetOrder::getIsFake, 0))
.stream()
.mapToInt(BetOrder::getBetAmount)
.sum();
if (roundTotal + betAmount > 20000) {
throw new RuntimeException("单局累计下注不能超过20000");
}
// 获取赔率
BigDecimal odds = getOdds(playType);
// 扣减积分
boolean success = pointsService.subPoints(userId, betAmount.longValue(),
IPointsService.TYPE_BET, null, null, "游戏下注");
if (!success) {
throw new RuntimeException("余额不足");
}
// 创建注单
BetOrder bet = new BetOrder();
bet.setUserId(userId);
bet.setGroupId(groupId);
bet.setRoundId(round.getId());
bet.setClientMsgId(clientMsgId);
bet.setPlayType(playType);
bet.setBetAmount(betAmount);
bet.setOdds(odds);
bet.setIsFake(0);
bet.setStatus(0);
bet.setCreateTime(LocalDateTime.now());
betOrderMapper.insert(bet);
// 审计日志
auditService.log(groupId, round.getId(), userId, "BET_PLACE",
String.format("{\"betId\":%d,\"playType\":\"%s\",\"amount\":%d}",
bet.getId(), playType, betAmount));
return bet;
}
/**
* 获取赔率
*/
@Override
public BigDecimal getOdds(String playType) {
if ("".equals(playType) || "".equals(playType)) {
return IGameService.ODDS_SINGLE_DOUBLE;
} else if ("".equals(playType) || "".equals(playType)) {
return IGameService.ODDS_SIZE;
} else {
return IGameService.ODDS_DIGIT;
}
}
/**
* 获取用户本局下注总额
*/
@Override
public Integer getUserRoundBetAmount(Long userId, Long roundId) {
return betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
.eq(BetOrder::getUserId, userId)
.eq(BetOrder::getRoundId, roundId)
.eq(BetOrder::getIsFake, 0))
.stream()
.mapToInt(BetOrder::getBetAmount)
.sum();
}
}
@@ -0,0 +1,177 @@
package com.tailbet.service.impl;
import com.tailbet.model.entity.User;
import com.tailbet.model.entity.GameRound;
import com.tailbet.mapper.UserMapper;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.service.IFakeUserService;
import com.tailbet.service.IBetService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 假人服务实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class FakeUserServiceImpl implements IFakeUserService {
private final UserMapper userMapper;
private final GameRoundMapper gameRoundMapper;
private final IBetService betService;
private final StringRedisTemplate redisTemplate;
private static final String FAKE_ACTIVE_KEY = "fake:active:";
private static final Random RANDOM = new Random();
// 假人昵称素材
private static final String[] NICKNAME_PREFIXES = {"", "", "", "", ""};
private static final String[] NICKNAME_SUFFIXES = {"", "", "", "", ""};
/**
* 初始化群假人
*/
@Override
public void initGroupFakeUsers(Long groupId, int minCount, int maxCount) {
int count = minCount + RANDOM.nextInt(maxCount - minCount + 1);
log.info("为群{}初始化{}个假人", groupId, count);
for (int i = 0; i < count; i++) {
createFakeUser(groupId);
}
}
/**
* 创建单个假人
*/
@Override
public User createFakeUser(Long groupId) {
User fake = new User();
fake.setUsername("fake_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
fake.setNickname(generateNickname());
fake.setPoints((long) (1000 + RANDOM.nextInt(49000)));
fake.setIsFake(1);
fake.setIsBot(0);
fake.setIsOps(0);
fake.setIsWhite(0);
fake.setStatus(1);
userMapper.insert(fake);
return fake;
}
/**
* 生成随机昵称
*/
private String generateNickname() {
String prefix = NICKNAME_PREFIXES[RANDOM.nextInt(NICKNAME_PREFIXES.length)];
String suffix = NICKNAME_SUFFIXES[RANDOM.nextInt(NICKNAME_SUFFIXES.length)];
int number = RANDOM.nextInt(1000);
return prefix + suffix + number;
}
/**
* 每日重置活跃假人
*/
@Override
@Scheduled(cron = "0 0 0 * * ?")
public void resetDailyActiveFakes() {
// 清理昨天的活跃标记
Set<String> keys = redisTemplate.opsForSet().members(FAKE_ACTIVE_KEY + "yesterday");
if (keys != null) {
for (String userId : keys) {
redisTemplate.opsForSet().remove(FAKE_ACTIVE_KEY + "yesterday", userId);
}
}
// 查询所有假人
List<User> fakes = userMapper.selectList(new LambdaQueryWrapper<User>()
.eq(User::getIsFake, 1)
.eq(User::getStatus, 1));
if (fakes.isEmpty()) {
return;
}
// 随机选15-20个作为今日活跃
int activeCount = 15 + RANDOM.nextInt(6);
activeCount = Math.min(activeCount, fakes.size());
Collections.shuffle(fakes);
List<String> activeUserIds = new ArrayList<>();
for (int i = 0; i < activeCount; i++) {
activeUserIds.add(String.valueOf(fakes.get(i).getId()));
}
// 存入Redis
String today = java.time.LocalDate.now().toString();
redisTemplate.opsForSet().add(FAKE_ACTIVE_KEY + today, activeUserIds.toArray(new String[0]));
log.info("今日活跃假人已更新: {}个", activeCount);
}
/**
* 检查用户是否是今日活跃假人
*/
@Override
public boolean isTodayActiveFake(Long userId) {
String today = java.time.LocalDate.now().toString();
Boolean isMember = redisTemplate.opsForSet().isMember(FAKE_ACTIVE_KEY + today, String.valueOf(userId));
return Boolean.TRUE.equals(isMember);
}
/**
* 假人下注
*/
@Override
public void fakeBet(Long groupId) {
// 获取今日活跃假人
String today = java.time.LocalDate.now().toString();
Set<String> activeFakes = redisTemplate.opsForSet().members(FAKE_ACTIVE_KEY + today);
if (activeFakes == null || activeFakes.isEmpty()) {
return;
}
// 随机选一个假人
List<String> fakeList = new ArrayList<>(activeFakes);
String fakeIdStr = fakeList.get(RANDOM.nextInt(fakeList.size()));
Long fakeId = Long.parseLong(fakeIdStr);
// 检查是否有进行中的局
GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
.eq(GameRound::getGroupId, groupId)
.eq(GameRound::getStatus, 0));
if (round == null) {
return;
}
// 随机玩法和金额
String[] playTypes = {"", "", "", ""};
String playType = playTypes[RANDOM.nextInt(playTypes.length)];
int amount = (1 + RANDOM.nextInt(10)) * 10; // 10,20,30...100
try {
betService.placeBet(fakeId, groupId, "fake_" + UUID.randomUUID(), playType, amount);
log.debug("假人{}下注: {} {}", fakeId, playType, amount);
} catch (Exception e) {
log.debug("假人下注失败: {}", e.getMessage());
}
}
/**
* 获取假人列表
*/
@Override
public List<User> getFakeUsers() {
return userMapper.selectList(new LambdaQueryWrapper<User>()
.eq(User::getIsFake, 1)
.eq(User::getStatus, 1));
}
}
@@ -0,0 +1,336 @@
package com.tailbet.service.impl;
import com.tailbet.entity.GameRound;
import com.tailbet.entity.DrawRecord;
import com.tailbet.entity.BetOrder;
import com.tailbet.entity.Group;
import com.tailbet.entity.User;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.mapper.DrawRecordMapper;
import com.tailbet.mapper.BetOrderMapper;
import com.tailbet.mapper.GroupMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.service.IGameService;
import com.tailbet.service.IPointsService;
import com.tailbet.service.IAuditService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 游戏Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class GameServiceImpl implements IGameService {
private final GameRoundMapper gameRoundMapper;
private final DrawRecordMapper drawRecordMapper;
private final BetOrderMapper betOrderMapper;
private final GroupMapper groupMapper;
private final UserMapper userMapper;
private final IPointsService pointsService;
private final IAuditService auditService;
/**
* 获取群当前进行中的局
*/
@Override
public GameRound getOngoingRound(Long groupId) {
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
.eq(GameRound::getGroupId, groupId)
.eq(GameRound::getStatus, STATUS_ONGOING));
}
/**
* 生成期号
*/
@Override
public String generateRoundNo(Long groupId) {
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
// 查询当日该群最后一期
LambdaQueryWrapper<GameRound> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(GameRound::getGroupId, groupId)
.likeRight(GameRound::getRoundNo, date)
.orderByDesc(GameRound::getCreateTime)
.last("LIMIT 1");
GameRound lastRound = gameRoundMapper.selectOne(wrapper);
int seq = 1;
if (lastRound != null) {
String lastNo = lastRound.getRoundNo();
String seqStr = lastNo.substring(lastNo.lastIndexOf("-") + 1);
seq = Integer.parseInt(seqStr) + 1;
}
return date + "-" + String.format("%02d", seq);
}
/**
* 开始游戏(群主操作)
*/
@Override
@Transactional
public GameRound startGame(Long groupId, Long userId) {
// 校验群主权限
Group group = groupMapper.selectById(groupId);
if (group == null || !group.getOwnerUserId().equals(userId)) {
throw new RuntimeException("只有群主可以开始游戏");
}
// 检查是否有进行中的局
if (getOngoingRound(groupId) != null) {
throw new RuntimeException("当前已有进行中的局");
}
// 创建新局
GameRound round = new GameRound();
round.setGroupId(groupId);
round.setRoundNo(generateRoundNo(groupId));
round.setStatus(STATUS_ONGOING);
round.setStartTime(LocalDateTime.now());
round.setDigitStrategy("random");
round.setAutoNext(1);
gameRoundMapper.insert(round);
// 审计日志
auditService.log(groupId, round.getId(), userId, "GAME_START",
String.format("{\"roundNo\":\"%s\"}", round.getRoundNo()));
return round;
}
/**
* 结束接注
*/
@Override
@Transactional
public GameRound endBet(Long groupId, Long userId) {
GameRound round = getOngoingRound(groupId);
if (round == null) {
throw new RuntimeException("没有进行中的局");
}
// 校验群主权限
Group group = groupMapper.selectById(groupId);
if (!group.getOwnerUserId().equals(userId)) {
throw new RuntimeException("只有群主可以结束接注");
}
round.setStatus(STATUS_ENDED);
round.setEndBetTime(LocalDateTime.now());
gameRoundMapper.updateById(round);
// 审计日志
auditService.log(groupId, round.getId(), userId, "GAME_END_BET", "{}");
return round;
}
/**
* 停止自动连开
*/
@Override
@Transactional
public void stopAutoNext(Long groupId, Long userId) {
GameRound round = getOngoingRound(groupId);
if (round == null) {
throw new RuntimeException("没有进行中的局");
}
Group group = groupMapper.selectById(groupId);
if (!group.getOwnerUserId().equals(userId)) {
throw new RuntimeException("只有群主可以停止自动连开");
}
round.setAutoNext(0);
gameRoundMapper.updateById(round);
auditService.log(groupId, round.getId(), userId, "STOP_AUTO_NEXT", "{}");
}
/**
* 设置目标尾数
*/
@Override
@Transactional
public void setTargetDigit(Long groupId, Integer digit, Long userId) {
GameRound round = getOngoingRound(groupId);
if (round == null) {
throw new RuntimeException("没有进行中的局");
}
round.setTargetDigit(digit);
round.setDigitStrategy("specified");
gameRoundMapper.updateById(round);
auditService.log(groupId, round.getId(), userId, "SET_DIGIT",
String.format("{\"digit\":%d}", digit));
}
/**
* 尾数试算
*/
@Override
public List<IGameService.DigitTrial> trialDigit(Long groupId, Long roundId) {
GameRound round;
if (roundId != null) {
round = gameRoundMapper.selectById(roundId);
} else {
round = getOngoingRound(groupId);
}
if (round == null) {
throw new RuntimeException("没有进行中的局");
}
// 获取本局所有注单
List<BetOrder> bets = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
.eq(BetOrder::getRoundId, round.getId())
.eq(BetOrder::getIsFake, 0));
// 计算0-9各尾数的庄家利润
java.util.List<IGameService.DigitTrial> result = new java.util.ArrayList<>();
for (int d = 0; d <= 9; d++) {
IGameService.DigitTrial trial = new IGameService.DigitTrial();
trial.setDigit(d);
trial.setPlayWin(getPlayWinDesc(d));
int totalBet = 0;
long totalAward = 0;
for (BetOrder bet : bets) {
totalBet += bet.getBetAmount();
if (isWin(bet, d)) {
totalAward += bet.getBetAmount() * (1 + bet.getOdds().doubleValue());
}
}
trial.setTotalBet(totalBet);
trial.setTotalAward(totalAward);
trial.setProfit(totalBet - totalAward);
result.add(trial);
}
return result;
}
/**
* 判定是否中奖
*/
@Override
public boolean isWin(BetOrder bet, int digit) {
String playType = bet.getPlayType();
if ("".equals(playType)) {
return digit % 2 == 1; // 奇数为单
} else if ("".equals(playType)) {
return digit % 2 == 0; // 偶数为双(含0)
} else if ("".equals(playType)) {
return digit >= 5;
} else if ("".equals(playType)) {
return digit < 5;
} else if (playType.matches("[0-9]")) {
return Integer.parseInt(playType) == digit;
}
return false;
}
/**
* 获取中奖玩法描述
*/
@Override
public String getPlayWinDesc(int digit) {
StringBuilder sb = new StringBuilder();
sb.append(digit % 2 == 1 ? "" : "");
sb.append("/");
sb.append(digit >= 5 ? "" : "");
sb.append("/");
sb.append("数字").append(digit);
return sb.toString();
}
/**
* 开奖结算
*/
@Override
@Transactional
public void settle(Long roundId, int digit) {
GameRound round = gameRoundMapper.selectById(roundId);
if (round == null) {
throw new RuntimeException("局不存在");
}
// 获取本局所有注单
List<BetOrder> bets = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
.eq(BetOrder::getRoundId, roundId)
.eq(BetOrder::getStatus, 0)); // 待结算
long totalBet = 0;
long totalAward = 0;
for (BetOrder bet : bets) {
totalBet += bet.getBetAmount();
if (isWin(bet, digit)) {
// 中奖,派奖
long winAmount = (long) (bet.getBetAmount() * (1 + bet.getOdds().doubleValue()));
bet.setStatus(1); // 已结算
bet.setWinAmount(winAmount);
betOrderMapper.updateById(bet);
// 给用户加积分
pointsService.addPoints(bet.getUserId(), winAmount, IPointsService.TYPE_AWARD,
String.valueOf(bet.getId()), null, "游戏中奖");
totalAward += winAmount;
} else {
// 未中奖
bet.setStatus(2); // 未中奖
betOrderMapper.updateById(bet);
}
}
// 记录开奖结果
DrawRecord record = new DrawRecord();
record.setGroupId(round.getGroupId());
record.setRoundNo(round.getRoundNo());
record.setRoundId(roundId);
record.setDigit(digit);
record.setPlayWins(getPlayWinDesc(digit));
record.setTotalBet((int) totalBet);
record.setTotalAward(totalAward);
record.setProfit(totalBet - totalAward);
record.setCreateTime(LocalDateTime.now());
drawRecordMapper.insert(record);
// 更新局状态
round.setFinishTime(LocalDateTime.now());
round.setStatus(STATUS_ENDED);
gameRoundMapper.updateById(round);
// 审计日志
auditService.log(round.getGroupId(), roundId, null, "SETTLE",
String.format("{\"digit\":%d,\"totalBet\":%d,\"totalAward\":%d,\"profit\":%d}",
digit, totalBet, totalAward, totalBet - totalAward));
}
/**
* 获取开奖历史
*/
@Override
public Page<DrawRecord> getHistory(Long groupId, Integer pageNum, Integer pageSize) {
Page<DrawRecord> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<DrawRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DrawRecord::getGroupId, groupId)
.orderByDesc(DrawRecord::getCreateTime);
return drawRecordMapper.selectPage(page, wrapper);
}
}
@@ -0,0 +1,214 @@
package com.tailbet.service.impl;
import com.tailbet.model.entity.Group;
import com.tailbet.model.entity.GroupMember;
import com.tailbet.mapper.GroupMapper;
import com.tailbet.mapper.GroupMemberMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.service.IGroupService;
import com.tailbet.service.IAuditService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 群Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class GroupServiceImpl implements IGroupService {
private final GroupMapper groupMapper;
private final GroupMemberMapper groupMemberMapper;
private final UserMapper userMapper;
private final IAuditService auditService;
/**
* 获取群信息
*/
@Override
public Group getById(Long groupId) {
return groupMapper.selectById(groupId);
}
/**
* 获取用户的群列表
*/
@Override
public List<Group> getUserGroups(Long userId) {
List<GroupMember> members = groupMemberMapper.selectList(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getUserId, userId));
if (members.isEmpty()) {
return List.of();
}
List<Long> groupIds = members.stream()
.map(GroupMember::getGroupId)
.toList();
return groupMapper.selectList(new LambdaQueryWrapper<Group>()
.in(Group::getId, groupIds)
.eq(Group::getStatus, 1));
}
/**
* 拉用户入群
*/
@Override
@Transactional
public void pullUser(Long groupId, Long userId, Long operatorId) {
// 检查是否已在群中
GroupMember existing = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId)
.eq(GroupMember::getUserId, userId));
if (existing != null) {
throw new RuntimeException("用户已在群中");
}
GroupMember member = new GroupMember();
member.setGroupId(groupId);
member.setUserId(userId);
member.setRole(ROLE_NORMAL);
member.setJoinTime(LocalDateTime.now());
member.setCreateTime(LocalDateTime.now());
member.setUpdateTime(LocalDateTime.now());
groupMemberMapper.insert(member);
// 审计日志
auditService.log(groupId, null, operatorId, "PULL_USER",
String.format("{\"userId\":%d}", userId));
}
/**
* 踢出用户
*/
@Override
@Transactional
public void kickUser(Long groupId, Long targetUserId, Long operatorId) {
Group group = groupMapper.selectById(groupId);
// 检查权限
GroupMember operator = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId)
.eq(GroupMember::getUserId, operatorId));
if (operator == null) {
throw new RuntimeException("你不是群成员");
}
// 只有群主和管理员可以踢人
if (operator.getRole() != ROLE_OWNER && operator.getRole() != ROLE_ADMIN) {
throw new RuntimeException("只有群主和管理员可以踢人");
}
// 不能踢群主
if (group.getOwnerUserId().equals(targetUserId)) {
throw new RuntimeException("不能踢出群主");
}
// 删除成员
groupMemberMapper.delete(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId)
.eq(GroupMember::getUserId, targetUserId));
// 审计日志
auditService.log(groupId, null, operatorId, "KICK_USER",
String.format("{\"targetUserId\":%d}", targetUserId));
}
/**
* 设置管理员
*/
@Override
@Transactional
public void setAdmin(Long groupId, Long targetUserId, Long operatorId) {
Group group = groupMapper.selectById(groupId);
// 只有群主可以设置管理员
if (!group.getOwnerUserId().equals(operatorId)) {
throw new RuntimeException("只有群主可以设置管理员");
}
GroupMember member = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId)
.eq(GroupMember::getUserId, targetUserId));
if (member == null) {
throw new RuntimeException("目标用户不是群成员");
}
member.setRole(ROLE_ADMIN);
member.setUpdateTime(LocalDateTime.now());
groupMemberMapper.updateById(member);
// 审计日志
auditService.log(groupId, null, operatorId, "SET_ADMIN",
String.format("{\"targetUserId\":%d}", targetUserId));
}
/**
* 取消管理员
*/
@Override
@Transactional
public void removeAdmin(Long groupId, Long targetUserId, Long operatorId) {
Group group = groupMapper.selectById(groupId);
if (!group.getOwnerUserId().equals(operatorId)) {
throw new RuntimeException("只有群主可以取消管理员");
}
GroupMember member = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId)
.eq(GroupMember::getUserId, targetUserId));
if (member == null) {
throw new RuntimeException("目标用户不是群成员");
}
member.setRole(ROLE_NORMAL);
member.setUpdateTime(LocalDateTime.now());
groupMemberMapper.updateById(member);
// 审计日志
auditService.log(groupId, null, operatorId, "REMOVE_ADMIN",
String.format("{\"targetUserId\":%d}", targetUserId));
}
/**
* 获取群成员
*/
@Override
public List<GroupMember> getMembers(Long groupId) {
return groupMemberMapper.selectList(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId));
}
/**
* 检查用户是否是群主
*/
@Override
public boolean isOwner(Long groupId, Long userId) {
Group group = groupMapper.selectById(groupId);
return group != null && group.getOwnerUserId().equals(userId);
}
/**
* 检查用户是否是管理员
*/
@Override
public boolean isAdmin(Long groupId, Long userId) {
GroupMember member = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, groupId)
.eq(GroupMember::getUserId, userId));
return member != null && (member.getRole() == ROLE_ADMIN || member.getRole() == ROLE_OWNER);
}
}
@@ -0,0 +1,111 @@
package com.tailbet.service.impl;
import com.tailbet.model.entity.PointsFlow;
import com.tailbet.model.entity.User;
import com.tailbet.mapper.PointsFlowMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.service.IPointsService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 积分Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class PointsServiceImpl implements IPointsService {
private final PointsFlowMapper pointsFlowMapper;
private final UserMapper userMapper;
/**
* 添加积分
*/
@Override
@Transactional
public void addPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
Long before = user.getPoints();
Long after = before + amount;
user.setPoints(after);
userMapper.updateById(user);
// 记录流水
PointsFlow flow = new PointsFlow();
flow.setUserId(userId);
flow.setType(type);
flow.setAmount(amount);
flow.setBalanceBefore(before);
flow.setBalanceAfter(after);
flow.setBizNo(bizNo);
flow.setOperatorId(operatorId);
flow.setRemark(remark);
pointsFlowMapper.insert(flow);
}
/**
* 扣除积分
*/
@Override
@Transactional
public boolean subPoints(Long userId, Long amount, String type, String bizNo, Long operatorId, String remark) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (user.getPoints() < amount) {
return false;
}
Long before = user.getPoints();
Long after = before - amount;
user.setPoints(after);
userMapper.updateById(user);
// 记录流水
PointsFlow flow = new PointsFlow();
flow.setUserId(userId);
flow.setType(type);
flow.setAmount(-amount);
flow.setBalanceBefore(before);
flow.setBalanceAfter(after);
flow.setBizNo(bizNo);
flow.setOperatorId(operatorId);
flow.setRemark(remark);
pointsFlowMapper.insert(flow);
return true;
}
/**
* 获取用户积分
*/
@Override
public Long getPoints(Long userId) {
User user = userMapper.selectById(userId);
return user != null ? user.getPoints() : 0L;
}
/**
* 查询积分流水记录
*/
@Override
public Page<PointsFlow> getFlowPage(Long userId, Integer pageNum, Integer pageSize) {
Page<PointsFlow> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<PointsFlow> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PointsFlow::getUserId, userId)
.orderByDesc(PointsFlow::getCreateTime);
return pointsFlowMapper.selectPage(page, wrapper);
}
}
@@ -0,0 +1,125 @@
package com.tailbet.service.impl;
import com.tailbet.entity.User;
import com.tailbet.entity.DigitWhite;
import com.tailbet.mapper.UserMapper;
import com.tailbet.mapper.DigitWhiteMapper;
import com.tailbet.openim.OpenIMClient;
import com.tailbet.service.IPushService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 推送服务实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class PushServiceImpl implements IPushService {
private final UserMapper userMapper;
private final DigitWhiteMapper digitWhiteMapper;
private final OpenIMClient openIMClient;
/**
* 推送尾数试算完成通知给白名单用户
*/
@Override
public void pushDigitTrialComplete(Long groupId, Long roundId) {
List<DigitWhite> whites = digitWhiteMapper.selectList(new LambdaQueryWrapper<DigitWhite>());
for (DigitWhite white : whites) {
User user = userMapper.selectById(white.getUserId());
if (user != null) {
PushService.PushMessage message = new PushService.PushMessage();
message.setType(PUSH_TYPE_DIGIT_TRIAL);
message.setTitle("尾数试算完成");
message.setContent("可前往修改尾数");
message.setUserId(user.getId());
message.setGroupId(groupId);
message.setRoundId(roundId);
sendPush(message);
}
}
}
/**
* 推送游戏开始通知
*/
@Override
public void pushGameStart(Long groupId, String roundNo) {
PushService.PushMessage message = new PushService.PushMessage();
message.setType(PUSH_TYPE_GAME_START);
message.setTitle("游戏开始");
message.setContent(roundNo + "期已开始下注");
message.setGroupId(groupId);
message.setRoundNo(roundNo);
// 推送给所有群成员
broadcastToGroup(groupId, message);
}
/**
* 推送开奖结果通知
*/
@Override
public void pushGameResult(Long groupId, String roundNo, int digit, String playWins) {
PushService.PushMessage message = new PushService.PushMessage();
message.setType(PUSH_TYPE_RESULT);
message.setTitle("开奖结果");
message.setContent(roundNo + "期开奖\n尾数: " + digit + "\n中奖玩法: " + playWins);
message.setGroupId(groupId);
message.setRoundNo(roundNo);
message.setDigit(digit);
// 广播给所有群成员
broadcastToGroup(groupId, message);
}
/**
* 发送单用户推送
*/
@Override
public void sendPush(PushService.PushMessage message) {
try {
User user = userMapper.selectById(message.getUserId());
if (user == null) {
log.warn("推送用户不存在: userId={}", message.getUserId());
return;
}
// TODO: 根据实际推送通道实现
// 1. 如果用户在线,通过OpenIM发送即时消息
// 2. 如果用户离线,通过FCM/APNs发送离线推送
// 通过OpenIM单聊发送
openIMClient.sendUserMessage(String.valueOf(user.getId()),
message.getTitle() + " - " + message.getContent());
log.info("推送发送成功: userId={}, type={}", message.getUserId(), message.getType());
} catch (Exception e) {
log.error("推送发送失败: userId={}, type={}, error={}",
message.getUserId(), message.getType(), e.getMessage());
}
}
/**
* 广播推送消息给群成员
*/
@Override
public void broadcastToGroup(Long groupId, PushService.PushMessage message) {
try {
// 通过OpenIM群消息发送
openIMClient.sendGroupMessage(String.valueOf(groupId),
message.getContent());
log.info("群广播发送成功: groupId={}, type={}", groupId, message.getType());
} catch (Exception e) {
log.error("群广播发送失败: groupId={}, type={}, error={}",
groupId, message.getType(), e.getMessage());
}
}
}
@@ -0,0 +1,205 @@
package com.tailbet.service.impl;
import com.tailbet.model.entity.RedPacket;
import com.tailbet.model.entity.RedPacketRecv;
import com.tailbet.model.entity.User;
import com.tailbet.mapper.RedPacketMapper;
import com.tailbet.mapper.RedPacketRecvMapper;
import com.tailbet.mapper.UserMapper;
import com.tailbet.mapper.GameRoundMapper;
import com.tailbet.service.IRedPacketService;
import com.tailbet.service.IPointsService;
import com.tailbet.service.IAuditService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
/**
* 红包Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class RedPacketServiceImpl implements IRedPacketService {
private final RedPacketMapper redPacketMapper;
private final RedPacketRecvMapper redPacketRecvMapper;
private final UserMapper userMapper;
private final GameRoundMapper gameRoundMapper;
private final IPointsService pointsService;
private final IAuditService auditService;
/**
* 发红包
*/
@Override
@Transactional
public RedPacket sendRp(Long userId, Long groupId, BigDecimal totalAmount,
Integer totalCount, Long roundId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 扣积分
boolean success = pointsService.subPoints(userId, totalAmount.longValue(),
IPointsService.TYPE_RP_SEND, null, null, "发送红包");
if (!success) {
throw new RuntimeException("余额不足");
}
// 创建红包
RedPacket rp = new RedPacket();
rp.setUserId(userId);
rp.setGroupId(groupId);
rp.setRoundId(roundId);
rp.setTotalAmount(totalAmount);
rp.setTotalCount(totalCount);
rp.setRemainAmount(totalAmount);
rp.setRemainCount(totalCount);
rp.setStatus(STATUS_ONGOING);
rp.setCreateTime(LocalDateTime.now());
// 判断红包类型
if (roundId != null) {
rp.setType(TYPE_GAME);
} else {
rp.setType(TYPE_RANDOM);
}
redPacketMapper.insert(rp);
// 审计日志
auditService.log(groupId, roundId, userId, "RP_SEND",
String.format("{\"rpId\":%d,\"amount\":%s,\"count\":%d}",
rp.getId(), totalAmount, totalCount));
return rp;
}
/**
* 领红包
*/
@Override
@Transactional
public RedPacketRecv receiveRp(Long rpId, Long userId) {
RedPacket rp = redPacketMapper.selectById(rpId);
if (rp == null) {
throw new RuntimeException("红包不存在");
}
if (rp.getStatus() != STATUS_ONGOING) {
throw new RuntimeException("红包已领完");
}
// 检查是否已领取
RedPacketRecv existing = redPacketRecvMapper.selectOne(new LambdaQueryWrapper<RedPacketRecv>()
.eq(RedPacketRecv::getRpId, rpId)
.eq(RedPacketRecv::getUserId, userId));
if (existing != null) {
throw new RuntimeException("已领取过该红包");
}
// 计算领取金额
BigDecimal amount = calculateReceiveAmount(rp);
// 更新红包
rp.setRemainAmount(rp.getRemainAmount().subtract(amount));
rp.setRemainCount(rp.getRemainCount() - 1);
if (rp.getRemainCount() <= 0) {
rp.setStatus(STATUS_FINISHED);
}
redPacketMapper.updateById(rp);
// 加积分
pointsService.addPoints(userId, amount.longValue(),
IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包");
// 记录领取
RedPacketRecv recv = new RedPacketRecv();
recv.setRpId(rpId);
recv.setUserId(userId);
recv.setAmount(amount);
recv.setCreateTime(LocalDateTime.now());
redPacketRecvMapper.insert(recv);
// 审计日志
auditService.log(rp.getGroupId(), rp.getRoundId(), userId, "RP_RECV",
String.format("{\"rpId\":%d,\"amount\":%s}", rpId, amount));
return recv;
}
/**
* 计算领取金额
*/
@Override
public BigDecimal calculateReceiveAmount(RedPacket rp) {
if (rp.getRemainCount() <= 0) {
return BigDecimal.ZERO;
}
// 如果是游戏红包且有目标尾数,则需要控制手气王
// 简化实现:随机分配
Random random = new Random();
if (rp.getRemainCount() == 1) {
// 最后一个,拿剩余全部
return rp.getRemainAmount();
}
// 随机金额,最小0.01
double min = 0.01;
double max = rp.getRemainAmount().divide(BigDecimal.valueOf(rp.getRemainCount()), 2, RoundingMode.HALF_UP).doubleValue() * 2;
double amount = min + (max - min) * random.nextDouble();
return BigDecimal.valueOf(amount).setScale(2, RoundingMode.HALF_UP);
}
/**
* 获取红包详情
*/
@Override
public RedPacket getById(Long rpId) {
return redPacketMapper.selectById(rpId);
}
/**
* 获取红包领取列表
*/
@Override
public List<RedPacketRecv> getReceives(Long rpId) {
return redPacketRecvMapper.selectList(new LambdaQueryWrapper<RedPacketRecv>()
.eq(RedPacketRecv::getRpId, rpId)
.orderByAsc(RedPacketRecv::getCreateTime));
}
/**
* 查询红包记录
*/
@Override
public Page<RedPacket> getRecords(Long userId, Integer pageNum, Integer pageSize) {
Page<RedPacket> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<RedPacket> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RedPacket::getUserId, userId)
.orderByDesc(RedPacket::getCreateTime);
return redPacketMapper.selectPage(page, wrapper);
}
/**
* 查找进行中的游戏红包
*/
@Override
public RedPacket getOngoingGameRp(Long groupId) {
return redPacketMapper.selectOne(new LambdaQueryWrapper<RedPacket>()
.eq(RedPacket::getGroupId, groupId)
.eq(RedPacket::getType, TYPE_GAME)
.eq(RedPacket::getStatus, STATUS_ONGOING)
.isNotNull(RedPacket::getRoundId));
}
}
@@ -0,0 +1,149 @@
package com.tailbet.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tailbet.model.entity.User;
import com.tailbet.mapper.UserMapper;
import com.tailbet.model.dto.UpdateUserDTO;
import com.tailbet.service.IUserService;
import com.tailbet.util.JwtUtil;
import com.tailbet.util.PasswordUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 用户Service实现
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class UserServiceImpl implements IUserService {
private final UserMapper userMapper;
private final PasswordUtil passwordUtil;
private final JwtUtil jwtUtil;
@Override
public User getById(Long id) {
return userMapper.selectById(id);
}
/**
* 根据用户名查询用户
*/
@Override
public User getByUsername(String username) {
return userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));
}
/**
* 注册用户
*/
@Override
public User register(String username, String password) {
// 检查用户名是否已存在
if (getByUsername(username) != null) {
throw new RuntimeException("用户名已存在");
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordUtil.encode(password));
user.setNickname(username);
user.setPoints(0L);
user.setStatus(1);
userMapper.insert(user);
// 分配运营(按最少绑定+优先在线算法)
assignOps(user);
return user;
}
/**
* 用户登录
*/
@Override
public String login(String username, String password) {
User user = getByUsername(username);
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (user.getStatus() == 0) {
throw new RuntimeException("账号已被封禁");
}
if (!passwordUtil.matches(password, user.getPassword())) {
throw new RuntimeException("密码错误");
}
return jwtUtil.generateToken(user.getId(), user.getUsername());
}
/**
* 更新用户
*/
@Override
public boolean updateUser(User user) {
return userMapper.updateById(user) > 0;
}
/**
* 更新用户信息
*/
@Override
public void updateUserInfo(Long userId, UpdateUserDTO dto) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (dto.getNickname() != null && !dto.getNickname().isEmpty()) {
user.setNickname(dto.getNickname());
}
if (dto.getAvatarUrl() != null) {
user.setAvatarUrl(dto.getAvatarUrl());
}
if (dto.getPassword() != null && !dto.getPassword().isEmpty()) {
user.setPassword(passwordUtil.encode(dto.getPassword()));
}
userMapper.updateById(user);
}
/**
* 获取当前登录用户
*/
@Override
public User getCurrentUser() {
return null;
}
/**
* 分配运营(粘性绑定:在线+最少绑定)
*/
private void assignOps(User user) {
// 查询所有运营账号
List<User> opsList = userMapper.selectList(new LambdaQueryWrapper<User>()
.eq(User::getIsOps, 1)
.eq(User::getStatus, 1));
if (opsList == null || opsList.isEmpty()) {
log.warn("没有可用的运营账号,用户{}将无法分配运营", user.getUsername());
return;
}
// 找绑定用户最少的运营
User selectedOps = opsList.stream()
.min((a, b) -> {
Long countA = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getBoundOpsUserId, a.getId()));
Long countB = userMapper.selectCount(new LambdaQueryWrapper<User>()
.eq(User::getBoundOpsUserId, b.getId()));
return countA.compareTo(countB);
})
.orElse(opsList.get(0));
user.setBoundOpsUserId(selectedOps.getId());
userMapper.updateById(user);
}
}
@@ -0,0 +1,93 @@
package com.tailbet.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT工具类
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 生成Token
*/
public String generateToken(Long userId, String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
return createToken(claims, username);
}
private String createToken(Map<String, Object> claims, String subject) {
Date now = new Date();
Date expirationDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expirationDate)
.signWith(getSigningKey())
.compact();
}
/**
* 解析Token
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 获取用户ID
*/
public Long getUserId(String token) {
Claims claims = parseToken(token);
return claims.get("userId", Long.class);
}
/**
* 获取用户名
*/
public String getUsername(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
/**
* 验证Token是否过期
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = parseToken(token);
return claims.getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
}
@@ -0,0 +1,29 @@
package com.tailbet.util;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import org.springframework.stereotype.Component;
/**
* 密码工具类
*/
@Component
public class PasswordUtil {
/**
* BCrypt加密
*/
public String encode(String rawPassword) {
return DigestUtil.bcrypt(rawPassword);
}
/**
* BCrypt校验
*/
public boolean matches(String rawPassword, String encodedPassword) {
if (StrUtil.isBlank(rawPassword) || StrUtil.isBlank(encodedPassword)) {
return false;
}
return DigestUtil.bcryptCheck(rawPassword, encodedPassword);
}
}
@@ -0,0 +1,32 @@
package com.tailbet.util;
import java.lang.annotation.*;
/**
* Redis分布式锁注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedisLock {
/**
* 锁的key,支持SpEL表达式
*/
String key();
/**
* 锁过期时间(秒)
*/
int expire() default 30;
/**
* 是否等待锁
*/
boolean waitLock() default false;
/**
* 等待锁的最大时间(毫秒)
*/
long waitTime() default 3000;
}
@@ -0,0 +1,83 @@
package com.tailbet.util;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
/**
* Redis分布式锁工具
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisLockUtil {
private final StringRedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "lock:";
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
/**
* 加锁
*/
public boolean lock(String key, String value, long expireSeconds) {
String lockKey = LOCK_PREFIX + key;
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, value, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 解锁
*/
public boolean unlock(String key, String value) {
String lockKey = LOCK_PREFIX + key;
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(lockKey), value);
return result != null && result == 1;
}
/**
* 尝试获取锁
*/
public String tryLock(String key, long expireSeconds, long waitTimeMillis) {
String lockKey = LOCK_PREFIX + key;
String value = String.valueOf(System.currentTimeMillis());
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < waitTimeMillis) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, value, expireSeconds, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(result)) {
return value;
}
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return null;
}
/**
* 释放锁
*/
public void delete(String key) {
String lockKey = LOCK_PREFIX + key;
redisTemplate.delete(lockKey);
}
}
+53
View File
@@ -0,0 +1,53 @@
spring:
application:
name: guess-game
profiles:
active: dev
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/guess_game?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8
username: root
password: ${DB_PASSWORD:123456}
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:123456}
database: ${REDIS_DB:0}
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
server:
port: 8080
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.tailbet.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
openim:
api-url: ${OPENIM_API_URL:http://localhost:10010}
ws-url: ${OPENIM_WS_URL:ws://localhost:10010}
admin-user: ${OPENIM_ADMIN_USER:admin}
admin-token: ${OPENIM_ADMIN_TOKEN:}
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-for-jwt-token-generation}
expiration: 604800000
logging:
level:
com.tailbet: debug
+223
View File
@@ -0,0 +1,223 @@
#
CREATE DATABASE IF NOT EXISTS guess_game DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE guess_game;
-- 用户表
CREATE TABLE IF NOT EXISTS user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '账号',
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt)',
nickname VARCHAR(50) DEFAULT '' COMMENT '昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
points BIGINT DEFAULT 0 COMMENT '积分余额',
is_ops TINYINT DEFAULT 0 COMMENT '运营标记:0否 1是',
is_bot TINYINT DEFAULT 0 COMMENT '机器人标记:0否 1是',
is_fake TINYINT DEFAULT 0 COMMENT '假人托标记:0否 1是',
is_white TINYINT DEFAULT 0 COMMENT '加白(尾数控制)标记',
bound_ops_user_id BIGINT DEFAULT 0 COMMENT '绑定的运营用户ID',
status TINYINT DEFAULT 1 COMMENT '状态:1正常 0封禁',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0否 1是',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_ops (is_ops),
INDEX idx_fake (is_fake),
INDEX idx_bound_ops (bound_ops_user_id),
INDEX idx_status (status)
) COMMENT '用户表';
-- 群表
CREATE TABLE IF NOT EXISTS `group` (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '群名称',
openim_group_id VARCHAR(100) NOT NULL COMMENT 'OpenIM群ID',
owner_user_id BIGINT NOT NULL COMMENT '群主用户ID',
round_interval INT DEFAULT 60 COMMENT '接注间隔(秒)',
status TINYINT DEFAULT 1 COMMENT '状态:1启用 0停用',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_owner (owner_user_id),
INDEX idx_status (status)
) COMMENT '群表';
-- 群成员表
CREATE TABLE IF NOT EXISTS group_member (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL COMMENT '群ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
role TINYINT DEFAULT 0 COMMENT '角色:0普通 1管理员 2群主',
join_time DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
UNIQUE KEY uk_group_user (group_id, user_id),
INDEX idx_user (user_id)
) COMMENT '群成员表';
-- 积分流水表
CREATE TABLE IF NOT EXISTS points_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
type VARCHAR(20) NOT NULL COMMENT '类型:ops_add/ops_sub/bet/award/rp_send/rp_recv',
amount BIGINT NOT NULL COMMENT '变动数值(正负)',
balance_before BIGINT NOT NULL COMMENT '变动前余额',
balance_after BIGINT NOT NULL COMMENT '变动后余额',
biz_no VARCHAR(100) COMMENT '业务单号',
operator_id BIGINT COMMENT '操作人ID',
remark VARCHAR(255) DEFAULT '' COMMENT '备注',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_time (user_id, create_time),
INDEX idx_biz_no (biz_no),
INDEX idx_type (type)
) COMMENT '积分流水表';
-- 游戏局表
CREATE TABLE IF NOT EXISTS game_round (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL COMMENT '群ID',
round_no VARCHAR(20) NOT NULL COMMENT '期号:yyyy-MM-dd-xx',
status TINYINT DEFAULT 0 COMMENT '状态:0进行中 1已结束',
start_time DATETIME COMMENT '开始时间',
end_bet_time DATETIME COMMENT '截止下注时间',
start_rp_time DATETIME COMMENT '开始发红包时间',
finish_time DATETIME COMMENT '结束时间',
target_digit INT COMMENT '目标尾数:0-9 null表示待定',
digit_strategy VARCHAR(20) DEFAULT 'random' COMMENT '尾数策略',
auto_next TINYINT DEFAULT 1 COMMENT '自动开启下一期:0否 1是',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_round (group_id, round_no),
INDEX idx_status (status),
INDEX idx_group_id (group_id)
) COMMENT '游戏局表';
-- 注单表
CREATE TABLE IF NOT EXISTS bet_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
group_id BIGINT NOT NULL COMMENT '群ID',
round_id BIGINT NOT NULL COMMENT '局ID',
client_msg_id VARCHAR(100) COMMENT '客户端消息ID(防重)',
play_type VARCHAR(10) NOT NULL COMMENT '玩法:单/双/大/小/数字',
bet_amount INT NOT NULL COMMENT '下注金额',
odds DECIMAL(5,2) NOT NULL COMMENT '赔率',
is_fake TINYINT DEFAULT 0 COMMENT '是否假人下注',
status TINYINT DEFAULT 0 COMMENT '状态:0待结算 1已结算 2未中奖',
win_amount BIGINT DEFAULT 0 COMMENT '中奖金额',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_round (user_id, round_id),
INDEX idx_round (round_id),
INDEX idx_client_msg (client_msg_id),
INDEX idx_group_id (group_id)
) COMMENT '注单表';
-- 红包表
CREATE TABLE IF NOT EXISTS red_packet (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL COMMENT '群ID',
round_id BIGINT COMMENT '绑定局ID(null=普通红包)',
user_id BIGINT NOT NULL COMMENT '发送者ID',
type VARCHAR(20) NOT NULL COMMENT '类型:game游戏/random随机',
total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额',
total_count INT NOT NULL COMMENT '总个数',
remain_amount DECIMAL(10,2) NOT NULL COMMENT '剩余金额',
remain_count INT NOT NULL COMMENT '剩余个数',
lucky_amount DECIMAL(10,2) COMMENT '手气王金额',
lucky_user_id BIGINT COMMENT '手气王用户ID',
lucky_digit INT COMMENT '手气王尾数',
status TINYINT DEFAULT 0 COMMENT '状态:0进行中 1已领完 2已退款',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_round (round_id),
INDEX idx_group_status (group_id, status),
INDEX idx_user (user_id)
) COMMENT '红包表';
-- 红包领取表
CREATE TABLE IF NOT EXISTS red_packet_recv (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
rp_id BIGINT NOT NULL COMMENT '红包ID',
user_id BIGINT NOT NULL COMMENT '领取者ID',
amount DECIMAL(10,2) NOT NULL COMMENT '领取金额',
is_lucky TINYINT DEFAULT 0 COMMENT '是否手气王',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_rp_user (rp_id, user_id),
INDEX idx_user (user_id)
) COMMENT '红包领取表';
-- 开奖记录表
CREATE TABLE IF NOT EXISTS draw_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL COMMENT '群ID',
round_no VARCHAR(20) NOT NULL COMMENT '期号',
round_id BIGINT NOT NULL COMMENT '局ID',
digit INT NOT NULL COMMENT '开奖尾数:0-9',
play_wins VARCHAR(100) COMMENT '中奖玩法:单,大,数字7',
total_bet INT DEFAULT 0 COMMENT '总下注',
total_award BIGINT DEFAULT 0 COMMENT '总派奖',
profit BIGINT DEFAULT 0 COMMENT '庄家利润',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_round (group_id, round_no),
INDEX idx_group_time (group_id, create_time)
) COMMENT '开奖记录表';
-- 尾数控制白名单表
CREATE TABLE IF NOT EXISTS digit_white (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL UNIQUE COMMENT '用户ID',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id)
) COMMENT '尾数控制白名单';
-- 审计日志表
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT COMMENT '群ID',
round_id BIGINT COMMENT '局ID',
user_id BIGINT COMMENT '操作用户ID',
action VARCHAR(50) NOT NULL COMMENT '动作类型',
detail JSON COMMENT '详情JSON',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_group_round (group_id, round_id),
INDEX idx_user_time (user_id, create_time),
INDEX idx_action (action)
) COMMENT '审计日志表';
-- 系统配置表
CREATE TABLE IF NOT EXISTS sys_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
cfg_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
cfg_value VARCHAR(500) NOT NULL COMMENT '配置值',
remark VARCHAR(255) DEFAULT '' COMMENT '备注',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT '系统配置表';
-- 初始化系统配置
INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES
('bet_min', '1', '单笔最小下注'),
('bet_max', '2000', '单笔最大下注'),
('bet_round_max', '20000', '单局最大下注'),
('round_interval', '60', '接注间隔(秒)'),
('send_rp_delay', '20', '结束下注后多少秒开始发红包'),
('auto_rp_timeout', '10', '开始发红包后多少秒无人发则机器人发'),
('fake_recv_timeout', '5', '红包发出后多少秒未领完托代领'),
('auto_next_delay', '5', '开奖后多少秒自动下一期'),
('odds_single_double', '1', '单双赔率'),
('odds_size', '1', '大小赔率'),
('odds_digit', '2', '数字赔率'),
('bot_initial_points', '1000000', '机器人初始积分'),
('fake_min_points', '1000', '假人初始积分最小值'),
('fake_max_points', '50000', '假人初始积分最大值'),
('fake_pool_min', '60', '假人池最小数量'),
('fake_pool_max', '120', '假人池最大数量'),
('fake_daily_active_min', '15', '每日活跃假人最小数量'),
('fake_daily_active_max', '20', '每日活跃假人最大数量'),
('win_profit_threshold', '500', '小赢利润阈值'),
('lose_profit_threshold', '-500', '小输利润阈值');