commit 1303bf37dc8dc4f441986c570407046eff8e1e4d Author: wells <123456@qq.com> Date: Wed Aug 5 13:48:48 2026 +0800 init diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1894bc0 --- /dev/null +++ b/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.tailbet + guess-game-backend + 1.0.0 + jar + guess-game-backend + 尾数竞猜游戏后端 + + + 17 + 3.5.9 + 2023.0.3 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.projectlombok + lombok + true + + + + + cn.hutool + hutool-all + 5.8.30 + + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/tailbet/GuessGameApplication.java b/src/main/java/com/tailbet/GuessGameApplication.java new file mode 100644 index 0000000..5aae68b --- /dev/null +++ b/src/main/java/com/tailbet/GuessGameApplication.java @@ -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); + } +} diff --git a/src/main/java/com/tailbet/config/AuthInterceptor.java b/src/main/java/com/tailbet/config/AuthInterceptor.java new file mode 100644 index 0000000..23a04aa --- /dev/null +++ b/src/main/java/com/tailbet/config/AuthInterceptor.java @@ -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)); + } +} diff --git a/src/main/java/com/tailbet/config/GlobalExceptionHandler.java b/src/main/java/com/tailbet/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..a94f6f6 --- /dev/null +++ b/src/main/java/com/tailbet/config/GlobalExceptionHandler.java @@ -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()); + } +} diff --git a/src/main/java/com/tailbet/config/OpenIMConfig.java b/src/main/java/com/tailbet/config/OpenIMConfig.java new file mode 100644 index 0000000..589c301 --- /dev/null +++ b/src/main/java/com/tailbet/config/OpenIMConfig.java @@ -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; +} diff --git a/src/main/java/com/tailbet/config/RedisConfig.java b/src/main/java/com/tailbet/config/RedisConfig.java new file mode 100644 index 0000000..484963a --- /dev/null +++ b/src/main/java/com/tailbet/config/RedisConfig.java @@ -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; + } +} diff --git a/src/main/java/com/tailbet/config/RestTemplateConfig.java b/src/main/java/com/tailbet/config/RestTemplateConfig.java new file mode 100644 index 0000000..d9520a1 --- /dev/null +++ b/src/main/java/com/tailbet/config/RestTemplateConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/tailbet/config/WebMvcConfig.java b/src/main/java/com/tailbet/config/WebMvcConfig.java new file mode 100644 index 0000000..1b77d6a --- /dev/null +++ b/src/main/java/com/tailbet/config/WebMvcConfig.java @@ -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"); + } +} diff --git a/src/main/java/com/tailbet/controller/GameController.java b/src/main/java/com/tailbet/controller/GameController.java new file mode 100644 index 0000000..cd77165 --- /dev/null +++ b/src/main/java/com/tailbet/controller/GameController.java @@ -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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/controller/OpsController.java b/src/main/java/com/tailbet/controller/OpsController.java new file mode 100644 index 0000000..2d5c0bc --- /dev/null +++ b/src/main/java/com/tailbet/controller/OpsController.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/controller/RedPacketController.java b/src/main/java/com/tailbet/controller/RedPacketController.java new file mode 100644 index 0000000..2f47eed --- /dev/null +++ b/src/main/java/com/tailbet/controller/RedPacketController.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/controller/UserController.java b/src/main/java/com/tailbet/controller/UserController.java new file mode 100644 index 0000000..b0c39c2 --- /dev/null +++ b/src/main/java/com/tailbet/controller/UserController.java @@ -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("退出成功"); + } +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/job/GameJob.java b/src/main/java/com/tailbet/job/GameJob.java new file mode 100644 index 0000000..942866f --- /dev/null +++ b/src/main/java/com/tailbet/job/GameJob.java @@ -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 roundTasks = new ConcurrentHashMap<>(); + + /** + * 检查红包是否需要托领取(每3秒执行) + */ + @Scheduled(fixedDelay = 3000) + public void checkRedPacketForFakeReceive() { + // 查找进行中的游戏红包 + List ongoingRps = redPacketMapper.selectList(new LambdaQueryWrapper() + .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 fakes = fakeUserService.getFakeUsers(); + if (fakes.isEmpty()) { + return; + } + + // 随机选一个假人 + User fake = fakes.get((int) (Math.random() * fakes.size())); + + // 检查是否已经领取过 + var existing = redPacketMapper.selectList(new LambdaQueryWrapper() + .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 ongoingRounds = gameRoundMapper.selectList(new LambdaQueryWrapper() + .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() + .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 whites = digitWhiteMapper.selectList(new LambdaQueryWrapper()); + 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; + } +} diff --git a/src/main/java/com/tailbet/mapper/AuditLogMapper.java b/src/main/java/com/tailbet/mapper/AuditLogMapper.java new file mode 100644 index 0000000..51efd58 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/AuditLogMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/BetOrderMapper.java b/src/main/java/com/tailbet/mapper/BetOrderMapper.java new file mode 100644 index 0000000..a82ecd6 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/BetOrderMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/DigitWhiteMapper.java b/src/main/java/com/tailbet/mapper/DigitWhiteMapper.java new file mode 100644 index 0000000..385bcb2 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/DigitWhiteMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/DrawRecordMapper.java b/src/main/java/com/tailbet/mapper/DrawRecordMapper.java new file mode 100644 index 0000000..895a052 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/DrawRecordMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/GameRoundMapper.java b/src/main/java/com/tailbet/mapper/GameRoundMapper.java new file mode 100644 index 0000000..fbc41f7 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/GameRoundMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/GroupMapper.java b/src/main/java/com/tailbet/mapper/GroupMapper.java new file mode 100644 index 0000000..23f4c1f --- /dev/null +++ b/src/main/java/com/tailbet/mapper/GroupMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/GroupMemberMapper.java b/src/main/java/com/tailbet/mapper/GroupMemberMapper.java new file mode 100644 index 0000000..448ad02 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/GroupMemberMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/PointsFlowMapper.java b/src/main/java/com/tailbet/mapper/PointsFlowMapper.java new file mode 100644 index 0000000..dcb933d --- /dev/null +++ b/src/main/java/com/tailbet/mapper/PointsFlowMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/RedPacketMapper.java b/src/main/java/com/tailbet/mapper/RedPacketMapper.java new file mode 100644 index 0000000..7c20438 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/RedPacketMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/RedPacketRecvMapper.java b/src/main/java/com/tailbet/mapper/RedPacketRecvMapper.java new file mode 100644 index 0000000..b3d550b --- /dev/null +++ b/src/main/java/com/tailbet/mapper/RedPacketRecvMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/SysConfigMapper.java b/src/main/java/com/tailbet/mapper/SysConfigMapper.java new file mode 100644 index 0000000..c9a4725 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/SysConfigMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/mapper/UserMapper.java b/src/main/java/com/tailbet/mapper/UserMapper.java new file mode 100644 index 0000000..4470730 --- /dev/null +++ b/src/main/java/com/tailbet/mapper/UserMapper.java @@ -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 { +} diff --git a/src/main/java/com/tailbet/model/PageDomain.java b/src/main/java/com/tailbet/model/PageDomain.java new file mode 100644 index 0000000..de8b11f --- /dev/null +++ b/src/main/java/com/tailbet/model/PageDomain.java @@ -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 Page toPage() { + return new Page<>(pageNum, pageSize); + + } +} diff --git a/src/main/java/com/tailbet/model/dto/BetDTO.java b/src/main/java/com/tailbet/model/dto/BetDTO.java new file mode 100644 index 0000000..598f6f8 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/BetDTO.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/dto/GameOperateDTO.java b/src/main/java/com/tailbet/model/dto/GameOperateDTO.java new file mode 100644 index 0000000..cdfda12 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/GameOperateDTO.java @@ -0,0 +1,14 @@ +package com.tailbet.model.dto; + +import lombok.Data; + +/** + * 游戏操作请求 + */ +@Data +public class GameOperateDTO { + /** + * 群ID + */ + private Long groupId; +} diff --git a/src/main/java/com/tailbet/model/dto/HistoryQueryDTO.java b/src/main/java/com/tailbet/model/dto/HistoryQueryDTO.java new file mode 100644 index 0000000..8e663e9 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/HistoryQueryDTO.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/model/dto/LoginDTO.java b/src/main/java/com/tailbet/model/dto/LoginDTO.java new file mode 100644 index 0000000..34b25c0 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/LoginDTO.java @@ -0,0 +1,19 @@ +package com.tailbet.model.dto; + +import lombok.Data; + +/** + * 用户登录请求 + */ +@Data +public class LoginDTO { + /** + * 账号 + */ + private String username; + + /** + * 密码 + */ + private String password; +} diff --git a/src/main/java/com/tailbet/model/dto/OpenIMCallbackDTO.java b/src/main/java/com/tailbet/model/dto/OpenIMCallbackDTO.java new file mode 100644 index 0000000..8545d91 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/OpenIMCallbackDTO.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/dto/PageParam.java b/src/main/java/com/tailbet/model/dto/PageParam.java new file mode 100644 index 0000000..d819741 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/PageParam.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/dto/PointsDTO.java b/src/main/java/com/tailbet/model/dto/PointsDTO.java new file mode 100644 index 0000000..075b5ef --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/PointsDTO.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/dto/PullGroupDTO.java b/src/main/java/com/tailbet/model/dto/PullGroupDTO.java new file mode 100644 index 0000000..02e9129 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/PullGroupDTO.java @@ -0,0 +1,19 @@ +package com.tailbet.model.dto; + +import lombok.Data; + +/** + * 拉群请求 + */ +@Data +public class PullGroupDTO { + /** + * 用户ID + */ + private Long userId; + + /** + * 群ID + */ + private Long groupId; +} diff --git a/src/main/java/com/tailbet/model/dto/ReceiveRpDTO.java b/src/main/java/com/tailbet/model/dto/ReceiveRpDTO.java new file mode 100644 index 0000000..50ca399 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/ReceiveRpDTO.java @@ -0,0 +1,14 @@ +package com.tailbet.model.dto; + +import lombok.Data; + +/** + * 领红包请求 + */ +@Data +public class ReceiveRpDTO { + /** + * 红包ID + */ + private Long rpId; +} diff --git a/src/main/java/com/tailbet/model/dto/RegisterDTO.java b/src/main/java/com/tailbet/model/dto/RegisterDTO.java new file mode 100644 index 0000000..45c1738 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/RegisterDTO.java @@ -0,0 +1,19 @@ +package com.tailbet.model.dto; + +import lombok.Data; + +/** + * 用户注册请求 + */ +@Data +public class RegisterDTO { + /** + * 账号 + */ + private String username; + + /** + * 密码 + */ + private String password; +} diff --git a/src/main/java/com/tailbet/model/dto/SendRpDTO.java b/src/main/java/com/tailbet/model/dto/SendRpDTO.java new file mode 100644 index 0000000..4a795f2 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/SendRpDTO.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/dto/SetDigitDTO.java b/src/main/java/com/tailbet/model/dto/SetDigitDTO.java new file mode 100644 index 0000000..cdb41d2 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/SetDigitDTO.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/dto/UpdateUserDTO.java b/src/main/java/com/tailbet/model/dto/UpdateUserDTO.java new file mode 100644 index 0000000..75e5f26 --- /dev/null +++ b/src/main/java/com/tailbet/model/dto/UpdateUserDTO.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/AuditLog.java b/src/main/java/com/tailbet/model/entity/AuditLog.java new file mode 100644 index 0000000..b8d9e66 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/AuditLog.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/BetOrder.java b/src/main/java/com/tailbet/model/entity/BetOrder.java new file mode 100644 index 0000000..5c9fbc6 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/BetOrder.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/DigitWhite.java b/src/main/java/com/tailbet/model/entity/DigitWhite.java new file mode 100644 index 0000000..b4ec5bc --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/DigitWhite.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/DrawRecord.java b/src/main/java/com/tailbet/model/entity/DrawRecord.java new file mode 100644 index 0000000..61b4602 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/DrawRecord.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/GameRound.java b/src/main/java/com/tailbet/model/entity/GameRound.java new file mode 100644 index 0000000..9c3083c --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/GameRound.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/Group.java b/src/main/java/com/tailbet/model/entity/Group.java new file mode 100644 index 0000000..66ba88f --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/Group.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/GroupMember.java b/src/main/java/com/tailbet/model/entity/GroupMember.java new file mode 100644 index 0000000..e0f13cc --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/GroupMember.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/PointsFlow.java b/src/main/java/com/tailbet/model/entity/PointsFlow.java new file mode 100644 index 0000000..e4b0a55 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/PointsFlow.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/RedPacket.java b/src/main/java/com/tailbet/model/entity/RedPacket.java new file mode 100644 index 0000000..3db30f8 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/RedPacket.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/RedPacketRecv.java b/src/main/java/com/tailbet/model/entity/RedPacketRecv.java new file mode 100644 index 0000000..41c60f7 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/RedPacketRecv.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/SysConfig.java b/src/main/java/com/tailbet/model/entity/SysConfig.java new file mode 100644 index 0000000..eaf2f96 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/SysConfig.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/entity/User.java b/src/main/java/com/tailbet/model/entity/User.java new file mode 100644 index 0000000..327a766 --- /dev/null +++ b/src/main/java/com/tailbet/model/entity/User.java @@ -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; +} diff --git a/src/main/java/com/tailbet/model/vo/LoginUserVo.java b/src/main/java/com/tailbet/model/vo/LoginUserVo.java new file mode 100644 index 0000000..7831fe6 --- /dev/null +++ b/src/main/java/com/tailbet/model/vo/LoginUserVo.java @@ -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; + } +} diff --git a/src/main/java/com/tailbet/model/vo/R.java b/src/main/java/com/tailbet/model/vo/R.java new file mode 100644 index 0000000..1978560 --- /dev/null +++ b/src/main/java/com/tailbet/model/vo/R.java @@ -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 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 R ok() { + return new R<>(SUCCESS, "操作成功", null); + } + + public static R ok(T data) { + return new R<>(SUCCESS, "操作成功", data); + } + + public static R ok(String msg, T data) { + return new R<>(SUCCESS, msg, data); + } + + public static R fail() { + return new R<>(FAIL, "操作失败", null); + } + + public static R fail(String msg) { + return new R<>(FAIL, msg, null); + } + + public static R fail(int code, String msg) { + return new R<>(code, msg, null); + } + + public boolean isSuccess() { + return code == SUCCESS; + } +} diff --git a/src/main/java/com/tailbet/model/vo/UserContext.java b/src/main/java/com/tailbet/model/vo/UserContext.java new file mode 100644 index 0000000..2c03a0f --- /dev/null +++ b/src/main/java/com/tailbet/model/vo/UserContext.java @@ -0,0 +1,46 @@ +package com.tailbet.model.vo; + +/** + * 用户上下文 - ThreadLocal保存当前登录用户 + */ +public class UserContext { + + private static final ThreadLocal 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()); + } +} diff --git a/src/main/java/com/tailbet/openim/OpenIMCallbackController.java b/src/main/java/com/tailbet/openim/OpenIMCallbackController.java new file mode 100644 index 0000000..d98258b --- /dev/null +++ b/src/main/java/com/tailbet/openim/OpenIMCallbackController.java @@ -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; + } +} diff --git a/src/main/java/com/tailbet/openim/OpenIMClient.java b/src/main/java/com/tailbet/openim/OpenIMClient.java new file mode 100644 index 0000000..516a30c --- /dev/null +++ b/src/main/java/com/tailbet/openim/OpenIMClient.java @@ -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 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> 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 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> 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 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> 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 body = new HashMap<>(); + body.put("groupName", groupName); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("token", config.getAdminToken()); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity 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 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> 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; + } + } +} diff --git a/src/main/java/com/tailbet/service/IAuditService.java b/src/main/java/com/tailbet/service/IAuditService.java new file mode 100644 index 0000000..16a6b5c --- /dev/null +++ b/src/main/java/com/tailbet/service/IAuditService.java @@ -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 getPage(Long groupId, Long roundId, String action, + Integer pageNum, Integer pageSize); +} diff --git a/src/main/java/com/tailbet/service/IBetService.java b/src/main/java/com/tailbet/service/IBetService.java new file mode 100644 index 0000000..9293c65 --- /dev/null +++ b/src/main/java/com/tailbet/service/IBetService.java @@ -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); +} diff --git a/src/main/java/com/tailbet/service/IFakeUserService.java b/src/main/java/com/tailbet/service/IFakeUserService.java new file mode 100644 index 0000000..ea1089c --- /dev/null +++ b/src/main/java/com/tailbet/service/IFakeUserService.java @@ -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 getFakeUsers(); +} diff --git a/src/main/java/com/tailbet/service/IGameService.java b/src/main/java/com/tailbet/service/IGameService.java new file mode 100644 index 0000000..12f1012 --- /dev/null +++ b/src/main/java/com/tailbet/service/IGameService.java @@ -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 trialDigit(Long groupId, Long roundId); + + /** + * 判定是否中奖 + */ + boolean isWin(BetOrder bet, int digit); + + /** + * 获取中奖玩法描述 + */ + String getPlayWinDesc(int digit); + + /** + * 开奖结算 + */ + void settle(Long roundId, int digit); + + /** + * 获取开奖历史 + */ + Page getHistory(Long groupId, Integer pageNum, Integer pageSize); +} diff --git a/src/main/java/com/tailbet/service/IGroupService.java b/src/main/java/com/tailbet/service/IGroupService.java new file mode 100644 index 0000000..6c48615 --- /dev/null +++ b/src/main/java/com/tailbet/service/IGroupService.java @@ -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 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 getMembers(Long groupId); + + /** + * 检查用户是否是群主 + */ + boolean isOwner(Long groupId, Long userId); + + /** + * 检查用户是否是管理员 + */ + boolean isAdmin(Long groupId, Long userId); +} diff --git a/src/main/java/com/tailbet/service/IPointsService.java b/src/main/java/com/tailbet/service/IPointsService.java new file mode 100644 index 0000000..7bf9480 --- /dev/null +++ b/src/main/java/com/tailbet/service/IPointsService.java @@ -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 getFlowPage(Long userId, Integer pageNum, Integer pageSize); +} diff --git a/src/main/java/com/tailbet/service/IPushService.java b/src/main/java/com/tailbet/service/IPushService.java new file mode 100644 index 0000000..66563df --- /dev/null +++ b/src/main/java/com/tailbet/service/IPushService.java @@ -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); +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/service/IRedPacketService.java b/src/main/java/com/tailbet/service/IRedPacketService.java new file mode 100644 index 0000000..63d2611 --- /dev/null +++ b/src/main/java/com/tailbet/service/IRedPacketService.java @@ -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 getReceives(Long rpId); + + /** + * 查询红包记录 + */ + Page getRecords(Long userId, Integer pageNum, Integer pageSize); + + /** + * 查找进行中的游戏红包 + */ + RedPacket getOngoingGameRp(Long groupId); +} diff --git a/src/main/java/com/tailbet/service/IUserService.java b/src/main/java/com/tailbet/service/IUserService.java new file mode 100644 index 0000000..31dc73f --- /dev/null +++ b/src/main/java/com/tailbet/service/IUserService.java @@ -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 getByUsername(String username); + + /** + * 注册用户 + */ + User register(String username, String password); + + /** + * 用户登录 + */ + String login(String username, String password); + + /** + * 获取当前登录用户 + */ + User getCurrentUser(); +} diff --git a/src/main/java/com/tailbet/service/impl/AuditServiceImpl.java b/src/main/java/com/tailbet/service/impl/AuditServiceImpl.java new file mode 100644 index 0000000..5db9be8 --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/AuditServiceImpl.java @@ -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 getPage(Long groupId, Long roundId, String action, + Integer pageNum, Integer pageSize) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper 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); + } +} diff --git a/src/main/java/com/tailbet/service/impl/BetServiceImpl.java b/src/main/java/com/tailbet/service/impl/BetServiceImpl.java new file mode 100644 index 0000000..b4645fd --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/BetServiceImpl.java @@ -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() + .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() + .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() + .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() + .eq(BetOrder::getUserId, userId) + .eq(BetOrder::getRoundId, roundId) + .eq(BetOrder::getIsFake, 0)) + .stream() + .mapToInt(BetOrder::getBetAmount) + .sum(); + } +} diff --git a/src/main/java/com/tailbet/service/impl/FakeUserServiceImpl.java b/src/main/java/com/tailbet/service/impl/FakeUserServiceImpl.java new file mode 100644 index 0000000..33e74ca --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/FakeUserServiceImpl.java @@ -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 keys = redisTemplate.opsForSet().members(FAKE_ACTIVE_KEY + "yesterday"); + if (keys != null) { + for (String userId : keys) { + redisTemplate.opsForSet().remove(FAKE_ACTIVE_KEY + "yesterday", userId); + } + } + + // 查询所有假人 + List fakes = userMapper.selectList(new LambdaQueryWrapper() + .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 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 activeFakes = redisTemplate.opsForSet().members(FAKE_ACTIVE_KEY + today); + if (activeFakes == null || activeFakes.isEmpty()) { + return; + } + + // 随机选一个假人 + List fakeList = new ArrayList<>(activeFakes); + String fakeIdStr = fakeList.get(RANDOM.nextInt(fakeList.size())); + Long fakeId = Long.parseLong(fakeIdStr); + + // 检查是否有进行中的局 + GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper() + .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 getFakeUsers() { + return userMapper.selectList(new LambdaQueryWrapper() + .eq(User::getIsFake, 1) + .eq(User::getStatus, 1)); + } +} diff --git a/src/main/java/com/tailbet/service/impl/GameServiceImpl.java b/src/main/java/com/tailbet/service/impl/GameServiceImpl.java new file mode 100644 index 0000000..eec5028 --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/GameServiceImpl.java @@ -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() + .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 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 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 bets = betOrderMapper.selectList(new LambdaQueryWrapper() + .eq(BetOrder::getRoundId, round.getId()) + .eq(BetOrder::getIsFake, 0)); + + // 计算0-9各尾数的庄家利润 + java.util.List 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 bets = betOrderMapper.selectList(new LambdaQueryWrapper() + .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 getHistory(Long groupId, Integer pageNum, Integer pageSize) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(DrawRecord::getGroupId, groupId) + .orderByDesc(DrawRecord::getCreateTime); + return drawRecordMapper.selectPage(page, wrapper); + } +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/service/impl/GroupServiceImpl.java b/src/main/java/com/tailbet/service/impl/GroupServiceImpl.java new file mode 100644 index 0000000..424407f --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/GroupServiceImpl.java @@ -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 getUserGroups(Long userId) { + List members = groupMemberMapper.selectList(new LambdaQueryWrapper() + .eq(GroupMember::getUserId, userId)); + + if (members.isEmpty()) { + return List.of(); + } + + List groupIds = members.stream() + .map(GroupMember::getGroupId) + .toList(); + + return groupMapper.selectList(new LambdaQueryWrapper() + .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() + .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() + .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() + .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() + .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() + .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 getMembers(Long groupId) { + return groupMemberMapper.selectList(new LambdaQueryWrapper() + .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() + .eq(GroupMember::getGroupId, groupId) + .eq(GroupMember::getUserId, userId)); + return member != null && (member.getRole() == ROLE_ADMIN || member.getRole() == ROLE_OWNER); + } +} diff --git a/src/main/java/com/tailbet/service/impl/PointsServiceImpl.java b/src/main/java/com/tailbet/service/impl/PointsServiceImpl.java new file mode 100644 index 0000000..e94d517 --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/PointsServiceImpl.java @@ -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 getFlowPage(Long userId, Integer pageNum, Integer pageSize) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsFlow::getUserId, userId) + .orderByDesc(PointsFlow::getCreateTime); + return pointsFlowMapper.selectPage(page, wrapper); + } +} diff --git a/src/main/java/com/tailbet/service/impl/PushServiceImpl.java b/src/main/java/com/tailbet/service/impl/PushServiceImpl.java new file mode 100644 index 0000000..ff59344 --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/PushServiceImpl.java @@ -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 whites = digitWhiteMapper.selectList(new LambdaQueryWrapper()); + 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java b/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java new file mode 100644 index 0000000..65047d7 --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/RedPacketServiceImpl.java @@ -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() + .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 getReceives(Long rpId) { + return redPacketRecvMapper.selectList(new LambdaQueryWrapper() + .eq(RedPacketRecv::getRpId, rpId) + .orderByAsc(RedPacketRecv::getCreateTime)); + } + + /** + * 查询红包记录 + */ + @Override + public Page getRecords(Long userId, Integer pageNum, Integer pageSize) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper 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() + .eq(RedPacket::getGroupId, groupId) + .eq(RedPacket::getType, TYPE_GAME) + .eq(RedPacket::getStatus, STATUS_ONGOING) + .isNotNull(RedPacket::getRoundId)); + } +} diff --git a/src/main/java/com/tailbet/service/impl/UserServiceImpl.java b/src/main/java/com/tailbet/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..f3f13d6 --- /dev/null +++ b/src/main/java/com/tailbet/service/impl/UserServiceImpl.java @@ -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() + .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 opsList = userMapper.selectList(new LambdaQueryWrapper() + .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() + .eq(User::getBoundOpsUserId, a.getId())); + Long countB = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getBoundOpsUserId, b.getId())); + return countA.compareTo(countB); + }) + .orElse(opsList.get(0)); + + user.setBoundOpsUserId(selectedOps.getId()); + userMapper.updateById(user); + } +} diff --git a/src/main/java/com/tailbet/util/JwtUtil.java b/src/main/java/com/tailbet/util/JwtUtil.java new file mode 100644 index 0000000..5836d5a --- /dev/null +++ b/src/main/java/com/tailbet/util/JwtUtil.java @@ -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 claims = new HashMap<>(); + claims.put("userId", userId); + claims.put("username", username); + return createToken(claims, username); + } + + private String createToken(Map 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; + } + } +} diff --git a/src/main/java/com/tailbet/util/PasswordUtil.java b/src/main/java/com/tailbet/util/PasswordUtil.java new file mode 100644 index 0000000..706adf4 --- /dev/null +++ b/src/main/java/com/tailbet/util/PasswordUtil.java @@ -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); + } +} diff --git a/src/main/java/com/tailbet/util/RedisLock.java b/src/main/java/com/tailbet/util/RedisLock.java new file mode 100644 index 0000000..bab983e --- /dev/null +++ b/src/main/java/com/tailbet/util/RedisLock.java @@ -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; +} diff --git a/src/main/java/com/tailbet/util/RedisLockUtil.java b/src/main/java/com/tailbet/util/RedisLockUtil.java new file mode 100644 index 0000000..58ca953 --- /dev/null +++ b/src/main/java/com/tailbet/util/RedisLockUtil.java @@ -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 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); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..eacd460 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql new file mode 100644 index 0000000..1bf709d --- /dev/null +++ b/src/main/resources/sql/init.sql @@ -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', '小输利润阈值');