diff --git a/Excel导入使用说明.md b/Excel导入使用说明.md deleted file mode 100644 index d0f52af..0000000 --- a/Excel导入使用说明.md +++ /dev/null @@ -1,484 +0,0 @@ -# Excel数据导入功能使用说明 - -## 功能概述 - -本系统提供了Excel数据导入功能,可以将包含T1、T2、T3、T4、T5、T6、T7、T8、T10和T11工作表的Excel文件数据导入到十四个数据库表中: - -### 红球数据(T1 Sheet): -- `history_all` - 红球全部历史数据表 -- `history_100` - 红球最近100期数据表 -- `history_top` - 红球历史数据排行表 -- `history_top_100` - 红球100期数据排行表 - -### 蓝球数据(T2 Sheet): -- `blue_history_all` - 蓝球全部历史数据表 -- `blue_history_100` - 蓝球最近100期数据表 -- `blue_history_top` - 蓝球历史数据排行表 -- `blue_history_top_100` - 蓝球100期数据排行表 - -### 红球线系数数据(T3 Sheet): -- `t3` - 红球组红球的线系数表 - -### 蓝球组红球线系数数据(T4 Sheet): -- `t4` - 蓝球组红球的线系数表 - -### 蓝球组蓝球线系数数据(T5 Sheet): -- `t5` - 蓝球组蓝球的线系数表 - -### 红球组蓝球线系数数据(T6 Sheet): -- `t6` - 红球组蓝球的线系数表 - -### 红球组红球面系数数据(T7 Sheet): -- `t7` - 红球组红球的面系数表 - -### 红球组蓝球面系数数据(T8 Sheet): -- `t8` - 红球组蓝球的面系数表 - -### 彩票开奖信息数据(T10 Sheet): -- `lottery_draws` - 彩票开奖信息表 - -### 蓝球组红球面系数数据(T11 Sheet): -- `t11` - 蓝球组红球的面系数表 - -## Excel文件格式要求 - -### 文件要求 -- **文件格式**:必须是`.xlsx`格式 -- **工作表名称**:必须包含名为`T1`、`T2`、`T3`、`T4`、`T5`、`T6`、`T7`、`T8`、`T10`和`T11`的工作表 -- **编码**:支持中文 - -### 数据结构要求 - -**T1工作表(红球数据)**的数据结构如下: - -| 列位置 | 列名 | 对应表 | 字段说明 | -|--------|------|--------|----------| -| A-G | 全部历史数据 | history_all | 球号、出现频次、出现频率%、平均隐现期次、最长隐现期次、最多连出期次、点系数 | -| H-M | 最近100期数据 | history_100 | 出现频次、出现频率%、平均隐现期、当前隐现期、最多连出期次、点系数 | -| N-P | 历史数据排行 | history_top | 排行、球号、点系数 | -| Q-S | 100期数据排行 | history_top_100 | 排行、球号、点系数 | - -**T2工作表(蓝球数据)**的数据结构如下: - -| 列位置 | 列名 | 对应表 | 字段说明 | -|--------|------|--------|----------| -| A-G | 全部历史数据 | blue_history_all | 球号、出现频次、出现频率%、平均隐现期次、最长隐现期次、最多连出期次、点系数 | -| H-M | 最近100期数据 | blue_history_100 | 出现频次、出现频率%、平均隐现期、当前隐现期、最多连出期次、点系数 | -| N-P | 历史数据排行 | blue_history_top | 排行、球号、点系数 | -| Q-S | 100期数据排行 | blue_history_top_100 | 排行、球号、点系数 | - -**T3工作表(红球线系数数据)**的数据结构如下: - -| 列位置 | 数据组织 | 对应表 | 字段说明 | -|--------|----------|--------|----------| -| C | 1号组线系数 | t3 | 主球=1,从球=1-33,线系数=C列 | -| F | 2号组线系数 | t3 | 主球=2,从球=1-33,线系数=F列 | -| I | 3号组线系数 | t3 | 主球=3,从球=1-33,线系数=I列 | -| ... | 依此类推 | t3 | 每三列为一组,组号即主球号 | - -**说明**:T3工作表每三列为一组数据,每组有33行数据,从球号固定为1-33(行号),线系数在C、F、I、L...列。 - -**T4工作表(蓝球组红球线系数数据)**的数据结构如下: - -| 列位置 | 数据组织 | 对应表 | 字段说明 | -|--------|----------|--------|----------| -| C | 蓝球1号线系数 | t4 | 主球=1,从球=1-33,线系数=C列 | -| F | 蓝球2号线系数 | t4 | 主球=2,从球=1-33,线系数=F列 | -| I | 蓝球3号线系数 | t4 | 主球=3,从球=1-33,线系数=I列 | -| ... | 依此类推 | t4 | 每三列为一组,最多16组 | - -**说明**:T4工作表每三列为一组数据,每组有33行数据,蓝球号码1-16(主球),红球号码1-33(从球,行号),线系数在C、F、I、L...列。 - -**T5工作表(蓝球组蓝球线系数数据)**的数据结构如下: - -| 列位置 | 数据组织 | 对应表 | 字段说明 | -|--------|----------|--------|----------| -| C | 蓝球1号线系数 | t5 | 主球=1,从球=1-16,线系数=C列 | -| F | 蓝球2号线系数 | t5 | 主球=2,从球=1-16,线系数=F列 | -| I | 蓝球3号线系数 | t5 | 主球=3,从球=1-16,线系数=I列 | -| ... | 依此类推 | t5 | 每三列为一组,最多16组 | - -**说明**:T5工作表每三列为一组数据,每组有16行数据,蓝球号码1-16(主球和从球),线系数在C、F、I、L...列。 - -**T6工作表(红球组蓝球线系数数据)**的数据结构如下: - -| 列位置 | 数据组织 | 对应表 | 字段说明 | -|--------|----------|--------|----------| -| C | 红球1号线系数 | t6 | 主球=1,从球=1-16,线系数=C列 | -| F | 红球2号线系数 | t6 | 主球=2,从球=1-16,线系数=F列 | -| I | 红球3号线系数 | t6 | 主球=3,从球=1-16,线系数=I列 | -| ... | 依此类推 | t6 | 每三列为一组,最多33组 | - -**说明**:T6工作表每三列为一组数据,每组有16行数据,红球号码1-33(主球),蓝球号码1-16(从球,行号),线系数在C、F、I、L...列。 - -**T7工作表(红球组红球面系数数据)**的数据结构如下: - -| 列位置 | 数据组织 | 对应表 | 字段说明 | -|--------|----------|--------|----------| -| C | 红球1号面系数 | t7 | 主球=1,从球=2-33,面系数=C列 | -| F | 红球2号面系数 | t7 | 主球=2,从球=1,3-33,面系数=F列 | -| I | 红球3号面系数 | t7 | 主球=3,从球=1-2,4-33,面系数=I列 | -| ... | 依此类推 | t7 | 每三列为一组,最多33组 | - -**说明**:T7工作表每三列为一组数据,每组有33行数据,红球号码1-33(主球和从球),面系数在C、F、I、L...列。**特殊处理**:排除自己和自己组合的情况。 - -**Excel数据结构**: -- **第1行**:标题行(1号组、面系数、2号组、面系数...) -- **第2行**:从球1号的数据(对应所有主球的面系数) -- **第3行**:从球2号的数据(对应所有主球的面系数) -- **...** -- **第34行**:从球33号的数据(对应所有主球的面系数) - -**处理逻辑**: -- **1号组(主球1)**: - - 球号列:B列,面系数列:C列 - - 读取所有行,从B列获取从球号,从C列获取面系数 - - 排除对角线(主球1=从球1的情况) -- **2号组(主球2)**: - - 球号列:E列,面系数列:F列 - - 读取所有行,从E列获取从球号,从F列获取面系数 - - 排除对角线(主球2=从球2的情况) -- **依此类推...** - -**关键改进**: -- **动态读取球号**:从Excel的球号列(B、E、H...)读取实际球号,不依赖行号 -- **完整数据覆盖**:读取到Excel的最后一行,确保包含33号球数据 -- **不排除对角线**:读取到什么数据就插入什么数据,包括主球=从球的情况 -- **最终生成**:33×33=1089条记录 - -### 具体列映射 - -#### 红球表映射(T1 Sheet) - -**history_all 表 (列A-G)** -- A列:球号 (ballNumber) -- B列:出现频次 (frequencyCount) -- C列:出现频率% (frequencyPercentage) -- D列:平均隐现期次 (averageInterval) -- E列:最长隐现期次 (maxHiddenInterval) -- F列:最多连出期次 (maxConsecutiveCount) -- G列:点系数 (pointCoefficient) - -**history_100 表 (列H-M,球号使用A列)** -- A列:球号 (ballNumber) -- H列:出现频次 (frequencyCount) -- J列:平均隐现期 (averageInterval) -- K列:当前隐现期 (nowInterval) -- L列:最多连出期次 (maxConsecutiveCount) -- M列:点系数 (pointCoefficient) - -**history_top 表 (列N-P)** -- N列:排行 (no) -- O列:球号 (ballNumber) -- P列:点系数 (pointCoefficient) - -**history_top_100 表 (列Q-S)** -- Q列:排行 (no) -- R列:球号 (ballNumber) -- S列:点系数 (pointCoefficient) - -#### 蓝球表映射(T2 Sheet) - -**blue_history_all 表 (列A-G)** -- A列:球号 (ballNumber) -- B列:出现频次 (frequencyCount) -- C列:出现频率% (frequencyPercentage) -- D列:平均隐现期次 (averageInterval) -- E列:最长隐现期次 (maxHiddenInterval) -- F列:最多连出期次 (maxConsecutiveCount) -- G列:点系数 (pointCoefficient) - -**blue_history_100 表 (列H-M,球号使用A列)** -- A列:球号 (ballNumber) -- H列:出现频次 (frequencyCount) -- J列:平均隐现期 (averageInterval) -- K列:当前隐现期 (nowInterval) -- L列:最多连出期次 (maxConsecutiveCount) -- M列:点系数 (pointCoefficient) - -**blue_history_top 表 (列N-P)** -- N列:排行 (no) -- O列:球号 (ballNumber) -- P列:点系数 (pointCoefficient) - -**blue_history_top_100 表 (列Q-S)** -- Q列:排行 (no) -- R列:球号 (ballNumber) -- S列:点系数 (pointCoefficient) - -#### T3表映射(T3 Sheet) - -**t3 表(红球线系数数据)** -- 数据组织:每三列为一组,每组33行数据 -- 红球号码范围:1-33(主球和从球都是) -- 线系数位置:C、F、I、L...列 - -**数据映射**: -- C列:红球1号线系数(主球=1,从球=1-33,线系数=C列) -- F列:红球2号线系数(主球=2,从球=1-33,线系数=F列) -- I列:红球3号线系数(主球=3,从球=1-33,线系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:主红球号码(1-33) -- slaveBallNumber:从红球号码(固定1-33,对应行号) -- lineCoefficient:线系数(每组第三列,保留两位小数) - -#### T4表映射(T4 Sheet) - -**t4 表(蓝球组红球的线系数)** -- 数据组织:每三列为一组,每组33行数据 -- 蓝球号码范围:1-16(主球) -- 红球号码范围:1-33(从球,对应行号) -- 线系数位置:C、F、I、L...列 - -**数据映射**: -- C列:蓝球1号线系数(主球=1,从球=1-33,线系数=C列) -- F列:蓝球2号线系数(主球=2,从球=1-33,线系数=F列) -- I列:蓝球3号线系数(主球=3,从球=1-33,线系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:蓝球号码(1-16) -- slaveBallNumber:红球号码(固定1-33,对应行号) -- lineCoefficient:线系数(每组第三列,保留两位小数) - -#### T5表映射(T5 Sheet) - -**t5 表(蓝球组蓝球的线系数)** -- 数据组织:每三列为一组,每组16行数据 -- 蓝球号码范围:1-16(主球和从球都是) -- 线系数位置:C、F、I、L...列 - -**数据映射**: -- C列:蓝球1号线系数(主球=1,从球=1-16,线系数=C列) -- F列:蓝球2号线系数(主球=2,从球=1-16,线系数=F列) -- I列:蓝球3号线系数(主球=3,从球=1-16,线系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:主蓝球号码(1-16) -- slaveBallNumber:从蓝球号码(固定1-16,对应行号) -- lineCoefficient:线系数(每组第三列,保留两位小数) - -#### T6表映射(T6 Sheet) - -**t6 表(红球组蓝球的线系数)** -- 数据组织:每三列为一组,每组16行数据 -- 红球号码范围:1-33(主球) -- 蓝球号码范围:1-16(从球) -- 线系数位置:C、F、I、L...列 - -**数据映射**: -- C列:红球1号线系数(主球=1,从球=1-16,线系数=C列) -- F列:红球2号线系数(主球=2,从球=1-16,线系数=F列) -- I列:红球3号线系数(主球=3,从球=1-16,线系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:主红球号码(1-33) -- slaveBallNumber:从蓝球号码(固定1-16,对应行号) -- lineCoefficient:线系数(每组第三列,保留两位小数) - -#### T7表映射(T7 Sheet) - -**t7 表(红球组红球的面系数)** -- 数据组织:每三列为一组,每组33行数据 -- 红球号码范围:1-33(主球和从球都是) -- 面系数位置:C、F、I、L...列 -- 特殊处理:读取到什么数据就插入什么数据,包括对角线 - -**数据映射**: -- C列:红球1号面系数(主球=1,从球=1-33,面系数=C列) -- F列:红球2号面系数(主球=2,从球=1-33,面系数=F列) -- I列:红球3号面系数(主球=3,从球=1-33,面系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:主红球号码(1-33) -- slaveBallNumber:从红球号码(1-33,包括与主球相同的号码) -- faceCoefficient:面系数(每组第三列,保留两位小数) - -#### T8表映射(T8 Sheet) - -**t8 表(红球组蓝球的面系数)** -- 数据组织:每三列为一组,每组16行数据 -- 红球号码范围:1-33(主球) -- 蓝球号码范围:1-16(从球) -- 面系数位置:C、F、I、L...列 - -**数据映射**: -- C列:红球1号面系数(主球=1,从球=1-16,面系数=C列) -- F列:红球2号面系数(主球=2,从球=1-16,面系数=F列) -- I列:红球3号面系数(主球=3,从球=1-16,面系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:主红球号码(1-33) -- slaveBallNumber:从蓝球号码(固定1-16,对应行号) -- faceCoefficient:面系数(每组第三列,保留两位小数) - -#### T10表映射(T10 Sheet) - -**lottery_draws 表(彩票开奖信息)** -- 数据组织:标准表格结构,每行一条开奖记录 -- 开奖期号:Long类型主键 -- 开奖日期:Date类型,支持多种格式 -- 红球1-6:Integer类型 -- 蓝球:Integer类型 - -**数据映射**: -- A列:开奖期号(drawId) -- B列:开奖日期(drawDate) -- C列:红球1(redBall1) -- D列:红球2(redBall2) -- E列:红球3(redBall3) -- F列:红球4(redBall4) -- G列:红球5(redBall5) -- H列:红球6(redBall6) -- I列:蓝球(blueBall) - -**字段映射**: -- drawId:开奖期号(Long类型,主键) -- drawDate:开奖日期(Date类型,支持yyyy-MM-dd、yyyy/MM/dd等格式) -- redBall1-redBall6:红球1-6(Integer类型) -- blueBall:蓝球(Integer类型) - -**数据特性**: -- 所有字段均为必填项 -- 开奖期号为主键,不能重复 -- 日期格式自动识别和转换 -- 数据完整性验证 - -#### T11表映射(T11 Sheet) - -**t11 表(蓝球组红球的面系数)** -- 数据组织:每三列为一组,每组33行数据 -- 蓝球号码范围:1-16(主球) -- 红球号码范围:1-33(从球) -- 面系数位置:C、F、I、L...列 - -**数据映射**: -- C列:蓝球1号面系数(主球=1,从球=1-33,面系数=C列) -- F列:蓝球2号面系数(主球=2,从球=1-33,面系数=F列) -- I列:蓝球3号面系数(主球=3,从球=1-33,面系数=I列) -- 依此类推... - -**字段映射**: -- masterBallNumber:主蓝球号码(1-16) -- slaveBallNumber:从红球号码(固定1-33,对应行号) -- faceCoefficient:面系数(每组第三列,保留两位小数) - -## 使用方法 - -### 1. API接口方式 - -#### 1.1 文件上传导入 -```http -POST /api/excel/upload -Content-Type: multipart/form-data - -参数: -- file: Excel文件 (.xlsx格式) -``` - -#### 1.2 文件路径导入 -```http -POST /api/excel/import-by-path -Content-Type: application/x-www-form-urlencoded - -参数: -- filePath: Excel文件的完整路径 (例如: D:/data/kaifa1.xlsx) -``` - -#### 1.3 获取导入说明 -```http -GET /api/excel/import-info -``` - -### 2. 程序调用方式 - -```java -@Autowired -private ExcelImportService excelImportService; - -// 方式1:通过文件路径导入 -String result = excelImportService.importExcelFileByPath("D:/data/kaifa1.xlsx"); - -// 方式2:通过MultipartFile导入 -String result = excelImportService.importExcelFile(multipartFile); -``` - -### 3. 测试方式 - -运行测试类: -```java -// 运行 ExcelImportTest 类中的测试方法 -@Test -public void testImportExcelByPath() { - // 修改文件路径为实际路径 - String filePath = "D:/code/xy-ai-cpzs/kaifa1.xlsx"; - String result = excelImportService.importExcelFileByPath(filePath); - System.out.println("导入结果:" + result); -} -``` - -## 注意事项 - -1. **数据清空**:每次导入前会清空现有数据,请谨慎操作 -2. **数据验证**:系统会验证数据的完整性,球号为空的记录会被跳过 -3. **错误处理**:导入过程中如有错误会回滚操作并返回错误信息 -4. **日志记录**:导入过程会记录详细日志,便于问题排查 -5. **文件大小**:建议文件大小不超过10MB -6. **并发限制**:避免同时进行多个导入操作 - -## 常见问题 - -### Q1: 提示"未找到T1/T2/T3/T4/T5/T6/T7/T8/T10/T11工作表" -**A**: 请检查Excel文件是否包含名为"T1"(红球数据)、"T2"(蓝球数据)、"T3"(红球线系数)、"T4"(蓝球组红球线系数)、"T5"(蓝球组蓝球线系数)、"T6"(红球组蓝球线系数)、"T7"(红球组红球面系数)、"T8"(红球组蓝球面系数)、"T10"(彩票开奖信息)和"T11"(蓝球组红球面系数)的工作表,注意区分大小写。如果缺少某个工作表,系统会跳过该部分数据并显示警告。 - -### Q2: 导入后数据不完整 -**A**: 请检查Excel数据格式是否正确,确保数值类型的列包含有效数字。 - -### Q3: 导入失败提示文件格式错误 -**A**: 请确保文件是.xlsx格式,不支持.xls格式。 - -### Q4: 如何查看导入日志 -**A**: 导入过程中的日志会输出到控制台,可以通过查看应用日志了解详细信息。 - -### Q5: 报错"Cannot get a NUMERIC value from a STRING cell" -**A**: 这个错误已经修复。系统现在能够自动处理字符串类型的数值单元格,会尝试将字符串转换为数值。 - -### Q6: Excel中有公式单元格怎么办 -**A**: 系统支持公式单元格,会自动读取公式计算后的结果值。 - -### Q7: 单元格为空怎么处理 -**A**: 空白单元格会被自动跳过,对应的字段值会设为null。 - -## 数据类型支持 - -系统支持以下类型的Excel单元格: -- **数值类型** - 直接读取数值 -- **字符串类型** - 尝试转换为数值(如果包含数字) -- **公式类型** - 读取公式计算结果 -- **空白类型** - 设为null -- **其他类型** - 会记录警告日志并设为null - -## 数据精度处理 - -- **浮点数字段**:自动保留两位小数(使用四舍五入) -- **整数字段**:直接转换为整数(去除小数部分) -- **特殊值处理**:NaN和无穷大值会被设为null - -示例: -- `123.456789` → `123.46` -- `12.1` → `12.10` -- `5` → `5.00` - -## Swagger文档 - -启动应用后,可以通过以下地址访问API文档: -- Swagger UI: http://localhost:8123/api/swagger-ui.html -- Knife4j UI: http://localhost:8123/api/doc.html - -在文档中可以直接测试Excel导入接口。 \ No newline at end of file diff --git a/VIP兑换记录查询API使用说明.md b/VIP兑换记录查询API使用说明.md deleted file mode 100644 index 2d03d6c..0000000 --- a/VIP兑换记录查询API使用说明.md +++ /dev/null @@ -1,182 +0,0 @@ -# VIP兑换记录查询API使用说明 - -## 接口概述 - -本文档描述了VIP兑换记录查询相关的API接口,主要用于查询用户的VIP兑换记录信息。 - -## 接口列表 - -### 1. 获取用户所有兑换记录 - -**接口地址:** `GET /vip-exchange-record/user/{userId}` - -**接口描述:** 根据用户ID获取该用户的所有VIP兑换记录 - -**请求参数:** -- `userId` (必填): 用户ID,路径参数,必须大于0 - -**请求示例:** -```http -GET /vip-exchange-record/user/123 -``` - -**响应示例:** -```json -{ - "code": 0, - "message": "success", - "data": [ - { - "id": 1, - "userId": 123, - "type": "月度会员", - "exchangeMode": 1, - "orderNo": 1234567890123456, - "orderAmount": 0, - "isUse": 1, - "exchangeTime": "2024-01-15T10:30:00", - "updateTime": "2024-01-15T10:30:00" - }, - { - "id": 2, - "userId": 123, - "type": "年度会员", - "exchangeMode": 1, - "orderNo": 1234567890123457, - "orderAmount": 0, - "isUse": 1, - "exchangeTime": "2024-02-15T14:20:00", - "updateTime": "2024-02-15T14:20:00" - } - ] -} -``` - -### 2. 分页获取用户兑换记录 - -**接口地址:** `GET /vip-exchange-record/user/{userId}/page` - -**接口描述:** 根据用户ID分页获取该用户的VIP兑换记录 - -**请求参数:** -- `userId` (必填): 用户ID,路径参数,必须大于0 -- `page` (可选): 页码,从1开始,默认为1 -- `size` (可选): 每页大小,默认为10,最大100 - -**请求示例:** -```http -GET /vip-exchange-record/user/123/page?page=1&size=5 -``` - -**响应示例:** -```json -{ - "code": 0, - "message": "success", - "data": [ - { - "id": 1, - "userId": 123, - "type": "月度会员", - "exchangeMode": 1, - "orderNo": 1234567890123456, - "orderAmount": 0, - "isUse": 1, - "exchangeTime": "2024-01-15T10:30:00", - "updateTime": "2024-01-15T10:30:00" - } - ] -} -``` - -### 3. 获取兑换记录详情 - -**接口地址:** `GET /vip-exchange-record/{recordId}` - -**接口描述:** 根据兑换记录ID获取详细信息 - -**请求参数:** -- `recordId` (必填): 兑换记录ID,路径参数,必须大于0 - -**请求示例:** -```http -GET /vip-exchange-record/1 -``` - -**响应示例:** -```json -{ - "code": 0, - "message": "success", - "data": { - "id": 1, - "userId": 123, - "type": "月度会员", - "exchangeMode": 1, - "orderNo": 1234567890123456, - "orderAmount": 0, - "isUse": 1, - "exchangeTime": "2024-01-15T10:30:00", - "updateTime": "2024-01-15T10:30:00" - } -} -``` - -## 数据字段说明 - -### VipExchangeRecord 字段说明 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | Long | 兑换记录唯一标识符 | -| userId | Long | 用户ID | -| type | String | 会员类型(月度会员/年度会员) | -| exchangeMode | Integer | 兑换方式(1-VIP码兑换) | -| orderNo | Long | 订单编号(16位随机数字) | -| orderAmount | Integer | 订单金额(单位:分) | -| isUse | Integer | 是否已兑换(0-未兑换,1-已兑换) | -| exchangeTime | Date | 兑换时间 | -| updateTime | Date | 更新时间 | - -## 响应状态码 - -| 状态码 | 说明 | -|--------|------| -| 0 | 成功 | -| 40000 | 请求参数错误 | -| 40400 | 请求数据不存在 | -| 50000 | 系统内部异常 | - -## 错误响应示例 - -```json -{ - "code": 40000, - "message": "用户ID不能为空且必须大于0", - "data": null -} -``` - -## 使用说明 - -1. **数据排序**: 所有查询结果都按照兑换时间倒序排列(最新的记录在前) - -2. **分页查询**: - - 页码从1开始 - - 每页大小限制在1-100之间 - - 超出范围会使用默认值 - -3. **参数校验**: - - 用户ID和记录ID必须为正整数 - - 参数错误会返回40000状态码 - -4. **异常处理**: - - 系统异常会返回50000状态码 - - 详细错误信息会在message字段中说明 - -## 注意事项 - -1. 接口支持Swagger文档,可通过 `/swagger-ui/index.html` 查看详细文档 -2. 所有接口都有完整的日志记录,便于问题排查 -3. 建议在生产环境中添加认证和权限控制 -4. 分页查询采用内存分页,大数据量时建议使用数据库分页优化 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 4bf350a..7f88913 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,12 @@ 3.1.1281 + + + com.qcloud + cos_api + 5.6.155 + com.alibaba.nls @@ -83,7 +89,12 @@ commons-lang3 3.12.0 - + + + com.tencentcloudapi + tencentcloud-sdk-java + 3.1.1412 + org.springframework.boot diff --git a/sql/add_prize_pool_column.sql b/sql/add_prize_pool_column.sql new file mode 100644 index 0000000..4d04be3 --- /dev/null +++ b/sql/add_prize_pool_column.sql @@ -0,0 +1,11 @@ +-- 为 lottery_draws 表添加奖池字段 +ALTER TABLE `lottery_draws` +ADD COLUMN `prizePool` BIGINT DEFAULT 0 COMMENT '奖池' AFTER `blueBall`; + +-- 为 dlt_draw_record 表添加奖池字段 +ALTER TABLE `dlt_draw_record` +ADD COLUMN `prizePool` BIGINT DEFAULT 0 COMMENT '奖池' AFTER `backBall2`; + +-- 验证字段是否添加成功 +-- SELECT * FROM lottery_draws LIMIT 1; +-- SELECT * FROM dlt_draw_record LIMIT 1; diff --git a/sql/add_special_rule_status.sql b/sql/add_special_rule_status.sql new file mode 100644 index 0000000..b44bb18 --- /dev/null +++ b/sql/add_special_rule_status.sql @@ -0,0 +1,9 @@ +-- 为双色球开奖记录表添加特别规定期间标识字段 +-- 用于标识该期是否处于特别规定期间(福运奖期间) + +ALTER TABLE lottery_draws ADD COLUMN is_special_period TINYINT DEFAULT 0 COMMENT '是否处于特别规定期间:0-否,1-是'; + +-- 更新说明: +-- 特别规定启动条件:当奖池资金 >= 15亿元时开始执行 +-- 特别规定停止条件:执行特别规定后,某期开奖后奖池资金 < 3亿元时停止 +-- 在特别规定期间,3个红球匹配可获得福运奖(5元) diff --git a/sql/ddl.sql b/sql/ddl.sql index 91071d1..db189f8 100644 --- a/sql/ddl.sql +++ b/sql/ddl.sql @@ -166,7 +166,8 @@ CREATE TABLE IF NOT EXISTS `lottery_draws` ( `redBall4` INT NOT NULL COMMENT '红4', `redBall5` INT NOT NULL COMMENT '红5', `redBall6` INT NOT NULL COMMENT '红6', - `blueBall` INT NOT NULL COMMENT '蓝球' + `blueBall` INT NOT NULL COMMENT '蓝球', + `prizePool` BIGINT NULL COMMENT '奖池' ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '彩票开奖信息表'; @@ -183,6 +184,10 @@ create table if not exists user userPassword varchar(512) not null comment '密码', isVip int default 0 not null comment '是否会员:0-非会员,1-会员', vipExpire datetime null comment '会员到期时间', + vipType varchar(50) default '体验会员' null comment '套餐类别(体验会员/月度会员/年度会员)', + location varchar(100) null comment '所在省市', + preference varchar(100) null comment '彩票偏好(双色球/大乐透等)', + channel varchar(100) null comment '获客渠道', # vipNum int not null comment '会员编号', `status` tinyint DEFAULT '0' COMMENT '状态0正常1不正常', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', @@ -273,7 +278,8 @@ CREATE TABLE IF NOT EXISTS `dlt_draw_record` ( `frontBall4` INT NOT NULL COMMENT '前区4', `frontBall5` INT NOT NULL COMMENT '前区5', `backBall1` INT NOT NULL COMMENT '后区1', - `backBall2` INT NOT NULL COMMENT '后区2' + `backBall2` INT NOT NULL COMMENT '后区2', + `prizePool` BIGINT NULL COMMENT '奖池' ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透开奖信息表'; @@ -438,3 +444,30 @@ CREATE TABLE IF NOT EXISTS `dlt_predict_record` ( `bonus` BIGINT default 0 NOT NULL COMMENT '奖金' ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透推测记录表'; + + +-- 公告管理表 +CREATE TABLE IF NOT EXISTS `announcement` ( + `id` BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, + `title` VARCHAR(200) NOT NULL COMMENT '公告标题', + `content` TEXT NOT NULL COMMENT '公告详情内容', + `publisherId` BIGINT NOT NULL COMMENT '发布人ID', + `publisherName` VARCHAR(256) NOT NULL COMMENT '发布人名称', + `status` TINYINT DEFAULT 1 NOT NULL COMMENT '公告状态:0-草稿,1-已发布,2-已下架', + `priority` TINYINT DEFAULT 0 NOT NULL COMMENT '优先级:0-普通,1-置顶', + `viewCount` INT DEFAULT 0 NOT NULL COMMENT '浏览次数', + `createTime` DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间', + `updateTime` DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `isDelete` TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除:0-未删除,1-已删除' +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT = '公告管理表'; + +-- 为已存在的user表添加新字段 +ALTER TABLE `user` + ADD COLUMN `vipType` VARCHAR(50) DEFAULT '体验会员' NULL COMMENT '套餐类别(体验会员/月度会员/年度会员)' AFTER `vipExpire`, + ADD COLUMN `location` VARCHAR(100) NULL COMMENT '所在省市' AFTER `vipType`, + ADD COLUMN `preference` VARCHAR(100) NULL COMMENT '彩票偏好(双色球/大乐透等)' AFTER `location`, + ADD COLUMN `channel` VARCHAR(100) NULL COMMENT '获客渠道' AFTER `preference`; + +ALTER TABLE `user` + ADD COLUMN `vipType` VARCHAR(50) DEFAULT '体验会员' NULL COMMENT '套餐类别(体验会员/月度会员/年度会员)' AFTER `vipExpire` diff --git a/sql/update_prize_pool_to_decimal.sql b/sql/update_prize_pool_to_decimal.sql new file mode 100644 index 0000000..a78b9f1 --- /dev/null +++ b/sql/update_prize_pool_to_decimal.sql @@ -0,0 +1,14 @@ +-- 修改奖池字段类型:从 BIGINT 改为 DECIMAL(10,2) +-- 单位从"元"改为"亿元" + +-- 修改 lottery_draws 表的奖池字段 +ALTER TABLE `lottery_draws` +MODIFY COLUMN `prizePool` DECIMAL(10,2) DEFAULT 0.00 COMMENT '奖池(单位:亿元)'; + +-- 修改 dlt_draw_record 表的奖池字段 +ALTER TABLE `dlt_draw_record` +MODIFY COLUMN `prizePool` DECIMAL(10,2) DEFAULT 0.00 COMMENT '奖池(单位:亿元)'; + +-- 验证字段类型是否修改成功 +-- SHOW COLUMNS FROM lottery_draws LIKE 'prizePool'; +-- SHOW COLUMNS FROM dlt_draw_record LIKE 'prizePool'; diff --git a/sql/user_viptype_update.sql b/sql/user_viptype_update.sql new file mode 100644 index 0000000..2954fcf --- /dev/null +++ b/sql/user_viptype_update.sql @@ -0,0 +1,93 @@ +-- ============================================ +-- 用户表vipType字段更新SQL脚本 +-- 功能:为user表添加vipType等字段,并设置默认值 +-- 日期:2026-01-26 +-- ============================================ + +-- 方案一:为已存在的user表添加新字段(推荐用于已有数据的表) +-- 如果字段已存在,请先删除后再添加,或者直接跳过此步骤 + +-- 添加vipType字段,默认值为'体验会员' +ALTER TABLE `user` + ADD COLUMN `vipType` VARCHAR(50) DEFAULT '体验会员' NULL COMMENT '套餐类别(体验会员/月度会员/年度会员)' AFTER `vipExpire`; + +-- 添加location字段 +ALTER TABLE `user` + ADD COLUMN `location` VARCHAR(100) NULL COMMENT '所在省市' AFTER `vipType`; + +-- 添加preference字段 +ALTER TABLE `user` + ADD COLUMN `preference` VARCHAR(100) NULL COMMENT '彩票偏好(双色球/大乐透等)' AFTER `location`; + +-- 添加channel字段 +ALTER TABLE `user` + ADD COLUMN `channel` VARCHAR(100) NULL COMMENT '获客渠道' AFTER `preference`; + +-- ============================================ +-- 如果字段已存在但需要修改默认值,使用以下语句 +-- ============================================ + +-- 修改vipType字段,设置默认值为'体验会员' +ALTER TABLE `user` + MODIFY COLUMN `vipType` VARCHAR(50) DEFAULT '体验会员' NULL COMMENT '套餐类别(体验会员/月度会员/年度会员)'; + +-- ============================================ +-- 为已有用户设置默认vipType(可选) +-- ============================================ + +-- 将所有vipType为NULL的用户设置为'体验会员' +UPDATE `user` SET `vipType` = '体验会员' WHERE `vipType` IS NULL; + +-- 将所有非会员用户设置为'体验会员' +UPDATE `user` SET `vipType` = '体验会员' WHERE `isVip` = 0 AND (`vipType` IS NULL OR `vipType` = ''); + +-- ============================================ +-- 验证SQL +-- ============================================ + +-- 查看user表结构 +DESC `user`; + +-- 查看vipType字段的分布情况 +SELECT `vipType`, COUNT(*) as count FROM `user` GROUP BY `vipType`; + +-- 查看会员类型分布 +SELECT + `isVip`, + `vipType`, + COUNT(*) as count, + CASE + WHEN `vipExpire` IS NULL THEN '无到期时间' + WHEN `vipExpire` < NOW() THEN '已过期' + ELSE '未过期' + END as expire_status +FROM `user` +GROUP BY `isVip`, `vipType`, expire_status; + +-- ============================================ +-- 说明 +-- ============================================ +-- vipType字段取值说明: +-- 1. '体验会员' - 默认值,所有新注册用户和非会员用户 +-- 2. '月度会员' - 激活码时效 < 12个月的用户 +-- 3. '年度会员' - 激活码时效 >= 12个月的用户 +-- +-- 激活会员码时的逻辑: +-- - 如果激活码时效 < 12个月,设置vipType为'月度会员' +-- - 如果激活码时效 >= 12个月,设置vipType为'年度会员' +-- ============================================ + + + + -- 为 lottery_draws 表添加奖池字段 +ALTER TABLE `lottery_draws` + ADD COLUMN `prizePool` BIGINT DEFAULT 0 COMMENT '奖池' AFTER `blueBall`; + +-- 为 dlt_draw_record 表添加奖池字段 +ALTER TABLE `dlt_draw_record` + ADD COLUMN `prizePool` BIGINT DEFAULT 0 COMMENT '奖池' AFTER `backBall2`; + +-- 验证字段是否添加成功 +-- SELECT * FROM lottery_draws LIMIT 1; +-- SELECT * FROM dlt_draw_record LIMIT 1; + diff --git a/src/main/java/com/xy/xyaicpzs/Main.java b/src/main/java/com/xy/xyaicpzs/Main.java deleted file mode 100644 index 5cccccd..0000000 --- a/src/main/java/com/xy/xyaicpzs/Main.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.xy.xyaicpzs; - -import com.alibaba.dashscope.app.*; -import com.alibaba.dashscope.exception.ApiException; -import com.alibaba.dashscope.exception.InputRequiredException; -import com.alibaba.dashscope.exception.NoApiKeyException; -import io.reactivex.Flowable; - -import java.util.Arrays; -import java.util.List; - -public class Main { - - public static void streamCall() throws NoApiKeyException, InputRequiredException { - ApplicationParam param = ApplicationParam.builder() - // 若没有配置环境变量,可用百炼API Key将下行替换为:.apiKey("sk-xxx")。但不建议在生产环境中直接将API Key硬编码到代码中,以减少API Key泄露风险。 - .apiKey(System.getenv("DASHSCOPE_API_KEY")) - // 替换为实际的应用 ID - .appId("ec08d5b81ca248e8834228c1133e2c78") - .prompt("你是谁") - // 增量输出 - .incrementalOutput(true) - .build(); - Application application = new Application(); - // .streamCall():流式输出内容 - Flowable result = application.streamCall(param); - result.blockingForEach(data -> { - System.out.printf("%s\n", - data.getOutput().getText()); -// System.out.println("session_id: " + data.getOutput().getSessionId()); - }); - } - public static void main(String[] args) { - try { - streamCall(); - } catch (ApiException | NoApiKeyException | InputRequiredException e) { - System.out.printf("Exception: %s", e.getMessage()); - System.out.println("请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"); - } - System.exit(0); - } -} \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementAddRequest.java b/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementAddRequest.java new file mode 100644 index 0000000..05bdbe0 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementAddRequest.java @@ -0,0 +1,36 @@ +package com.xy.xyaicpzs.common.requset; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 公告添加请求 + */ +@Data +@Schema(description = "公告添加请求") +public class AnnouncementAddRequest { + + /** + * 公告标题 + */ + @Schema(description = "公告标题", required = true) + private String title; + + /** + * 公告详情内容 + */ + @Schema(description = "公告详情内容", required = true) + private String content; + + /** + * 公告状态:0-草稿,1-已发布,2-已下架 + */ + @Schema(description = "公告状态:0-草稿,1-已发布,2-已下架,默认为1-已发布") + private Integer status = 1; + + /** + * 优先级:0-普通,1-置顶 + */ + @Schema(description = "优先级:0-普通,1-置顶,默认为0-普通") + private Integer priority = 0; +} diff --git a/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementQueryRequest.java b/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementQueryRequest.java new file mode 100644 index 0000000..e8e5b21 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementQueryRequest.java @@ -0,0 +1,54 @@ +package com.xy.xyaicpzs.common.requset; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 公告查询请求 + */ +@Data +@Schema(description = "公告查询请求") +public class AnnouncementQueryRequest { + + /** + * 当前页码 + */ + @Schema(description = "当前页码") + private long current = 1; + + /** + * 页面大小 + */ + @Schema(description = "页面大小") + private long pageSize = 10; + + /** + * 公告标题(模糊查询) + */ + @Schema(description = "公告标题(模糊查询)") + private String title; + + /** + * 公告状态:0-草稿,1-已发布,2-已下架 + */ + @Schema(description = "公告状态:0-草稿,1-已发布,2-已下架") + private Integer status; + + /** + * 优先级:0-普通,1-置顶 + */ + @Schema(description = "优先级:0-普通,1-置顶") + private Integer priority; + + /** + * 发布人ID + */ + @Schema(description = "发布人ID") + private Long publisherId; + + /** + * 发布人名称(模糊查询) + */ + @Schema(description = "发布人名称(模糊查询)") + private String publisherName; +} diff --git a/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementUpdateRequest.java b/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementUpdateRequest.java new file mode 100644 index 0000000..141f6ac --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/common/requset/AnnouncementUpdateRequest.java @@ -0,0 +1,42 @@ +package com.xy.xyaicpzs.common.requset; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 公告更新请求 + */ +@Data +@Schema(description = "公告更新请求") +public class AnnouncementUpdateRequest { + + /** + * 唯一标识符 + */ + @Schema(description = "唯一标识符", required = true) + private Long id; + + /** + * 公告标题 + */ + @Schema(description = "公告标题") + private String title; + + /** + * 公告详情内容 + */ + @Schema(description = "公告详情内容") + private String content; + + /** + * 公告状态:0-草稿,1-已发布,2-已下架 + */ + @Schema(description = "公告状态:0-草稿,1-已发布,2-已下架") + private Integer status; + + /** + * 优先级:0-普通,1-置顶 + */ + @Schema(description = "优先级:0-普通,1-置顶") + private Integer priority; +} diff --git a/src/main/java/com/xy/xyaicpzs/config/CosConfig.java b/src/main/java/com/xy/xyaicpzs/config/CosConfig.java new file mode 100644 index 0000000..48563ee --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/config/CosConfig.java @@ -0,0 +1,63 @@ +package com.xy.xyaicpzs.config; + +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.region.Region; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 腾讯云 COS 配置类 + * + * @author xy + */ +@Configuration +@ConfigurationProperties(prefix = "cos") +@Data +public class CosConfig { + + /** + * SecretId + */ + private String secretId; + + /** + * SecretKey + */ + private String secretKey; + + /** + * 存储桶名称 + */ + private String bucketName; + + /** + * 地域 + */ + private String region; + + /** + * 访问域名 + */ + private String domain; + + /** + * 创建 COSClient 实例 + */ + @Bean + public COSClient cosClient() { + // 初始化用户身份信息(secretId, secretKey) + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + + // 设置 bucket 的地域 + Region regionObj = new Region(region); + ClientConfig clientConfig = new ClientConfig(regionObj); + + // 生成 cos 客户端 + return new COSClient(cred, clientConfig); + } +} diff --git a/src/main/java/com/xy/xyaicpzs/config/JacksonConfig.java b/src/main/java/com/xy/xyaicpzs/config/JacksonConfig.java new file mode 100644 index 0000000..ee8ce11 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/config/JacksonConfig.java @@ -0,0 +1,37 @@ +package com.xy.xyaicpzs.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import java.math.BigInteger; + +/** + * Jackson 配置类 + * 解决前端无法接收完整 Long 类型 ID 的问题 + * + * @author xy + */ +@Configuration +public class JacksonConfig { + + /** + * Jackson 全局配置 + * 将 Long、BigInteger 类型序列化为字符串,避免前端精度丢失 + * + * @return Jackson2ObjectMapperBuilderCustomizer + */ + @Bean + public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { + return jacksonObjectMapperBuilder -> { + jacksonObjectMapperBuilder.serializerByType(Long.class, ToStringSerializer.instance); + jacksonObjectMapperBuilder.serializerByType(Long.TYPE, ToStringSerializer.instance); + jacksonObjectMapperBuilder.serializerByType(BigInteger.class, ToStringSerializer.instance); + }; + } +} + diff --git a/src/main/java/com/xy/xyaicpzs/config/ObjectMapperConfig.java b/src/main/java/com/xy/xyaicpzs/config/ObjectMapperConfig.java index a4242f3..0859ebe 100644 --- a/src/main/java/com/xy/xyaicpzs/config/ObjectMapperConfig.java +++ b/src/main/java/com/xy/xyaicpzs/config/ObjectMapperConfig.java @@ -1,22 +1,43 @@ package com.xy.xyaicpzs.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.math.BigInteger; /** * ObjectMapper配置类 + * 解决Long类型精度丢失问题 */ @Configuration public class ObjectMapperConfig { /** * 配置ObjectMapper Bean + * 将Long类型序列化为String,避免前端JavaScript精度丢失 * * @return ObjectMapper实例 */ @Bean + @Primary public ObjectMapper objectMapper() { - return new ObjectMapper(); + ObjectMapper objectMapper = new ObjectMapper(); + + // 创建自定义模块 + SimpleModule simpleModule = new SimpleModule(); + + // 将Long类型序列化为String + simpleModule.addSerializer(Long.class, ToStringSerializer.instance); + simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance); + simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance); + + // 注册模块 + objectMapper.registerModule(simpleModule); + + return objectMapper; } } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/constant/UserConstant.java b/src/main/java/com/xy/xyaicpzs/constant/UserConstant.java index 9db1298..1b18a1f 100644 --- a/src/main/java/com/xy/xyaicpzs/constant/UserConstant.java +++ b/src/main/java/com/xy/xyaicpzs/constant/UserConstant.java @@ -9,6 +9,21 @@ public interface UserConstant { * 用户登录态键 */ String USER_LOGIN_STATE = "userLoginState"; + + /** + * 用户登录token键(存储在Session中) + */ + String USER_LOGIN_TOKEN = "userLoginToken"; + + /** + * Redis中用户登录token的key前缀 + */ + String REDIS_USER_LOGIN_TOKEN_PREFIX = "user:login:token:"; + + /** + * 用户登录token过期时间(秒),24小时 + */ + long USER_LOGIN_TOKEN_EXPIRE = 86400; /** * 系统用户 id(虚拟用户) diff --git a/src/main/java/com/xy/xyaicpzs/controller/AnnouncementController.java b/src/main/java/com/xy/xyaicpzs/controller/AnnouncementController.java new file mode 100644 index 0000000..f75ac65 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/controller/AnnouncementController.java @@ -0,0 +1,630 @@ +package com.xy.xyaicpzs.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xy.xyaicpzs.common.ErrorCode; +import com.xy.xyaicpzs.common.ResultUtils; +import com.xy.xyaicpzs.common.requset.AnnouncementAddRequest; +import com.xy.xyaicpzs.common.requset.AnnouncementQueryRequest; +import com.xy.xyaicpzs.common.requset.AnnouncementUpdateRequest; +import com.xy.xyaicpzs.common.response.ApiResponse; +import com.xy.xyaicpzs.domain.entity.Announcement; +import com.xy.xyaicpzs.domain.entity.User; +import com.xy.xyaicpzs.domain.vo.AnnouncementVO; +import com.xy.xyaicpzs.exception.BusinessException; +import com.xy.xyaicpzs.service.AnnouncementService; +import com.xy.xyaicpzs.service.OperationHistoryService; +import com.xy.xyaicpzs.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 公告管理接口 + */ +@Slf4j +@RestController +@RequestMapping("/announcement") +@Tag(name = "公告管理", description = "公告管理相关接口") +public class AnnouncementController { + + @Autowired + private AnnouncementService announcementService; + + @Autowired + private UserService userService; + + @Autowired + private OperationHistoryService operationHistoryService; + + /** + * 添加公告 + * + * @param request 公告添加请求 + * @param httpServletRequest Http请求 + * @return 添加成功的公告ID + */ + @PostMapping("/add") + @Operation(summary = "添加公告", description = "管理员添加新公告") + public ApiResponse addAnnouncement(@RequestBody AnnouncementAddRequest request, + HttpServletRequest httpServletRequest) { + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + Long userId = loginUser.getId(); + String userName = loginUser.getUserName(); + + // 参数校验 + if (request == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数不能为空"); + } + if (StringUtils.isBlank(request.getTitle())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告标题不能为空"); + } + if (StringUtils.isBlank(request.getContent())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告内容不能为空"); + } + if (request.getTitle().length() > 200) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告标题不能超过200个字符"); + } + + try { + Announcement announcement = new Announcement(); + BeanUtils.copyProperties(request, announcement); + announcement.setPublisherId(userId); + announcement.setPublisherName(userName); + announcement.setViewCount(0); + + boolean result = announcementService.save(announcement); + if (!result) { + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "添加公告失败"); + } + + // 记录操作历史 - 成功 + String resultMessage = String.format("%s成功添加公告:%s", userName, request.getTitle()); + operationHistoryService.recordOperation(userId, "添加公告", 1, "成功", resultMessage); + + log.info("管理员{}添加公告成功,公告ID:{}", userName, announcement.getId()); + return ResultUtils.success(announcement.getId()); + + } catch (Exception e) { + log.error("添加公告失败:{}", e.getMessage(), e); + + // 记录操作历史 - 失败 + String resultMessage = String.format("%s添加公告失败:%s,公告标题:%s", + userName, e.getMessage(), request.getTitle()); + operationHistoryService.recordOperation(userId, "添加公告", 1, "失败", resultMessage); + + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "添加公告失败:" + e.getMessage()); + } + } + + /** + * 更新公告 + * + * @param request 公告更新请求 + * @param httpServletRequest Http请求 + * @return 更新结果 + */ + @PostMapping("/update") + @Operation(summary = "更新公告", description = "管理员更新公告信息") + public ApiResponse updateAnnouncement(@RequestBody AnnouncementUpdateRequest request, + HttpServletRequest httpServletRequest) { + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + Long userId = loginUser.getId(); + String userName = loginUser.getUserName(); + + // 参数校验 + if (request == null || request.getId() == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数不能为空"); + } + if (request.getTitle() != null && request.getTitle().length() > 200) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告标题不能超过200个字符"); + } + + try { + // 检查公告是否存在 + Announcement existingAnnouncement = announcementService.getById(request.getId()); + if (existingAnnouncement == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "公告不存在"); + } + + Announcement announcement = new Announcement(); + BeanUtils.copyProperties(request, announcement); + boolean result = announcementService.updateById(announcement); + + if (!result) { + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新公告失败"); + } + + // 记录操作历史 - 成功 + String resultMessage = String.format("%s成功更新公告:%s(ID:%d)", + userName, existingAnnouncement.getTitle(), request.getId()); + operationHistoryService.recordOperation(userId, "更新公告", 1, "成功", resultMessage); + + log.info("管理员{}更新公告成功,公告ID:{}", userName, request.getId()); + return ResultUtils.success(true); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("更新公告失败:{}", e.getMessage(), e); + + // 记录操作历史 - 失败 + String resultMessage = String.format("%s更新公告失败:%s,公告ID:%d", + userName, e.getMessage(), request.getId()); + operationHistoryService.recordOperation(userId, "更新公告", 1, "失败", resultMessage); + + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新公告失败:" + e.getMessage()); + } + } + + /** + * 删除公告(逻辑删除) + * + * @param id 公告ID + * @param httpServletRequest Http请求 + * @return 删除结果 + */ + @DeleteMapping("/delete/{id}") + @Operation(summary = "删除公告", description = "管理员删除公告(逻辑删除)") + public ApiResponse deleteAnnouncement(@Parameter(description = "公告ID", required = true) + @PathVariable Long id, + HttpServletRequest httpServletRequest) { + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + Long userId = loginUser.getId(); + String userName = loginUser.getUserName(); + + if (id == null || id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告ID无效"); + } + + try { + // 检查公告是否存在 + Announcement announcement = announcementService.getById(id); + if (announcement == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "公告不存在"); + } + + // 逻辑删除 + UpdateWrapper updateWrapper = new UpdateWrapper<>(); + updateWrapper.eq("id", id); + updateWrapper.set("isDelete", 1); + boolean result = announcementService.update(updateWrapper); + + if (!result) { + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除公告失败"); + } + + // 记录操作历史 - 成功 + String resultMessage = String.format("%s成功删除公告:%s(ID:%d)", + userName, announcement.getTitle(), id); + operationHistoryService.recordOperation(userId, "删除公告", 1, "成功", resultMessage); + + log.info("管理员{}删除公告成功,公告ID:{}", userName, id); + return ResultUtils.success(true); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("删除公告失败:{}", e.getMessage(), e); + + // 记录操作历史 - 失败 + String resultMessage = String.format("%s删除公告失败:%s,公告ID:%d", + userName, e.getMessage(), id); + operationHistoryService.recordOperation(userId, "删除公告", 1, "失败", resultMessage); + + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除公告失败:" + e.getMessage()); + } + } + + /** + * 分页获取公告列表 + * + * @param queryRequest 查询请求 + * @param httpServletRequest Http请求 + * @return 分页公告列表 + */ + @GetMapping("/list/page") + @Operation(summary = "分页获取公告列表", description = "分页获取公告列表,支持多条件查询") + public ApiResponse> listAnnouncementsByPage(AnnouncementQueryRequest queryRequest, + HttpServletRequest httpServletRequest) { + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + if (queryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数不能为空"); + } + + long current = queryRequest.getCurrent(); + long pageSize = queryRequest.getPageSize(); + + // 构建查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("isDelete", 0); // 只查询未删除的 + + // 标题模糊查询 + if (StringUtils.isNotBlank(queryRequest.getTitle())) { + queryWrapper.like("title", queryRequest.getTitle()); + } + + // 状态筛选 + if (queryRequest.getStatus() != null) { + queryWrapper.eq("status", queryRequest.getStatus()); + } + + // 优先级筛选 + if (queryRequest.getPriority() != null) { + queryWrapper.eq("priority", queryRequest.getPriority()); + } + + // 发布人ID筛选 + if (queryRequest.getPublisherId() != null) { + queryWrapper.eq("publisherId", queryRequest.getPublisherId()); + } + + // 发布人名称模糊查询 + if (StringUtils.isNotBlank(queryRequest.getPublisherName())) { + queryWrapper.like("publisherName", queryRequest.getPublisherName()); + } + + // 排序:优先级降序,然后创建时间降序 + queryWrapper.orderByDesc("priority", "createTime"); + + // 执行分页查询 + Page announcementPage = announcementService.page(new Page<>(current, pageSize), queryWrapper); + + // 转换为VO对象 + List announcementVOList = announcementPage.getRecords().stream().map(announcement -> { + AnnouncementVO announcementVO = new AnnouncementVO(); + BeanUtils.copyProperties(announcement, announcementVO); + return announcementVO; + }).collect(Collectors.toList()); + + // 创建VO分页对象 + Page announcementVOPage = new Page<>(announcementPage.getCurrent(), + announcementPage.getSize(), + announcementPage.getTotal()); + announcementVOPage.setRecords(announcementVOList); + announcementVOPage.setPages(announcementPage.getPages()); + + return ResultUtils.success(announcementVOPage); + } + + /** + * 获取单个公告详情 + * + * @param id 公告ID + * @param httpServletRequest Http请求 + * @return 公告详情 + */ + @GetMapping("/get/{id}") + @Operation(summary = "获取公告详情", description = "根据ID获取公告详情") + public ApiResponse getAnnouncement(@Parameter(description = "公告ID", required = true) + @PathVariable Long id, + HttpServletRequest httpServletRequest) { + if (id == null || id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告ID无效"); + } + + try { + Announcement announcement = announcementService.getById(id); + if (announcement == null || announcement.getIsDelete() == 1) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "公告不存在"); + } + + AnnouncementVO announcementVO = new AnnouncementVO(); + BeanUtils.copyProperties(announcement, announcementVO); + + return ResultUtils.success(announcementVO); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("获取公告详情失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取公告详情失败:" + e.getMessage()); + } + } + + /** + * 更新公告状态 + * + * @param id 公告ID + * @param status 状态:0-草稿,1-已发布,2-已下架 + * @param httpServletRequest Http请求 + * @return 更新结果 + */ + @PutMapping("/status/{id}") + @Operation(summary = "更新公告状态", description = "管理员更新公告状态(发布/下架)") + public ApiResponse updateAnnouncementStatus( + @Parameter(description = "公告ID", required = true) @PathVariable Long id, + @Parameter(description = "状态:0-草稿,1-已发布,2-已下架", required = true) @RequestParam Integer status, + HttpServletRequest httpServletRequest) { + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + Long userId = loginUser.getId(); + String userName = loginUser.getUserName(); + + if (id == null || id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告ID无效"); + } + if (status == null || status < 0 || status > 2) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "状态值无效"); + } + + try { + // 检查公告是否存在 + Announcement announcement = announcementService.getById(id); + if (announcement == null || announcement.getIsDelete() == 1) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "公告不存在"); + } + + // 更新状态 + UpdateWrapper updateWrapper = new UpdateWrapper<>(); + updateWrapper.eq("id", id); + updateWrapper.set("status", status); + boolean result = announcementService.update(updateWrapper); + + if (!result) { + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新公告状态失败"); + } + + // 记录操作历史 - 成功 + String statusText = status == 0 ? "草稿" : (status == 1 ? "已发布" : "已下架"); + String resultMessage = String.format("%s成功将公告状态更新为%s:%s(ID:%d)", + userName, statusText, announcement.getTitle(), id); + operationHistoryService.recordOperation(userId, "更新公告状态", 1, "成功", resultMessage); + + log.info("管理员{}更新公告状态成功,公告ID:{},新状态:{}", userName, id, statusText); + return ResultUtils.success(true); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("更新公告状态失败:{}", e.getMessage(), e); + + // 记录操作历史 - 失败 + String resultMessage = String.format("%s更新公告状态失败:%s,公告ID:%d", + userName, e.getMessage(), id); + operationHistoryService.recordOperation(userId, "更新公告状态", 1, "失败", resultMessage); + + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新公告状态失败:" + e.getMessage()); + } + } + + /** + * 增加公告浏览次数 + * + * @param id 公告ID + * @return 更新结果 + */ + @PutMapping("/view/{id}") + @Operation(summary = "增加浏览次数", description = "用户查看公告时增加浏览次数") + public ApiResponse increaseViewCount(@Parameter(description = "公告ID", required = true) + @PathVariable Long id) { + if (id == null || id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "公告ID无效"); + } + + try { + Announcement announcement = announcementService.getById(id); + if (announcement == null || announcement.getIsDelete() == 1) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "公告不存在"); + } + + // 增加浏览次数 + UpdateWrapper updateWrapper = new UpdateWrapper<>(); + updateWrapper.eq("id", id); + updateWrapper.setSql("viewCount = viewCount + 1"); + boolean result = announcementService.update(updateWrapper); + + return ResultUtils.success(result); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("增加浏览次数失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "增加浏览次数失败:" + e.getMessage()); + } + } + + /** + * 获取公告统计数据 + * + * @param httpServletRequest Http请求 + * @return 公告统计数据 + */ + @GetMapping("/statistics") + @Operation(summary = "获取公告统计数据", description = "获取公告总数、已发布、草稿和已下架的数量") + public ApiResponse> getAnnouncementStatistics(HttpServletRequest httpServletRequest) { + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + try { + // 总数(未删除的) + QueryWrapper totalWrapper = new QueryWrapper<>(); + totalWrapper.eq("isDelete", 0); + long totalCount = announcementService.count(totalWrapper); + + // 已发布的 + QueryWrapper publishedWrapper = new QueryWrapper<>(); + publishedWrapper.eq("isDelete", 0).eq("status", 1); + long publishedCount = announcementService.count(publishedWrapper); + + // 草稿 + QueryWrapper draftWrapper = new QueryWrapper<>(); + draftWrapper.eq("isDelete", 0).eq("status", 0); + long draftCount = announcementService.count(draftWrapper); + + // 已下架的 + QueryWrapper offlineWrapper = new QueryWrapper<>(); + offlineWrapper.eq("isDelete", 0).eq("status", 2); + long offlineCount = announcementService.count(offlineWrapper); + + // 构造返回结果 + Map statistics = new HashMap<>(); + statistics.put("totalCount", totalCount); + statistics.put("publishedCount", publishedCount); + statistics.put("draftCount", draftCount); + statistics.put("offlineCount", offlineCount); + + return ResultUtils.success(statistics); + + } catch (Exception e) { + log.error("获取公告统计数据失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取公告统计数据失败:" + e.getMessage()); + } + } + + /** + * 获取最新的公告列表(用于首页展示) + * + * @param limit 限制数量,默认5条 + * @return 最新公告列表 + */ + @GetMapping("/latest") + @Operation(summary = "获取最新公告", description = "获取最新的已发布公告列表,用于首页展示") + public ApiResponse> getLatestAnnouncements( + @Parameter(description = "限制数量,默认5条") @RequestParam(defaultValue = "5") Integer limit) { + if (limit == null || limit <= 0) { + limit = 5; + } + if (limit > 20) { + limit = 20; // 最多返回20条 + } + + try { + // 查询已发布的公告,按优先级和创建时间排序 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("isDelete", 0) + .eq("status", 1) // 只查已发布的 + .orderByDesc("priority", "createTime") + .last("LIMIT " + limit); + + List announcements = announcementService.list(queryWrapper); + + // 转换为VO + List announcementVOList = announcements.stream().map(announcement -> { + AnnouncementVO announcementVO = new AnnouncementVO(); + BeanUtils.copyProperties(announcement, announcementVO); + return announcementVO; + }).collect(Collectors.toList()); + + return ResultUtils.success(announcementVOList); + + } catch (Exception e) { + log.error("获取最新公告失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取最新公告失败:" + e.getMessage()); + } + } + + /** + * 获取置顶公告列表 + * + * @param limit 限制数量,默认10条 + * @return 置顶公告列表 + */ + @GetMapping("/top") + @Operation(summary = "获取置顶公告", description = "获取所有置顶的已发布公告列表") + public ApiResponse> getTopAnnouncements( + @Parameter(description = "限制数量,默认10条") @RequestParam(defaultValue = "10") Integer limit) { + if (limit == null || limit <= 0) { + limit = 10; + } + if (limit > 50) { + limit = 50; // 最多返回50条 + } + + try { + // 查询置顶且已发布的公告,按创建时间降序排序 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("isDelete", 0) + .eq("status", 1) // 只查已发布的 + .eq("priority", 1) // 只查置顶的 + .orderByDesc("createTime") + .last("LIMIT " + limit); + + List announcements = announcementService.list(queryWrapper); + + // 转换为VO + List announcementVOList = announcements.stream().map(announcement -> { + AnnouncementVO announcementVO = new AnnouncementVO(); + BeanUtils.copyProperties(announcement, announcementVO); + return announcementVO; + }).collect(Collectors.toList()); + + return ResultUtils.success(announcementVOList); + + } catch (Exception e) { + log.error("获取置顶公告失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取置顶公告失败:" + e.getMessage()); + } + } + + /** + * 用户端获取所有已发布公告(置顶优先) + * + * @return 已发布公告列表,置顶优先 + */ + @GetMapping("/published") + @Operation(summary = "获取所有已发布公告", description = "用户端获取所有已发布的公告,置顶优先,按创建时间降序排序") + public ApiResponse> getPublishedAnnouncements() { + try { + // 查询所有已发布的公告,置顶优先,然后按创建时间降序 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("isDelete", 0) + .eq("status", 1) // 只查已发布的 + .orderByDesc("priority", "createTime"); + + List announcements = announcementService.list(queryWrapper); + + // 转换为VO + List announcementVOList = announcements.stream().map(announcement -> { + AnnouncementVO announcementVO = new AnnouncementVO(); + BeanUtils.copyProperties(announcement, announcementVO); + return announcementVO; + }).collect(Collectors.toList()); + + return ResultUtils.success(announcementVOList); + + } catch (Exception e) { + log.error("获取已发布公告失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取已发布公告失败:" + e.getMessage()); + } + } +} diff --git a/src/main/java/com/xy/xyaicpzs/controller/BallAnalysisController.java b/src/main/java/com/xy/xyaicpzs/controller/BallAnalysisController.java index 605011b..97d6438 100644 --- a/src/main/java/com/xy/xyaicpzs/controller/BallAnalysisController.java +++ b/src/main/java/com/xy/xyaicpzs/controller/BallAnalysisController.java @@ -1,5 +1,7 @@ package com.xy.xyaicpzs.controller; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xy.xyaicpzs.common.ErrorCode; import com.xy.xyaicpzs.common.ResultUtils; import com.xy.xyaicpzs.common.response.ApiResponse; @@ -1744,6 +1746,54 @@ public class BallAnalysisController { } } + /** + * 管理员获取双色球中奖记录统计(返回每一条中奖记录) + * @param userId 用户ID(可选) + * @param prizeGrade 奖项等级(可选) + * @param current 当前页码 + * @param pageSize 每页大小 + * @param request HTTP请求 + * @return 分页的中奖记录列表 + */ + @GetMapping("/admin/prize-statistics") + @Operation(summary = "管理员获取中奖记录明细", description = "管理员获取所有用户的双色球中奖记录明细,支持根据用户ID和奖项等级筛选") + public ApiResponse> getAdminPrizeStatistics( + @Parameter(description = "用户ID(可选)") + @RequestParam(value = "userId", required = false) Long userId, + @Parameter(description = "奖项等级(可选),例如:一等奖、二等奖") + @RequestParam(value = "prizeGrade", required = false) String prizeGrade, + @Parameter(description = "当前页码,从1开始,默认为1") + @RequestParam(value = "current", defaultValue = "1") Integer current, + @Parameter(description = "每页大小,默认为10") + @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest request) { + + // 验证管理员权限 + User loginUser = userService.getLoginUser(request); + if (loginUser == null) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); + } + + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "无权限访问,仅管理员可用"); + } + + try { + log.info("管理员获取双色球中奖记录请求:userId={},prizeGrade={},current={},pageSize={}", + userId, prizeGrade, current, pageSize); + + // 调用服务获取中奖记录列表 + PageResponse result = predictRecordService.getWinningRecordsForAdmin(userId, prizeGrade, current, pageSize); + + log.info("管理员获取双色球中奖记录完成,总记录数:{}", result.getTotal()); + return ResultUtils.success(result); + + } catch (Exception e) { + log.error("管理员获取双色球中奖记录失败:{}", e.getMessage(), e); + return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "管理员获取双色球中奖记录失败:" + e.getMessage()); + } + } + /** * 获取近10期开奖期号 * @return 近10期开奖期号列表 @@ -1804,4 +1854,51 @@ public class BallAnalysisController { } } + /** + * 管理员获取所有双色球推测记录(支持分页和用户ID筛选) + * @param userId 用户ID(可选) + * @param current 当前页码,默认为1 + * @param pageSize 每页大小,默认为10 + * @param request HTTP请求 + * @return 分页的双色球预测记录 + */ + @GetMapping("/admin/all-records") + @Operation(summary = "管理员获取所有推测记录", description = "管理员获取所有双色球推测记录,支持分页和根据用户ID、中奖等级筛选") + public ApiResponse> getAllRecordsForAdmin( + @Parameter(description = "用户ID(可选),用于筛选指定用户的记录") + @RequestParam(value = "userId", required = false) Long userId, + @Parameter(description = "中奖等级(可选),例如:一等奖、二等奖、未中奖") + @RequestParam(value = "predictResult", required = false) String predictResult, + @Parameter(description = "当前页码,从1开始,默认为1") + @RequestParam(value = "current", defaultValue = "1") Integer current, + @Parameter(description = "每页大小,默认为10") + @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest request) { + + // 验证管理员权限 + User loginUser = userService.getLoginUser(request); + if (loginUser == null) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); + } + + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "无权限访问,仅管理员可用"); + } + + try { + log.info("管理员获取所有双色球推测记录,userId={},predictResult={},current={},pageSize={}", + userId, predictResult, current, pageSize); + + // 调用Service层方法 + PageResponse result = predictRecordService.getAllRecordsForAdmin(userId, predictResult, current, pageSize); + + log.info("管理员获取双色球推测记录完成,总记录数:{}", result.getTotal()); + return ResultUtils.success(result); + + } catch (Exception e) { + log.error("管理员获取双色球推测记录失败:{}", e.getMessage(), e); + return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "获取推测记录失败:" + e.getMessage()); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/controller/DltBallAnalysisController.java b/src/main/java/com/xy/xyaicpzs/controller/DltBallAnalysisController.java index 8d07c37..f6bad10 100644 --- a/src/main/java/com/xy/xyaicpzs/controller/DltBallAnalysisController.java +++ b/src/main/java/com/xy/xyaicpzs/controller/DltBallAnalysisController.java @@ -7,7 +7,9 @@ import com.xy.xyaicpzs.common.requset.FirstBallPredictionRequest; import com.xy.xyaicpzs.common.requset.FollowBackBallPredictionRequest; import com.xy.xyaicpzs.common.requset.FollowerBallPredictionRequest; import com.xy.xyaicpzs.common.response.ApiResponse; +import com.xy.xyaicpzs.domain.entity.DltDrawRecord; import com.xy.xyaicpzs.domain.entity.DltPredictRecord; +import com.xy.xyaicpzs.domain.entity.User; import com.xy.xyaicpzs.domain.vo.BallCombinationAnalysisVO; import com.xy.xyaicpzs.domain.vo.BallPersistenceAnalysisVO; import com.xy.xyaicpzs.domain.vo.FirstBallPredictionResultVO; @@ -20,8 +22,10 @@ import com.xy.xyaicpzs.dlt.FirstBallPredictor.FirstBallPredictionResult; import com.xy.xyaicpzs.dlt.FollowBackBallPredictor; import com.xy.xyaicpzs.dlt.FollowerBallPredictor; import com.xy.xyaicpzs.service.DltCombinationAnalysisService; +import com.xy.xyaicpzs.service.DltDrawRecordService; import com.xy.xyaicpzs.service.DltPersistenceAnalysisService; import com.xy.xyaicpzs.service.DltPredictRecordService; +import com.xy.xyaicpzs.service.UserService; import com.xy.xyaicpzs.util.UserAuthValidator; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -66,6 +70,41 @@ public class DltBallAnalysisController { @Autowired private UserAuthValidator userAuthValidator; + @Autowired + private DltDrawRecordService dltDrawRecordService; + + @Autowired + private UserService userService; + + /** + * 获取近期大乐透开奖信息 + * @param limit 获取条数,可选参数,默认10条 + * @return 近期大乐透开奖信息列表 + */ + @GetMapping("/recent-draws") + @Operation(summary = "获取近期大乐透开奖信息", description = "获取最近的大乐透开奖信息,默认返回10条,按开奖期号倒序排列") + public ApiResponse> getRecentDraws( + @Parameter(description = "获取条数,默认10条", required = false) + @RequestParam(required = false, defaultValue = "10") Integer limit, HttpServletRequest request) { + User loginUser = userService.getLoginUser(request); + if (loginUser == null){ + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "您还未登录,请登录后查看"); + } + try { + log.info("接收到获取近期大乐透开奖信息请求:条数={}", limit); + + // 调用服务获取近期大乐透开奖信息 + List result = dltDrawRecordService.getRecentDraws(limit); + + log.info("获取近期大乐透开奖信息完成,返回{}条记录", result.size()); + return ResultUtils.success(result); + + } catch (Exception e) { + log.error("获取近期大乐透开奖信息失败:{}", e.getMessage(), e); + return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "获取近期大乐透开奖信息失败:" + e.getMessage()); + } + } + @PostMapping("/predict-first-ball") @Operation(summary = "前区首球预测", description = "根据输入的级别和号码,预测前区首球") public ApiResponse predictFirstBall(@RequestBody FirstBallPredictionRequest request) { diff --git a/src/main/java/com/xy/xyaicpzs/controller/DltPredictController.java b/src/main/java/com/xy/xyaicpzs/controller/DltPredictController.java index 6050afa..34fa4f6 100644 --- a/src/main/java/com/xy/xyaicpzs/controller/DltPredictController.java +++ b/src/main/java/com/xy/xyaicpzs/controller/DltPredictController.java @@ -300,18 +300,10 @@ public class DltPredictController { @GetMapping("/front-first-ball-hit-rate") @Operation(summary = "获取大乐透前区首球命中率统计", description = "统计用户的大乐透前区首球命中次数和命中率") public ApiResponse getFrontFirstBallHitRate(HttpServletRequest request) { - User loginUser = userService.getLoginUser(request); if (loginUser == null) { return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "您还未登录,请登录后查看"); } - - // 检查VIP权限 - Date now = new Date(); - if (loginUser.getVipExpire() == null || loginUser.getVipExpire().before(now)) { - return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "VIP会员已过期,请续费后使用"); - } - try { log.info("接收到获取大乐透前区首球命中率统计请求"); @@ -335,18 +327,10 @@ public class DltPredictController { @GetMapping("/front-ball-hit-rate") @Operation(summary = "获取大乐透前区球号命中率统计", description = "统计用户的大乐透前区球号命中总数和命中率") public ApiResponse getFrontBallHitRate(HttpServletRequest request) { - User loginUser = userService.getLoginUser(request); if (loginUser == null) { return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "您还未登录,请登录后查看"); } - - // 检查VIP权限 - Date now = new Date(); - if (loginUser.getVipExpire() == null || loginUser.getVipExpire().before(now)) { - return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "VIP会员已过期,请续费后使用"); - } - try { log.info("接收到获取大乐透前区球号命中率统计请求"); @@ -370,18 +354,10 @@ public class DltPredictController { @GetMapping("/back-first-ball-hit-rate") @Operation(summary = "获取大乐透后区首球命中率统计", description = "统计用户的大乐透后区首球命中次数和命中率") public ApiResponse getBackFirstBallHitRate(HttpServletRequest request) { - User loginUser = userService.getLoginUser(request); if (loginUser == null) { return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "您还未登录,请登录后查看"); } - - // 检查VIP权限 - Date now = new Date(); - if (loginUser.getVipExpire() == null || loginUser.getVipExpire().before(now)) { - return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "VIP会员已过期,请续费后使用"); - } - try { log.info("接收到获取大乐透后区首球命中率统计请求"); @@ -405,18 +381,10 @@ public class DltPredictController { @GetMapping("/back-ball-hit-rate") @Operation(summary = "获取大乐透后区球号命中率统计", description = "统计用户的大乐透后区球号命中次数和命中率") public ApiResponse getBackBallHitRate(HttpServletRequest request) { - User loginUser = userService.getLoginUser(request); if (loginUser == null) { return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "您还未登录,请登录后查看"); } - - // 检查VIP权限 - Date now = new Date(); - if (loginUser.getVipExpire() == null || loginUser.getVipExpire().before(now)) { - return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "VIP会员已过期,请续费后使用"); - } - try { log.info("接收到获取大乐透后区球号命中率统计请求"); @@ -432,5 +400,100 @@ public class DltPredictController { return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "获取大乐透后区球号命中率统计失败:" + e.getMessage()); } } + + /** + * 管理员获取所有大乐透推测记录(支持分页和用户ID筛选) + * @param userId 用户ID(可选) + * @param current 当前页码,默认为1 + * @param pageSize 每页大小,默认为10 + * @param request HTTP请求 + * @return 分页的大乐透预测记录 + */ + @GetMapping("/admin/all-records") + @Operation(summary = "管理员获取所有推测记录", description = "管理员获取所有大乐透推测记录,支持分页和根据用户ID、中奖等级筛选") + public ApiResponse> getAllRecordsForAdmin( + @Parameter(description = "用户ID(可选),用于筛选指定用户的记录") + @RequestParam(value = "userId", required = false) Long userId, + @Parameter(description = "中奖等级(可选),例如:一等奖、二等奖、未中奖") + @RequestParam(value = "predictResult", required = false) String predictResult, + @Parameter(description = "当前页码,从1开始,默认为1") + @RequestParam(value = "current", defaultValue = "1") Integer current, + @Parameter(description = "每页大小,默认为10") + @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest request) { + + // 验证管理员权限 + User loginUser = userService.getLoginUser(request); + if (loginUser == null) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); + } + + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "无权限访问,仅管理员可用"); + } + + try { + log.info("管理员获取所有大乐透推测记录,userId={},predictResult={},current={},pageSize={}", + userId, predictResult, current, pageSize); + + // 调用Service层方法 + PageResponse result = dltPredictRecordService.getAllRecordsForAdmin(userId, predictResult, current, pageSize); + + log.info("管理员获取大乐透推测记录完成,总记录数:{}", result.getTotal()); + return ResultUtils.success(result); + + } catch (Exception e) { + log.error("管理员获取大乐透推测记录失败:{}", e.getMessage(), e); + return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "获取推测记录失败:" + e.getMessage()); + } + } + + /** + * 管理员获取大乐透中奖记录统计(返回每一条中奖记录) + * @param userId 用户ID(可选) + * @param prizeGrade 奖项等级(可选) + * @param current 当前页码 + * @param pageSize 每页大小 + * @param request HTTP请求 + * @return 分页的中奖记录列表 + */ + @GetMapping("/admin/prize-statistics") + @Operation(summary = "管理员获取中奖记录明细", description = "管理员获取所有用户的大乐透中奖记录明细,支持根据用户ID和奖项等级筛选") + public ApiResponse> getAdminDltPrizeStatistics( + @Parameter(description = "用户ID(可选)") + @RequestParam(value = "userId", required = false) Long userId, + @Parameter(description = "奖项等级(可选),例如:一等奖、二等奖") + @RequestParam(value = "prizeGrade", required = false) String prizeGrade, + @Parameter(description = "当前页码,从1开始,默认为1") + @RequestParam(value = "current", defaultValue = "1") Integer current, + @Parameter(description = "每页大小,默认为10") + @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest request) { + + // 验证管理员权限 + User loginUser = userService.getLoginUser(request); + if (loginUser == null) { + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); + } + + if (!userService.isAdmin(loginUser)) { + return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "无权限访问,仅管理员可用"); + } + + try { + log.info("管理员获取大乐透中奖记录请求:userId={},prizeGrade={},current={},pageSize={}", + userId, prizeGrade, current, pageSize); + + // 调用服务获取中奖记录列表 + PageResponse result = dltPredictRecordService.getWinningRecordsForAdmin(userId, prizeGrade, current, pageSize); + + log.info("管理员获取大乐透中奖记录完成,总记录数:{}", result.getTotal()); + return ResultUtils.success(result); + + } catch (Exception e) { + log.error("管理员获取大乐透中奖记录失败:{}", e.getMessage(), e); + return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "管理员获取大乐透中奖记录失败:" + e.getMessage()); + } + } } diff --git a/src/main/java/com/xy/xyaicpzs/controller/FileUploadController.java b/src/main/java/com/xy/xyaicpzs/controller/FileUploadController.java new file mode 100644 index 0000000..d173af0 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/controller/FileUploadController.java @@ -0,0 +1,210 @@ +package com.xy.xyaicpzs.controller; + +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import com.qcloud.cos.model.PutObjectResult; +import com.xy.xyaicpzs.common.ErrorCode; +import com.xy.xyaicpzs.common.ResultUtils; +import com.xy.xyaicpzs.common.response.ApiResponse; +import com.xy.xyaicpzs.config.CosConfig; +import com.xy.xyaicpzs.exception.BusinessException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 文件上传控制器 + * + * @author xy + */ +@Tag(name = "文件上传", description = "腾讯云 COS 文件上传相关接口") +@RestController +@RequestMapping("/file") +@Slf4j +public class FileUploadController { + + @Resource + private COSClient cosClient; + + @Resource + private CosConfig cosConfig; + + /** + * 上传图片到腾讯云 COS + * + * @param file 上传的图片文件(最大50MB) + * @return 文件访问 URL + */ + @Operation(summary = "上传图片", description = "上传图片到腾讯云 COS,文件名使用UUID命名,最大支持50MB") + @PostMapping("/upload") + public ApiResponse> uploadImage(@RequestParam("file") MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件不能为空"); + } + + try { + // 获取原始文件名 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件名不能为空"); + } + + // 验证文件类型(仅允许图片) + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "仅支持上传图片文件"); + } + + // 获取文件扩展名 + String extension = ""; + int lastDotIndex = originalFilename.lastIndexOf("."); + if (lastDotIndex > 0) { + extension = originalFilename.substring(lastDotIndex); + } + + // 使用 UUID 生成唯一文件名 + String fileName = UUID.randomUUID().toString() + extension; + + // 设置对象元数据 + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.getSize()); + objectMetadata.setContentType(file.getContentType()); + + // 创建上传请求 + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + objectMetadata + ); + + // 执行上传 + PutObjectResult putObjectResult = cosClient.putObject(putObjectRequest); + log.info("图片上传成功,文件名: {}, RequestId: {}", fileName, putObjectResult.getRequestId()); + + // 生成文件访问 URL + String fileUrl = cosConfig.getDomain() + "/" + fileName; + + // 返回结果 + Map result = new HashMap<>(); + result.put("fileName", fileName); + result.put("fileUrl", fileUrl); + result.put("originalFilename", originalFilename); + + return ResultUtils.success(result); + + } catch (IOException e) { + log.error("图片上传失败", e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "图片上传失败:" + e.getMessage()); + } + } + + /** + * 批量上传图片 + * + * @param files 上传的图片文件列表(单个文件最大50MB,总请求最大100MB) + * @return 文件访问 URL 列表 + */ + @Operation(summary = "批量上传图片", description = "批量上传多张图片到腾讯云 COS,单个文件最大50MB") + @PostMapping("/upload/batch") + public ApiResponse> uploadImages(@RequestParam("files") MultipartFile[] files) { + if (files == null || files.length == 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件列表不能为空"); + } + + try { + java.util.List> fileList = new java.util.ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (MultipartFile file : files) { + if (file.isEmpty()) { + failCount++; + continue; + } + + try { + // 获取原始文件名 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + failCount++; + continue; + } + + // 验证文件类型(仅允许图片) + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + log.warn("跳过非图片文件: {}", originalFilename); + failCount++; + continue; + } + + // 获取文件扩展名 + String extension = ""; + int lastDotIndex = originalFilename.lastIndexOf("."); + if (lastDotIndex > 0) { + extension = originalFilename.substring(lastDotIndex); + } + + // 使用 UUID 生成唯一文件名 + String fileName = UUID.randomUUID().toString() + extension; + + // 设置对象元数据 + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.getSize()); + objectMetadata.setContentType(file.getContentType()); + + // 创建上传请求 + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + objectMetadata + ); + + // 执行上传 + PutObjectResult putObjectResult = cosClient.putObject(putObjectRequest); + log.info("图片上传成功,文件名: {}, RequestId: {}", fileName, putObjectResult.getRequestId()); + + // 生成文件访问 URL + String fileUrl = cosConfig.getDomain() + "/" + fileName; + + // 添加到结果列表 + Map fileInfo = new HashMap<>(); + fileInfo.put("fileName", fileName); + fileInfo.put("fileUrl", fileUrl); + fileInfo.put("originalFilename", originalFilename); + fileList.add(fileInfo); + + successCount++; + + } catch (Exception e) { + log.error("单个图片上传失败:{}", file.getOriginalFilename(), e); + failCount++; + } + } + + // 返回结果 + Map result = new HashMap<>(); + result.put("fileList", fileList); + result.put("total", files.length); + result.put("successCount", successCount); + result.put("failCount", failCount); + + return ResultUtils.success(result); + + } catch (Exception e) { + log.error("批量图片上传失败", e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "批量图片上传失败:" + e.getMessage()); + } + } +} diff --git a/src/main/java/com/xy/xyaicpzs/controller/PageViewController.java b/src/main/java/com/xy/xyaicpzs/controller/PageViewController.java new file mode 100644 index 0000000..7879729 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/controller/PageViewController.java @@ -0,0 +1,49 @@ +package com.xy.xyaicpzs.controller; + +import com.xy.xyaicpzs.common.response.ApiResponse; +import com.xy.xyaicpzs.service.PageViewService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 页面访问量统计控制器 + */ +@RestController +@RequestMapping("/pv") +@RequiredArgsConstructor +@Tag(name = "页面访问量统计", description = "PV统计相关接口") +public class PageViewController { + + private final PageViewService pageViewService; + + @PostMapping("/record") + @Operation(summary = "记录页面访问", description = "记录一次页面访问") + public ApiResponse recordPageView() { + pageViewService.incrementPageView(); + return ApiResponse.success("记录成功"); + } + + @GetMapping("/total") + @Operation(summary = "获取总PV", description = "获取网站累计总访问量") + public ApiResponse getTotalPageViews() { + return ApiResponse.success(pageViewService.getTotalPageViews()); + } + + @GetMapping("/today") + @Operation(summary = "获取今日PV", description = "获取今日访问量") + public ApiResponse getTodayPageViews() { + return ApiResponse.success(pageViewService.getTodayPageViews()); + } + + @GetMapping("/stats") + @Operation(summary = "根据天数获取PV统计", description = "获取近N天的PV统计(最大90天)") + public ApiResponse> getPageViewsByDays( + @Parameter(description = "天数:7/30/90,默认7天,最大90天") @RequestParam(defaultValue = "7") int days) { + return ApiResponse.success(pageViewService.getPageViewsByDays(days)); + } +} diff --git a/src/main/java/com/xy/xyaicpzs/controller/UserController.java b/src/main/java/com/xy/xyaicpzs/controller/UserController.java index 76d015c..fe7104b 100644 --- a/src/main/java/com/xy/xyaicpzs/controller/UserController.java +++ b/src/main/java/com/xy/xyaicpzs/controller/UserController.java @@ -363,6 +363,26 @@ public class UserController { if (userQueryRequest.getIsVip() != null) { queryWrapper.eq("isVip", userQueryRequest.getIsVip()); } + + // 套餐类别匹配 + if (StringUtils.isNotBlank(userQueryRequest.getVipType())) { + queryWrapper.eq("vipType", userQueryRequest.getVipType()); + } + + // 所在省市模糊匹配 + if (StringUtils.isNotBlank(userQueryRequest.getLocation())) { + queryWrapper.like("location", userQueryRequest.getLocation()); + } + + // 彩票偏好匹配 + if (StringUtils.isNotBlank(userQueryRequest.getPreference())) { + queryWrapper.like("preference", userQueryRequest.getPreference()); + } + + // 获客渠道匹配 + if (StringUtils.isNotBlank(userQueryRequest.getChannel())) { + queryWrapper.like("channel", userQueryRequest.getChannel()); + } } List userList = userService.list(queryWrapper); @@ -424,6 +444,26 @@ public class UserController { if (userQueryRequest.getIsVip() != null) { queryWrapper.eq("isVip", userQueryRequest.getIsVip()); } + + // 套餐类别匹配 + if (StringUtils.isNotBlank(userQueryRequest.getVipType())) { + queryWrapper.eq("vipType", userQueryRequest.getVipType()); + } + + // 所在省市模糊匹配 + if (StringUtils.isNotBlank(userQueryRequest.getLocation())) { + queryWrapper.like("location", userQueryRequest.getLocation()); + } + + // 彩票偏好匹配 + if (StringUtils.isNotBlank(userQueryRequest.getPreference())) { + queryWrapper.like("preference", userQueryRequest.getPreference()); + } + + // 获客渠道匹配 + if (StringUtils.isNotBlank(userQueryRequest.getChannel())) { + queryWrapper.like("channel", userQueryRequest.getChannel()); + } } Page userPage = userService.page(new Page<>(current, size), queryWrapper); diff --git a/src/main/java/com/xy/xyaicpzs/controller/VipCodeController.java b/src/main/java/com/xy/xyaicpzs/controller/VipCodeController.java index c646b47..eff2744 100644 --- a/src/main/java/com/xy/xyaicpzs/controller/VipCodeController.java +++ b/src/main/java/com/xy/xyaicpzs/controller/VipCodeController.java @@ -15,6 +15,7 @@ import com.xy.xyaicpzs.service.OperationHistoryService; import com.xy.xyaicpzs.service.UserService; import com.xy.xyaicpzs.service.VipCodeService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -110,7 +111,7 @@ public class VipCodeController { /** * 获取一个可用的会员码 * - * @param vipExpireTime 会员有效月数(1或12) + * @param vipExpireTime 会员有效月数 * @return 可用的会员码 */ @GetMapping("/available") @@ -128,8 +129,8 @@ public class VipCodeController { if (vipExpireTime == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "会员有效月数不能为空"); } - if (vipExpireTime != 1 && vipExpireTime != 12) { - throw new BusinessException(ErrorCode.PARAMS_ERROR, "会员有效月数只能是1或12"); + if (vipExpireTime <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "会员有效月数必须大于0"); } try { @@ -295,4 +296,79 @@ public class VipCodeController { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取会员码统计数量失败,请稍后重试"); } } + + /** + * 根据月份获取已使用的会员码(分页) + * + * @param vipExpireTime 会员有效月数 + * @param current 当前页码,默认为1 + * @param pageSize 每页大小,默认为10 + * @param httpServletRequest Http请求 + * @return 分页的已使用会员码列表 + */ + @GetMapping("/used/by-month") + @Operation(summary = "根据月份获取已使用的会员码", description = "根据会员有效月数查询已使用的会员码,支持分页") + public ApiResponse> getUsedVipCodesByMonth( + @Parameter(description = "会员有效月数", required = true) + @RequestParam("vipExpireTime") Integer vipExpireTime, + @Parameter(description = "当前页码,默认为1", required = false) + @RequestParam(value = "current", defaultValue = "1") Long current, + @Parameter(description = "每页大小,默认为10", required = false) + @RequestParam(value = "pageSize", defaultValue = "10") Long pageSize, + HttpServletRequest httpServletRequest) { + + // 权限校验 + User loginUser = userService.getLoginUser(httpServletRequest); + if (!userService.isAdmin(loginUser)){ + return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "无权限"); + } + + // 参数校验 + if (vipExpireTime == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "会员有效月数不能为空"); + } + if (vipExpireTime <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "会员有效月数必须大于0"); + } + if (current <= 0) { + current = 1L; + } + if (pageSize <= 0) { + pageSize = 10L; + } + + try { + // 构建查询条件:已使用且指定月份 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("isUse", 1) // 已使用 + .eq("vipExpireTime", vipExpireTime); // 指定月份 + + // 按使用时间倒序排序(最近使用的在前) + queryWrapper.orderByDesc("updateTime"); + + // 执行分页查询 + Page vipCodePage = vipCodeService.page(new Page<>(current, pageSize), queryWrapper); + + // 转换为VO对象 + List vipCodeVOList = vipCodePage.getRecords().stream().map(vipCode -> { + VipCodeVO vipCodeVO = new VipCodeVO(); + BeanUtils.copyProperties(vipCode, vipCodeVO); + return vipCodeVO; + }).collect(Collectors.toList()); + + // 创建VO分页对象,确保正确传递所有分页信息 + Page vipCodeVOPage = new Page<>(vipCodePage.getCurrent(), vipCodePage.getSize(), vipCodePage.getTotal()); + vipCodeVOPage.setRecords(vipCodeVOList); + // 手动设置pages值 + vipCodeVOPage.setPages(vipCodePage.getPages()); + + log.info("根据月份获取已使用的会员码完成,月份:{},页码:{},每页大小:{},返回{}条记录", + vipExpireTime, current, pageSize, vipCodeVOList.size()); + return ResultUtils.success(vipCodeVOPage); + + } catch (Exception e) { + log.error("根据月份获取已使用的会员码失败:{}", e.getMessage(), e); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取已使用的会员码失败:" + e.getMessage()); + } + } } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/dlt/BackBallPredictor.java b/src/main/java/com/xy/xyaicpzs/dlt/BackBallPredictor.java index 8166369..4f55d07 100644 --- a/src/main/java/com/xy/xyaicpzs/dlt/BackBallPredictor.java +++ b/src/main/java/com/xy/xyaicpzs/dlt/BackBallPredictor.java @@ -80,7 +80,7 @@ public class BackBallPredictor { * @param previousFrontBalls 上期前区5个号码 * @param previousBackBalls 上期后区2个号码 * @param nextBackBalls 下期后区2个号码 - * @return 预测的4个后区首球号码和筛选过程 + * @return 预测的5个后区首球号码和筛选过程 */ public BackBallPredictionResult predictBackBallWithProcess(String level, List nextFrontBalls, List previousFrontBalls, List previousBackBalls, List nextBackBalls) { @@ -876,10 +876,10 @@ public class BackBallPredictor { } /** - * 从所有候选球中筛选最终的4个球(带过程) + * 从所有候选球中筛选最终的5个球(带过程) * @param allCandidateBalls 所有候选球 * @param d6CoefficientMap D6表中的系数信息 - * @return 最终选出的4个球和筛选过程 + * @return 最终选出的5个球和筛选过程 */ private BackBallPredictionResult selectFinal4BallsWithProcess(List allCandidateBalls, Map> d6CoefficientMap) { // 统计球号出现次数 @@ -909,7 +909,7 @@ public class BackBallPredictor { int frequency = frequencyGroup.getKey(); int ballsInGroup = frequencyGroup.getValue().size(); - if (currentSelected + ballsInGroup <= 4) { + if (currentSelected + ballsInGroup <= 5) { // 这个频率组的球全部入选 currentSelected += ballsInGroup; minSelectedFrequency = frequency; @@ -935,12 +935,12 @@ public class BackBallPredictor { // 按频率从高到低处理每个频率组 for (Map.Entry> frequencyGroup : frequencyGroups.entrySet()) { - if (resultBalls.size() >= 4) { + if (resultBalls.size() >= 5) { break; } List ballsInGroup = frequencyGroup.getValue(); - int needCount = Math.min(4 - resultBalls.size(), ballsInGroup.size()); + int needCount = Math.min(5 - resultBalls.size(), ballsInGroup.size()); if (ballsInGroup.size() <= needCount) { // 如果这个频率组的球数量 <= 需要的数量,全部加入 @@ -975,7 +975,7 @@ public class BackBallPredictor { // 生成筛选过程说明 if (hasSecondarySelection) { - processBuilder.append("无法直接筛选出前4个,其中"); + processBuilder.append("无法直接筛选出前5个,其中"); Collections.sort(directSelected); String directSelectedStr = directSelected.stream().map(String::valueOf).collect(Collectors.joining(", ")); processBuilder.append(directSelectedStr).append("直接入选,"); @@ -991,7 +991,7 @@ public class BackBallPredictor { processBuilder.append("最终筛选出").append(finalSelectedStr).append(","); String allResultStr = resultBalls.stream().map(String::valueOf).collect(Collectors.joining(", ")); - processBuilder.append("组成前4个球号:").append(allResultStr).append("。"); + processBuilder.append("组成前5个球号:").append(allResultStr).append("。"); processBuilder.append("筛选步骤:").append(selectionSteps).append("。"); if (!detailedCoefficientInfo.isEmpty()) { @@ -999,7 +999,7 @@ public class BackBallPredictor { } } else { String allResultStr = resultBalls.stream().map(String::valueOf).collect(Collectors.joining(", ")); - processBuilder.append("直接筛选出前4个球号:").append(allResultStr).append("。"); + processBuilder.append("直接筛选出前5个球号:").append(allResultStr).append("。"); } // 无论是否有二次筛选,都要显示筛选步骤 diff --git a/src/main/java/com/xy/xyaicpzs/dlt/FollowBackBallPredictor.java b/src/main/java/com/xy/xyaicpzs/dlt/FollowBackBallPredictor.java index 3f3f4b1..bb63c27 100644 --- a/src/main/java/com/xy/xyaicpzs/dlt/FollowBackBallPredictor.java +++ b/src/main/java/com/xy/xyaicpzs/dlt/FollowBackBallPredictor.java @@ -83,7 +83,7 @@ public class FollowBackBallPredictor { * @param nextFrontBalls 下期前区5个号码 * @param previousFrontBalls 上期前区5个号码 * @param previousBackBalls 上期后区2个号码 - * @return 预测的3个后区随球号码和筛选过程 + * @return 预测的4个后区随球号码和筛选过程 */ public FollowBackBallPredictionResult predictFollowBackBallWithProcess(String level, Integer backFirstBall, List nextFrontBalls, List previousFrontBalls, List previousBackBalls) { @@ -141,6 +141,9 @@ public class FollowBackBallPredictor { // (6) 从百期排行表获取前2名 List top2FromHistoryTop100 = getTop2FromBackendHistoryTop100(); allCandidateBalls.addAll(top2FromHistoryTop100); + + // (7) 排除后区首球,确保随球不包含首球本身 + allCandidateBalls.removeIf(ball -> ball.equals(backFirstBall)); return selectFinal3BallsWithProcess(allCandidateBalls, d7CoefficientMap); } @@ -215,6 +218,9 @@ public class FollowBackBallPredictor { allCandidateBalls.add(ball); } + // (7) 排除后区首球,确保随球不包含首球本身 + allCandidateBalls.removeIf(ball -> ball.equals(backFirstBall)); + // 最终筛选3个球 return selectFinal3Balls(allCandidateBalls, d7CoefficientMap); } @@ -949,10 +955,10 @@ public class FollowBackBallPredictor { } /** - * 从所有候选球中筛选最终的3个球(带过程) + * 从所有候选球中筛选最终的4个球(带过程) * @param allCandidateBalls 所有候选球 * @param d7CoefficientMap D7表中的系数信息 - * @return 最终选出的3个球和筛选过程 + * @return 最终选出的4个球和筛选过程 */ private FollowBackBallPredictionResult selectFinal3BallsWithProcess(List allCandidateBalls, Map> d7CoefficientMap) { // 统计球号出现次数 @@ -982,7 +988,7 @@ public class FollowBackBallPredictor { int frequency = frequencyGroup.getKey(); int ballsInGroup = frequencyGroup.getValue().size(); - if (currentSelected + ballsInGroup <= 3) { + if (currentSelected + ballsInGroup <= 4) { // 这个频率组的球全部入选 currentSelected += ballsInGroup; minSelectedFrequency = frequency; @@ -1008,12 +1014,12 @@ public class FollowBackBallPredictor { // 按频率从高到低处理每个频率组 for (Map.Entry> frequencyGroup : frequencyGroups.entrySet()) { - if (resultBalls.size() >= 3) { + if (resultBalls.size() >= 4) { break; } List ballsInGroup = frequencyGroup.getValue(); - int needCount = Math.min(3 - resultBalls.size(), ballsInGroup.size()); + int needCount = Math.min(4 - resultBalls.size(), ballsInGroup.size()); if (ballsInGroup.size() <= needCount) { // 如果这个频率组的球数量 <= 需要的数量,全部加入 @@ -1048,7 +1054,7 @@ public class FollowBackBallPredictor { // 生成筛选过程说明 if (hasSecondarySelection) { - processBuilder.append("无法直接筛选出前3个,其中"); + processBuilder.append("无法直接筛选出前4个,其中"); Collections.sort(directSelected); String directSelectedStr = directSelected.stream().map(String::valueOf).collect(Collectors.joining(", ")); processBuilder.append(directSelectedStr).append("直接入选,"); @@ -1064,7 +1070,7 @@ public class FollowBackBallPredictor { processBuilder.append("最终筛选出").append(finalSelectedStr).append(","); String allResultStr = resultBalls.stream().map(String::valueOf).collect(Collectors.joining(", ")); - processBuilder.append("组成前3个球号:").append(allResultStr).append("。"); + processBuilder.append("组成前4个球号:").append(allResultStr).append("。"); processBuilder.append("筛选步骤:").append(selectionSteps).append("。"); if (!detailedCoefficientInfo.isEmpty()) { @@ -1072,7 +1078,7 @@ public class FollowBackBallPredictor { } } else { String allResultStr = resultBalls.stream().map(String::valueOf).collect(Collectors.joining(", ")); - processBuilder.append("直接筛选出前3个球号:").append(allResultStr).append("。"); + processBuilder.append("直接筛选出前4个球号:").append(allResultStr).append("。"); } // 无论是否有二次筛选,都要显示筛选步骤 diff --git a/src/main/java/com/xy/xyaicpzs/dlt/FollowerBallPredictor.java b/src/main/java/com/xy/xyaicpzs/dlt/FollowerBallPredictor.java index a70af1c..3749456 100644 --- a/src/main/java/com/xy/xyaicpzs/dlt/FollowerBallPredictor.java +++ b/src/main/java/com/xy/xyaicpzs/dlt/FollowerBallPredictor.java @@ -927,10 +927,10 @@ public class FollowerBallPredictor { } /** - * 从所有候选球中筛选最终的10个球(带过程) + * 从所有候选球中筛选最终的12个球(带过程) * @param allCandidateBalls 所有候选球 * @param d5CoefficientMap D5表中的系数信息 - * @return 最终选出的10个球和筛选过程 + * @return 最终选出的12个球和筛选过程 */ private FollowerBallPredictionResult selectFinal10BallsWithProcess(List allCandidateBalls, Map> d5CoefficientMap) { // 统计球号出现次数 @@ -960,7 +960,7 @@ public class FollowerBallPredictor { int frequency = frequencyGroup.getKey(); int ballsInGroup = frequencyGroup.getValue().size(); - if (currentSelected + ballsInGroup <= 10) { + if (currentSelected + ballsInGroup <= 12) { // 这个频率组的球全部入选 currentSelected += ballsInGroup; minSelectedFrequency = frequency; @@ -986,12 +986,12 @@ public class FollowerBallPredictor { // 按频率从高到低处理每个频率组 for (Map.Entry> frequencyGroup : frequencyGroups.entrySet()) { - if (resultBalls.size() >= 10) { + if (resultBalls.size() >= 12) { break; } List ballsInGroup = frequencyGroup.getValue(); - int needCount = Math.min(10 - resultBalls.size(), ballsInGroup.size()); + int needCount = Math.min(12 - resultBalls.size(), ballsInGroup.size()); if (ballsInGroup.size() <= needCount) { // 如果这个频率组的球数量 <= 需要的数量,全部加入 @@ -1026,7 +1026,7 @@ public class FollowerBallPredictor { // 生成筛选过程说明 if (hasSecondarySelection) { - processBuilder.append("无法直接筛选出前10个,其中"); + processBuilder.append("无法直接筛选出前12个,其中"); Collections.sort(directSelected); String directSelectedStr = directSelected.stream().map(String::valueOf).collect(Collectors.joining(", ")); processBuilder.append(directSelectedStr).append("直接入选,"); @@ -1042,7 +1042,7 @@ public class FollowerBallPredictor { processBuilder.append("最终筛选出").append(finalSelectedStr).append(","); String allResultStr = resultBalls.stream().map(String::valueOf).collect(Collectors.joining(", ")); - processBuilder.append("组成前10个球号:").append(allResultStr).append("。"); + processBuilder.append("组成前12个球号:").append(allResultStr).append("。"); processBuilder.append("筛选步骤:").append(selectionSteps).append("。"); if (!detailedCoefficientInfo.isEmpty()) { @@ -1050,7 +1050,7 @@ public class FollowerBallPredictor { } } else { String allResultStr = resultBalls.stream().map(String::valueOf).collect(Collectors.joining(", ")); - processBuilder.append("直接筛选出前10个球号:").append(allResultStr).append("。"); + processBuilder.append("直接筛选出前12个球号:").append(allResultStr).append("。"); } // 无论是否有二次筛选,都要显示筛选步骤 diff --git a/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserAddRequest.java b/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserAddRequest.java index 0fe715b..d75cb73 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserAddRequest.java +++ b/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserAddRequest.java @@ -62,6 +62,26 @@ public class UserAddRequest implements Serializable { * 会员到期时间 */ private Date vipExpire; + + /** + * 套餐类别(体验会员/月度会员/年度会员) + */ + private String vipType; + + /** + * 所在省市 + */ + private String location; + + /** + * 彩票偏好(双色球/大乐透等) + */ + private String preference; + + /** + * 获客渠道 + */ + private String channel; /** * 状态:0-正常,1-封禁 diff --git a/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserQueryRequest.java b/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserQueryRequest.java index 2dfbdf8..1ba7207 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserQueryRequest.java +++ b/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserQueryRequest.java @@ -51,6 +51,26 @@ public class UserQueryRequest extends PageRequest implements Serializable { * 是否会员:0-非会员,1-会员 */ private Integer isVip; + + /** + * 套餐类别(体验会员/月度会员/年度会员) + */ + private String vipType; + + /** + * 所在省市 + */ + private String location; + + /** + * 彩票偏好(双色球/大乐透等) + */ + private String preference; + + /** + * 获客渠道 + */ + private String channel; /** * 状态:0-正常,1-封禁 diff --git a/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserUpdateRequest.java b/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserUpdateRequest.java index 839a43d..c1414a4 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserUpdateRequest.java +++ b/src/main/java/com/xy/xyaicpzs/domain/dto/user/UserUpdateRequest.java @@ -67,6 +67,26 @@ public class UserUpdateRequest implements Serializable { * 会员到期时间 */ private Date vipExpire; + + /** + * 套餐类别(体验会员/月度会员/年度会员) + */ + private String vipType; + + /** + * 所在省市 + */ + private String location; + + /** + * 彩票偏好(双色球/大乐透等) + */ + private String preference; + + /** + * 获客渠道 + */ + private String channel; /** * 状态:0-正常,1-封禁 diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/Announcement.java b/src/main/java/com/xy/xyaicpzs/domain/entity/Announcement.java new file mode 100644 index 0000000..ebd3712 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/Announcement.java @@ -0,0 +1,75 @@ +package com.xy.xyaicpzs.domain.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import java.util.Date; +import lombok.Data; + +/** + * 公告管理表 + * @TableName announcement + */ +@TableName(value ="announcement") +@Data +public class Announcement { + /** + * 唯一标识符 + */ + @TableId(type = IdType.AUTO) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 公告标题 + */ + private String title; + + /** + * 公告详情内容 + */ + private String content; + + /** + * 发布人ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long publisherId; + + /** + * 发布人名称 + */ + private String publisherName; + + /** + * 公告状态:0-草稿,1-已发布,2-已下架 + */ + private Integer status; + + /** + * 优先级:0-普通,1-置顶 + */ + private Integer priority; + + /** + * 浏览次数 + */ + private Integer viewCount; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 是否删除:0-未删除,1-已删除 + */ + private Integer isDelete; +} \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/DltDrawRecord.java b/src/main/java/com/xy/xyaicpzs/domain/entity/DltDrawRecord.java index a3d6d23..24f5fdf 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/DltDrawRecord.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/DltDrawRecord.java @@ -63,4 +63,9 @@ public class DltDrawRecord { * 后区2 */ private Integer backBall2; + + /** + * 奖池(单位:亿元) + */ + private Double prizePool; } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/DltPredictRecord.java b/src/main/java/com/xy/xyaicpzs/domain/entity/DltPredictRecord.java index 63f0ba5..b0a6d3b 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/DltPredictRecord.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/DltPredictRecord.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -17,16 +19,19 @@ public class DltPredictRecord { * 唯一标识符 */ @TableId(type = IdType.AUTO) + @JsonSerialize(using = ToStringSerializer.class) private Long id; /** * 用户ID */ + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** * 开奖期号 */ + @JsonSerialize(using = ToStringSerializer.class) private Long drawId; /** @@ -88,5 +93,6 @@ public class DltPredictRecord { /** * 奖金 */ + @JsonSerialize(using = ToStringSerializer.class) private Long bonus; } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/LotteryDraws.java b/src/main/java/com/xy/xyaicpzs/domain/entity/LotteryDraws.java index ca9c840..5285565 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/LotteryDraws.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/LotteryDraws.java @@ -2,6 +2,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -16,6 +18,7 @@ public class LotteryDraws { * 开奖期号 */ @TableId + @JsonSerialize(using = ToStringSerializer.class) private Long drawId; /** @@ -57,4 +60,14 @@ public class LotteryDraws { * 蓝球 */ private Integer blueBall; + + /** + * 奖池(单位:亿元) + */ + private Double prizePool; + + /** + * 是否处于特别规定期间:0-否,1-是 + */ + private Integer isSpecialPeriod; } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/OperationHistory.java b/src/main/java/com/xy/xyaicpzs/domain/entity/OperationHistory.java index 0539183..af3024a 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/OperationHistory.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/OperationHistory.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -17,11 +19,13 @@ public class OperationHistory { * 唯一标识符 */ @TableId(type = IdType.AUTO) + @JsonSerialize(using = ToStringSerializer.class) private Long id; /** * 操作用户ID */ + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/PredictRecord.java b/src/main/java/com/xy/xyaicpzs/domain/entity/PredictRecord.java index 25f267f..2baf953 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/PredictRecord.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/PredictRecord.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -17,16 +19,19 @@ public class PredictRecord { * 唯一标识符 */ @TableId(type = IdType.AUTO) + @JsonSerialize(using = ToStringSerializer.class) private Long id; /** * 用户ID */ + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** * 开奖期号 */ + @JsonSerialize(using = ToStringSerializer.class) private Long drawId; /** @@ -87,6 +92,7 @@ public class PredictRecord { /** * 奖金 */ + @JsonSerialize(using = ToStringSerializer.class) private Long bonus; /** diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/User.java b/src/main/java/com/xy/xyaicpzs/domain/entity/User.java index 9ff29fc..77c5b73 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/User.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/User.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -17,6 +19,7 @@ public class User { * id */ @TableId(type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) private Long id; /** @@ -64,6 +67,26 @@ public class User { */ private Date vipExpire; + /** + * 套餐类别(体验会员/月度会员/年度会员) + */ + private String vipType; + + /** + * 所在省市 + */ + private String location; + + /** + * 彩票偏好(双色球/大乐透等) + */ + private String preference; + + /** + * 获客渠道 + */ + private String channel; + /** * 状态:0-正常,1-封禁 */ diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/VipCode.java b/src/main/java/com/xy/xyaicpzs/domain/entity/VipCode.java index 40ac723..29515dd 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/VipCode.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/VipCode.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -17,6 +19,7 @@ public class VipCode { * 主键 */ @TableId(type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) private Long id; /** @@ -42,6 +45,7 @@ public class VipCode { /** * 创建的用户id */ + @JsonSerialize(using = ToStringSerializer.class) private Long createdUserId; /** @@ -53,6 +57,7 @@ public class VipCode { /** * 使用的用户id */ + @JsonSerialize(using = ToStringSerializer.class) private Long usedUserId; /** diff --git a/src/main/java/com/xy/xyaicpzs/domain/entity/VipExchangeRecord.java b/src/main/java/com/xy/xyaicpzs/domain/entity/VipExchangeRecord.java index dfd246b..62492d4 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/entity/VipExchangeRecord.java +++ b/src/main/java/com/xy/xyaicpzs/domain/entity/VipExchangeRecord.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.domain.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.util.Date; import lombok.Data; @@ -17,11 +19,13 @@ public class VipExchangeRecord { * 唯一标识符 */ @TableId(type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) private Long id; /** * 用户ID */ + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** @@ -37,6 +41,7 @@ public class VipExchangeRecord { /** * 订单编号 */ + @JsonSerialize(using = ToStringSerializer.class) private Long orderNo; /** diff --git a/src/main/java/com/xy/xyaicpzs/domain/vo/AnnouncementVO.java b/src/main/java/com/xy/xyaicpzs/domain/vo/AnnouncementVO.java new file mode 100644 index 0000000..5c86e6b --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/domain/vo/AnnouncementVO.java @@ -0,0 +1,74 @@ +package com.xy.xyaicpzs.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; + +/** + * 公告视图对象 + */ +@Data +@Schema(description = "公告视图对象") +public class AnnouncementVO { + + /** + * 唯一标识符 + */ + @Schema(description = "唯一标识符") + private Long id; + + /** + * 公告标题 + */ + @Schema(description = "公告标题") + private String title; + + /** + * 公告详情内容 + */ + @Schema(description = "公告详情内容") + private String content; + + /** + * 发布人ID + */ + @Schema(description = "发布人ID") + private Long publisherId; + + /** + * 发布人名称 + */ + @Schema(description = "发布人名称") + private String publisherName; + + /** + * 公告状态:0-草稿,1-已发布,2-已下架 + */ + @Schema(description = "公告状态:0-草稿,1-已发布,2-已下架") + private Integer status; + + /** + * 优先级:0-普通,1-置顶 + */ + @Schema(description = "优先级:0-普通,1-置顶") + private Integer priority; + + /** + * 浏览次数 + */ + @Schema(description = "浏览次数") + private Integer viewCount; + + /** + * 创建时间 + */ + @Schema(description = "创建时间") + private Date createTime; + + /** + * 更新时间 + */ + @Schema(description = "更新时间") + private Date updateTime; +} diff --git a/src/main/java/com/xy/xyaicpzs/domain/vo/UserVO.java b/src/main/java/com/xy/xyaicpzs/domain/vo/UserVO.java index 2fb6ed4..22a8bee 100644 --- a/src/main/java/com/xy/xyaicpzs/domain/vo/UserVO.java +++ b/src/main/java/com/xy/xyaicpzs/domain/vo/UserVO.java @@ -60,6 +60,26 @@ public class UserVO implements Serializable { * 会员到期时间 */ private Date vipExpire; + + /** + * 套餐类别(体验会员/月度会员/年度会员) + */ + private String vipType; + + /** + * 所在省市 + */ + private String location; + + /** + * 彩票偏好(双色球/大乐透等) + */ + private String preference; + + /** + * 获客渠道 + */ + private String channel; /** * 状态:0-正常,1-封禁 diff --git a/src/main/java/com/xy/xyaicpzs/mapper/AnnouncementMapper.java b/src/main/java/com/xy/xyaicpzs/mapper/AnnouncementMapper.java new file mode 100644 index 0000000..12465a6 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/mapper/AnnouncementMapper.java @@ -0,0 +1,18 @@ +package com.xy.xyaicpzs.mapper; + +import com.xy.xyaicpzs.domain.entity.Announcement; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** +* @author XY003 +* @description 针对表【announcement(公告管理表)】的数据库操作Mapper +* @createDate 2025-11-21 17:52:54 +* @Entity com.xy.xyaicpzs.domain.entity.Announcement +*/ +public interface AnnouncementMapper extends BaseMapper { + +} + + + + diff --git a/src/main/java/com/xy/xyaicpzs/service/AnnouncementService.java b/src/main/java/com/xy/xyaicpzs/service/AnnouncementService.java new file mode 100644 index 0000000..d2767a6 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/service/AnnouncementService.java @@ -0,0 +1,13 @@ +package com.xy.xyaicpzs.service; + +import com.xy.xyaicpzs.domain.entity.Announcement; +import com.baomidou.mybatisplus.extension.service.IService; + +/** +* @author XY003 +* @description 针对表【announcement(公告管理表)】的数据库操作Service +* @createDate 2025-11-21 17:52:54 +*/ +public interface AnnouncementService extends IService { + +} diff --git a/src/main/java/com/xy/xyaicpzs/service/DltDataAnalysisService.java b/src/main/java/com/xy/xyaicpzs/service/DltDataAnalysisService.java index 946242c..1f6eb43 100644 --- a/src/main/java/com/xy/xyaicpzs/service/DltDataAnalysisService.java +++ b/src/main/java/com/xy/xyaicpzs/service/DltDataAnalysisService.java @@ -57,4 +57,12 @@ public interface DltDataAnalysisService { * @return 后区球号命中率统计信息 */ RedBallHitRateVO getBackBallHitRate(Long userId); + /** + * 管理员获取大乐透奖金统计(支持用户ID和奖项筛选) + * @param userId 用户ID(可选) + * @param prizeGrade 奖项等级(可选) + * @return 奖金统计信息 + */ + PrizeEstimateVO getAllUsersDltPrizeStatistics(Long userId, String prizeGrade); + } diff --git a/src/main/java/com/xy/xyaicpzs/service/DltDrawRecordService.java b/src/main/java/com/xy/xyaicpzs/service/DltDrawRecordService.java index 8f4377a..8c1e93d 100644 --- a/src/main/java/com/xy/xyaicpzs/service/DltDrawRecordService.java +++ b/src/main/java/com/xy/xyaicpzs/service/DltDrawRecordService.java @@ -3,6 +3,8 @@ package com.xy.xyaicpzs.service; import com.xy.xyaicpzs.domain.entity.DltDrawRecord; import com.baomidou.mybatisplus.extension.service.IService; +import java.util.List; + /** * @author XY003 * @description 针对表【dlt_draw_record(大乐透开奖信息表)】的数据库操作Service @@ -10,4 +12,11 @@ import com.baomidou.mybatisplus.extension.service.IService; */ public interface DltDrawRecordService extends IService { + /** + * 获取近期大乐透开奖信息 + * @param limit 获取条数,默认10条 + * @return 开奖信息列表,按开奖期号倒序排列 + */ + List getRecentDraws(Integer limit); + } diff --git a/src/main/java/com/xy/xyaicpzs/service/DltPredictRecordService.java b/src/main/java/com/xy/xyaicpzs/service/DltPredictRecordService.java index be86a37..67997e5 100644 --- a/src/main/java/com/xy/xyaicpzs/service/DltPredictRecordService.java +++ b/src/main/java/com/xy/xyaicpzs/service/DltPredictRecordService.java @@ -1,6 +1,7 @@ package com.xy.xyaicpzs.service; import com.xy.xyaicpzs.domain.entity.DltPredictRecord; +import com.xy.xyaicpzs.common.response.PageResponse; import com.baomidou.mybatisplus.extension.service.IService; import java.util.Date; @@ -40,4 +41,24 @@ public interface DltPredictRecordService extends IService { */ Long getDltPredictRecordsCountByUserId(Long userId); + /** + * 管理员获取所有大乐透推测记录(支持分页和用户ID筛选) + * @param userId 用户ID(可选) + * @param predictResult 中奖等级(可选) + * @param current 当前页码 + * @param pageSize 每页大小 + * @return 分页的大乐透预测记录 + */ + PageResponse getAllRecordsForAdmin(Long userId, String predictResult, Integer current, Integer pageSize); + + /** + * 管理员获取所有中奖记录(支持分页和筛选) + * @param userId 用户ID(可选) + * @param prizeGrade 奖项等级(可选) + * @param current 当前页码 + * @param pageSize 每页大小 + * @return 分页的中奖记录 + */ + PageResponse getWinningRecordsForAdmin(Long userId, String prizeGrade, Integer current, Integer pageSize); + } diff --git a/src/main/java/com/xy/xyaicpzs/service/PageViewService.java b/src/main/java/com/xy/xyaicpzs/service/PageViewService.java new file mode 100644 index 0000000..80903d6 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/service/PageViewService.java @@ -0,0 +1,92 @@ +package com.xy.xyaicpzs.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 页面访问量统计服务 + * 使用Redis实现PV统计 + */ +@Service +@RequiredArgsConstructor +public class PageViewService { + + private final RedisTemplate redisTemplate; + + // Redis Key前缀 + private static final String PV_TOTAL_KEY = "pv:total"; // 总PV + private static final String PV_DAILY_KEY = "pv:daily:"; // 每日PV前缀 + private static final int MAX_DAYS = 90; // 最大保存天数 + + /** + * 增加页面访问量 + */ + public void incrementPageView() { + String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE); + + // 增加总PV + redisTemplate.opsForValue().increment(PV_TOTAL_KEY); + + // 增加今日总PV + String dailyKey = PV_DAILY_KEY + today; + redisTemplate.opsForValue().increment(dailyKey); + // 设置过期时间为90天 + redisTemplate.expire(dailyKey, MAX_DAYS, TimeUnit.DAYS); + } + + /** + * 获取总PV + */ + public Long getTotalPageViews() { + Object value = redisTemplate.opsForValue().get(PV_TOTAL_KEY); + return value != null ? Long.parseLong(value.toString()) : 0L; + } + + /** + * 获取今日总PV + */ + public Long getTodayPageViews() { + String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE); + Object value = redisTemplate.opsForValue().get(PV_DAILY_KEY + today); + return value != null ? Long.parseLong(value.toString()) : 0L; + } + + /** + * 获取指定日期的PV + * @param date 日期,格式:yyyy-MM-dd + */ + public Long getDailyPageViews(String date) { + Object value = redisTemplate.opsForValue().get(PV_DAILY_KEY + date); + return value != null ? Long.parseLong(value.toString()) : 0L; + } + + /** + * 根据日期范围获取PV统计(近7天/30天/90天) + * @param days 天数,最大90天 + * @return 日期和对应PV的Map,按日期倒序排列 + */ + public Map getPageViewsByDays(int days) { + // 限制最大天数为90天 + if (days > MAX_DAYS) { + days = MAX_DAYS; + } + + Map result = new LinkedHashMap<>(); + LocalDate today = LocalDate.now(); + DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE; + + for (int i = 0; i < days; i++) { + String date = today.minusDays(i).format(formatter); + Long pv = getDailyPageViews(date); + result.put(date, pv); + } + return result; + } +} diff --git a/src/main/java/com/xy/xyaicpzs/service/PredictRecordService.java b/src/main/java/com/xy/xyaicpzs/service/PredictRecordService.java index 0a356a1..2bc8855 100644 --- a/src/main/java/com/xy/xyaicpzs/service/PredictRecordService.java +++ b/src/main/java/com/xy/xyaicpzs/service/PredictRecordService.java @@ -1,6 +1,7 @@ package com.xy.xyaicpzs.service; import com.xy.xyaicpzs.domain.entity.PredictRecord; +import com.xy.xyaicpzs.common.response.PageResponse; import com.baomidou.mybatisplus.extension.service.IService; import java.util.Date; @@ -47,4 +48,24 @@ public interface PredictRecordService extends IService { */ Long getPredictRecordsCountByUserId(Long userId); + /** + * 管理员获取所有推测记录(支持分页和用户ID筛选) + * @param userId 用户ID(可选) + * @param predictResult 中奖等级(可选) + * @param current 当前页码 + * @param pageSize 每页大小 + * @return 分页的预测记录 + */ + PageResponse getAllRecordsForAdmin(Long userId, String predictResult, Integer current, Integer pageSize); + + /** + * 管理员获取所有中奖记录(支持分页和筛选) + * @param userId 用户ID(可选) + * @param prizeGrade 奖项等级(可选) + * @param current 当前页码 + * @param pageSize 每页大小 + * @return 分页的中奖记录 + */ + PageResponse getWinningRecordsForAdmin(Long userId, String prizeGrade, Integer current, Integer pageSize); + } diff --git a/src/main/java/com/xy/xyaicpzs/service/VipCodeService.java b/src/main/java/com/xy/xyaicpzs/service/VipCodeService.java index e4ed167..e8428ad 100644 --- a/src/main/java/com/xy/xyaicpzs/service/VipCodeService.java +++ b/src/main/java/com/xy/xyaicpzs/service/VipCodeService.java @@ -30,7 +30,7 @@ public interface VipCodeService extends IService { /** * 获取一个可用的会员码 - * @param vipExpireTime 会员有效月数(1或12) + * @param vipExpireTime 会员有效月数 * @param createdUserId 创建人ID * @param createdUserName 创建人名称 * @return 可用的会员码,如果没有则返回null diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/AnnouncementServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/AnnouncementServiceImpl.java new file mode 100644 index 0000000..4ad1aba --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/service/impl/AnnouncementServiceImpl.java @@ -0,0 +1,22 @@ +package com.xy.xyaicpzs.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.xy.xyaicpzs.domain.entity.Announcement; +import com.xy.xyaicpzs.service.AnnouncementService; +import com.xy.xyaicpzs.mapper.AnnouncementMapper; +import org.springframework.stereotype.Service; + +/** +* @author XY003 +* @description 针对表【announcement(公告管理表)】的数据库操作Service实现 +* @createDate 2025-11-21 17:52:54 +*/ +@Service +public class AnnouncementServiceImpl extends ServiceImpl + implements AnnouncementService{ + +} + + + + diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/DataAnalysisServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/DataAnalysisServiceImpl.java index 050c597..67071be 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/DataAnalysisServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/DataAnalysisServiceImpl.java @@ -10,6 +10,8 @@ import com.xy.xyaicpzs.domain.vo.UserPredictStatVO; import com.xy.xyaicpzs.mapper.LotteryDrawsMapper; import com.xy.xyaicpzs.mapper.PredictRecordMapper; import com.xy.xyaicpzs.service.DataAnalysisService; +import com.xy.xyaicpzs.util.SsqPrizeCalculator; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -19,6 +21,7 @@ import java.util.List; /** * 数据分析服务实现类 */ +@Slf4j @Service public class DataAnalysisServiceImpl implements DataAnalysisService { @@ -82,37 +85,83 @@ public class DataAnalysisServiceImpl implements DataAnalysisService { @Override public int processPendingPredictions() { + log.info("开始处理双色球待开奖预测记录"); + // 查询所有待开奖的预测记录 QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("predictStatus", "待开奖") .eq("type", "ssq"); List pendingRecords = predictRecordMapper.selectList(queryWrapper); + log.info("找到{}条待开奖的双色球预测记录", pendingRecords.size()); + int processedCount = 0; for (PredictRecord record : pendingRecords) { - // 查询对应期号的开奖结果 - LotteryDraws draws = lotteryDrawsMapper.selectById(record.getDrawId()); - if (draws != null) { - // 比较预测号码与开奖号码,计算中奖结果 - String result = calculatePredictResult(record, draws); - long bonus = calculateBonus(result); - if(result.equals("未中奖")){ - record.setPredictStatus("未中奖"); - }else{ - record.setPredictStatus("已中奖"); + try { + // 查询对应期号的开奖结果 + LotteryDraws draws = lotteryDrawsMapper.selectById(record.getDrawId()); + + if (draws != null) { + log.debug("处理预测记录ID:{},期号:{},奖池:{}亿元,特别规定期间:{}", + record.getId(), record.getDrawId(), draws.getPrizePool(), + draws.getIsSpecialPeriod()); + + // 构建预测号码数组 [红球6个, 蓝球1个] + Integer[] predictNumbers = { + record.getRedBall1(), + record.getRedBall2(), + record.getRedBall3(), + record.getRedBall4(), + record.getRedBall5(), + record.getRedBall6(), + record.getBlueBall() + }; + + // 构建开奖号码数组 [红球6个, 蓝球1个] + Integer[] drawNumbers = { + draws.getRedBall1(), + draws.getRedBall2(), + draws.getRedBall3(), + draws.getRedBall4(), + draws.getRedBall5(), + draws.getRedBall6(), + draws.getBlueBall() + }; + + // 获取奖池金额和特别规定状态 + Double prizePool = draws.getPrizePool() != null ? draws.getPrizePool() : 0.0; + boolean isSpecialPeriod = draws.getIsSpecialPeriod() != null && draws.getIsSpecialPeriod() == 1; + + // 使用SsqPrizeCalculator计算中奖结果 + SsqPrizeCalculator.PrizeResult prizeResult = SsqPrizeCalculator.calculatePrize( + predictNumbers, drawNumbers, prizePool, isSpecialPeriod); + + // 更新预测记录 + if(prizeResult.getPrizeLevel().equals("未中奖")){ + record.setPredictStatus("未中奖"); + }else{ + record.setPredictStatus("已中奖"); + } + + record.setPredictResult(prizeResult.getPrizeLevel()); + record.setBonus(prizeResult.getBonus()); + predictRecordMapper.updateById(record); + + log.debug("预测记录ID:{} 处理完成,中奖等级:{},奖金:{}元(奖池:{}亿元,特别规定:{})", + record.getId(), prizeResult.getPrizeLevel(), prizeResult.getBonus(), + prizePool, isSpecialPeriod); + + processedCount++; + } else { + log.debug("未找到期号{}的开奖记录", record.getDrawId()); } - - // 更新预测记录 - - record.setPredictResult(result); - record.setBonus(bonus); - predictRecordMapper.updateById(record); - - processedCount++; + } catch (Exception e) { + log.error("处理预测记录ID:{} 时发生错误:{}", record.getId(), e.getMessage(), e); } } + log.info("双色球待开奖预测记录处理完成,共处理{}条记录", processedCount); return processedCount; } @@ -157,68 +206,4 @@ public class DataAnalysisServiceImpl implements DataAnalysisService { queryWrapper.eq("type", "ssq"); return predictRecordMapper.selectCount(queryWrapper); } - - /** - * 计算预测结果 - */ - private String calculatePredictResult(PredictRecord record, LotteryDraws draws) { - // 比较红球 - int redMatches = 0; - Integer[] predictReds = {record.getRedBall1(), record.getRedBall2(), record.getRedBall3(), - record.getRedBall4(), record.getRedBall5(), record.getRedBall6()}; - Integer[] drawReds = {draws.getRedBall1(), draws.getRedBall2(), draws.getRedBall3(), - draws.getRedBall4(), draws.getRedBall5(), draws.getRedBall6()}; - - for (Integer predictRed : predictReds) { - for (Integer drawRed : drawReds) { - if (predictRed != null && predictRed.equals(drawRed)) { - redMatches++; - break; - } - } - } - - // 比较蓝球 - boolean blueMatch = record.getBlueBall() != null && - record.getBlueBall().equals(draws.getBlueBall()); - - // 根据中奖规则判断奖项 - if (redMatches == 6 && blueMatch) { - return "一等奖"; - } else if (redMatches == 6) { - return "二等奖"; - } else if (redMatches == 5 && blueMatch) { - return "三等奖"; - } else if ((redMatches == 5) || (redMatches == 4 && blueMatch)) { - return "四等奖"; - } else if ((redMatches == 4) || (redMatches == 3 && blueMatch)) { - return "五等奖"; - } else if (blueMatch && (redMatches == 0 || redMatches == 1 || redMatches == 2)) { - return "六等奖"; - } else { - return "未中奖"; - } - } - - /** - * 计算奖金 - */ - private long calculateBonus(String result) { - switch (result) { - case "一等奖": - return 5000000L; // 500万 - case "二等奖": - return 1000000L; // 100万 - case "三等奖": - return 3000L; // 3000元 - case "四等奖": - return 200L; // 200元 - case "五等奖": - return 10L; // 10元 - case "六等奖": - return 5L; // 5元 - default: - return 0L; - } - } } \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/DltDataAnalysisServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/DltDataAnalysisServiceImpl.java index cc2b433..e200a13 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/DltDataAnalysisServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/DltDataAnalysisServiceImpl.java @@ -61,7 +61,8 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { DltDrawRecord drawRecord = dltDrawRecordMapper.selectOne(drawQueryWrapper); if (drawRecord != null) { - log.debug("处理预测记录ID:{},期号:{}", record.getId(), record.getDrawId()); + log.debug("处理预测记录ID:{},期号:{},奖池:{}亿元", + record.getId(), record.getDrawId(), drawRecord.getPrizePool()); // 构建预测号码数组 [前区5个号码, 后区2个号码] Integer[] predictNumbers = { @@ -85,8 +86,12 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { drawRecord.getBackBall2() }; - // 使用DltPrizeCalculator计算中奖结果 - DltPrizeCalculator.PrizeResult prizeResult = DltPrizeCalculator.calculatePrize(predictNumbers, drawNumbers); + // 获取奖池金额,如果为null则默认为0 + Double prizePool = drawRecord.getPrizePool() != null ? drawRecord.getPrizePool() : 0.0; + + // 使用DltPrizeCalculator计算中奖结果,传入奖池金额 + DltPrizeCalculator.PrizeResult prizeResult = DltPrizeCalculator.calculatePrize( + predictNumbers, drawNumbers, prizePool); // 更新预测记录 // 根据中奖结果设置状态 @@ -100,8 +105,8 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { dltPredictRecordMapper.updateById(record); - log.debug("预测记录ID:{} 处理完成,中奖等级:{},奖金:{}", - record.getId(), prizeResult.getPrizeLevel(), prizeResult.getBonus()); + log.debug("预测记录ID:{} 处理完成,中奖等级:{},奖金:{}元(奖池:{}亿元)", + record.getId(), prizeResult.getPrizeLevel(), prizeResult.getBonus(), prizePool); processedCount++; } else { @@ -147,7 +152,8 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { for (DltPredictRecord record : predictRecords) { if ("待开奖".equals(record.getPredictStatus())) { pendingCount++; - } else if ("已开奖".equals(record.getPredictStatus())) { + } else { + // 已开奖的记录(包括已开奖、已中奖、未中奖等状态) drawnCount++; // 检查是否中奖(除了"未中奖"都算中奖) if (!"未中奖".equals(record.getPredictResult()) && @@ -158,7 +164,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { } } - // 计算命中率 + // 计算命中率:命中次数 ÷ (总次数 - 待开奖次数) = 命中次数 ÷ 已开奖次数 BigDecimal hitRate = drawnCount > 0 ? BigDecimal.valueOf(hitCount).divide(BigDecimal.valueOf(drawnCount), 4, RoundingMode.HALF_UP) : BigDecimal.ZERO; @@ -184,7 +190,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userId", userId); // 排除待开奖状态 - queryWrapper.eq("predictStatus", "已开奖"); + queryWrapper.ne("predictStatus", "待开奖"); // 如果指定了预测记录ID,则只查询该记录 if (predictId != null) { queryWrapper.eq("id", predictId); @@ -198,7 +204,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { Map totalBonusByPrizeLevel = new HashMap<>(); // 初始化大乐透的所有等级 - String[] prizeLevels = {"一等奖", "二等奖", "三等奖", "四等奖", "五等奖", "六等奖", "七等奖", "八等奖", "九等奖", "未中奖"}; + String[] prizeLevels = {"一等奖", "二等奖", "三等奖", "四等奖", "五等奖", "六等奖", "七等奖", "未中奖"}; for (String level : prizeLevels) { countByPrizeLevel.put(level, 0); totalBonusByPrizeLevel.put(level, BigDecimal.ZERO); @@ -269,7 +275,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { // 查询用户的所有预测记录(除了"待开奖"状态的) QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userId", userId) - .eq("predictStatus", "已开奖"); + .ne("predictStatus", "待开奖"); List predictRecords = dltPredictRecordMapper.selectList(queryWrapper); @@ -323,7 +329,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { // 查询用户的所有预测记录(除了"待开奖"状态的) QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userId", userId) - .eq("predictStatus", "已开奖"); + .ne("predictStatus", "待开奖"); List predictRecords = dltPredictRecordMapper.selectList(queryWrapper); @@ -384,7 +390,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { // 查询用户的所有预测记录(除了"待开奖"状态的) QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userId", userId) - .eq("predictStatus", "已开奖"); + .ne("predictStatus", "待开奖"); List predictRecords = dltPredictRecordMapper.selectList(queryWrapper); @@ -435,7 +441,7 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { // 查询用户的所有预测记录(除了"待开奖"状态的) QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userId", userId) - .eq("predictStatus", "已开奖"); + .ne("predictStatus", "待开奖"); List predictRecords = dltPredictRecordMapper.selectList(queryWrapper); @@ -482,4 +488,125 @@ public class DltDataAnalysisServiceImpl implements DltDataAnalysisService { return result; } + + @Override + public PrizeEstimateVO getAllUsersDltPrizeStatistics(Long userId, String prizeGrade) { + log.info("管理员开始获取所有用户的大乐透奖金统计,userId={},prizeGrade={}", userId, prizeGrade); + + // 查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + // 排除待开奖状态 + queryWrapper.ne("predictStatus", "待开奖"); + + // 如果提供了用户ID,添加筛选条件 + if (userId != null) { + queryWrapper.eq("userId", userId); + } + + // 如果提供了奖项等级,添加筛选条件 + if (prizeGrade != null && !prizeGrade.trim().isEmpty()) { + queryWrapper.eq("predictResult", prizeGrade); + } + + // 查询符合条件的大乐透预测记录 + List records = dltPredictRecordMapper.selectList(queryWrapper); + + // 按中奖等级分组统计 + Map countByPrizeLevel = new HashMap<>(); + Map totalBonusByPrizeLevel = new HashMap<>(); + + // 初始化大乐透的所有等级 + String[] prizeLevels = {"一等奖", "二等奖", "三等奖", "四等奖", "五等奖", "六等奖", "七等奖", "未中奖"}; + for (String level : prizeLevels) { + countByPrizeLevel.put(level, 0); + totalBonusByPrizeLevel.put(level, BigDecimal.ZERO); + } + + // 统计各等级数量和奖金 + for (DltPredictRecord record : records) { + String prizeLevel = record.getPredictResult(); + if (prizeLevel == null || prizeLevel.trim().isEmpty()) { + prizeLevel = "未中奖"; + } + + // 如果只查询特定奖项,且当前记录不是该奖项(理论上SQL已经过滤,这里双重保险) + if (prizeGrade != null && !prizeGrade.trim().isEmpty() && !prizeGrade.equals(prizeLevel)) { + continue; + } + + // 确保prizeLevel在预定义的等级中,或者动态添加 + if (!countByPrizeLevel.containsKey(prizeLevel)) { + countByPrizeLevel.put(prizeLevel, 0); + totalBonusByPrizeLevel.put(prizeLevel, BigDecimal.ZERO); + } + + // 更新计数 + countByPrizeLevel.put(prizeLevel, countByPrizeLevel.get(prizeLevel) + 1); + + // 累计奖金 + if (record.getBonus() != null) { + BigDecimal bonus = new BigDecimal(record.getBonus().toString()); + totalBonusByPrizeLevel.put(prizeLevel, + totalBonusByPrizeLevel.get(prizeLevel).add(bonus)); + } + } + + // 构建返回结果 + List prizeDetails = new ArrayList<>(); + + // 如果指定了奖项,只返回该奖项的统计 + if (prizeGrade != null && !prizeGrade.trim().isEmpty()) { + if (countByPrizeLevel.containsKey(prizeGrade)) { + int count = countByPrizeLevel.get(prizeGrade); + BigDecimal totalBonus = totalBonusByPrizeLevel.get(prizeGrade); + BigDecimal singlePrize = BigDecimal.ZERO; + if (count > 0 && totalBonus.compareTo(BigDecimal.ZERO) > 0) { + singlePrize = totalBonus.divide(new BigDecimal(count), 2, RoundingMode.HALF_UP); + } + + prizeDetails.add(PrizeEstimateVO.PrizeDetailItem.builder() + .prizeLevel(prizeGrade) + .winningCount(count) + .singlePrize(singlePrize) + .subtotal(totalBonus) + .build()); + } + } else { + // 否则返回所有等级的统计 + for (String level : prizeLevels) { + // 跳过没有记录的等级 + if (!countByPrizeLevel.containsKey(level) || countByPrizeLevel.get(level) <= 0) { + continue; + } + + int count = countByPrizeLevel.get(level); + BigDecimal totalBonus = totalBonusByPrizeLevel.get(level); + BigDecimal singlePrize = BigDecimal.ZERO; + if (count > 0 && totalBonus.compareTo(BigDecimal.ZERO) > 0) { + singlePrize = totalBonus.divide(new BigDecimal(count), 2, RoundingMode.HALF_UP); + } + + prizeDetails.add(PrizeEstimateVO.PrizeDetailItem.builder() + .prizeLevel(level) + .winningCount(count) + .singlePrize(singlePrize) + .subtotal(totalBonus) + .build()); + } + } + + // 计算总奖金 + BigDecimal totalPrize = prizeDetails.stream() + .map(PrizeEstimateVO.PrizeDetailItem::getSubtotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + PrizeEstimateVO result = PrizeEstimateVO.builder() + .totalPrize(totalPrize) + .prizeDetails(prizeDetails) + .build(); + + log.info("管理员获取大乐透奖金统计完成,总奖金{},明细数量:{}", totalPrize, prizeDetails.size()); + return result; + } } + diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/DltDrawRecordServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/DltDrawRecordServiceImpl.java index 6408f41..d4621ed 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/DltDrawRecordServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/DltDrawRecordServiceImpl.java @@ -1,11 +1,14 @@ package com.xy.xyaicpzs.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.xy.xyaicpzs.domain.entity.DltDrawRecord; import com.xy.xyaicpzs.service.DltDrawRecordService; import com.xy.xyaicpzs.mapper.DltDrawRecordMapper; import org.springframework.stereotype.Service; +import java.util.List; + /** * @author XY003 * @description 针对表【dlt_draw_record(大乐透开奖信息表)】的数据库操作Service实现 @@ -15,6 +18,15 @@ import org.springframework.stereotype.Service; public class DltDrawRecordServiceImpl extends ServiceImpl implements DltDrawRecordService{ + @Override + public List getRecentDraws(Integer limit) { + if (limit == null || limit <= 0) { + limit = 10; + } + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.orderByDesc("draw_id").last("LIMIT " + limit); + return list(queryWrapper); + } } diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/DltPredictRecordServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/DltPredictRecordServiceImpl.java index 23de76e..6c17e05 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/DltPredictRecordServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/DltPredictRecordServiceImpl.java @@ -1,10 +1,13 @@ package com.xy.xyaicpzs.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.xy.xyaicpzs.domain.entity.DltPredictRecord; import com.xy.xyaicpzs.service.DltPredictRecordService; import com.xy.xyaicpzs.mapper.DltPredictRecordMapper; +import com.xy.xyaicpzs.common.response.PageResponse; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import java.util.Date; @@ -68,8 +71,95 @@ public class DltPredictRecordServiceImpl extends ServiceImpl getAllRecordsForAdmin(Long userId, String predictResult, Integer current, Integer pageSize) { + // 参数校验 + if (current < 1) { + current = 1; + } + if (pageSize < 1) { + pageSize = 10; + } + if (pageSize > 100) { + pageSize = 100; // 限制最大每页100条 + } + + // 构建查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + + // 如果提供了用户ID,添加筛选条件 + if (userId != null) { + queryWrapper.eq("userId", userId); + } + + // 如果提供了中奖等级,添加筛选条件 + if (StringUtils.isNotBlank(predictResult)) { + queryWrapper.eq("predictResult", predictResult); + } + + // 按预测时间降序排序 + queryWrapper.orderByDesc("predictTime"); + + // 执行分页查询 + Page page = new Page<>(current, pageSize); + Page resultPage = page(page, queryWrapper); + + // 构建分页响应对象 + return PageResponse.of( + resultPage.getRecords(), + resultPage.getTotal(), + (int) resultPage.getCurrent(), + (int) resultPage.getSize() + ); + } + + @Override + public PageResponse getWinningRecordsForAdmin(Long userId, String prizeGrade, Integer current, Integer pageSize) { + // 参数校验 + if (current < 1) { + current = 1; + } + if (pageSize < 1) { + pageSize = 10; + } + if (pageSize > 100) { + pageSize = 100; + } + + // 构建查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + + // 排除待开奖和未中奖的记录,只保留中奖记录 + queryWrapper.ne("predictStatus", "待开奖"); + // 注意:数据库中未中奖可能存为"未中奖"或空,视具体逻辑而定,这里假设是"未中奖" + // 同时为了保险,也可以检查bonus > 0,但有些小奖可能bonus未设置?通常会有bonus。 + // 这里使用predictResult不为"未中奖"且不为空 + queryWrapper.isNotNull("predictResult"); + + // 如果提供了用户ID + if (userId != null) { + queryWrapper.eq("userId", userId); + } + + // 如果提供了具体奖项等级 + if (StringUtils.isNotBlank(prizeGrade)) { + queryWrapper.eq("predictResult", prizeGrade); + } + + // 按预测时间降序排序 + queryWrapper.orderByDesc("predictTime"); + + // 执行分页查询 + Page page = new Page<>(current, pageSize); + Page resultPage = page(page, queryWrapper); + + // 构建分页响应对象 + return PageResponse.of( + resultPage.getRecords(), + resultPage.getTotal(), + (int) resultPage.getCurrent(), + (int) resultPage.getSize() + ); + } + } - - - - diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/PredictRecordServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/PredictRecordServiceImpl.java index 317fedf..9765782 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/PredictRecordServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/PredictRecordServiceImpl.java @@ -1,10 +1,13 @@ package com.xy.xyaicpzs.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.xy.xyaicpzs.domain.entity.PredictRecord; import com.xy.xyaicpzs.mapper.PredictRecordMapper; import com.xy.xyaicpzs.service.PredictRecordService; +import com.xy.xyaicpzs.common.response.PageResponse; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import java.util.Date; @@ -75,4 +78,92 @@ public class PredictRecordServiceImpl extends ServiceImpl getAllRecordsForAdmin(Long userId, String predictResult, Integer current, Integer pageSize) { + // 参数校验 + if (current < 1) { + current = 1; + } + if (pageSize < 1) { + pageSize = 10; + } + if (pageSize > 100) { + pageSize = 100; // 限制最大每页100条 + } + + // 构建查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + + // 如果提供了用户ID,添加筛选条件 + if (userId != null) { + queryWrapper.eq("userId", userId); + } + + // 如果提供了中奖等级,添加筛选条件 + if (StringUtils.isNotBlank(predictResult)) { + queryWrapper.eq("predictResult", predictResult); + } + + // 按预测时间降序排序 + queryWrapper.orderByDesc("predictTime"); + + // 执行分页查询 + Page page = new Page<>(current, pageSize); + Page resultPage = page(page, queryWrapper); + + // 构建分页响应对象 + return PageResponse.of( + resultPage.getRecords(), + resultPage.getTotal(), + (int) resultPage.getCurrent(), + (int) resultPage.getSize() + ); + } + + @Override + public PageResponse getWinningRecordsForAdmin(Long userId, String prizeGrade, Integer current, Integer pageSize) { + // 参数校验 + if (current < 1) { + current = 1; + } + if (pageSize < 1) { + pageSize = 10; + } + if (pageSize > 100) { + pageSize = 100; + } + + // 构建查询条件 + QueryWrapper queryWrapper = new QueryWrapper<>(); + + // 排除待开奖和未中奖的记录 + queryWrapper.ne("predictStatus", "待开奖"); + queryWrapper.isNotNull("predictResult"); + + // 如果提供了用户ID + if (userId != null) { + queryWrapper.eq("userId", userId); + } + + // 如果提供了具体奖项等级 + if (StringUtils.isNotBlank(prizeGrade)) { + queryWrapper.eq("predictResult", prizeGrade); + } + + // 按预测时间降序排序 + queryWrapper.orderByDesc("predictTime"); + + // 执行分页查询 + Page page = new Page<>(current, pageSize); + Page resultPage = page(page, queryWrapper); + + // 构建分页响应对象 + return PageResponse.of( + resultPage.getRecords(), + resultPage.getTotal(), + (int) resultPage.getCurrent(), + (int) resultPage.getSize() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/SmsServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/SmsServiceImpl.java index 5ba5f74..1729659 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/SmsServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/SmsServiceImpl.java @@ -1,9 +1,10 @@ package com.xy.xyaicpzs.service.impl; -import com.aliyun.dysmsapi20170525.Client; -import com.aliyun.dysmsapi20170525.models.SendSmsRequest; -import com.aliyun.tea.TeaException; -import com.aliyun.teautil.models.RuntimeOptions; +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.sms.v20210111.SmsClient; +import com.tencentcloudapi.sms.v20210111.models.SendSmsRequest; +import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse; import com.xy.xyaicpzs.service.SmsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,24 +20,30 @@ import java.util.Random; import java.util.concurrent.TimeUnit; /** - * 短信服务实现类 + * 短信服务实现类(腾讯云SMS) */ @Service public class SmsServiceImpl implements SmsService { private static final Logger logger = LoggerFactory.getLogger(SmsServiceImpl.class); - @Value("${aliyun.sms.sign-name:西安精彩数据服务社}") + @Value("${tencent.sms.secret-id}") + private String secretId; + + @Value("${tencent.sms.secret-key}") + private String secretKey; + + @Value("${tencent.sms.sdk-app-id}") + private String sdkAppId; + + @Value("${tencent.sms.template-id}") + private String templateId; + + @Value("${tencent.sms.sign-name}") private String signName; - @Value("${aliyun.sms.template-code:SMS_489840017}") - private String templateCode; - - @Value("${aliyun.sms.access-key-id}") - private String accessKeyId; - - @Value("${aliyun.sms.access-key-secret}") - private String accessKeySecret; + @Value("${tencent.sms.region:ap-guangzhou}") + private String region; @Autowired private RedisTemplate redisTemplate; @@ -51,15 +58,11 @@ public class SmsServiceImpl implements SmsService { private static final int MAX_SMS_COUNT_PER_DAY = 10; /** - * 创建阿里云短信客户端 + * 创建腾讯云短信客户端 */ - private Client createSmsClient() throws Exception { - // 从配置文件中获取阿里云AccessKey配置 - com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config(); - config.accessKeyId = accessKeyId; - config.accessKeySecret = accessKeySecret; - config.endpoint = "dysmsapi.aliyuncs.com"; - return new Client(config); + private SmsClient createSmsClient() { + Credential cred = new Credential(secretId, secretKey); + return new SmsClient(cred, region); } /** @@ -98,37 +101,66 @@ public class SmsServiceImpl implements SmsService { // 生成6位随机验证码 String verificationCode = generateVerificationCode(); - // 构建短信请求 - Client client = createSmsClient(); - SendSmsRequest sendSmsRequest = new SendSmsRequest() - .setSignName(signName) - .setTemplateCode(templateCode) - .setPhoneNumbers(phoneNumber) - .setTemplateParam("{\"code\":\"" + verificationCode + "\"}"); - - RuntimeOptions runtime = new RuntimeOptions(); try { + // 创建腾讯云短信客户端 + SmsClient client = createSmsClient(); + + // 构建短信请求 + SendSmsRequest req = new SendSmsRequest(); + + // 短信应用ID + req.setSmsSdkAppId(sdkAppId); + + // 短信签名内容 + req.setSignName(signName); + + // 模板ID + req.setTemplateId(templateId); + + // 模板参数:验证码 + String[] templateParamSet = {verificationCode}; + req.setTemplateParamSet(templateParamSet); + + // 下发手机号码,采用E.164标准,+[国家或地区码][手机号] + // 例如:+8613711112222,其中前面有一个+号,86为国家码,13711112222为手机号 + String[] phoneNumberSet = {"+86" + phoneNumber}; + req.setPhoneNumberSet(phoneNumberSet); + // 发送短信 - client.sendSmsWithOptions(sendSmsRequest, runtime); - logger.info("短信验证码发送成功,手机号: {}", phoneNumber); + SendSmsResponse resp = client.SendSms(req); - // 将验证码保存到Redis,设置过期时间 - String codeKey = SMS_CODE_PREFIX + phoneNumber; - redisTemplate.opsForValue().set(codeKey, verificationCode, SMS_CODE_EXPIRE, TimeUnit.MINUTES); - - // 增加当天发送次数,并设置过期时间为当天结束 - if (count == null) { - count = 0; + // 检查发送结果 + if (resp.getSendStatusSet() != null && resp.getSendStatusSet().length > 0) { + String code = resp.getSendStatusSet()[0].getCode(); + if ("Ok".equals(code)) { + logger.info("短信验证码发送成功,手机号: {}, SerialNo: {}", phoneNumber, resp.getSendStatusSet()[0].getSerialNo()); + + // 将验证码保存到Redis,设置过期时间 + String codeKey = SMS_CODE_PREFIX + phoneNumber; + redisTemplate.opsForValue().set(codeKey, verificationCode, SMS_CODE_EXPIRE, TimeUnit.MINUTES); + + // 增加当天发送次数,并设置过期时间为当天结束 + if (count == null) { + count = 0; + } + redisTemplate.opsForValue().set(countKey, count + 1, getSecondsUntilEndOfDay(), TimeUnit.SECONDS); + + return true; + } else { + logger.error("短信发送失败, 手机号: {}, Code: {}, Message: {}", + phoneNumber, code, resp.getSendStatusSet()[0].getMessage()); + return false; + } + } else { + logger.error("短信发送失败, 手机号: {}, 响应为空", phoneNumber); + return false; } - redisTemplate.opsForValue().set(countKey, count + 1, getSecondsUntilEndOfDay(), TimeUnit.SECONDS); - return true; - } catch (TeaException error) { - logger.error("短信发送失败, 手机号: {}, 错误信息: {}, 诊断信息: {}", - phoneNumber, error.getMessage(), error.getData().get("Recommend")); + } catch (TencentCloudSDKException e) { + logger.error("腾讯云短信发送异常, 手机号: {}, 错误信息: {}", phoneNumber, e.getMessage(), e); return false; - } catch (Exception error) { - logger.error("短信发送异常, 手机号: {}", phoneNumber, error); + } catch (Exception e) { + logger.error("短信发送异常, 手机号: {}", phoneNumber, e); return false; } } diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/UserServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/UserServiceImpl.java index 68a019a..9f7bca7 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/UserServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/UserServiceImpl.java @@ -17,11 +17,13 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.util.DigestUtils; import jakarta.servlet.http.HttpServletRequest; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -41,6 +43,9 @@ public class UserServiceImpl extends ServiceImpl @Autowired private SmsService smsService; + + @Autowired + private RedisTemplate redisTemplate; @Override public long userRegister(String userAccount, String userName, String userPassword, String checkPassword) { @@ -122,8 +127,18 @@ public class UserServiceImpl extends ServiceImpl throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误"); } - // 3. 记录用户的登录态 + // 3. 生成唯一token并存储到Redis(实现单终端登录) + String token = UUID.randomUUID().toString().replace("-", ""); + String redisKey = UserConstant.REDIS_USER_LOGIN_TOKEN_PREFIX + user.getId(); + + // 将token存储到Redis,如果之前有token会被覆盖(实现单终端登录) + redisTemplate.opsForValue().set(redisKey, token, UserConstant.USER_LOGIN_TOKEN_EXPIRE, TimeUnit.SECONDS); + + // 4. 将token和用户信息存储到Session + request.getSession().setAttribute(UserConstant.USER_LOGIN_TOKEN, token); request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user); + + log.info("用户登录成功,用户ID:{},生成token:{}", user.getId(), token); return getSafetyUser(user); } @@ -141,6 +156,27 @@ public class UserServiceImpl extends ServiceImpl if (currentUser == null || currentUser.getId() == null) { throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR); } + + // 验证token是否有效(单终端登录验证) + String sessionToken = (String) request.getSession().getAttribute(UserConstant.USER_LOGIN_TOKEN); + if (sessionToken == null) { + log.warn("Session中不存在token,用户ID:{}", currentUser.getId()); + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "登录已失效,请重新登录"); + } + + // 从Redis获取该用户的有效token + String redisKey = UserConstant.REDIS_USER_LOGIN_TOKEN_PREFIX + currentUser.getId(); + String redisToken = (String) redisTemplate.opsForValue().get(redisKey); + + // 如果Redis中没有token,或者Session中的token与Redis中的不一致,说明在其他设备登录了 + if (redisToken == null || !redisToken.equals(sessionToken)) { + log.warn("Token验证失败,用户ID:{},Session token:{},Redis token:{}", + currentUser.getId(), sessionToken, redisToken); + // 清除Session + request.getSession().invalidate(); + throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "账号在其他设备登录,当前会话已失效"); + } + // 从数据库查询(追求性能的话可以注释,直接走缓存) long userId = currentUser.getId(); currentUser = this.getById(userId); @@ -157,11 +193,24 @@ public class UserServiceImpl extends ServiceImpl */ @Override public boolean userLogout(HttpServletRequest request) { - if (request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE) == null) { + Object userObj = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE); + if (userObj == null) { throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登录"); } - // 移除登录态 + + User user = (User) userObj; + Long userId = user.getId(); + + // 清除Redis中的token + if (userId != null) { + String redisKey = UserConstant.REDIS_USER_LOGIN_TOKEN_PREFIX + userId; + redisTemplate.delete(redisKey); + log.info("用户注销,清除Redis token,用户ID:{}", userId); + } + + // 移除Session中的登录态和token request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE); + request.getSession().removeAttribute(UserConstant.USER_LOGIN_TOKEN); return true; } @@ -269,9 +318,9 @@ public class UserServiceImpl extends ServiceImpl user.setUserName(userName); user.setCreateTime(new Date()); user.setUpdateTime(new Date()); - // 设置为VIP用户,有效期10天 + // 设置为VIP用户,有效期30天 user.setIsVip(0); - Date vipExpireDate = new Date(System.currentTimeMillis() + 10L * 24 * 60 * 60 * 1000); + Date vipExpireDate = new Date(System.currentTimeMillis() + 30L * 24 * 60 * 60 * 1000); user.setVipExpire(vipExpireDate); boolean saveResult = this.save(user); @@ -319,8 +368,18 @@ public class UserServiceImpl extends ServiceImpl throw new BusinessException(ErrorCode.PARAMS_ERROR, "手机号未注册"); } - // 3. 记录用户的登录态 + // 3. 生成唯一token并存储到Redis(实现单终端登录) + String token = UUID.randomUUID().toString().replace("-", ""); + String redisKey = UserConstant.REDIS_USER_LOGIN_TOKEN_PREFIX + user.getId(); + + // 将token存储到Redis,如果之前有token会被覆盖(实现单终端登录) + redisTemplate.opsForValue().set(redisKey, token, UserConstant.USER_LOGIN_TOKEN_EXPIRE, TimeUnit.SECONDS); + + // 4. 将token和用户信息存储到Session + request.getSession().setAttribute(UserConstant.USER_LOGIN_TOKEN, token); request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user); + + log.info("用户手机号登录成功,用户ID:{},生成token:{}", user.getId(), token); return getSafetyUser(user); } diff --git a/src/main/java/com/xy/xyaicpzs/service/impl/VipCodeServiceImpl.java b/src/main/java/com/xy/xyaicpzs/service/impl/VipCodeServiceImpl.java index ed5c68f..ceb664c 100644 --- a/src/main/java/com/xy/xyaicpzs/service/impl/VipCodeServiceImpl.java +++ b/src/main/java/com/xy/xyaicpzs/service/impl/VipCodeServiceImpl.java @@ -68,15 +68,22 @@ public class VipCodeServiceImpl extends ServiceImpl impl // 4. 根据会员码的vipExpireTime判断会员类型 String memberType; + String vipType; // 用户表的vipType字段 int orderAmount; - if (vipCode.getVipExpireTime() == 1) { + int expireMonths = vipCode.getVipExpireTime(); + if (expireMonths < 12) { memberType = "月度会员"; - orderAmount = 10; - } else if (vipCode.getVipExpireTime() == 12) { + vipType = "月度会员"; + orderAmount = 10 * expireMonths; + } else if (expireMonths >= 12) { memberType = "年度会员"; - orderAmount = 100; + vipType = "年度会员"; + orderAmount = 100 * (expireMonths / 12); } else { - throw new IllegalArgumentException("无效的会员有效期:" + vipCode.getVipExpireTime()); + // 支持自定义月份,按比例计算订单金额(10元/月) + memberType = expireMonths + "个月会员"; + vipType = "月度会员"; // 默认归类为月度会员 + orderAmount = 10 * expireMonths; } // 5. 先在vip_exchange_record表插入兑换记录 @@ -107,6 +114,7 @@ public class VipCodeServiceImpl extends ServiceImpl impl updateUser.setId(userId); updateUser.setIsVip(1); // 设置为会员 updateUser.setVipExpire(newVipExpire); + updateUser.setVipType(vipType); // 设置会员类型 updateUser.setUpdateTime(new Date()); int userUpdateResult = userMapper.updateById(updateUser); @@ -115,6 +123,8 @@ public class VipCodeServiceImpl extends ServiceImpl impl throw new RuntimeException("更新用户会员状态失败"); } + log.info("会员码激活成功,用户ID:{},会员类型:{},vipType:{},新的到期时间:{}", userId, memberType, vipType, newVipExpire); + // 8. 标记会员码为已使用,并记录使用人信息和使用时间 Date now = new Date(); VipCode updateVipCode = new VipCode(); @@ -175,8 +185,8 @@ public class VipCodeServiceImpl extends ServiceImpl impl throw new IllegalArgumentException("生成数量必须大于0"); } - if (vipExpireTime != 1 && vipExpireTime != 12) { - throw new IllegalArgumentException("会员有效月数只能是1或12"); + if (vipExpireTime <= 0) { + throw new IllegalArgumentException("会员有效月数必须大于0"); } if (numCodes > 1000) { @@ -228,8 +238,8 @@ public class VipCodeServiceImpl extends ServiceImpl impl public String getAvailableVipCode(int vipExpireTime, Long createdUserId, String createdUserName) { log.info("查找可用会员码,有效月数:{},创建人ID:{}", vipExpireTime, createdUserId); - if (vipExpireTime != 1 && vipExpireTime != 12) { - throw new IllegalArgumentException("会员有效月数只能是1或12"); + if (vipExpireTime <= 0) { + throw new IllegalArgumentException("会员有效月数必须大于0"); } QueryWrapper queryWrapper = new QueryWrapper<>(); diff --git a/src/main/java/com/xy/xyaicpzs/util/DltDataImporter.java b/src/main/java/com/xy/xyaicpzs/util/DltDataImporter.java index 09c4e2f..20b9479 100644 --- a/src/main/java/com/xy/xyaicpzs/util/DltDataImporter.java +++ b/src/main/java/com/xy/xyaicpzs/util/DltDataImporter.java @@ -734,6 +734,10 @@ public class DltDataImporter { // I列: 后区2 entity.setBackBall2(getCellIntegerValue(row.getCell(8))); + + // J列: 奖池(单位:亿元,直接存储) + Double prizePool = getCellNumericValue(row.getCell(9)); + entity.setPrizePool(prizePool != null ? prizePool : 0.0); // 验证必要字段 if (entity.getFrontBall1() != null && entity.getFrontBall2() != null && @@ -741,11 +745,11 @@ public class DltDataImporter { entity.getFrontBall5() != null && entity.getBackBall1() != null && entity.getBackBall2() != null) { dataList.add(entity); - log.debug("添加大乐透开奖记录:期号{},日期{},前区{}-{}-{}-{}-{},后区{}-{}", + log.debug("添加大乐透开奖记录:期号{},日期{},前区{}-{}-{}-{}-{},后区{}-{},奖池{}亿", entity.getDrawId(), entity.getDrawDate(), entity.getFrontBall1(), entity.getFrontBall2(), entity.getFrontBall3(), entity.getFrontBall4(), entity.getFrontBall5(), - entity.getBackBall1(), entity.getBackBall2()); + entity.getBackBall1(), entity.getBackBall2(), entity.getPrizePool()); } else { log.warn("第{}行数据不完整,跳过", i + 1); } @@ -825,6 +829,10 @@ public class DltDataImporter { // I列: 后区2 entity.setBackBall2(getCellIntegerValue(row.getCell(8))); + + // J列: 奖池(单位:亿元,直接存储) + Double prizePool = getCellNumericValue(row.getCell(9)); + entity.setPrizePool(prizePool != null ? prizePool : 0.0); // 验证必要字段 if (entity.getFrontBall1() != null && entity.getFrontBall2() != null && @@ -833,11 +841,11 @@ public class DltDataImporter { entity.getBackBall2() != null) { dataList.add(entity); newCount++; - log.debug("添加新大乐透开奖记录:期号{},日期{},前区{}-{}-{}-{}-{},后区{}-{}", + log.debug("添加新大乐透开奖记录:期号{},日期{},前区{}-{}-{}-{}-{},后区{}-{},奖池{}亿", entity.getDrawId(), entity.getDrawDate(), entity.getFrontBall1(), entity.getFrontBall2(), entity.getFrontBall3(), entity.getFrontBall4(), entity.getFrontBall5(), - entity.getBackBall1(), entity.getBackBall2()); + entity.getBackBall1(), entity.getBackBall2(), entity.getPrizePool()); } else { log.warn("第{}行数据不完整,跳过", i + 1); } @@ -955,6 +963,58 @@ public class DltDataImporter { return numericValue != null ? numericValue.intValue() : null; } + /** + * 获取单元格的Long值(用于奖池等大数值) + */ + private Long getCellLongValue(Cell cell) { + if (cell == null) return null; + + try { + double value = 0.0; + switch (cell.getCellType()) { + case NUMERIC: + value = cell.getNumericCellValue(); + break; + case STRING: + String strValue = cell.getStringCellValue().trim(); + if (strValue.isEmpty()) { + return null; + } + try { + return Long.parseLong(strValue); + } catch (NumberFormatException e) { + try { + // 尝试解析为double再转long + value = Double.parseDouble(strValue); + } catch (NumberFormatException ex) { + log.warn("无法解析单元格Long值:{}", strValue); + return null; + } + } + break; + case FORMULA: + try { + value = cell.getNumericCellValue(); + } catch (Exception e) { + log.warn("无法获取公式单元格的Long值:{}", e.getMessage()); + return null; + } + break; + case BLANK: + return null; + default: + log.warn("不支持的单元格类型:{}", cell.getCellType()); + return null; + } + + return (long) value; + + } catch (Exception e) { + log.warn("读取单元格Long值失败:{}", e.getMessage()); + return null; + } + } + /** * 获取单元格的Date值 */ diff --git a/src/main/java/com/xy/xyaicpzs/util/DltPrizeCalculator.java b/src/main/java/com/xy/xyaicpzs/util/DltPrizeCalculator.java index 79ccf8e..fe2ad08 100644 --- a/src/main/java/com/xy/xyaicpzs/util/DltPrizeCalculator.java +++ b/src/main/java/com/xy/xyaicpzs/util/DltPrizeCalculator.java @@ -41,9 +41,10 @@ public class DltPrizeCalculator { * * @param predictNumbers 预测号码数组,格式:[前区5个号码, 后区2个号码] * @param drawNumbers 开奖号码数组,格式:[前区5个号码, 后区2个号码] + * @param prizePool 奖池金额(单位:亿元),用于判断奖金档位 * @return 中奖结果 */ - public static PrizeResult calculatePrize(Integer[] predictNumbers, Integer[] drawNumbers) { + public static PrizeResult calculatePrize(Integer[] predictNumbers, Integer[] drawNumbers, Double prizePool) { if (predictNumbers == null || drawNumbers == null) { throw new IllegalArgumentException("预测号码和开奖号码不能为空"); } @@ -64,14 +65,25 @@ public class DltPrizeCalculator { // 计算后区命中个数 int backMatches = calculateMatches(predictBack, drawBack); - log.debug("前区命中{}个,后区命中{}个", frontMatches, backMatches); + log.debug("前区命中{}个,后区命中{}个,奖池金额:{}亿元", frontMatches, backMatches, prizePool); // 根据中奖规则判断奖级 String prizeLevel = determinePrizeLevel(frontMatches, backMatches); - Long bonus = calculateBonus(prizeLevel); + Long bonus = calculateBonus(prizeLevel, prizePool); return new PrizeResult(prizeLevel, bonus, frontMatches, backMatches); } + + /** + * 计算大乐透中奖结果(兼容旧版本,默认奖池为0) + * + * @param predictNumbers 预测号码数组,格式:[前区5个号码, 后区2个号码] + * @param drawNumbers 开奖号码数组,格式:[前区5个号码, 后区2个号码] + * @return 中奖结果 + */ + public static PrizeResult calculatePrize(Integer[] predictNumbers, Integer[] drawNumbers) { + return calculatePrize(predictNumbers, drawNumbers, 0.0); + } /** * 计算两个号码数组的匹配个数 @@ -91,63 +103,93 @@ public class DltPrizeCalculator { /** * 根据前区和后区命中个数判断中奖等级 + * + * 中奖规则: + * 一等奖:5+2(前区5个+后区2个) + * 二等奖:5+1(前区5个+后区1个) + * 三等奖:5+0 或 4+2(前区5个 或 前区4个+后区2个) + * 四等奖:4+1(前区4个+后区1个) + * 五等奖:4+0 或 3+2(前区4个 或 前区3个+后区2个) + * 六等奖:3+1 或 2+2(前区3个+后区1个 或 前区2个+后区2个) + * 七等奖:3+0 或 1+2 或 2+1 或 0+2(前区3个 或 前区1个+后区2个 或 前区2个+后区1个 或 后区2个) */ private static String determinePrizeLevel(int frontMatches, int backMatches) { - // 根据大乐透中奖规则图片判断 if (frontMatches == 5 && backMatches == 2) { return "一等奖"; } else if (frontMatches == 5 && backMatches == 1) { return "二等奖"; - } else if (frontMatches == 5 && backMatches == 0) { + } else if ((frontMatches == 5 && backMatches == 0) || + (frontMatches == 4 && backMatches == 2)) { return "三等奖"; - } else if (frontMatches == 4 && backMatches == 2) { - return "四等奖"; } else if (frontMatches == 4 && backMatches == 1) { + return "四等奖"; + } else if ((frontMatches == 4 && backMatches == 0) || + (frontMatches == 3 && backMatches == 2)) { return "五等奖"; - } else if (frontMatches == 4 && backMatches == 0) { - return "六等奖"; - } else if (frontMatches == 3 && backMatches == 2) { - return "七等奖"; } else if ((frontMatches == 3 && backMatches == 1) || (frontMatches == 2 && backMatches == 2)) { - return "八等奖"; + return "六等奖"; } else if ((frontMatches == 3 && backMatches == 0) || - (frontMatches == 2 && backMatches == 1) || (frontMatches == 1 && backMatches == 2) || + (frontMatches == 2 && backMatches == 1) || (frontMatches == 0 && backMatches == 2)) { - return "九等奖"; + return "七等奖"; } else { return "未中奖"; } } /** - * 根据中奖等级计算奖金(基本投注2元) + * 根据中奖等级和奖池金额计算奖金 + * + * 奖金规则(基本投注2元): + * - 奖池≥8亿元时为高奖金档 + * - 奖池<8亿元时为低奖金档 + * + * 一等奖:浮动奖金,500万封顶(不区分奖池) + * 二等奖:浮动奖金,500万封顶(不区分奖池) + * 三等奖:奖池≥8亿 6666元,奖池<8亿 5000元 + * 四等奖:奖池≥8亿 380元,奖池<8亿 300元 + * 五等奖:奖池≥8亿 200元,奖池<8亿 150元 + * 六等奖:奖池≥8亿 18元,奖池<8亿 15元 + * 七等奖:奖池≥8亿 7元,奖池<8亿 5元 + * + * @param prizeLevel 中奖等级 + * @param prizePool 奖池金额(单位:亿元) + * @return 奖金(元) */ - private static Long calculateBonus(String prizeLevel) { + private static Long calculateBonus(String prizeLevel, Double prizePool) { + // 判断奖池档位:8亿元 + boolean isHighPrizePool = (prizePool != null && prizePool >= 8.0); + switch (prizeLevel) { case "一等奖": - return 10000000L; // 1000万元(浮动奖金,这里设置为最高奖金) + return 5000000L; // 500万元封顶(浮动奖金) case "二等奖": - return 5000000L; // 500万元(浮动奖金) + return 5000000L; // 500万元封顶(浮动奖金) case "三等奖": - return 10000L; // 1万元 + return isHighPrizePool ? 6666L : 5000L; case "四等奖": - return 3000L; // 3000元 + return isHighPrizePool ? 380L : 300L; case "五等奖": - return 300L; // 300元 + return isHighPrizePool ? 200L : 150L; case "六等奖": - return 200L; // 200元 + return isHighPrizePool ? 18L : 15L; case "七等奖": - return 100L; // 100元 - case "八等奖": - return 15L; // 15元 - case "九等奖": - return 5L; // 5元 + return isHighPrizePool ? 7L : 5L; default: - return 0L; // 未中奖 + return 0L; // 未中奖 } } + + /** + * 根据中奖等级计算奖金(兼容旧版本,默认奖池为0) + * @deprecated 请使用 calculateBonus(String prizeLevel, Double prizePool) + */ + @Deprecated + private static Long calculateBonus(String prizeLevel) { + return calculateBonus(prizeLevel, 0.0); + } /** * 验证号码格式是否正确 diff --git a/src/main/java/com/xy/xyaicpzs/util/ExcelDataImporter.java b/src/main/java/com/xy/xyaicpzs/util/ExcelDataImporter.java index badc74f..7e04c8d 100644 --- a/src/main/java/com/xy/xyaicpzs/util/ExcelDataImporter.java +++ b/src/main/java/com/xy/xyaicpzs/util/ExcelDataImporter.java @@ -32,6 +32,7 @@ import com.xy.xyaicpzs.mapper.T7Mapper; import com.xy.xyaicpzs.mapper.T8Mapper; import com.xy.xyaicpzs.mapper.T11Mapper; import com.xy.xyaicpzs.mapper.LotteryDrawsMapper; +import com.xy.xyaicpzs.util.SsqPrizeCalculator; import com.xy.xyaicpzs.service.T3Service; import com.xy.xyaicpzs.service.T4Service; import com.xy.xyaicpzs.service.T5Service; @@ -48,6 +49,7 @@ import com.xy.xyaicpzs.service.BlueHistoryAllService; import com.xy.xyaicpzs.service.BlueHistory100Service; import com.xy.xyaicpzs.service.BlueHistoryTopService; import com.xy.xyaicpzs.service.BlueHistoryTop100Service; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; @@ -1229,6 +1231,22 @@ public class ExcelDataImporter { // I列: 蓝球 entity.setBlueBall(getCellIntegerValue(row.getCell(8))); + + // J列: 奖池(单位:亿元,直接存储) + Double prizePool = getCellNumericValue(row.getCell(9)); + entity.setPrizePool(prizePool != null ? prizePool : 0.0); + + // 判断特别规定状态(需要查询上一期的状态) + // 由于是覆盖导入,按顺序处理,可以从已添加的列表中获取上一期状态 + boolean wasInSpecialPeriod = false; + if (!dataList.isEmpty()) { + // 获取上一条记录的特别规定状态 + LotteryDraws previousRecord = dataList.get(dataList.size() - 1); + wasInSpecialPeriod = previousRecord.getIsSpecialPeriod() != null && previousRecord.getIsSpecialPeriod() == 1; + } + boolean isSpecialPeriod = SsqPrizeCalculator.shouldExecuteSpecialRule( + entity.getPrizePool(), wasInSpecialPeriod); + entity.setIsSpecialPeriod(isSpecialPeriod ? 1 : 0); // 验证必要字段 if (entity.getRedBall1() != null && entity.getRedBall2() != null && @@ -1236,11 +1254,11 @@ public class ExcelDataImporter { entity.getRedBall5() != null && entity.getRedBall6() != null && entity.getBlueBall() != null) { dataList.add(entity); - log.debug("添加开奖记录:期号{},日期{},红球{}-{}-{}-{}-{}-{},蓝球{}", + log.debug("添加开奖记录:期号{},日期{},红球{}-{}-{}-{}-{}-{},蓝球{},奖池{}亿", entity.getDrawId(), entity.getDrawDate(), entity.getRedBall1(), entity.getRedBall2(), entity.getRedBall3(), entity.getRedBall4(), entity.getRedBall5(), entity.getRedBall6(), - entity.getBlueBall()); + entity.getBlueBall(), entity.getPrizePool()); } else { log.warn("第{}行数据不完整,跳过", i + 1); } @@ -1416,6 +1434,30 @@ public class ExcelDataImporter { // I列: 蓝球 entity.setBlueBall(getCellIntegerValue(row.getCell(8))); + + // J列: 奖池(单位:亿元,直接存储) + Double prizePool = getCellNumericValue(row.getCell(9)); + entity.setPrizePool(prizePool != null ? prizePool : 0.0); + + // 判断特别规定状态(需要查询上一期的状态) + // 追加导入时,需要查询数据库中最新一期的状态 + boolean wasInSpecialPeriod = false; + if (dataList.isEmpty()) { + // 第一条记录,查询数据库中最新一期的状态 + QueryWrapper lastRecordQuery = new QueryWrapper<>(); + lastRecordQuery.orderByDesc("drawId").last("LIMIT 1"); + LotteryDraws lastRecord = lotteryDrawsMapper.selectOne(lastRecordQuery); + if (lastRecord != null) { + wasInSpecialPeriod = lastRecord.getIsSpecialPeriod() != null && lastRecord.getIsSpecialPeriod() == 1; + } + } else { + // 从已添加的列表中获取上一期状态 + LotteryDraws previousRecord = dataList.get(dataList.size() - 1); + wasInSpecialPeriod = previousRecord.getIsSpecialPeriod() != null && previousRecord.getIsSpecialPeriod() == 1; + } + boolean isSpecialPeriod = SsqPrizeCalculator.shouldExecuteSpecialRule( + entity.getPrizePool(), wasInSpecialPeriod); + entity.setIsSpecialPeriod(isSpecialPeriod ? 1 : 0); // 验证必要字段 if (entity.getRedBall1() != null && entity.getRedBall2() != null && @@ -1424,11 +1466,11 @@ public class ExcelDataImporter { entity.getBlueBall() != null) { dataList.add(entity); newCount++; - log.debug("添加新开奖记录:期号{},日期{},红球{}-{}-{}-{}-{}-{},蓝球{}", + log.debug("添加新开奖记录:期号{},日期{},红球{}-{}-{}-{}-{}-{},蓝球{},奖池{}亿", entity.getDrawId(), entity.getDrawDate(), entity.getRedBall1(), entity.getRedBall2(), entity.getRedBall3(), entity.getRedBall4(), entity.getRedBall5(), entity.getRedBall6(), - entity.getBlueBall()); + entity.getBlueBall(), entity.getPrizePool()); } else { log.warn("第{}行数据不完整,跳过", i + 1); } diff --git a/src/main/java/com/xy/xyaicpzs/util/SsqPrizeCalculator.java b/src/main/java/com/xy/xyaicpzs/util/SsqPrizeCalculator.java new file mode 100644 index 0000000..0270094 --- /dev/null +++ b/src/main/java/com/xy/xyaicpzs/util/SsqPrizeCalculator.java @@ -0,0 +1,117 @@ +package com.xy.xyaicpzs.util; + +import lombok.Data; + +/** + * 双色球奖金计算器 + * 根据新规则计算中奖等级和奖金 + */ +public class SsqPrizeCalculator { + + // 特别规定启动阈值:15亿(数据库存储单位为亿) + private static final double SPECIAL_RULE_START_THRESHOLD = 15.0; + + // 特别规定停止阈值:3亿(数据库存储单位为亿) + private static final double SPECIAL_RULE_STOP_THRESHOLD = 3.0; + + /** + * 中奖结果 + */ + @Data + public static class PrizeResult { + private String prizeLevel; // 中奖等级 + private Long bonus; // 奖金(元) + + public PrizeResult(String prizeLevel, Long bonus) { + this.prizeLevel = prizeLevel; + this.bonus = bonus; + } + } + + /** + * 计算中奖结果 + * + * @param predictNumbers 预测号码 [红球1-6, 蓝球] + * @param drawNumbers 开奖号码 [红球1-6, 蓝球] + * @param prizePool 奖池金额(单位:亿元) + * @param isSpecialPeriod 是否处于特别规定期间 + * @return 中奖结果 + */ + public static PrizeResult calculatePrize(Integer[] predictNumbers, Integer[] drawNumbers, + Double prizePool, boolean isSpecialPeriod) { + // 比较红球匹配数 + int redMatches = 0; + for (int i = 0; i < 6; i++) { + for (int j = 0; j < 6; j++) { + if (predictNumbers[i] != null && predictNumbers[i].equals(drawNumbers[j])) { + redMatches++; + break; + } + } + } + + // 比较蓝球是否匹配 + boolean blueMatch = predictNumbers[6] != null && predictNumbers[6].equals(drawNumbers[6]); + + // 根据匹配情况判断奖项和奖金 + String prizeLevel; + Long bonus; + + if (redMatches == 6 && blueMatch) { + // 一等奖:6红+1蓝 + prizeLevel = "一等奖"; + bonus = 5_000_000L; // 500万封顶 + } else if (redMatches == 6) { + // 二等奖:6红 + prizeLevel = "二等奖"; + bonus = 5_000_000L; // 500万封顶 + } else if (redMatches == 5 && blueMatch) { + // 三等奖:5红+1蓝 + prizeLevel = "三等奖"; + bonus = 3_000L; + } else if (redMatches == 5 || (redMatches == 4 && blueMatch)) { + // 四等奖:5红 或 4红+1蓝 + prizeLevel = "四等奖"; + bonus = 200L; + } else if (redMatches == 4 || (redMatches == 3 && blueMatch)) { + // 五等奖:4红 或 3红+1蓝 + prizeLevel = "五等奖"; + bonus = 10L; + } else if (blueMatch) { + // 六等奖:1蓝 + prizeLevel = "六等奖"; + bonus = 5L; + } else if (redMatches == 3 && isSpecialPeriod) { + // 福运奖:3红(仅在特别规定期间) + prizeLevel = "福运奖"; + bonus = 5L; + } else { + // 未中奖 + prizeLevel = "未中奖"; + bonus = 0L; + } + + return new PrizeResult(prizeLevel, bonus); + } + + /** + * 判断是否应该执行特别规定 + * + * @param currentPrizePool 当前期奖池金额(单位:亿元) + * @param previousIsSpecialPeriod 上一期是否处于特别规定期间 + * @return 当前期是否应该执行特别规定 + */ + public static boolean shouldExecuteSpecialRule(Double currentPrizePool, boolean previousIsSpecialPeriod) { + if (currentPrizePool == null) { + return false; + } + + // 如果上一期不在特别规定期间,检查是否达到启动阈值 + if (!previousIsSpecialPeriod) { + return currentPrizePool >= SPECIAL_RULE_START_THRESHOLD; + } + + // 如果上一期在特别规定期间,检查是否低于停止阈值 + return currentPrizePool >= SPECIAL_RULE_STOP_THRESHOLD; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c93a405..a9e44b2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,13 +1,19 @@ spring: application: name: xy-ai-cpzs + # 文件上传配置 + servlet: + multipart: + enabled: true + max-file-size: 50MB + max-request-size: 100MB datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cpzs - username: cpzs_root - password: cpzs_123456 -# username: root -# password: root +# username: cpzs +# password: cpzs_123456 + username: root + password: root # datasource: # driver-class-name: com.mysql.cj.jdbc.Driver # url: jdbc:mysql://47.117.22.239:3306/cpzs?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&autoReconnect=true&failOverReadOnly=false&connectTimeout=10000&socketTimeout=30000 @@ -28,7 +34,7 @@ spring: # database: 0 data: redis: - host: 47.117.22.239 + host: 124.222.15.61 port: 6379 database: 0 password: cpzs_123456 @@ -78,8 +84,21 @@ aliyun: access-key-id: LTAI5tR18rXPYazi3y8kAuep access-key-secret: KZ1aKZOupilVc332SXE1g1DfKsqHPu gateway-url: wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1 + +# 腾讯云短信服务配置 +tencent: sms: - sign-name: 西安精彩数据服务社 - template-code: SMS_489840017 - access-key-id: LTAI5tR18rXPYazi3y8kAuep - access-key-secret: KZ1aKZOupilVc332SXE1g1DfKsqHPu \ No newline at end of file + secret-id: AKIDTuCIBrJXZVEl7FBtOfjaGj283lNSRDnA + secret-key: EG7mFXdWJwMcfU8ZptmSvCHBtPm02sX0 + sdk-app-id: 1401068120 + template-id: 2598660 + sign-name: 西安溢彩数智科技有限公司 + region: ap-beijing + +# 腾讯云COS配置 +cos: + secret-id: AKIDTuCIBrJXZVEl7FBtOfjaGj283lNSRDnA + secret-key: EG7mFXdWJwMcfU8ZptmSvCHBtPm02sX0 + bucket-name: yicaishuzhi-1326058838 + region: ap-beijing + domain: https://yicaishuzhi-1326058838.cos.ap-beijing.myqcloud.com \ No newline at end of file diff --git a/src/main/resources/generator/mapper/AnnouncementMapper.xml b/src/main/resources/generator/mapper/AnnouncementMapper.xml new file mode 100644 index 0000000..4948b2f --- /dev/null +++ b/src/main/resources/generator/mapper/AnnouncementMapper.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + id,title,content,announcementTime,publisherId,publisherName, + status,priority,viewCount,createTime,updateTime, + isDelete + + diff --git a/src/main/resources/generator/mapper/DltDrawRecordMapper.xml b/src/main/resources/generator/mapper/DltDrawRecordMapper.xml index 93cdfe0..620eb57 100644 --- a/src/main/resources/generator/mapper/DltDrawRecordMapper.xml +++ b/src/main/resources/generator/mapper/DltDrawRecordMapper.xml @@ -15,13 +15,14 @@ + id,drawId,drawDate,frontBall1,frontBall2,frontBall3, - frontBall4,frontBall5,backBall1,backBall2,createTime, + frontBall4,frontBall5,backBall1,backBall2,prizePool,createTime, updateTime diff --git a/src/main/resources/generator/mapper/LotteryDrawsMapper.xml b/src/main/resources/generator/mapper/LotteryDrawsMapper.xml index 7ff8e2a..466bd75 100644 --- a/src/main/resources/generator/mapper/LotteryDrawsMapper.xml +++ b/src/main/resources/generator/mapper/LotteryDrawsMapper.xml @@ -14,10 +14,12 @@ + + drawId,drawDate,redBall1,redBall2,redBall3,redBall4, - redBall5,redBall6,blueBall + redBall5,redBall6,blueBall,prizePool,is_special_period diff --git a/src/test/java/com/xy/xyaicpzs/ApiResponseTest.java b/src/test/java/com/xy/xyaicpzs/ApiResponseTest.java deleted file mode 100644 index c4bc4bf..0000000 --- a/src/test/java/com/xy/xyaicpzs/ApiResponseTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.xy.xyaicpzs; - -import com.xy.xyaicpzs.common.response.ApiResponse; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -/** - * ApiResponse测试类 - */ -public class ApiResponseTest { - - @Test - public void testSuccessResponse() { - String data = "测试数据"; - ApiResponse response = ApiResponse.success(data); - - assertEquals(0, response.getCode()); - assertTrue(response.isSuccess()); - assertEquals("操作成功", response.getMessage()); - assertEquals(data, response.getData()); - } - - @Test - public void testErrorResponse() { - String errorMessage = "错误信息"; - ApiResponse response = ApiResponse.error(errorMessage); - - assertEquals(1, response.getCode()); - assertFalse(response.isSuccess()); - assertEquals(errorMessage, response.getMessage()); - assertNull(response.getData()); - } - - @Test - public void testErrorResponseWithCode() { - Integer errorCode = 500; - String errorMessage = "系统错误"; - ApiResponse response = ApiResponse.error(errorCode, errorMessage); - - assertEquals(errorCode, response.getCode()); - assertFalse(response.isSuccess()); - assertEquals(errorMessage, response.getMessage()); - assertNull(response.getData()); - } -} \ No newline at end of file diff --git a/src/test/java/com/xy/xyaicpzs/DltPrizeCalculatorTest.java b/src/test/java/com/xy/xyaicpzs/DltPrizeCalculatorTest.java deleted file mode 100644 index 3c9b194..0000000 --- a/src/test/java/com/xy/xyaicpzs/DltPrizeCalculatorTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.xy.xyaicpzs; - -import com.xy.xyaicpzs.util.DltPrizeCalculator; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -/** - * 大乐透中奖规则计算器测试类 - */ -public class DltPrizeCalculatorTest { - - @Test - public void testFirstPrize() { - // 一等奖:前区5+后区2 - Integer[] predict = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw = {1, 2, 3, 4, 5, 6, 7}; - - DltPrizeCalculator.PrizeResult result = DltPrizeCalculator.calculatePrize(predict, draw); - - assertEquals("一等奖", result.getPrizeLevel()); - assertEquals(5, result.getFrontMatches()); - assertEquals(2, result.getBackMatches()); - assertEquals(10000000L, result.getBonus()); - } - - @Test - public void testSecondPrize() { - // 二等奖:前区5+后区1 - Integer[] predict = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw = {1, 2, 3, 4, 5, 6, 8}; - - DltPrizeCalculator.PrizeResult result = DltPrizeCalculator.calculatePrize(predict, draw); - - assertEquals("二等奖", result.getPrizeLevel()); - assertEquals(5, result.getFrontMatches()); - assertEquals(1, result.getBackMatches()); - assertEquals(5000000L, result.getBonus()); - } - - @Test - public void testThirdPrize() { - // 三等奖:前区5+后区0 - Integer[] predict = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw = {1, 2, 3, 4, 5, 8, 9}; - - DltPrizeCalculator.PrizeResult result = DltPrizeCalculator.calculatePrize(predict, draw); - - assertEquals("三等奖", result.getPrizeLevel()); - assertEquals(5, result.getFrontMatches()); - assertEquals(0, result.getBackMatches()); - assertEquals(10000L, result.getBonus()); - } - - @Test - public void testFourthPrize() { - // 四等奖:前区4+后区2 - Integer[] predict = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw = {1, 2, 3, 4, 8, 6, 7}; - - DltPrizeCalculator.PrizeResult result = DltPrizeCalculator.calculatePrize(predict, draw); - - assertEquals("四等奖", result.getPrizeLevel()); - assertEquals(4, result.getFrontMatches()); - assertEquals(2, result.getBackMatches()); - assertEquals(3000L, result.getBonus()); - } - - @Test - public void testEighthPrize() { - // 八等奖:前区3+后区1 或 前区2+后区2 - Integer[] predict1 = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw1 = {1, 2, 3, 8, 9, 6, 10}; - - DltPrizeCalculator.PrizeResult result1 = DltPrizeCalculator.calculatePrize(predict1, draw1); - assertEquals("八等奖", result1.getPrizeLevel()); - assertEquals(15L, result1.getBonus()); - - // 前区2+后区2 - Integer[] predict2 = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw2 = {1, 2, 8, 9, 10, 6, 7}; - - DltPrizeCalculator.PrizeResult result2 = DltPrizeCalculator.calculatePrize(predict2, draw2); - assertEquals("八等奖", result2.getPrizeLevel()); - assertEquals(15L, result2.getBonus()); - } - - @Test - public void testNinthPrize() { - // 九等奖:前区3+后区0 - Integer[] predict = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw = {1, 2, 3, 8, 9, 10, 11}; - - DltPrizeCalculator.PrizeResult result = DltPrizeCalculator.calculatePrize(predict, draw); - - assertEquals("九等奖", result.getPrizeLevel()); - assertEquals(3, result.getFrontMatches()); - assertEquals(0, result.getBackMatches()); - assertEquals(5L, result.getBonus()); - } - - @Test - public void testNoPrize() { - // 未中奖 - Integer[] predict = {1, 2, 3, 4, 5, 6, 7}; - Integer[] draw = {8, 9, 10, 11, 12, 8, 9}; - - DltPrizeCalculator.PrizeResult result = DltPrizeCalculator.calculatePrize(predict, draw); - - assertEquals("未中奖", result.getPrizeLevel()); - assertEquals(0, result.getFrontMatches()); - assertEquals(0, result.getBackMatches()); - assertEquals(0L, result.getBonus()); - } - - @Test - public void testValidateNumbers() { - // 有效号码 - Integer[] validNumbers = {1, 2, 3, 4, 5, 6, 7}; - assertTrue(DltPrizeCalculator.validateNumbers(validNumbers)); - - // 无效号码 - 长度不对 - Integer[] invalidLength = {1, 2, 3, 4, 5, 6}; - assertFalse(DltPrizeCalculator.validateNumbers(invalidLength)); - - // 无效号码 - 前区号码超出范围 - Integer[] invalidFront = {1, 2, 3, 4, 36, 6, 7}; - assertFalse(DltPrizeCalculator.validateNumbers(invalidFront)); - - // 无效号码 - 后区号码超出范围 - Integer[] invalidBack = {1, 2, 3, 4, 5, 6, 13}; - assertFalse(DltPrizeCalculator.validateNumbers(invalidBack)); - - // 无效号码 - 前区重复 - Integer[] duplicateFront = {1, 2, 3, 4, 1, 6, 7}; - assertFalse(DltPrizeCalculator.validateNumbers(duplicateFront)); - - // 无效号码 - 后区重复 - Integer[] duplicateBack = {1, 2, 3, 4, 5, 6, 6}; - assertFalse(DltPrizeCalculator.validateNumbers(duplicateBack)); - } - - @Test - public void testFormatNumbers() { - Integer[] numbers = {1, 2, 3, 4, 5, 6, 7}; - String formatted = DltPrizeCalculator.formatNumbers(numbers); - assertEquals("前区: 01 02 03 04 05 | 后区: 06 07", formatted); - } -} - - - diff --git a/src/test/java/com/xy/xyaicpzs/XyAiCpzsApplicationTests.java b/src/test/java/com/xy/xyaicpzs/XyAiCpzsApplicationTests.java deleted file mode 100644 index 5fc41f3..0000000 --- a/src/test/java/com/xy/xyaicpzs/XyAiCpzsApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.xy.xyaicpzs; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class XyAiCpzsApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/test.txt b/test.txt deleted file mode 100644 index 6ffd492..0000000 --- a/test.txt +++ /dev/null @@ -1,229 +0,0 @@ -{ - "code": 0, - "success": true, - "message": "操作成功", - "data": { - "results": [ - { - "ballNumber": 14, - "frequency": 8, - "coefficientSum": 186.64000000000001, - "top100Ranking": 6, - "historyRanking": 1 - }, - { - "ballNumber": 7, - "frequency": 7, - "coefficientSum": 174.87, - "top100Ranking": 8, - "historyRanking": 10 - }, - { - "ballNumber": 26, - "frequency": 7, - "coefficientSum": 150.24, - "top100Ranking": 31, - "historyRanking": 2 - }, - { - "ballNumber": 2, - "frequency": 6, - "coefficientSum": 146.03, - "top100Ranking": 5, - "historyRanking": 12 - }, - { - "ballNumber": 18, - "frequency": 6, - "coefficientSum": 172.67000000000002, - "top100Ranking": 8, - "historyRanking": 8 - }, - { - "ballNumber": 20, - "frequency": 6, - "coefficientSum": 144.49, - "top100Ranking": 23, - "historyRanking": 7 - }, - { - "ballNumber": 22, - "frequency": 6, - "coefficientSum": 125.7, - "top100Ranking": 8, - "historyRanking": 3 - }, - { - "ballNumber": 4, - "frequency": 5, - "coefficientSum": 114.42999999999999, - "top100Ranking": 19, - "historyRanking": 22 - }, - { - "ballNumber": 6, - "frequency": 5, - "coefficientSum": 142.57, - "top100Ranking": 1, - "historyRanking": 6 - }, - { - "ballNumber": 8, - "frequency": 5, - "coefficientSum": 109.75999999999999, - "top100Ranking": 8, - "historyRanking": 13 - }, - { - "ballNumber": 17, - "frequency": 5, - "coefficientSum": 144.9, - "top100Ranking": 6, - "historyRanking": 4 - }, - { - "ballNumber": 27, - "frequency": 5, - "coefficientSum": 122.24000000000001, - "top100Ranking": 8, - "historyRanking": 11 - }, - { - "ballNumber": 30, - "frequency": 5, - "coefficientSum": 123.61, - "top100Ranking": 8, - "historyRanking": 19 - }, - { - "ballNumber": 1, - "frequency": 4, - "coefficientSum": 87.6, - "top100Ranking": 33, - "historyRanking": 4 - }, - { - "ballNumber": 10, - "frequency": 4, - "coefficientSum": 87.45, - "top100Ranking": 2, - "historyRanking": 14 - }, - { - "ballNumber": 12, - "frequency": 4, - "coefficientSum": 107.33, - "top100Ranking": 23, - "historyRanking": 18 - }, - { - "ballNumber": 15, - "frequency": 4, - "coefficientSum": 85.14, - "top100Ranking": 8, - "historyRanking": 25 - }, - { - "ballNumber": 5, - "frequency": 3, - "coefficientSum": 57.349999999999994, - "top100Ranking": 30, - "historyRanking": 21 - }, - { - "ballNumber": 9, - "frequency": 3, - "coefficientSum": 83.07000000000001, - "top100Ranking": 19, - "historyRanking": 16 - }, - { - "ballNumber": 13, - "frequency": 3, - "coefficientSum": 81.9, - "top100Ranking": 8, - "historyRanking": 19 - }, - { - "ballNumber": 19, - "frequency": 3, - "coefficientSum": 80.5, - "top100Ranking": 19, - "historyRanking": 15 - }, - { - "ballNumber": 24, - "frequency": 3, - "coefficientSum": 58.43, - "top100Ranking": 26, - "historyRanking": 31 - }, - { - "ballNumber": 25, - "frequency": 3, - "coefficientSum": 58.9, - "top100Ranking": 8, - "historyRanking": 23 - }, - { - "ballNumber": 32, - "frequency": 3, - "coefficientSum": 83.3, - "top100Ranking": 28, - "historyRanking": 8 - }, - { - "ballNumber": 3, - "frequency": 2, - "coefficientSum": 52.97, - "top100Ranking": 8, - "historyRanking": 17 - }, - { - "ballNumber": 11, - "frequency": 2, - "coefficientSum": 55.769999999999996, - "top100Ranking": 19, - "historyRanking": 23 - }, - { - "ballNumber": 16, - "frequency": 2, - "coefficientSum": 54.83, - "top100Ranking": 3, - "historyRanking": 25 - }, - { - "ballNumber": 23, - "frequency": 1, - "coefficientSum": 28.23, - "top100Ranking": 23, - "historyRanking": 27 - }, - { - "ballNumber": 31, - "frequency": 1, - "coefficientSum": 4.53, - "top100Ranking": 31, - "historyRanking": 29 - }, - { - "ballNumber": 33, - "frequency": 1, - "coefficientSum": 27.3, - "top100Ranking": 3, - "historyRanking": 33 - } - ], - "strategy": "H", - "redBalls": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "blueBall": 7 - } -} \ No newline at end of file diff --git a/userController.txt b/userController.txt deleted file mode 100644 index 8348b60..0000000 --- a/userController.txt +++ /dev/null @@ -1,164 +0,0 @@ -2 签署 JWT -扣子的 JWT 生成方式及部分参数定义沿用业界统一流程规范,但 JWT 中 Header 和 Payload 部分由扣子平台自行定义。 -在 JWT(JSON Web Tokens)的流程中,通常使用私钥来签署(sign)token。JWT 包含三部分,即 Header、Payload 和 signature,其中 header 和 payload 由参数拼接而成,signature 根据指定的签名算法和私钥对 Header 和 Payload 自动计算生成。三部分之间用点(.)分隔。详细的签署方式可参考JWT 官方文档。 -Header 和 Payload -扣子平台对 Header 和 Payload 的定义如下: -Header -Header 部分的参数定义如下: -参数 -类型 -是否必选 -说明 -alg -String -必选 -签名使用的加密算法。固定为 RS256,即非对称加密算法,一种基于 RSA(非对称加密算法)+ SHA256(安全哈希函数)的签名算法,该算法使用私钥进行签名,公钥进行验证。 -typ -String -必选 -固定为 JWT。 -kid -String -必选 -OAuth 应用的公钥指纹,可以在OAuth 应用页面找到这个应用,在操作列单击编辑图标,进入配置页面查看公钥指纹。 - - -Header 示例如下: -{ - "alg": "RS256", // 固定为RS256 - "typ": "JWT", // 固定为 JWT - "kid": "gdehvaDegW....." // OAuth 应用的公钥指纹 -} - -Payload: -Payload 部分的参数定义如下: -参数 -类型 -是否必选 -说明 -iss -String -必选 -OAuth 应用的 ID,可以在OAuth 应用页面查看。 -aud -String -必选 -扣子 API 的 Endpoint,即 api.coze.cn。 -iat -Integer -必选 -JWT 开始生效的时间,Unixtime 时间戳格式,精确到秒。一般为当前时刻。 -exp -Integer -必选 -JWT 过期的时间,Unixtime 时间戳格式,精确到秒。必须晚于 iat。 -jti -String -必选 -随机字符串,用于防止重放攻击。建议长度大于 32 字节。每次签署 JWT 时应指定为不同的字符串。 -session_name -String -可选 -访问令牌的会话标识。目前仅限在会话隔离场景下使用,即将 session_name 指定为用户在业务侧的 UID,以此区分不同业务侧用户的对话历史。 -若未指定 session_name,不同用户的对话历史可能会掺杂在一起。 -会话隔离的详细实现方法请参见如何实现会话隔离。 - -session_context -Object -可选 -会话上下文信息,包含设备相关信息等。 - -session_context.device_info -Object -可选 -用于配置设备相关信息,扣子平台基于该部分信息对设备做用量管控以及账单记录。 -该参数为企业白版白名单功能,需要联系扣子商务经理开通后才能使用。硬件设备用量管控的具体操作可参考硬件设备用量查询和配额管控。 -session_context.device_info.device_id -String -可选 -IoT 等硬件设备 ID,一个设备对应一个唯一的设备号。 -当需要记录设备用量或对设备用量进行管控时,需要填写该参数,否则,无法对设备进行用量管控,用量统计页面对应的设备 ID 将显示为 N/A。 -session_context.device_info.custom_consumer - -String -可选 -自定义维度的实体 ID,你可以根据业务需要进行设置,例如 APP 上的用户名等。 -当需要记录设备用量或对设备用量进行管控,需要填写该参数,否则,无法对设备进行用量管控,用量统计页面对应的自定义 ID 将显示为 N/A。 -device_id 和 custom_consumer 建议选择其中一个即可。 -custom_consumer 参数用于设备用量管控,与对话等 API 传入的 user_id 无关,user_id 主要用于上下文、数据库隔离等场景。 -出于数据隐私及信息安全等方面的考虑,不建议使用业务系统中定义的用户敏感标识(如手机号等)作为 custom_consumer 的值。 - - -Payload 示例如下: -{ - "iss": "310000000002", // OAuth 应用的 ID - "aud": "api.coze.cn", // 扣子 API 的 Endpoint - "iat": 1516239022, // JWT 开始生效的时间,秒级时间戳 - "exp": 1516259022, // JWT 过期时间,秒级时间戳 - "jti": "fhjashjgkhalskj", // 随机字符串,防止重放攻击 - "session_name": "user_2222", //用户在业务侧的 UID - "session_context": { - "device_info": { - "device_id": "1234567890" // IoT 等硬件设备的唯一标识 ID - } - } -} - -示例代码 -你可以直接参考以下示例代码签署 JWT。 -# You must run `pip install PyJWT cryptography` to install the PyJWT and the cryptography packages in order to use this script. - -#!/usr/bin/env python3 -import sys -import time -import uuid - -import jwt - -# 替换为你的实际 Coze App 私钥 -signing_key = ''' ------BEGIN PRIVATE KEY----- -xxxxxxxxxxxxxxxxxx ------END PRIVATE KEY----- -''' - -payload = { - 'iat': int(time.time()), - 'exp': int(time.time()) + 600, - "jti": str(uuid.uuid4()), - 'aud': 'api.coze.cn', - 'iss': '1127900106117' # 替换为你的实际 Coze App ID -} - -headers = { - 'kid': '_v0VjcMlLdQc3tRTD3jC5Xz29TUnKQOhtuD5k-gpyf4' # 替换为你的实际 Coze App 公钥指纹 -} - -# Create JWT with headers -encoded_jwt = jwt.encode(payload, signing_key, algorithm='RS256', headers=headers) - -print(f"JWT: {encoded_jwt}") - -最终生成的 JWT 示例如下: -eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InZZd2ZsdFR1OWZBbWtwWFhSdnR5UmREc3RONVMzZWNFcDFqVzB6dVQyRE****.eyJpc3MiOiIzMTAwMDAwMDAwMDIiLCJhdWQiOiJhcGkuY296ZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjI1OTAyMiwianRpIjoiZmhqaGFsc2tqZmFkc2pld3F****.CuoiCCF-nHFyGmu2EKlwFoyd3uDyKQ3Drc1CrXQyMVySTzZlZd2M7zKWsziB3AktwbUZiRJlQ1HbghR05CW2YRHwKL4-dlJ4koR3onU7iQAO5DkPCaIxbAuTsQobtCAdkkZTg8gav9EnN1QN_1xq0w8BzuuhS7wCeY8UbaskkTK9GnO4eU9tEINmVw-2CrfB-kNbEHlEDwXfcrb4YPpkw3GhmuPShenNLObfSWS0CqIyakXL8qD5AgXLoB-SejAsRdzloSUInNXENJHfSVMkThxRhJy7yEjX3BmculC54fMKENRfLElBqwJyLLUjeRHsYnaru2ca4W8_yaPJ7F**** - - - - - -3 获取访问令牌 -应用程序调用 通过 JWT 获取 Oauth Access Token API ,请求 Header 中携带 JWT,扣子服务端会在响应中通过 access_token 字段返回访问令牌。 -请求示例如下: -curl --location --request POST 'https://api.coze.cn/api/permission/oauth2/token' \ ---header 'Content-Type: application/json' \ ---header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InZZd2ZsdFR1OWZBbWtwWFhSdnR5UmREc3RONVMzZWNFcDFqVzB6dVQyRE****.eyJpc3MiOiIzMTAwMDAwMDAwMDIiLCJhdWQiOiJhcGkuY296ZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjI1OTAyMiwianRpIjoiZmhqaGFsc2tqZmFkc2pld3F****.CuoiCCF-nHFyGmu2EKlwFoyd3uDyKQ3Drc1CrXQyMVySTzZlZd2M7zKWsziB3AktwbUZiRJlQ1HbghR05CW2YRHwKL4-dlJ4koR3onU7iQAO5DkPCaIxbAuTsQobtCAdkkZTg8gav9EnN1QN_1xq0w8BzuuhS7wCeY8UbaskkTK9GnO4eU9tEINmVw-2CrfB-kNbEHlEDwXfcrb4YPpkw3GhmuPShenNLObfSWS0CqIyakXL8qD5AgXLoB-SejAsRdzloSUInNXENJHfSVMkThxRhJy7yEjX3BmculC54fMKENRfLElBqwJyLLUjeRHsYnaru2ca4W8_yaPJ7F****' \ ---data '{ - "duration_seconds": 86399, - "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer" -}' - -返回示例如下: -{ - "access_token": "czs_RQOhsc7vmUzK4bNgb7hn4wqOgRBYAO6xvpFHNbnl6RiQJX3cSXSguIhFDzgy****", - "expires_in": 1721135859 -} \ No newline at end of file diff --git a/会员码激活API使用说明.md b/会员码激活API使用说明.md deleted file mode 100644 index c3f0a42..0000000 --- a/会员码激活API使用说明.md +++ /dev/null @@ -1,355 +0,0 @@ -# 会员码管理API使用说明 - -## 功能概述 - -会员码管理系统包含两个主要功能: -1. **会员码生成**:管理员可以批量生成会员码,用于分发给用户 -2. **会员码激活**:用户通过输入有效的会员码来延长或激活会员服务 - -系统会校验会员码的有效性,并根据会员码设定的月数来更新用户的会员到期时间。 - -## 接口信息 - -### 1. 批量生成会员码 - -**接口地址:** `POST /vip-code/generate` - -**请求参数:** - -```json -{ - "numCodes": 100, - "vipExpireTime": 12 -} -``` - -**参数说明:** - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| numCodes | Integer | 是 | 生成数量(1-1000) | -| vipExpireTime | Integer | 是 | 会员有效月数 | - -**响应示例:** - -成功响应: -```json -{ - "code": 0, - "data": 100, - "message": "ok" -} -``` - -失败响应: -```json -{ - "code": 40000, - "data": null, - "message": "生成数量必须大于0" -} -``` - -### 2. 获取可用会员码 - -**接口地址:** `GET /vip-code/available?vipExpireTime=1` - -**请求参数:** - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| vipExpireTime | Integer | 是 | 会员有效月数(1或12) | - -**响应示例:** - -成功响应: -```json -{ - "code": 0, - "data": "ABC123DEF456GHI7", - "message": "ok" -} -``` - -失败响应: -```json -{ - "code": 40400, - "data": null, - "message": "没有找到可用的会员码" -} -``` - -### 3. 激活会员码 - -**接口地址:** `POST /user/activate-vip` - -**请求参数:** - -```json -{ - "userId": 1, - "code": "VIP_CODE_123456" -} -``` - -**参数说明:** - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| userId | Long | 是 | 用户ID | -| code | String | 是 | 会员码 | - -**响应示例:** - -成功响应: -```json -{ - "code": 0, - "data": true, - "message": "ok" -} -``` - -失败响应: -```json -{ - "code": 40000, - "data": null, - "message": "会员码不存在或无效" -} -``` - -## 业务逻辑 - -### 会员码生成流程 - -1. **参数校验** - - 验证生成数量大于0且不超过1000 - - 验证会员有效月数大于0 - -2. **生成唯一会员码** - - 使用随机算法生成16位会员码 - - 检查数据库确保会员码唯一性 - - 重复生成直到达到指定数量 - -3. **会员编号分配** - - 查询当前最大会员编号 - - 从最大编号+1开始连续分配 - -4. **批量插入数据库** - - 使用事务确保数据一致性 - - 批量插入提高性能 - -### 获取可用会员码流程 - -1. **参数校验** - - 验证会员有效月数只能是1或12 - -2. **数据库查询** - - 查询指定月数的未使用会员码 - - 按创建时间升序排列,获取最早的一个 - - 确保返回的会员码处于可用状态 - -3. **结果返回** - - 如果找到可用会员码,返回会员码字符串 - - 如果没有找到,返回错误信息 - -### 会员码激活流程 - -1. **参数校验** - - 验证userId不为空 - - 验证会员码不为空或空字符串 - -2. **用户存在性校验** - - 检查用户ID对应的用户是否存在 - - 不存在则抛出"用户不存在"异常 - -3. **会员码有效性校验** - - 根据会员码查询vip_code表 - - 检查会员码是否存在 - - 检查会员码是否已被使用(isUse字段) - -4. **会员时间计算** - - 获取用户当前的会员到期时间(vipExpire) - - 如果当前时间晚于会员到期时间或会员到期时间为空,则从当前时间开始计算 - - 如果当前时间早于会员到期时间,则从会员到期时间开始计算 - - 添加会员码对应的月数(vipExpireTime字段) - -5. **数据库更新** - - 更新用户表:设置isVip=1,更新vipExpire为新计算的时间 - - 更新会员码表:设置isUse=1,标记为已使用 - -### 数据库表结构 - -**vip_code表:** -- `id`: 主键 -- `code`: 会员码(唯一) -- `vipExpireTime`: 会员有效月数 -- `vipNumber`: 会员编号 -- `isUse`: 是否使用(0-未使用,1-已使用) -- `createTime`: 创建时间 -- `updateTime`: 更新时间 - -**user表:** -- `isVip`: 是否会员(0-非会员,1-会员) -- `vipExpire`: 会员到期时间 - -## 错误码说明 - -| 错误码 | 说明 | -|--------|------| -| 40000 | 参数错误(用户ID为空、会员码为空、用户不存在、会员码无效等) | -| 50000 | 系统错误(数据库操作失败等) | - -## 使用示例 - -### cURL 示例 - -**生成会员码:** -```bash -curl -X POST http://localhost:8080/vip-code/generate \ - -H "Content-Type: application/json" \ - -d '{ - "numCodes": 100, - "vipExpireTime": 12 - }' -``` - -**获取可用会员码:** -```bash -curl -X GET "http://localhost:8080/vip-code/available?vipExpireTime=1" -``` - -**激活会员码:** -```bash -curl -X POST http://localhost:8080/user/activate-vip \ - -H "Content-Type: application/json" \ - -d '{ - "userId": 1, - "code": "VIP_CODE_123456" - }' -``` - -### JavaScript 示例 - -**生成会员码:** -```javascript -const generateVipCodes = async (numCodes, vipExpireTime) => { - try { - const response = await fetch('/vip-code/generate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - numCodes: numCodes, - vipExpireTime: vipExpireTime - }) - }); - - const result = await response.json(); - if (result.code === 0) { - console.log('会员码生成成功,数量:', result.data); - return result.data; - } else { - console.error('会员码生成失败:', result.message); - return 0; - } - } catch (error) { - console.error('请求失败:', error); - return 0; - } -}; -``` - -**获取可用会员码:** -```javascript -const getAvailableVipCode = async (vipExpireTime) => { - try { - const response = await fetch(`/vip-code/available?vipExpireTime=${vipExpireTime}`, { - method: 'GET' - }); - - const result = await response.json(); - if (result.code === 0) { - console.log('获取可用会员码成功:', result.data); - return result.data; - } else { - console.error('获取可用会员码失败:', result.message); - return null; - } - } catch (error) { - console.error('请求失败:', error); - return null; - } -}; -``` - -**激活会员码:** -```javascript -const activateVipCode = async (userId, code) => { - try { - const response = await fetch('/user/activate-vip', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - userId: userId, - code: code - }) - }); - - const result = await response.json(); - if (result.code === 0) { - console.log('会员码激活成功'); - return true; - } else { - console.error('会员码激活失败:', result.message); - return false; - } - } catch (error) { - console.error('请求失败:', error); - return false; - } -}; -``` - -## 注意事项 - -### 会员码生成 -1. **批量限制**:单次最多生成1000个会员码,避免系统负载过高 -2. **唯一性保证**:系统会检查数据库确保生成的会员码唯一 -3. **事务管理**:使用数据库事务确保批量插入的数据一致性 -4. **性能优化**:使用批量插入提高大量数据的插入性能 - -### 获取可用会员码 -1. **参数限制**:只支持1个月和12个月两种类型的会员码 -2. **优先级策略**:按创建时间升序返回,优先返回最早创建的会员码 -3. **状态检查**:只返回未使用状态的会员码 -4. **库存管理**:如果指定类型的会员码已用完,会返回相应错误信息 - -### 会员码激活 -1. **事务管理**:整个激活过程使用数据库事务,确保数据一致性 -2. **重复使用**:每个会员码只能使用一次,使用后会被标记为已使用 -3. **时间计算**:会员时间会根据用户当前状态智能计算,不会丢失已有的会员时间 -4. **日志记录**:所有操作都有详细的日志记录,便于问题排查 -5. **异常处理**:完善的异常处理机制,提供清晰的错误信息 - -## 测试数据 - -在测试环境中,可以在vip_code表中插入测试数据: - -```sql -INSERT INTO vip_code (id, code, vipExpireTime, vipNumber, isUse, createTime, updateTime) -VALUES (1, 'TEST_CODE_001', 1, 1001, 0, NOW(), NOW()); - -INSERT INTO vip_code (id, code, vipExpireTime, vipNumber, isUse, createTime, updateTime) -VALUES (2, 'TEST_CODE_002', 3, 1002, 0, NOW(), NOW()); - -INSERT INTO vip_code (id, code, vipExpireTime, vipNumber, isUse, createTime, updateTime) -VALUES (3, 'TEST_CODE_003', 12, 1003, 0, NOW(), NOW()); -``` - -这些测试数据分别对应1个月、3个月和12个月的会员时长。 \ No newline at end of file diff --git a/双色球奖金规则更新说明.md b/双色球奖金规则更新说明.md new file mode 100644 index 0000000..06c6f62 --- /dev/null +++ b/双色球奖金规则更新说明.md @@ -0,0 +1,195 @@ +# 双色球奖金规则更新说明 + +## 更新概述 +根据新的双色球开奖规则,系统已完成以下更新: + +## 1. 数据库变更 + +### 新增字段 +在 `lottery_draws` 表中新增字段: +- `is_special_period` (TINYINT): 标识该期是否处于特别规定期间(0-否,1-是) +- `prize_pool` (BIGINT): 奖池金额(元),默认值为0 + +### SQL脚本 +- `sql/add_special_rule_status.sql`: 添加特别规定期间标识字段 +- `sql/add_prize_pool_column.sql`: 添加奖池字段 + +## 2. 新增工具类 + +### SsqPrizeCalculator.java +位置:`src/main/java/com/xy/xyaicpzs/util/SsqPrizeCalculator.java` + +主要功能: +- 根据新规则计算中奖等级和奖金 +- 判断是否应该执行特别规定(福运奖期间) + +关键方法: +```java +// 计算中奖结果 +public static PrizeResult calculatePrize( + Integer[] predictNumbers, // 预测号码 [红球1-6, 蓝球] + Integer[] drawNumbers, // 开奖号码 [红球1-6, 蓝球] + Long prizePool, // 奖池金额(元) + boolean isSpecialPeriod // 是否处于特别规定期间 +) + +// 判断是否应该执行特别规定 +public static boolean shouldExecuteSpecialRule( + Double currentPrizePool, // 当前期奖池金额(单位:亿元) + boolean previousIsSpecialPeriod // 上一期是否处于特别规定期间 +) +``` + +## 3. 新的中奖规则 + +### 奖项设置(7个等级) + +| 奖项 | 中奖条件 | 奖金 | +|------|---------|------| +| 一等奖 | 6红+1蓝 | 500万封顶(浮动) | +| 二等奖 | 6红 | 500万封顶(浮动) | +| 三等奖 | 5红+1蓝 | 3000元(固定) | +| 四等奖 | 5红 或 4红+1蓝 | 200元(固定) | +| 五等奖 | 4红 或 3红+1蓝 | 10元(固定) | +| 六等奖 | 1蓝 | 5元(固定) | +| 福运奖 | 3红(仅特别规定期间) | 5元(固定) | + +### 特别规定(福运奖)执行规则 + +**启动条件:** +- 当奖池资金 ≥ 15亿元时开始执行特别规定 + +**停止条件:** +- 执行特别规定后,某期开奖后奖池资金 < 3亿元时停止执行 + +**福运奖:** +- 仅在特别规定期间有效 +- 中奖条件:投注号码与开奖号码中的任意3个红球相同 +- 奖金:5元 + +**重要说明:** +特别规定状态需要跨期追踪,不能仅根据当期奖池判断。系统会记录每期的 `is_special_period` 状态,并根据上一期状态和当期奖池金额来判断当期是否执行特别规定。 + +## 4. 代码更新 + +### 4.1 实体类更新 +**LotteryDraws.java** +- 新增 `prizePool` 字段(Double类型,单位:亿元) +- 新增 `isSpecialPeriod` 字段(Integer类型,0或1) + +### 4.2 Mapper XML更新 +**LotteryDrawsMapper.xml** +- 在 `Base_Column_List` 中添加 `prize_pool` 和 `is_special_period` 字段 +- 更新所有相关的 SQL 语句 + +### 4.3 服务层更新 +**DataAnalysisServiceImpl.java** +- 使用 `SsqPrizeCalculator` 替代旧的计算逻辑 +- 在 `processPendingPredictions()` 方法中: + - 获取开奖记录的奖池金额和特别规定状态 + - 调用 `SsqPrizeCalculator.calculatePrize()` 计算中奖结果 + - 更新预测记录的中奖状态、等级和奖金 +- 删除旧的 `calculatePredictResult()` 和 `calculateBonus()` 方法 + +### 4.4 Excel导入更新 +**ExcelDataImporter.java** +- `importT10Data()` 方法: + - 读取J列(索引9)的奖池金额 + - 根据上一期状态计算当期的特别规定状态 + - 设置 `isSpecialPeriod` 字段 +- `appendT10Data()` 方法: + - 同样支持奖池和特别规定状态的计算 + - 追加导入时会查询数据库中最新一期的状态 + +## 5. 使用说明 + +### 5.1 数据库迁移 +执行以下SQL脚本: +```bash +# 添加特别规定期间标识字段 +mysql -u用户名 -p数据库名 < sql/add_special_rule_status.sql + +# 修改奖池字段类型(从BIGINT改为DECIMAL,单位从元改为亿元) +mysql -u用户名 -p数据库名 < sql/update_prize_pool_to_decimal.sql +``` + +### 5.2 Excel导入格式 +T10工作表(双色球开奖数据)列结构: +- A列:开奖期号 +- B列:开奖日期 +- C-H列:红球1-6 +- I列:蓝球 +- **J列:奖池金额(单位:亿元,如 12.00、13.33)** ← 新增 + +D1工作表(大乐透开奖数据)列结构: +- A列:开奖期号 +- B列:开奖日期 +- C-G列:前区球1-5 +- H-I列:后区球1-2 +- **J列:奖池金额(单位:亿元,如 8.50、10.25)** ← 新增 + +**重要说明**: +- Excel中奖池以**亿元**为单位(如 12.00 表示12亿元) +- 系统导入时会自动转换为**元**(12.00亿 → 1200000000元) +- 判断规则时使用元为单位: + - 双色球:15亿 = 1500000000元,3亿 = 300000000元 + - 大乐透:8亿 = 800000000元 + +### 5.3 API接口 +现有接口无需修改,系统会自动: +1. 导入数据时计算特别规定状态 +2. 处理待开奖预测时使用新规则计算奖金 +3. 返回结果中包含福运奖等级(如果适用) + +## 6. 注意事项 + +1. **奖池金额单位变更**: + - Excel中:以"亿元"为单位(如14.01表示14.01亿元) + - 数据库中:以"亿元"为单位存储(DECIMAL(10,2)类型) + - 判断阈值:15亿元启动,3亿元停止 + - **重要**:如果之前已经导入过数据(以元为单位),需要执行 `sql/update_prize_pool_to_decimal.sql` 来转换数据类型 + +2. **历史数据处理**: + - 如果需要重新计算历史预测记录,需要确保 `lottery_draws` 表中的 `is_special_period` 字段已正确设置 + - 可以通过重新导入Excel数据来自动计算历史期次的特别规定状态 + +3. **特别规定状态的连续性**: + - 系统会自动追踪特别规定状态的变化 + - 导入数据时必须按期号顺序导入,以确保状态计算正确 + +4. **数据精度**: + - 奖池金额使用 DECIMAL(10,2) 类型,最多支持10位数字,小数点后2位 + - 可以精确表示到0.01亿元(即100万元) + +## 7. 测试建议 + +1. 测试特别规定启动:导入奖池≥15亿的数据,验证 `is_special_period` 为1 +2. 测试特别规定停止:在特别规定期间导入奖池<3亿的数据,验证 `is_special_period` 变为0 +3. 测试福运奖:在特别规定期间,预测3个红球匹配,验证能获得福运奖(5元) +4. 测试非特别规定期间:验证3个红球匹配时不会获得福运奖 + +## 8. 文件清单 + +### 新增文件 +- `src/main/java/com/xy/xyaicpzs/util/SsqPrizeCalculator.java` +- `sql/add_special_rule_status.sql` +- `sql/update_prize_pool_to_decimal.sql` +- `双色球奖金规则更新说明.md`(本文件) + +### 修改文件 +- `src/main/java/com/xy/xyaicpzs/domain/entity/LotteryDraws.java` +- `src/main/resources/generator/mapper/LotteryDrawsMapper.xml` +- `src/main/java/com/xy/xyaicpzs/service/impl/DataAnalysisServiceImpl.java` +- `src/main/java/com/xy/xyaicpzs/util/ExcelDataImporter.java` + +## 9. 完成状态 + +✅ 数据库字段添加 +✅ 实体类更新 +✅ Mapper XML更新 +✅ 奖金计算器实现 +✅ 服务层更新 +✅ Excel导入逻辑更新 +✅ 代码编译通过 + +**所有代码已完成并通过编译检查,可以部署使用。** diff --git a/大乐透奖金规则更新说明.md b/大乐透奖金规则更新说明.md new file mode 100644 index 0000000..eb3e47a --- /dev/null +++ b/大乐透奖金规则更新说明.md @@ -0,0 +1,132 @@ +# 大乐透奖金规则更新说明 + +## 更新概述 +大乐透奖池字段单位从"元"改为"亿元",以便与Excel导入数据保持一致。 + +## 1. 数据库变更 + +### 字段类型修改 +- **字段名**:`prizePool` +- **旧类型**:`BIGINT`(单位:元) +- **新类型**:`DECIMAL(10,2)`(单位:亿元) +- **表名**:`dlt_draw_record` + +### SQL脚本 +- `sql/update_prize_pool_to_decimal.sql`: 修改奖池字段类型 + +## 2. 代码变更 + +### 2.1 实体类更新 +**DltDrawRecord.java** +- `prizePool` 字段从 `Long` 改为 `Double` +- 单位从"元"改为"亿元" + +### 2.2 工具类更新 +**DltPrizeCalculator.java** +- `calculatePrize()` 方法参数 `prizePool` 从 `Long` 改为 `Double` +- 奖池阈值从 `800000000L`(8亿元)改为 `8.0`(8亿) +- 注释更新为"单位:亿元" + +### 2.3 导入逻辑更新 +**DltDataImporter.java** +- `importD1Data()` 方法:删除 `* 100000000` 转换,直接存储亿元值 +- `appendD1Data()` 方法:删除 `* 100000000` 转换,直接存储亿元值 +- 日志输出改为"亿元" + +### 2.4 服务层更新 +**DltDataAnalysisServiceImpl.java** +- `processPendingDltPredictions()` 方法中 `prizePool` 类型从 `Long` 改为 `Double` +- 日志输出改为"亿元" + +## 3. 奖金规则 + +### 奖池阈值 +- **高奖金档**:奖池 ≥ 8亿元 +- **低奖金档**:奖池 < 8亿元 + +### 奖金标准 + +| 奖项 | 中奖条件 | 奖池≥8亿 | 奖池<8亿 | +|------|---------|---------|---------| +| 一等奖 | 5+2 | 500万封顶(浮动) | 500万封顶(浮动) | +| 二等奖 | 5+1 | 500万封顶(浮动) | 500万封顶(浮动) | +| 三等奖 | 5+0 或 4+2 | 6666元 | 5000元 | +| 四等奖 | 4+1 | 380元 | 300元 | +| 五等奖 | 4+0 或 3+2 | 200元 | 150元 | +| 六等奖 | 3+1 或 2+2 | 18元 | 15元 | +| 七等奖 | 3+0 或 1+2 或 2+1 或 0+2 | 7元 | 5元 | + +## 4. 使用说明 + +### 4.1 数据库迁移 +执行SQL脚本修改字段类型: +```bash +mysql -u用户名 -p数据库名 < sql/update_prize_pool_to_decimal.sql +``` + +### 4.2 Excel导入格式 +D1工作表(大乐透开奖数据)列结构: +- A列:开奖期号 +- B列:开奖日期 +- C-G列:前区球1-5 +- H-I列:后区球1-2 +- **J列:奖池金额(单位:亿元,如 8.50、10.25)** + +**示例**: +- Excel中填写:`8.50` 表示 8.50亿元 +- 数据库存储:`8.50` +- 判断逻辑:`8.50 >= 8.0` → 使用高奖金档 + +### 4.3 API接口 +现有接口无需修改,系统会自动: +1. 导入数据时直接存储亿元值 +2. 处理待开奖预测时根据奖池判断奖金档位 +3. 返回结果中奖池单位为亿元 + +## 5. 注意事项 + +1. **奖池金额单位变更**: + - Excel中:以"亿元"为单位(如8.50表示8.50亿元) + - 数据库中:以"亿元"为单位存储(DECIMAL(10,2)类型) + - 判断阈值:8亿元 + - **重要**:如果之前已经导入过数据(以元为单位),需要执行 `sql/update_prize_pool_to_decimal.sql` 来转换数据类型 + +2. **数据精度**: + - 奖池金额使用 DECIMAL(10,2) 类型,最多支持10位数字,小数点后2位 + - 可以精确表示到0.01亿元(即100万元) + +3. **向后兼容**: + - `DltPrizeCalculator.calculatePrize()` 提供了无奖池参数的重载方法,默认奖池为0 + - 旧代码可以继续使用,但建议更新为传入奖池参数的版本 + +## 6. 测试建议 + +1. 测试高奖金档:导入奖池≥8亿的数据,验证奖金计算正确 +2. 测试低奖金档:导入奖池<8亿的数据,验证奖金计算正确 +3. 测试边界值:导入奖池=8.00的数据,验证使用高奖金档 +4. 测试空值:验证奖池为空时默认为0.0 + +## 7. 文件清单 + +### 修改文件 +- `src/main/java/com/xy/xyaicpzs/domain/entity/DltDrawRecord.java` +- `src/main/java/com/xy/xyaicpzs/util/DltPrizeCalculator.java` +- `src/main/java/com/xy/xyaicpzs/util/DltDataImporter.java` +- `src/main/java/com/xy/xyaicpzs/service/impl/DltDataAnalysisServiceImpl.java` + +### 新增文件 +- `大乐透奖金规则更新说明.md`(本文件) + +### SQL脚本 +- `sql/update_prize_pool_to_decimal.sql`(同时更新双色球和大乐透) + +## 8. 完成状态 + +✅ 数据库字段类型修改 +✅ 实体类更新 +✅ 工具类更新 +✅ 导入逻辑更新 +✅ 服务层更新 +✅ 代码编译通过 + +**所有代码已完成并通过编译检查,可以部署使用。** diff --git a/大乐透数据导入使用说明.md b/大乐透数据导入使用说明.md deleted file mode 100644 index 233177f..0000000 --- a/大乐透数据导入使用说明.md +++ /dev/null @@ -1,471 +0,0 @@ -# 大乐透数据导入使用说明 - -## 概述 - -本文档介绍如何使用大乐透数据导入功能,将包含D1工作表的Excel文件数据导入到`dlt_draw_record`数据库表中。 - -## 文件结构 - -### 新增文件 - -1. **DltDataImporter.java** - 大乐透数据导入工具类 - - 位置:`src/main/java/com/xy/xyaicpzs/util/DltDataImporter.java` - - 功能:解析Excel文件中的D1工作表,将数据导入到数据库 - -2. **DltTestRunner.java** - 测试运行器 - - 位置:`src/main/java/com/xy/xyaicpzs/util/DltTestRunner.java` - - 功能:提供手动测试导入功能 - -3. **DltImportController.java** - 导入控制器 - - 位置:`src/main/java/com/xy/xyaicpzs/controller/DltImportController.java` - - 功能:提供RESTful API接口 - -## Excel文件格式要求 - -### D1工作表结构(开奖数据) - -Excel文件必须包含名为"D1"的工作表,数据格式如下: - -| 列 | 字段名 | 数据类型 | 描述 | 示例 | -|----|--------|----------|------|------| -| A | 开奖期号 | 字符串 | 大乐透期号 | 07001 | -| B | 开奖日期 | 日期 | 开奖日期 | 2007-05-30 | -| C | 前区1 | 整数 | 前区第1个号码 | 22 | -| D | 前区2 | 整数 | 前区第2个号码 | 24 | -| E | 前区3 | 整数 | 前区第3个号码 | 29 | -| F | 前区4 | 整数 | 前区第4个号码 | 31 | -| G | 前区5 | 整数 | 前区第5个号码 | 33 | -| H | 后区1 | 整数 | 后区第1个号码 | 4 | -| I | 后区2 | 整数 | 后区第2个号码 | 11 | - -### 数据要求 - -- 第一行为标题行,从第二行开始为数据行 -- 开奖期号必须唯一,不能为空 -- 开奖日期支持多种格式:yyyy-MM-dd、yyyy/MM/dd、yyyy年MM月dd日等 -- 前区号码范围:1-35 -- 后区号码范围:1-12 -- 所有球号字段都不能为空 - -### D3工作表结构(前区历史数据) - -Excel文件必须包含名为"D3"的工作表,数据格式如下: - -| 列 | 字段名 | 数据类型 | 描述 | 示例 | -|----|--------|----------|------|------| -| A | 球号 | 整数 | 前区球号 | 1 | -| B | 出现频次 | 整数 | 全部历史出现频次 | 410 | -| C | 出现频率% | 小数 | 出现频率百分比 | 14.84 | -| D | 平均隐现期 | 整数 | 平均隐现期次数 | 6 | -| E | 最长隐现期 | 整数 | 最长隐现期次数 | 34 | -| F | 最多连出期 | 整数 | 最多连出期次数 | 4 | -| G | 活跃系数 | 小数 | 活跃系数 | 101.47 | -| H | 出现频次 | 整数 | 最近100期出现频次 | 12 | -| I | 平均隐现期 | 小数 | 最近100期平均隐现期 | 8.33 | -| J | 当前隐现期 | 整数 | 当前隐现期 | 1 | -| K | 最多连出期 | 整数 | 最近100期最多连出期 | 1 | -| L | 活跃系数 | 小数 | 最近100期活跃系数 | 2.97 | -| M | 排位 | 整数 | 历史数据排位 | 1 | -| N | 球号 | 整数 | 排行球号 | 29 | -| O | 活跃系数 | 小数 | 排行活跃系数 | 117.56 | -| P | 排位 | 整数 | 百期数据排位 | 1 | -| Q | 球号 | 整数 | 百期排行球号 | 20 | -| R | 活跃系数 | 小数 | 百期排行活跃系数 | 5.20 | - -### 数据要求(D3工作表) - -- 第一行为标题行,从第二行开始为数据行 -- 球号必须唯一,不能为空 -- 前区球号范围:1-35 -- 排位数据必须完整 -- 活跃系数支持小数 - -### D4工作表结构(后区历史数据) - -Excel文件必须包含名为"D4"的工作表,数据格式与D3相同: - -| 列 | 字段名 | 数据类型 | 描述 | 示例 | -|----|--------|----------|------|------| -| A | 球号 | 整数 | 后区球号 | 1 | -| B | 出现频次 | 整数 | 全部历史出现频次 | 150 | -| C | 出现频率% | 小数 | 出现频率百分比 | 12.50 | -| D | 平均隐现期 | 整数 | 平均隐现期次数 | 8 | -| E | 最长隐现期 | 整数 | 最长隐现期次数 | 25 | -| F | 最多连出期 | 整数 | 最多连出期次数 | 3 | -| G | 活跃系数 | 小数 | 活跃系数 | 95.20 | -| H | 出现频次 | 整数 | 最近100期出现频次 | 8 | -| I | 平均隐现期 | 小数 | 最近100期平均隐现期 | 12.50 | -| J | 当前隐现期 | 整数 | 当前隐现期 | 2 | -| K | 最多连出期 | 整数 | 最近100期最多连出期 | 2 | -| L | 活跃系数 | 小数 | 最近100期活跃系数 | 4.00 | -| M | 排位 | 整数 | 历史数据排位 | 1 | -| N | 球号 | 整数 | 排行球号 | 12 | -| O | 活跃系数 | 小数 | 排行活跃系数 | 105.50 | -| P | 排位 | 整数 | 百期数据排位 | 1 | -| Q | 球号 | 整数 | 百期排行球号 | 8 | -| R | 活跃系数 | 小数 | 百期排行活跃系数 | 4.50 | - -### 数据要求(D4工作表) - -- 第一行为标题行,从第二行开始为数据行 -- 球号必须唯一,不能为空 -- 后区球号范围:1-12 -- 排位数据必须完整 -- 活跃系数支持小数 - -## API接口 - -### 1. 上传文件导入开奖数据 - -**接口地址:** `POST /dlt/upload-draw-data` - -**描述:** 上传Excel文件并导入大乐透开奖数据(D1工作表,会清空现有数据) - -**参数:** -- `file`: MultipartFile类型,Excel文件(.xlsx格式) - -**权限:** 需要管理员权限 - -**返回示例:** -```json -{ - "code": 0, - "success": true, - "message": "操作成功", - "data": "大乐透开奖数据导入成功" -} -``` - -### 2. 上传文件导入前区历史数据 - -**接口地址:** `POST /dlt/upload-frontend-history` - -**描述:** 上传Excel文件并导入大乐透前区历史数据(D3工作表,会清空现有数据) - -**参数:** -- `file`: MultipartFile类型,Excel文件(.xlsx格式) - -**权限:** 需要管理员权限 - -**返回示例:** -```json -{ - "code": 0, - "success": true, - "message": "操作成功", - "data": "大乐透前区历史数据导入成功" -} -``` - -### 3. 上传文件导入后区历史数据 - -**接口地址:** `POST /dlt/upload-backend-history` - -**描述:** 上传Excel文件并导入大乐透后区历史数据(D4工作表,会清空现有数据) - -**参数:** -- `file`: MultipartFile类型,Excel文件(.xlsx格式) - -**权限:** 需要管理员权限 - -**返回示例:** -```json -{ - "code": 0, - "success": true, - "message": "操作成功", - "data": "大乐透后区历史数据导入成功" -} -``` - -### 4. 追加导入开奖数据 - -**接口地址:** `POST /dlt/upload-append-draw` - -**描述:** 上传Excel文件并追加导入大乐透开奖数据(D1工作表,不清空现有数据,跳过重复记录) - -**参数:** -- `file`: MultipartFile类型,Excel文件(.xlsx格式) - -**权限:** 需要管理员权限 - -**返回示例:** -```json -{ - "code": 0, - "success": true, - "message": "操作成功", - "data": "大乐透开奖数据追加导入成功" -} -``` - -### 5. 文件路径导入开奖数据(测试用) - -**接口地址:** `POST /dlt/import-draw-by-path` - -**描述:** 根据服务器上的文件路径导入开奖数据 - -**参数:** -- `filePath`: 字符串,服务器上的Excel文件路径 - -**权限:** 需要管理员权限 - -### 6. 文件路径导入前区历史数据(测试用) - -**接口地址:** `POST /dlt/import-frontend-by-path` - -**描述:** 根据服务器上的文件路径导入前区历史数据 - -**参数:** -- `filePath`: 字符串,服务器上的Excel文件路径 - -**权限:** 需要管理员权限 - -### 7. 文件路径导入后区历史数据(测试用) - -**接口地址:** `POST /dlt/import-backend-by-path` - -**描述:** 根据服务器上的文件路径导入后区历史数据 - -**参数:** -- `filePath`: 字符串,服务器上的Excel文件路径 - -**权限:** 需要管理员权限 - -## 使用步骤 - -### 1. 准备Excel文件 - -#### 开奖数据文件(D1工作表) -1. 创建包含D1工作表的Excel文件(.xlsx格式) -2. 按照上述D1格式要求填入大乐透开奖数据 -3. 确保数据完整性和格式正确性 - -#### 前区历史数据文件(D3工作表) -1. 创建包含D3工作表的Excel文件(.xlsx格式) -2. 按照上述D3格式要求填入前区历史数据 -3. 确保数据完整性和格式正确性 - -#### 后区历史数据文件(D4工作表) -1. 创建包含D4工作表的Excel文件(.xlsx格式) -2. 按照上述D4格式要求填入后区历史数据 -3. 确保数据完整性和格式正确性 - -### 2. 通过API导入 - -#### 方式一:文件上传导入开奖数据 -```bash -curl -X POST \ - http://localhost:8080/dlt/upload-draw-data \ - -H 'Content-Type: multipart/form-data' \ - -F 'file=@/path/to/your/dlt_draw_data.xlsx' -``` - -#### 方式二:文件上传导入前区历史数据 -```bash -curl -X POST \ - http://localhost:8080/dlt/upload-frontend-history \ - -H 'Content-Type: multipart/form-data' \ - -F 'file=@/path/to/your/dlt_frontend_data.xlsx' -``` - -#### 方式三:文件上传导入后区历史数据 -```bash -curl -X POST \ - http://localhost:8080/dlt/upload-backend-history \ - -H 'Content-Type: multipart/form-data' \ - -F 'file=@/path/to/your/dlt_backend_data.xlsx' -``` - -#### 方式四:服务器文件路径导入 -```bash -# 导入开奖数据 -curl -X POST \ - 'http://localhost:8080/dlt/import-draw-by-path?filePath=/path/to/server/file.xlsx' - -# 导入前区历史数据 -curl -X POST \ - 'http://localhost:8080/dlt/import-frontend-by-path?filePath=/path/to/server/file.xlsx' - -# 导入后区历史数据 -curl -X POST \ - 'http://localhost:8080/dlt/import-backend-by-path?filePath=/path/to/server/file.xlsx' -``` - -### 3. 查看导入结果 - -- 开奖数据导入成功后,数据将保存到`dlt_draw_record`表中 -- 前区历史数据导入成功后,数据将保存到四个前区历史相关表中 -- 后区历史数据导入成功后,数据将保存到四个后区历史相关表中 -- 系统会记录操作历史,可通过操作历史接口查看详细信息 -- 查看应用日志了解详细的导入过程和统计信息 - -## 数据库表结构 - -### 1. dlt_draw_record表(开奖数据) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_draw_record` ( - `id` BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - `drawId` VARCHAR(50) NOT NULL COMMENT '开奖期号', - `drawDate` DATE NOT NULL COMMENT '开奖日期', - `frontBall1` INT NOT NULL COMMENT '前区1', - `frontBall2` INT NOT NULL COMMENT '前区2', - `frontBall3` INT NOT NULL COMMENT '前区3', - `frontBall4` INT NOT NULL COMMENT '前区4', - `frontBall5` INT NOT NULL COMMENT '前区5', - `backBall1` INT NOT NULL COMMENT '后区1', - `backBall2` INT NOT NULL COMMENT '后区2', - `createTime` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '记录创建时间', - `updateTime` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL COMMENT '记录更新时间' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透开奖信息表'; -``` - -### 2. dlt_frontend_history_all表(前区全部历史数据) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_frontend_history_all` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ballNumber INT NOT NULL COMMENT '球号', - frequencyCount INT NULL COMMENT '出现频次', - frequencyPercentage FLOAT NULL COMMENT '出现频率%', - averageHiddenAppear INT NULL COMMENT '平均隐现期(次)', - maxHiddenInterval INT NULL COMMENT '最长隐现期(次)', - maxConsecutive INT NULL COMMENT '最多连出期(次)', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透前区全部历史数据表'; -``` - -### 3. dlt_frontend_history_100表(前区最近100期数据) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_frontend_history_100` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ballNumber INT NOT NULL COMMENT '球号', - frequencyCount INT NULL COMMENT '出现频次', - averageHiddenAppear FLOAT NULL COMMENT '平均隐现期(次)', - currentHiddenInterval INT NULL COMMENT '当前隐现期', - maxConsecutive INT NULL COMMENT '最多连出期(次)', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透前区最近100期数据表'; -``` - -### 4. dlt_frontend_history_top表(前区历史数据排行) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_frontend_history_top` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ranking INT NULL COMMENT '排位', - ballNumber INT NULL COMMENT '球号', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透前区历史数据排行表'; -``` - -### 5. dlt_frontend_history_top_100表(前区百期数据排行) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_frontend_history_top_100` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ranking INT NULL COMMENT '排位', - ballNumber INT NULL COMMENT '球号', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透前区百期数据排行表'; -``` - -### 6. dlt_backend_history_all表(后区全部历史数据) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_backend_history_all` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ballNumber INT NOT NULL COMMENT '球号', - frequencyCount INT NULL COMMENT '出现频次', - frequencyPercentage FLOAT NULL COMMENT '出现频率%', - averageHiddenAppear INT NULL COMMENT '平均隐现期(次)', - maxHiddenInterval INT NULL COMMENT '最长隐现期(次)', - maxConsecutive INT NULL COMMENT '最多连出期(次)', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透后区全部历史数据表'; -``` - -### 7. dlt_backend_history_100表(后区最近100期数据) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_backend_history_100` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ballNumber INT NOT NULL COMMENT '球号', - frequencyCount INT NULL COMMENT '出现频次', - averageHiddenAppear FLOAT NULL COMMENT '平均隐现期(次)', - currentHiddenInterval INT NULL COMMENT '当前隐现期', - maxConsecutive INT NULL COMMENT '最多连出期(次)', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透后区最近100期数据表'; -``` - -### 8. dlt_backend_history_top表(后区历史数据排行) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_backend_history_top` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ranking INT NULL COMMENT '排位', - ballNumber INT NULL COMMENT '球号', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透后区历史数据排行表'; -``` - -### 9. dlt_backend_history_top_100表(后区百期数据排行) - -```sql -CREATE TABLE IF NOT EXISTS `dlt_backend_history_top_100` ( - id BIGINT AUTO_INCREMENT COMMENT '唯一标识符' PRIMARY KEY, - ranking INT NULL COMMENT '排位', - ballNumber INT NULL COMMENT '球号', - activeCoefficient FLOAT NULL COMMENT '活跃系数' -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '大乐透后区百期数据排行表'; -``` - -## 错误处理 - -### 常见错误及解决方案 - -1. **文件格式错误** - - 错误:`只支持.xlsx格式的Excel文件` - - 解决:确保上传的是.xlsx格式的Excel文件 - -2. **工作表不存在** - - 错误:`未找到D1工作表` - - 解决:确保Excel文件包含名为"D1"的工作表 - -3. **数据不完整** - - 错误:`第X行数据不完整,跳过` - - 解决:检查对应行的数据,确保所有必需字段都有值 - -4. **日期格式错误** - - 错误:`第X行开奖日期为空,跳过` - - 解决:检查日期格式,支持yyyy-MM-dd、yyyy/MM/dd等格式 - -5. **权限不足** - - 错误:`无权限` - - 解决:确保使用管理员账号登录 - -## 日志信息 - -导入过程中会产生详细的日志信息: - -- **INFO级别**:导入进度、成功统计 -- **WARN级别**:跳过的无效数据行 -- **ERROR级别**:导入失败的错误信息 -- **DEBUG级别**:每条记录的详细信息(需开启DEBUG日志) - -## 性能说明 - -- 支持批量导入,使用MyBatis-Plus的`saveBatch`方法 -- 追加导入时会检查重复记录,避免数据重复 -- 大文件导入建议分批处理,避免内存溢出 - -## 注意事项 - -1. **数据备份**:导入前建议备份现有数据 -2. **权限控制**:所有导入接口都需要管理员权限 -3. **文件大小**:注意服务器文件上传大小限制 -4. **并发控制**:避免同时进行多个导入操作 -5. **操作记录**:所有导入操作都会记录到操作历史表中 \ No newline at end of file diff --git a/短信服务迁移说明.md b/短信服务迁移说明.md new file mode 100644 index 0000000..4bba5d8 --- /dev/null +++ b/短信服务迁移说明.md @@ -0,0 +1,238 @@ +# 短信服务迁移说明 - 从阿里云SMS到腾讯云SMS + +## 迁移概述 +将短信服务从阿里云SMS迁移到腾讯云SMS,保持原有功能不变。 + +## 1. 代码变更 + +### 1.1 SmsServiceImpl.java +**位置**:`src/main/java/com/xy/xyaicpzs/service/impl/SmsServiceImpl.java` + +**主要变更**: +- 移除阿里云SDK依赖(`com.aliyun.dysmsapi20170525`) +- 引入腾讯云SDK(`com.tencentcloudapi.sms.v20210111`) +- 修改客户端创建方式 +- 修改短信发送请求构建方式 +- 手机号格式改为E.164标准(+86前缀) + +**关键代码**: +```java +// 创建腾讯云短信客户端 +private SmsClient createSmsClient() { + Credential cred = new Credential(secretId, secretKey); + return new SmsClient(cred, region); +} + +// 发送短信 +SendSmsRequest req = new SendSmsRequest(); +req.setSmsSdkAppId(sdkAppId); +req.setSignName(signName); +req.setTemplateId(templateId); +req.setTemplateParamSet(new String[]{verificationCode}); +req.setPhoneNumberSet(new String[]{"+86" + phoneNumber}); +``` + +### 1.2 配置文件更新 +**位置**:`src/main/resources/application.yml` + +**旧配置(阿里云)**: +```yaml +aliyun: + sms: + sign-name: 西安精彩数据服务社 + template-code: SMS_489840017 + access-key-id: LTAI5tR18rXPYazi3y8kAuep + access-key-secret: KZ1aKZOupilVc332SXE1g1DfKsqHPu +``` + +**新配置(腾讯云)**: +```yaml +tencent: + sms: + secret-id: ${TENCENT_SMS_SECRET_ID:your-secret-id} + secret-key: ${TENCENT_SMS_SECRET_KEY:your-secret-key} + sdk-app-id: 1401068120 + template-id: 2598660 + sign-name: 西安溢彩数智科技有限公司 + region: ap-guangzhou +``` + +## 2. 配置说明 + +### 2.1 必需配置项 +| 配置项 | 说明 | 示例值 | +|--------|------|--------| +| secret-id | 腾讯云SecretId | 从腾讯云控制台获取 | +| secret-key | 腾讯云SecretKey | 从腾讯云控制台获取 | +| sdk-app-id | 短信应用ID | 1401068120 | +| template-id | 短信模板ID | 2598660 | +| sign-name | 短信签名 | 西安溢彩数智科技有限公司 | +| region | 地域 | ap-guangzhou(默认) | + +### 2.2 环境变量配置(推荐) +为了安全,建议使用环境变量配置敏感信息: + +**Linux/Mac**: +```bash +export TENCENT_SMS_SECRET_ID=your-secret-id +export TENCENT_SMS_SECRET_KEY=your-secret-key +``` + +**Windows**: +```cmd +set TENCENT_SMS_SECRET_ID=your-secret-id +set TENCENT_SMS_SECRET_KEY=your-secret-key +``` + +或者直接在 `application.yml` 中替换默认值。 + +## 3. Maven依赖 + +确保 `pom.xml` 中已添加腾讯云SMS SDK依赖: + +```xml + + + com.tencentcloudapi + tencentcloud-sdk-java-sms + 3.1.xxx + +``` + +**注意**:可以移除阿里云SMS相关依赖: +```xml + + + com.aliyun + dysmsapi20170525 + xxx + +``` + +## 4. 功能保持不变 + +### 4.1 验证码生成 +- 仍然生成6位随机数字验证码 +- 验证码有效期:5分钟 + +### 4.2 发送限制 +- 每个手机号每天最多发送10次 +- 使用Redis存储发送次数和验证码 + +### 4.3 API接口 +- 接口路径不变:`POST /api/sms/sendCode` +- 请求参数不变:`phoneNumber` +- 响应格式不变 + +## 5. 腾讯云SMS模板要求 + +### 5.1 模板格式 +根据模板ID `2598660`,模板内容应该类似: +``` +您的验证码是{1},请在5分钟内完成验证。 +``` + +### 5.2 模板参数 +代码中传递的参数: +```java +String[] templateParamSet = {verificationCode}; +``` + +**注意**:腾讯云模板参数是数组形式,按顺序对应模板中的 `{1}`, `{2}` 等占位符。 + +## 6. 手机号格式 + +### 6.1 阿里云格式 +``` +13868246742 // 直接11位手机号 +``` + +### 6.2 腾讯云格式(E.164标准) +``` ++8613868246742 // 需要加 +86 前缀 +``` + +代码中已自动处理: +```java +String[] phoneNumberSet = {"+86" + phoneNumber}; +``` + +## 7. 错误处理 + +### 7.1 发送成功判断 +腾讯云返回的状态码为 `"Ok"` 表示成功: +```java +if ("Ok".equals(code)) { + // 发送成功 +} +``` + +### 7.2 常见错误码 +| 错误码 | 说明 | 处理方式 | +|--------|------|---------| +| FailedOperation.ContainSensitiveWord | 内容包含敏感词 | 检查模板内容 | +| FailedOperation.SignatureIncorrectOrUnapproved | 签名未审核或不正确 | 检查签名配置 | +| LimitExceeded.PhoneNumberDailyLimit | 手机号日发送量超限 | 已在代码中限制 | +| InvalidParameterValue.TemplateParameterFormatError | 模板参数格式错误 | 检查参数数组 | + +## 8. 测试建议 + +### 8.1 单元测试 +1. 测试验证码生成 +2. 测试发送次数限制 +3. 测试验证码验证 + +### 8.2 集成测试 +1. 发送验证码到真实手机号 +2. 验证短信内容格式 +3. 验证验证码有效期 +4. 验证每日发送次数限制 + +### 8.3 测试命令 +```bash +curl -X POST http://localhost:8123/api/sms/sendCode \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "phoneNumber=13868246742" +``` + +## 9. 注意事项 + +1. **配置敏感信息**: + - 不要将 `secret-id` 和 `secret-key` 提交到代码仓库 + - 使用环境变量或配置中心管理 + +2. **模板审核**: + - 确保腾讯云控制台中模板ID `2598660` 已审核通过 + - 确保签名"西安溢彩数智科技有限公司"已审核通过 + +3. **地域选择**: + - 默认使用 `ap-guangzhou`(广州) + - 可根据实际情况修改为其他地域 + +4. **费用**: + - 腾讯云SMS按条计费 + - 建议在控制台设置费用告警 + +## 10. 回滚方案 + +如果需要回滚到阿里云SMS: + +1. 恢复 `SmsServiceImpl.java` 的旧版本 +2. 恢复 `application.yml` 中的阿里云配置 +3. 确保阿里云SDK依赖存在 +4. 重启应用 + +## 11. 完成状态 + +✅ 代码迁移完成 +✅ 配置文件更新 +✅ 编译检查通过 +✅ 功能保持不变 + +**迁移已完成,可以部署使用!** + +## 12. 相关文档 + +- [腾讯云SMS SDK文档](https://cloud.tencent.com/document/product/382/43199) +- [腾讯云SMS API文档](https://cloud.tencent.com/document/api/382/52071) +- [腾讯云SMS控制台](https://console.cloud.tencent.com/smsv2) diff --git a/阿里云SMS接口集成文档.md b/阿里云SMS接口集成文档.md new file mode 100644 index 0000000..e214417 --- /dev/null +++ b/阿里云SMS接口集成文档.md @@ -0,0 +1,712 @@ +# 阿里云SMS短信服务集成文档 + +## 目录 +1. [概述](#概述) +2. [Maven依赖](#maven依赖) +3. [配置文件](#配置文件) +4. [代码实现](#代码实现) +5. [使用示例](#使用示例) +6. [常见问题](#常见问题) + +--- + +## 概述 + +本文档提供阿里云短信服务(SMS)的完整集成方案,包括验证码发送、验证码校验、发送频率限制等功能。 + +### 功能特性 +- ✅ 发送6位数字验证码 +- ✅ 验证码5分钟有效期 +- ✅ 每个手机号每天最多发送10次 +- ✅ 使用Redis存储验证码和发送次数 +- ✅ 完整的错误处理和日志记录 + +--- + +## Maven依赖 + +### pom.xml + +```xml + + + + com.aliyun + dysmsapi20170525 + 2.0.24 + + + + + com.aliyun + tea-openapi + 0.2.8 + + + + + com.aliyun + tea-util + 0.2.21 + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-web + + +``` + +--- + +## 配置文件 + +### application.yml + +```yaml +spring: + data: + redis: + host: localhost + port: 6379 + database: 0 + password: # 如果有密码则填写 + +# 阿里云短信服务配置 +aliyun: + sms: + # 短信签名(需要在阿里云控制台申请) + sign-name: 你的短信签名 + # 短信模板CODE(需要在阿里云控制台申请) + template-code: SMS_xxxxxxxx + # 阿里云AccessKey ID + access-key-id: ${ALIYUN_ACCESS_KEY_ID:your-access-key-id} + # 阿里云AccessKey Secret + access-key-secret: ${ALIYUN_ACCESS_KEY_SECRET:your-access-key-secret} +``` + +### 环境变量配置(推荐) + +**Linux/Mac**: +```bash +export ALIYUN_ACCESS_KEY_ID=your-access-key-id +export ALIYUN_ACCESS_KEY_SECRET=your-access-key-secret +``` + +**Windows**: +```cmd +set ALIYUN_ACCESS_KEY_ID=your-access-key-id +set ALIYUN_ACCESS_KEY_SECRET=your-access-key-secret +``` + +--- + +## 代码实现 + +### 1. Service接口 + +**SmsService.java** + +```java +package com.example.service; + +/** + * 短信服务接口 + */ +public interface SmsService { + + /** + * 发送短信验证码 + * + * @param phoneNumber 手机号码 + * @return 是否发送成功 + * @throws Exception 发送异常 + */ + boolean sendVerificationCode(String phoneNumber) throws Exception; + + /** + * 验证短信验证码 + * + * @param phoneNumber 手机号码 + * @param code 验证码 + * @return 是否验证通过 + */ + boolean verifyCode(String phoneNumber, String code); +} +``` + +### 2. Service实现类 + +**SmsServiceImpl.java** + +```java +package com.example.service.impl; + +import com.aliyun.dysmsapi20170525.Client; +import com.aliyun.dysmsapi20170525.models.SendSmsRequest; +import com.aliyun.tea.TeaException; +import com.aliyun.teautil.models.RuntimeOptions; +import com.example.service.SmsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * 阿里云短信服务实现类 + */ +@Service +public class SmsServiceImpl implements SmsService { + + private static final Logger logger = LoggerFactory.getLogger(SmsServiceImpl.class); + + @Value("${aliyun.sms.sign-name}") + private String signName; + + @Value("${aliyun.sms.template-code}") + private String templateCode; + + @Value("${aliyun.sms.access-key-id}") + private String accessKeyId; + + @Value("${aliyun.sms.access-key-secret}") + private String accessKeySecret; + + @Autowired + private RedisTemplate redisTemplate; + + // 短信验证码Redis前缀 + private static final String SMS_CODE_PREFIX = "sms:code:"; + // 短信发送次数Redis前缀 + private static final String SMS_COUNT_PREFIX = "sms:count:"; + // 短信验证码有效期(分钟) + private static final long SMS_CODE_EXPIRE = 5; + // 每天最大发送次数 + private static final int MAX_SMS_COUNT_PER_DAY = 10; + + /** + * 创建阿里云短信客户端 + */ + private Client createSmsClient() throws Exception { + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config(); + config.accessKeyId = accessKeyId; + config.accessKeySecret = accessKeySecret; + config.endpoint = "dysmsapi.aliyuncs.com"; + return new Client(config); + } + + /** + * 生成6位随机数字验证码 + */ + private String generateVerificationCode() { + Random random = new Random(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < 6; i++) { + code.append(random.nextInt(10)); + } + return code.toString(); + } + + /** + * 获取当天结束时间的剩余秒数 + */ + private long getSecondsUntilEndOfDay() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); + Duration duration = Duration.between(now, endOfDay); + return duration.getSeconds(); + } + + @Override + public boolean sendVerificationCode(String phoneNumber) throws Exception { + // 1. 检查当天发送次数是否达到上限 + String countKey = SMS_COUNT_PREFIX + phoneNumber + ":" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + Integer count = (Integer) redisTemplate.opsForValue().get(countKey); + + if (count != null && count >= MAX_SMS_COUNT_PER_DAY) { + logger.warn("手机号{}今日短信发送次数已达上限: {}", phoneNumber, MAX_SMS_COUNT_PER_DAY); + return false; + } + + // 2. 生成6位随机验证码 + String verificationCode = generateVerificationCode(); + + // 3. 构建短信请求 + Client client = createSmsClient(); + SendSmsRequest sendSmsRequest = new SendSmsRequest() + .setSignName(signName) + .setTemplateCode(templateCode) + .setPhoneNumbers(phoneNumber) + .setTemplateParam("{\"code\":\"" + verificationCode + "\"}"); + + RuntimeOptions runtime = new RuntimeOptions(); + + try { + // 4. 发送短信 + client.sendSmsWithOptions(sendSmsRequest, runtime); + logger.info("短信验证码发送成功,手机号: {}", phoneNumber); + + // 5. 将验证码保存到Redis,设置过期时间 + String codeKey = SMS_CODE_PREFIX + phoneNumber; + redisTemplate.opsForValue().set(codeKey, verificationCode, SMS_CODE_EXPIRE, TimeUnit.MINUTES); + + // 6. 增加当天发送次数,并设置过期时间为当天结束 + if (count == null) { + count = 0; + } + redisTemplate.opsForValue().set(countKey, count + 1, getSecondsUntilEndOfDay(), TimeUnit.SECONDS); + + return true; + + } catch (TeaException error) { + logger.error("短信发送失败, 手机号: {}, 错误信息: {}, 诊断信息: {}", + phoneNumber, error.getMessage(), error.getData().get("Recommend")); + return false; + } catch (Exception error) { + logger.error("短信发送异常, 手机号: {}", phoneNumber, error); + return false; + } + } + + @Override + public boolean verifyCode(String phoneNumber, String code) { + if (phoneNumber == null || code == null) { + return false; + } + + String codeKey = SMS_CODE_PREFIX + phoneNumber; + String savedCode = (String) redisTemplate.opsForValue().get(codeKey); + + if (savedCode != null && savedCode.equals(code)) { + // 验证成功后删除验证码 + redisTemplate.delete(codeKey); + return true; + } + + return false; + } +} +``` + +### 3. Controller控制器 + +**SmsController.java** + +```java +package com.example.controller; + +import com.example.common.ApiResponse; +import com.example.common.ResultUtils; +import com.example.service.SmsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 短信控制器 + */ +@RestController +@RequestMapping("/sms") +@Tag(name = "短信接口", description = "提供短信验证码相关功能") +public class SmsController { + + @Autowired + private SmsService smsService; + + /** + * 发送短信验证码 + * + * @param phoneNumber 手机号 + * @return 发送结果 + */ + @PostMapping("/sendCode") + @Operation(summary = "发送短信验证码", description = "向指定手机号发送验证码,每个手机号每天最多发送10次") + public ApiResponse sendVerificationCode( + @Parameter(description = "手机号码", required = true) + @RequestParam String phoneNumber) { + try { + boolean success = smsService.sendVerificationCode(phoneNumber); + if (success) { + return ResultUtils.success(true, "验证码发送成功"); + } else { + return ResultUtils.error(40001, "发送验证码失败,请稍后重试"); + } + } catch (Exception e) { + return ResultUtils.error(50000, "发送验证码异常:" + e.getMessage()); + } + } + + /** + * 验证短信验证码 + * + * @param phoneNumber 手机号 + * @param code 验证码 + * @return 验证结果 + */ + @PostMapping("/verifyCode") + @Operation(summary = "验证短信验证码", description = "验证手机号和验证码是否匹配") + public ApiResponse verifyCode( + @Parameter(description = "手机号码", required = true) + @RequestParam String phoneNumber, + @Parameter(description = "验证码", required = true) + @RequestParam String code) { + boolean valid = smsService.verifyCode(phoneNumber, code); + if (valid) { + return ResultUtils.success(true, "验证成功"); + } else { + return ResultUtils.error(40002, "验证码错误或已过期"); + } + } +} +``` + +### 4. 通用响应类(可选) + +**ApiResponse.java** + +```java +package com.example.common; + +import lombok.Data; + +/** + * 通用API响应类 + */ +@Data +public class ApiResponse { + private int code; + private String message; + private T data; + + public ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } +} +``` + +**ResultUtils.java** + +```java +package com.example.common; + +/** + * 响应工具类 + */ +public class ResultUtils { + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "success", data); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} +``` + +--- + +## 使用示例 + +### 1. 发送验证码 + +**请求**: +```bash +curl -X POST http://localhost:8080/sms/sendCode \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "phoneNumber=13800138000" +``` + +**响应**: +```json +{ + "code": 200, + "message": "验证码发送成功", + "data": true +} +``` + +### 2. 验证验证码 + +**请求**: +```bash +curl -X POST http://localhost:8080/sms/verifyCode \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "phoneNumber=13800138000&code=123456" +``` + +**响应**: +```json +{ + "code": 200, + "message": "验证成功", + "data": true +} +``` + +### 3. 在业务代码中使用 + +```java +@Service +public class UserService { + + @Autowired + private SmsService smsService; + + /** + * 用户注册 + */ + public void register(String phoneNumber, String code, String password) { + // 1. 验证验证码 + if (!smsService.verifyCode(phoneNumber, code)) { + throw new BusinessException("验证码错误或已过期"); + } + + // 2. 执行注册逻辑 + // ... + } + + /** + * 找回密码 + */ + public void resetPassword(String phoneNumber, String code, String newPassword) { + // 1. 验证验证码 + if (!smsService.verifyCode(phoneNumber, code)) { + throw new BusinessException("验证码错误或已过期"); + } + + // 2. 重置密码 + // ... + } +} +``` + +--- + +## 常见问题 + +### 1. 如何申请阿里云短信服务? + +1. 登录[阿里云控制台](https://www.aliyun.com/) +2. 进入"短信服务"产品页面 +3. 申请短信签名(需要企业资质或个人认证) +4. 申请短信模板(需要审核,一般1-2个工作日) +5. 获取AccessKey ID和AccessKey Secret + +### 2. 短信模板格式 + +**模板示例**: +``` +您的验证码是${code},请在5分钟内完成验证。 +``` + +**模板变量**: +- 使用 `${变量名}` 格式 +- 代码中传递JSON格式:`{"code":"123456"}` + +### 3. 常见错误码 + +| 错误码 | 说明 | 解决方案 | +|--------|------|---------| +| isv.BUSINESS_LIMIT_CONTROL | 触发业务流控 | 降低发送频率 | +| isv.MOBILE_NUMBER_ILLEGAL | 手机号码格式错误 | 检查手机号格式 | +| isv.TEMPLATE_MISSING_PARAMETERS | 模板参数缺失 | 检查模板参数 | +| isv.INVALID_PARAMETERS | 参数异常 | 检查所有参数 | +| isv.AMOUNT_NOT_ENOUGH | 账户余额不足 | 充值 | +| isv.TEMPLATE_PARAMS_ILLEGAL | 模板变量值非法 | 检查变量值格式 | + +### 4. 如何修改验证码位数? + +修改 `generateVerificationCode()` 方法中的循环次数: + +```java +private String generateVerificationCode() { + Random random = new Random(); + StringBuilder code = new StringBuilder(); + // 修改这里的数字,比如改为4位验证码 + for (int i = 0; i < 4; i++) { + code.append(random.nextInt(10)); + } + return code.toString(); +} +``` + +### 5. 如何修改验证码有效期? + +修改常量 `SMS_CODE_EXPIRE`: + +```java +// 修改为10分钟 +private static final long SMS_CODE_EXPIRE = 10; +``` + +### 6. 如何修改每日发送次数限制? + +修改常量 `MAX_SMS_COUNT_PER_DAY`: + +```java +// 修改为5次 +private static final int MAX_SMS_COUNT_PER_DAY = 5; +``` + +### 7. Redis连接失败怎么办? + +确保Redis服务已启动: + +```bash +# Linux/Mac +redis-server + +# 检查Redis是否运行 +redis-cli ping +# 应该返回 PONG +``` + +### 8. 如何测试短信发送? + +阿里云提供测试环境,可以使用测试手机号: + +```java +// 测试环境配置 +config.endpoint = "dysmsapi.aliyuncs.com"; // 正式环境 +// config.endpoint = "dysmsapi-test.aliyuncs.com"; // 测试环境(如果有) +``` + +### 9. 生产环境安全建议 + +1. **不要硬编码密钥**:使用环境变量或配置中心 +2. **启用HTTPS**:保护API通信安全 +3. **添加图形验证码**:防止恶意刷短信 +4. **IP限流**:防止单个IP频繁请求 +5. **手机号验证**:验证手机号格式和归属地 +6. **监控告警**:设置短信发送量告警 + +### 10. 性能优化建议 + +1. **Redis连接池**:配置合理的连接池参数 +2. **异步发送**:使用 `@Async` 异步发送短信 +3. **批量发送**:如果需要群发,使用批量接口 +4. **缓存优化**:合理设置Redis过期时间 + +--- + +## 完整项目结构 + +``` +src/ +├── main/ +│ ├── java/ +│ │ └── com/ +│ │ └── example/ +│ │ ├── controller/ +│ │ │ └── SmsController.java +│ │ ├── service/ +│ │ │ ├── SmsService.java +│ │ │ └── impl/ +│ │ │ └── SmsServiceImpl.java +│ │ ├── common/ +│ │ │ ├── ApiResponse.java +│ │ │ └── ResultUtils.java +│ │ └── Application.java +│ └── resources/ +│ └── application.yml +└── test/ + └── java/ + └── com/ + └── example/ + └── service/ + └── SmsServiceTest.java +``` + +--- + +## 单元测试示例 + +**SmsServiceTest.java** + +```java +package com.example.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class SmsServiceTest { + + @Autowired + private SmsService smsService; + + @Test + void testSendVerificationCode() throws Exception { + // 使用测试手机号 + String phoneNumber = "13800138000"; + boolean result = smsService.sendVerificationCode(phoneNumber); + assertTrue(result, "验证码发送应该成功"); + } + + @Test + void testVerifyCode() throws Exception { + String phoneNumber = "13800138000"; + + // 发送验证码 + smsService.sendVerificationCode(phoneNumber); + + // 验证错误的验证码 + boolean result1 = smsService.verifyCode(phoneNumber, "000000"); + assertFalse(result1, "错误的验证码应该验证失败"); + + // 注意:无法测试正确的验证码,因为验证码是随机生成的 + } +} +``` + +--- + +## 总结 + +本文档提供了阿里云SMS短信服务的完整集成方案,包括: + +✅ Maven依赖配置 +✅ 完整的代码实现 +✅ 配置文件示例 +✅ 使用示例和测试方法 +✅ 常见问题解答 +✅ 安全和性能优化建议 + +将此文档保存到你的项目中,以后需要集成阿里云SMS时可以直接参考使用。 + +--- + +## 相关链接 + +- [阿里云短信服务官网](https://www.aliyun.com/product/sms) +- [阿里云短信服务API文档](https://help.aliyun.com/document_detail/101414.html) +- [阿里云短信服务SDK](https://help.aliyun.com/document_detail/215759.html) +- [阿里云短信服务控制台](https://dysms.console.aliyun.com/)