Compare commits
41
Commits
main
..
1c5e9d4cde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5e9d4cde | ||
|
|
dc98850b4d | ||
|
|
724dae91b1 | ||
|
|
aad68e407e | ||
|
|
6be2339d87 | ||
|
|
c5250e23ef | ||
|
|
2c6f405524 | ||
|
|
ee5f33aba6 | ||
|
|
114a0d93fe | ||
|
|
41e55bc94e | ||
|
|
d5440cc8db | ||
|
|
ef841a3e5d | ||
|
|
e6a4cf882a | ||
|
|
223966b59f | ||
|
|
daf1b1df8f | ||
|
|
0de53ff641 | ||
|
|
7784e01e5e | ||
|
|
b227f33d9f | ||
|
|
cfb5b4f2a8 | ||
|
|
52061b9d59 | ||
|
|
d8b2eb4bbb | ||
|
|
4cba51f98d | ||
|
|
aa1a9c9f23 | ||
|
|
1f93719ee5 | ||
|
|
aa48225d85 | ||
|
|
0e51812d80 | ||
|
|
dc33b8008c | ||
|
|
98e6aaa159 | ||
|
|
96207c03b9 | ||
|
|
e5f60b7b30 | ||
|
|
7f96115a6a | ||
|
|
ecb372119c | ||
|
|
6ec17b45f0 | ||
|
|
f889eeab77 | ||
|
|
d6b3b1f733 | ||
|
|
16242b97de | ||
|
|
ce7a619791 | ||
|
|
50005dd439 | ||
|
|
e6637012d6 | ||
|
|
45e8e3d637 | ||
|
|
1303bf37dc |
-26
@@ -1,26 +0,0 @@
|
|||||||
# ---> Java
|
|
||||||
# Compiled class file
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Log file
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# BlueJ files
|
|
||||||
*.ctxt
|
|
||||||
|
|
||||||
# Mobile Tools for Java (J2ME)
|
|
||||||
.mtj.tmp/
|
|
||||||
|
|
||||||
# Package Files #
|
|
||||||
*.jar
|
|
||||||
*.war
|
|
||||||
*.nar
|
|
||||||
*.ear
|
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
*.rar
|
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
|
||||||
hs_err_pid*
|
|
||||||
replay_pid*
|
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.tailbet</groupId>
|
||||||
|
<artifactId>guess-game-backend</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<name>guess-game-backend</name>
|
||||||
|
<description>尾数竞猜游戏后端</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<mybatis-plus.version>3.5.9</mybatis-plus.version>
|
||||||
|
<spring-cloud.version>2023.0.3</spring-cloud.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Validation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Redis -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MyBatis Plus -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>${mybatis-plus.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MySQL Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Hutool 工具库 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>5.8.30</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.redisson</groupId>
|
||||||
|
<artifactId>redisson-spring-boot-starter</artifactId>
|
||||||
|
<version>3.44.0</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.tailbet;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
@MapperScan("com.tailbet.mapper")
|
||||||
|
public class GuessGameApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(GuessGameApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package com.tailbet.common.constant;
|
||||||
|
|
||||||
|
import com.tailbet.common.enums.PlatformEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 协议相关常量
|
||||||
|
* <p>
|
||||||
|
* 统一管理 Webhook 回调命令、请求字段、响应字段、消息类型等协议魔法值,
|
||||||
|
* 避免散落在 Controller / Service 中。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author socialapp团队
|
||||||
|
* @since 1.0.7
|
||||||
|
*/
|
||||||
|
public final class OpenImConstants {
|
||||||
|
|
||||||
|
private OpenImConstants() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Webhook 回调命令 ====================
|
||||||
|
|
||||||
|
/** 单聊消息发送后回调 */
|
||||||
|
public static final String CALLBACK_AFTER_SEND_SINGLE_MSG = "callbackAfterSendSingleMsgCommand";
|
||||||
|
|
||||||
|
/** 群聊消息发送后回调 */
|
||||||
|
public static final String CALLBACK_AFTER_SEND_GROUP_MSG = "callbackAfterSendGroupMsgCommand";
|
||||||
|
|
||||||
|
/** 成员进群后回调 */
|
||||||
|
public static final String CALLBACK_AFTER_MEMBER_ENTER_GROUP = "callbackAfterMemberEnterGroupCommand";
|
||||||
|
|
||||||
|
/** 成员退群后回调 */
|
||||||
|
public static final String CALLBACK_AFTER_MEMBER_QUIT_GROUP = "callbackAfterMemberQuitGroupCommand";
|
||||||
|
|
||||||
|
/** 用户上线回调 */
|
||||||
|
public static final String CALLBACK_AFTER_USER_ONLINE = "callbackAfterUserOnlineCommand";
|
||||||
|
|
||||||
|
/** 用户下线回调 */
|
||||||
|
public static final String CALLBACK_AFTER_USER_OFFLINE = "callbackAfterUserOfflineCommand";
|
||||||
|
|
||||||
|
// ==================== 请求头 / JSON 字段名 ====================
|
||||||
|
|
||||||
|
/** OpenIM Webhook 鉴权请求头名 */
|
||||||
|
public static final String HEADER_TOKEN = "token";
|
||||||
|
|
||||||
|
/** 发送方用户 ID */
|
||||||
|
public static final String FIELD_SEND_ID = "sendID";
|
||||||
|
|
||||||
|
/** 接收方用户 ID */
|
||||||
|
public static final String FIELD_RECV_ID = "recvID";
|
||||||
|
|
||||||
|
/** 群组 ID(OpenIM 群 ID) */
|
||||||
|
public static final String FIELD_GROUP_ID = "groupID";
|
||||||
|
|
||||||
|
/** 用户 ID */
|
||||||
|
public static final String FIELD_USER_ID = "userID";
|
||||||
|
|
||||||
|
/** 平台 ID */
|
||||||
|
public static final String FIELD_PLATFORM_ID = "platformID";
|
||||||
|
|
||||||
|
/** 消息内容 */
|
||||||
|
public static final String FIELD_CONTENT = "content";
|
||||||
|
|
||||||
|
/** 消息内容类型 */
|
||||||
|
public static final String FIELD_CONTENT_TYPE = "contentType";
|
||||||
|
|
||||||
|
/** 客户端消息 ID */
|
||||||
|
public static final String FIELD_CLIENT_MSG_ID = "clientMsgID";
|
||||||
|
|
||||||
|
/** 服务端消息 ID */
|
||||||
|
public static final String FIELD_SERVER_MSG_ID = "serverMsgID";
|
||||||
|
|
||||||
|
// ==================== Webhook 成功响应 ====================
|
||||||
|
|
||||||
|
/** 响应字段:动作码 */
|
||||||
|
public static final String RESP_ACTION_CODE = "actionCode";
|
||||||
|
|
||||||
|
/** 响应字段:错误码 */
|
||||||
|
public static final String RESP_ERR_CODE = "errCode";
|
||||||
|
|
||||||
|
/** 响应字段:错误信息 */
|
||||||
|
public static final String RESP_ERR_MSG = "errMsg";
|
||||||
|
|
||||||
|
/** 响应字段:错误详情 */
|
||||||
|
public static final String RESP_ERR_DLT = "errDlt";
|
||||||
|
|
||||||
|
/** 响应字段:下一动作码 */
|
||||||
|
public static final String RESP_NEXT_CODE = "nextCode";
|
||||||
|
|
||||||
|
/** OpenIM 成功码(actionCode / errCode / nextCode) */
|
||||||
|
public static final int RESP_CODE_SUCCESS = 0;
|
||||||
|
|
||||||
|
/** OpenIM 成功响应空字符串字段值 */
|
||||||
|
public static final String RESP_EMPTY_MSG = "";
|
||||||
|
|
||||||
|
// ==================== Token / 鉴权错误码(OpenIM 官方) ====================
|
||||||
|
|
||||||
|
/** Token 过期 */
|
||||||
|
public static final int ERR_TOKEN_EXPIRED = 1501;
|
||||||
|
|
||||||
|
/** Token 无效 */
|
||||||
|
public static final int ERR_TOKEN_INVALID = 1502;
|
||||||
|
|
||||||
|
/** Token 格式错误 */
|
||||||
|
public static final int ERR_TOKEN_MALFORMED = 1503;
|
||||||
|
|
||||||
|
/** Token 不存在 */
|
||||||
|
public static final int ERR_TOKEN_NOT_EXIST = 1504;
|
||||||
|
|
||||||
|
/** Token 平台不一致 */
|
||||||
|
public static final int ERR_TOKEN_DIFFERENT_PLATFORM = 1505;
|
||||||
|
|
||||||
|
/** Token 被踢(多端/多实例刷新 adminToken、重复 get_user_token 等) */
|
||||||
|
public static final int ERR_TOKEN_KICKED = 1506;
|
||||||
|
|
||||||
|
/** Token 尚未生效 */
|
||||||
|
public static final int ERR_TOKEN_NOT_VALID_YET = 1507;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端代操作使用的平台 ID(Web)。
|
||||||
|
* 与 App 端 iOS/Android 平台隔离,避免顶掉用户已登录 session。
|
||||||
|
*/
|
||||||
|
public static final int PLATFORM_ID_SERVER_WEB = PlatformEnum.WEB.getCode();
|
||||||
|
|
||||||
|
/** App 端 iOS 平台 ID */
|
||||||
|
public static final int PLATFORM_ID_IOS = PlatformEnum.IOS.getCode();
|
||||||
|
|
||||||
|
/** App 端 Android 平台 ID */
|
||||||
|
public static final int PLATFORM_ID_ANDROID = PlatformEnum.ANDROID.getCode();
|
||||||
|
|
||||||
|
// ==================== 会话摘要 / 未读 ====================
|
||||||
|
|
||||||
|
/** 会话 lastMessage 摘要最大长度 */
|
||||||
|
public static final int CONVERSATION_CONTENT_MAX_LEN = 100;
|
||||||
|
|
||||||
|
/** 未读数自增 SQL 片段 */
|
||||||
|
public static final String SQL_UNREAD_COUNT_INCREMENT = "unread_count = unread_count + 1";
|
||||||
|
|
||||||
|
/** 新建会话初始未读数(接收方) */
|
||||||
|
public static final int UNREAD_COUNT_INITIAL_RECEIVER = 1;
|
||||||
|
|
||||||
|
/** 新建会话初始未读数(发送方) */
|
||||||
|
public static final int UNREAD_COUNT_INITIAL_SENDER = 0;
|
||||||
|
|
||||||
|
// ==================== OpenIM contentType ====================
|
||||||
|
|
||||||
|
/** 文本 */
|
||||||
|
public static final int CONTENT_TYPE_TEXT = 101;
|
||||||
|
|
||||||
|
/** 图片 */
|
||||||
|
public static final int CONTENT_TYPE_IMAGE = 102;
|
||||||
|
|
||||||
|
/** 语音 */
|
||||||
|
public static final int CONTENT_TYPE_VOICE = 103;
|
||||||
|
|
||||||
|
/** 视频 */
|
||||||
|
public static final int CONTENT_TYPE_VIDEO = 104;
|
||||||
|
|
||||||
|
/** 文件 */
|
||||||
|
public static final int CONTENT_TYPE_FILE = 105;
|
||||||
|
|
||||||
|
/** 位置 */
|
||||||
|
public static final int CONTENT_TYPE_LOCATION = 106;
|
||||||
|
|
||||||
|
/** 名片 */
|
||||||
|
public static final int CONTENT_TYPE_CARD = 114;
|
||||||
|
|
||||||
|
/** 自定义消息 */
|
||||||
|
public static final int CONTENT_TYPE_CUSTOM = 110;
|
||||||
|
|
||||||
|
// ==================== OpenIM sessionType ====================
|
||||||
|
|
||||||
|
/** 单聊 */
|
||||||
|
public static final int SESSION_TYPE_SINGLE = 1;
|
||||||
|
|
||||||
|
/** 群聊 */
|
||||||
|
public static final int SESSION_TYPE_GROUP = 3;
|
||||||
|
|
||||||
|
// ==================== 离线推送 extras / 文案 ====================
|
||||||
|
|
||||||
|
/** 推送业务类型:IM 离线消息 */
|
||||||
|
public static final String PUSH_BIZ_TYPE_IM_MESSAGE = "IM_MESSAGE";
|
||||||
|
|
||||||
|
/** 推送标题 */
|
||||||
|
public static final String PUSH_TITLE_NEW_MESSAGE = "您有新消息";
|
||||||
|
|
||||||
|
/** 发送方昵称兜底 */
|
||||||
|
public static final String PUSH_DEFAULT_SENDER_NAME = "用户";
|
||||||
|
|
||||||
|
/** 系统/管理员代发时的发送方昵称兜底(如 OpenIM imAdmin) */
|
||||||
|
public static final String PUSH_DEFAULT_SYSTEM_SENDER_NAME = "系统";
|
||||||
|
|
||||||
|
/** 群名称兜底 */
|
||||||
|
public static final String PUSH_DEFAULT_GROUP_NAME = "群聊";
|
||||||
|
|
||||||
|
/** extras:会话 ID */
|
||||||
|
public static final String EXTRAS_CONVERSATION_ID = "conversationId";
|
||||||
|
|
||||||
|
/** extras:发送方 ID */
|
||||||
|
public static final String EXTRAS_SENDER_ID = "senderId";
|
||||||
|
|
||||||
|
/** extras:消息类型 */
|
||||||
|
public static final String EXTRAS_MESSAGE_TYPE = "messageType";
|
||||||
|
|
||||||
|
/** extras:会话类型 */
|
||||||
|
public static final String EXTRAS_SESSION_TYPE = "sessionType";
|
||||||
|
|
||||||
|
/** extras:本地群组 ID */
|
||||||
|
public static final String EXTRAS_GROUP_ID = "groupId";
|
||||||
|
|
||||||
|
/** 单聊 conversationId 前缀 */
|
||||||
|
public static final String CONVERSATION_ID_SINGLE_PREFIX = "si_";
|
||||||
|
|
||||||
|
/** 群聊 conversationId 前缀(业务推送 extras 约定,非 OpenIM 原生 ID) */
|
||||||
|
public static final String CONVERSATION_ID_GROUP_PREFIX = "group:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 原生群聊 conversationID 前缀(sessionType=3 ReadGroupChat)
|
||||||
|
* 形如 {@code sg_{openimGroupId}}
|
||||||
|
*/
|
||||||
|
public static final String OPENIM_NATIVE_GROUP_CONVERSATION_PREFIX = "sg_";
|
||||||
|
|
||||||
|
/** 单聊 conversationId 分隔符 */
|
||||||
|
public static final String CONVERSATION_ID_SEPARATOR = "_";
|
||||||
|
|
||||||
|
/** 推送正文最大长度(对齐 PushSendReq) */
|
||||||
|
public static final int PUSH_CONTENT_MAX_LEN = 200;
|
||||||
|
|
||||||
|
/** 推送 bizId 最大长度(对齐 PushSendReq) */
|
||||||
|
public static final int PUSH_BIZ_ID_MAX_LEN = 64;
|
||||||
|
|
||||||
|
/** 推送摘要最大长度 */
|
||||||
|
public static final int PUSH_SUMMARY_MAX_LEN = 50;
|
||||||
|
|
||||||
|
/** 单次 Feign 推送批量用户数 */
|
||||||
|
public static final int PUSH_BATCH_SIZE = 500;
|
||||||
|
|
||||||
|
/** 离线推送幂等 TTL(分钟) */
|
||||||
|
public static final long PUSH_DEDUP_TTL_MINUTES = 10L;
|
||||||
|
|
||||||
|
/** Redis 幂等占位值 */
|
||||||
|
public static final String PUSH_DEDUP_PLACEHOLDER = "1";
|
||||||
|
|
||||||
|
/** 推送正文:昵称与摘要分隔符 */
|
||||||
|
public static final String PUSH_BODY_NAME_CONTENT_SEPARATOR = ":";
|
||||||
|
|
||||||
|
/** 推送正文:群名与发送者分隔符 */
|
||||||
|
public static final String PUSH_BODY_GROUP_SEPARATOR = " | ";
|
||||||
|
|
||||||
|
/** 摘要:图片 */
|
||||||
|
public static final String SUMMARY_IMAGE = "[图片]";
|
||||||
|
|
||||||
|
/** 摘要:语音 */
|
||||||
|
public static final String SUMMARY_VOICE = "[语音]";
|
||||||
|
|
||||||
|
/** 摘要:视频 */
|
||||||
|
public static final String SUMMARY_VIDEO = "[视频]";
|
||||||
|
|
||||||
|
/** 摘要:文件 */
|
||||||
|
public static final String SUMMARY_FILE = "[文件]";
|
||||||
|
|
||||||
|
/** 摘要:位置 */
|
||||||
|
public static final String SUMMARY_LOCATION = "[位置]";
|
||||||
|
|
||||||
|
/** 摘要:名片 */
|
||||||
|
public static final String SUMMARY_CARD = "[名片]";
|
||||||
|
|
||||||
|
/** 摘要:其他消息 */
|
||||||
|
public static final String SUMMARY_DEFAULT = "[消息]";
|
||||||
|
|
||||||
|
/** JSON 对象起始字符(用于判断 content 是否为 JSON 字符串) */
|
||||||
|
public static final char JSON_OBJECT_START = '{';
|
||||||
|
|
||||||
|
/** 自定义消息 content.data / extension 字段 */
|
||||||
|
public static final String FIELD_DATA = "data";
|
||||||
|
|
||||||
|
/** 自定义消息 extension 字段 */
|
||||||
|
public static final String FIELD_EXTENSION = "extension";
|
||||||
|
|
||||||
|
/** 业务自定义类型字段 */
|
||||||
|
public static final String FIELD_CUSTOM_TYPE = "customType";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包领取提醒:仅聊天内系统记录,禁止离线推送 / 系统通知
|
||||||
|
*/
|
||||||
|
public static final String CUSTOM_TYPE_RED_ENVELOPE_CLAIM_NOTICE = "red_envelope_claim_notice";
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
package com.tailbet.common.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 平台 ID 枚举
|
||||||
|
* <p>
|
||||||
|
* 表示用户连接所在的终端平台。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author socialapp团队
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public enum PlatformEnum {
|
||||||
|
|
||||||
|
IOS(1, "iOS"),
|
||||||
|
ANDROID(2, "Android"),
|
||||||
|
WINDOWS(3, "Windows"),
|
||||||
|
OSX(4, "macOS"),
|
||||||
|
WEB(5, "Web"),
|
||||||
|
MINI_WEB(6, "小程序或轻量 Web"),
|
||||||
|
LINUX(7, "Linux"),
|
||||||
|
APAD(8, "Android 平板"),
|
||||||
|
IPAD(9, "iPad"),
|
||||||
|
ADMIN(10, "管理端"),
|
||||||
|
HARMONYOS(11, "HarmonyOS"),
|
||||||
|
BOT(12, "Bot");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台 ID
|
||||||
|
*/
|
||||||
|
private final int code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台名称
|
||||||
|
*/
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
PlatformEnum(int code, String name) {
|
||||||
|
this.code = code;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 code 获取枚举
|
||||||
|
*
|
||||||
|
* @param code 平台 ID
|
||||||
|
* @return 对应的枚举值,未找到返回 null
|
||||||
|
*/
|
||||||
|
public static PlatformEnum fromCode(int code) {
|
||||||
|
for (PlatformEnum platform : values()) {
|
||||||
|
if (platform.code == code) {
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.tailbet.common.exception;
|
||||||
|
|
||||||
|
import com.tailbet.common.enums.ErrorCodeEnum;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务异常
|
||||||
|
* <p>
|
||||||
|
* 封装业务逻辑层面的异常,区别于系统运行时异常。
|
||||||
|
* 业务异常通常有明确的错误码和提示信息,可直接返回给前端展示。
|
||||||
|
* 使用时优先使用预定义的错误码枚举,也支持自定义消息。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author socialapp团队
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误码
|
||||||
|
*/
|
||||||
|
private final int code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附加数据(如当前场次信息等)
|
||||||
|
*/
|
||||||
|
private final Object data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于错误码枚举构造业务异常
|
||||||
|
*
|
||||||
|
* @param errorCode 错误码枚举
|
||||||
|
*/
|
||||||
|
public BusinessException(ErrorCodeEnum errorCode) {
|
||||||
|
super(errorCode.getMessage());
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
this.data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于错误码枚举和自定义消息构造业务异常
|
||||||
|
*
|
||||||
|
* @param errorCode 错误码枚举
|
||||||
|
* @param message 自定义错误消息
|
||||||
|
*/
|
||||||
|
public BusinessException(ErrorCodeEnum errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
this.data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于错误码枚举、自定义消息和原因构造业务异常
|
||||||
|
*
|
||||||
|
* @param errorCode 错误码枚举
|
||||||
|
* @param message 自定义错误消息
|
||||||
|
* @param cause 原始异常
|
||||||
|
*/
|
||||||
|
public BusinessException(ErrorCodeEnum errorCode, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
this.data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于状态码和自定义消息构造业务异常
|
||||||
|
*
|
||||||
|
* @param code 状态码
|
||||||
|
* @param message 错误消息
|
||||||
|
*/
|
||||||
|
public BusinessException(int code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于自定义消息构造业务异常(默认错误码 INTERNAL_ERROR)
|
||||||
|
*
|
||||||
|
* @param message 错误消息
|
||||||
|
*/
|
||||||
|
public BusinessException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = ErrorCodeEnum.INTERNAL_ERROR.getCode();
|
||||||
|
this.data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于错误码枚举和附加数据构造业务异常
|
||||||
|
* <p>
|
||||||
|
* 用于需要向前端返回额外数据的异常场景,
|
||||||
|
* 如 LIVE_ALREADY_ACTIVE 时携带已有场次信息。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param errorCode 错误码枚举
|
||||||
|
* @param data 附加数据
|
||||||
|
*/
|
||||||
|
public BusinessException(ErrorCodeEnum errorCode, Object data) {
|
||||||
|
super(errorCode.getMessage());
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对应的错误码枚举
|
||||||
|
*
|
||||||
|
* @return 错误码枚举
|
||||||
|
*/
|
||||||
|
public ErrorCodeEnum getErrorCodeEnum() {
|
||||||
|
return ErrorCodeEnum.of(this.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.model.vo.LoginUserVo;
|
||||||
|
import com.tailbet.model.vo.R;
|
||||||
|
import com.tailbet.model.vo.UserContext;
|
||||||
|
import com.tailbet.util.JwtUtil;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录认证拦截器
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
// 跳过OPTIONS请求
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
// 放行登录注册接口
|
||||||
|
if (uri.contains("/api/user/register") || uri.contains("/api/user/login")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = extractToken(request);
|
||||||
|
if (StrUtil.isBlank(token)) {
|
||||||
|
writeUnauthorizedResponse(response, "未登录");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证token是否过期
|
||||||
|
if (jwtUtil.isTokenExpired(token)) {
|
||||||
|
writeUnauthorizedResponse(response, "登录已过期");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查Redis中token是否有效
|
||||||
|
Long userId = jwtUtil.getUserId(token);
|
||||||
|
if (userId == null) {
|
||||||
|
writeUnauthorizedResponse(response, "无效的token");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询用户
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null || user.getStatus() == 0) {
|
||||||
|
writeUnauthorizedResponse(response, "用户不存在或已被封禁");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置用户上下文
|
||||||
|
UserContext.setUser(LoginUserVo.fromUser(user));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
|
||||||
|
// 清理上下文
|
||||||
|
UserContext.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractToken(HttpServletRequest request) {
|
||||||
|
String bearerToken = request.getHeader("Authorization");
|
||||||
|
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||||
|
return bearerToken.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws Exception {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
R<?> result = R.fail(401, message);
|
||||||
|
response.getWriter().write(objectMapper.writeValueAsString(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
|
||||||
|
import com.tailbet.common.enums.ErrorCodeEnum;
|
||||||
|
import com.tailbet.common.exception.BusinessException;
|
||||||
|
import com.tailbet.model.vo.R;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.ConstraintViolation;
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.MessageSource;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.validation.BindException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常处理器
|
||||||
|
* <p>
|
||||||
|
* 统一捕获并处理Controller层抛出的各类异常,将异常转换为统一的Result响应格式。
|
||||||
|
* 处理优先级:具体异常 > 通用异常 > 兜底异常。
|
||||||
|
* 支持国际化:根据请求头 Accept-Language 自动返回对应语言的错误信息。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author socialapp团队
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 国际化消息源,用于将消息 key 解析为对应语言的文本
|
||||||
|
*/
|
||||||
|
private final MessageSource messageSource;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理业务异常(最高优先级)
|
||||||
|
*
|
||||||
|
* @param e 业务异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
public R<?> handleBusinessException(BusinessException e, HttpServletRequest request) {
|
||||||
|
// 业务异常的 message 可能是 key,也可能是业务自定义文本,统一走 resolveMessage 处理
|
||||||
|
String message = e.getMessage();
|
||||||
|
log.warn("【业务异常】URI: {}, 错误码: {}, 消息: {}",
|
||||||
|
request.getRequestURI(), e.getCode(), message);
|
||||||
|
if (e.getData() != null) {
|
||||||
|
R r = R.fail(e.getCode(), message);
|
||||||
|
r.setData(e.getData());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
return R.fail(e.getCode(), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数校验异常(@Valid / @RequestBody)
|
||||||
|
*
|
||||||
|
* @param e 方法参数校验异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public R<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpServletRequest request) {
|
||||||
|
String message = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(FieldError::getDefaultMessage)
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
log.warn("【参数校验失败】URI: {}, 错误: {}", request.getRequestURI(), message);
|
||||||
|
// 参数校验失败的 message 来自注解,直接使用,不走国际化 key 解析
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数绑定异常(@ModelAttribute)
|
||||||
|
*
|
||||||
|
* @param e 参数绑定异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BindException.class)
|
||||||
|
public R<Void> handleBindException(BindException e, HttpServletRequest request) {
|
||||||
|
String message = e.getFieldErrors().stream()
|
||||||
|
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
log.warn("【参数绑定失败】URI: {}, 错误: {}", request.getRequestURI(), message);
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理约束校验异常(@Validated 方法级别)
|
||||||
|
*
|
||||||
|
* @param e 约束校验异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public R<Void> handleConstraintViolation(ConstraintViolationException e, HttpServletRequest request) {
|
||||||
|
String message = e.getConstraintViolations().stream()
|
||||||
|
.map(ConstraintViolation::getMessage)
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
log.warn("【约束校验失败】URI: {}, 错误: {}", request.getRequestURI(), message);
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理缺少请求参数异常
|
||||||
|
*
|
||||||
|
* @param e 缺少参数异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||||
|
public R<Void> handleMissingParam(MissingServletRequestParameterException e, HttpServletRequest request) {
|
||||||
|
// 缺少参数的提示信息拼接参数名,不走国际化 key
|
||||||
|
String message = e.getMessage()
|
||||||
|
+ ": " + e.getParameterName();
|
||||||
|
log.warn("【缺少参数】URI: {}, 错误: {}", request.getRequestURI(), message);
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理请求体不可读异常(请求体缺失、格式错误、缺少必填字段等)
|
||||||
|
*
|
||||||
|
* @param e 请求体不可读异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public R<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e, HttpServletRequest request) {
|
||||||
|
// 请求体格式错误,使用国际化后的错误码消息
|
||||||
|
String message = e.getMessage();
|
||||||
|
log.warn("【请求体不可读】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_FORMAT_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数类型不匹配异常
|
||||||
|
*
|
||||||
|
* @param e 参数类型不匹配异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||||
|
public R<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
|
||||||
|
String message = String.format("参数[%s]类型错误,期望类型: %s",
|
||||||
|
e.getName(), e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "未知");
|
||||||
|
log.warn("【参数类型错误】URI: {}, 错误: {}", request.getRequestURI(), message);
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_FORMAT_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理非法参数异常
|
||||||
|
*
|
||||||
|
* @param e 非法参数异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public R<Void> handleIllegalArgument(IllegalArgumentException e, HttpServletRequest request) {
|
||||||
|
log.warn("【非法参数】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
|
||||||
|
return R.fail(ErrorCodeEnum.PARAM_ERROR, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理非法状态异常
|
||||||
|
*
|
||||||
|
* @param e 非法状态异常
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalStateException.class)
|
||||||
|
public R<Void> handleIllegalState(IllegalStateException e, HttpServletRequest request) {
|
||||||
|
log.warn("【非法状态】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
|
||||||
|
return R.fail(ErrorCodeEnum.OPERATION_NOT_ALLOWED, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底异常处理器(最低优先级)
|
||||||
|
* <p>
|
||||||
|
* 捕获所有未被前面处理器捕获的异常,避免异常信息直接暴露给客户端。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param e 异常对象
|
||||||
|
* @param request HTTP请求
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public R<Void> handleException(Exception e, HttpServletRequest request) {
|
||||||
|
log.error("【系统异常】URI: {}, 错误类型: {}, 消息: {}",
|
||||||
|
request.getRequestURI(), e.getClass().getName(), e.getMessage(), e);
|
||||||
|
// 兜底异常使用国际化后的系统繁忙提示
|
||||||
|
return R.fail(ErrorCodeEnum.INTERNAL_ERROR.getCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 集成配置属性类
|
||||||
|
* <p>
|
||||||
|
* 从 Nacos/application.yml 中读取 OpenIM 第三方即时通讯服务相关配置属性,
|
||||||
|
* 对应配置前缀为 {@code openim}。OpenIM 是独立部署的外部第三方服务(非 Spring 微服务),
|
||||||
|
* 不参与 Nacos 服务发现,因此配置项中直接使用完整的 HTTP/WS 地址。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author socialapp团队
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "openim")
|
||||||
|
public class OpenIMProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM REST API 外网地址,下发给客户端 {@code httpUrl}(如 https://im-api-pre.aimchat.im)
|
||||||
|
* <p>
|
||||||
|
* 历史配置中亦可能同时作为服务端调用地址;配置了 {@link #internalApiUrl} 后仅用于下发。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private String apiUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM REST API 内网地址,服务端 {@code OpenIMApiClient} 调用(如 http://10.x.x.x:10002)
|
||||||
|
* <p>
|
||||||
|
* 未配置时回落 {@link #apiUrl},兼容旧 Nacos。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private String internalApiUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM WebSocket 外网地址,下发给客户端用于 IM SDK 初始化(如 wss://im-ws-pre.aimchat.im)
|
||||||
|
*/
|
||||||
|
private String wsUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 管理员密钥,用于调用管理接口换取 adminToken
|
||||||
|
* 生产环境通过 Nacos 敏感配置注入,禁止硬编码
|
||||||
|
*/
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 管理员用户 ID,默认 imAdmin
|
||||||
|
*/
|
||||||
|
private String adminUserId = "imAdmin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用标识,用于 operationID 生成前缀
|
||||||
|
*/
|
||||||
|
private String appId = "socialapp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* adminToken 有效期(秒),用于定时刷新任务计算刷新周期,默认 82800 秒(23小时)
|
||||||
|
*/
|
||||||
|
private int tokenExpireSeconds = 82800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 OpenIM 接口的连接超时时间(毫秒),默认 5000
|
||||||
|
*/
|
||||||
|
private int connectTimeoutMs = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 OpenIM 接口的读取超时时间(毫秒),默认 10000
|
||||||
|
*/
|
||||||
|
private int readTimeoutMs = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 签名密钥,用于校验 OpenIM 服务端回调请求的合法性(对应请求头 X-Webhook-Secret)
|
||||||
|
* 为空时跳过校验并输出警告日志
|
||||||
|
*/
|
||||||
|
private String webhookSecret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置加载后的校验方法
|
||||||
|
* 检查必填配置是否已从 Nacos 注入,未配置则输出警告日志
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void validate() {
|
||||||
|
if (StrUtil.isBlank(apiUrl)) {
|
||||||
|
log.warn("【OpenIM配置】apiUrl(外网下发 httpUrl)未配置,OpenIM 相关功能将不可用");
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(internalApiUrl)) {
|
||||||
|
log.warn("【OpenIM配置】internalApiUrl(内网调用)未配置,服务端将回落 apiUrl");
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(secret)) {
|
||||||
|
log.warn("【OpenIM配置】secret 未配置,管理员Token获取将失败");
|
||||||
|
}
|
||||||
|
if (StrUtil.isBlank(wsUrl)) {
|
||||||
|
log.warn("【OpenIM配置】wsUrl(外网下发)未配置,客户端 SDK 可能无法初始化");
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(apiUrl)) {
|
||||||
|
log.info("【OpenIM配置】加载完成,外网apiUrl: {}, 内网internalApiUrl: {}, wsUrl: {}, adminUserId: {}, secret: {}",
|
||||||
|
apiUrl,
|
||||||
|
StrUtil.blankToDefault(internalApiUrl, "(回落 api-url)"),
|
||||||
|
wsUrl,
|
||||||
|
adminUserId,
|
||||||
|
maskString(secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析服务端调用 OpenIM REST 基址(优先内网)
|
||||||
|
*
|
||||||
|
* @return HTTP(S) 基址,末尾无斜杠约定由调用方拼接 path
|
||||||
|
*/
|
||||||
|
public String resolveServerApiUrl() {
|
||||||
|
return StrUtil.blankToDefault(internalApiUrl, apiUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析下发给客户端的 OpenIM HTTP API 地址(外网)
|
||||||
|
*
|
||||||
|
* @return 外网 api-url
|
||||||
|
*/
|
||||||
|
public String resolvePublicApiUrl() {
|
||||||
|
return apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对敏感字符串进行脱敏处理
|
||||||
|
* 只显示前3位和后3位,中间用星号代替
|
||||||
|
*
|
||||||
|
* @param str 原始字符串
|
||||||
|
* @return 脱敏后的字符串
|
||||||
|
*/
|
||||||
|
private String maskString(String str) {
|
||||||
|
if (str == null || str.length() <= 6) {
|
||||||
|
return "***";
|
||||||
|
}
|
||||||
|
return str.substring(0, 3) + "****" + str.substring(str.length() - 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis配置
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
StringRedisTemplate template = new StringRedisTemplate();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setValueSerializer(new StringRedisSerializer());
|
||||||
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setHashValueSerializer(new StringRedisSerializer());
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
import org.redisson.Redisson;
|
||||||
|
import org.redisson.api.RedissonClient;
|
||||||
|
import org.redisson.codec.JsonJacksonCodec;
|
||||||
|
import org.redisson.config.Config;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(RedisProperties.class)
|
||||||
|
public class RedissonConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisProperties redisProps;
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "shutdown")
|
||||||
|
RedissonClient redissonSingle() {
|
||||||
|
Config config = new Config();
|
||||||
|
config.setCodec(new JsonJacksonCodec());
|
||||||
|
config.useSingleServer()
|
||||||
|
.setAddress("redis://" + redisProps.getHost() + ":" + redisProps.getPort())
|
||||||
|
.setDatabase(redisProps.getDatabase())
|
||||||
|
.setPassword(redisProps.getPassword())
|
||||||
|
.setTimeout(3000)
|
||||||
|
.setRetryAttempts(3)// # 如果尝试在此限制之内发送成功,则开始启用 timeout 计时。
|
||||||
|
.setRetryInterval(1000)//命令重试发送时间间隔,单位:毫秒
|
||||||
|
// .setPingConnectionInterval(5000)//**此项务必设置为redisson解决之前bug的timeout问题关键*****
|
||||||
|
.setIdleConnectionTimeout(10000)//连接空闲超时,单位:毫秒
|
||||||
|
.setConnectionPoolSize(2000)//# 连接池大小
|
||||||
|
.setConnectionMinimumIdleSize(20)//最小空闲连接数
|
||||||
|
;
|
||||||
|
return Redisson.create(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RestTemplate配置
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RestTemplateConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate() {
|
||||||
|
return new RestTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.tailbet.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebMvc配置
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final AuthInterceptor authInterceptor;
|
||||||
|
|
||||||
|
public WebMvcConfig(AuthInterceptor authInterceptor) {
|
||||||
|
this.authInterceptor = authInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOriginPatterns("*")
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(true)
|
||||||
|
.maxAge(3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(authInterceptor)
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.excludePathPatterns("/api/user/register", "/api/user/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.tailbet.controller;
|
||||||
|
|
||||||
|
import com.tailbet.job.GameJob;
|
||||||
|
import com.tailbet.mapper.DigitWhiteMapper;
|
||||||
|
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.entity.BetOrder;
|
||||||
|
import com.tailbet.model.entity.DigitWhite;
|
||||||
|
import com.tailbet.model.entity.GameRound;
|
||||||
|
import com.tailbet.model.vo.DigitTrialVo;
|
||||||
|
import com.tailbet.model.vo.R;
|
||||||
|
import com.tailbet.model.vo.UserContext;
|
||||||
|
import com.tailbet.service.IBetService;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
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;
|
||||||
|
private final GameJob gameJob;
|
||||||
|
private final DigitWhiteMapper digitWhiteMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始游戏(群主)
|
||||||
|
*/
|
||||||
|
@PostMapping("/start")
|
||||||
|
public R<GameRound> startGame(@RequestBody GameOperateDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
var round = gameService.startGame(dto.getGroupId(), userId);
|
||||||
|
// 触发游戏开始后的定时器和广播
|
||||||
|
int betWindowSeconds = gameJob.getConfigInt("bet_window_seconds", 60);
|
||||||
|
gameJob.onGameStart(round.getId(), dto.getGroupId(), betWindowSeconds);
|
||||||
|
return R.ok(round);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束接注(群主)
|
||||||
|
*/
|
||||||
|
@PostMapping("/end")
|
||||||
|
public R<GameRound> endBet(@RequestBody GameOperateDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
var round = gameService.endBet(dto.getGroupId(), userId);
|
||||||
|
// 触发结束接注后的广播和发红包流程
|
||||||
|
gameJob.onEndBet(round.getId(), dto.getGroupId());
|
||||||
|
return R.ok(round);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止自动连开
|
||||||
|
*/
|
||||||
|
@PostMapping("/stop")
|
||||||
|
public R<Void> stopAutoNext(@RequestBody GameOperateDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
gameService.stopAutoNext(dto.getGroupId(), userId);
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下注
|
||||||
|
*/
|
||||||
|
@PostMapping("/bet")
|
||||||
|
public R<BetOrder> bet(@RequestBody BetDTO dto) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾数试算
|
||||||
|
*/
|
||||||
|
@GetMapping("/bet/trial")
|
||||||
|
public R<List<DigitTrialVo>> trialDigit(Long groupId, Long roundId) {
|
||||||
|
List<DigitTrialVo> trials = gameService.trialDigit(groupId, roundId);
|
||||||
|
return R.ok(trials);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖历史
|
||||||
|
*/
|
||||||
|
@PostMapping("/history")
|
||||||
|
public R<Page<com.tailbet.model.entity.DrawRecord>> history(@RequestBody HistoryQueryDTO query) {
|
||||||
|
var page = gameService.getHistory(query.getGroupId(), query.getPageNum(), query.getPageSize());
|
||||||
|
return R.ok(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置尾数(白名单用户)
|
||||||
|
*/
|
||||||
|
@PostMapping("/digit/set")
|
||||||
|
public R<Void> setDigit(@RequestBody SetDigitDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
if (!UserContext.isWhite()) {
|
||||||
|
return R.fail("您不是白名单用户,无权设置尾数");
|
||||||
|
}
|
||||||
|
|
||||||
|
gameService.setTargetDigit(dto.getGroupId(), dto.getDigit(), userId);
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.tailbet.controller;
|
||||||
|
|
||||||
|
import com.tailbet.model.dto.CreateGroupDTO;
|
||||||
|
import com.tailbet.model.dto.GroupMemberOperateDTO;
|
||||||
|
import com.tailbet.model.dto.PointsDTO;
|
||||||
|
import com.tailbet.model.dto.PullGroupDTO;
|
||||||
|
import com.tailbet.model.entity.Group;
|
||||||
|
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 jakarta.validation.Valid;
|
||||||
|
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<Void> addPoints(@RequestBody PointsDTO dto) {
|
||||||
|
if (!UserContext.isOps()) {
|
||||||
|
return R.fail("无权限:仅运营可操作");
|
||||||
|
}
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
pointsService.addPoints(dto.getUserId(), dto.getAmount(),
|
||||||
|
IPointsService.TYPE_OPS_ADD, null, operatorId, dto.getRemark());
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减积分
|
||||||
|
*/
|
||||||
|
@PostMapping("/points/sub")
|
||||||
|
public R subPoints(@RequestBody PointsDTO dto) {
|
||||||
|
if (!UserContext.isOps()) {
|
||||||
|
return R.fail("无权限:仅运营可操作");
|
||||||
|
}
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
pointsService.subPoints(dto.getUserId(), dto.getAmount(),
|
||||||
|
IPointsService.TYPE_OPS_SUB, null, operatorId, dto.getRemark());
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群(指定群ID、名称,群主为传入用户,并自动把机器人拉入群)
|
||||||
|
*/
|
||||||
|
@PostMapping("/group/create")
|
||||||
|
public R<Long> createGroup(@Valid @RequestBody CreateGroupDTO dto) {
|
||||||
|
if (!UserContext.isOps()) {
|
||||||
|
return R.fail("无权限:仅运营可操作");
|
||||||
|
}
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
Group group = groupService.createGroup(dto.getName(),
|
||||||
|
dto.getUserId(), dto.getRoundInterval(), operatorId);
|
||||||
|
return R.ok(group.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉入群
|
||||||
|
*/
|
||||||
|
@PostMapping("/group/pull")
|
||||||
|
public R<Void> pullGroup(@Valid @RequestBody PullGroupDTO dto) {
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
groupService.pullUser(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 踢出群成员
|
||||||
|
* <p>
|
||||||
|
* 操作人需为该群群主或管理员;同时从本地数据库和 OpenIM 移除成员。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@PostMapping("/group/kick")
|
||||||
|
public R<Void> kickGroup(@Valid @RequestBody GroupMemberOperateDTO dto) {
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
groupService.kickUser(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置为管理员
|
||||||
|
* <p>
|
||||||
|
* 操作人需为该群群主;同时本地与 OpenIM 角色都会更新(OpenIM roleLevel=60=管理员),
|
||||||
|
* OpenIM 同步失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@PostMapping("/group/set-admin")
|
||||||
|
public R<Void> setAdmin(@Valid @RequestBody GroupMemberOperateDTO dto) {
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
groupService.setAdmin(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消管理员
|
||||||
|
* <p>
|
||||||
|
* 操作人需为该群群主;同时本地与 OpenIM 角色都会更新(OpenIM roleLevel=20=普通成员),
|
||||||
|
* OpenIM 同步失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@PostMapping("/group/remove-admin")
|
||||||
|
public R<Void> removeAdmin(@Valid @RequestBody GroupMemberOperateDTO dto) {
|
||||||
|
Long operatorId = UserContext.getUserId();
|
||||||
|
groupService.removeAdmin(dto.getGroupId(), dto.getUserId(), operatorId);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.tailbet.controller;
|
||||||
|
|
||||||
|
import com.tailbet.model.dto.ReceiveRpDTO;
|
||||||
|
import com.tailbet.model.dto.SendRpDTO;
|
||||||
|
import com.tailbet.model.entity.RedPacket;
|
||||||
|
import com.tailbet.model.entity.RedPacketRecv;
|
||||||
|
import com.tailbet.model.vo.R;
|
||||||
|
import com.tailbet.model.vo.UserContext;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import com.tailbet.service.IRedPacketService;
|
||||||
|
import com.tailbet.util.RedisLockUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包Controller
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/rp")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RedPacketController {
|
||||||
|
|
||||||
|
private final IRedPacketService redPacketService;
|
||||||
|
private final IGameService gameService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发红包
|
||||||
|
*/
|
||||||
|
@PostMapping("/send")
|
||||||
|
public R<RedPacket> sendRp(@RequestBody SendRpDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
// roundId从当前group的RP_SENDING状态局获取
|
||||||
|
var sendingRound = gameService.getRpSendingRound(dto.getGroupId());
|
||||||
|
Long roundId = sendingRound == null ? null : sendingRound.getId();
|
||||||
|
var rp = redPacketService.sendRp(userId, dto.getGroupId(),
|
||||||
|
dto.getTotalAmount(), dto.getTotalCount(), roundId);
|
||||||
|
return R.ok(rp);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领红包
|
||||||
|
*/
|
||||||
|
@PostMapping("/receive")
|
||||||
|
public R<RedPacketRecv> receiveRp(@RequestBody ReceiveRpDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
// 使用分布式锁防止多领
|
||||||
|
var recv = RedisLockUtil.lock("rp:receive:" + dto.getRpId(),
|
||||||
|
() -> redPacketService.receiveRp(dto.getRpId(), userId));
|
||||||
|
if (recv == null) {
|
||||||
|
return R.fail("领取失败,请重试");
|
||||||
|
}
|
||||||
|
return R.ok(recv);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红包详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail")
|
||||||
|
public R<RedPacket> detail(Long rpId) {
|
||||||
|
var rp = redPacketService.getById(rpId);
|
||||||
|
return R.ok(rp);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.tailbet.controller;
|
||||||
|
|
||||||
|
import com.tailbet.model.dto.LoginDTO;
|
||||||
|
import com.tailbet.model.dto.PageParam;
|
||||||
|
import com.tailbet.model.dto.PointsFlowQueryDTO;
|
||||||
|
import com.tailbet.model.dto.RegisterDTO;
|
||||||
|
import com.tailbet.model.dto.UpdatePasswordDTO;
|
||||||
|
import com.tailbet.model.dto.UpdateUserDTO;
|
||||||
|
import com.tailbet.model.entity.PointsFlow;
|
||||||
|
import com.tailbet.model.entity.RedPacket;
|
||||||
|
import com.tailbet.model.entity.RedPacketRecv;
|
||||||
|
import com.tailbet.model.vo.LoginUserVo;
|
||||||
|
import com.tailbet.model.vo.LoginVo;
|
||||||
|
import com.tailbet.model.vo.R;
|
||||||
|
import com.tailbet.model.vo.UserContext;
|
||||||
|
import com.tailbet.openim.OpenIMApiClient;
|
||||||
|
import com.tailbet.service.IPointsService;
|
||||||
|
import com.tailbet.service.IRedPacketService;
|
||||||
|
import com.tailbet.service.IUserService;
|
||||||
|
import com.tailbet.util.JwtUtil;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户Controller
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/user")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final IUserService userService;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final OpenIMApiClient openIMApiClient;
|
||||||
|
private final IPointsService pointsService;
|
||||||
|
private final IRedPacketService redPacketService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
public R<Void> register(@RequestBody @Validated RegisterDTO dto) {
|
||||||
|
userService.register(dto.getUsername(), dto.getPassword());
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
public R<LoginVo> login(@RequestBody @Validated LoginDTO dto) {
|
||||||
|
Long userId = userService.login(dto.getUsername(), dto.getPassword());
|
||||||
|
String token = jwtUtil.generateToken(userId, dto.getUsername());
|
||||||
|
String openIMToken = openIMApiClient.getUserToken(String.valueOf(userId), 2); // platformID 2=Android
|
||||||
|
|
||||||
|
LoginVo vo = new LoginVo();
|
||||||
|
vo.setToken(token);
|
||||||
|
vo.setOpenIMToken(openIMToken);
|
||||||
|
return R.ok("登录成功", vo);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
@GetMapping("/info")
|
||||||
|
public R<LoginUserVo> getInfo() {
|
||||||
|
LoginUserVo user = UserContext.getUser();
|
||||||
|
return R.ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
public R<Void> update(@RequestBody UpdateUserDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
userService.updateUserInfo(userId, dto);
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
@PutMapping("/password")
|
||||||
|
public R<Void> changePassword(@RequestBody @Validated UpdatePasswordDTO dto) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
userService.changePassword(userId, dto.getOldPassword(), dto.getNewPassword());
|
||||||
|
return R.ok();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的积分流水分页
|
||||||
|
*/
|
||||||
|
@PostMapping("/points/flow")
|
||||||
|
public R<Page<PointsFlow>> pointsFlow(@RequestBody PointsFlowQueryDTO query) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
var page = pointsService.getFlowPage(userId, query.getType(), query.getPageNum(), query.getPageSize());
|
||||||
|
return R.ok(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已发红包分页
|
||||||
|
*/
|
||||||
|
@PostMapping("/rp/sent")
|
||||||
|
public R<Page<RedPacket>> sentRp(@RequestBody PageParam query) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
var page = redPacketService.getRecords(userId, query.getPageNum(), query.getPageSize());
|
||||||
|
return R.ok(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已收红包分页
|
||||||
|
*/
|
||||||
|
@PostMapping("/rp/received")
|
||||||
|
public R<Page<RedPacketRecv>> receivedRp(@RequestBody PageParam query) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
var page = redPacketService.getReceivedRecords(userId, query.getPageNum(), query.getPageSize());
|
||||||
|
return R.ok(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public R<Void> logout() {
|
||||||
|
// 可以在这里清理Redis中的token
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.tailbet.event;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包领完事件,触发开奖结算
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class RedPacketFinishedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final Long roundId;
|
||||||
|
private final Long groupId;
|
||||||
|
private final Long rpId;
|
||||||
|
private final Integer luckyDigit;
|
||||||
|
|
||||||
|
public RedPacketFinishedEvent(Object source, Long roundId, Long groupId, Long rpId, Integer luckyDigit) {
|
||||||
|
super(source);
|
||||||
|
this.roundId = roundId;
|
||||||
|
this.groupId = groupId;
|
||||||
|
this.rpId = rpId;
|
||||||
|
this.luckyDigit = luckyDigit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.tailbet.job;
|
||||||
|
|
||||||
|
import com.tailbet.config.OpenIMProperties;
|
||||||
|
import com.tailbet.mapper.DigitWhiteMapper;
|
||||||
|
import com.tailbet.mapper.DrawRecordMapper;
|
||||||
|
import com.tailbet.mapper.GameRoundMapper;
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.model.entity.DigitWhite;
|
||||||
|
import com.tailbet.model.entity.DrawRecord;
|
||||||
|
import com.tailbet.model.entity.GameRound;
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.openim.OpenIMApiClient;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏广播器 —— 把 OpenIM 消息发送逻辑从 {@link GameJob} 抽出
|
||||||
|
*
|
||||||
|
* <p>负责所有游戏流程的 OpenIM 消息:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #broadcastStartBet} —— 开始下注</li>
|
||||||
|
* <li>{@link #broadcastEndBet} —— 结束下注</li>
|
||||||
|
* <li>{@link #broadcastStartRp} —— 开始发红包</li>
|
||||||
|
* <li>{@link #broadcastResult} —— 开奖结果(含近30期历史)</li>
|
||||||
|
* <li>{@link #notifyWhiteUsers} —— 通知尾数控制白名单</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>所有方法失败时只记日志、不抛异常,避免阻塞调用方业务流程。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GameBroadcaster {
|
||||||
|
|
||||||
|
/** sessionType: 1=单聊 */
|
||||||
|
private static final int SESSION_TYPE_C2C = 1;
|
||||||
|
/** sessionType: 3=群聊 */
|
||||||
|
private static final int SESSION_TYPE_GROUP = 3;
|
||||||
|
/** contentType: 101=文本 */
|
||||||
|
private static final int CONTENT_TYPE_TEXT = 101;
|
||||||
|
|
||||||
|
private final OpenIMApiClient openIMApiClient;
|
||||||
|
private final OpenIMProperties openIMProperties;
|
||||||
|
private final GameRoundMapper gameRoundMapper;
|
||||||
|
private final DrawRecordMapper drawRecordMapper;
|
||||||
|
private final DigitWhiteMapper digitWhiteMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final IGameService gameService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知白名单用户(尾数试算完成)
|
||||||
|
*/
|
||||||
|
public void notifyWhiteUsers(Long roundId) {
|
||||||
|
try {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round == null) return;
|
||||||
|
|
||||||
|
List<DigitWhite> whites = digitWhiteMapper.selectList(new LambdaQueryWrapper<>());
|
||||||
|
for (DigitWhite white : whites) {
|
||||||
|
User user = userMapper.selectById(white.getUserId());
|
||||||
|
if (user == null) continue;
|
||||||
|
|
||||||
|
sendC2C(user.getId(), "尾数试算完成,请前往修改尾数。期号: " + round.getRoundNo());
|
||||||
|
log.info("通知白名单用户尾数试算完成: userId={}, roundId={}", user.getId(), roundId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("通知白名单用户失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播开始下注
|
||||||
|
*/
|
||||||
|
public void broadcastStartBet(Long groupId, String roundNo) {
|
||||||
|
sendGroup(groupId, roundNo + "期开始下注");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播结束下注
|
||||||
|
*/
|
||||||
|
public void broadcastEndBet(Long groupId, String roundNo) {
|
||||||
|
sendGroup(groupId, roundNo + "期结束下注");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播开始发红包
|
||||||
|
*/
|
||||||
|
public void broadcastStartRp(Long groupId, String roundNo) {
|
||||||
|
sendGroup(groupId, roundNo + "期开始发红包");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播开奖结果(含近30期历史)
|
||||||
|
*/
|
||||||
|
public void broadcastResult(Long groupId, int digit, String roundNo) {
|
||||||
|
try {
|
||||||
|
String playWinDesc = gameService.getPlayWinDesc(digit);
|
||||||
|
StringBuilder message = new StringBuilder()
|
||||||
|
.append(roundNo).append("期开奖\n")
|
||||||
|
.append("尾数: ").append(digit).append("\n")
|
||||||
|
.append("中奖玩法: ").append(playWinDesc);
|
||||||
|
|
||||||
|
String history = getRecentHistory(groupId);
|
||||||
|
if (!history.isEmpty()) {
|
||||||
|
message.append("\n\n近30期:\n").append(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendGroup(groupId, message.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("广播开奖结果失败: groupId={}, digit={}, error={}", groupId, digit, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取近30期历史开奖记录
|
||||||
|
*/
|
||||||
|
private String getRecentHistory(Long groupId) {
|
||||||
|
List<DrawRecord> records = drawRecordMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<DrawRecord>()
|
||||||
|
.eq(DrawRecord::getGroupId, groupId)
|
||||||
|
.orderByDesc(DrawRecord::getCreateTime)
|
||||||
|
.last("LIMIT 30"));
|
||||||
|
|
||||||
|
if (records == null || records.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (DrawRecord record : records) {
|
||||||
|
sb.append(record.getRoundNo()).append(":").append(record.getDigit()).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 底层发送封装 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送群聊文本消息
|
||||||
|
*/
|
||||||
|
private void sendGroup(Long groupId, String content) {
|
||||||
|
try {
|
||||||
|
// 注意: 实际生产中需要通过Group表映射获取OpenIM群ID
|
||||||
|
openIMApiClient.sendMsg(
|
||||||
|
openIMProperties.getAdminUserId(),
|
||||||
|
String.valueOf(groupId),
|
||||||
|
SESSION_TYPE_GROUP,
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
java.util.Map.of("content", content)
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("群消息发送失败: groupId={}, content={}, error={}", groupId, content, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送单聊文本消息
|
||||||
|
*/
|
||||||
|
private void sendC2C(Long userId, String content) {
|
||||||
|
try {
|
||||||
|
openIMApiClient.sendMsg(
|
||||||
|
openIMProperties.getAdminUserId(),
|
||||||
|
String.valueOf(userId),
|
||||||
|
SESSION_TYPE_C2C,
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
java.util.Map.of("content", content)
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("单聊消息发送失败: userId={}, content={}, error={}", userId, content, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
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.GroupMemberMapper;
|
||||||
|
import com.tailbet.mapper.RedPacketMapper;
|
||||||
|
import com.tailbet.mapper.RedPacketRecvMapper;
|
||||||
|
import com.tailbet.mapper.SysConfigMapper;
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.model.entity.GroupMember;
|
||||||
|
import com.tailbet.model.entity.RedPacketRecv;
|
||||||
|
import com.tailbet.model.entity.SysConfig;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import com.tailbet.service.IRedPacketService;
|
||||||
|
import com.tailbet.service.IFakeUserService;
|
||||||
|
import com.tailbet.util.RedisLockUtil;
|
||||||
|
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;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏定时任务
|
||||||
|
*
|
||||||
|
* <p>状态完全由 {@code game_round} 数据库表承载,不再使用内存 Map。
|
||||||
|
* 定时任务只做"数据库扫描 + 状态机推进",天然支持单实例重启恢复。
|
||||||
|
*
|
||||||
|
* <p>扫描任务(每 2 秒):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #scanRoundTimeouts()} —— 下注超时自动封盘</li>
|
||||||
|
* <li>{@link #scanStartRpTimeouts()} —— 封盘后延迟开始发红包</li>
|
||||||
|
* <li>{@link #scanBotSendRpTimeouts()} —— 发包超时由机器人代发</li>
|
||||||
|
* <li>{@link #scanAutoNextTimeouts()} —— 结算后自动开下一期</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>所有 OpenIM 消息广播逻辑由 {@link GameBroadcaster} 负责。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GameJob {
|
||||||
|
|
||||||
|
private final GameRoundMapper gameRoundMapper;
|
||||||
|
private final RedPacketMapper redPacketMapper;
|
||||||
|
private final RedPacketRecvMapper redPacketRecvMapper;
|
||||||
|
private final SysConfigMapper sysConfigMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final GroupMemberMapper groupMemberMapper;
|
||||||
|
private final IGameService gameService;
|
||||||
|
private final IRedPacketService redPacketService;
|
||||||
|
private final IFakeUserService fakeUserService;
|
||||||
|
private final GameBroadcaster broadcaster;
|
||||||
|
|
||||||
|
// 配置缓存
|
||||||
|
private final Map<String, String> configCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时加载配置缓存
|
||||||
|
* <p>不再做 roundTasks/pendingTasks 内存恢复 —— 扫描任务启动后会自动从数据库接管
|
||||||
|
*/
|
||||||
|
@jakarta.annotation.PostConstruct
|
||||||
|
public void loadConfig() {
|
||||||
|
try {
|
||||||
|
List<SysConfig> configs = sysConfigMapper.selectList(null);
|
||||||
|
for (SysConfig config : configs) {
|
||||||
|
configCache.put(config.getCfgKey(), config.getCfgValue());
|
||||||
|
}
|
||||||
|
log.info("配置缓存加载完成,共 {} 条配置", configs.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("启动初始化失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置值
|
||||||
|
*/
|
||||||
|
private String getConfig(String key) {
|
||||||
|
return configCache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取整型配置值
|
||||||
|
*/
|
||||||
|
public int getConfigInt(String key, int defaultValue) {
|
||||||
|
String value = configCache.get(key);
|
||||||
|
if (value == null) return defaultValue;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字符串配置值(带默认值)
|
||||||
|
*/
|
||||||
|
public String getConfig(String key, String defaultValue) {
|
||||||
|
String value = configCache.get(key);
|
||||||
|
return value == null ? defaultValue : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查红包是否需要托领取(每3秒执行)
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 3000)
|
||||||
|
public void checkRedPacketForFakeReceive() {
|
||||||
|
// 查找进行中的游戏红包
|
||||||
|
List<RedPacket> ongoingRps = redPacketMapper.selectList(new LambdaQueryWrapper<RedPacket>()
|
||||||
|
.eq(RedPacket::getStatus, 0)
|
||||||
|
.eq(RedPacket::getType, IRedPacketService.TYPE_GAME)
|
||||||
|
.isNotNull(RedPacket::getRoundId));
|
||||||
|
|
||||||
|
for (RedPacket rp : ongoingRps) {
|
||||||
|
LocalDateTime createTime = rp.getCreateTime();
|
||||||
|
// 发出超过配置时间未领完,让托领取
|
||||||
|
int fakeRecvTimeout = getConfigInt("fake_recv_seconds", 5);
|
||||||
|
if (createTime.plusSeconds(fakeRecvTimeout).isBefore(LocalDateTime.now())) {
|
||||||
|
// 查找未领取的用户,随机选一个托领取
|
||||||
|
try {
|
||||||
|
fakeUserReceiveRemaining(rp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("托领取红包失败: rpId={}, error={}", rp.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假人托领取剩余份额(循环领取直到红包领完)
|
||||||
|
*/
|
||||||
|
private void fakeUserReceiveRemaining(RedPacket rp) {
|
||||||
|
if (rp.getGroupId() == null) {
|
||||||
|
log.warn("红包未关联群组,托跳过领取: rpId={}", rp.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅允许群内假人领取,避免跨群领取红包
|
||||||
|
List<Long> groupMemberIds = groupMemberMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, rp.getGroupId())
|
||||||
|
.select(GroupMember::getUserId))
|
||||||
|
.stream()
|
||||||
|
.map(GroupMember::getUserId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (groupMemberIds.isEmpty()) {
|
||||||
|
log.warn("红包所属群组无成员,托跳过领取: rpId={}, groupId={}", rp.getId(), rp.getGroupId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<User> fakes = fakeUserService.getFakeUsers().stream()
|
||||||
|
.filter(fake -> groupMemberIds.contains(fake.getId()))
|
||||||
|
.toList();
|
||||||
|
if (fakes.isEmpty()) {
|
||||||
|
log.info("红包所属群组无可用假人,托跳过领取: rpId={}, groupId={}", rp.getId(), rp.getGroupId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环领取直到红包领完
|
||||||
|
while (true) {
|
||||||
|
RedPacket rpCheck = redPacketMapper.selectById(rp.getId());
|
||||||
|
if (rpCheck == null || rpCheck.getStatus() != 0) {
|
||||||
|
// 红包已领完或不存在
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找还未领取的假人
|
||||||
|
User fakeToUse = null;
|
||||||
|
for (User fake : fakes) {
|
||||||
|
// 检查该假人是否已领取过本红包
|
||||||
|
var received = redPacketRecvMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<RedPacketRecv>()
|
||||||
|
.eq(RedPacketRecv::getRpId, rp.getId())
|
||||||
|
.eq(RedPacketRecv::getUserId, fake.getId()));
|
||||||
|
if (received.isEmpty()) {
|
||||||
|
fakeToUse = fake;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fakeToUse == null) {
|
||||||
|
// 所有假人都已领取过,无法继续
|
||||||
|
log.warn("所有假人都已领取过本红包,无法继续: rpId={}", rp.getId());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final User fakeUser = fakeToUse;
|
||||||
|
try {
|
||||||
|
// 使用分布式锁防止多领
|
||||||
|
RedisLockUtil.lock("rp:receive:" + rp.getId(),
|
||||||
|
() -> redPacketService.receiveRp(rp.getId(), fakeUser.getId()));
|
||||||
|
log.info("假人{}领取红包{}剩余份额", fakeToUse.getId(), rp.getId());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("假人领取红包失败: rpId={}, fakeId={}, error={}", rp.getId(), fakeUser.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描下注超时局 —— 每 2 秒
|
||||||
|
* <p>条件:{@code status=0 (下注中) AND start_time + bet_window_seconds 秒 < NOW()}
|
||||||
|
* <p>说明:{@code end_bet_time} 在 {@code startGame} 时为 null(仅群主手动 {@code endBet} 时写入),
|
||||||
|
* 所以基准时间戳必须用 {@code start_time},与原 {@code recoverRoundTasks} 的 fallback 逻辑一致。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 2000)
|
||||||
|
public void scanRoundTimeouts() {
|
||||||
|
int betWindowSeconds = getConfigInt("bet_window_seconds", 60);
|
||||||
|
LocalDateTime threshold = LocalDateTime.now().minusSeconds(betWindowSeconds);
|
||||||
|
List<GameRound> overdue = gameRoundMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getStatus, IGameService.STATUS_BETTING)
|
||||||
|
.isNotNull(GameRound::getStartTime)
|
||||||
|
.lt(GameRound::getStartTime, threshold)
|
||||||
|
.last("LIMIT 50"));
|
||||||
|
|
||||||
|
for (GameRound round : overdue) {
|
||||||
|
try {
|
||||||
|
gameService.endBet(round.getGroupId(), 0L); // 0L 表示系统自动触发
|
||||||
|
// 触发结束接注后的广播和发红包流程
|
||||||
|
onEndBet(round.getId(), round.getGroupId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("自动结束接注失败: roundId={}, error={}", round.getId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描"封盘后到点开始发红包" —— 每 2 秒
|
||||||
|
* <p>条件:{@code status=1 (已封盘) AND start_rp_time IS NULL AND end_bet_time + send_rp_delay 秒 < NOW()}
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 2000)
|
||||||
|
public void scanStartRpTimeouts() {
|
||||||
|
int sendRpDelay = getConfigInt("send_rp_delay", 20);
|
||||||
|
LocalDateTime threshold = LocalDateTime.now().minusSeconds(sendRpDelay);
|
||||||
|
List<GameRound> due = gameRoundMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getStatus, IGameService.STATUS_BET_CLOSED)
|
||||||
|
.isNull(GameRound::getStartRpTime)
|
||||||
|
.lt(GameRound::getEndBetTime, threshold)
|
||||||
|
.last("LIMIT 50"));
|
||||||
|
|
||||||
|
for (GameRound round : due) {
|
||||||
|
try {
|
||||||
|
onStartRp(round.getId(), round.getGroupId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("延迟开始发红包失败: roundId={}, error={}", round.getId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描"发包超时由机器人代发" —— 每 2 秒
|
||||||
|
* <p>条件:{@code status=2 (发红包中) AND first_rp_id IS NULL AND start_rp_time + auto_rp_timeout 秒 < NOW()}
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 2000)
|
||||||
|
public void scanBotSendRpTimeouts() {
|
||||||
|
int autoRpTimeout = getConfigInt("auto_rp_timeout", 10);
|
||||||
|
LocalDateTime threshold = LocalDateTime.now().minusSeconds(autoRpTimeout);
|
||||||
|
List<GameRound> due = gameRoundMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getStatus, IGameService.STATUS_RP_SENDING)
|
||||||
|
.isNull(GameRound::getFirstRpId)
|
||||||
|
.isNotNull(GameRound::getStartRpTime)
|
||||||
|
.lt(GameRound::getStartRpTime, threshold)
|
||||||
|
.last("LIMIT 50"));
|
||||||
|
|
||||||
|
for (GameRound round : due) {
|
||||||
|
try {
|
||||||
|
botSendRp(round.getId(), round.getGroupId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("机器人自动发包扫描执行失败: roundId={}, error={}", round.getId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描"结算后自动开下一期" —— 每 2 秒
|
||||||
|
* <p>条件:{@code status=3 (已结算) AND auto_next=1 AND finish_time + 5 秒 < NOW()}
|
||||||
|
* 且本群当前无进行中局
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 2000)
|
||||||
|
public void scanAutoNextTimeouts() {
|
||||||
|
int autoNextDelay = getConfigInt("auto_next_delay", 5);
|
||||||
|
LocalDateTime threshold = LocalDateTime.now().minusSeconds(autoNextDelay);
|
||||||
|
List<GameRound> finished = gameRoundMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getStatus, IGameService.STATUS_FINISHED)
|
||||||
|
.eq(GameRound::getAutoNext, 1)
|
||||||
|
.isNotNull(GameRound::getFinishTime)
|
||||||
|
.lt(GameRound::getFinishTime, threshold)
|
||||||
|
.last("LIMIT 50"));
|
||||||
|
|
||||||
|
for (GameRound round : finished) {
|
||||||
|
try {
|
||||||
|
// 仅当该群当前无进行中局时才开下一期
|
||||||
|
GameRound ongoing = gameService.getOngoingRound(round.getGroupId());
|
||||||
|
if (ongoing != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
onAutoNext(round.getGroupId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("自动开下一期失败: groupId={}, error={}", round.getGroupId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日凌晨重置活跃假人池
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0 0 * * ?")
|
||||||
|
public void resetDailyFakeUsers() {
|
||||||
|
fakeUserService.resetDailyActiveFakes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始游戏后广播并打日志
|
||||||
|
*/
|
||||||
|
public void onGameStart(Long roundId, Long groupId, int intervalSeconds) {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round != null) {
|
||||||
|
broadcaster.broadcastStartBet(groupId, round.getRoundNo());
|
||||||
|
}
|
||||||
|
log.info("游戏开始: roundId={}, {}秒后结束接注", roundId, intervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束接注后广播 + 通知白名单
|
||||||
|
* <p>延迟开始发红包由 {@link #scanStartRpTimeouts()} 接管
|
||||||
|
*/
|
||||||
|
public void onEndBet(Long roundId, Long groupId) {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round != null) {
|
||||||
|
broadcaster.broadcastEndBet(groupId, round.getRoundNo());
|
||||||
|
}
|
||||||
|
broadcaster.notifyWhiteUsers(roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始发红包
|
||||||
|
* <p>机器人超时发包由 {@link #scanBotSendRpTimeouts()} 接管
|
||||||
|
*/
|
||||||
|
public boolean onStartRp(Long roundId, Long groupId) {
|
||||||
|
try {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再次校验:避免重复推进状态机
|
||||||
|
if (round.getStatus() == null || round.getStatus() != IGameService.STATUS_BET_CLOSED) {
|
||||||
|
log.debug("本局{}当前状态非封盘中(status={}),跳过发红包开始",
|
||||||
|
roundId, round.getStatus());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新局状态为开始发红包
|
||||||
|
round.setStatus(IGameService.STATUS_RP_SENDING);
|
||||||
|
round.setStartRpTime(LocalDateTime.now());
|
||||||
|
gameRoundMapper.updateById(round);
|
||||||
|
|
||||||
|
// 广播开始发红包
|
||||||
|
broadcaster.broadcastStartRp(groupId, round.getRoundNo());
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("开始发红包失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器人自动发包
|
||||||
|
*/
|
||||||
|
public boolean botSendRp(Long roundId, Long groupId) {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在"开始发红包"状态下才执行,避免误触发
|
||||||
|
if (round.getStatus() == null || round.getStatus() != IGameService.STATUS_RP_SENDING) {
|
||||||
|
log.debug("本局{}当前状态非发红包中(status={}),机器人跳过发包",
|
||||||
|
roundId, round.getStatus());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有用户先发包,机器人不再重复发包(让用户的红包触发开奖)
|
||||||
|
if (round.getFirstRpId() != null) {
|
||||||
|
log.info("本局{}已有用户先发包(firstRpId={}),机器人跳过发包",
|
||||||
|
roundId, round.getFirstRpId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本群成员中查找机器人账号
|
||||||
|
List<Long> memberIds = groupMemberMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.select(GroupMember::getUserId))
|
||||||
|
.stream()
|
||||||
|
.map(GroupMember::getUserId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (memberIds.isEmpty()) {
|
||||||
|
log.warn("群{}无成员,机器人跳过发包", groupId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
User bot = userMapper.selectOne(new LambdaQueryWrapper<User>()
|
||||||
|
.in(User::getId, memberIds)
|
||||||
|
.eq(User::getIsBot, 1)
|
||||||
|
.eq(User::getStatus, 1)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
|
||||||
|
if (bot == null) {
|
||||||
|
log.warn("群{}成员中无可用机器人账号,跳过发包", groupId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从配置读取金额和数量,默认1元3个包
|
||||||
|
BigDecimal amount = new BigDecimal(getConfig("bot_rp_amount", "1"));
|
||||||
|
int count = getConfigInt("bot_rp_count", 3);
|
||||||
|
|
||||||
|
// 机器人发包
|
||||||
|
RedPacket rp = redPacketService.sendRp(bot.getId(), groupId, amount, count, roundId);
|
||||||
|
log.info("机器人自动发包: roundId={}, rpId={}, amount={}, count={}, targetDigit={}",
|
||||||
|
roundId, rp.getId(), amount, count, round.getTargetDigit());
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("机器人发包失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖结算
|
||||||
|
* <p>自动开下一期由 {@link #scanAutoNextTimeouts()} 接管
|
||||||
|
*/
|
||||||
|
public boolean onSettle(Long roundId, Long groupId, int digit) {
|
||||||
|
try {
|
||||||
|
gameService.settle(roundId, digit);
|
||||||
|
|
||||||
|
// 广播开奖结果
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round != null) {
|
||||||
|
broadcaster.broadcastResult(groupId, digit, round.getRoundNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("局结算完成: roundId={}, digit={}", roundId, digit);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("结算失败: roundId={}, error={}", roundId, e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动开始下一期
|
||||||
|
*/
|
||||||
|
private boolean onAutoNext(Long groupId) {
|
||||||
|
try {
|
||||||
|
GameRound round = gameService.startGame(groupId, 0L); // 0L表示系统自动
|
||||||
|
if (round == null || round.getId() == null) {
|
||||||
|
log.warn("自动开始下一期失败:startGame未返回局信息, groupId={}", groupId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 复用与手动流程一致的逻辑:广播开始下注
|
||||||
|
int betWindowSeconds = getConfigInt("bet_window_seconds", 60);
|
||||||
|
onGameStart(round.getId(), groupId, betWindowSeconds);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("自动开始下一期失败: groupId={}, error={}", groupId, e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.tailbet.listener;
|
||||||
|
|
||||||
|
import com.tailbet.event.RedPacketFinishedEvent;
|
||||||
|
import com.tailbet.job.GameJob;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包事件监听器
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RedPacketEventListener {
|
||||||
|
|
||||||
|
private final IGameService gameService;
|
||||||
|
private final GameJob gameJob;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听红包领完事件,触发开奖结算
|
||||||
|
*/
|
||||||
|
@EventListener
|
||||||
|
public void onRedPacketFinished(RedPacketFinishedEvent event) {
|
||||||
|
log.info("收到红包领完事件: roundId={}, groupId={}, rpId={}, luckyDigit={}",
|
||||||
|
event.getRoundId(), event.getGroupId(), event.getRpId(), event.getLuckyDigit());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 GameJob.onSettle 进行开奖结算和广播
|
||||||
|
gameJob.onSettle(event.getRoundId(), event.getGroupId(), event.getLuckyDigit());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("开奖结算失败: roundId={}, error={}", event.getRoundId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.AuditLog;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AuditLogMapper extends BaseMapper<AuditLog> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.BetOrder;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注单Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface BetOrderMapper extends BaseMapper<BetOrder> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.DigitWhite;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾数控制白名单Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface DigitWhiteMapper extends BaseMapper<DigitWhite> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.DrawRecord;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖记录Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface DrawRecordMapper extends BaseMapper<DrawRecord> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.GameRound;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏局Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface GameRoundMapper extends BaseMapper<GameRound> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.Group;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface GroupMapper extends BaseMapper<Group> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.GroupMember;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群成员Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface GroupMemberMapper extends BaseMapper<GroupMember> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.PointsFlow;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分流水Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PointsFlowMapper extends BaseMapper<PointsFlow> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.RedPacket;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface RedPacketMapper extends BaseMapper<RedPacket> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.RedPacketRecv;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包领取Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface RedPacketRecvMapper extends BaseMapper<RedPacketRecv> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.SysConfig;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysConfigMapper extends BaseMapper<SysConfig> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.tailbet.mapper;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper extends BaseMapper<User> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子增加积分
|
||||||
|
*/
|
||||||
|
@Update("UPDATE user SET points = points + #{amount} WHERE id = #{id}")
|
||||||
|
int addPoints(@Param("id") Long id, @Param("amount") BigDecimal amount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子扣除积分(积分不足时返回0)
|
||||||
|
*/
|
||||||
|
@Update("UPDATE user SET points = points - #{amount} WHERE id = #{id} AND points >= #{amount}")
|
||||||
|
int subPoints(@Param("id") Long id, @Param("amount") BigDecimal amount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.tailbet.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PageDomain {
|
||||||
|
/**
|
||||||
|
* 页码
|
||||||
|
*/
|
||||||
|
private Integer pageNum = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每页显示数量
|
||||||
|
*/
|
||||||
|
private Integer pageSize = 10;
|
||||||
|
|
||||||
|
|
||||||
|
public <T> Page<T> toPage() {
|
||||||
|
return new Page<>(pageNum, pageSize);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下注请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class BetDTO {
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端消息ID(防重)
|
||||||
|
*/
|
||||||
|
private String clientMsgId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩法:单/双/大/小/数字
|
||||||
|
*/
|
||||||
|
private String playType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下注金额
|
||||||
|
*/
|
||||||
|
private BigDecimal betAmount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CreateGroupDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群名称
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "群名称不能为空")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群主用户ID
|
||||||
|
*/
|
||||||
|
@NotNull(message = "群主用户ID不能为空")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接注间隔(秒),不传则走库默认值60
|
||||||
|
*/
|
||||||
|
@Min(value = 10, message = "接注间隔不能小于10秒")
|
||||||
|
@Max(value = 3600, message = "接注间隔不能大于3600秒")
|
||||||
|
private Integer roundInterval;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏操作请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class GameOperateDTO {
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群成员角色操作请求(设置/取消管理员)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class GroupMemberOperateDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
@NotNull(message = "群ID不能为空")
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标用户ID
|
||||||
|
*/
|
||||||
|
@NotNull(message = "目标用户ID不能为空")
|
||||||
|
private Long userId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import com.tailbet.model.PageDomain;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史查询分页参数
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class HistoryQueryDTO extends PageDomain {
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class LoginDTO {
|
||||||
|
/**
|
||||||
|
* 账号
|
||||||
|
*/
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
private String password;
|
||||||
|
/**
|
||||||
|
* 平台ID
|
||||||
|
* @see com.tailbet.common.enums.PlatformEnum
|
||||||
|
* <ul>
|
||||||
|
* <li>1=iOS</li>
|
||||||
|
* <li>2=Android</li>
|
||||||
|
* <li>3=Windows</li>
|
||||||
|
* <li>4=macOS</li>
|
||||||
|
* <li>5=Web</li>
|
||||||
|
* <li>6=小程序或轻量 Web</li>
|
||||||
|
* <li>7=Linux</li>
|
||||||
|
* <li>8=Android 平板</li>
|
||||||
|
* <li>9=iPad</li>
|
||||||
|
* <li>10=管理端</li>
|
||||||
|
* <li>11=HarmonyOS</li>
|
||||||
|
* <li>12=Bot</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@NotNull(message = "platformID不能为空")
|
||||||
|
private Integer platformID;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM回调消息体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class OpenIMCallbackDTO {
|
||||||
|
/**
|
||||||
|
* 消息ID
|
||||||
|
*/
|
||||||
|
private String msgId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送者用户ID
|
||||||
|
*/
|
||||||
|
private String sendId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收者ID(群ID或用户ID)
|
||||||
|
*/
|
||||||
|
private String recvId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息内容
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息类型: text, custom, etc
|
||||||
|
*/
|
||||||
|
private String msgType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话类型: 1-单聊, 2-群聊
|
||||||
|
*/
|
||||||
|
private Integer sessionType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID(群聊时)
|
||||||
|
*/
|
||||||
|
private String groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端平台
|
||||||
|
*/
|
||||||
|
private String platform;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页请求基类
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PageParam {
|
||||||
|
/**
|
||||||
|
* 页码
|
||||||
|
*/
|
||||||
|
private Integer pageNum = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每页显示数量
|
||||||
|
*/
|
||||||
|
private Integer pageSize = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分操作请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PointsDTO {
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分数量
|
||||||
|
*/
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分流水分页查询
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class PointsFlowQueryDTO extends PageParam {
|
||||||
|
/**
|
||||||
|
* 类型(可选):ops_add/ops_sub/bet/award/rp_send/rp_recv
|
||||||
|
*/
|
||||||
|
private String type;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉群请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PullGroupDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
@NotNull(message = "用户ID不能为空")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
@NotNull(message = "群ID不能为空")
|
||||||
|
private Long groupId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领红包请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ReceiveRpDTO {
|
||||||
|
/**
|
||||||
|
* 红包ID
|
||||||
|
*/
|
||||||
|
private Long rpId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class RegisterDTO {
|
||||||
|
/**
|
||||||
|
* 账号
|
||||||
|
*/
|
||||||
|
@NotEmpty(message = "username不能为空")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
@NotEmpty(message = "password不能为空")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置尾数请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SetDigitDTO {
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标尾数(0-9)
|
||||||
|
*/
|
||||||
|
private Integer digit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UpdatePasswordDTO {
|
||||||
|
/**
|
||||||
|
* 原始密码
|
||||||
|
*/
|
||||||
|
@NotEmpty(message = "oldPassword不能为空")
|
||||||
|
private String oldPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新密码
|
||||||
|
*/
|
||||||
|
@NotEmpty(message = "newPassword不能为空")
|
||||||
|
private String newPassword;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.tailbet.model.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UpdateUserDTO {
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像URL
|
||||||
|
*/
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("audit_log")
|
||||||
|
public class AuditLog {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局ID
|
||||||
|
*/
|
||||||
|
private Long roundId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作用户ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动作类型
|
||||||
|
*/
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情JSON
|
||||||
|
*/
|
||||||
|
private String detail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注单表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("bet_order")
|
||||||
|
public class BetOrder {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局ID
|
||||||
|
*/
|
||||||
|
private Long roundId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端消息ID(防重)
|
||||||
|
*/
|
||||||
|
private String clientMsgId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩法:单/双/大/小/数字
|
||||||
|
*/
|
||||||
|
private String playType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下注金额
|
||||||
|
*/
|
||||||
|
private BigDecimal betAmount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 赔率
|
||||||
|
*/
|
||||||
|
private BigDecimal odds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否假人下注
|
||||||
|
*/
|
||||||
|
private Integer isFake;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0待结算 1已结算 2未中奖
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中奖金额
|
||||||
|
*/
|
||||||
|
private BigDecimal winAmount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾数控制白名单表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("digit_white")
|
||||||
|
public class DigitWhite {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖记录表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("draw_record")
|
||||||
|
public class DrawRecord {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 期号
|
||||||
|
*/
|
||||||
|
private String roundNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局ID
|
||||||
|
*/
|
||||||
|
private Long roundId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖尾数:0-9
|
||||||
|
*/
|
||||||
|
private Integer digit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中奖玩法:单,大,数字7
|
||||||
|
*/
|
||||||
|
private String playWins;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总下注
|
||||||
|
*/
|
||||||
|
private Integer totalBet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总派奖
|
||||||
|
*/
|
||||||
|
private Long totalAward;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 庄家利润
|
||||||
|
*/
|
||||||
|
private Long profit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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结束下注 2开始发红包 3已结束
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第一个红包ID
|
||||||
|
*/
|
||||||
|
private Long firstRpId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("g_group")
|
||||||
|
public class Group {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群名称
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM群ID
|
||||||
|
*/
|
||||||
|
private String openimGroupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群主用户ID
|
||||||
|
*/
|
||||||
|
private Long ownerUserId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接注间隔(秒)
|
||||||
|
*/
|
||||||
|
private Integer roundInterval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:1启用 0停用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群成员表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("group_member")
|
||||||
|
public class GroupMember {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色:0普通 1管理员 2群主
|
||||||
|
*/
|
||||||
|
private Integer role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime joinTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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("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 BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变动前余额
|
||||||
|
*/
|
||||||
|
private BigDecimal balanceBefore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变动后余额
|
||||||
|
*/
|
||||||
|
private BigDecimal balanceAfter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务单号
|
||||||
|
*/
|
||||||
|
private String bizNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作人ID
|
||||||
|
*/
|
||||||
|
private Long operatorId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预分配金额列表(JSON数组),发红包时写入,领取时不再修改
|
||||||
|
*/
|
||||||
|
private String shareAmounts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一个待领取份额的索引(从0开始,已领取N个则值为N)
|
||||||
|
*/
|
||||||
|
private Integer shareTakenIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包领取表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("red_packet_recv")
|
||||||
|
public class RedPacketRecv {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包ID
|
||||||
|
*/
|
||||||
|
private Long rpId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取者ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取金额
|
||||||
|
*/
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否手气王
|
||||||
|
*/
|
||||||
|
private Integer isLucky;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.tailbet.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("sys_config")
|
||||||
|
public class SysConfig {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置键
|
||||||
|
*/
|
||||||
|
private String cfgKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置值
|
||||||
|
*/
|
||||||
|
private String cfgValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户表
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分余额(与红包金额一致使用DECIMAL,避免浮点误差)
|
||||||
|
*/
|
||||||
|
private BigDecimal points;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营标记:0否 1是
|
||||||
|
*/
|
||||||
|
private Integer isOps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器人标记:0否 1是
|
||||||
|
*/
|
||||||
|
private Integer isBot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假人托标记:0否 1是
|
||||||
|
*/
|
||||||
|
private Integer isFake;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加白(尾数控制)标记
|
||||||
|
*/
|
||||||
|
private Integer isWhite;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定的运营用户ID
|
||||||
|
*/
|
||||||
|
private Long boundOpsUserId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:1正常 0封禁
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.tailbet.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾数试算结果
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class DigitTrialVo {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾数(0-9)
|
||||||
|
*/
|
||||||
|
private int digit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩法
|
||||||
|
*/
|
||||||
|
private String playWin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总投注次数
|
||||||
|
*/
|
||||||
|
private int totalBetCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总投注金额
|
||||||
|
*/
|
||||||
|
private BigDecimal totalBet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总奖励/赔付金额
|
||||||
|
*/
|
||||||
|
private BigDecimal totalAward;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净盈亏(totalAward - totalBet)
|
||||||
|
*/
|
||||||
|
private BigDecimal profit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.tailbet.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群统计信息
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class GroupStatVo {
|
||||||
|
/**
|
||||||
|
* 群ID
|
||||||
|
*/
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总局数
|
||||||
|
*/
|
||||||
|
private Integer totalRounds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进行中局数
|
||||||
|
*/
|
||||||
|
private Integer ongoingRounds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总下注人次
|
||||||
|
*/
|
||||||
|
private Integer totalBetCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总下注金额
|
||||||
|
*/
|
||||||
|
private Long totalBetAmount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总派奖金额
|
||||||
|
*/
|
||||||
|
private Long totalAwardAmount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 庄家总利润
|
||||||
|
*/
|
||||||
|
private Long totalProfit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各玩法下注统计
|
||||||
|
*/
|
||||||
|
private List<PlayTypeStat> playTypeStats;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩法统计
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class PlayTypeStat {
|
||||||
|
private String playType;
|
||||||
|
private Integer betCount;
|
||||||
|
private Integer betAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.tailbet.model.vo;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录用户信息
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class LoginUserVo {
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像
|
||||||
|
*/
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分
|
||||||
|
*/
|
||||||
|
private BigDecimal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.tailbet.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录返回
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class LoginVo {
|
||||||
|
/**
|
||||||
|
* token
|
||||||
|
*/
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM Token
|
||||||
|
*/
|
||||||
|
private String openIMToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.tailbet.model.vo;
|
||||||
|
|
||||||
|
import com.tailbet.common.enums.ErrorCodeEnum;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一返回对象
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class R<T> implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 成功 */
|
||||||
|
public static final int SUCCESS = 200;
|
||||||
|
|
||||||
|
/** 失败 */
|
||||||
|
public static final int FAIL = 500;
|
||||||
|
|
||||||
|
private int code;
|
||||||
|
private String msg;
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
public static <T> R<T> ok() {
|
||||||
|
return new R<>(SUCCESS, "操作成功", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> R<T> ok(T data) {
|
||||||
|
return new R<>(SUCCESS, "操作成功", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> R<T> ok(String msg, T data) {
|
||||||
|
return new R<>(SUCCESS, msg, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> R<T> fail() {
|
||||||
|
return new R<>(FAIL, "操作失败", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> R<T> fail(String msg) {
|
||||||
|
return new R<>(FAIL, msg, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> R<T> fail(int code, String msg) {
|
||||||
|
return new R<>(code, msg, null);
|
||||||
|
}
|
||||||
|
public static <T> R<T> fail(ErrorCodeEnum errorCode, String msg) {
|
||||||
|
return new R<>(errorCode.getCode(), msg, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return code == SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.tailbet.model.vo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户上下文 - ThreadLocal保存当前登录用户
|
||||||
|
*/
|
||||||
|
public class UserContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<LoginUserVo> USER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
public static void setUser(LoginUserVo user) {
|
||||||
|
USER.set(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LoginUserVo getUser() {
|
||||||
|
return USER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long getUserId() {
|
||||||
|
LoginUserVo user = USER.get();
|
||||||
|
return user != null ? user.getUserId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void remove() {
|
||||||
|
USER.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isOps() {
|
||||||
|
LoginUserVo user = USER.get();
|
||||||
|
return user != null && Boolean.TRUE.equals(user.getIsOps());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isBot() {
|
||||||
|
LoginUserVo user = USER.get();
|
||||||
|
return user != null && Boolean.TRUE.equals(user.getIsBot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isFake() {
|
||||||
|
LoginUserVo user = USER.get();
|
||||||
|
return user != null && Boolean.TRUE.equals(user.getIsFake());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isWhite() {
|
||||||
|
LoginUserVo user = USER.get();
|
||||||
|
return user != null && Boolean.TRUE.equals(user.getIsWhite());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.tailbet.openim;
|
||||||
|
|
||||||
|
|
||||||
|
import com.tailbet.common.enums.ErrorCodeEnum;
|
||||||
|
import com.tailbet.common.exception.BusinessException;
|
||||||
|
import com.tailbet.config.OpenIMProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM adminToken 管理器
|
||||||
|
* <p>
|
||||||
|
* 负责 OpenIM 管理员 Token 的获取与定期刷新:服务启动时通过 OpenIM
|
||||||
|
* {@code /auth/get_admin_token} 接口获取一次,之后每 23 小时自动刷新一次
|
||||||
|
* (OpenIM 管理员 Token 默认 24 小时过期,提前 1 小时刷新以规避临界失效)。
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* 本类不依赖 {@link OpenIMApiClient},避免出现
|
||||||
|
* OpenIMApiClient → OpenIMAdminTokenManager → OpenIMApiClient 的循环依赖,
|
||||||
|
* 内部直接使用专用 RestTemplate 调用 OpenIM 接口。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author socialapp团队
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class OpenIMAdminTokenManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时刷新周期:23 小时(毫秒)
|
||||||
|
*/
|
||||||
|
private static final long REFRESH_INTERVAL_MS = 23 * 60 * 60 * 1000L;
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM 集成配置属性
|
||||||
|
*/
|
||||||
|
private final OpenIMProperties openIMProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前有效的 adminToken,使用原子引用保证多线程读取的可见性与线程安全
|
||||||
|
*/
|
||||||
|
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务启动时初始化 adminToken
|
||||||
|
* <p>
|
||||||
|
* 若启动时获取失败,仅记录错误日志,不阻塞服务启动;
|
||||||
|
* 后续定时任务会持续尝试刷新,业务调用方在 adminToken 为空时会因鉴权失败而收到明确异常。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
refreshToken();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("【OpenIM】服务启动时获取 adminToken 失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每 23 小时自动刷新 adminToken(避免 24 小时过期)
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = REFRESH_INTERVAL_MS)
|
||||||
|
public void scheduledRefresh() {
|
||||||
|
try {
|
||||||
|
refreshToken();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("【OpenIM】定时刷新 adminToken 失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前有效的 adminToken
|
||||||
|
* <p>
|
||||||
|
* 若 token 尚未初始化(null 或空),则尝试立即刷新一次,防止 NPE 及空 token 导致的鉴权失败。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return adminToken 字符串,刷新失败时返回空字符串
|
||||||
|
*/
|
||||||
|
public String getAdminToken() {
|
||||||
|
String token = tokenRef.get();
|
||||||
|
if (token == null || token.isEmpty()) {
|
||||||
|
log.warn("【OpenIM】adminToken 尚未初始化,尝试立即刷新");
|
||||||
|
refreshToken();
|
||||||
|
token = tokenRef.get();
|
||||||
|
}
|
||||||
|
return token != null ? token : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 OpenIM {@code /auth/get_admin_token} 接口刷新 adminToken
|
||||||
|
* <p>
|
||||||
|
* 获取 adminToken 属于鉴权前置操作,请求本身不能携带 adminToken 请求头。
|
||||||
|
* 方法加锁,避免同进程内并发刷新互相顶掉(OpenIM 同 user 新 token 会使旧 token 变为 TokenKicked)。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public synchronized void refreshToken() {
|
||||||
|
String url = openIMProperties.resolveServerApiUrl() + "/auth/get_admin_token";
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("secret", openIMProperties.getSecret());
|
||||||
|
body.put("userID", openIMProperties.getAdminUserId());
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.set("operationID", UUID.randomUUID().toString());
|
||||||
|
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
|
OpenIMApiClient.OpenIMBaseResp resp;
|
||||||
|
try {
|
||||||
|
ResponseEntity<OpenIMApiClient.OpenIMBaseResp> response =
|
||||||
|
restTemplate.postForEntity(url, entity, OpenIMApiClient.OpenIMBaseResp.class);
|
||||||
|
resp = response.getBody();
|
||||||
|
} catch (org.springframework.web.client.HttpStatusCodeException e) {
|
||||||
|
// OpenIM 偶发以 HTTP 4xx + 业务 JSON 返回,尽量解析 errMsg
|
||||||
|
String raw = e.getResponseBodyAsString();
|
||||||
|
log.error("【OpenIM】获取 adminToken HTTP 失败: status={}, body={}", e.getStatusCode().value(), raw);
|
||||||
|
throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED,
|
||||||
|
raw != null && !raw.isBlank() ? raw : e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp == null || resp.getErrCode() != 0) {
|
||||||
|
String errMsg = resp != null ? resp.getErrMsg() : "响应为空";
|
||||||
|
log.error("【OpenIM】获取 adminToken 失败: errMsg={}", errMsg);
|
||||||
|
throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> data = (Map<String, Object>) resp.getData();
|
||||||
|
String newToken = data != null ? (String) data.get("token") : null;
|
||||||
|
if (newToken == null) {
|
||||||
|
throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, "响应中缺少 token 字段");
|
||||||
|
}
|
||||||
|
tokenRef.set(newToken);
|
||||||
|
log.info("【OpenIM】adminToken 刷新成功: token={}", maskString(newToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对敏感字符串进行脱敏处理
|
||||||
|
* 只显示前3位和后3位,中间用星号代替
|
||||||
|
*
|
||||||
|
* @param str 原始字符串
|
||||||
|
* @return 脱敏后的字符串
|
||||||
|
*/
|
||||||
|
private String maskString(String str) {
|
||||||
|
if (str == null || str.length() <= 6) {
|
||||||
|
return "***";
|
||||||
|
}
|
||||||
|
return str.substring(0, 3) + "****" + str.substring(str.length() - 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
package com.tailbet.openim;
|
||||||
|
|
||||||
|
import com.tailbet.model.dto.OpenIMCallbackDTO;
|
||||||
|
import com.tailbet.model.vo.R;
|
||||||
|
import com.tailbet.service.IBetService;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import com.tailbet.service.IRedPacketService;
|
||||||
|
import com.tailbet.service.IGroupService;
|
||||||
|
import com.tailbet.openim.OpenIMApiClient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenIM回调接口
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/openim/callback")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OpenIMCallbackController {
|
||||||
|
|
||||||
|
private final IBetService betService;
|
||||||
|
private final IGameService gameService;
|
||||||
|
private final IRedPacketService redPacketService;
|
||||||
|
private final IGroupService groupService;
|
||||||
|
private final OpenIMApiClient openIMApiClient;
|
||||||
|
private final com.tailbet.config.OpenIMProperties openIMProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收群消息回调
|
||||||
|
*/
|
||||||
|
@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];
|
||||||
|
BigDecimal betAmount;
|
||||||
|
try {
|
||||||
|
betAmount = new BigDecimal(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 %s", playType, betAmount.toPlainString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
sendGroupMessage(groupId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理统计指令
|
||||||
|
*/
|
||||||
|
private void handleStatCommand(String groupId) {
|
||||||
|
try {
|
||||||
|
Long bizGroupId = getBizGroupId(groupId);
|
||||||
|
if (bizGroupId == null) {
|
||||||
|
sendGroupMessage(groupId, "群未配置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stat = gameService.getGroupStat(bizGroupId);
|
||||||
|
String message = String.format(
|
||||||
|
"【本群统计】\n总局数: %d\n进行中: %d\n总下注人次: %d\n总下注金额: %d\n总派奖金额: %d\n庄家利润: %d",
|
||||||
|
stat.getTotalRounds(), stat.getOngoingRounds(),
|
||||||
|
stat.getTotalBetCount(), stat.getTotalBetAmount(),
|
||||||
|
stat.getTotalAwardAmount(), stat.getTotalProfit()
|
||||||
|
);
|
||||||
|
sendGroupMessage(groupId, message);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理统计指令失败: {}", e.getMessage());
|
||||||
|
sendGroupMessage(groupId, "统计查询失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送群消息
|
||||||
|
*/
|
||||||
|
private void sendGroupMessage(String openimGroupId, String message) {
|
||||||
|
try {
|
||||||
|
openIMApiClient.sendMsg(
|
||||||
|
openIMProperties.getAdminUserId(),
|
||||||
|
openimGroupId,
|
||||||
|
3, // sessionType 3=群聊
|
||||||
|
101, // contentType 101=文本
|
||||||
|
java.util.Map.of("content", 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) {
|
||||||
|
// 通过GroupService查询映射
|
||||||
|
var group = groupService.getByOpenimGroupId(openimGroupId);
|
||||||
|
return group != null ? group.getId() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.AuditLog;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志Service接口
|
||||||
|
*/
|
||||||
|
public interface IAuditService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录审计日志
|
||||||
|
*/
|
||||||
|
void log(Long groupId, Long roundId, Long userId, String action, String detail);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询审计日志
|
||||||
|
*/
|
||||||
|
Page<AuditLog> getPage(Long groupId, Long roundId, String action,
|
||||||
|
Integer pageNum, Integer pageSize);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.BetOrder;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注单Service接口
|
||||||
|
*/
|
||||||
|
public interface IBetService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下注
|
||||||
|
*/
|
||||||
|
BetOrder placeBet(Long userId, Long groupId, String clientMsgId,
|
||||||
|
String playType, BigDecimal betAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取赔率
|
||||||
|
*/
|
||||||
|
BigDecimal getOdds(String playType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户本局下注总额
|
||||||
|
*/
|
||||||
|
BigDecimal getUserRoundBetAmount(Long userId, Long roundId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假人服务接口
|
||||||
|
*/
|
||||||
|
public interface IFakeUserService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化群假人
|
||||||
|
*/
|
||||||
|
void initGroupFakeUsers(Long groupId, int minCount, int maxCount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建单个假人
|
||||||
|
*/
|
||||||
|
User createFakeUser(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日重置活跃假人
|
||||||
|
*/
|
||||||
|
void resetDailyActiveFakes();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是今日活跃假人
|
||||||
|
*/
|
||||||
|
boolean isTodayActiveFake(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假人下注
|
||||||
|
*/
|
||||||
|
void fakeBet(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取假人列表
|
||||||
|
*/
|
||||||
|
List<User> getFakeUsers();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从现有假人中随机挑选指定数量
|
||||||
|
*/
|
||||||
|
List<User> pickFakeUsers(int count);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.GameRound;
|
||||||
|
import com.tailbet.model.entity.DrawRecord;
|
||||||
|
import com.tailbet.model.entity.BetOrder;
|
||||||
|
import com.tailbet.model.vo.GroupStatVo;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.tailbet.model.vo.DigitTrialVo;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏Service接口
|
||||||
|
*/
|
||||||
|
public interface IGameService {
|
||||||
|
|
||||||
|
// 局状态常量
|
||||||
|
int STATUS_BETTING = 0; // 开始下注
|
||||||
|
int STATUS_BET_CLOSED = 1; // 结束下注,等待发红包
|
||||||
|
int STATUS_RP_SENDING = 2; // 开始发红包,等待红包领完
|
||||||
|
int STATUS_FINISHED = 3; // 已结束
|
||||||
|
|
||||||
|
// 向后兼容别名
|
||||||
|
int STATUS_ONGOING = STATUS_BETTING;
|
||||||
|
int STATUS_ENDED = STATUS_FINISHED;
|
||||||
|
|
||||||
|
// 玩法赔率
|
||||||
|
BigDecimal ODDS_SINGLE_DOUBLE = new BigDecimal("1");
|
||||||
|
BigDecimal ODDS_SIZE = new BigDecimal("1");
|
||||||
|
BigDecimal ODDS_DIGIT = new BigDecimal("2");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前进行中的局
|
||||||
|
*/
|
||||||
|
GameRound getOngoingRound(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前正在下注的局
|
||||||
|
*/
|
||||||
|
GameRound getBettingRound(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前正在发红包的局
|
||||||
|
*/
|
||||||
|
GameRound getRpSendingRound(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前可发红包的局(已封盘或正在发红包中)
|
||||||
|
*/
|
||||||
|
GameRound getActiveSendRound(Long groupId);
|
||||||
|
/**
|
||||||
|
* 获取群当前结束下注的局
|
||||||
|
*/
|
||||||
|
GameRound getBetEndRound(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<DigitTrialVo> trialDigit(Long groupId, Long roundId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定是否中奖
|
||||||
|
*/
|
||||||
|
boolean isWin(BetOrder bet, int digit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取中奖玩法描述
|
||||||
|
*/
|
||||||
|
String getPlayWinDesc(int digit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖结算
|
||||||
|
*/
|
||||||
|
void settle(Long roundId, int digit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取开奖历史
|
||||||
|
*/
|
||||||
|
Page<DrawRecord> getHistory(Long groupId, Integer pageNum, Integer pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群统计信息
|
||||||
|
*/
|
||||||
|
GroupStatVo getGroupStat(Long groupId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.Group;
|
||||||
|
import com.tailbet.model.entity.GroupMember;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群Service接口
|
||||||
|
*/
|
||||||
|
public interface IGroupService {
|
||||||
|
|
||||||
|
// 角色
|
||||||
|
int ROLE_NORMAL = 0;
|
||||||
|
int ROLE_ADMIN = 1;
|
||||||
|
int ROLE_OWNER = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群信息
|
||||||
|
*/
|
||||||
|
Group getById(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的群列表
|
||||||
|
*/
|
||||||
|
List<Group> getUserGroups(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群(群ID自增生成,群主为传入用户,并自动把机器人拉入群)
|
||||||
|
* <p>机器人取 sys_config 中 cfg_key=robot_id 的配置值</p>
|
||||||
|
*
|
||||||
|
* @param name 群名称
|
||||||
|
* @param ownerUserId 群主用户ID
|
||||||
|
* @param roundInterval 接注间隔(秒),可为null走库默认值
|
||||||
|
* @param operatorId 操作人用户ID
|
||||||
|
* @return 创建出的群
|
||||||
|
*/
|
||||||
|
Group createGroup( String name, Long ownerUserId, Integer roundInterval, Long operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉用户入群
|
||||||
|
*/
|
||||||
|
void pullUser(Long groupId, Long userId, Long operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 踢出用户
|
||||||
|
*/
|
||||||
|
void kickUser(Long groupId, Long targetUserId, Long operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置管理员
|
||||||
|
*/
|
||||||
|
void setAdmin(Long groupId, Long targetUserId, Long operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消管理员
|
||||||
|
*/
|
||||||
|
void removeAdmin(Long groupId, Long targetUserId, Long operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群成员
|
||||||
|
*/
|
||||||
|
List<GroupMember> getMembers(Long groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是群主
|
||||||
|
*/
|
||||||
|
boolean isOwner(Long groupId, Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是管理员
|
||||||
|
*/
|
||||||
|
boolean isAdmin(Long groupId, Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据OpenIM群ID获取群信息
|
||||||
|
*/
|
||||||
|
Group getByOpenimGroupId(String openimGroupId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.PointsFlow;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分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, BigDecimal amount, String type, String bizNo, Long operatorId, String remark);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扣除积分
|
||||||
|
*/
|
||||||
|
void subPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户积分
|
||||||
|
*/
|
||||||
|
BigDecimal getPoints(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询积分流水记录
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param type 类型(可选,空表示全部)
|
||||||
|
* @param pageNum 页码
|
||||||
|
* @param pageSize 每页大小
|
||||||
|
*/
|
||||||
|
Page<PointsFlow> getFlowPage(Long userId, String type, Integer pageNum, Integer pageSize);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送服务接口
|
||||||
|
*/
|
||||||
|
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"; // 开奖结果
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送消息
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
class PushMessage {
|
||||||
|
private String type;
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private Long userId;
|
||||||
|
private Long groupId;
|
||||||
|
private Long roundId;
|
||||||
|
private String roundNo;
|
||||||
|
private Integer digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送尾数试算完成通知给白名单用户
|
||||||
|
*/
|
||||||
|
void pushDigitTrialComplete(Long groupId, Long roundId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送游戏开始通知
|
||||||
|
*/
|
||||||
|
void pushGameStart(Long groupId, String roundNo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送开奖结果通知
|
||||||
|
*/
|
||||||
|
void pushGameResult(Long groupId, String roundNo, int digit, String playWins);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送单用户推送
|
||||||
|
*/
|
||||||
|
void sendPush(PushMessage message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播推送消息给群成员
|
||||||
|
*/
|
||||||
|
void broadcastToGroup(Long groupId, PushMessage message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.RedPacket;
|
||||||
|
import com.tailbet.model.entity.RedPacketRecv;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包Service接口
|
||||||
|
*/
|
||||||
|
public interface IRedPacketService {
|
||||||
|
|
||||||
|
// 红包状态
|
||||||
|
int STATUS_ONGOING = 0;
|
||||||
|
int STATUS_FINISHED = 1;
|
||||||
|
int STATUS_REFUND = 2;
|
||||||
|
|
||||||
|
// 红包类型
|
||||||
|
String TYPE_GAME = "game";
|
||||||
|
String TYPE_RANDOM = "random";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发红包
|
||||||
|
*/
|
||||||
|
RedPacket sendRp(Long userId, Long groupId, BigDecimal totalAmount,
|
||||||
|
Integer totalCount, Long roundId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领红包
|
||||||
|
*/
|
||||||
|
RedPacketRecv receiveRp(Long rpId, Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算领取金额
|
||||||
|
*/
|
||||||
|
BigDecimal calculateReceiveAmount(RedPacket rp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红包详情
|
||||||
|
*/
|
||||||
|
RedPacket getById(Long rpId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红包领取列表
|
||||||
|
*/
|
||||||
|
List<RedPacketRecv> getReceives(Long rpId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询红包记录
|
||||||
|
*/
|
||||||
|
Page<RedPacket> getRecords(Long userId, Integer pageNum, Integer pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询已收红包记录
|
||||||
|
*/
|
||||||
|
Page<RedPacketRecv> getReceivedRecords(Long userId, Integer pageNum, Integer pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找进行中的游戏红包
|
||||||
|
*/
|
||||||
|
RedPacket getOngoingGameRp(Long groupId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.tailbet.service;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.model.dto.UpdateUserDTO;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户Service接口
|
||||||
|
*/
|
||||||
|
public interface IUserService extends IService<User> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户名查询用户
|
||||||
|
*/
|
||||||
|
User getByUsername(String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册用户
|
||||||
|
*/
|
||||||
|
User register(String username, String password);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
Long login(String username, String password);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户
|
||||||
|
*/
|
||||||
|
User getCurrentUser();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
*/
|
||||||
|
void updateUserInfo(Long userId, UpdateUserDTO dto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
void changePassword(Long userId, String oldPassword, String newPassword);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.tailbet.service.impl;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.AuditLog;
|
||||||
|
import com.tailbet.mapper.AuditLogMapper;
|
||||||
|
import com.tailbet.service.IAuditService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志Service实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class AuditServiceImpl implements IAuditService {
|
||||||
|
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录审计日志
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void log(Long groupId, Long roundId, Long userId, String action, String detail) {
|
||||||
|
AuditLog auditLog = new AuditLog();
|
||||||
|
auditLog.setGroupId(groupId);
|
||||||
|
auditLog.setRoundId(roundId);
|
||||||
|
auditLog.setUserId(userId);
|
||||||
|
auditLog.setAction(action);
|
||||||
|
auditLog.setDetail(detail);
|
||||||
|
auditLog.setCreateTime(LocalDateTime.now());
|
||||||
|
auditLogMapper.insert(auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询审计日志
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Page<AuditLog> getPage(Long groupId, Long roundId, String action,
|
||||||
|
Integer pageNum, Integer pageSize) {
|
||||||
|
Page<AuditLog> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<AuditLog> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (groupId != null) {
|
||||||
|
wrapper.eq(AuditLog::getGroupId, groupId);
|
||||||
|
}
|
||||||
|
if (roundId != null) {
|
||||||
|
wrapper.eq(AuditLog::getRoundId, roundId);
|
||||||
|
}
|
||||||
|
if (action != null) {
|
||||||
|
wrapper.eq(AuditLog::getAction, action);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(AuditLog::getCreateTime);
|
||||||
|
return auditLogMapper.selectPage(page, wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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, BigDecimal betAmount) {
|
||||||
|
// 防重检查
|
||||||
|
if (clientMsgId != null) {
|
||||||
|
BetOrder existing = betOrderMapper.selectOne(new LambdaQueryWrapper<BetOrder>()
|
||||||
|
.eq(BetOrder::getClientMsgId, clientMsgId));
|
||||||
|
if (existing != null) {
|
||||||
|
throw new RuntimeException("重复下注");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("用户不存在");
|
||||||
|
}
|
||||||
|
if (user.getIsFake() != null && user.getIsFake() == 1) {
|
||||||
|
// 假人下注只记录审计日志,不产生订单
|
||||||
|
auditService.log(groupId, null, userId, "FAKE_BET",
|
||||||
|
String.format("{\"playType\":\"%s\",\"amount\":%s}", playType, betAmount.toPlainString()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查进行中的局(下注中)
|
||||||
|
GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.eq(GameRound::getStatus, IGameService.STATUS_BETTING));
|
||||||
|
if (round == null) {
|
||||||
|
throw new RuntimeException("当前不在下注阶段");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查限额(必须为正整数,避免小数下注带来的精度问题)
|
||||||
|
if (betAmount == null || betAmount.signum() <= 0
|
||||||
|
|| betAmount.compareTo(BigDecimal.valueOf(2000)) > 0) {
|
||||||
|
throw new RuntimeException("单笔下注金额需在1-2000之间");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查余额
|
||||||
|
BigDecimal points = user.getPoints() == null ? BigDecimal.ZERO : user.getPoints();
|
||||||
|
if (points.compareTo(betAmount) < 0) {
|
||||||
|
throw new RuntimeException("余额不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查单局累计
|
||||||
|
BigDecimal roundTotal = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||||
|
.eq(BetOrder::getUserId, userId)
|
||||||
|
.eq(BetOrder::getRoundId, round.getId())
|
||||||
|
.eq(BetOrder::getIsFake, 0))
|
||||||
|
.stream()
|
||||||
|
.map(BetOrder::getBetAmount)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
if (roundTotal.add(betAmount).compareTo(BigDecimal.valueOf(20000)) > 0) {
|
||||||
|
throw new RuntimeException("单局累计下注不能超过20000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取赔率
|
||||||
|
BigDecimal odds = getOdds(playType);
|
||||||
|
|
||||||
|
// 扣减积分
|
||||||
|
pointsService.subPoints(userId, betAmount,
|
||||||
|
IPointsService.TYPE_BET, null, null, "游戏下注");
|
||||||
|
|
||||||
|
|
||||||
|
// 创建注单
|
||||||
|
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\":%s}",
|
||||||
|
bet.getId(), playType, betAmount.toPlainString()));
|
||||||
|
|
||||||
|
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 BigDecimal getUserRoundBetAmount(Long userId, Long roundId) {
|
||||||
|
return betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||||
|
.eq(BetOrder::getUserId, userId)
|
||||||
|
.eq(BetOrder::getRoundId, roundId)
|
||||||
|
.eq(BetOrder::getIsFake, 0))
|
||||||
|
.stream()
|
||||||
|
.map(BetOrder::getBetAmount)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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.math.BigDecimal;
|
||||||
|
|
||||||
|
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(BigDecimal.valueOf(1000 + RANDOM.nextInt(49000)));
|
||||||
|
fake.setIsFake(1);
|
||||||
|
fake.setIsBot(0);
|
||||||
|
fake.setIsOps(0);
|
||||||
|
fake.setIsWhite(0);
|
||||||
|
fake.setStatus(1);
|
||||||
|
userMapper.insert(fake);
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机昵称
|
||||||
|
*/
|
||||||
|
private String generateNickname() {
|
||||||
|
String prefix = NICKNAME_PREFIXES[RANDOM.nextInt(NICKNAME_PREFIXES.length)];
|
||||||
|
String suffix = NICKNAME_SUFFIXES[RANDOM.nextInt(NICKNAME_SUFFIXES.length)];
|
||||||
|
int number = RANDOM.nextInt(1000);
|
||||||
|
return prefix + suffix + number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日重置活跃假人
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Scheduled(cron = "0 0 0 * * ?")
|
||||||
|
public void resetDailyActiveFakes() {
|
||||||
|
// 清理昨天的活跃标记
|
||||||
|
Set<String> keys = redisTemplate.opsForSet().members(FAKE_ACTIVE_KEY + "yesterday");
|
||||||
|
if (keys != null) {
|
||||||
|
for (String userId : keys) {
|
||||||
|
redisTemplate.opsForSet().remove(FAKE_ACTIVE_KEY + "yesterday", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询所有假人
|
||||||
|
List<User> fakes = userMapper.selectList(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getIsFake, 1)
|
||||||
|
.eq(User::getStatus, 1));
|
||||||
|
|
||||||
|
if (fakes.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机选15-20个作为今日活跃
|
||||||
|
int activeCount = 15 + RANDOM.nextInt(6);
|
||||||
|
activeCount = Math.min(activeCount, fakes.size());
|
||||||
|
|
||||||
|
Collections.shuffle(fakes);
|
||||||
|
List<String> activeUserIds = new ArrayList<>();
|
||||||
|
for (int i = 0; i < activeCount; i++) {
|
||||||
|
activeUserIds.add(String.valueOf(fakes.get(i).getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存入Redis
|
||||||
|
String today = java.time.LocalDate.now().toString();
|
||||||
|
redisTemplate.opsForSet().add(FAKE_ACTIVE_KEY + today, activeUserIds.toArray(new String[0]));
|
||||||
|
|
||||||
|
log.info("今日活跃假人已更新: {}个", activeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是今日活跃假人
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isTodayActiveFake(Long userId) {
|
||||||
|
String today = java.time.LocalDate.now().toString();
|
||||||
|
Boolean isMember = redisTemplate.opsForSet().isMember(FAKE_ACTIVE_KEY + today, String.valueOf(userId));
|
||||||
|
return Boolean.TRUE.equals(isMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 假人下注
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void fakeBet(Long groupId) {
|
||||||
|
// 获取今日活跃假人
|
||||||
|
String today = java.time.LocalDate.now().toString();
|
||||||
|
Set<String> activeFakes = redisTemplate.opsForSet().members(FAKE_ACTIVE_KEY + today);
|
||||||
|
if (activeFakes == null || activeFakes.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机选一个假人
|
||||||
|
List<String> fakeList = new ArrayList<>(activeFakes);
|
||||||
|
String fakeIdStr = fakeList.get(RANDOM.nextInt(fakeList.size()));
|
||||||
|
Long fakeId = Long.parseLong(fakeIdStr);
|
||||||
|
|
||||||
|
// 检查是否有下注中的局
|
||||||
|
GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.eq(GameRound::getStatus, com.tailbet.service.IGameService.STATUS_BETTING));
|
||||||
|
|
||||||
|
if (round == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机玩法和金额
|
||||||
|
String[] playTypes = {"单", "双", "大", "小"};
|
||||||
|
String playType = playTypes[RANDOM.nextInt(playTypes.length)];
|
||||||
|
BigDecimal amount = BigDecimal.valueOf((1 + RANDOM.nextInt(10)) * 10L); // 10,20,30...100
|
||||||
|
|
||||||
|
try {
|
||||||
|
betService.placeBet(fakeId, groupId, "fake_" + UUID.randomUUID(), playType, amount);
|
||||||
|
log.debug("假人{}下注: {} {}", fakeId, playType, amount.toPlainString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("假人下注失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取假人列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<User> getFakeUsers() {
|
||||||
|
return userMapper.selectList(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getIsFake, 1)
|
||||||
|
.eq(User::getStatus, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从现有假人中随机挑选指定数量
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<User> pickFakeUsers(int count) {
|
||||||
|
List<User> fakes = getFakeUsers();
|
||||||
|
if (fakes.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Collections.shuffle(fakes, RANDOM);
|
||||||
|
int size = Math.min(count, fakes.size());
|
||||||
|
return new ArrayList<>(fakes.subList(0, size));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package com.tailbet.service.impl;
|
||||||
|
|
||||||
|
|
||||||
|
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.model.entity.BetOrder;
|
||||||
|
import com.tailbet.model.entity.DrawRecord;
|
||||||
|
import com.tailbet.model.entity.GameRound;
|
||||||
|
import com.tailbet.model.entity.Group;
|
||||||
|
import com.tailbet.model.vo.DigitTrialVo;
|
||||||
|
import com.tailbet.model.vo.GroupStatVo;
|
||||||
|
import com.tailbet.service.IGameService;
|
||||||
|
import com.tailbet.service.IPointsService;
|
||||||
|
import com.tailbet.service.IAuditService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏Service实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class GameServiceImpl implements IGameService {
|
||||||
|
|
||||||
|
private final GameRoundMapper gameRoundMapper;
|
||||||
|
private final DrawRecordMapper drawRecordMapper;
|
||||||
|
private final BetOrderMapper betOrderMapper;
|
||||||
|
private final GroupMapper groupMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final IPointsService pointsService;
|
||||||
|
private final IAuditService auditService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前未结束的局(包含下注中、已封盘、发红包中)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public GameRound getOngoingRound(Long groupId) {
|
||||||
|
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.lt(GameRound::getStatus, STATUS_FINISHED)
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前正在下注中的局
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public GameRound getBettingRound(Long groupId) {
|
||||||
|
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.eq(GameRound::getStatus, STATUS_BETTING)
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前正在发红包的局
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public GameRound getRpSendingRound(Long groupId) {
|
||||||
|
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.eq(GameRound::getStatus, STATUS_RP_SENDING)
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群当前可发红包的局(已封盘或正在发红包中)
|
||||||
|
* 改尾数阶段已封盘也允许玩家发红包(普通游戏红包,无尾数控制)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public GameRound getActiveSendRound(Long groupId) {
|
||||||
|
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.in(GameRound::getStatus, java.util.Arrays.asList(STATUS_BET_CLOSED, STATUS_RP_SENDING))
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GameRound getBetEndRound(Long groupId) {
|
||||||
|
return gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.eq(GameRound::getStatus, STATUS_BET_CLOSED)
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1")); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成期号
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String generateRoundNo(Long groupId) {
|
||||||
|
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||||
|
// 查询当日该群最后一期
|
||||||
|
LambdaQueryWrapper<GameRound> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(GameRound::getGroupId, groupId)
|
||||||
|
.likeRight(GameRound::getRoundNo, date)
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1");
|
||||||
|
|
||||||
|
GameRound lastRound = gameRoundMapper.selectOne(wrapper);
|
||||||
|
int seq = 1;
|
||||||
|
if (lastRound != null) {
|
||||||
|
String lastNo = lastRound.getRoundNo();
|
||||||
|
String seqStr = lastNo.substring(lastNo.lastIndexOf("-") + 1);
|
||||||
|
seq = Integer.parseInt(seqStr) + 1;
|
||||||
|
}
|
||||||
|
return date + "-" + String.format("%02d", seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始游戏(群主操作或系统自动,userId=0L 表示系统)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public GameRound startGame(Long groupId, Long userId) {
|
||||||
|
Group group = groupMapper.selectById(groupId);
|
||||||
|
if (group == null) {
|
||||||
|
throw new RuntimeException("群组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验群主权限(系统调用时 userId=0L 跳过校验)
|
||||||
|
if (!Long.valueOf(0L).equals(userId) && !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_BETTING);
|
||||||
|
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 = getBettingRound(groupId);
|
||||||
|
if (round == null) {
|
||||||
|
throw new RuntimeException("当前没有下注中的局");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验群主权限(系统调用时userId=0L跳过校验)
|
||||||
|
if (!Long.valueOf(0L).equals(userId)) {
|
||||||
|
Group group = groupMapper.selectById(groupId);
|
||||||
|
if (!group.getOwnerUserId().equals(userId)) {
|
||||||
|
throw new RuntimeException("只有群主可以结束接注");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
round.setStatus(STATUS_BET_CLOSED);
|
||||||
|
round.setEndBetTime(LocalDateTime.now());
|
||||||
|
gameRoundMapper.updateById(round);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(groupId, round.getId(), userId, "GAME_END_BET", "{}");
|
||||||
|
|
||||||
|
return round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止自动连开
|
||||||
|
* 直接修改最新一局(不限状态)——autoNext 是"本局结算后是否自动开下一期"的开关,
|
||||||
|
* 即使本局已结算但下一期还未自动开出的窗口期内也要能关掉
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void stopAutoNext(Long groupId, Long userId) {
|
||||||
|
GameRound round = gameRoundMapper.selectOne(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId)
|
||||||
|
.orderByDesc(GameRound::getCreateTime)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
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 = getBetEndRound(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<DigitTrialVo> trialDigit(Long groupId, Long roundId) {
|
||||||
|
GameRound round;
|
||||||
|
if (roundId != null) {
|
||||||
|
round = gameRoundMapper.selectById(roundId);
|
||||||
|
} else {
|
||||||
|
round = getOngoingRound(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round == null) {
|
||||||
|
throw new RuntimeException("没有进行中的局");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本局所有注单
|
||||||
|
List<BetOrder> bets = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||||
|
.eq(BetOrder::getRoundId, round.getId())
|
||||||
|
.eq(BetOrder::getIsFake, 0));
|
||||||
|
|
||||||
|
// 计算0-9各尾数的庄家利润
|
||||||
|
java.util.List<DigitTrialVo> result = new java.util.ArrayList<>();
|
||||||
|
for (int d = 0; d <= 9; d++) {
|
||||||
|
DigitTrialVo trial = new DigitTrialVo();
|
||||||
|
trial.setDigit(d);
|
||||||
|
trial.setPlayWin(getPlayWinDesc(d));
|
||||||
|
|
||||||
|
BigDecimal totalBet = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalAward = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (BetOrder bet : bets) {
|
||||||
|
totalBet = totalBet.add(nullToZero(bet.getBetAmount()));
|
||||||
|
if (isWin(bet, d)) {
|
||||||
|
totalAward = totalAward.add(nullToZero(bet.getBetAmount())
|
||||||
|
.multiply(BigDecimal.ONE.add(nullToZero(bet.getOdds()))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trial.setTotalBet(totalBet);
|
||||||
|
trial.setTotalAward(totalAward);
|
||||||
|
trial.setProfit(totalBet.subtract(totalAward));
|
||||||
|
result.add(trial);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定是否中奖
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isWin(BetOrder bet, int digit) {
|
||||||
|
String playType = bet.getPlayType();
|
||||||
|
if ("单".equals(playType)) {
|
||||||
|
return digit % 2 == 1; // 奇数为单
|
||||||
|
} else if ("双".equals(playType)) {
|
||||||
|
return digit % 2 == 0; // 偶数为双(含0)
|
||||||
|
} else if ("大".equals(playType)) {
|
||||||
|
return digit >= 5;
|
||||||
|
} else if ("小".equals(playType)) {
|
||||||
|
return digit < 5;
|
||||||
|
} else if (playType.matches("[0-9]")) {
|
||||||
|
return Integer.parseInt(playType) == digit;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取中奖玩法描述
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getPlayWinDesc(int digit) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(digit % 2 == 1 ? "单" : "双");
|
||||||
|
sb.append("/");
|
||||||
|
sb.append(digit >= 5 ? "大" : "小");
|
||||||
|
sb.append("/");
|
||||||
|
sb.append("数字").append(digit);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开奖结算
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void settle(Long roundId, int digit) {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round == null) {
|
||||||
|
throw new RuntimeException("局不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本局所有注单
|
||||||
|
List<BetOrder> bets = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||||
|
.eq(BetOrder::getRoundId, roundId)
|
||||||
|
.eq(BetOrder::getStatus, 0)); // 待结算
|
||||||
|
|
||||||
|
BigDecimal totalBet = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalAward = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (BetOrder bet : bets) {
|
||||||
|
totalBet = totalBet.add(nullToZero(bet.getBetAmount()));
|
||||||
|
|
||||||
|
if (isWin(bet, digit)) {
|
||||||
|
// 中奖,派奖(用BigDecimal精确计算,避免double转换误差)
|
||||||
|
BigDecimal winAmount = nullToZero(bet.getBetAmount())
|
||||||
|
.multiply(BigDecimal.ONE.add(nullToZero(bet.getOdds())));
|
||||||
|
bet.setStatus(1); // 已结算
|
||||||
|
bet.setWinAmount(winAmount);
|
||||||
|
betOrderMapper.updateById(bet);
|
||||||
|
|
||||||
|
// 给用户加积分
|
||||||
|
pointsService.addPoints(bet.getUserId(), winAmount, IPointsService.TYPE_AWARD,
|
||||||
|
String.valueOf(bet.getId()), null, "游戏中奖");
|
||||||
|
|
||||||
|
totalAward = totalAward.add(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(totalBet.intValue());
|
||||||
|
record.setTotalAward(totalAward.longValue());
|
||||||
|
record.setProfit(totalBet.subtract(totalAward).longValue());
|
||||||
|
record.setCreateTime(LocalDateTime.now());
|
||||||
|
drawRecordMapper.insert(record);
|
||||||
|
|
||||||
|
// 更新局状态
|
||||||
|
round.setFinishTime(LocalDateTime.now());
|
||||||
|
round.setStatus(STATUS_FINISHED);
|
||||||
|
gameRoundMapper.updateById(round);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(round.getGroupId(), roundId, null, "SETTLE",
|
||||||
|
String.format("{\"digit\":%d,\"totalBet\":%s,\"totalAward\":%s,\"profit\":%s}",
|
||||||
|
digit, totalBet.toPlainString(), totalAward.toPlainString(),
|
||||||
|
totalBet.subtract(totalAward).toPlainString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取开奖历史
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Page<DrawRecord> getHistory(Long groupId, Integer pageNum, Integer pageSize) {
|
||||||
|
Page<DrawRecord> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<DrawRecord> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(DrawRecord::getGroupId, groupId)
|
||||||
|
.orderByDesc(DrawRecord::getCreateTime);
|
||||||
|
return drawRecordMapper.selectPage(page, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群统计信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public GroupStatVo getGroupStat(Long groupId) {
|
||||||
|
GroupStatVo stat = new GroupStatVo();
|
||||||
|
stat.setGroupId(groupId);
|
||||||
|
|
||||||
|
// 统计局数
|
||||||
|
List<GameRound> allRounds = gameRoundMapper.selectList(new LambdaQueryWrapper<GameRound>()
|
||||||
|
.eq(GameRound::getGroupId, groupId));
|
||||||
|
stat.setTotalRounds(allRounds.size());
|
||||||
|
stat.setOngoingRounds((int) allRounds.stream().filter(r -> r.getStatus() != null && r.getStatus() < STATUS_FINISHED).count());
|
||||||
|
|
||||||
|
// 统计注单
|
||||||
|
List<BetOrder> allBets = betOrderMapper.selectList(new LambdaQueryWrapper<BetOrder>()
|
||||||
|
.eq(BetOrder::getGroupId, groupId)
|
||||||
|
.eq(BetOrder::getIsFake, 0));
|
||||||
|
stat.setTotalBetCount(allBets.size());
|
||||||
|
stat.setTotalBetAmount(allBets.stream().map(b -> b.getBetAmount() == null ? 0L : b.getBetAmount().longValue()).mapToLong(Long::longValue).sum());
|
||||||
|
|
||||||
|
// 统计派奖
|
||||||
|
List<DrawRecord> records = drawRecordMapper.selectList(new LambdaQueryWrapper<DrawRecord>()
|
||||||
|
.eq(DrawRecord::getGroupId, groupId));
|
||||||
|
stat.setTotalAwardAmount(records.stream().mapToLong(DrawRecord::getTotalAward).sum());
|
||||||
|
stat.setTotalProfit(records.stream().mapToLong(DrawRecord::getProfit).sum());
|
||||||
|
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BigDecimal空值防御:null视为0
|
||||||
|
*/
|
||||||
|
private static BigDecimal nullToZero(BigDecimal v) {
|
||||||
|
return v == null ? BigDecimal.ZERO : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package com.tailbet.service.impl;
|
||||||
|
|
||||||
|
import com.tailbet.model.entity.Group;
|
||||||
|
import com.tailbet.model.entity.GroupMember;
|
||||||
|
import com.tailbet.model.entity.SysConfig;
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.mapper.GroupMapper;
|
||||||
|
import com.tailbet.mapper.GroupMemberMapper;
|
||||||
|
import com.tailbet.mapper.SysConfigMapper;
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.openim.OpenIMApiClient;
|
||||||
|
import com.tailbet.service.IGroupService;
|
||||||
|
import com.tailbet.service.IAuditService;
|
||||||
|
import com.tailbet.service.IFakeUserService;
|
||||||
|
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 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置键:机器人用户ID
|
||||||
|
*/
|
||||||
|
private static final String CFG_KEY_ROBOT_ID = "robot_id";
|
||||||
|
|
||||||
|
private final GroupMapper groupMapper;
|
||||||
|
private final GroupMemberMapper groupMemberMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final SysConfigMapper sysConfigMapper;
|
||||||
|
private final IAuditService auditService;
|
||||||
|
private final IFakeUserService fakeUserService;
|
||||||
|
private final OpenIMApiClient openIMApiClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Group getById(Long groupId) {
|
||||||
|
return groupMapper.selectById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的群列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<Group> getUserGroups(Long userId) {
|
||||||
|
List<GroupMember> members = groupMemberMapper.selectList(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getUserId, userId));
|
||||||
|
|
||||||
|
if (members.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Long> groupIds = members.stream()
|
||||||
|
.map(GroupMember::getGroupId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return groupMapper.selectList(new LambdaQueryWrapper<Group>()
|
||||||
|
.in(Group::getId, groupIds)
|
||||||
|
.eq(Group::getStatus, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群(群ID自增生成,群主为传入用户,并自动把机器人拉入群)
|
||||||
|
* <p>
|
||||||
|
* 机器人取 sys_config 中 cfg_key=robot_id 的配置值。
|
||||||
|
* 先落本地库,再调 OpenIM 建群:OpenIM 失败会抛异常回滚本地事务,
|
||||||
|
* 保证不出现"本地有群、IM 无群"的脏数据。反向残留(IM 有群、本地回滚)可安全重试,
|
||||||
|
* 因为 {@link OpenIMApiClient#createGroup} 对已存在的群做了幂等跳过。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Group createGroup( String name, Long ownerUserId, Integer roundInterval, Long operatorId) {
|
||||||
|
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new RuntimeException("群名称不能为空");
|
||||||
|
}
|
||||||
|
if (ownerUserId == null) {
|
||||||
|
throw new RuntimeException("群主用户ID不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 群主必须是已存在的用户
|
||||||
|
User owner = userMapper.selectById(ownerUserId);
|
||||||
|
if (owner == null) {
|
||||||
|
throw new RuntimeException("群主用户不存在: " + ownerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 机器人账号(sys_config 中 cfg_key=robot_id)
|
||||||
|
Long botUserId = getRobotUserId();
|
||||||
|
if (userMapper.selectById(botUserId) == null) {
|
||||||
|
throw new RuntimeException("机器人用户不存在: " + botUserId + ",请检查 sys_config.robot_id 配置");
|
||||||
|
}
|
||||||
|
// 群主本人就是机器人时,只需建一条群主记录
|
||||||
|
boolean ownerIsBot = botUserId.equals(ownerUserId);
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
// 建群:id 由数据库自增生成
|
||||||
|
// openim_group_id 为 NOT NULL 且要与自增 id 保持一致,
|
||||||
|
// 故先占位插入拿到 id,再回填。同一事务内,不存在中间态外泄。
|
||||||
|
Group group = new Group();
|
||||||
|
group.setName(name);
|
||||||
|
group.setOpenimGroupId("");
|
||||||
|
group.setOwnerUserId(ownerUserId);
|
||||||
|
group.setRoundInterval(roundInterval);
|
||||||
|
group.setStatus(1);
|
||||||
|
group.setCreateTime(now);
|
||||||
|
group.setUpdateTime(now);
|
||||||
|
groupMapper.insert(group);
|
||||||
|
|
||||||
|
Long groupId = group.getId();
|
||||||
|
group.setOpenimGroupId(String.valueOf(groupId));
|
||||||
|
groupMapper.updateById(group);
|
||||||
|
|
||||||
|
// 群主入群
|
||||||
|
groupMemberMapper.insert(buildMember(groupId, ownerUserId, ROLE_OWNER, now));
|
||||||
|
|
||||||
|
// 机器人入群
|
||||||
|
if (!ownerIsBot) {
|
||||||
|
groupMemberMapper.insert(buildMember(groupId, botUserId, ROLE_NORMAL, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从现有假人中挑选10个进群
|
||||||
|
List<User> fakeUsers = fakeUserService.pickFakeUsers(10);
|
||||||
|
for (User fake : fakeUsers) {
|
||||||
|
groupMemberMapper.insert(buildMember(groupId, fake.getId(), ROLE_NORMAL, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenIM 侧建群:群主、机器人和假人作为初始成员
|
||||||
|
List<String> memberUserIDs = new java.util.ArrayList<>();
|
||||||
|
if (!ownerIsBot) {
|
||||||
|
memberUserIDs.add(String.valueOf(botUserId));
|
||||||
|
}
|
||||||
|
fakeUsers.stream()
|
||||||
|
.map(fake -> String.valueOf(fake.getId()))
|
||||||
|
.forEach(memberUserIDs::add);
|
||||||
|
openIMApiClient.createGroup(String.valueOf(groupId), name, String.valueOf(ownerUserId), memberUserIDs);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(groupId, null, operatorId, "CREATE_GROUP",
|
||||||
|
String.format("{\"name\":\"%s\",\"ownerUserId\":%d,\"botUserId\":%d,\"fakeCount\":%d}",
|
||||||
|
name.replace("\\", "\\\\").replace("\"", "\\\""), ownerUserId, botUserId, fakeUsers.size()));
|
||||||
|
|
||||||
|
log.info("创建群成功: groupId={}, name={}, ownerUserId={}, botUserId={}, operatorId={}",
|
||||||
|
groupId, name, ownerUserId, botUserId, operatorId);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取机器人用户ID(sys_config 中 cfg_key=robot_id)
|
||||||
|
*/
|
||||||
|
private Long getRobotUserId() {
|
||||||
|
SysConfig config = sysConfigMapper.selectOne(new LambdaQueryWrapper<SysConfig>()
|
||||||
|
.eq(SysConfig::getCfgKey, CFG_KEY_ROBOT_ID));
|
||||||
|
|
||||||
|
if (config == null || config.getCfgValue() == null || config.getCfgValue().isBlank()) {
|
||||||
|
throw new RuntimeException("系统配置缺失: sys_config 中未配置 " + CFG_KEY_ROBOT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Long.parseLong(config.getCfgValue().trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new RuntimeException("系统配置 " + CFG_KEY_ROBOT_ID + " 不是合法的用户ID: " + config.getCfgValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造群成员记录
|
||||||
|
*/
|
||||||
|
private GroupMember buildMember(Long groupId, Long userId, int role, LocalDateTime now) {
|
||||||
|
GroupMember member = new GroupMember();
|
||||||
|
member.setGroupId(groupId);
|
||||||
|
member.setUserId(userId);
|
||||||
|
member.setRole(role);
|
||||||
|
member.setJoinTime(now);
|
||||||
|
member.setCreateTime(now);
|
||||||
|
member.setUpdateTime(now);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉用户入群
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void pullUser(Long groupId, Long userId, Long operatorId) {
|
||||||
|
// 检查是否已在群中
|
||||||
|
GroupMember existing = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.eq(GroupMember::getUserId, userId));
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
throw new RuntimeException("用户已在群中");
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupMember member = new GroupMember();
|
||||||
|
member.setGroupId(groupId);
|
||||||
|
member.setUserId(userId);
|
||||||
|
member.setRole(ROLE_NORMAL);
|
||||||
|
member.setJoinTime(LocalDateTime.now());
|
||||||
|
member.setCreateTime(LocalDateTime.now());
|
||||||
|
member.setUpdateTime(LocalDateTime.now());
|
||||||
|
groupMemberMapper.insert(member);
|
||||||
|
|
||||||
|
// OpenIM 侧邀请用户入群
|
||||||
|
openIMApiClient.inviteUserToGroup(String.valueOf(groupId), List.of(String.valueOf(userId)));
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
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);
|
||||||
|
if (group == null) {
|
||||||
|
throw new RuntimeException("群组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查操作人权限
|
||||||
|
GroupMember operator = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.eq(GroupMember::getUserId, operatorId));
|
||||||
|
|
||||||
|
if (operator == null) {
|
||||||
|
throw new RuntimeException("你不是群成员");
|
||||||
|
}
|
||||||
|
if (operator.getRole() != ROLE_OWNER && operator.getRole() != ROLE_ADMIN) {
|
||||||
|
throw new RuntimeException("只有群主和管理员可以踢人");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不能踢群主
|
||||||
|
if (group.getOwnerUserId().equals(targetUserId)) {
|
||||||
|
throw new RuntimeException("不能踢出群主");
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupMember target = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.eq(GroupMember::getUserId, targetUserId));
|
||||||
|
if (target == null) {
|
||||||
|
throw new RuntimeException("目标用户不是群成员");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先同步 OpenIM,失败时事务回滚,避免本地与 IM 状态不一致
|
||||||
|
openIMApiClient.kickGroupMember(
|
||||||
|
String.valueOf(groupId), List.of(String.valueOf(targetUserId)));
|
||||||
|
|
||||||
|
groupMemberMapper.deleteById(target.getId());
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(groupId, null, operatorId, "KICK_USER",
|
||||||
|
String.format("{\"targetUserId\":%d}", targetUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置管理员
|
||||||
|
* <p>
|
||||||
|
* 本地更新群成员角色后,调用 OpenIM 接口同步角色(roleLevel=60=管理员)。
|
||||||
|
* OpenIM 调用失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void setAdmin(Long groupId, Long targetUserId, Long operatorId) {
|
||||||
|
Group group = groupMapper.selectById(groupId);
|
||||||
|
|
||||||
|
// 只有群主可以设置管理员
|
||||||
|
if (!group.getOwnerUserId().equals(operatorId)) {
|
||||||
|
throw new RuntimeException("只有群主可以设置管理员");
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupMember member = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.eq(GroupMember::getUserId, targetUserId));
|
||||||
|
|
||||||
|
if (member == null) {
|
||||||
|
throw new RuntimeException("目标用户不是群成员");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.setRole(ROLE_ADMIN);
|
||||||
|
member.setUpdateTime(LocalDateTime.now());
|
||||||
|
groupMemberMapper.updateById(member);
|
||||||
|
|
||||||
|
// OpenIM 同步:roleLevel=60 表示管理员
|
||||||
|
openIMApiClient.setGroupMemberRole(
|
||||||
|
String.valueOf(groupId), String.valueOf(targetUserId), 60);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(groupId, null, operatorId, "SET_ADMIN",
|
||||||
|
String.format("{\"targetUserId\":%d}", targetUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消管理员
|
||||||
|
* <p>
|
||||||
|
* 本地更新群成员角色后,调用 OpenIM 接口同步角色(roleLevel=20=普通成员)。
|
||||||
|
* OpenIM 调用失败会抛异常回滚本地事务,保证本地与 IM 角色一致。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void removeAdmin(Long groupId, Long targetUserId, Long operatorId) {
|
||||||
|
Group group = groupMapper.selectById(groupId);
|
||||||
|
|
||||||
|
if (!group.getOwnerUserId().equals(operatorId)) {
|
||||||
|
throw new RuntimeException("只有群主可以取消管理员");
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupMember member = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.eq(GroupMember::getUserId, targetUserId));
|
||||||
|
|
||||||
|
if (member == null) {
|
||||||
|
throw new RuntimeException("目标用户不是群成员");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.setRole(ROLE_NORMAL);
|
||||||
|
member.setUpdateTime(LocalDateTime.now());
|
||||||
|
groupMemberMapper.updateById(member);
|
||||||
|
|
||||||
|
// OpenIM 同步:roleLevel=20 表示普通成员
|
||||||
|
openIMApiClient.setGroupMemberRole(
|
||||||
|
String.valueOf(groupId), String.valueOf(targetUserId), 20);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(groupId, null, operatorId, "REMOVE_ADMIN",
|
||||||
|
String.format("{\"targetUserId\":%d}", targetUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群成员
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<GroupMember> getMembers(Long groupId) {
|
||||||
|
return groupMemberMapper.selectList(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是群主
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isOwner(Long groupId, Long userId) {
|
||||||
|
Group group = groupMapper.selectById(groupId);
|
||||||
|
return group != null && group.getOwnerUserId().equals(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否是管理员
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isAdmin(Long groupId, Long userId) {
|
||||||
|
GroupMember member = groupMemberMapper.selectOne(new LambdaQueryWrapper<GroupMember>()
|
||||||
|
.eq(GroupMember::getGroupId, groupId)
|
||||||
|
.eq(GroupMember::getUserId, userId));
|
||||||
|
return member != null && (member.getRole() == ROLE_ADMIN || member.getRole() == ROLE_OWNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据OpenIM群ID获取群信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Group getByOpenimGroupId(String openimGroupId) {
|
||||||
|
return groupMapper.selectOne(new LambdaQueryWrapper<Group>()
|
||||||
|
.eq(Group::getOpenimGroupId, openimGroupId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分Service实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class PointsServiceImpl implements IPointsService {
|
||||||
|
|
||||||
|
private final PointsFlowMapper pointsFlowMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加积分
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void addPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark) {
|
||||||
|
if (amount == null || amount.signum() == 0) {
|
||||||
|
throw new RuntimeException("变动数值必须为正数");
|
||||||
|
}
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal before = user.getPoints() == null ? BigDecimal.ZERO : user.getPoints();
|
||||||
|
|
||||||
|
// 原子增加积分
|
||||||
|
int rows = userMapper.addPoints(userId, amount);
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new RuntimeException("积分增加失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal after = before.add(amount);
|
||||||
|
|
||||||
|
// 记录流水
|
||||||
|
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 void subPoints(Long userId, BigDecimal amount, String type, String bizNo, Long operatorId, String remark) {
|
||||||
|
if (amount == null || amount.signum() <= 0) {
|
||||||
|
throw new RuntimeException("变动数值必须为正数");
|
||||||
|
}
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("用户不存在");
|
||||||
|
}
|
||||||
|
BigDecimal current = user.getPoints() == null ? BigDecimal.ZERO : user.getPoints();
|
||||||
|
if (current.compareTo(amount) < 0) {
|
||||||
|
throw new RuntimeException("余额不足");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal before = current;
|
||||||
|
|
||||||
|
// 原子扣除积分(SQL层保证余额充足才扣)
|
||||||
|
int rows = userMapper.subPoints(userId, amount);
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new RuntimeException("积分扣除失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal after = before.subtract(amount);
|
||||||
|
|
||||||
|
// 记录流水
|
||||||
|
PointsFlow flow = new PointsFlow();
|
||||||
|
flow.setUserId(userId);
|
||||||
|
flow.setType(type);
|
||||||
|
flow.setAmount(amount.negate());
|
||||||
|
flow.setBalanceBefore(before);
|
||||||
|
flow.setBalanceAfter(after);
|
||||||
|
flow.setBizNo(bizNo);
|
||||||
|
flow.setOperatorId(operatorId);
|
||||||
|
flow.setRemark(remark);
|
||||||
|
pointsFlowMapper.insert(flow);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户积分
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public BigDecimal getPoints(Long userId) {
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
return user != null && user.getPoints() != null ? user.getPoints() : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询积分流水记录
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Page<PointsFlow> getFlowPage(Long userId, String type, Integer pageNum, Integer pageSize) {
|
||||||
|
Page<PointsFlow> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<PointsFlow> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(PointsFlow::getUserId, userId)
|
||||||
|
.eq(type != null && !type.isEmpty(), PointsFlow::getType, type)
|
||||||
|
.orderByDesc(PointsFlow::getCreateTime);
|
||||||
|
return pointsFlowMapper.selectPage(page, wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.tailbet.service.impl;
|
||||||
|
|
||||||
|
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.mapper.DigitWhiteMapper;
|
||||||
|
import com.tailbet.model.entity.DigitWhite;
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.openim.OpenIMApiClient;
|
||||||
|
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 OpenIMApiClient openIMApiClient;
|
||||||
|
private final com.tailbet.config.OpenIMProperties openIMProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送尾数试算完成通知给白名单用户
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void pushDigitTrialComplete(Long groupId, Long roundId) {
|
||||||
|
List<DigitWhite> whites = digitWhiteMapper.selectList(new LambdaQueryWrapper<DigitWhite>());
|
||||||
|
for (DigitWhite white : whites) {
|
||||||
|
User user = userMapper.selectById(white.getUserId());
|
||||||
|
if (user != null) {
|
||||||
|
IPushService.PushMessage message = new IPushService.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) {
|
||||||
|
IPushService.PushMessage message = new IPushService.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) {
|
||||||
|
IPushService.PushMessage message = new IPushService.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(IPushService.PushMessage message) {
|
||||||
|
try {
|
||||||
|
User user = userMapper.selectById(message.getUserId());
|
||||||
|
if (user == null) {
|
||||||
|
log.warn("推送用户不存在: userId={}", message.getUserId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过OpenIM单聊发送(生产环境可扩展FCM/APNs离线推送)
|
||||||
|
openIMApiClient.sendMsg(
|
||||||
|
openIMProperties.getAdminUserId(),
|
||||||
|
String.valueOf(user.getId()),
|
||||||
|
1, // sessionType 1=单聊
|
||||||
|
101, // contentType 101=文本
|
||||||
|
java.util.Map.of("content", 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, IPushService.PushMessage message) {
|
||||||
|
try {
|
||||||
|
// 通过OpenIM群消息发送(使用管理员身份发送)
|
||||||
|
openIMApiClient.sendMsg(
|
||||||
|
openIMProperties.getAdminUserId(),
|
||||||
|
String.valueOf(groupId),
|
||||||
|
3, // sessionType 3=群聊
|
||||||
|
101, // contentType 101=文本
|
||||||
|
java.util.Map.of("content", message.getContent())
|
||||||
|
);
|
||||||
|
log.info("群广播发送成功: groupId={}, type={}", groupId, message.getType());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("群广播发送失败: groupId={}, type={}, error={}",
|
||||||
|
groupId, message.getType(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package com.tailbet.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.tailbet.event.RedPacketFinishedEvent;
|
||||||
|
import com.tailbet.model.entity.GameRound;
|
||||||
|
import com.tailbet.model.entity.RedPacket;
|
||||||
|
import com.tailbet.model.entity.RedPacketRecv;
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.mapper.GameRoundMapper;
|
||||||
|
import com.tailbet.mapper.RedPacketMapper;
|
||||||
|
import com.tailbet.mapper.RedPacketRecvMapper;
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.service.IAuditService;
|
||||||
|
import com.tailbet.service.IPointsService;
|
||||||
|
import com.tailbet.service.IRedPacketService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
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.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红包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;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发红包
|
||||||
|
*/
|
||||||
|
@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("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前置校验:总金额必须够每个包至少 1 分,避免产生 0 元红包
|
||||||
|
BigDecimal minRequired = BigDecimal.valueOf(totalCount == null ? 0 : totalCount)
|
||||||
|
.multiply(new BigDecimal("0.01"));
|
||||||
|
if (totalAmount == null || totalAmount.compareTo(minRequired) < 0) {
|
||||||
|
throw new RuntimeException("红包总金额不能低于 " + minRequired.toPlainString() + " 元");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣积分
|
||||||
|
pointsService.subPoints(userId, totalAmount,
|
||||||
|
IPointsService.TYPE_RP_SEND, null, null, "发送红包");
|
||||||
|
|
||||||
|
|
||||||
|
// 创建红包
|
||||||
|
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());
|
||||||
|
|
||||||
|
// 判断红包类型
|
||||||
|
Integer targetDigit = null;
|
||||||
|
if (roundId != null) {
|
||||||
|
rp.setType(TYPE_GAME);
|
||||||
|
} else {
|
||||||
|
rp.setType(TYPE_RANDOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
redPacketMapper.insert(rp);
|
||||||
|
|
||||||
|
// 如果是游戏红包,检查并绑定第一个红包
|
||||||
|
if (roundId != null) {
|
||||||
|
GameRound round = gameRoundMapper.selectById(roundId);
|
||||||
|
if (round == null) {
|
||||||
|
throw new RuntimeException("局不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round.getFirstRpId() == null) {
|
||||||
|
// 第一个红包,绑定到局
|
||||||
|
round.setFirstRpId(rp.getId());
|
||||||
|
gameRoundMapper.updateById(round);
|
||||||
|
|
||||||
|
// 获取局的目标尾数(可能为null)
|
||||||
|
targetDigit = round.getTargetDigit();
|
||||||
|
} else {
|
||||||
|
// 已有红包,不再绑定本局,作为普通游戏红包发放(不计算尾数)
|
||||||
|
log.info("本局已有红包,当前红包不绑定: rpId={}, roundId={}", rp.getId(), roundId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无论红包类型(TYPE_GAME/TYPE_RANDOM),都预先生成份额金额
|
||||||
|
// 游戏红包有targetDigit时控制手气王尾数,无targetDigit或非游戏红包按纯随机份额
|
||||||
|
List<BigDecimal> shareAmounts = preCalculateShareAmounts(rp, targetDigit);
|
||||||
|
try {
|
||||||
|
rp.setShareAmounts(objectMapper.writeValueAsString(shareAmounts));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("序列化shareAmounts失败: {}", shareAmounts, e);
|
||||||
|
throw new RuntimeException("系统错误");
|
||||||
|
}
|
||||||
|
rp.setShareTakenIndex(0);
|
||||||
|
redPacketMapper.updateById(rp);
|
||||||
|
log.info("预分配红包份额: rpId={}, type={}, targetDigit={}, amounts={}",
|
||||||
|
rp.getId(), rp.getType(), targetDigit, shareAmounts);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(groupId, roundId, userId, "RP_SEND",
|
||||||
|
String.format("{\"rpId\":%d,\"amount\":%s,\"count\":%d}",
|
||||||
|
rp.getId(), totalAmount, totalCount));
|
||||||
|
|
||||||
|
return rp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领红包
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public RedPacketRecv receiveRp(Long rpId, Long userId) {
|
||||||
|
RedPacket rp = redPacketMapper.selectById(rpId);
|
||||||
|
if (rp == null) {
|
||||||
|
throw new RuntimeException("红包不存在");
|
||||||
|
}
|
||||||
|
if (rp.getStatus() != STATUS_ONGOING) {
|
||||||
|
throw new RuntimeException("红包已领完");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已领取
|
||||||
|
RedPacketRecv existing = redPacketRecvMapper.selectOne(new LambdaQueryWrapper<RedPacketRecv>()
|
||||||
|
.eq(RedPacketRecv::getRpId, rpId)
|
||||||
|
.eq(RedPacketRecv::getUserId, userId));
|
||||||
|
if (existing != null) {
|
||||||
|
throw new RuntimeException("已领取过该红包");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算领取金额(从预分配中取或随机)
|
||||||
|
BigDecimal amount = drawFromPreAllocatedAmounts(rp);
|
||||||
|
|
||||||
|
// 更新红包
|
||||||
|
rp.setRemainAmount(rp.getRemainAmount().subtract(amount));
|
||||||
|
rp.setRemainCount(rp.getRemainCount() - 1);
|
||||||
|
boolean isFinished = rp.getRemainCount() <= 0;
|
||||||
|
if (isFinished) {
|
||||||
|
rp.setStatus(STATUS_FINISHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新手气王信息(领取金额最大的用户为手气王)
|
||||||
|
Integer takenIndex = rp.getShareTakenIndex();
|
||||||
|
BigDecimal currentLucky = rp.getLuckyAmount();
|
||||||
|
if (currentLucky == null || amount.compareTo(currentLucky) > 0) {
|
||||||
|
rp.setLuckyAmount(amount);
|
||||||
|
rp.setLuckyUserId(userId);
|
||||||
|
rp.setLuckyDigit(digitOf(amount));
|
||||||
|
}
|
||||||
|
rp.setShareTakenIndex(takenIndex != null ? takenIndex + 1 : 1);
|
||||||
|
|
||||||
|
redPacketMapper.updateById(rp);
|
||||||
|
|
||||||
|
// 如果是游戏红包且已领完,标记手气王并发布开奖事件
|
||||||
|
if (isFinished && TYPE_GAME.equals(rp.getType()) && rp.getRoundId() != null) {
|
||||||
|
// 标记手气王的领取记录 is_lucky=1
|
||||||
|
if (rp.getLuckyUserId() != null) {
|
||||||
|
redPacketRecvMapper.update(null, new LambdaUpdateWrapper<RedPacketRecv>()
|
||||||
|
.eq(RedPacketRecv::getRpId, rpId)
|
||||||
|
.eq(RedPacketRecv::getUserId, rp.getLuckyUserId())
|
||||||
|
.set(RedPacketRecv::getIsLucky, 1));
|
||||||
|
}
|
||||||
|
eventPublisher.publishEvent(new RedPacketFinishedEvent(
|
||||||
|
this, rp.getRoundId(), rp.getGroupId(), rp.getId(), rp.getLuckyDigit()));
|
||||||
|
log.info("游戏红包领完,标记手气王并发布开奖事件: roundId={}, luckyDigit={}, luckyUserId={}",
|
||||||
|
rp.getRoundId(), rp.getLuckyDigit(), rp.getLuckyUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加积分
|
||||||
|
pointsService.addPoints(userId, amount,
|
||||||
|
IPointsService.TYPE_RP_RECV, String.valueOf(rpId), null, "领取红包");
|
||||||
|
|
||||||
|
// 记录领取
|
||||||
|
RedPacketRecv recv = new RedPacketRecv();
|
||||||
|
recv.setRpId(rpId);
|
||||||
|
recv.setUserId(userId);
|
||||||
|
recv.setAmount(amount);
|
||||||
|
recv.setCreateTime(LocalDateTime.now());
|
||||||
|
redPacketRecvMapper.insert(recv);
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
auditService.log(rp.getGroupId(), rp.getRoundId(), userId, "RP_RECV",
|
||||||
|
String.format("{\"rpId\":%d,\"amount\":%s}", rpId, amount));
|
||||||
|
|
||||||
|
return recv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算领取金额
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public BigDecimal calculateReceiveAmount(RedPacket rp) {
|
||||||
|
if (rp.getRemainCount() <= 0) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是游戏红包且有目标尾数,则需要控制手气王
|
||||||
|
// 简化实现:随机分配
|
||||||
|
Random random = new Random();
|
||||||
|
if (rp.getRemainCount() == 1) {
|
||||||
|
// 最后一个,拿剩余全部
|
||||||
|
return rp.getRemainAmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机金额,最小0.01
|
||||||
|
double min = 0.01;
|
||||||
|
double max = rp.getRemainAmount().divide(BigDecimal.valueOf(rp.getRemainCount()), 2, RoundingMode.HALF_UP).doubleValue() * 2;
|
||||||
|
double amount = min + (max - min) * random.nextDouble();
|
||||||
|
return BigDecimal.valueOf(amount).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红包详情
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RedPacket getById(Long rpId) {
|
||||||
|
return redPacketMapper.selectById(rpId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取红包领取列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<RedPacketRecv> getReceives(Long rpId) {
|
||||||
|
return redPacketRecvMapper.selectList(new LambdaQueryWrapper<RedPacketRecv>()
|
||||||
|
.eq(RedPacketRecv::getRpId, rpId)
|
||||||
|
.orderByAsc(RedPacketRecv::getCreateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询红包记录
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Page<RedPacket> getRecords(Long userId, Integer pageNum, Integer pageSize) {
|
||||||
|
Page<RedPacket> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<RedPacket> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(RedPacket::getUserId, userId)
|
||||||
|
.orderByDesc(RedPacket::getCreateTime);
|
||||||
|
return redPacketMapper.selectPage(page, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询已收红包记录
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Page<RedPacketRecv> getReceivedRecords(Long userId, Integer pageNum, Integer pageSize) {
|
||||||
|
Page<RedPacketRecv> page = new Page<>(pageNum, pageSize);
|
||||||
|
LambdaQueryWrapper<RedPacketRecv> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(RedPacketRecv::getUserId, userId)
|
||||||
|
.orderByDesc(RedPacketRecv::getCreateTime);
|
||||||
|
return redPacketRecvMapper.selectPage(page, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找进行中的游戏红包
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RedPacket getOngoingGameRp(Long groupId) {
|
||||||
|
return redPacketMapper.selectOne(new LambdaQueryWrapper<RedPacket>()
|
||||||
|
.eq(RedPacket::getGroupId, groupId)
|
||||||
|
.eq(RedPacket::getType, TYPE_GAME)
|
||||||
|
.eq(RedPacket::getStatus, STATUS_ONGOING)
|
||||||
|
.isNotNull(RedPacket::getRoundId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从预分配金额中按索引顺序取值(不再修改 shareAmounts JSON)
|
||||||
|
* 兼容旧数据:如果没有预分配金额,则走随机算法
|
||||||
|
*/
|
||||||
|
private BigDecimal drawFromPreAllocatedAmounts(RedPacket rp) {
|
||||||
|
if (rp.getShareAmounts() == null || rp.getShareAmounts().isEmpty()) {
|
||||||
|
// 兼容旧数据fallback到随机
|
||||||
|
return calculateReceiveAmount(rp);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<BigDecimal> amounts = objectMapper.readValue(rp.getShareAmounts(),
|
||||||
|
new TypeReference<List<BigDecimal>>() {
|
||||||
|
});
|
||||||
|
if (amounts.isEmpty()) {
|
||||||
|
return calculateReceiveAmount(rp);
|
||||||
|
}
|
||||||
|
// 按 shareTakenIndex 索引取值,shareAmounts 不再被修改
|
||||||
|
Integer index = rp.getShareTakenIndex();
|
||||||
|
if (index == null || index >= amounts.size()) {
|
||||||
|
// 越界:兼容旧数据或异常情况,走随机
|
||||||
|
return calculateReceiveAmount(rp);
|
||||||
|
}
|
||||||
|
return amounts.get(index);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析shareAmounts失败: {}, error={}", rp.getShareAmounts(), e.getMessage());
|
||||||
|
return calculateReceiveAmount(rp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预计算红包份额金额
|
||||||
|
* targetDigit 非空时确保手气王金额尾数=targetDigit
|
||||||
|
* targetDigit 为空时按纯随机份额处理
|
||||||
|
*/
|
||||||
|
private List<BigDecimal> preCalculateShareAmounts(RedPacket rp, Integer targetDigit) {
|
||||||
|
List<BigDecimal> amounts = new ArrayList<>();
|
||||||
|
int count = rp.getTotalCount();
|
||||||
|
BigDecimal total = rp.getTotalAmount();
|
||||||
|
BigDecimal minUnit = new BigDecimal("0.01"); // 最小份额:1 分
|
||||||
|
|
||||||
|
if (count <= 1) {
|
||||||
|
amounts.add(total);
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
// 1. 计算手气王金额(已保证不超过 luckyMax 且尾数=targetDigit)
|
||||||
|
BigDecimal luckyAmount = calculateLuckyAmount(total, targetDigit, count, random);
|
||||||
|
|
||||||
|
// 2. 计算其他人总额(保证每人至少 minUnit)
|
||||||
|
BigDecimal minForOthers = minUnit.multiply(BigDecimal.valueOf(count - 1));
|
||||||
|
BigDecimal remaining = total.subtract(luckyAmount);
|
||||||
|
if (remaining.compareTo(minForOthers) < 0) {
|
||||||
|
// 手气王挤占了他人最小份额,回收
|
||||||
|
luckyAmount = total.subtract(minForOthers);
|
||||||
|
if (targetDigit != null) {
|
||||||
|
luckyAmount = adjustToTargetDigit(luckyAmount, luckyAmount.add(minUnit), targetDigit);
|
||||||
|
}
|
||||||
|
remaining = minForOthers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 其他人金额(带小随机波动)
|
||||||
|
// 计算 avgAmount 后,确保 luckyAmount ≥ avgAmount × 1.2(maxFactor)
|
||||||
|
// 这样循环里的 amount 自然 ≤ luckyAmount,无需钳制,金额严格守恒
|
||||||
|
double maxFactor = 1.2;
|
||||||
|
BigDecimal avgAmount = remaining.divide(BigDecimal.valueOf(count - 1), 2, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal needed = avgAmount.multiply(BigDecimal.valueOf(maxFactor))
|
||||||
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
if (luckyAmount.compareTo(needed) < 0) {
|
||||||
|
// 手气王不够大,抬高到 needed(满足"分位尾数"=targetDigit 优先)
|
||||||
|
BigDecimal lifted = needed;
|
||||||
|
if (targetDigit != null) {
|
||||||
|
lifted = adjustToTargetDigit(lifted, total, targetDigit);
|
||||||
|
}
|
||||||
|
// 若抬高后超过 luckyMax(total - minForOthers),回退取 luckyMax 并再调尾数
|
||||||
|
BigDecimal cap = total.subtract(minForOthers);
|
||||||
|
if (lifted.compareTo(cap) > 0) {
|
||||||
|
lifted = cap;
|
||||||
|
if (targetDigit != null) {
|
||||||
|
lifted = adjustToTargetDigit(lifted, cap, targetDigit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luckyAmount = lifted;
|
||||||
|
remaining = total.subtract(luckyAmount);
|
||||||
|
// 重新计算 avgAmount,确保循环里的金额仍 ≥ minUnit
|
||||||
|
avgAmount = remaining.divide(BigDecimal.valueOf(count - 1), 2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
amounts.add(luckyAmount);
|
||||||
|
|
||||||
|
BigDecimal remainingForOthers = remaining;
|
||||||
|
for (int i = 1; i < count - 1; i++) {
|
||||||
|
double factor = 0.8 + random.nextDouble() * 0.4;
|
||||||
|
BigDecimal amount = avgAmount.multiply(BigDecimal.valueOf(factor))
|
||||||
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
// 下限保护:每个份额至少 minUnit
|
||||||
|
if (amount.compareTo(minUnit) < 0) {
|
||||||
|
amount = minUnit;
|
||||||
|
}
|
||||||
|
// 累加上限:不超过 remainingForOthers
|
||||||
|
if (amount.compareTo(remainingForOthers) > 0) {
|
||||||
|
amount = remainingForOthers;
|
||||||
|
}
|
||||||
|
amounts.add(amount);
|
||||||
|
remainingForOthers = remainingForOthers.subtract(amount);
|
||||||
|
}
|
||||||
|
// 最后一个拿剩余
|
||||||
|
BigDecimal last = remainingForOthers;
|
||||||
|
// 下限保护:累减后可能 < minUnit
|
||||||
|
if (last.compareTo(minUnit) < 0) {
|
||||||
|
last = minUnit;
|
||||||
|
}
|
||||||
|
amounts.add(last.setScale(2, RoundingMode.HALF_UP));
|
||||||
|
|
||||||
|
// 打乱顺序(让领取顺序随机)
|
||||||
|
Collections.shuffle(amounts);
|
||||||
|
|
||||||
|
// 如果指定了目标尾数,确保列表中至少有1个金额的尾数=targetDigit
|
||||||
|
if (targetDigit != null) {
|
||||||
|
ensureLuckyDigit(amounts, targetDigit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算手气王金额
|
||||||
|
* - 比例为总额的 [1/count, 50%],下限定为 1/count 以保证 luckyAmount ≥ avgAmount
|
||||||
|
* (手气王必须 ≥ 其他人的平均金额,否则随机波动后有人会比手气王还大)
|
||||||
|
* - 有目标尾数时确保"分位尾数"=targetDigit
|
||||||
|
* - 不超过 luckyMax(total - minUnit * (count-1)),留出最小份额给其他人
|
||||||
|
*/
|
||||||
|
private BigDecimal calculateLuckyAmount(BigDecimal total, Integer targetDigit, int totalCount, Random random) {
|
||||||
|
BigDecimal minUnit = new BigDecimal("0.01");
|
||||||
|
BigDecimal minForOthers = minUnit.multiply(BigDecimal.valueOf(totalCount - 1));
|
||||||
|
BigDecimal luckyMax = total.subtract(minForOthers);
|
||||||
|
|
||||||
|
// 比例下限 1/count(保证 luckyAmount ≥ avgAmount),上限 50%
|
||||||
|
double minRatio = 1.0 / totalCount;
|
||||||
|
double ratio = minRatio + random.nextDouble() * (0.5 - minRatio);
|
||||||
|
BigDecimal luckyAmount = total.multiply(BigDecimal.valueOf(ratio))
|
||||||
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
// 调整尾数(如果指定)
|
||||||
|
if (targetDigit != null) {
|
||||||
|
luckyAmount = adjustToTargetDigit(luckyAmount, luckyMax, targetDigit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上限保护:不超过 luckyMax
|
||||||
|
if (luckyAmount.compareTo(luckyMax) > 0) {
|
||||||
|
luckyAmount = luckyMax;
|
||||||
|
if (targetDigit != null) {
|
||||||
|
luckyAmount = adjustToTargetDigit(luckyAmount, luckyMax, targetDigit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return luckyAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整金额让"分位尾数"=targetDigit,且不超过 maxAllowed
|
||||||
|
* 优先向上调整(保持金额更大),如会超出则向下调整
|
||||||
|
*/
|
||||||
|
private BigDecimal adjustToTargetDigit(BigDecimal amount, BigDecimal maxAllowed, int targetDigit) {
|
||||||
|
BigDecimal minUnit = new BigDecimal("0.01");
|
||||||
|
int lastDigit = digitOf(amount);
|
||||||
|
if (lastDigit == targetDigit) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
int upDiff = (targetDigit - lastDigit + 10) % 10; // 向上调(单位:分)
|
||||||
|
int downDiff = (lastDigit - targetDigit + 10) % 10; // 向下调
|
||||||
|
BigDecimal up = amount.add(BigDecimal.valueOf(upDiff).movePointLeft(2))
|
||||||
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
if (up.compareTo(maxAllowed) <= 0 && up.compareTo(minUnit) >= 0) {
|
||||||
|
return up;
|
||||||
|
}
|
||||||
|
BigDecimal down = amount.subtract(BigDecimal.valueOf(downDiff).movePointLeft(2))
|
||||||
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
if (down.compareTo(minUnit) >= 0) {
|
||||||
|
return down;
|
||||||
|
}
|
||||||
|
return amount; // 实在调不动,保持原值(兜底)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取金额的"分位尾数":金额×100 后取个位(如 0.45→5,1.05→5,0.40→0)
|
||||||
|
* BigDecimal.remainder(BigDecimal.TEN) 对小数返回自身+intValue 截断会丢精度,
|
||||||
|
* 这里按国内红包/微信红包标准按"分位"算尾数
|
||||||
|
*/
|
||||||
|
private static int digitOf(BigDecimal amount) {
|
||||||
|
if (amount == null) return 0;
|
||||||
|
return amount.movePointRight(2).remainder(BigDecimal.TEN).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 amounts[0] 的"分位尾数"=targetDigit(手气王必须在首位)
|
||||||
|
* 如果首位已经命中则不操作;否则找一个命中项 swap 到首位;都没有则兜底不动
|
||||||
|
*/
|
||||||
|
private void ensureLuckyDigit(List<BigDecimal> amounts, int targetDigit) {
|
||||||
|
if (digitOf(amounts.get(0)) == targetDigit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 首位不命中,找一个命中项 swap 到首位
|
||||||
|
for (int i = 1; i < amounts.size(); i++) {
|
||||||
|
if (digitOf(amounts.get(i)) == targetDigit) {
|
||||||
|
Collections.swap(amounts, 0, i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有任何命中项,无法保证(这种情况很罕见:total/count 太小无空间调整)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.tailbet.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.tailbet.model.entity.User;
|
||||||
|
import com.tailbet.mapper.UserMapper;
|
||||||
|
import com.tailbet.model.dto.UpdateUserDTO;
|
||||||
|
import com.tailbet.openim.OpenIMApiClient;
|
||||||
|
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 org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户Service实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final PasswordUtil passwordUtil;
|
||||||
|
private final OpenIMApiClient openIMApiClient;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户名查询用户
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public User getByUsername(String username) {
|
||||||
|
return userMapper.selectOne(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getUsername, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册用户
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
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(BigDecimal.ZERO);
|
||||||
|
user.setStatus(1);
|
||||||
|
userMapper.insert(user);
|
||||||
|
|
||||||
|
// 在OpenIM创建用户
|
||||||
|
try {
|
||||||
|
openIMApiClient.userRegister(String.valueOf(user.getId()), user.getNickname(), null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("OpenIM用户创建失败: userId={}, username={}, error={}", user.getId(), username, e.getMessage());
|
||||||
|
throw new RuntimeException("OpenIM用户创建失败,请稍后再试!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分配运营(按最少绑定+优先在线算法)
|
||||||
|
assignOps(user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Long 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 user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
*/
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
userMapper.updateById(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
||||||
|
User user = userMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("用户不存在");
|
||||||
|
}
|
||||||
|
if (!passwordUtil.matches(oldPassword, user.getPassword())) {
|
||||||
|
throw new RuntimeException("原密码错误");
|
||||||
|
}
|
||||||
|
user.setPassword(passwordUtil.encode(newPassword));
|
||||||
|
userMapper.updateById(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public User getCurrentUser() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配运营(粘性绑定:在线+最少绑定)
|
||||||
|
*/
|
||||||
|
private void assignOps(User user) {
|
||||||
|
// 查询所有运营账号
|
||||||
|
List<User> opsList = userMapper.selectList(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getIsOps, 1)
|
||||||
|
.eq(User::getStatus, 1));
|
||||||
|
|
||||||
|
if (opsList == null || opsList.isEmpty()) {
|
||||||
|
log.warn("没有可用的运营账号,用户{}将无法分配运营", user.getUsername());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找绑定用户最少的运营
|
||||||
|
User selectedOps = opsList.stream()
|
||||||
|
.min((a, b) -> {
|
||||||
|
Long countA = userMapper.selectCount(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getBoundOpsUserId, a.getId()));
|
||||||
|
Long countB = userMapper.selectCount(new LambdaQueryWrapper<User>()
|
||||||
|
.eq(User::getBoundOpsUserId, b.getId()));
|
||||||
|
return countA.compareTo(countB);
|
||||||
|
})
|
||||||
|
.orElse(opsList.get(0));
|
||||||
|
|
||||||
|
user.setBoundOpsUserId(selectedOps.getId());
|
||||||
|
userMapper.updateById(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.tailbet.util;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT工具类
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtUtil {
|
||||||
|
|
||||||
|
@Value("${jwt.secret}")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Value("${jwt.expiration}")
|
||||||
|
private Long expiration;
|
||||||
|
|
||||||
|
private SecretKey getSigningKey() {
|
||||||
|
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成Token
|
||||||
|
*/
|
||||||
|
public String generateToken(Long userId, String username) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put("userId", userId);
|
||||||
|
claims.put("username", username);
|
||||||
|
return createToken(claims, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createToken(Map<String, Object> claims, String subject) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expirationDate = new Date(now.getTime() + expiration);
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(claims)
|
||||||
|
.subject(subject)
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(expirationDate)
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析Token
|
||||||
|
*/
|
||||||
|
public Claims parseToken(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户ID
|
||||||
|
*/
|
||||||
|
public Long getUserId(String token) {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
return claims.get("userId", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户名
|
||||||
|
*/
|
||||||
|
public String getUsername(String token) {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
return claims.getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Token是否过期
|
||||||
|
*/
|
||||||
|
public boolean isTokenExpired(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
return claims.getExpiration().before(new Date());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.tailbet.util;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码工具类
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PasswordUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt加密
|
||||||
|
*/
|
||||||
|
public String encode(String rawPassword) {
|
||||||
|
return DigestUtil.bcrypt(rawPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt校验
|
||||||
|
*/
|
||||||
|
public boolean matches(String rawPassword, String encodedPassword) {
|
||||||
|
if (StrUtil.isBlank(rawPassword) || StrUtil.isBlank(encodedPassword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return DigestUtil.bcryptCheck(rawPassword, encodedPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.tailbet.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.redisson.api.RLock;
|
||||||
|
import org.redisson.api.RedissonClient;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 分布式锁工具类(静态工具方法)
|
||||||
|
* <p>
|
||||||
|
* 使用 Redisson 实现,支持:
|
||||||
|
* <ul>
|
||||||
|
* <li>tryLock 模式(等待时间 + 租赁时间)</li>
|
||||||
|
* <li>lock 模式(阻塞获取)</li>
|
||||||
|
* <li>事务提交后释放锁(防止脏读)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 使用前需要调用 {@link #setRedissonClient(RedissonClient)} 初始化,
|
||||||
|
* 或通过 Spring 注入 {@link RedisLockUtil} bean 并调用初始化方法。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class RedisLockUtil {
|
||||||
|
|
||||||
|
private static final String LOCK_PREFIX = "redis:lock:";
|
||||||
|
|
||||||
|
private static RedissonClient redissonClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 RedissonClient 实例(静态初始化)
|
||||||
|
*/
|
||||||
|
public static void setRedissonClient(RedissonClient client) {
|
||||||
|
redissonClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RedissonClient getRedissonClient() {
|
||||||
|
return redissonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RedisLockUtil() {
|
||||||
|
// 工具类禁止实例化
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== tryLock 模式 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,执行逻辑后释放锁
|
||||||
|
*
|
||||||
|
* @param lockName 锁名称(会自动添加前缀)
|
||||||
|
* @param waitTime 等待时间
|
||||||
|
* @param leaseTime 租赁时间
|
||||||
|
* @param timeUnit 时间单位
|
||||||
|
* @param supplier 获取锁后执行的逻辑
|
||||||
|
* @return 执行结果,锁获取失败返回 null
|
||||||
|
*/
|
||||||
|
public static <T> T tryLock(String lockName, int waitTime, int leaseTime,
|
||||||
|
TimeUnit timeUnit, Supplier<T> supplier) {
|
||||||
|
String fullLockName = LOCK_PREFIX + lockName;
|
||||||
|
RLock lock = redissonClient.getLock(fullLockName);
|
||||||
|
boolean locked = false;
|
||||||
|
try {
|
||||||
|
locked = lock.tryLock(waitTime, leaseTime, timeUnit);
|
||||||
|
if (locked) {
|
||||||
|
return supplier.get();
|
||||||
|
}
|
||||||
|
log.warn("[RedisLock] 获取锁失败, lockName={}", lockName);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("[RedisLock] 获取锁被中断, lockName={}", lockName, e);
|
||||||
|
} finally {
|
||||||
|
if (locked && lock.isHeldByCurrentThread()) {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,执行逻辑后释放锁(使用默认时间单位秒)
|
||||||
|
*/
|
||||||
|
public static <T> T tryLock(String lockName, int waitTime, int leaseTime,
|
||||||
|
Supplier<T> supplier) {
|
||||||
|
return tryLock(lockName, waitTime, leaseTime, TimeUnit.SECONDS, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,执行逻辑后释放锁(使用默认租赁时间10秒)
|
||||||
|
*/
|
||||||
|
public static <T> T tryLock(String lockName, int waitTime, Supplier<T> supplier) {
|
||||||
|
return tryLock(lockName, waitTime, 10, TimeUnit.SECONDS, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,执行逻辑后释放锁(使用默认等待时间3秒,租赁时间10秒)
|
||||||
|
*/
|
||||||
|
public static <T> T tryLock(String lockName, Supplier<T> supplier) {
|
||||||
|
return tryLock(lockName, 3, 10, TimeUnit.SECONDS, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== lock 模式(阻塞获取) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阻塞获取锁,执行逻辑后释放锁
|
||||||
|
*
|
||||||
|
* @param lockName 锁名称
|
||||||
|
* @param supplier 获取锁后执行的逻辑
|
||||||
|
*/
|
||||||
|
public static <T> T lock(String lockName, Supplier<T> supplier) {
|
||||||
|
return lock(lockName, 0, -1, TimeUnit.SECONDS, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阻塞获取锁(带租赁时间),执行逻辑后释放锁
|
||||||
|
*/
|
||||||
|
public static <T> T lock(String lockName, int leaseTime, TimeUnit timeUnit,
|
||||||
|
Supplier<T> supplier) {
|
||||||
|
return lock(lockName, 0, leaseTime, timeUnit, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阻塞获取锁(带等待时间和租赁时间),执行逻辑后释放锁
|
||||||
|
*/
|
||||||
|
public static <T> T lock(String lockName, int waitTime, int leaseTime,
|
||||||
|
TimeUnit timeUnit, Supplier<T> supplier) {
|
||||||
|
String fullLockName = LOCK_PREFIX + lockName;
|
||||||
|
RLock lock = redissonClient.getLock(fullLockName);
|
||||||
|
boolean locked = false;
|
||||||
|
try {
|
||||||
|
// lock() 方法会一直阻塞直到获取锁
|
||||||
|
// 如果 leaseTime = -1,则使用 Redisson 默认的 30 秒
|
||||||
|
if (leaseTime <= 0) {
|
||||||
|
lock.lock();
|
||||||
|
} else {
|
||||||
|
lock.lock(leaseTime, timeUnit);
|
||||||
|
}
|
||||||
|
locked = true;
|
||||||
|
return supplier.get();
|
||||||
|
} finally {
|
||||||
|
if (locked && lock.isHeldByCurrentThread()) {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事务提交后释放锁 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,在事务提交后再释放锁(防止并发读取未提交数据)
|
||||||
|
* <p>
|
||||||
|
* 适用于:分布式锁 + 数据库事务 配合使用的场景
|
||||||
|
*
|
||||||
|
* @param lockName 锁名称
|
||||||
|
* @param waitTime 等待时间
|
||||||
|
* @param leaseTime 租赁时间
|
||||||
|
* @param timeUnit 时间单位
|
||||||
|
* @param supplier 获取锁后执行的逻辑
|
||||||
|
* @return 执行结果,锁获取失败返回 null
|
||||||
|
*/
|
||||||
|
public static <T> T tryLockAfterCommit(String lockName, int waitTime, int leaseTime,
|
||||||
|
TimeUnit timeUnit, Supplier<T> supplier) {
|
||||||
|
return tryLockAfterCommit(lockName, waitTime, leaseTime, timeUnit, false, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,在事务提交后再释放锁(使用默认等待时间3秒,租赁时间10秒)
|
||||||
|
*/
|
||||||
|
public static <T> T tryLockAfterCommit(String lockName, Supplier<T> supplier) {
|
||||||
|
return tryLockAfterCommit(lockName, 3, 10, TimeUnit.SECONDS, false, supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取锁,在事务提交后再释放锁
|
||||||
|
*
|
||||||
|
* @param lockName 锁名称
|
||||||
|
* @param waitTime 等待时间
|
||||||
|
* @param leaseTime 租赁时间
|
||||||
|
* @param timeUnit 时间单位
|
||||||
|
* @param releaseOnRollback 是否在事务回滚后释放锁(false 则始终在 afterCommit 释放)
|
||||||
|
* @param supplier 获取锁后执行的逻辑
|
||||||
|
* @return 执行结果,锁获取失败返回 null
|
||||||
|
*/
|
||||||
|
public static <T> T tryLockAfterCommit(String lockName, int waitTime, int leaseTime,
|
||||||
|
TimeUnit timeUnit, boolean releaseOnRollback,
|
||||||
|
Supplier<T> supplier) {
|
||||||
|
String fullLockName = LOCK_PREFIX + lockName;
|
||||||
|
RLock lock = redissonClient.getLock(fullLockName);
|
||||||
|
boolean locked = false;
|
||||||
|
try {
|
||||||
|
locked = lock.tryLock(waitTime, leaseTime, timeUnit);
|
||||||
|
if (!locked) {
|
||||||
|
log.warn("[RedisLock] 获取锁失败, lockName={}", lockName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有活跃事务,注册 afterCommit 回调释放锁
|
||||||
|
if (org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
if (lock.isHeldByCurrentThread()) {
|
||||||
|
lock.unlock();
|
||||||
|
log.debug("[RedisLock] 事务提交后释放锁, lockName={}", lockName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(int status) {
|
||||||
|
// 如果设置了回滚时也释放,或者不是回滚
|
||||||
|
if (releaseOnRollback || status == STATUS_COMMITTED) {
|
||||||
|
return; // 已在 afterCommit 处理
|
||||||
|
}
|
||||||
|
// 回滚情况下释放锁
|
||||||
|
if (lock.isHeldByCurrentThread()) {
|
||||||
|
lock.unlock();
|
||||||
|
log.debug("[RedisLock] 事务回滚后释放锁, lockName={}", lockName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return supplier.get();
|
||||||
|
} else {
|
||||||
|
// 无事务,直接释放锁
|
||||||
|
return supplier.get();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("[RedisLock] 获取锁被中断, lockName={}", lockName, e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// 如果没有活跃事务,需要在这里释放锁
|
||||||
|
if (locked && lock.isHeldByCurrentThread() &&
|
||||||
|
!org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 锁状态检查 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查锁是否被持有
|
||||||
|
*/
|
||||||
|
public static boolean isLocked(String lockName) {
|
||||||
|
String fullLockName = LOCK_PREFIX + lockName;
|
||||||
|
RLock lock = redissonClient.getLock(fullLockName);
|
||||||
|
return lock.isLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查锁是否被当前线程持有
|
||||||
|
*/
|
||||||
|
public static boolean isHeldByCurrentThread(String lockName) {
|
||||||
|
String fullLockName = LOCK_PREFIX + lockName;
|
||||||
|
RLock lock = redissonClient.getLock(fullLockName);
|
||||||
|
return lock.isHeldByCurrentThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 强制释放 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制释放锁(谨慎使用)
|
||||||
|
*/
|
||||||
|
public static void forceUnlock(String lockName) {
|
||||||
|
String fullLockName = LOCK_PREFIX + lockName;
|
||||||
|
RLock lock = redissonClient.getLock(fullLockName);
|
||||||
|
if (lock.isLocked()) {
|
||||||
|
lock.forceUnlock();
|
||||||
|
log.warn("[RedisLock] 强制释放锁, lockName={}", lockName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Spring 初始化组件 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring 环境下使用的初始化组件
|
||||||
|
* <p>
|
||||||
|
* 在 Spring 环境中,可以将此类配置为 Bean 并在 PostConstruct 中调用 {@link #setRedissonClient(RedissonClient)}
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public static class RedisLockUtilInitializer {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedissonClient redissonClient;
|
||||||
|
|
||||||
|
@jakarta.annotation.PostConstruct
|
||||||
|
public void init() {
|
||||||
|
setRedissonClient(redissonClient);
|
||||||
|
log.info("[RedisLock] RedissonClient 初始化完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: guess-game
|
||||||
|
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:127.0.0.1}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:123456}
|
||||||
|
# password: ${REDIS_PASSWORD:ejgheyg2@eHSY2!.}
|
||||||
|
database: ${REDIS_DB:0}
|
||||||
|
timeout: 5000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8
|
||||||
|
max-wait: -1ms
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 0
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 9080
|
||||||
|
|
||||||
|
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
|
||||||
|
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||||
|
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
id-type: auto
|
||||||
|
logic-delete-field: deleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
|
||||||
|
openim:
|
||||||
|
# OpenIM REST API 外网地址(必填),下发给客户端 httpUrl
|
||||||
|
apiUrl: https://openim.xiu6688.com
|
||||||
|
# OpenIM REST API 内网地址(可选),服务端调用,未配置则回落 apiUrl
|
||||||
|
# internalApiUrl: http://172.31.5.16:10002
|
||||||
|
# OpenIM WebSocket 外网地址(必填),下发给客户端用于 IM SDK 初始化
|
||||||
|
wsUrl: wss://im.xiu6688.com
|
||||||
|
# OpenIM 管理员密钥(必填),用于获取 adminToken
|
||||||
|
secret: d71c0861267DM23adg
|
||||||
|
# OpenIM 管理员用户 ID(默认 imAdmin)
|
||||||
|
adminUserId: imAdmin
|
||||||
|
# 应用标识(默认 socialapp)
|
||||||
|
appId: socialapp
|
||||||
|
# adminToken 有效期秒数(默认 82800,即 23 小时)
|
||||||
|
tokenExpireSeconds: 82800
|
||||||
|
# 调用 OpenIM 接口的连接超时时间毫秒(默认 5000)
|
||||||
|
connectTimeoutMs: 5000
|
||||||
|
# 调用 OpenIM 接口的读取超时时间毫秒(默认 10000)
|
||||||
|
readTimeoutMs: 10000
|
||||||
|
# Webhook 签名密钥(可选),用于校验 OpenIM 回调请求
|
||||||
|
webhookSecret: your-webhook-secret
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:ecc6de473d6f4e0fbdaf5ea899fd2e26}
|
||||||
|
expiration: 604800000
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.tailbet: info
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: guess-game
|
||||||
|
datasource:
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: jdbc:mysql://13.212.30.9:3306/guess_game?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8
|
||||||
|
username: app_user
|
||||||
|
password: ${DB_PASSWORD:dhOmjPJ2321wq!.}
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:127.0.0.1}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:ejgheyg2@eHSY2!.}
|
||||||
|
database: ${REDIS_DB:0}
|
||||||
|
timeout: 5000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8
|
||||||
|
max-wait: -1ms
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 0
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 9080
|
||||||
|
|
||||||
|
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.slf4j.Slf4jImpl
|
||||||
|
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
id-type: auto
|
||||||
|
logic-delete-field: deleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
openim:
|
||||||
|
# OpenIM REST API 外网地址(必填),下发给客户端 httpUrl
|
||||||
|
apiUrl: https://openim.xiu6688.com
|
||||||
|
# OpenIM REST API 内网地址(可选),服务端调用,未配置则回落 apiUrl
|
||||||
|
internalApiUrl: http://172.31.5.16:10002
|
||||||
|
# OpenIM WebSocket 外网地址(必填),下发给客户端用于 IM SDK 初始化
|
||||||
|
wsUrl: wss://im.xiu6688.com
|
||||||
|
# OpenIM 管理员密钥(必填),用于获取 adminToken
|
||||||
|
secret: d71c0861267DM23adg
|
||||||
|
# OpenIM 管理员用户 ID(默认 imAdmin)
|
||||||
|
adminUserId: imAdmin
|
||||||
|
# 应用标识(默认 socialapp)
|
||||||
|
appId: socialapp
|
||||||
|
# adminToken 有效期秒数(默认 82800,即 23 小时)
|
||||||
|
tokenExpireSeconds: 82800
|
||||||
|
# 调用 OpenIM 接口的连接超时时间毫秒(默认 5000)
|
||||||
|
connectTimeoutMs: 5000
|
||||||
|
# 调用 OpenIM 接口的读取超时时间毫秒(默认 10000)
|
||||||
|
readTimeoutMs: 10000
|
||||||
|
# Webhook 签名密钥(可选),用于校验 OpenIM 回调请求
|
||||||
|
webhookSecret: your-webhook-secret
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:ecc6de473d6f4e0fbdaf5ea899fd2e26}
|
||||||
|
expiration: 604800000
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.tailbet: info
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# 数据库初始化脚本
|
||||||
|
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 DECIMAL(20,2) 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 DECIMAL(20,2) NOT NULL COMMENT '变动数值(正负)',
|
||||||
|
balance_before DECIMAL(20,2) NOT NULL COMMENT '变动前余额',
|
||||||
|
balance_after DECIMAL(20,2) 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),
|
||||||
|
INDEX idx_status_endbet (status, end_bet_time),
|
||||||
|
INDEX idx_status_startrp (status, start_rp_time),
|
||||||
|
INDEX idx_status_finishtime (status, finish_time)
|
||||||
|
) 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 DECIMAL(20,2) 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 DECIMAL(20,2) 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', '小输利润阈值');
|
||||||
|
INSERT INTO `guess_game`.`sys_config`(`id`, `cfg_key`, `cfg_value`, `remark`, `update_time`) VALUES (27, 'robot_id', '8', '机器人id', '2026-08-08 16:33:16');
|
||||||
|
|
||||||
|
-- red_packet 表增加预分配金额字段
|
||||||
|
ALTER TABLE red_packet m COLUMN share_amounts JSON COMMENT '预分配金额列表(JSON数组,发红包时写入,领取时不再修改)';
|
||||||
|
ALTER TABLE red_packet ADD COLUMN share_taken_index INT DEFAULT 0 COMMENT '下一个待领取份额的索引(从0开始)';
|
||||||
|
|
||||||
|
-- game_round 表增加第一个红包ID字段
|
||||||
|
ALTER TABLE game_round ADD COLUMN first_rp_id BIGINT COMMENT '第一个红包ID';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- 新增配置项
|
||||||
|
INSERT INTO sys_config (cfg_key, cfg_value, remark) VALUES
|
||||||
|
('bet_window_seconds', '60', '接注窗口时间(秒)'),
|
||||||
|
('fake_recv_seconds', '5', '托领取超时时间(秒)');
|
||||||
|
|
||||||
|
-- ====== 去内存 Map 重构后,pending_task 表已不再被应用写入,保留 DDL 仅作历史兼容参考 ======
|
||||||
|
-- 实际生产如需清理,可执行:
|
||||||
|
-- DROP TABLE IF EXISTS `pending_task`;
|
||||||
|
-- CREATE TABLE `pending_task` (
|
||||||
|
-- `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
-- `task_id` VARCHAR(64) NOT NULL COMMENT '任务ID',
|
||||||
|
-- `type` VARCHAR(32) NOT NULL COMMENT 'start_rp/settle/auto_next/bot_send_rp',
|
||||||
|
-- `round_id` BIGINT COMMENT '局ID',
|
||||||
|
-- `group_id` BIGINT NOT NULL COMMENT '群ID',
|
||||||
|
-- `digit` INT COMMENT '开奖尾数(settle任务用)',
|
||||||
|
-- `execute_time` DATETIME NOT NULL COMMENT '执行时间',
|
||||||
|
-- `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
-- INDEX `idx_execute_time` (`execute_time`),
|
||||||
|
-- UNIQUE INDEX `idx_task_id` (`task_id`)
|
||||||
|
-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ====== 积分字段类型迁移:BIGINT → DECIMAL(20,2),与红包金额统一精度 ======
|
||||||
|
-- 历史BIGINT都是整数,DECIMAL(20,2)可无损容纳,无需特殊转换
|
||||||
|
ALTER TABLE `user` MODIFY COLUMN `points` DECIMAL(20,2) DEFAULT 0 COMMENT '积分余额';
|
||||||
|
ALTER TABLE `points_flow` MODIFY COLUMN `amount` DECIMAL(20,2) NOT NULL COMMENT '变动数值(正负)';
|
||||||
|
ALTER TABLE `points_flow` MODIFY COLUMN `balance_before` DECIMAL(20,2) NOT NULL COMMENT '变动前余额';
|
||||||
|
ALTER TABLE `points_flow` MODIFY COLUMN `balance_after` DECIMAL(20,2) NOT NULL COMMENT '变动后余额';
|
||||||
|
|
||||||
|
-- ====== 注单金额字段类型迁移:INT/BIGINT → DECIMAL(20,2),与积分/红包统一精度 ======
|
||||||
|
ALTER TABLE `bet_order` MODIFY COLUMN `bet_amount` DECIMAL(20,2) NOT NULL COMMENT '下注金额';
|
||||||
|
ALTER TABLE `bet_order` MODIFY COLUMN `win_amount` DECIMAL(20,2) DEFAULT 0 COMMENT '中奖金额';
|
||||||
Reference in New Issue
Block a user