feat: 添加用户端链接状态查询接口及自动刷新逻辑
主要修改: 1. 在LinkController中新增获取用户链接状态的接口,支持通过linkId或codeNo查询。 2. 在LinkStatusService中实现用户链接状态查询逻辑,包含自动刷新和二维码更新功能。 3. 更新LinkTask实体,添加needRefresh、refreshTime、qrCreatedAt和qrExpireAt字段以支持新功能。 4. 在ScriptClient中新增检查空闲设备、选区、刷新、检查上号状态等操作的实现。 5. 更新SecurityConfig,允许用户端获取链接状态接口公开访问。 技术细节: - 新增UserLinkStatusResponse DTO以支持用户链接状态的返回格式。 - 通过脚本端接口实现链接状态的自动刷新和二维码信息更新。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ Thumbs.db
|
||||
*.temp
|
||||
|
||||
|
||||
|
||||
|
||||
33
docs/database_migration_add_user_fields.sql
Normal file
33
docs/database_migration_add_user_fields.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- 数据库迁移脚本:为link_task表添加用户端需要的字段
|
||||
-- 执行时间:2025-01-XX
|
||||
-- 说明:为支持用户端链接状态查询功能添加字段
|
||||
|
||||
-- 为link_task表添加新字段
|
||||
ALTER TABLE `link_task`
|
||||
ADD COLUMN `need_refresh` tinyint(1) NULL DEFAULT 0 COMMENT '是否需要刷新(0否,1是)' AFTER `updated_at`,
|
||||
ADD COLUMN `refresh_time` datetime(3) NULL DEFAULT NULL COMMENT '刷新时间' AFTER `need_refresh`,
|
||||
ADD COLUMN `qr_created_at` datetime(3) NULL DEFAULT NULL COMMENT '二维码创建时间' AFTER `refresh_time`,
|
||||
ADD COLUMN `qr_expire_at` datetime(3) NULL DEFAULT NULL COMMENT '二维码过期时间' AFTER `qr_created_at`;
|
||||
|
||||
-- 添加索引以优化查询性能
|
||||
ALTER TABLE `link_task`
|
||||
ADD INDEX `idx_need_refresh` (`need_refresh` ASC),
|
||||
ADD INDEX `idx_qr_expire` (`qr_expire_at` ASC);
|
||||
|
||||
-- 更新系统配置,添加用户端相关配置
|
||||
INSERT INTO `system_config` (`config_key`, `config_value`, `config_type`, `description`, `is_system`) VALUES
|
||||
('user.qr_expire_seconds', '60', 'INTEGER', '用户端二维码有效期(秒)', 1),
|
||||
('user.refresh_wait_seconds', '10', 'INTEGER', '用户端刷新等待时间(秒)', 1),
|
||||
('user.link_expire_hours', '24', 'INTEGER', '用户端链接有效期(小时)', 1),
|
||||
('user.assets_base_url', 'http://36.138.184.60:12345', 'STRING', '用户端静态资源基础URL', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`config_value` = VALUES(`config_value`),
|
||||
`updated_at` = CURRENT_TIMESTAMP(3);
|
||||
|
||||
-- 验证表结构变更
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'link_task'
|
||||
AND COLUMN_NAME IN ('need_refresh', 'refresh_time', 'qr_created_at', 'qr_expire_at')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
128
docs/终端用户接口文档.md
Normal file
128
docs/终端用户接口文档.md
Normal file
@@ -0,0 +1,128 @@
|
||||
根据需求文档分析,我理解的后端需要实现以下接口和业务逻辑:
|
||||
|
||||
## 后端接口设计文档
|
||||
|
||||
### 1. 获取链接状态接口
|
||||
|
||||
**接口:** `GET /api/link/status?code={code}`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 解密和验证code参数(包含linkId、签发时间、过期时间等)
|
||||
2. 检查链接是否过期(默认24小时)
|
||||
3. 从数据库查询链接当前状态
|
||||
4. 如果状态为USING,检查二维码是否仍有效
|
||||
5. 返回链接状态和相关信息
|
||||
|
||||
**需要的数据表字段:**
|
||||
```sql
|
||||
link_task表需要字段:
|
||||
- id (链接ID)
|
||||
- code_no (编号,全局唯一)
|
||||
- status (状态: NEW/USING/LOGGED_IN/REFUNDED/EXPIRED)
|
||||
- region (选择的区域: Q/V/null)
|
||||
- need_refresh (是否需要刷新)
|
||||
- refresh_time (刷新时间,用于10秒等待逻辑)
|
||||
- qr_created_at (二维码创建时间)
|
||||
- qr_expire_at (二维码过期时间)
|
||||
- created_at (链接创建时间)
|
||||
- expire_at (链接过期时间,默认24小时)
|
||||
- times (次数)
|
||||
- quantity (数量)
|
||||
```
|
||||
|
||||
### 2. 选区接口
|
||||
|
||||
**接口:** `POST /api/link/select-region`
|
||||
**请求体:** `{ code: string, region: "Q" | "V" }`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code和region参数
|
||||
2. 检查链接状态,只有NEW状态才能选区
|
||||
3. 如果need_refresh=true,检查是否已等待10秒
|
||||
4. 调用脚本端分配空闲设备: `GET http://36.138.184.60:1234/yijianwan_netfile/readAllMsg?文件名=判断分数`
|
||||
5. 调用脚本端选区: `POST http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断系统&编号={region}`
|
||||
6. 等待脚本端生成二维码(轮询检查二维码文件是否存在)
|
||||
7. 更新数据库状态为USING,记录二维码创建和过期时间
|
||||
8. 返回二维码信息
|
||||
|
||||
**数据库操作:**
|
||||
- 更新link_task状态为USING
|
||||
- 记录region、qr_created_at、qr_expire_at
|
||||
- 设置need_refresh=false
|
||||
|
||||
### 3. 刷新接口
|
||||
|
||||
**接口:** `POST /api/link/refresh`
|
||||
**请求体:** `{ code: string }`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code参数
|
||||
2. 调用脚本端刷新: `POST http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断刷新&编号=刷新`
|
||||
3. 设置need_refresh=true,记录refresh_time
|
||||
4. 返回等待时间(默认10秒)
|
||||
|
||||
**数据库操作:**
|
||||
- 设置need_refresh=true
|
||||
- 记录refresh_time为当前时间
|
||||
|
||||
### 4. 轮询上号接口
|
||||
|
||||
**接口:** `GET /api/link/poll-login?code={code}`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code参数
|
||||
2. 检查链接状态,只有USING状态才能轮询
|
||||
3. 调用脚本端检查上号状态: `GET http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名={code_no}`
|
||||
4. 如果返回"已上号",更新状态为LOGGED_IN
|
||||
5. 返回上号结果和资源信息
|
||||
|
||||
**数据库操作:**
|
||||
- 如果已上号,更新status为LOGGED_IN
|
||||
|
||||
### 5. 二维码代理接口(可选)
|
||||
|
||||
**接口:** `GET /api/link/qr.png?code={code}`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code参数
|
||||
2. 获取对应的编号
|
||||
3. 代理请求: `http://36.138.184.60:12345/{编号}/二维码.png`
|
||||
4. 返回图片数据
|
||||
|
||||
### 6. 脚本端适配服务类
|
||||
|
||||
需要创建ScriptClient服务来封装与脚本端的交互:
|
||||
|
||||
**方法包括:**
|
||||
- `checkAvailableDevice()` - 检查空闲设备
|
||||
- `selectRegion(codeNo, region)` - 选区
|
||||
- `refresh(codeNo)` - 刷新
|
||||
- `checkLoginStatus(codeNo)` - 检查上号状态
|
||||
- `getQrCodeUrl(codeNo)` - 获取二维码URL
|
||||
- `getTargetScore(codeNo)` - 获取目标分数
|
||||
|
||||
### 7. 需要的配置项
|
||||
|
||||
在system_config表中需要配置:
|
||||
- `qr_expire_seconds` - 二维码有效期(默认60秒)
|
||||
- `refresh_wait_seconds` - 刷新等待时间(默认10秒)
|
||||
- `link_expire_hours` - 链接有效期(默认24小时)
|
||||
|
||||
### 8. 错误处理
|
||||
|
||||
- 401/403:链接无效或已篡改
|
||||
- 410:链接已过期
|
||||
- 423:需要刷新等待
|
||||
- 504:脚本端超时
|
||||
|
||||
### 9. 日志记录
|
||||
|
||||
需要记录的操作日志:
|
||||
- 链接访问
|
||||
- 选区操作
|
||||
- 二维码生成
|
||||
- 上号结果
|
||||
- 刷新操作
|
||||
- 错误信息
|
||||
|
||||
这个接口设计是否符合你的理解?有什么需要调整的地方吗?确认无误后我就开始实现代码。
|
||||
118
docs/终端用户文档v2.md
Normal file
118
docs/终端用户文档v2.md
Normal file
@@ -0,0 +1,118 @@
|
||||
明白了!让我重新整理接口设计:
|
||||
|
||||
## 后端接口设计文档(最终版)
|
||||
|
||||
### 1. 获取链接状态接口
|
||||
|
||||
**接口:** `GET /api/link/status?code={code}`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 解密和验证code参数(包含linkId、签发时间、过期时间等)
|
||||
2. 检查链接是否过期(默认24小时)
|
||||
3. 从数据库查询链接当前状态
|
||||
4. **如果状态不是NEW,后端自动执行刷新逻辑:**
|
||||
- 调用脚本端刷新:`POST http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断刷新&编号=刷新`
|
||||
- 设置need_refresh=true,记录refresh_time
|
||||
- 等待10秒(或配置的刷新等待时间)
|
||||
- 执行完毕后继续下面的逻辑
|
||||
5. **如果状态为USING,重新获取二维码:**
|
||||
- 调用:`http://36.138.184.60:12345/{编号}/二维码.png?t={timestamp}`
|
||||
- 更新qr_created_at和qr_expire_at
|
||||
6. 返回链接状态和相关信息
|
||||
|
||||
**前端调用方式:**
|
||||
```javascript
|
||||
// 前端只需要调用一次,后端会自动处理刷新逻辑
|
||||
const statusResponse = await fetch('/api/link/status?code=' + code);
|
||||
const status = await statusResponse.json();
|
||||
// 根据返回的状态直接渲染对应页面
|
||||
```
|
||||
|
||||
**数据库操作:**
|
||||
- 如果状态不是NEW,设置need_refresh=true,记录refresh_time
|
||||
- 如果状态为USING,更新qr_created_at和qr_expire_at
|
||||
|
||||
### 2. 刷新接口(保留,供手动刷新使用)
|
||||
|
||||
**接口:** `POST /api/link/refresh`
|
||||
**请求体:** `{ code: string }`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code参数
|
||||
2. 调用脚本端刷新
|
||||
3. 设置need_refresh=true,记录refresh_time
|
||||
4. 返回等待时间
|
||||
|
||||
**使用时机:**
|
||||
- 用户在扫码页面手动点击"刷新"按钮时
|
||||
|
||||
### 3. 选区接口
|
||||
|
||||
**接口:** `POST /api/link/select-region`
|
||||
**请求体:** `{ code: string, region: "Q" | "V" }`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code和region参数
|
||||
2. 检查链接状态,只有NEW状态才能选区
|
||||
3. **如果need_refresh=true,检查是否已等待10秒,否则返回423错误**
|
||||
4. 调用脚本端分配空闲设备
|
||||
5. 调用脚本端选区
|
||||
6. 等待脚本端生成二维码
|
||||
7. 更新数据库状态为USING
|
||||
8. 返回二维码信息
|
||||
|
||||
### 4. 轮询上号接口
|
||||
|
||||
**接口:** `GET /api/link/poll-login?code={code}`
|
||||
|
||||
**业务逻辑:**
|
||||
1. 验证code参数
|
||||
2. 检查链接状态,只有USING状态才能轮询
|
||||
3. 调用脚本端检查上号状态
|
||||
4. 如果返回"已上号",更新状态为LOGGED_IN
|
||||
5. 返回上号结果和资源信息
|
||||
|
||||
### 5. 核心改动说明
|
||||
|
||||
**获取状态接口的核心逻辑:**
|
||||
```java
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<LinkStatusResponse> getStatus(@RequestParam String code) {
|
||||
// 1. 验证code
|
||||
LinkTask linkTask = validateAndGetLinkTask(code);
|
||||
|
||||
// 2. 如果状态不是NEW,自动执行刷新
|
||||
if (!"NEW".equals(linkTask.getStatus())) {
|
||||
// 调用脚本端刷新
|
||||
scriptClient.refresh(linkTask.getCodeNo());
|
||||
|
||||
// 设置刷新标记和时间
|
||||
linkTask.setNeedRefresh(true);
|
||||
linkTask.setRefreshTime(new Date());
|
||||
linkTaskMapper.updateById(linkTask);
|
||||
|
||||
// 等待10秒
|
||||
try {
|
||||
Thread.sleep(10000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果状态为USING,重新获取二维码
|
||||
if ("USING".equals(linkTask.getStatus())) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String qrUrl = String.format("http://36.138.184.60:12345/%s/二维码.png?t=%d",
|
||||
linkTask.getCodeNo(), timestamp);
|
||||
|
||||
linkTask.setQrCreatedAt(new Date());
|
||||
linkTask.setQrExpireAt(new Date(System.currentTimeMillis() + 60000));
|
||||
linkTaskMapper.updateById(linkTask);
|
||||
}
|
||||
|
||||
// 4. 返回状态信息
|
||||
return ResponseEntity.ok(buildStatusResponse(linkTask));
|
||||
}
|
||||
```
|
||||
|
||||
这样的设计是否符合你的需求?前端只需要调用一次获取状态接口,后端会自动处理所有刷新逻辑。
|
||||
299
docs/需求文档.md
299
docs/需求文档.md
@@ -1,21 +1,288 @@
|
||||
明白✅
|
||||
# 上号系统需求文档(PRD/SRS)v0.9
|
||||
|
||||
1. 二界面 URL 模板
|
||||
我已把配置改成:**`域名/{codeNo}`**(文档已更新为 `https://你的域名/{codeNo}`,按运行环境替换域名即可)。
|
||||
> 基于《安装步骤.docx》与用户确认的补充说明整理。本文作为首版“可评审草案”,确认后进入详细设计与开发。
|
||||
|
||||
2. 关于 `http://36.138.184.60:12345/编号/二维码.png`
|
||||
这个是“**脚本端**”暴露出来的**静态图片**地址——**正常情况下会直接返回 PNG 图片**。如果你在页面里看不到图,常见原因有:
|
||||
---
|
||||
|
||||
## 0. 版本信息
|
||||
|
||||
* 文档版本:v0.9(草案)
|
||||
* 更新时间:2025-08-23
|
||||
* 负责人:待定
|
||||
* 评审人:待定
|
||||
* 目标里程碑:首版上线(TBD)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
提供“上号任务”的一站式系统,生成带签名与加密的用户链接,用户进入后进行选区、扫码上号、实时进度与二界面跳转;面向商家(管理员、代理商)提供链接批量生成与额度(点数)管理、退单与公告等功能。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
* 终端用户侧:通过单个加密链接完成选区 → 扫码上号 → 进入二界面。
|
||||
* 商家侧:
|
||||
|
||||
* 管理员:创建代理商账号、为代理商添加点数(余额)、批量生成链接、退单、公告管理、数据导出。
|
||||
* 代理商:查看自身额度、批量生成链接、退单、数据导出、查看公告。
|
||||
|
||||
### 1.3 不在范围(本期)
|
||||
|
||||
* 对账、统计、风控(均不做)。
|
||||
* 富文本公告、多语言、公告定时上线下线。
|
||||
* 数据导入(仅导出)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 角色与权限
|
||||
|
||||
* **终端用户**:仅访问加密链接完成流程。
|
||||
* **管理员**:代理商管理(创建/启停/加点)、批量生成链接、退单、公告发布、导出。
|
||||
* **代理商**:批量生成链接(前提是额度充足)、退单、导出、查看公告。
|
||||
|
||||
### 2.1 权限矩阵(简表)
|
||||
|
||||
| 功能 | 终端用户 | 代理商 | 管理员 |
|
||||
| ---------- | ---- | ------- | ------ |
|
||||
| 访问加密链接进行上号 | ✔️ | — | — |
|
||||
| 批量生成链接 | — | ✔️ | ✔️ |
|
||||
| 查看/导出链接 | — | ✔️(仅自己) | ✔️(全量) |
|
||||
| 退单 | — | ✔️(仅自己) | ✔️(全量) |
|
||||
| 公告查看 | — | ✔️ | ✔️ |
|
||||
| 公告发布 | — | — | ✔️ |
|
||||
| 代理商账号管理/加点 | — | — | ✔️ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 术语与规则
|
||||
|
||||
* **链接(Link)**:一次任务的入口地址,带签名与加密;默认有效期 **24 小时**(可配)。
|
||||
* **编号(CodeNo)**:由后端自动生成,**全局唯一**、可追踪;用于日志与二界面模板替换。
|
||||
* **数量(Quantity)**:一次执行产生的奖励数,例如 50。
|
||||
* **次数(Times)**:重复执行次数,例如 20。
|
||||
* **总点数(TotalPoints)**:`Times × Quantity`,例如 20×50=1000。
|
||||
* **批量生成**:同一组设置一次生成 N 个链接(默认 10 个,可配)。
|
||||
* **二界面**:外部提供的页面或资源,系统在上号成功后 **用编号动态替换**后跳转。
|
||||
* **脚本端**:运行在手机上的脚本与相关资源(例如二维码图片、进度截图、上号判断等),由系统通过适配器服务进行对接。
|
||||
|
||||
---
|
||||
|
||||
## 4. 业务流程
|
||||
|
||||
### 4.1 链接生命周期(简述)
|
||||
|
||||
1. 商家侧生成链接:校验额度 → 扣减点数(`Times×Quantity`)→ 生成编号 → 生成**加密+签名**链接 → 设置过期时间(24h)。
|
||||
2. 终端用户访问链接:若未过期,进入“选区→扫码上号”流程;成功则跳转二界面;若退单或过期则提示不可用。
|
||||
3. 退单:释放机器(不回滚额度),记录日志。
|
||||
|
||||
### 4.2 终端用户流程(含二次识别/刷新约束)
|
||||
|
||||
* **首次进入**:
|
||||
|
||||
1. 选区(安卓QQ/安卓WX)。
|
||||
2. 触发脚本端生成二维码;前端按返回的 `qrCreatedAt/qrExpireAt` 本地计算倒计时并轮询上号状态。
|
||||
3. 上号成功 → 跳转二界面(按模板替换编号)。
|
||||
* **未进入二界面即退出/刷新/返回**:
|
||||
|
||||
* 规则:**必须先调用“刷新”接口** → 等待 **Y 秒(默认 10s,可配)** → 才允许再次选区与扫码。
|
||||
|
||||
### 4.3 退单流程
|
||||
|
||||
* 商家提交编号 → 系统释放关联机器(若有)→ 标记退单 → 记录日志;额度**不回滚**。
|
||||
|
||||
### 4.4 扣费时机与计算
|
||||
|
||||
* **生成链接时即扣费**:`扣减点数 = Times × Quantity × 批量个数`。
|
||||
* 额度不足:拒绝生成。
|
||||
|
||||
### 4.5 公告
|
||||
|
||||
* 简单文本公告(可配置是否展示、展示位置),无定时/富文本/多语言。
|
||||
|
||||
### 4.6 用户端(扫码登录)规范(单链接模式 · 定稿)
|
||||
|
||||
> 本节**仅**描述用户访问与后端处理逻辑;不涉及商家功能。**一切界面都在同一 URL**:`https://yourdomain.com/play?code={codeNo}`。前端通过该 URL 内的 `code` 调用后端查询状态并渲染对应页面(首界面/扫码界面/二界面)。
|
||||
|
||||
#### 4.6.1 URL 与安全
|
||||
|
||||
* 入口:`GET https://yourdomain.com/play?code={opaque}`,`code` 为**加密+签名**的**不透明**令牌(包含 linkId/iat/exp 等),不可被推断。
|
||||
* 后端在**每次**接口调用时校验:解密→签名→有效期(默认 24h)→状态;失败返回 401/403/410(过期)。
|
||||
|
||||
#### 4.6.2 状态模型(后端对前端暴露)
|
||||
|
||||
* `NEW`:未进入扫码流程(未选区)。
|
||||
* `USING`:已进入扫码流程(已选区,二维码有效期内)。
|
||||
* `LOGGED_IN`:已上号(展示二界面)。
|
||||
* `REFUNDED`:已退单(不可用)。
|
||||
* `EXPIRED`:已过期(24h)。
|
||||
* `needRefresh: boolean`:是否必须先刷新→等待 10 秒→再允许选区与扫码(只要进入过扫码但未到二界面时生效)。
|
||||
|
||||
#### 4.6.3 用户端 API(仅用户用)
|
||||
|
||||
> 路径示例以 `/api/link/*` 说明;实际以最终路由为准。
|
||||
|
||||
1. **获取状态**
|
||||
`GET /api/link/status?code={code}` →
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "NEW | USING | LOGGED_IN | REFUNDED | EXPIRED",
|
||||
"needRefresh": false,
|
||||
"region": "Q | V | null",
|
||||
"qr": { "url": "http://ip:12345/{编号}/二维码.png", "createdAt": 1730000000000, "expireAt": 1730000060000 },
|
||||
"view": "FIRST | SCAN | SECOND",
|
||||
"assets": {
|
||||
"base": "http://ip:12345/{编号}/",
|
||||
"firstHome": "首次主页.png",
|
||||
"firstBonus": "首次赏金.png",
|
||||
"midBonus": "中途赏金.png",
|
||||
"endBonus": "结束赏金.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* 说明:
|
||||
|
||||
* `qr` 仅在 `status=USING` 时返回;若脚本端无 TTL,后端以默认 **60s** 回填。
|
||||
* `view` 是前端渲染建议:`FIRST(选区) / SCAN(扫码) / SECOND(二界面)`;实际以 `status/needRefresh` 为准。
|
||||
* `assets` 为二界面所需的静态资源信息(路径中需要自动替换编号)。
|
||||
|
||||
2. **选区**
|
||||
`POST /api/link/select-region`
|
||||
Body: `{ code: string, region: "Q" | "V" }` → 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "USING",
|
||||
"needRefresh": false,
|
||||
"qr": { "url": "http://ip:12345/{编号}/二维码.png", "createdAt": 1730000000000, "expireAt": 1730000060000 }
|
||||
}
|
||||
```
|
||||
|
||||
* 后端行为:按需分配机器、下发任务参数、请求脚本端生成二维码;将状态置为 `USING`。
|
||||
|
||||
3. **刷新**
|
||||
`POST /api/link/refresh`
|
||||
Body: `{ code: string }` → 返回:
|
||||
|
||||
```json
|
||||
{ "needRefresh": true, "waitSeconds": 10 }
|
||||
```
|
||||
|
||||
* 规则:**未到二界面前**,再次尝试必须先刷新并在前端**等待 10 秒**后才允许重新选区与扫码。
|
||||
|
||||
4. **轮询上号**
|
||||
`GET /api/link/poll-login?code={code}` →
|
||||
|
||||
* 未上号:`{ "success": false, "status": "USING" }`
|
||||
* 已上号:
|
||||
|
||||
```json
|
||||
{ "success": true, "status": "LOGGED_IN", "view": "SECOND", "assets": { "base": "http://ip:12345/{编号}/", "endBonus": "结束赏金.png" } }
|
||||
```
|
||||
|
||||
* 前端据返回改为渲染**二界面**(同一个 URL,不跳转)。
|
||||
|
||||
> 可选:`GET /api/link/qr.png?code={code}` 由后端代理二维码,确保整链路 HTTPS,避免浏览器混合内容拦截。
|
||||
|
||||
#### 4.6.4 前端渲染流程(同一路由)
|
||||
|
||||
1. **首访**:调用 `/api/link/status` 渲染:
|
||||
|
||||
* `NEW` → 显示选区页(两个按钮 Q/V)。
|
||||
* `USING` → 显示扫码页(二维码+倒计时),并开始轮询 `/poll-login`(每 1s)。
|
||||
* `LOGGED_IN` → 显示二界面(依 `assets` 取图/信息)。
|
||||
* `REFUNDED/EXPIRED` → 显示不可用页。
|
||||
2. **选区**:`POST /select-region` 成功后进入扫码页,开始轮询。
|
||||
3. **二维码过期**:倒计时到 0 或轮询返回需刷新 → 显示“请刷新”按钮;调用 `/refresh` 并禁用按钮 **10s**,倒计时结束后恢复“选区”按钮。
|
||||
4. **上号成功**:轮询返回 `LOGGED_IN` → 在**同一 URL** 切换到二界面视图;前端可停止所有轮询与倒计时。
|
||||
|
||||
#### 4.6.5 后端内部处理要点
|
||||
|
||||
* **校验**:对所有接口按 `code` 做鉴权(签名/解密/过期/撤销/退单)。
|
||||
* **状态流转**:
|
||||
|
||||
* `NEW → USING`:选区成功并生成二维码。
|
||||
* `USING → LOGGED_IN`:脚本端返回“已上号”。
|
||||
* `NEW/USING → REFUNDED | EXPIRED`:退单或超时达 24h。
|
||||
* **刷新约束**:对进入过扫码但未上号的会话,标记 `needRefresh=true`;`/refresh` 后写入“刷新时间”,后续 **10s** 内拒绝 `select-region`。
|
||||
* **脚本端适配**:分配/释放机器、生成二维码、查询“是否上号”、可选的分数读取;超时重试与错误兜底(不把脚本端错误直接暴露给前端)。
|
||||
* **幂等**:`select-region`/`refresh`/`poll-login` 按 `code` 幂等处理,避免重复侧效应。
|
||||
* **日志**:记录编号、选区、二维码下发/失效、上号结果、刷新与错误(保留 90 天)。
|
||||
|
||||
#### 4.6.6 错误与前端文案(示例)
|
||||
|
||||
* 401/403:链接无效或已篡改 → “链接无效,请联系商家”。
|
||||
* 410:链接已过期 → “链接已过期,请联系商家重新获取”。
|
||||
* 423:需刷新等待 → “请先刷新并等待 10 秒后再试”。
|
||||
* 504:脚本端超时 → “当前排队人数较多,请稍后重试”。
|
||||
|
||||
> 以上约束确保“同一链接承载首界面/扫码/二界面”的产品体验,前端仅根据后端状态渲染视图,无需路由跳转。
|
||||
|
||||
|
||||
|
||||
次数链接
|
||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=总次数&编号=次数
|
||||
|
||||
后台操作流程: 后台管理员在后台输入框里面填写了一个次数5, 然后点击了生成, 平台会自动生成一个链接,格式如同:域名/加密
|
||||
|
||||
批量生成可以搞成下载文件或者复制粘贴都可以
|
||||
|
||||
后台功能:
|
||||
1.后台可以直接单生成链接或者批量生成链接给用户使用如图
|
||||
2.代理系统, 代理也可单独生成链接或批量生成, 后台可帮他设置金额, 代理使用多少,自动扣除多少
|
||||
3.退单: 后台只需填写正确的编号运行退单链接,即可直接退单
|
||||
4.用户选区之后等待几秒 出现二维码到期时间 刷新时间 用户使用有效期 设置
|
||||
5.次数等于数量 设置
|
||||
6.可以设置公告弹窗, 代理也可以自己生成自己的, 不会互通! 只有确定和取消按钮,点确定会跳转某网页
|
||||
|
||||
退单链接()
|
||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断退单&编号=T
|
||||
|
||||
第一步;用户界面 1和2
|
||||
2
|
||||
判断空的设备链接
|
||||
http://36.138.184.60:1234/yijianwan_netfile/readAllMsg?文件名=判断分数
|
||||
|
||||
选区链接
|
||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断系统&编号=Q或V
|
||||
|
||||
刷新链接
|
||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断刷新&编号=刷新
|
||||
|
||||
二维码的http://36.138.184.60:12345/编号/二维码.png
|
||||
|
||||
判断是否上号
|
||||
http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名=编号 状态: 未上号 已上号 【已上号跳转第二个界面】
|
||||
|
||||
讲解:后台生成了一个链接,填写了次数, 用户拿到链接之后,在浏览器运行, 后台会自动获取一个空的编号, 然后用户可以自己选QQ区或微信区, 如果用户选择QQ区,那么直接执行选区链接执行QQ的, 微信也一样, 根据后台设置的等待,几秒后获取二维码链接 如同第三张图, 点击之后你可以观看第三张图,有一个倒数时间,也可在后台设置时间, 如果用户在我设置的60秒内没扫码,自动会跳出扫码超时,如第二张图, 用户可以继续选区, 后台会首先执行刷新链接, 然后根据后台设置的刷新时间,等待多少秒后再执行选择QQ区或微信区的链接, 根据用户选择, 作者可以写一个日志或者其他把把这个些链接的使用流程记录下来,方便第二次识别,
|
||||
|
||||
假如:用户他第一次已经选过了区,但是他直接第二次刷新了浏览器, 导致没办法确认我后台对接的脚本是否在首页, 所以作者必须写个记录, 来判断这个链接是否被使用过一次,如果被使用过一次, 那么用户不管是刷新浏览器,还是等待超时扫码, 都判定为第二次! 所以必须先执行刷新根据后台设计的时间等待 再选区!
|
||||
|
||||
用户只要选区了,出现了二维码,那么后台只需要一直识别判断它是否上号那条链接,如果出现已上号,那么直接跳转第二个界面,未上号就一直在第一个界面!
|
||||
|
||||
|
||||
|
||||
用户扫码后跳转的:第二个界面
|
||||
|
||||
|
||||
|
||||
②http://36.138.184.60:12345/编号/首次主页.png
|
||||
③http://36.138.184.60:12345/编号/首次赏金.png
|
||||
④http://36.138.184.60:12345/编号/中途赏金.png
|
||||
⑤http://36.138.184.60:12345/编号/结束赏金.png
|
||||
|
||||
讲解:
|
||||
1.选择大厅这个就是用户自己选的区域显示
|
||||
2.状态=已打完 空闲, 只要显示这两个状态,余地都是已打完,其他都是代练中
|
||||
3.目标点数0/1000如图1000就是后台设置的次数x次数等于数量=1000 [假如后台设置的次数等于总量为50,那么必须设置20次才等于1000
|
||||
|
||||
使用过的所有链接总有效期为24小时 可设置
|
||||
|
||||
目标点数
|
||||
http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断分数&对象名=编号
|
||||
|
||||
* **混合内容被拦截**:你的前端如果是 **HTTPS**,而这张图是 **HTTP**,浏览器会拦截(安全策略)。
|
||||
解决:给 `36.138.184.60:12345` 配 **HTTPS**,或让后端提供一个 **HTTPS 代理端点**(例如:`GET /api/link/{token}/qr.png`)去转发这张图片,前端只请求自己的 HTTPS 域名即可。(我已在文档里加了这条注意事项)
|
||||
* **二维码尚未生成 / 已过期**:脚本端还没把 `二维码.png` 写到对应 `编号/` 目录,或已被轮转清理 → 会 404 或显示旧图。
|
||||
解决:确保先触发“生成二维码”动作,并用返回的 `createdAt/expireAt` 做本地倒计时;必要时加 `?t=时间戳` 防缓存。
|
||||
* **编号不匹配**:前端用的 `编号` 与实际分配的机器编号不同。
|
||||
解决:以后端返回的 `machineId/编号` 为准。
|
||||
* **端口/防火墙**:12345 端口未对外放通或临时不可达。
|
||||
解决:主机/网关放行该端口并做健康检查。
|
||||
* **跨域非问题**:`<img>` 加载图片不受 CORS 限制,但若被混合内容拦截,同样会显示不出。
|
||||
|
||||
如果你愿意最省心的做法:我们在后端新增一个**图片代理接口**(HTTPS),前端统一拿这个接口的 URL,当脚本端换 IP/端口或做 HTTPS,这边都不用改前端。
|
||||
|
||||
需要的话我可以顺手把这个代理端点的 Spring Boot 代码骨架也给你(带缓存/超时/错误降级)。
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.gameplatform.server.model.dto.link.LinkGenerateResponse;
|
||||
import com.gameplatform.server.model.dto.link.LinkListRequest;
|
||||
import com.gameplatform.server.model.dto.link.LinkListResponse;
|
||||
import com.gameplatform.server.model.dto.link.LinkStatusResponse;
|
||||
import com.gameplatform.server.model.dto.link.UserLinkStatusResponse;
|
||||
import com.gameplatform.server.service.link.LinkGenerationService;
|
||||
import com.gameplatform.server.service.link.LinkListService;
|
||||
import com.gameplatform.server.service.link.LinkStatusService;
|
||||
@@ -195,6 +196,37 @@ public class LinkController {
|
||||
return linkStatusService.isLinkValid(codeNo);
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
@Operation(summary = "用户端获取链接状态", description = "根据链接ID或链接编号获取链接状态,包含自动刷新逻辑,用于用户端页面")
|
||||
public Mono<UserLinkStatusResponse> getUserLinkStatus(
|
||||
@RequestParam(value = "linkId", required = false) Long linkId,
|
||||
@RequestParam(value = "codeNo", required = false) String codeNo,
|
||||
@RequestParam(value = "code", required = false) String code) {
|
||||
log.info("=== 用户端获取链接状态 ===");
|
||||
log.info("linkId: {}, codeNo: {}, code: {}", linkId, codeNo, code);
|
||||
|
||||
// 如果提供了code参数,则将其作为codeNo使用
|
||||
String actualCodeNo = codeNo;
|
||||
if (actualCodeNo == null || actualCodeNo.trim().isEmpty()) {
|
||||
actualCodeNo = code;
|
||||
}
|
||||
|
||||
// 验证参数:linkId和实际的codeNo至少提供一个
|
||||
if (linkId == null && (actualCodeNo == null || actualCodeNo.trim().isEmpty())) {
|
||||
log.error("参数错误:linkId、codeNo或code至少提供一个");
|
||||
return Mono.error(new IllegalArgumentException("参数错误:linkId、codeNo或code至少提供一个"));
|
||||
}
|
||||
|
||||
return linkStatusService.getUserLinkStatus(linkId, actualCodeNo)
|
||||
.doOnSuccess(response -> {
|
||||
log.info("用户端链接状态查询成功: status={}, view={}, needRefresh={}",
|
||||
response.getStatus(), response.getView(), response.getNeedRefresh());
|
||||
})
|
||||
.doOnError(error -> {
|
||||
log.error("用户端链接状态查询失败: {}", error.getMessage(), error);
|
||||
});
|
||||
}
|
||||
|
||||
@DeleteMapping("/{codeNo}")
|
||||
@Operation(summary = "删除链接", description = "删除指定的链接,用户只能删除自己创建的链接")
|
||||
public Mono<Boolean> deleteLink(@PathVariable("codeNo") String codeNo, Authentication authentication) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.gameplatform.server.model.dto.link;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(description = "用户端链接状态响应")
|
||||
public class UserLinkStatusResponse {
|
||||
|
||||
@Schema(description = "链接状态", example = "NEW", allowableValues = {"NEW", "USING", "LOGGED_IN", "REFUNDED", "EXPIRED"})
|
||||
private String status;
|
||||
|
||||
@Schema(description = "是否需要刷新", example = "false")
|
||||
private Boolean needRefresh;
|
||||
|
||||
@Schema(description = "选择的区域", example = "Q", allowableValues = {"Q", "V"})
|
||||
private String region;
|
||||
|
||||
@Schema(description = "二维码信息")
|
||||
private QrInfo qr;
|
||||
|
||||
@Schema(description = "视图类型", example = "FIRST", allowableValues = {"FIRST", "SCAN", "SECOND"})
|
||||
private String view;
|
||||
|
||||
@Schema(description = "静态资源信息")
|
||||
private AssetsInfo assets;
|
||||
|
||||
@Schema(description = "二维码信息")
|
||||
public static class QrInfo {
|
||||
@Schema(description = "二维码URL", example = "http://36.138.184.60:12345/{编号}/二维码.png")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "创建时间戳", example = "1730000000000")
|
||||
private Long createdAt;
|
||||
|
||||
@Schema(description = "过期时间戳", example = "1730000060000")
|
||||
private Long expireAt;
|
||||
|
||||
public String getUrl() { return url; }
|
||||
public void setUrl(String url) { this.url = url; }
|
||||
|
||||
public Long getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Long createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public Long getExpireAt() { return expireAt; }
|
||||
public void setExpireAt(Long expireAt) { this.expireAt = expireAt; }
|
||||
}
|
||||
|
||||
@Schema(description = "静态资源信息")
|
||||
public static class AssetsInfo {
|
||||
@Schema(description = "基础URL", example = "http://36.138.184.60:12345/{编号}/")
|
||||
private String base;
|
||||
|
||||
@Schema(description = "首次主页图片", example = "首次主页.png")
|
||||
private String firstHome;
|
||||
|
||||
@Schema(description = "首次赏金图片", example = "首次赏金.png")
|
||||
private String firstBonus;
|
||||
|
||||
@Schema(description = "中途赏金图片", example = "中途赏金.png")
|
||||
private String midBonus;
|
||||
|
||||
@Schema(description = "结束赏金图片", example = "结束赏金.png")
|
||||
private String endBonus;
|
||||
|
||||
public String getBase() { return base; }
|
||||
public void setBase(String base) { this.base = base; }
|
||||
|
||||
public String getFirstHome() { return firstHome; }
|
||||
public void setFirstHome(String firstHome) { this.firstHome = firstHome; }
|
||||
|
||||
public String getFirstBonus() { return firstBonus; }
|
||||
public void setFirstBonus(String firstBonus) { this.firstBonus = firstBonus; }
|
||||
|
||||
public String getMidBonus() { return midBonus; }
|
||||
public void setMidBonus(String midBonus) { this.midBonus = midBonus; }
|
||||
|
||||
public String getEndBonus() { return endBonus; }
|
||||
public void setEndBonus(String endBonus) { this.endBonus = endBonus; }
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
public Boolean getNeedRefresh() { return needRefresh; }
|
||||
public void setNeedRefresh(Boolean needRefresh) { this.needRefresh = needRefresh; }
|
||||
|
||||
public String getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
|
||||
public QrInfo getQr() { return qr; }
|
||||
public void setQr(QrInfo qr) { this.qr = qr; }
|
||||
|
||||
public String getView() { return view; }
|
||||
public void setView(String view) { this.view = view; }
|
||||
|
||||
public AssetsInfo getAssets() { return assets; }
|
||||
public void setAssets(AssetsInfo assets) { this.assets = assets; }
|
||||
}
|
||||
@@ -46,6 +46,18 @@ public class LinkTask {
|
||||
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableField("need_refresh")
|
||||
private Boolean needRefresh;
|
||||
|
||||
@TableField("refresh_time")
|
||||
private LocalDateTime refreshTime;
|
||||
|
||||
@TableField("qr_created_at")
|
||||
private LocalDateTime qrCreatedAt;
|
||||
|
||||
@TableField("qr_expire_at")
|
||||
private LocalDateTime qrExpireAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
@@ -88,4 +100,16 @@ public class LinkTask {
|
||||
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
|
||||
public Boolean getNeedRefresh() { return needRefresh; }
|
||||
public void setNeedRefresh(Boolean needRefresh) { this.needRefresh = needRefresh; }
|
||||
|
||||
public LocalDateTime getRefreshTime() { return refreshTime; }
|
||||
public void setRefreshTime(LocalDateTime refreshTime) { this.refreshTime = refreshTime; }
|
||||
|
||||
public LocalDateTime getQrCreatedAt() { return qrCreatedAt; }
|
||||
public void setQrCreatedAt(LocalDateTime qrCreatedAt) { this.qrCreatedAt = qrCreatedAt; }
|
||||
|
||||
public LocalDateTime getQrExpireAt() { return qrExpireAt; }
|
||||
public void setQrExpireAt(LocalDateTime qrExpireAt) { this.qrExpireAt = qrExpireAt; }
|
||||
}
|
||||
|
||||
@@ -58,6 +58,102 @@ public class JwtService {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成链接code(用于用户端访问链接)
|
||||
*/
|
||||
public String generateLinkCode(Long linkId, String codeNo, int expireHours) {
|
||||
log.info("=== 开始生成链接code ===");
|
||||
log.info("生成参数: linkId={}, codeNo={}, expireHours={}", linkId, codeNo, expireHours);
|
||||
|
||||
Instant now = Instant.now();
|
||||
var builder = Jwts.builder()
|
||||
.setSubject("link_access")
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(now.plus(expireHours, ChronoUnit.HOURS)))
|
||||
.claim("linkId", linkId)
|
||||
.claim("codeNo", codeNo)
|
||||
.claim("type", "link_access");
|
||||
|
||||
String code = builder.signWith(key, SignatureAlgorithm.HS256).compact();
|
||||
|
||||
log.info("=== 链接code生成成功 ===");
|
||||
log.info("code长度: {} 字符", code.length());
|
||||
log.info("过期时间: {} 小时后", expireHours);
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析链接code获取链接信息
|
||||
*/
|
||||
public LinkCodeInfo parseLinkCode(String code) {
|
||||
log.debug("=== 开始解析链接code ===");
|
||||
log.debug("code长度: {} 字符", code.length());
|
||||
|
||||
try {
|
||||
io.jsonwebtoken.Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(key)
|
||||
.build()
|
||||
.parseClaimsJws(code)
|
||||
.getBody();
|
||||
|
||||
// 检查是否是链接类型的token
|
||||
String type = claims.get("type", String.class);
|
||||
if (!"link_access".equals(type)) {
|
||||
throw new IllegalArgumentException("无效的链接code类型");
|
||||
}
|
||||
|
||||
Long linkId = claims.get("linkId", Long.class);
|
||||
String codeNo = claims.get("codeNo", String.class);
|
||||
|
||||
if (linkId == null || codeNo == null) {
|
||||
throw new IllegalArgumentException("链接code格式错误");
|
||||
}
|
||||
|
||||
LinkCodeInfo info = new LinkCodeInfo();
|
||||
info.setLinkId(linkId);
|
||||
info.setCodeNo(codeNo);
|
||||
info.setIssuedAt(claims.getIssuedAt());
|
||||
info.setExpireAt(claims.getExpiration());
|
||||
|
||||
log.debug("=== 链接code解析成功 ===");
|
||||
log.debug("linkId: {}, codeNo: {}", linkId, codeNo);
|
||||
|
||||
return info;
|
||||
} catch (Exception e) {
|
||||
log.warn("=== 链接code解析失败 ===");
|
||||
log.warn("code: {}", code);
|
||||
log.warn("错误详情: {}", e.getMessage());
|
||||
throw new IllegalArgumentException("无效的链接code", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 链接code信息
|
||||
*/
|
||||
public static class LinkCodeInfo {
|
||||
private Long linkId;
|
||||
private String codeNo;
|
||||
private Date issuedAt;
|
||||
private Date expireAt;
|
||||
|
||||
public Long getLinkId() { return linkId; }
|
||||
public void setLinkId(Long linkId) { this.linkId = linkId; }
|
||||
|
||||
public String getCodeNo() { return codeNo; }
|
||||
public void setCodeNo(String codeNo) { this.codeNo = codeNo; }
|
||||
|
||||
public Date getIssuedAt() { return issuedAt; }
|
||||
public void setIssuedAt(Date issuedAt) { this.issuedAt = issuedAt; }
|
||||
|
||||
public Date getExpireAt() { return expireAt; }
|
||||
public void setExpireAt(Date expireAt) { this.expireAt = expireAt; }
|
||||
|
||||
public boolean isExpired() {
|
||||
return expireAt != null && new Date().after(expireAt);
|
||||
}
|
||||
}
|
||||
|
||||
public io.jsonwebtoken.Claims parse(String token) {
|
||||
log.info("=== 开始解析JWT token ===");
|
||||
log.info("token长度: {} 字符", token.length());
|
||||
|
||||
@@ -41,7 +41,8 @@ public class SecurityConfig {
|
||||
.pathMatchers("/actuator/**").permitAll()
|
||||
.pathMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
||||
.pathMatchers(HttpMethod.GET, "/api/auth/me").permitAll()
|
||||
.pathMatchers("/api/link/**").authenticated() // 链接接口需要认证
|
||||
.pathMatchers(HttpMethod.GET, "/api/link/status").permitAll() // 用户端获取链接状态接口,公开访问
|
||||
.pathMatchers("/api/link/**").authenticated() // 其他链接接口需要认证
|
||||
.anyExchange().permitAll() // 其他接口后续再收紧
|
||||
)
|
||||
// 关键:将JWT过滤器集成到Security过滤链中,放在AUTHENTICATION位置
|
||||
@@ -59,6 +60,7 @@ public class SecurityConfig {
|
||||
log.info(" * /actuator/** -> 允许所有");
|
||||
log.info(" * POST /api/auth/login -> 允许所有");
|
||||
log.info(" * GET /api/auth/me -> 允许所有");
|
||||
log.info(" * GET /api/link/status -> 允许所有 (用户端公开接口)");
|
||||
log.info(" * /api/link/** -> 需要认证");
|
||||
log.info(" * 其他路径 -> 允许所有");
|
||||
|
||||
|
||||
@@ -55,6 +55,110 @@ public class ScriptClient {
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.doOnError(e -> log.warn("ScriptClient.getText error path={} err={}", path, e.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查空闲设备
|
||||
*/
|
||||
public Mono<String> checkAvailableDevice() {
|
||||
String url = "http://36.138.184.60:1234/yijianwan_netfile/readAllMsg?文件名=判断分数";
|
||||
log.debug("检查空闲设备: {}", url);
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.accept(MediaType.TEXT_PLAIN)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.doOnSuccess(result -> log.debug("检查空闲设备成功: {}", result))
|
||||
.doOnError(e -> log.warn("检查空闲设备失败: {}", e.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选区操作
|
||||
*/
|
||||
public Mono<String> selectRegion(String codeNo, String region) {
|
||||
String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断系统&编号=%s", region);
|
||||
log.debug("选区操作: codeNo={}, region={}, url={}", codeNo, region, url);
|
||||
return webClient.post()
|
||||
.uri(url)
|
||||
.accept(MediaType.TEXT_PLAIN)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.doOnSuccess(result -> log.debug("选区操作成功: codeNo={}, region={}, result={}", codeNo, region, result))
|
||||
.doOnError(e -> log.warn("选区操作失败: codeNo={}, region={}, error={}", codeNo, region, e.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新操作
|
||||
*/
|
||||
public Mono<String> refresh(String codeNo) {
|
||||
String url = "http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断刷新&编号=刷新";
|
||||
log.debug("刷新操作: codeNo={}, url={}", codeNo, url);
|
||||
return webClient.post()
|
||||
.uri(url)
|
||||
.accept(MediaType.TEXT_PLAIN)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.doOnSuccess(result -> log.debug("刷新操作成功: codeNo={}, result={}", codeNo, result))
|
||||
.doOnError(e -> log.warn("刷新操作失败: codeNo={}, error={}", codeNo, e.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查上号状态
|
||||
*/
|
||||
public Mono<String> checkLoginStatus(String codeNo) {
|
||||
String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名=%s", codeNo);
|
||||
log.debug("检查上号状态: codeNo={}, url={}", codeNo, url);
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.accept(MediaType.TEXT_PLAIN)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.doOnSuccess(result -> log.debug("检查上号状态成功: codeNo={}, result={}", codeNo, result))
|
||||
.doOnError(e -> log.warn("检查上号状态失败: codeNo={}, error={}", codeNo, e.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二维码URL(带时间戳防缓存)
|
||||
*/
|
||||
public String getQrCodeUrl(String codeNo) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
return String.format("http://36.138.184.60:12345/%s/二维码.png?t=%d", codeNo, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标分数
|
||||
*/
|
||||
public Mono<String> getTargetScore(String codeNo) {
|
||||
String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断分数&对象名=%s", codeNo);
|
||||
log.debug("获取目标分数: codeNo={}, url={}", codeNo, url);
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.accept(MediaType.TEXT_PLAIN)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.doOnSuccess(result -> log.debug("获取目标分数成功: codeNo={}, result={}", codeNo, result))
|
||||
.doOnError(e -> log.warn("获取目标分数失败: codeNo={}, error={}", codeNo, e.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置次数(生成链接时调用)
|
||||
*/
|
||||
public Mono<String> setTimes(String codeNo, int times) {
|
||||
String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=总次数&编号=%d", times);
|
||||
log.debug("设置次数: codeNo={}, times={}, url={}", codeNo, times, url);
|
||||
return webClient.post()
|
||||
.uri(url)
|
||||
.accept(MediaType.TEXT_PLAIN)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.doOnSuccess(result -> log.debug("设置次数成功: codeNo={}, times={}, result={}", codeNo, times, result))
|
||||
.doOnError(e -> log.warn("设置次数失败: codeNo={}, times={}, error={}", codeNo, times, e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ import com.gameplatform.server.mapper.agent.LinkBatchMapper;
|
||||
import com.gameplatform.server.mapper.agent.LinkTaskMapper;
|
||||
import com.gameplatform.server.model.dto.link.BatchDeleteResponse;
|
||||
import com.gameplatform.server.model.dto.link.LinkStatusResponse;
|
||||
import com.gameplatform.server.model.dto.link.UserLinkStatusResponse;
|
||||
import com.gameplatform.server.model.entity.agent.LinkBatch;
|
||||
import com.gameplatform.server.model.entity.agent.LinkTask;
|
||||
|
||||
import com.gameplatform.server.service.external.ScriptClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -28,6 +31,8 @@ public class LinkStatusService {
|
||||
private final LinkTaskMapper linkTaskMapper;
|
||||
private final LinkBatchMapper linkBatchMapper;
|
||||
|
||||
private final ScriptClient scriptClient;
|
||||
|
||||
// 状态描述映射
|
||||
private static final Map<String, String> STATUS_DESC_MAP = new HashMap<>();
|
||||
static {
|
||||
@@ -38,9 +43,11 @@ public class LinkStatusService {
|
||||
STATUS_DESC_MAP.put("EXPIRED", "已过期");
|
||||
}
|
||||
|
||||
public LinkStatusService(LinkTaskMapper linkTaskMapper, LinkBatchMapper linkBatchMapper) {
|
||||
public LinkStatusService(LinkTaskMapper linkTaskMapper, LinkBatchMapper linkBatchMapper,
|
||||
ScriptClient scriptClient) {
|
||||
this.linkTaskMapper = linkTaskMapper;
|
||||
this.linkBatchMapper = linkBatchMapper;
|
||||
this.scriptClient = scriptClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,4 +243,181 @@ public class LinkStatusService {
|
||||
return response;
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户端获取链接状态(支持linkId或codeNo参数,带自动刷新逻辑)
|
||||
*/
|
||||
public Mono<UserLinkStatusResponse> getUserLinkStatus(Long linkId, String codeNo) {
|
||||
return Mono.fromCallable(() -> doGetUserLinkStatus(linkId, codeNo))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private UserLinkStatusResponse doGetUserLinkStatus(Long linkId, String codeNo) {
|
||||
log.info("=== 开始处理用户端链接状态查询 ===");
|
||||
log.info("linkId: {}, codeNo: {}", linkId, codeNo);
|
||||
|
||||
try {
|
||||
// 1. 查询链接任务
|
||||
LinkTask linkTask = null;
|
||||
if (linkId != null) {
|
||||
linkTask = linkTaskMapper.findById(linkId);
|
||||
log.info("通过linkId查询链接任务: id={}", linkId);
|
||||
} else if (codeNo != null && !codeNo.trim().isEmpty()) {
|
||||
linkTask = linkTaskMapper.findByCodeNo(codeNo.trim());
|
||||
log.info("通过codeNo查询链接任务: codeNo={}", codeNo);
|
||||
}
|
||||
|
||||
if (linkTask == null) {
|
||||
log.error("链接任务不存在: linkId={}, codeNo={}", linkId, codeNo);
|
||||
throw new IllegalArgumentException("链接不存在");
|
||||
}
|
||||
|
||||
log.info("查询到链接任务: id={}, codeNo={}, status={}, needRefresh={}",
|
||||
linkTask.getId(), linkTask.getCodeNo(), linkTask.getStatus(), linkTask.getNeedRefresh());
|
||||
|
||||
// 2. 检查链接任务是否过期
|
||||
if (linkTask.getExpireAt() != null && linkTask.getExpireAt().isBefore(LocalDateTime.now())) {
|
||||
log.warn("链接任务已过期: expireAt={}", linkTask.getExpireAt());
|
||||
linkTask.setStatus("EXPIRED");
|
||||
linkTask.setUpdatedAt(LocalDateTime.now());
|
||||
linkTaskMapper.update(linkTask);
|
||||
|
||||
UserLinkStatusResponse response = new UserLinkStatusResponse();
|
||||
response.setStatus("EXPIRED");
|
||||
response.setView("EXPIRED");
|
||||
return response;
|
||||
}
|
||||
|
||||
// 3. 如果状态不是NEW,执行自动刷新逻辑
|
||||
if (!"NEW".equals(linkTask.getStatus())) {
|
||||
log.info("链接状态不是NEW,执行自动刷新逻辑");
|
||||
performAutoRefresh(linkTask);
|
||||
}
|
||||
|
||||
// 4. 如果状态是USING,重新获取二维码
|
||||
if ("USING".equals(linkTask.getStatus())) {
|
||||
log.info("链接状态是USING,重新获取二维码");
|
||||
updateQrCodeInfo(linkTask);
|
||||
}
|
||||
|
||||
// 5. 构建响应
|
||||
UserLinkStatusResponse response = buildUserStatusResponse(linkTask);
|
||||
log.info("=== 用户端链接状态查询完成 ===");
|
||||
log.info("返回状态: {}, view: {}", response.getStatus(), response.getView());
|
||||
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("=== 用户端链接状态查询失败 ===");
|
||||
log.error("错误详情: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自动刷新逻辑
|
||||
*/
|
||||
private void performAutoRefresh(LinkTask linkTask) {
|
||||
try {
|
||||
log.info("开始执行刷新操作");
|
||||
|
||||
// 调用脚本端刷新
|
||||
String refreshResult = scriptClient.refresh(linkTask.getCodeNo()).block();
|
||||
log.info("脚本端刷新结果: {}", refreshResult);
|
||||
|
||||
// 更新刷新状态
|
||||
linkTask.setNeedRefresh(true);
|
||||
linkTask.setRefreshTime(LocalDateTime.now());
|
||||
linkTask.setUpdatedAt(LocalDateTime.now());
|
||||
linkTaskMapper.update(linkTask);
|
||||
|
||||
// 等待10秒
|
||||
log.info("刷新完成,等待10秒...");
|
||||
Thread.sleep(10000);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("等待被中断: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("执行刷新操作失败: {}", e.getMessage());
|
||||
// 刷新失败不影响后续流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新二维码信息
|
||||
*/
|
||||
private void updateQrCodeInfo(LinkTask linkTask) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
linkTask.setQrCreatedAt(now);
|
||||
linkTask.setQrExpireAt(now.plusSeconds(60)); // 60秒后过期
|
||||
linkTask.setUpdatedAt(now);
|
||||
linkTaskMapper.update(linkTask);
|
||||
|
||||
log.info("更新二维码信息成功: qrCreatedAt={}, qrExpireAt={}",
|
||||
linkTask.getQrCreatedAt(), linkTask.getQrExpireAt());
|
||||
} catch (Exception e) {
|
||||
log.warn("更新二维码信息失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户端状态响应
|
||||
*/
|
||||
private UserLinkStatusResponse buildUserStatusResponse(LinkTask linkTask) {
|
||||
UserLinkStatusResponse response = new UserLinkStatusResponse();
|
||||
|
||||
// 基本状态信息
|
||||
response.setStatus(linkTask.getStatus());
|
||||
response.setNeedRefresh(Boolean.TRUE.equals(linkTask.getNeedRefresh()));
|
||||
response.setRegion(linkTask.getRegion());
|
||||
|
||||
// 确定视图类型
|
||||
String view = determineView(linkTask.getStatus(), response.getNeedRefresh());
|
||||
response.setView(view);
|
||||
|
||||
// 如果状态是USING,设置二维码信息
|
||||
if ("USING".equals(linkTask.getStatus()) && linkTask.getQrCreatedAt() != null) {
|
||||
UserLinkStatusResponse.QrInfo qrInfo = new UserLinkStatusResponse.QrInfo();
|
||||
qrInfo.setUrl(scriptClient.getQrCodeUrl(linkTask.getCodeNo()));
|
||||
qrInfo.setCreatedAt(java.sql.Timestamp.valueOf(linkTask.getQrCreatedAt()).getTime());
|
||||
if (linkTask.getQrExpireAt() != null) {
|
||||
qrInfo.setExpireAt(java.sql.Timestamp.valueOf(linkTask.getQrExpireAt()).getTime());
|
||||
}
|
||||
response.setQr(qrInfo);
|
||||
}
|
||||
|
||||
// 如果状态是LOGGED_IN,设置资源信息
|
||||
if ("LOGGED_IN".equals(linkTask.getStatus())) {
|
||||
UserLinkStatusResponse.AssetsInfo assets = new UserLinkStatusResponse.AssetsInfo();
|
||||
assets.setBase(String.format("http://36.138.184.60:12345/%s/", linkTask.getCodeNo()));
|
||||
assets.setFirstHome("首次主页.png");
|
||||
assets.setFirstBonus("首次赏金.png");
|
||||
assets.setMidBonus("中途赏金.png");
|
||||
assets.setEndBonus("结束赏金.png");
|
||||
response.setAssets(assets);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定视图类型
|
||||
*/
|
||||
private String determineView(String status, boolean needRefresh) {
|
||||
switch (status) {
|
||||
case "NEW":
|
||||
return needRefresh ? "REFRESH" : "FIRST";
|
||||
case "USING":
|
||||
return "SCAN";
|
||||
case "LOGGED_IN":
|
||||
return "SECOND";
|
||||
case "REFUNDED":
|
||||
case "EXPIRED":
|
||||
return "EXPIRED";
|
||||
default:
|
||||
return "FIRST";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user