diff --git a/src/main/java/com/gameplatform/server/controller/link/LinkController.java b/src/main/java/com/gameplatform/server/controller/link/LinkController.java index d573da6..45ef30f 100644 --- a/src/main/java/com/gameplatform/server/controller/link/LinkController.java +++ b/src/main/java/com/gameplatform/server/controller/link/LinkController.java @@ -6,18 +6,26 @@ import com.gameplatform.server.model.dto.link.LinkGenerateRequest; 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.PollLoginResponse; +import com.gameplatform.server.model.dto.link.SelectRegionRequest; +import com.gameplatform.server.model.dto.link.SelectRegionResponse; 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; +import com.gameplatform.server.service.device.DeviceCodeMappingService; +import com.gameplatform.server.service.external.ScriptClient; import io.jsonwebtoken.Claims; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import javax.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Mono; @@ -31,13 +39,19 @@ public class LinkController { private final LinkGenerationService linkGenerationService; private final LinkStatusService linkStatusService; private final LinkListService linkListService; + private final DeviceCodeMappingService deviceCodeMappingService; + private final ScriptClient scriptClient; public LinkController(LinkGenerationService linkGenerationService, LinkStatusService linkStatusService, - LinkListService linkListService) { + LinkListService linkListService, + DeviceCodeMappingService deviceCodeMappingService, + ScriptClient scriptClient) { this.linkGenerationService = linkGenerationService; this.linkStatusService = linkStatusService; this.linkListService = linkListService; + this.deviceCodeMappingService = deviceCodeMappingService; + this.scriptClient = scriptClient; } @GetMapping("/list") @@ -166,36 +180,124 @@ public class LinkController { }); } - @GetMapping("/{codeNo}/status") - @Operation(summary = "获取链接状态", description = "根据链接编号获取链接的详细状态信息,包括过期时间、奖励点数、当前状态等") - public Mono getLinkStatus(@PathVariable("codeNo") String codeNo) { - log.info("=== 开始查询链接状态 ==="); - log.info("链接编号: {}", codeNo); - - return linkStatusService.getLinkStatus(codeNo) +// @GetMapping("/{codeNo}/status") +// @Operation(summary = "获取链接状态", description = "根据链接编号获取链接的详细状态信息,包括过期时间、奖励点数、当前状态等") +// public Mono getLinkStatus(@PathVariable("codeNo") String codeNo) { +// log.info("=== 开始查询链接状态 ==="); +// log.info("链接编号: {}", codeNo); +// +// return linkStatusService.getLinkStatus(codeNo) +// .doOnSuccess(response -> { +// log.info("链接状态查询成功: codeNo={}, status={}, isExpired={}", +// codeNo, response.getStatus(), response.getIsExpired()); +// }) +// .doOnError(error -> { +// log.error("链接状态查询失败: codeNo={}, error={}", codeNo, error.getMessage(), error); +// }); +// } +// +// @GetMapping("/{codeNo}/exists") +// @Operation(summary = "检查链接是否存在", description = "检查指定链接编号是否存在") +// public Mono isLinkExists(@PathVariable("codeNo") String codeNo) { +// log.debug("检查链接是否存在: codeNo={}", codeNo); +// return linkStatusService.isLinkExists(codeNo); +// } +// +// @GetMapping("/{codeNo}/valid") +// @Operation(summary = "检查链接是否有效", description = "检查指定链接是否有效(未过期且状态正常)") +// public Mono isLinkValid(@PathVariable("codeNo") String codeNo) { +// log.debug("检查链接是否有效: codeNo={}", codeNo); +// return linkStatusService.isLinkValid(codeNo); +// } +@DeleteMapping("/{codeNo}") +@Operation(summary = "删除链接", description = "删除指定的链接,用户只能删除自己创建的链接") +public Mono deleteLink(@PathVariable("codeNo") String codeNo, Authentication authentication) { + log.info("=== 开始删除链接 ==="); + log.info("链接编号: {}", codeNo); + + if (authentication == null) { + log.error("=== 认证失败:Authentication为空 ==="); + return Mono.error(new IllegalArgumentException("用户未认证:Authentication为空")); + } + + // 获取用户ID + Claims claims = (Claims) authentication.getDetails(); + if (claims == null) { + log.error("=== 认证失败:Claims为空 ==="); + log.error("Authentication details: {}", authentication.getDetails()); + return Mono.error(new IllegalArgumentException("用户未认证:Claims为空")); + } + + Long agentId = claims.get("userId", Long.class); + String userType = claims.get("userType", String.class); + + log.info("用户信息: agentId={}, userType={}", agentId, userType); + + if (agentId == null) { + log.error("=== 无法获取用户ID ==="); + return Mono.error(new IllegalArgumentException("无法获取用户ID")); + } + + return linkStatusService.deleteLink(codeNo, agentId) + .doOnSuccess(success -> { + if (success) { + log.info("链接删除成功: codeNo={}, agentId={}", codeNo, agentId); + } else { + log.warn("链接删除失败: codeNo={}, agentId={}", codeNo, agentId); + } + }) + .doOnError(error -> { + log.error("删除链接时发生错误: codeNo={}, agentId={}, error={}", + codeNo, agentId, error.getMessage(), error); + }); +} + + @PostMapping("/batch-delete") + @Operation(summary = "批量删除链接", description = "批量删除指定的链接,用户只能删除自己创建的链接,最多一次删除100个") + public Mono batchDeleteLinks(@RequestBody BatchDeleteRequest request, Authentication authentication) { + log.info("=== 开始批量删除链接 ==="); + log.info("要删除的链接数量: {}", request.getCodeNos().size()); + log.info("链接编号列表: {}", request.getCodeNos()); + + if (authentication == null) { + log.error("=== 认证失败:Authentication为空 ==="); + return Mono.error(new IllegalArgumentException("用户未认证:Authentication为空")); + } + + // 获取用户ID + Claims claims = (Claims) authentication.getDetails(); + if (claims == null) { + log.error("=== 认证失败:Claims为空 ==="); + log.error("Authentication details: {}", authentication.getDetails()); + return Mono.error(new IllegalArgumentException("用户未认证:Claims为空")); + } + + Long agentId = claims.get("userId", Long.class); + String userType = claims.get("userType", String.class); + + log.info("用户信息: agentId={}, userType={}", agentId, userType); + + if (agentId == null) { + log.error("=== 无法获取用户ID ==="); + return Mono.error(new IllegalArgumentException("无法获取用户ID")); + } + + return linkStatusService.batchDeleteLinks(request.getCodeNos(), agentId) .doOnSuccess(response -> { - log.info("链接状态查询成功: codeNo={}, status={}, isExpired={}", - codeNo, response.getStatus(), response.getIsExpired()); + log.info("批量删除链接完成: 总数={}, 成功={}, 失败={}, agentId={}", + response.getTotalCount(), response.getSuccessCount(), + response.getFailedCount(), agentId); + if (!response.isAllSuccess()) { + log.warn("部分链接删除失败: 失败的链接={}, 失败原因={}", + response.getFailedCodeNos(), response.getFailedReasons()); + } }) .doOnError(error -> { - log.error("链接状态查询失败: codeNo={}, error={}", codeNo, error.getMessage(), error); + log.error("批量删除链接时发生错误: agentId={}, codeNos={}, error={}", + agentId, request.getCodeNos(), error.getMessage(), error); }); } - @GetMapping("/{codeNo}/exists") - @Operation(summary = "检查链接是否存在", description = "检查指定链接编号是否存在") - public Mono isLinkExists(@PathVariable("codeNo") String codeNo) { - log.debug("检查链接是否存在: codeNo={}", codeNo); - return linkStatusService.isLinkExists(codeNo); - } - - @GetMapping("/{codeNo}/valid") - @Operation(summary = "检查链接是否有效", description = "检查指定链接是否有效(未过期且状态正常)") - public Mono isLinkValid(@PathVariable("codeNo") String codeNo) { - log.debug("检查链接是否有效: codeNo={}", codeNo); - return linkStatusService.isLinkValid(codeNo); - } - @GetMapping("/status") @Operation(summary = "用户端获取链接状态", description = "根据链接ID或链接编号获取链接状态,包含自动刷新逻辑,用于用户端页面") public Mono getUserLinkStatus( @@ -227,92 +329,85 @@ public class LinkController { }); } - @DeleteMapping("/{codeNo}") - @Operation(summary = "删除链接", description = "删除指定的链接,用户只能删除自己创建的链接") - public Mono deleteLink(@PathVariable("codeNo") String codeNo, Authentication authentication) { - log.info("=== 开始删除链接 ==="); - log.info("链接编号: {}", codeNo); + + @PostMapping("/select-region") + @Operation(summary = "选择区域", description = "用户选择游戏区域(Q或V),选区成功后生成二维码") + public Mono selectRegion(@Valid @RequestBody SelectRegionRequest request) { + log.info("=== 开始处理选区请求 ==="); + log.info("请求参数: code={}, region={}", request.getCode(), request.getRegion()); - if (authentication == null) { - log.error("=== 认证失败:Authentication为空 ==="); - return Mono.error(new IllegalArgumentException("用户未认证:Authentication为空")); - } - - // 获取用户ID - Claims claims = (Claims) authentication.getDetails(); - if (claims == null) { - log.error("=== 认证失败:Claims为空 ==="); - log.error("Authentication details: {}", authentication.getDetails()); - return Mono.error(new IllegalArgumentException("用户未认证:Claims为空")); - } - - Long agentId = claims.get("userId", Long.class); - String userType = claims.get("userType", String.class); - - log.info("用户信息: agentId={}, userType={}", agentId, userType); - - if (agentId == null) { - log.error("=== 无法获取用户ID ==="); - return Mono.error(new IllegalArgumentException("无法获取用户ID")); - } - - return linkStatusService.deleteLink(codeNo, agentId) - .doOnSuccess(success -> { - if (success) { - log.info("链接删除成功: codeNo={}, agentId={}", codeNo, agentId); - } else { - log.warn("链接删除失败: codeNo={}, agentId={}", codeNo, agentId); - } + return linkStatusService.selectRegion(request.getCode(), request.getRegion()) + .doOnSuccess(response -> { + log.info("选区请求处理成功: success={}, status={}, region={}", + response.isSuccess(), response.getStatus(), response.getRegion()); }) .doOnError(error -> { - log.error("删除链接时发生错误: codeNo={}, agentId={}, error={}", - codeNo, agentId, error.getMessage(), error); + log.error("选区请求处理失败: code={}, region={}, error={}", + request.getCode(), request.getRegion(), error.getMessage(), error); }); } - @PostMapping("/batch-delete") - @Operation(summary = "批量删除链接", description = "批量删除指定的链接,用户只能删除自己创建的链接,最多一次删除100个") - public Mono batchDeleteLinks(@RequestBody BatchDeleteRequest request, Authentication authentication) { - log.info("=== 开始批量删除链接 ==="); - log.info("要删除的链接数量: {}", request.getCodeNos().size()); - log.info("链接编号列表: {}", request.getCodeNos()); + @GetMapping("/poll-login") + @Operation(summary = "轮询上号", description = "轮询检查用户是否已上号,仅在USING状态下有效") + public Mono pollLogin(@RequestParam("code") String code) { + log.info("=== 开始轮询上号 ==="); + log.info("请求参数: code={}", code); - if (authentication == null) { - log.error("=== 认证失败:Authentication为空 ==="); - return Mono.error(new IllegalArgumentException("用户未认证:Authentication为空")); - } - - // 获取用户ID - Claims claims = (Claims) authentication.getDetails(); - if (claims == null) { - log.error("=== 认证失败:Claims为空 ==="); - log.error("Authentication details: {}", authentication.getDetails()); - return Mono.error(new IllegalArgumentException("用户未认证:Claims为空")); - } - - Long agentId = claims.get("userId", Long.class); - String userType = claims.get("userType", String.class); - - log.info("用户信息: agentId={}, userType={}", agentId, userType); - - if (agentId == null) { - log.error("=== 无法获取用户ID ==="); - return Mono.error(new IllegalArgumentException("无法获取用户ID")); - } - - return linkStatusService.batchDeleteLinks(request.getCodeNos(), agentId) + return linkStatusService.pollLogin(code) .doOnSuccess(response -> { - log.info("批量删除链接完成: 总数={}, 成功={}, 失败={}, agentId={}", - response.getTotalCount(), response.getSuccessCount(), - response.getFailedCount(), agentId); - if (!response.isAllSuccess()) { - log.warn("部分链接删除失败: 失败的链接={}, 失败原因={}", - response.getFailedCodeNos(), response.getFailedReasons()); - } + log.info("轮询上号完成: success={}, status={}, view={}", + response.isSuccess(), response.getStatus(), response.getView()); }) .doOnError(error -> { - log.error("批量删除链接时发生错误: agentId={}, codeNos={}, error={}", - agentId, request.getCodeNos(), error.getMessage(), error); + log.error("轮询上号失败: code={}, error={}", + code, error.getMessage(), error); + }); + } + + /** + * 代理二维码获取接口 + * 通过代理code获取真实设备的二维码,避免暴露设备编号 + */ + @GetMapping("/qr/{proxyCode}") + @Operation(summary = "获取二维码", description = "通过代理code获取设备二维码,用于扫码上号") + public Mono> getProxyQrCode(@PathVariable("proxyCode") String proxyCode) { + log.info("=== 获取代理二维码 ==="); + log.info("代理code: {}", proxyCode); + + // 验证代理code是否有效 + if (!deviceCodeMappingService.isValidProxyCode(proxyCode)) { + log.warn("无效的代理code: {}", proxyCode); + return Mono.just(ResponseEntity.notFound().build()); + } + + // 获取真实设备编号 + String deviceId = deviceCodeMappingService.getDeviceId(proxyCode); + if (deviceId == null) { + log.warn("代理code对应的设备不存在: {}", proxyCode); + return Mono.just(ResponseEntity.notFound().build()); + } + + log.info("代理code {} 对应设备: {}", proxyCode, deviceId); + + // 获取真实设备的二维码 + return scriptClient.getDeviceQrCode(deviceId) + .map(qrData -> { + log.info("获取设备 {} 二维码成功,大小: {} 字节", deviceId, qrData.length); + + // 设置响应头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + headers.setCacheControl("no-cache, no-store, must-revalidate"); + headers.setPragma("no-cache"); + headers.setExpires(0); + + return ResponseEntity.ok() + .headers(headers) + .body(qrData); + }) + .onErrorResume(error -> { + log.error("获取设备 {} 二维码失败: {}", deviceId, error.getMessage(), error); + return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); }); } } diff --git a/src/main/java/com/gameplatform/server/model/dto/device/DeviceStatusResponse.java b/src/main/java/com/gameplatform/server/model/dto/device/DeviceStatusResponse.java new file mode 100644 index 0000000..9170b01 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/device/DeviceStatusResponse.java @@ -0,0 +1,69 @@ +package com.gameplatform.server.model.dto.device; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 设备状态响应 + */ +@Data +public class DeviceStatusResponse { + + /** + * 设备状态映射 (设备编号 -> 设备信息) + */ + private Map devices; + + /** + * 空闲设备列表 + */ + private List availableDevices; + + /** + * 总设备数 + */ + private int totalDevices; + + /** + * 空闲设备数 + */ + private int availableCount; + + /** + * 单个设备信息 + */ + @Data + public static class DeviceInfo { + /** + * 设备编号 + */ + private String deviceId; + + /** + * 设备状态值 + */ + private String val; + + /** + * 状态更新时间 + */ + private String time; + + /** + * 是否空闲 + */ + private boolean available; + + /** + * 设备系列 (f, s, g, d, ss, gg) + */ + private String series; + + /** + * 设备序号 (0-9) + */ + private Integer index; + } +} diff --git a/src/main/java/com/gameplatform/server/model/dto/link/PollLoginResponse.java b/src/main/java/com/gameplatform/server/model/dto/link/PollLoginResponse.java new file mode 100644 index 0000000..4c4c591 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/link/PollLoginResponse.java @@ -0,0 +1,142 @@ +package com.gameplatform.server.model.dto.link; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 轮询上号接口响应 + */ +@Schema(description = "轮询上号接口响应") +public class PollLoginResponse { + + @Schema(description = "是否上号成功", example = "false") + private boolean success; + + @Schema(description = "当前状态", example = "USING") + private String status; + + @Schema(description = "前端渲染建议", example = "SECOND") + private String view; + + @Schema(description = "静态资源信息") + private AssetsInfo assets; + + public PollLoginResponse() { + } + + public PollLoginResponse(boolean success, String status) { + this.success = success; + this.status = status; + } + + public PollLoginResponse(boolean success, String status, String view, AssetsInfo assets) { + this.success = success; + this.status = status; + this.view = view; + this.assets = assets; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + 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; + } + + /** + * 静态资源信息 + */ + @Schema(description = "静态资源信息") + public static class AssetsInfo { + + @Schema(description = "资源基础URL", example = "http://36.138.184.60:12345/ABC123/") + 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 AssetsInfo() { + } + + public AssetsInfo(String base) { + this.base = base; + this.firstHome = "首次主页.png"; + this.firstBonus = "首次赏金.png"; + this.midBonus = "中途赏金.png"; + this.endBonus = "结束赏金.png"; + } + + 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; + } + } +} diff --git a/src/main/java/com/gameplatform/server/model/dto/link/SelectRegionRequest.java b/src/main/java/com/gameplatform/server/model/dto/link/SelectRegionRequest.java new file mode 100644 index 0000000..1d45178 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/link/SelectRegionRequest.java @@ -0,0 +1,49 @@ +package com.gameplatform.server.model.dto.link; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +@Schema(description = "选区请求") +public class SelectRegionRequest { + + @NotBlank(message = "code不能为空") + @Schema(description = "链接编码", example = "66L8NM3L") + private String code; + + @NotBlank(message = "region不能为空") + @Pattern(regexp = "^[QV]$", message = "region只能是Q或V") + @Schema(description = "选择的区域", example = "Q", allowableValues = {"Q", "V"}) + private String region; + + public SelectRegionRequest() {} + + public SelectRegionRequest(String code, String region) { + this.code = code; + this.region = region; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + @Override + public String toString() { + return "SelectRegionRequest{" + + "code='" + code + '\'' + + ", region='" + region + '\'' + + '}'; + } +} diff --git a/src/main/java/com/gameplatform/server/model/dto/link/SelectRegionResponse.java b/src/main/java/com/gameplatform/server/model/dto/link/SelectRegionResponse.java new file mode 100644 index 0000000..e26abe8 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/link/SelectRegionResponse.java @@ -0,0 +1,117 @@ +package com.gameplatform.server.model.dto.link; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "选区响应") +public class SelectRegionResponse { + + @Schema(description = "操作是否成功", example = "true") + private boolean success; + + @Schema(description = "消息", example = "选区成功") + private String message; + + @Schema(description = "二维码URL", example = "http://36.138.184.60:12345/66L8NM3L/二维码.png") + private String qrCodeUrl; + + @Schema(description = "二维码创建时间") + private LocalDateTime qrCreatedAt; + + @Schema(description = "二维码过期时间") + private LocalDateTime qrExpireAt; + + @Schema(description = "链接状态", example = "USING") + private String status; + + @Schema(description = "选择的区域", example = "Q") + private String region; + + @Schema(description = "二维码出现延迟时间(秒)", example = "5") + private Integer qrDelaySeconds; + + public SelectRegionResponse() {} + + public SelectRegionResponse(boolean success, String message) { + this.success = success; + this.message = message; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getQrCodeUrl() { + return qrCodeUrl; + } + + public void setQrCodeUrl(String qrCodeUrl) { + this.qrCodeUrl = qrCodeUrl; + } + + 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; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public Integer getQrDelaySeconds() { + return qrDelaySeconds; + } + + public void setQrDelaySeconds(Integer qrDelaySeconds) { + this.qrDelaySeconds = qrDelaySeconds; + } + + @Override + public String toString() { + return "SelectRegionResponse{" + + "success=" + success + + ", message='" + message + '\'' + + ", qrCodeUrl='" + qrCodeUrl + '\'' + + ", qrCreatedAt=" + qrCreatedAt + + ", qrExpireAt=" + qrExpireAt + + ", status='" + status + '\'' + + ", region='" + region + '\'' + + ", qrDelaySeconds=" + qrDelaySeconds + + '}'; + } +} diff --git a/src/main/java/com/gameplatform/server/security/SecurityConfig.java b/src/main/java/com/gameplatform/server/security/SecurityConfig.java index 77bd4fe..a42c4db 100644 --- a/src/main/java/com/gameplatform/server/security/SecurityConfig.java +++ b/src/main/java/com/gameplatform/server/security/SecurityConfig.java @@ -42,6 +42,8 @@ public class SecurityConfig { .pathMatchers(HttpMethod.POST, "/api/auth/login").permitAll() .pathMatchers(HttpMethod.GET, "/api/auth/me").permitAll() .pathMatchers(HttpMethod.GET, "/api/link/status").permitAll() // 用户端获取链接状态接口,公开访问 + .pathMatchers(HttpMethod.POST, "/api/link/select-region").permitAll() // 用户端选区接口,公开访问 + .pathMatchers(HttpMethod.GET, "/api/link/poll-login").permitAll() // 用户端轮询登录接口,公开访问 .pathMatchers("/api/link/**").authenticated() // 其他链接接口需要认证 .anyExchange().permitAll() // 其他接口后续再收紧 ) @@ -61,6 +63,8 @@ public class SecurityConfig { log.info(" * POST /api/auth/login -> 允许所有"); log.info(" * GET /api/auth/me -> 允许所有"); log.info(" * GET /api/link/status -> 允许所有 (用户端公开接口)"); + log.info(" * POST /api/link/select-region -> 允许所有 (用户端公开接口)"); + log.info(" * GET /api/link/poll-login -> 允许所有 (用户端公开接口)"); log.info(" * /api/link/** -> 需要认证"); log.info(" * 其他路径 -> 允许所有"); diff --git a/src/main/java/com/gameplatform/server/service/device/DeviceCodeMappingService.java b/src/main/java/com/gameplatform/server/service/device/DeviceCodeMappingService.java new file mode 100644 index 0000000..b9c37ac --- /dev/null +++ b/src/main/java/com/gameplatform/server/service/device/DeviceCodeMappingService.java @@ -0,0 +1,139 @@ +package com.gameplatform.server.service.device; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 设备代码映射服务 + * 用于隐藏真实设备编号,避免被恶意用户直接访问 + */ +@Service +public class DeviceCodeMappingService { + private static final Logger log = LoggerFactory.getLogger(DeviceCodeMappingService.class); + + // 代理code -> 真实设备编号的映射 + private final Map codeToDeviceMap = new ConcurrentHashMap<>(); + // 真实设备编号 -> 代理code的映射 + private final Map deviceToCodeMap = new ConcurrentHashMap<>(); + + /** + * 为设备创建代理code + * @param deviceId 真实设备编号 (如 f1, ss9) + * @return 代理code + */ + public String createProxyCode(String deviceId) { + // 如果设备已经有代理code,则返回现有的 + if (deviceToCodeMap.containsKey(deviceId)) { + String existingCode = deviceToCodeMap.get(deviceId); + log.debug("设备 {} 已有代理code: {}", deviceId, existingCode); + return existingCode; + } + + // 生成新的代理code + String proxyCode = generateUniqueCode(); + + // 保存映射关系 + codeToDeviceMap.put(proxyCode, deviceId); + deviceToCodeMap.put(deviceId, proxyCode); + + log.info("为设备 {} 创建代理code: {}", deviceId, proxyCode); + return proxyCode; + } + + /** + * 根据代理code获取真实设备编号 + * @param proxyCode 代理code + * @return 真实设备编号,如果不存在则返回null + */ + public String getDeviceId(String proxyCode) { + String deviceId = codeToDeviceMap.get(proxyCode); + if (deviceId != null) { + log.debug("代理code {} 对应设备: {}", proxyCode, deviceId); + } else { + log.warn("无效的代理code: {}", proxyCode); + } + return deviceId; + } + + /** + * 根据设备编号获取代理code + * @param deviceId 真实设备编号 + * @return 代理code,如果不存在则返回null + */ + public String getProxyCode(String deviceId) { + String proxyCode = deviceToCodeMap.get(deviceId); + if (proxyCode != null) { + log.debug("设备 {} 对应代理code: {}", deviceId, proxyCode); + } + return proxyCode; + } + + /** + * 移除设备的代理映射 + * @param deviceId 真实设备编号 + */ + public void removeMapping(String deviceId) { + String proxyCode = deviceToCodeMap.remove(deviceId); + if (proxyCode != null) { + codeToDeviceMap.remove(proxyCode); + log.info("移除设备 {} 的代理映射: {}", deviceId, proxyCode); + } + } + + /** + * 检查代理code是否有效 + * @param proxyCode 代理code + * @return true if valid + */ + public boolean isValidProxyCode(String proxyCode) { + return codeToDeviceMap.containsKey(proxyCode); + } + + /** + * 获取当前映射数量 + * @return 映射数量 + */ + public int getMappingCount() { + return codeToDeviceMap.size(); + } + + /** + * 清空所有映射 + */ + public void clearAllMappings() { + int count = codeToDeviceMap.size(); + codeToDeviceMap.clear(); + deviceToCodeMap.clear(); + log.info("清空所有设备代理映射, 共 {} 个", count); + } + + /** + * 生成唯一的代理code + * 格式: CODE + 8位随机数字字母 + */ + private String generateUniqueCode() { + String code; + do { + code = "CODE" + generateRandomString(8); + } while (codeToDeviceMap.containsKey(code)); + return code; + } + + /** + * 生成随机字符串(数字+字母大写) + */ + private String generateRandomString(int length) { + String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + int index = ThreadLocalRandom.current().nextInt(chars.length()); + sb.append(chars.charAt(index)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java b/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java new file mode 100644 index 0000000..d428e92 --- /dev/null +++ b/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java @@ -0,0 +1,162 @@ +package com.gameplatform.server.service.device; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gameplatform.server.model.dto.device.DeviceStatusResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 设备状态服务 + */ +@Service +public class DeviceStatusService { + private static final Logger log = LoggerFactory.getLogger(DeviceStatusService.class); + + private final ObjectMapper objectMapper; + + // 设备编号解析正则 + private static final Pattern DEVICE_PATTERN = Pattern.compile("^(f|s|g|d|ss|gg)(\\d+)$"); + + public DeviceStatusService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 解析设备状态JSON响应 + */ + public DeviceStatusResponse parseDeviceStatus(String jsonResponse) { + try { + log.debug("开始解析设备状态响应"); + + JsonNode rootNode = objectMapper.readTree(jsonResponse); + Map devices = new HashMap<>(); + List availableDevices = new ArrayList<>(); + + // 遍历所有设备 + Iterator> fields = rootNode.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String deviceId = field.getKey(); + JsonNode deviceNode = field.getValue(); + + // 解析设备信息 + DeviceStatusResponse.DeviceInfo deviceInfo = parseDeviceInfo(deviceId, deviceNode); + devices.put(deviceId, deviceInfo); + + // 检查是否空闲 + if (deviceInfo.isAvailable()) { + availableDevices.add(deviceId); + } + } + + // 构建响应 + DeviceStatusResponse response = new DeviceStatusResponse(); + response.setDevices(devices); + response.setAvailableDevices(availableDevices); + response.setTotalDevices(devices.size()); + response.setAvailableCount(availableDevices.size()); + + log.info("设备状态解析完成: 总设备数={}, 空闲设备数={}, 空闲设备={}", + response.getTotalDevices(), response.getAvailableCount(), availableDevices); + + return response; + + } catch (JsonProcessingException e) { + log.error("解析设备状态JSON失败: {}", e.getMessage(), e); + throw new RuntimeException("解析设备状态失败", e); + } + } + + /** + * 解析单个设备信息 + */ + private DeviceStatusResponse.DeviceInfo parseDeviceInfo(String deviceId, JsonNode deviceNode) { + DeviceStatusResponse.DeviceInfo deviceInfo = new DeviceStatusResponse.DeviceInfo(); + deviceInfo.setDeviceId(deviceId); + + // 解析val和time + String val = deviceNode.get("val").asText(); + String time = deviceNode.get("time").asText(); + deviceInfo.setVal(val); + deviceInfo.setTime(time); + + // 解析设备系列和序号 + parseDeviceIdComponents(deviceId, deviceInfo); + + // 判断是否空闲 + boolean available = isDeviceAvailable(val); + deviceInfo.setAvailable(available); + + log.debug("解析设备信息: deviceId={}, val={}, time={}, available={}, series={}, index={}", + deviceId, val, time, available, deviceInfo.getSeries(), deviceInfo.getIndex()); + + return deviceInfo; + } + + /** + * 解析设备编号的组成部分 + */ + private void parseDeviceIdComponents(String deviceId, DeviceStatusResponse.DeviceInfo deviceInfo) { + Matcher matcher = DEVICE_PATTERN.matcher(deviceId); + if (matcher.matches()) { + String series = matcher.group(1); + String indexStr = matcher.group(2); + + deviceInfo.setSeries(series); + try { + deviceInfo.setIndex(Integer.parseInt(indexStr)); + } catch (NumberFormatException e) { + log.warn("解析设备序号失败: deviceId={}, indexStr={}", deviceId, indexStr); + deviceInfo.setIndex(null); + } + } else { + log.warn("设备编号格式不匹配: deviceId={}", deviceId); + deviceInfo.setSeries(null); + deviceInfo.setIndex(null); + } + } + + /** + * 判断设备是否空闲 + */ + private boolean isDeviceAvailable(String val) { + return "空闲".equals(val); + } + + /** + * 按系列分组空闲设备 + */ + public Map> groupAvailableDevicesBySeries(DeviceStatusResponse deviceStatus) { + Map> groupedDevices = new HashMap<>(); + + for (String deviceId : deviceStatus.getAvailableDevices()) { + DeviceStatusResponse.DeviceInfo deviceInfo = deviceStatus.getDevices().get(deviceId); + if (deviceInfo != null && deviceInfo.getSeries() != null) { + String series = deviceInfo.getSeries(); + groupedDevices.computeIfAbsent(series, k -> new ArrayList<>()).add(deviceId); + } + } + + return groupedDevices; + } + + /** + * 获取指定系列的空闲设备 + */ + public List getAvailableDevicesBySeries(DeviceStatusResponse deviceStatus, String series) { + return deviceStatus.getAvailableDevices().stream() + .filter(deviceId -> { + DeviceStatusResponse.DeviceInfo deviceInfo = deviceStatus.getDevices().get(deviceId); + return deviceInfo != null && series.equals(deviceInfo.getSeries()); + }) + .sorted() // 按设备编号排序 + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + } +} diff --git a/src/main/java/com/gameplatform/server/service/external/ScriptClient.java b/src/main/java/com/gameplatform/server/service/external/ScriptClient.java index 41918fd..56be7f1 100644 --- a/src/main/java/com/gameplatform/server/service/external/ScriptClient.java +++ b/src/main/java/com/gameplatform/server/service/external/ScriptClient.java @@ -1,5 +1,7 @@ package com.gameplatform.server.service.external; +import com.gameplatform.server.model.dto.device.DeviceStatusResponse; +import com.gameplatform.server.service.device.DeviceStatusService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -17,13 +19,19 @@ public class ScriptClient { private final WebClient webClient; private final String baseUrl; + private final String appBaseUrl; + private final DeviceStatusService deviceStatusService; public ScriptClient( @Value("${script.base-url}") String baseUrl, @Value("${script.connect-timeout-ms:3000}") int connectTimeoutMs, - @Value("${script.read-timeout-ms:5000}") int readTimeoutMs + @Value("${script.read-timeout-ms:5000}") int readTimeoutMs, + @Value("${app.base-url}") String appBaseUrl, + DeviceStatusService deviceStatusService ) { this.baseUrl = baseUrl; + this.appBaseUrl = appBaseUrl; + this.deviceStatusService = deviceStatusService; this.webClient = WebClient.builder() .baseUrl(baseUrl) .exchangeStrategies(ExchangeStrategies.builder() @@ -57,35 +65,61 @@ public class ScriptClient { } /** - * 检查空闲设备 + * 检查空闲设备(返回原始字符串) */ public Mono checkAvailableDevice() { String url = "http://36.138.184.60:1234/yijianwan_netfile/readAllMsg?文件名=判断分数"; log.debug("检查空闲设备: {}", url); return webClient.get() .uri(url) - .accept(MediaType.TEXT_PLAIN) + .accept(MediaType.APPLICATION_JSON) .retrieve() .bodyToMono(String.class) .timeout(Duration.ofSeconds(10)) .doOnSuccess(result -> log.debug("检查空闲设备成功: {}", result)) .doOnError(e -> log.warn("检查空闲设备失败: {}", e.toString())); } + + /** + * 检查空闲设备(解析后的结构化数据) + */ + public Mono checkAvailableDeviceStatus() { + return checkAvailableDevice() + .map(jsonResponse -> deviceStatusService.parseDeviceStatus(jsonResponse)) + .doOnSuccess(deviceStatus -> { + log.info("设备状态检查完成: 总设备数={}, 空闲设备数={}", + deviceStatus.getTotalDevices(), deviceStatus.getAvailableCount()); + if (deviceStatus.getAvailableCount() > 0) { + log.info("空闲设备列表: {}", deviceStatus.getAvailableDevices()); + } + }) + .doOnError(e -> log.error("设备状态解析失败: {}", e.getMessage(), e)); + } /** - * 选区操作 + * 选区操作 - 使用空闲设备编号 + * @param deviceId 空闲设备编号 (如 f1, ss9) + * @param region 区域参数 (Q或V) + * @return 选区结果 */ - public Mono 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() + public Mono selectRegion(String deviceId, String region) { + // 构建选区URL,使用设备编号作为参数名,区域作为参数值 + // 示例: http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断系统&f1=Q + String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断系统&%s=%s", deviceId, region); + log.info("选区操作: 设备={}, 区域={}, url={}", deviceId, region, url); + + return webClient.get() // 根据您的curl示例,这应该是GET请求 .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())); + .doOnSuccess(result -> { + log.info("选区操作成功: 设备={}, 区域={}, 结果={}", deviceId, region, result); + }) + .doOnError(e -> { + log.error("选区操作失败: 设备={}, 区域={}, 错误={}", deviceId, region, e.toString()); + }); } /** @@ -105,28 +139,64 @@ public class ScriptClient { } /** - * 检查上号状态 + * 检查设备是否已上号 - 根据您提供的API示例 + * URL格式: http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名=f1 + * 返回: "未上号" 或 其他状态 + * + * @param deviceId 设备编号 (真实设备编号,如 f1, ss9) + * @return 上号状态文本 */ - public Mono checkLoginStatus(String codeNo) { - String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名=%s", codeNo); - log.debug("检查上号状态: codeNo={}, url={}", codeNo, url); + public Mono checkLoginStatus(String deviceId) { + String url = String.format("http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名=%s", deviceId); + log.debug("检查设备上号状态: 设备={}, url={}", deviceId, 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())); + .timeout(Duration.ofSeconds(10)) + .doOnSuccess(result -> log.debug("检查设备上号状态成功: 设备={}, 状态={}", deviceId, result)) + .doOnError(e -> log.warn("检查设备上号状态失败: 设备={}, 错误={}", deviceId, e.toString())); } /** - * 获取二维码URL(带时间戳防缓存) + * 获取二维码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); } + + /** + * 获取代理二维码URL(通过本服务器代理,使用代理code隐藏真实设备编号) + */ + public String getProxyQrCodeUrl(String proxyCode) { + long timestamp = System.currentTimeMillis(); + return String.format("%s/api/link/qr/%s?t=%d", appBaseUrl, proxyCode, timestamp); + } + + /** + * 获取真实设备的二维码数据 + * @param deviceId 真实设备编号 (如 f1, ss9) + * @return 二维码图片数据 + */ + public Mono getDeviceQrCode(String deviceId) { + String path = String.format("/%s/二维码.png", deviceId); + log.debug("获取设备二维码: 设备={}, path={}", deviceId, path); + + return webClient.get() + .uri(path) + .accept(MediaType.IMAGE_PNG) + .retrieve() + .bodyToMono(byte[].class) + .timeout(Duration.ofSeconds(10)) + .doOnSuccess(data -> { + log.debug("获取设备二维码成功: 设备={}, 数据大小={}字节", deviceId, data.length); + }) + .doOnError(e -> { + log.error("获取设备二维码失败: 设备={}, 错误={}", deviceId, e.toString()); + }); + } /** * 获取目标分数 diff --git a/src/main/java/com/gameplatform/server/service/link/LinkStatusService.java b/src/main/java/com/gameplatform/server/service/link/LinkStatusService.java index c7ed86d..7ffcc86 100644 --- a/src/main/java/com/gameplatform/server/service/link/LinkStatusService.java +++ b/src/main/java/com/gameplatform/server/service/link/LinkStatusService.java @@ -2,13 +2,17 @@ package com.gameplatform.server.service.link; import com.gameplatform.server.mapper.agent.LinkBatchMapper; import com.gameplatform.server.mapper.agent.LinkTaskMapper; +import com.gameplatform.server.model.dto.device.DeviceStatusResponse; import com.gameplatform.server.model.dto.link.BatchDeleteResponse; import com.gameplatform.server.model.dto.link.LinkStatusResponse; +import com.gameplatform.server.model.dto.link.PollLoginResponse; +import com.gameplatform.server.model.dto.link.SelectRegionResponse; 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 com.gameplatform.server.service.device.DeviceCodeMappingService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -30,8 +34,8 @@ public class LinkStatusService { private final LinkTaskMapper linkTaskMapper; private final LinkBatchMapper linkBatchMapper; - private final ScriptClient scriptClient; + private final DeviceCodeMappingService deviceCodeMappingService; // 状态描述映射 private static final Map STATUS_DESC_MAP = new HashMap<>(); @@ -44,10 +48,11 @@ public class LinkStatusService { } public LinkStatusService(LinkTaskMapper linkTaskMapper, LinkBatchMapper linkBatchMapper, - ScriptClient scriptClient) { + ScriptClient scriptClient, DeviceCodeMappingService deviceCodeMappingService) { this.linkTaskMapper = linkTaskMapper; this.linkBatchMapper = linkBatchMapper; this.scriptClient = scriptClient; + this.deviceCodeMappingService = deviceCodeMappingService; } /** @@ -288,19 +293,23 @@ public class LinkStatusService { return response; } - // 3. 如果状态不是NEW,执行自动刷新逻辑 - if (!"NEW".equals(linkTask.getStatus())) { - log.info("链接状态不是NEW,执行自动刷新逻辑"); - performAutoRefresh(linkTask); - } - - // 4. 如果状态是USING,重新获取二维码 + // 3. 根据状态执行相应逻辑 if ("USING".equals(linkTask.getStatus())) { - log.info("链接状态是USING,重新获取二维码"); - updateQrCodeInfo(linkTask); + // 如果是USING状态,检查二维码是否过期,过期则刷新 + if (linkTask.getQrExpireAt() != null && linkTask.getQrExpireAt().isBefore(LocalDateTime.now())) { + log.info("二维码已过期,执行自动刷新重置选区状态"); + performAutoRefresh(linkTask); + } else { + // 二维码还未过期,更新二维码信息 + log.info("链接状态是USING,重新获取二维码"); + updateQrCodeInfo(linkTask); + } + } else if ("LOGGED_IN".equals(linkTask.getStatus()) || "REFUNDED".equals(linkTask.getStatus())) { + // 已上号或已退款状态,不需要刷新 + log.info("链接状态为 {},不需要刷新", linkTask.getStatus()); } - // 5. 构建响应 + // 4. 构建响应 UserLinkStatusResponse response = buildUserStatusResponse(linkTask); log.info("=== 用户端链接状态查询完成 ==="); log.info("返回状态: {}, view: {}", response.getStatus(), response.getView()); @@ -316,31 +325,41 @@ public class LinkStatusService { /** * 执行自动刷新逻辑 + * 刷新时会重置选区状态,让用户重新选择区域 */ private void performAutoRefresh(LinkTask linkTask) { try { - log.info("开始执行刷新操作"); + log.info("开始执行刷新操作,将重置选区状态"); - // 调用脚本端刷新 + // 1. 调用脚本端刷新 String refreshResult = scriptClient.refresh(linkTask.getCodeNo()).block(); log.info("脚本端刷新结果: {}", refreshResult); - // 更新刷新状态 - linkTask.setNeedRefresh(true); - linkTask.setRefreshTime(LocalDateTime.now()); - linkTask.setUpdatedAt(LocalDateTime.now()); + // 2. 重置选区状态,删除已有选区让用户重新选择 + log.info("重置选区状态: 从 {} 重置为 NEW", linkTask.getStatus()); + + LocalDateTime now = LocalDateTime.now(); + linkTask.setStatus("NEW"); // 重置状态为NEW,允许重新选区 + linkTask.setRegion(null); // 清空已选择的区域 + linkTask.setQrCreatedAt(null); // 清空二维码创建时间 + linkTask.setQrExpireAt(null); // 清空二维码过期时间 + linkTask.setNeedRefresh(true); // 标记为刷新状态 + linkTask.setRefreshTime(now); // 记录刷新时间 + linkTask.setUpdatedAt(now); // 更新修改时间 linkTaskMapper.update(linkTask); - // 等待10秒 - log.info("刷新完成,等待10秒..."); + log.info("选区状态重置完成: status=NEW, region=null, needRefresh=true"); + + // 3. 等待10秒后允许重新选区 + log.info("刷新完成,等待10秒后允许重新选区..."); Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.warn("等待被中断: {}", e.getMessage()); + log.warn("刷新等待被中断: {}", e.getMessage()); } catch (Exception e) { log.warn("执行刷新操作失败: {}", e.getMessage()); - // 刷新失败不影响后续流程 + // 刷新失败不影响后续流程,但记录错误日志 } } @@ -380,7 +399,7 @@ public class LinkStatusService { // 如果状态是USING,设置二维码信息 if ("USING".equals(linkTask.getStatus()) && linkTask.getQrCreatedAt() != null) { UserLinkStatusResponse.QrInfo qrInfo = new UserLinkStatusResponse.QrInfo(); - qrInfo.setUrl(scriptClient.getQrCodeUrl(linkTask.getCodeNo())); + qrInfo.setUrl(scriptClient.getProxyQrCodeUrl(linkTask.getCodeNo())); qrInfo.setCreatedAt(java.sql.Timestamp.valueOf(linkTask.getQrCreatedAt()).getTime()); if (linkTask.getQrExpireAt() != null) { qrInfo.setExpireAt(java.sql.Timestamp.valueOf(linkTask.getQrExpireAt()).getTime()); @@ -420,4 +439,231 @@ public class LinkStatusService { return "FIRST"; } } + + /** + * 选区操作 + */ + public Mono selectRegion(String code, String region) { + return Mono.fromCallable(() -> doSelectRegion(code, region)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private SelectRegionResponse doSelectRegion(String code, String region) { + log.info("=== 开始选区操作 ==="); + log.info("code: {}, region: {}", code, region); + + try { + // 1. 验证参数 + if (code == null || code.trim().isEmpty()) { + throw new IllegalArgumentException("code不能为空"); + } + if (region == null || (!region.equals("Q") && !region.equals("V"))) { + throw new IllegalArgumentException("region只能是Q或V"); + } + + // 2. 查询链接任务 + LinkTask linkTask = linkTaskMapper.findByCodeNo(code.trim()); + if (linkTask == null) { + log.error("链接任务不存在: code={}", code); + throw new IllegalArgumentException("链接不存在"); + } + + log.info("查询到链接任务: id={}, codeNo={}, status={}, needRefresh={}", + linkTask.getId(), linkTask.getCodeNo(), linkTask.getStatus(), linkTask.getNeedRefresh()); + + // 3. 检查链接状态,只有NEW状态才能选区 + if (!"NEW".equals(linkTask.getStatus())) { + log.error("链接状态不正确,无法选区: status={}", linkTask.getStatus()); + throw new IllegalArgumentException("链接状态不正确,只有新建状态的链接才能选区"); + } + + // 4. 检查链接是否过期 + if (linkTask.getExpireAt() != null && linkTask.getExpireAt().isBefore(LocalDateTime.now())) { + log.error("链接已过期: expireAt={}", linkTask.getExpireAt()); + throw new IllegalArgumentException("链接已过期"); + } + + // 5. 如果need_refresh=true,检查是否已等待10秒 + if (Boolean.TRUE.equals(linkTask.getNeedRefresh()) && linkTask.getRefreshTime() != null) { + LocalDateTime now = LocalDateTime.now(); + long secondsSinceRefresh = ChronoUnit.SECONDS.between(linkTask.getRefreshTime(), now); + if (secondsSinceRefresh < 10) { + long waitTime = 10 - secondsSinceRefresh; + log.error("刷新后需要等待,剩余等待时间: {}秒", waitTime); + SelectRegionResponse response = new SelectRegionResponse(false, "刷新后需要等待" + waitTime + "秒才能选区"); + return response; + } + } + + // 6. 检查空闲设备 + log.info("开始检查空闲设备"); + DeviceStatusResponse deviceStatus = scriptClient.checkAvailableDeviceStatus().block(); + + // 检查是否有空闲设备 + if (deviceStatus.getAvailableCount() == 0) { + log.warn("当前没有空闲设备,无法选择区域"); + throw new RuntimeException("当前没有空闲设备,请稍后再试"); + } + + log.info("空闲设备检查完成: 总设备数={}, 空闲设备数={}, 空闲设备列表={}", + deviceStatus.getTotalDevices(), deviceStatus.getAvailableCount(), deviceStatus.getAvailableDevices()); + + // 7. 选择一个空闲设备 + String selectedDevice = deviceStatus.getAvailableDevices().get(0); // 选择第一个空闲设备 + log.info("选择设备: {}", selectedDevice); + + // 8. 为选中的设备创建代理code + String proxyCode = deviceCodeMappingService.createProxyCode(selectedDevice); + log.info("为设备 {} 创建代理code: {}", selectedDevice, proxyCode); + + // 9. 调用脚本端选区,使用选中的设备 + log.info("开始调用脚本端选区,设备={}, 区域={}", selectedDevice, region); + String selectResult = scriptClient.selectRegion(selectedDevice, region).block(); + log.info("脚本端选区结果: {}", selectResult); + + // 10. 等待脚本端生成二维码(这里可以添加轮询逻辑) + log.info("等待脚本端生成二维码,等待3秒..."); + Thread.sleep(3000); + + // 11. 更新数据库状态为USING,保存设备信息和代理code + LocalDateTime now = LocalDateTime.now(); + linkTask.setStatus("USING"); + linkTask.setRegion(region); + linkTask.setCodeNo(proxyCode); // 使用代理code替换原来的codeNo + linkTask.setQrCreatedAt(now); + linkTask.setQrExpireAt(now.plusSeconds(60)); // 60秒后过期 + linkTask.setNeedRefresh(false); + linkTask.setUpdatedAt(now); + // 在machineId字段保存真实设备编号,便于调试和维护 + linkTask.setMachineId(selectedDevice); + linkTaskMapper.update(linkTask); + + log.info("链接状态更新成功: status=USING, region={}, proxyCode={}, device={}", region, proxyCode, selectedDevice); + + // 12. 构建响应 + SelectRegionResponse response = new SelectRegionResponse(true, "选区成功"); + response.setQrCodeUrl(scriptClient.getProxyQrCodeUrl(proxyCode)); + response.setQrCreatedAt(now); + response.setQrExpireAt(linkTask.getQrExpireAt()); + response.setStatus("USING"); + response.setRegion(region); + response.setQrDelaySeconds(5); // 客户端收到响应后,等待5秒再请求二维码 + + log.info("=== 选区操作完成 ==="); + log.info("二维码URL: {}", response.getQrCodeUrl()); + + return response; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("选区操作被中断: {}", e.getMessage()); + throw new RuntimeException("选区操作被中断", e); + } catch (Exception e) { + log.error("=== 选区操作失败 ==="); + log.error("错误详情: {}", e.getMessage(), e); + throw e; + } + } + + /** + * 轮询上号接口 + */ + public Mono pollLogin(String code) { + log.info("=== 开始轮询上号 ==="); + log.info("code: {}", code); + + return Mono.fromCallable(() -> { + // 1. 验证code参数 + if (code == null || code.trim().isEmpty()) { + throw new IllegalArgumentException("参数错误:code不能为空"); + } + + // 2. 获取链接任务 + LinkTask linkTask = linkTaskMapper.findByCodeNo(code); + if (linkTask == null) { + throw new IllegalArgumentException("链接不存在"); + } + + log.info("找到链接任务: id={}, status={}, codeNo={}", + linkTask.getId(), linkTask.getStatus(), linkTask.getCodeNo()); + + // 3. 检查链接状态,只有USING状态才能轮询 + if (!"USING".equals(linkTask.getStatus())) { + log.warn("链接状态不是USING,当前状态: {}", linkTask.getStatus()); + return new PollLoginResponse(false, linkTask.getStatus()); + } + + return linkTask; + }) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(result -> { + if (result instanceof PollLoginResponse) { + // 如果已经是响应对象,直接返回 + return Mono.just((PollLoginResponse) result); + } + + LinkTask linkTask = (LinkTask) result; + + // 4. 获取真实设备编号 - 从代理code映射中获取 + final String realDeviceId; + if (linkTask.getMachineId() != null) { + // 如果已保存了设备编号,直接使用 + realDeviceId = linkTask.getMachineId(); + } else if (linkTask.getCodeNo() != null) { + // 如果没有设备编号,尝试从代理code获取 + realDeviceId = deviceCodeMappingService.getDeviceId(linkTask.getCodeNo()); + } else { + realDeviceId = null; + } + + if (realDeviceId == null) { + log.error("无法获取设备编号: codeNo={}, machineId={}", linkTask.getCodeNo(), linkTask.getMachineId()); + throw new RuntimeException("设备信息异常,无法检查上号状态"); + } + + // 5. 调用脚本端检查上号状态,使用真实设备编号 + log.info("调用脚本端检查上号状态: 代理code={}, 真实设备={}", linkTask.getCodeNo(), realDeviceId); + return scriptClient.checkLoginStatus(realDeviceId) + .map(loginResult -> { + log.info("脚本端返回结果: {}", loginResult); + + // 6. 如果返回"已上号",更新状态为LOGGED_IN + if ("已上号".equals(loginResult) || "已登录".equals(loginResult)) { + log.info("检测到已上号,更新状态为LOGGED_IN"); + + // 更新数据库状态 + linkTask.setStatus("LOGGED_IN"); + linkTask.setUpdatedAt(LocalDateTime.now()); + linkTaskMapper.updateById(linkTask); + + log.info("状态更新完成: codeNo={}, status=LOGGED_IN", linkTask.getCodeNo()); + + // 7. 返回成功响应和资源信息,使用真实设备编号构建资源链接 + PollLoginResponse response = new PollLoginResponse(true, "LOGGED_IN", "SECOND", + new PollLoginResponse.AssetsInfo( + String.format("http://36.138.184.60:12345/%s/", realDeviceId) + )); + + log.info("=== 轮询上号成功 ==="); + return response; + } else { + // 未上号,返回当前状态 + log.debug("尚未上号,返回当前状态"); + return new PollLoginResponse(false, "USING"); + } + }) + .onErrorResume(error -> { + log.warn("调用脚本端检查上号状态失败: codeNo={}, error={}", + linkTask.getCodeNo(), error.getMessage()); + // 脚本端出错时,返回当前状态,不影响轮询 + return Mono.just(new PollLoginResponse(false, "USING")); + }); + }) + .doOnSuccess(response -> { + log.info("轮询上号完成: success={}, status={}", response.isSuccess(), response.getStatus()); + }) + .doOnError(error -> { + log.error("轮询上号失败: code={}, error={}", code, error.getMessage(), error); + }); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 178cf68..2fb96db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -67,5 +67,9 @@ script: connect-timeout-ms: 3000 read-timeout-ms: 5000 +# 服务器配置 +app: + base-url: "http://localhost:18080" # 生产环境需要配置为实际域名 + link: expire-hours: 2 diff --git a/test_select_region.http b/test_select_region.http new file mode 100644 index 0000000..84e317e --- /dev/null +++ b/test_select_region.http @@ -0,0 +1,48 @@ +### 测试选区接口 + +# 测试选区API - 选择Q区 +POST http://localhost:8080/api/link/select-region +Content-Type: application/json + +{ + "code": "66L8NM3L", + "region": "Q" +} + +### + +# 测试选区API - 选择V区 +POST http://localhost:8080/api/link/select-region +Content-Type: application/json + +{ + "code": "66L8NM3L", + "region": "V" +} + +### + +# 测试链接状态API +GET http://localhost:8080/api/link/status?code=66L8NM3L + +### + +# 测试参数验证 - 无效region +POST http://localhost:8080/api/link/select-region +Content-Type: application/json + +{ + "code": "66L8NM3L", + "region": "X" +} + +### + +# 测试参数验证 - 空code +POST http://localhost:8080/api/link/select-region +Content-Type: application/json + +{ + "code": "", + "region": "Q" +}