From b14573bb8884af1bbf28f632928e8a7bc2ea6a1b Mon Sep 17 00:00:00 2001 From: yahaozhang Date: Tue, 16 Sep 2025 01:21:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=8D=87=E7=BA=A7Java=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=87=B321=EF=BC=8C=E6=B7=BB=E5=8A=A0Lombok=E6=B3=A8?= =?UTF-8?q?=E8=A7=A3=E5=A4=84=E7=90=86=E5=99=A8=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=8A=B6=E6=80=81=E8=A7=A3=E6=9E=90=E5=92=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/compiler.xml | 8 + .idea/misc.xml | 2 +- device_cleanup_test.sql | 43 +++ pom.xml | 9 +- .../gameplatform/server/device/Detection.java | 209 ++++++++++++ .../server/device/DeviceStats.java | 304 ++++++++++++++++++ .../server/mapper/agent/LinkTaskMapper.java | 6 + .../history/DeviceStatusTransitionMapper.java | 25 ++ .../history/DeviceStatusTransition.java | 67 ++++ .../GameCompletionDetectionService.java | 2 +- .../service/device/DeviceStatusService.java | 57 ++-- .../server/service/external/ScriptClient.java | 63 +++- .../service/link/DeviceTaskUpdateService.java | 74 ++++- .../service/link/LinkStatusService.java | 23 +- .../server/task/DeviceStatusCheckTask.java | 2 +- .../DeviceStatusTransitionCleanupTask.java | 60 ++++ .../task/MachineCooldownCleanupTask.java | 2 +- .../server/task/UsingLinkCheckTask.java | 2 +- src/main/resources/application.yml | 42 ++- ...50915__create_device_status_transition.sql | 17 + .../resources/mapper/agent/LinkTaskMapper.xml | 8 + .../history/DeviceStatusTransitionMapper.xml | 36 +++ 22 files changed, 988 insertions(+), 73 deletions(-) create mode 100644 device_cleanup_test.sql create mode 100644 src/main/java/com/gameplatform/server/device/Detection.java create mode 100644 src/main/java/com/gameplatform/server/device/DeviceStats.java create mode 100644 src/main/java/com/gameplatform/server/mapper/history/DeviceStatusTransitionMapper.java create mode 100644 src/main/java/com/gameplatform/server/model/entity/history/DeviceStatusTransition.java create mode 100644 src/main/java/com/gameplatform/server/task/DeviceStatusTransitionCleanupTask.java create mode 100644 src/main/resources/db/migration/V20250915__create_device_status_transition.sql create mode 100644 src/main/resources/mapper/history/DeviceStatusTransitionMapper.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml index cc55540..7488d91 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -7,6 +7,14 @@ + + + + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml index baa5bf6..2701299 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -8,7 +8,7 @@ - + \ No newline at end of file diff --git a/device_cleanup_test.sql b/device_cleanup_test.sql new file mode 100644 index 0000000..aa479ea --- /dev/null +++ b/device_cleanup_test.sql @@ -0,0 +1,43 @@ +-- 设备状态变更记录清理测试脚本 +-- 用于验证24小时清理功能 + +-- 1. 检查表结构 +SELECT 'Table Structure:' as info; +DESC device_status_transition; + +-- 2. 查看当前记录数 +SELECT 'Current Records Count:' as info; +SELECT COUNT(*) as total_records FROM device_status_transition; + +-- 3. 查看24小时前的记录数(将被清理的记录) +SELECT '24+ Hours Old Records (to be cleaned):' as info; +SELECT COUNT(*) as old_records +FROM device_status_transition +WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR); + +-- 4. 查看最近24小时的记录数(将被保留的记录) +SELECT 'Recent 24 Hours Records (to be kept):' as info; +SELECT COUNT(*) as recent_records +FROM device_status_transition +WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR); + +-- 5. 查看记录的时间分布 +SELECT 'Records Distribution:' as info; +SELECT + DATE(created_at) as date, + COUNT(*) as count +FROM device_status_transition +GROUP BY DATE(created_at) +ORDER BY date DESC +LIMIT 10; + +-- 6. 预览将被删除的记录(最多显示5条) +SELECT 'Sample Records to be Deleted:' as info; +SELECT device_id, prev_status, new_status, created_at +FROM device_status_transition +WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR) +ORDER BY created_at DESC +LIMIT 5; + +-- 测试删除语句(注释掉,仅用于验证语法) +-- DELETE FROM device_status_transition WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR); diff --git a/pom.xml b/pom.xml index 72dc917..413d2ab 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ Spring Boot WebFlux + MyBatis + MySQL backend - 17 + 21 3.5.8 @@ -135,6 +135,13 @@ -parameters + + + org.projectlombok + lombok + 1.18.32 + + diff --git a/src/main/java/com/gameplatform/server/device/Detection.java b/src/main/java/com/gameplatform/server/device/Detection.java new file mode 100644 index 0000000..36cbcca --- /dev/null +++ b/src/main/java/com/gameplatform/server/device/Detection.java @@ -0,0 +1,209 @@ +package com.gameplatform.server.device; + +import com.gameplatform.server.model.dto.device.DeviceStatusResponse; +import com.gameplatform.server.service.external.ScriptClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 检测模块:封装与脚本端交互的设备状态读取能力。 + * - 提供全量快照与单设备状态两种接口 + * - 同时提供异步(Mono)与阻塞(block)调用 + * - 内置短TTL缓存,降低高并发下对脚本端的压力 + */ +@Service +public class Detection { + private static final Logger log = LoggerFactory.getLogger(Detection.class); + + private final ScriptClient scriptClient; + private final DeviceStats deviceStats; + + // 缓存配置(可通过 application.yml 覆盖) + private final long listAllCacheTtlMs; + private final long readOneCacheTtlMs; + + // 全量快照缓存 + private volatile DeviceStatusResponse lastSnapshot; + private volatile long lastSnapshotAtMs = 0L; + + // 单设备缓存 + private final ConcurrentHashMap deviceCache = new ConcurrentHashMap<>(); + + public Detection( + ScriptClient scriptClient, + DeviceStats deviceStats, + @Value("${detection.listAll.cacheTtlMs:500}") long listAllCacheTtlMs, + @Value("${detection.readOne.cacheTtlMs:200}") long readOneCacheTtlMs + ) { + this.scriptClient = scriptClient; + this.deviceStats = deviceStats; + this.listAllCacheTtlMs = listAllCacheTtlMs; + this.readOneCacheTtlMs = readOneCacheTtlMs; + } + + /** + * 异步获取全量设备快照(带TTL缓存)。 + */ + public Mono listAllDevicesAsync() { + DeviceStatusResponse cached = getFreshSnapshot(); + if (cached != null) { + if (log.isDebugEnabled()) { + log.debug("listAllDevicesAsync use cache: availableCount={}, total={}", + cached.getAvailableCount(), cached.getTotalDevices()); + } + return Mono.just(cached); + } + return scriptClient.checkAvailableDeviceStatus() + .doOnSuccess(resp -> { + updateSnapshotCache(resp); + if (resp != null && log.isDebugEnabled()) { + log.debug("listAllDevicesAsync fetched fresh: availableCount={}, total={}", + resp.getAvailableCount(), resp.getTotalDevices()); + } + }); + } + + /** + * 阻塞获取全量设备快照(带TTL缓存)。 + */ + public DeviceStatusResponse listAllDevices() { + long t0 = System.currentTimeMillis(); + DeviceStatusResponse cached = getFreshSnapshot(); + if (cached != null) { + long elapsed = System.currentTimeMillis() - t0; + if (log.isDebugEnabled()) { + log.debug("listAllDevices use cache in {} ms: availableCount={}, total={}", + elapsed, cached.getAvailableCount(), cached.getTotalDevices()); + } + return cached; + } + + long tFetchStart = System.currentTimeMillis(); + DeviceStatusResponse resp = scriptClient.checkAvailableDeviceStatus().block(); + long tFetchEnd = System.currentTimeMillis(); + + long tCacheStart = tFetchEnd; + updateSnapshotCache(resp); + long tCacheEnd = System.currentTimeMillis(); + + if (resp != null && log.isInfoEnabled()) { + long total = tCacheEnd - t0; + long fetch = tFetchEnd - tFetchStart; + long cacheWrite = tCacheEnd - tCacheStart; + log.info("listAllDevices fetched fresh in {} ms (http+parse={} ms, cacheWrite={} ms): availableCount={}, total={}", + total, fetch, cacheWrite, resp.getAvailableCount(), resp.getTotalDevices()); + } + return resp; + } + + /** + * 定时任务:全量拉取并交由 DeviceStats 更新内存分类与审计。 + * 默认每 30 秒执行一次,可通过配置覆盖:detection.poll.cron 或 detection.poll.fixedDelayMs + */ + @Scheduled(fixedRate = 30000) + public void pollAndUpdateDeviceStats() { + try { + log.info("定时拉取设备快照并更新统计"); + DeviceStatusResponse snapshot = listAllDevices(); + if (snapshot != null) { + deviceStats.updateWithSnapshot(snapshot); + log.info("设备快照更新统计完成"); + } + } catch (Exception e) { + log.error("定时拉取设备快照并更新统计失败", e); + } + } + + /** + * 异步读取单个设备状态(包含 f0/f1 等信息,带短TTL缓存)。 + */ + public Mono> readDeviceAsync(String machineId) { + Map cached = getFreshDevice(machineId); + if (cached != null) { + if (log.isDebugEnabled()) { + log.debug("readDeviceAsync use cache: machineId={}", machineId); + } + return Mono.just(cached); + } + return scriptClient.getDeviceStatus(machineId) + .doOnSuccess(map -> { + updateDeviceCache(machineId, map); + if (log.isDebugEnabled()) { + log.debug("readDeviceAsync fetched fresh: machineId={}", machineId); + } + }); + } + + /** + * 阻塞读取单个设备状态(包含 f0/f1 等信息,带短TTL缓存)。 + */ + public Map readDevice(String machineId) { + Map cached = getFreshDevice(machineId); + if (cached != null) { + if (log.isDebugEnabled()) { + log.debug("readDevice use cache: machineId={}", machineId); + } + return cached; + } + Map map = scriptClient.getDeviceStatus(machineId).block(); + updateDeviceCache(machineId, map); + if (log.isDebugEnabled()) { + log.debug("readDevice fetched fresh: machineId={}", machineId); + } + return map; + } + + // ---------------------- 内部:缓存管理 ---------------------- + + private DeviceStatusResponse getFreshSnapshot() { + DeviceStatusResponse snapshot = this.lastSnapshot; + long now = System.currentTimeMillis(); + if (snapshot != null && (now - lastSnapshotAtMs) < listAllCacheTtlMs) { + return snapshot; + } + return null; + } + + private void updateSnapshotCache(DeviceStatusResponse snapshot) { + if (snapshot == null) { + return; + } + this.lastSnapshot = snapshot; + this.lastSnapshotAtMs = System.currentTimeMillis(); + } + + private Map getFreshDevice(String machineId) { + DeviceEntry entry = deviceCache.get(machineId); + if (entry == null) { + return null; + } + long now = System.currentTimeMillis(); + if ((now - entry.cachedAtMs) < readOneCacheTtlMs) { + return entry.status; + } + return null; + } + + private void updateDeviceCache(String machineId, Map status) { + if (machineId == null || status == null) { + return; + } + deviceCache.put(machineId, new DeviceEntry(status, System.currentTimeMillis())); + } + + private static final class DeviceEntry { + final Map status; + final long cachedAtMs; + DeviceEntry(Map status, long cachedAtMs) { + this.status = status; + this.cachedAtMs = cachedAtMs; + } + } +} diff --git a/src/main/java/com/gameplatform/server/device/DeviceStats.java b/src/main/java/com/gameplatform/server/device/DeviceStats.java new file mode 100644 index 0000000..1594276 --- /dev/null +++ b/src/main/java/com/gameplatform/server/device/DeviceStats.java @@ -0,0 +1,304 @@ +package com.gameplatform.server.device; + +import com.gameplatform.server.model.dto.device.DeviceStatusResponse; +import com.gameplatform.server.model.entity.agent.LinkTask; +import com.gameplatform.server.model.entity.history.DeviceStatusTransition; +import com.gameplatform.server.service.cooldown.MemoryMachineCooldownService; +import com.gameplatform.server.service.link.DeviceAllocationService; +import com.gameplatform.server.mapper.agent.LinkTaskMapper; +import com.gameplatform.server.mapper.history.DeviceStatusTransitionMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 设备状态分组统计: + * - RUNNING:已上号(LOGGED_IN 任务或脚本返回“已运行”) + * - USING:登录中/选区中(USING 任务或脚本返回数字/“正在登录中”) + * - IDLE_COOLDOWN:处于冷却或脚本返回“已打完”(刚完成) + * - IDLE_FREE:脚本返回“空的/空闲”或其他均视为空闲可用 + */ +@Service +public class DeviceStats { + // 设备分组统计日志记录器 + private static final Logger log = LoggerFactory.getLogger(DeviceStats.class); + // 审计专用日志(输出到独立文件) + private static final Logger auditLog = LoggerFactory.getLogger("com.gameplatform.server.audit"); + + public enum Category { + RUNNING, + USING, + IDLE_COOLDOWN, + IDLE_FREE + } + + public static final class Snapshot { + private final Map> categoryToDevices; + private final int totalDevices; + private final int runningCount; + private final int usingCount; + private final int idleCooldownCount; + private final int idleFreeCount; + + public Snapshot(Map> categoryToDevices) { + this.categoryToDevices = categoryToDevices; + this.totalDevices = categoryToDevices.values().stream().mapToInt(List::size).sum(); + this.runningCount = categoryToDevices.getOrDefault(Category.RUNNING, List.of()).size(); + this.usingCount = categoryToDevices.getOrDefault(Category.USING, List.of()).size(); + this.idleCooldownCount = categoryToDevices.getOrDefault(Category.IDLE_COOLDOWN, List.of()).size(); + this.idleFreeCount = categoryToDevices.getOrDefault(Category.IDLE_FREE, List.of()).size(); + } + + public Map> getCategoryToDevices() { return categoryToDevices; } + public int getTotalDevices() { return totalDevices; } + public int getRunningCount() { return runningCount; } + public int getUsingCount() { return usingCount; } + public int getIdleCooldownCount() { return idleCooldownCount; } + public int getIdleFreeCount() { return idleFreeCount; } + } + + private final LinkTaskMapper linkTaskMapper; + private final MemoryMachineCooldownService cooldownService; + private final DeviceStatusTransitionMapper transitionMapper; + private final DeviceAllocationService deviceAllocationService; + + // 记录上一次统计时每台设备的分类结果,用于检测状态变更 + private final Map lastStatusByDevice = new ConcurrentHashMap<>(); + + public DeviceStats(LinkTaskMapper linkTaskMapper, + MemoryMachineCooldownService cooldownService, + DeviceStatusTransitionMapper transitionMapper, + DeviceAllocationService deviceAllocationService) { + this.linkTaskMapper = linkTaskMapper; + this.cooldownService = cooldownService; + this.transitionMapper = transitionMapper; + this.deviceAllocationService = deviceAllocationService; + } + + /** + * 根据外部传入的全量设备快照进行分组统计,并更新内存中的状态与变更记录。 + * 分类优先级(高→低): + * 1) RUNNING:存在 LOGGED_IN 任务 或 脚本返回“已运行” + * 2) USING:存在 USING 任务 或 脚本返回“正在登录中/数字(进度/积分)” + * 3) IDLE_COOLDOWN:脚本返回“已打完” 或 处于冷却服务中 + * 4) IDLE_FREE:其余所有(包括脚本返回“空的/空闲/空字符串/未知”) + */ + public Snapshot updateWithSnapshot(DeviceStatusResponse snapshot) { + Map devices = snapshot != null ? snapshot.getDevices() : Collections.emptyMap(); + if (devices == null) { + devices = Collections.emptyMap(); + } + log.info("接收设备快照:设备总数={}", devices.size()); + + // 初始化分类容器 + Map> bucket = new EnumMap<>(Category.class); + for (Category c : Category.values()) { + bucket.put(c, new ArrayList<>()); + } + + for (String deviceId : devices.keySet()) { + DeviceStatusResponse.DeviceInfo info = devices.get(deviceId); + String val = info != null ? info.getVal() : null; + String v = val != null ? val.trim() : null; + + // 预先计算判定条件,便于统一记录日志 + boolean loggedIn = hasLoggedInTask(deviceId); + boolean usingTask = hasUsingTask(deviceId); + boolean cooldown = cooldownService.isMachineInCooldown(deviceId); + boolean numeric = isNumeric(v); + +// log.debug("设备[{}] 原始脚本值='{}' | LOGGED_IN={} USING={} COOLDOWN={} NUMERIC={}", +// deviceId, v, loggedIn, usingTask, cooldown, numeric); + + // 分类与原因 + Category newCategory; + String reason; + if (loggedIn || "已运行".equals(v)) { + newCategory = Category.RUNNING; + reason = loggedIn ? "存在LOGGED_IN任务" : "脚本值=已运行"; + } else if (usingTask || "正在登录中".equals(v) || numeric) { + newCategory = Category.USING; + reason = usingTask ? "存在USING任务" : ("正在登录中".equals(v) ? "脚本值=正在登录中" : "脚本值为数字"); + } else if ("已打完".equals(v) || cooldown) { + newCategory = Category.IDLE_COOLDOWN; + reason = "已打完".equals(v) ? "脚本值=已打完" : "处于冷却服务中"; + } else { + newCategory = Category.RUNNING; + reason = "默认运行中/未知状态"; + } + + // 放入桶 + bucket.get(newCategory).add(deviceId); +// log.debug("设备[{}] 归类为 {},原因={}", deviceId, newCategory, reason); + + // 检测并记录状态变更 + Category prevCategory = lastStatusByDevice.get(deviceId); + boolean changed = prevCategory != null && prevCategory != newCategory; + if (changed) { + LocalDateTime occurredAt = parseTimeOrNow(info != null ? info.getTime() : null); + String series = info != null ? info.getSeries() : null; + Integer indexNo = info != null ? info.getIndex() : null; + + // 审计日志(独立文件) + auditLog.info("device={} prev={} next={} reason={} val='{}' at={}", + deviceId, + prevCategory != null ? prevCategory.name() : null, + newCategory.name(), + reason, + v, + occurredAt); + + // 入库(仅状态变化时) + DeviceStatusTransition t = new DeviceStatusTransition( + deviceId, + prevCategory != null ? prevCategory.name() : null, + newCategory.name(), + reason, + v, + series, + indexNo, + occurredAt + ); + try { + transitionMapper.insert(t); + } catch (Exception e) { + log.error("写入设备状态变更失败 device={} prev={} next={} err={}", + deviceId, + prevCategory != null ? prevCategory.name() : null, + newCategory.name(), + e.getMessage(), e); + } + } + + // 无论是否变化,都更新内存快照为最新分类 + lastStatusByDevice.put(deviceId, newCategory); + } + + Snapshot result = new Snapshot(bucket); + log.info("设备分组统计完成:total={} running={} using={} idleCooldown={} idleFree={}", + result.getTotalDevices(), result.getRunningCount(), result.getUsingCount(), + result.getIdleCooldownCount(), result.getIdleFreeCount()); + this.lastComputedSnapshot = result; + return result; + } + + // 最近一次分组统计结果(供查询展示) + private volatile Snapshot lastComputedSnapshot; + + public Snapshot getLastComputedSnapshot() { + return lastComputedSnapshot; + } + + /** + * 基于传入的快照进行设备分配,并立即将该设备的分组状态切换为 USING(内存与审计)。 + * 注意:数据库中 LinkTask 的状态更新与原子占用仍由上层调用完成(reserveDeviceIfFree)。 + * 这里仅完成: + * 1) 使用 DeviceAllocationService 基于快照可用列表进行原子分配(冷却+并发安全) + * 2) 如分配成功,在本地分类快照中将该设备标记为 USING,并记录 DeviceStatusTransition 审计 + * + * @param snapshot 全量设备快照 + * @param linkTaskId 链接任务ID + * @param reason 分配原因(审计用途) + * @return 分配成功的设备ID;若无可分配设备则返回 null + */ + public String allocateFromSnapshot(DeviceStatusResponse snapshot, Long linkTaskId, String reason) { + if (snapshot == null || snapshot.getAvailableDevices() == null || snapshot.getAvailableDevices().isEmpty()) { + log.warn("allocateFromSnapshot: 无可用设备,跳过分配"); + return null; + } + + // 通过分配服务进行原子分配(含冷却与并发控制) + String selectedDeviceId = deviceAllocationService.allocateDevice(snapshot.getAvailableDevices(), linkTaskId, reason); + if (selectedDeviceId == null) { + log.warn("allocateFromSnapshot: 原子分配失败(可能在冷却或被占用),linkTaskId={}", linkTaskId); + return null; + } + + // 审计与内存分类:将该设备在本地快照中标记为 USING + try { + DeviceStatusResponse.DeviceInfo info = null; + if (snapshot.getDevices() != null) { + info = snapshot.getDevices().get(selectedDeviceId); + } + + Category prevCategory = lastStatusByDevice.get(selectedDeviceId); + Category newCategory = Category.USING; + boolean changed = prevCategory == null || prevCategory != newCategory; + + if (changed) { + LocalDateTime occurredAt = parseTimeOrNow(info != null ? info.getTime() : null); + String series = info != null ? info.getSeries() : null; + Integer indexNo = info != null ? info.getIndex() : null; + String snapshotVal = info != null ? info.getVal() : null; + + // 写入设备状态变更历史 + DeviceStatusTransition t = new DeviceStatusTransition( + selectedDeviceId, + prevCategory != null ? prevCategory.name() : null, + newCategory.name(), + reason != null ? ("ALLOCATE:" + reason) : "ALLOCATE", + snapshotVal, + series, + indexNo, + occurredAt + ); + try { + transitionMapper.insert(t); + } catch (Exception e) { + log.error("写入设备状态变更(ALLOCATE)失败 device={} prev={} next={} err={}", + selectedDeviceId, + prevCategory != null ? prevCategory.name() : null, + newCategory.name(), + e.getMessage(), e); + } + } + + lastStatusByDevice.put(selectedDeviceId, Category.USING); + log.info("allocateFromSnapshot: 设备分配并标记USING完成 device={} linkTaskId={}", selectedDeviceId, linkTaskId); + } catch (Exception e) { + log.warn("allocateFromSnapshot: 标记内存分类为USING时发生异常 device={} err={}", selectedDeviceId, e.getMessage()); + } + + return selectedDeviceId; + } + + private boolean hasLoggedInTask(String deviceId) { + List loggedInTasks = linkTaskMapper.findByMachineIdAndStatus(deviceId, "LOGGED_IN"); + return loggedInTasks != null && !loggedInTasks.isEmpty(); + } + + private boolean hasUsingTask(String deviceId) { + List usingTasks = linkTaskMapper.findByMachineIdAndStatus(deviceId, "USING"); + return usingTasks != null && !usingTasks.isEmpty(); + } + + private static boolean isNumeric(String text) { + if (text == null) return false; + int len = text.length(); + if (len == 0) return false; + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + if (c < '0' || c > '9') return false; + } + return true; + } + + private static LocalDateTime parseTimeOrNow(String timeStr) { + if (timeStr == null || timeStr.isEmpty()) { + return LocalDateTime.now(); + } + // 常见格式:yyyy-MM-dd HH:mm:ss + try { + return LocalDateTime.parse(timeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } catch (DateTimeParseException e) { + // 回退当前时间 + return LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/gameplatform/server/mapper/agent/LinkTaskMapper.java b/src/main/java/com/gameplatform/server/mapper/agent/LinkTaskMapper.java index 48ac159..42f35be 100644 --- a/src/main/java/com/gameplatform/server/mapper/agent/LinkTaskMapper.java +++ b/src/main/java/com/gameplatform/server/mapper/agent/LinkTaskMapper.java @@ -27,6 +27,12 @@ public interface LinkTaskMapper extends BaseMapper { @Param("region") String region, @Param("machineId") String machineId, @Param("loginAt") LocalDateTime loginAt); + + /** + * 仅当新的点数更大时才更新 completed_points,避免不必要写锁 + */ + int updatePointsIfGreater(@Param("id") Long id, + @Param("newPoints") Integer newPoints); List findByAgentId(@Param("agentId") Long agentId, @Param("size") int size, diff --git a/src/main/java/com/gameplatform/server/mapper/history/DeviceStatusTransitionMapper.java b/src/main/java/com/gameplatform/server/mapper/history/DeviceStatusTransitionMapper.java new file mode 100644 index 0000000..870dfcb --- /dev/null +++ b/src/main/java/com/gameplatform/server/mapper/history/DeviceStatusTransitionMapper.java @@ -0,0 +1,25 @@ +package com.gameplatform.server.mapper.history; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gameplatform.server.model.entity.history.DeviceStatusTransition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface DeviceStatusTransitionMapper extends BaseMapper { + + /** + * 查询指定设备的最近状态变更记录 + */ + List findRecentByDeviceId(@Param("deviceId") String deviceId, @Param("size") int size); + + /** + * 删除24小时前的历史记录 + * @return 删除的记录数 + */ + int deleteOldRecords(); +} + + diff --git a/src/main/java/com/gameplatform/server/model/entity/history/DeviceStatusTransition.java b/src/main/java/com/gameplatform/server/model/entity/history/DeviceStatusTransition.java new file mode 100644 index 0000000..78d79c7 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/entity/history/DeviceStatusTransition.java @@ -0,0 +1,67 @@ +package com.gameplatform.server.model.entity.history; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +@TableName("device_status_transition") +public class DeviceStatusTransition { + @TableId(type = IdType.AUTO) + private Long id; + + private String deviceId; + private String prevStatus; // 例如 RUNNING/USING/IDLE_COOLDOWN/IDLE_FREE + private String newStatus; // 同上 + private String reason; // 变更原因(来源数据) + private String snapshotVal; // 当次脚本原始值 + private String series; // 设备系列 + private Integer indexNo; // 设备序号 + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime occurredAt; // 发生时间 + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; // 记录创建时间 + + public DeviceStatusTransition() {} + + public DeviceStatusTransition(String deviceId, String prevStatus, String newStatus, String reason, + String snapshotVal, String series, Integer indexNo, + LocalDateTime occurredAt) { + this.deviceId = deviceId; + this.prevStatus = prevStatus; + this.newStatus = newStatus; + this.reason = reason; + this.snapshotVal = snapshotVal; + this.series = series; + this.indexNo = indexNo; + this.occurredAt = occurredAt; + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getDeviceId() { return deviceId; } + public void setDeviceId(String deviceId) { this.deviceId = deviceId; } + public String getPrevStatus() { return prevStatus; } + public void setPrevStatus(String prevStatus) { this.prevStatus = prevStatus; } + public String getNewStatus() { return newStatus; } + public void setNewStatus(String newStatus) { this.newStatus = newStatus; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public String getSnapshotVal() { return snapshotVal; } + public void setSnapshotVal(String snapshotVal) { this.snapshotVal = snapshotVal; } + public String getSeries() { return series; } + public void setSeries(String series) { this.series = series; } + public Integer getIndexNo() { return indexNo; } + public void setIndexNo(Integer indexNo) { this.indexNo = indexNo; } + public LocalDateTime getOccurredAt() { return occurredAt; } + public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt = occurredAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} + + diff --git a/src/main/java/com/gameplatform/server/service/detection/GameCompletionDetectionService.java b/src/main/java/com/gameplatform/server/service/detection/GameCompletionDetectionService.java index ade2eff..0015f97 100644 --- a/src/main/java/com/gameplatform/server/service/detection/GameCompletionDetectionService.java +++ b/src/main/java/com/gameplatform/server/service/detection/GameCompletionDetectionService.java @@ -73,7 +73,7 @@ public class GameCompletionDetectionService { // 查找该设备上的 LOGGED_IN 状态任务 List loggedInTasks = linkTaskMapper.findByMachineIdAndStatus(machineId, "LOGGED_IN"); if (loggedInTasks.isEmpty()) { - log.debug("设备{}没有LOGGED_IN状态的任务,跳过完成检测", machineId); +// log.debug("设备{}没有LOGGED_IN状态的任务,跳过完成检测", machineId); return false; } diff --git a/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java b/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java index 60aaef2..0d77140 100644 --- a/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java +++ b/src/main/java/com/gameplatform/server/service/device/DeviceStatusService.java @@ -16,6 +16,12 @@ import java.util.regex.Pattern; /** * 设备状态服务 + * + * 负责: + * - 解析设备状态 JSON 字符串为结构化对象 + * - 识别空闲设备(支持配置项定义空闲值) + * - 提供针对单机的 f0/f1 状态解析辅助方法 + * - 提供按系列分组和筛选空闲设备的便捷方法 */ @Service public class DeviceStatusService { @@ -33,24 +39,32 @@ public class DeviceStatusService { } /** - * 解析设备状态JSON响应 + * 解析设备状态 JSON 响应。 + * + * 输入 JSON 示例(简化): + * { + * "f1": { "val": "已运行", "time": "2025-09-15 10:00:00" }, + * "f2": { "val": "正在登录中", "time": "2025-09-15 10:00:02" }, + * "s1": { "val": "123", "time": "2025-09-15 10:01:00" } + * } + * 其中 val 可能取值:数字(积分/进度)、"正在登录中"、"已运行"、"已打完"、"空的"、"空闲" 等。 */ public DeviceStatusResponse parseDeviceStatus(String jsonResponse) { try { - log.debug("开始解析设备状态响应"); + log.debug("开始解析设备状态响应,长度={} 字符", jsonResponse != null ? jsonResponse.length() : 0); 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); @@ -67,7 +81,7 @@ public class DeviceStatusService { response.setTotalDevices(devices.size()); response.setAvailableCount(availableDevices.size()); - log.info("设备状态解析完成: 总设备数={}, 空闲设备数={}, 空闲设备={}", + log.info("设备状态解析完成: total={} availableCount={} availableDevices={}", response.getTotalDevices(), response.getAvailableCount(), availableDevices); return response; @@ -79,7 +93,9 @@ public class DeviceStatusService { } /** - * 解析单个设备信息 + * 解析单个设备信息。 + * - 识别设备系列与序号 + * - 计算是否空闲(依赖配置项) */ private DeviceStatusResponse.DeviceInfo parseDeviceInfo(String deviceId, JsonNode deviceNode) { DeviceStatusResponse.DeviceInfo deviceInfo = new DeviceStatusResponse.DeviceInfo(); @@ -98,14 +114,15 @@ public class DeviceStatusService { boolean available = isDeviceAvailable(val); deviceInfo.setAvailable(available); - log.debug("解析设备信息: deviceId={}, val={}, time={}, available={}, series={}, index={}", - deviceId, val, time, available, deviceInfo.getSeries(), deviceInfo.getIndex()); +// log.debug("解析设备信息: id={} val='{}' time='{}' available={} series={} index={}", +// deviceId, val, time, available, deviceInfo.getSeries(), deviceInfo.getIndex()); return deviceInfo; } /** - * 解析设备编号的组成部分 + * 解析设备编号的组成部分。 + * 支持的前缀:f/s/g/d/ss/gg,后缀为数字序号。 */ private void parseDeviceIdComponents(String deviceId, DeviceStatusResponse.DeviceInfo deviceInfo) { Matcher matcher = DEVICE_PATTERN.matcher(deviceId); @@ -117,7 +134,7 @@ public class DeviceStatusService { try { deviceInfo.setIndex(Integer.parseInt(indexStr)); } catch (NumberFormatException e) { - log.warn("解析设备序号失败: deviceId={}, indexStr={}", deviceId, indexStr); + log.warn("解析设备序号失败: id={} indexStr='{}'", deviceId, indexStr); deviceInfo.setIndex(null); } } else { @@ -127,7 +144,9 @@ public class DeviceStatusService { } /** - * 判断设备是否空闲 + * 判断设备是否空闲。 + * 依据系统配置 `deviceIdleStatus` 的值进行比对。 + * 约定:返回 true 表示设备可分配使用。 */ private boolean isDeviceAvailable(String val) { String idleStatus = systemConfigService.getDeviceIdleStatus(); @@ -135,23 +154,23 @@ public class DeviceStatusService { } /** - * 解析特定设备的状态信息(用于检查是否完成游戏) - * @param jsonResponse JSON响应字符串 + * 解析特定设备的状态信息(用于检查是否完成/或提取分数)。 + * @param jsonResponse JSON字符串 * @param machineId 设备ID - * @return 包含f0(点数)和f1(状态)的Map + * @return 返回 map,可能包含键 f0(分数) 与 f1(状态)。 */ public Map parseDeviceStatusForMachine(String jsonResponse, String machineId) { Map result = new HashMap<>(); try { - log.debug("解析设备 {} 的状态信息", machineId); + log.debug("解析设备状态:id={},payloadLen={}", machineId, jsonResponse != null ? jsonResponse.length() : 0); JsonNode rootNode = objectMapper.readTree(jsonResponse); // 查找指定设备的信息 JsonNode deviceNode = rootNode.get(machineId); if (deviceNode == null) { - log.warn("未找到设备 {} 的状态信息", machineId); + log.warn("未找到设备状态节点:id={}", machineId); return result; } @@ -183,12 +202,12 @@ public class DeviceStatusService { result.put("f1", f1Info); } - log.debug("设备 {} 状态解析完成: {}", machineId, result); + log.debug("设备状态解析完成:id={} result={}", machineId, result); } catch (JsonProcessingException e) { - log.error("解析设备 {} 状态JSON失败: {}", machineId, e.getMessage(), e); + log.error("设备状态JSON解析失败:id={} err={}", machineId, e.getMessage(), e); } catch (Exception e) { - log.error("解析设备 {} 状态时发生异常", machineId, e); + log.error("解析设备状态发生异常:id={}", machineId, e); } return result; 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 5f5bb03..4924af1 100644 --- a/src/main/java/com/gameplatform/server/service/external/ScriptClient.java +++ b/src/main/java/com/gameplatform/server/service/external/ScriptClient.java @@ -7,11 +7,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; import java.time.Duration; @@ -97,37 +99,74 @@ public class ScriptClient { * 检查空闲设备(返回原始字符串) */ public Mono checkAvailableDevice() { - String url = apiBaseUrl + "/yijianwan_netfile/readAllMsg?文件名=判断分数"; - log.debug("检查空闲设备: {}", url); return webClient.get() - .uri(url) - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .bodyToMono(String.class) + .uri(apiBaseUrl + "/yijianwan_netfile/readAllMsg?文件名=判断分数") + .accept(MediaType.ALL) // 先别卡死在 JSON + .exchangeToMono(resp -> { + HttpStatus sc = resp.statusCode(); + return resp.bodyToMono(String.class) + .defaultIfEmpty("") + .map(body -> { + log.debug("status={}, headers={}, body={}", sc.value(), resp.headers().asHttpHeaders(), body); + if (sc.is2xxSuccessful()) return body; + throw new IllegalStateException("HTTP " + sc.value() + " - " + body); + }); + }) .timeout(Duration.ofSeconds(10)) - .retry(3) // 失败时重试3次 - .doOnSuccess(result -> log.debug("检查空闲设备成功: {}", result)) - .doOnError(e -> log.warn("检查空闲设备失败: {}", e.toString())); + // 仅在 5xx 或 IO 问题时重试;4xx/超时不重试,避免凑满 40s + .retryWhen(Retry.fixedDelay(2, Duration.ofMillis(500)) + .filter(ex -> { + if (ex instanceof java.io.IOException) return true; + if (ex instanceof IllegalStateException ise) { + String m = ise.getMessage(); + return m != null && m.startsWith("HTTP 5"); + } + return false; + }) + .onRetryExhaustedThrow((spec, signal) -> signal.failure())) + .doOnError(e -> log.warn("checkAvailableDevice failed: {}", e.toString())); } /** * 检查空闲设备(解析后的结构化数据) */ public Mono checkAvailableDeviceStatus() { + long tStart = System.currentTimeMillis(); + return checkAvailableDevice() - .map(jsonResponse -> deviceStatusService.parseDeviceStatus(jsonResponse)) + .map(jsonResponse -> { + long tHttpEnd = System.currentTimeMillis(); + long tParseStart = tHttpEnd; + DeviceStatusResponse parsed = deviceStatusService.parseDeviceStatus(jsonResponse); + long tParseEnd = System.currentTimeMillis(); + log.debug("checkAvailableDeviceStatus jsonResponse: {}", jsonResponse); + // 在对象里临时塞入测量数据不可取,这里通过日志输出 + if (log.isInfoEnabled()) { + long httpCost = tHttpEnd - tStart; + long parseCost = tParseEnd - tParseStart; + log.info("checkAvailableDeviceStatus stage cost: http={} ms, parse={} ms", httpCost, parseCost); + } + return parsed; + }) .doOnSuccess(deviceStatus -> { log.info("设备状态检查完成: 总设备数={}, 空闲设备数={}", deviceStatus.getTotalDevices(), deviceStatus.getAvailableCount()); if (deviceStatus.getAvailableCount() > 0) { log.info("空闲设备列表: {}", deviceStatus.getAvailableDevices()); } - - // 发布设备状态更新事件,由事件监听器处理任务更新 + + long tEventStart = System.currentTimeMillis(); try { eventPublisher.publishEvent(new com.gameplatform.server.event.DeviceStatusUpdatedEvent(this, deviceStatus)); } catch (Exception e) { log.error("发布设备状态更新事件时发生异常", e); + } finally { + long tEventEnd = System.currentTimeMillis(); + long total = tEventEnd - tStart; + long eventCost = tEventEnd - tEventStart; + if (log.isInfoEnabled()) { + log.info("checkAvailableDeviceStatus stage cost: event={} ms, total={} ms", eventCost, total); + } } }) .doOnError(e -> log.error("设备状态解析失败: {}", e.getMessage(), e)); diff --git a/src/main/java/com/gameplatform/server/service/link/DeviceTaskUpdateService.java b/src/main/java/com/gameplatform/server/service/link/DeviceTaskUpdateService.java index 7525d6f..7a4f8e9 100644 --- a/src/main/java/com/gameplatform/server/service/link/DeviceTaskUpdateService.java +++ b/src/main/java/com/gameplatform/server/service/link/DeviceTaskUpdateService.java @@ -10,10 +10,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.event.EventListener; +import org.springframework.dao.CannotAcquireLockException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; /** @@ -45,13 +45,12 @@ public class DeviceTaskUpdateService { * 根据设备状态信息更新链接任务 - 改进版 * @param deviceInfo 设备状态信息 */ - @Transactional public void updateTaskByDeviceStatus(DeviceStatusResponse.DeviceInfo deviceInfo) { String deviceId = deviceInfo.getDeviceId(); String val = deviceInfo.getVal(); - log.debug("开始处理设备 {} 的状态更新: val={}, available={}", - deviceId, val, deviceInfo.isAvailable()); +// log.debug("开始处理设备 {} 的状态更新: val={}, available={}", +// deviceId, val, deviceInfo.isAvailable()); // 使用改进的游戏完成检测服务 boolean completionDetected = completionDetectionService.detectGameCompletion( @@ -68,30 +67,68 @@ public class DeviceTaskUpdateService { } /** - * 仅更新任务点数(不改变状态) + * 仅更新任务点数(不改变状态)- 带重试机制 */ private void updateTaskPointsOnly(String deviceId, Integer points) { List loggedInTasks = linkTaskMapper.findByMachineIdAndStatus(deviceId, "LOGGED_IN"); for (LinkTask task : loggedInTasks) { try { - // 只更新点数,保持LOGGED_IN状态 - task.setCompletedPoints(points); - task.setUpdatedAt(LocalDateTime.now()); + updateSingleTaskPoints(task, points, 3); // 最多重试3次 + } catch (Exception e) { + log.error("更新任务 {} 点数时发生异常,跳过该任务继续处理其他任务", task.getCodeNo(), e); + // 继续处理其他任务,不因单个任务失败而中断 + } + } + } + + /** + * 更新单个任务点数,带重试机制 + */ + private void updateSingleTaskPoints(LinkTask task, Integer points, int maxRetries) { + for (int attempts = 1; attempts <= maxRetries; attempts++) { + try { + updateTaskPointsTransaction(task, points); + return; // 成功则退出 + } catch (CannotAcquireLockException e) { + log.warn("任务 {} 更新时遇到锁等待超时,重试次数: {}/{}", + task.getCodeNo(), attempts, maxRetries); - int updated = linkTaskMapper.update(task); - if (updated > 0) { - log.debug("任务 {} 点数已更新为: {}", task.getCodeNo(), points); - } else { - log.warn("更新任务 {} 点数失败", task.getCodeNo()); + if (attempts >= maxRetries) { + log.error("任务 {} 点数更新失败,已达到最大重试次数", task.getCodeNo()); + throw e; // 重新抛出异常让上层处理 + } + + // 短暂等待后重试 + try { + long jitter = (long) (Math.random() * 50L); + Thread.sleep(100L * attempts + jitter); // 递增 + 抖动 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("重试被中断", ie); } } catch (Exception e) { log.error("更新任务 {} 点数时发生异常", task.getCodeNo(), e); + throw e; // 其他异常直接抛出 } } } + /** + * 在事务中更新任务点数 + */ + @Transactional(timeout = 10) + private void updateTaskPointsTransaction(LinkTask task, Integer points) { + // 仅当点数更大时才更新,减少写锁争用 + int updated = linkTaskMapper.updatePointsIfGreater(task.getId(), points); + if (updated > 0) { + log.debug("任务 {} 点数已更新为: {}", task.getCodeNo(), points); + } else { + log.debug("任务 {} 点数未更新(新值不大于现有值或记录已变更)", task.getCodeNo()); + } + } + // 保留原方法以兼容现有代码,但标记为已弃用 /** @@ -128,18 +165,21 @@ public class DeviceTaskUpdateService { } /** - * 批量处理设备状态更新 + * 批量处理设备状态更新 - 优化版本 + * 移除大事务,改为单个设备独立处理以避免锁等待超时 * @param deviceStatus 设备状态响应 */ - @Transactional public void batchUpdateTasksByDeviceStatus(DeviceStatusResponse deviceStatus) { log.debug("开始批量处理设备状态更新,设备数量: {}", deviceStatus.getTotalDevices()); + // 移除 @Transactional 注解,让每个设备独立处理 for (DeviceStatusResponse.DeviceInfo deviceInfo : deviceStatus.getDevices().values()) { try { + // 每个设备状态更新都是独立的小事务 updateTaskByDeviceStatus(deviceInfo); } catch (Exception e) { log.error("处理设备 {} 状态更新时发生异常", deviceInfo.getDeviceId(), e); + // 继续处理其他设备,不因单个设备失败而中断整个批次 } } @@ -147,11 +187,11 @@ public class DeviceTaskUpdateService { } /** - * 监听设备状态更新事件 + * 监听设备状态更新事件 - 优化版本 + * 移除事务注解,避免长事务导致的锁等待 * @param event 设备状态更新事件 */ @EventListener - @Transactional public void handleDeviceStatusUpdatedEvent(DeviceStatusUpdatedEvent event) { log.debug("收到设备状态更新事件,开始处理任务更新"); try { 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 1c0ab15..2d6bc6a 100644 --- a/src/main/java/com/gameplatform/server/service/link/LinkStatusService.java +++ b/src/main/java/com/gameplatform/server/service/link/LinkStatusService.java @@ -13,6 +13,8 @@ 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.device.Detection; +import com.gameplatform.server.device.DeviceStats; import org.springframework.beans.factory.annotation.Autowired; import com.gameplatform.server.service.device.DeviceStatusCheckService; import com.gameplatform.server.service.admin.SystemConfigService; @@ -44,6 +46,8 @@ public class LinkStatusService { private final SystemConfigService systemConfigService; private final MemoryMachineCooldownService machineCooldownService; private final DeviceAllocationService deviceAllocationService; + private final Detection detection; + private final DeviceStats deviceStats; @Autowired(required = false) private com.gameplatform.server.service.detection.GameCompletionDetectionService completionDetectionService; @Autowired(required = false) @@ -62,7 +66,7 @@ public class LinkStatusService { public LinkStatusService(LinkTaskMapper linkTaskMapper, LinkBatchMapper linkBatchMapper, ScriptClient scriptClient, - DeviceStatusCheckService deviceStatusCheckService, SystemConfigService systemConfigService, MemoryMachineCooldownService machineCooldownService, DeviceAllocationService deviceAllocationService) { + DeviceStatusCheckService deviceStatusCheckService, SystemConfigService systemConfigService, MemoryMachineCooldownService machineCooldownService, DeviceAllocationService deviceAllocationService, Detection detection, DeviceStats deviceStats) { this.linkTaskMapper = linkTaskMapper; this.linkBatchMapper = linkBatchMapper; this.scriptClient = scriptClient; @@ -70,6 +74,8 @@ public class LinkStatusService { this.systemConfigService = systemConfigService; this.machineCooldownService = machineCooldownService; this.deviceAllocationService = deviceAllocationService; + this.detection = detection; + this.deviceStats = deviceStats; } /** @@ -699,7 +705,12 @@ private UserLinkStatusResponse doGetUserLinkStatus(Long linkId, String codeNo) { linkTaskMapper.updateById(linkTask); } log.info("首次选区: 开始检查和分配空闲设备"); - DeviceStatusResponse deviceStatus = scriptClient.checkAvailableDeviceStatus().block(); + // 使用 Detection 的短TTL缓存,避免频繁触发事件引发并发写 + DeviceStatusResponse deviceStatus = detection.listAllDevices(); + if (deviceStatus == null) { + log.error("获取设备快照失败,无法进行选区"); + throw new RuntimeException("暂时无法获取设备状态,请稍后再试"); + } // 检查是否有空闲设备 if (deviceStatus.getAvailableCount() == 0) { @@ -711,12 +722,12 @@ private UserLinkStatusResponse doGetUserLinkStatus(Long linkId, String codeNo) { log.info("空闲设备检查完成 总设备数={}, 空闲设备数{}, 空闲设备列表={}", deviceStatus.getTotalDevices(), deviceStatus.getAvailableCount(), deviceStatus.getAvailableDevices()); - // 使用新的设备分配服务进行原子设备分配 - List availableDevices = deviceStatus.getAvailableDevices(); - selectedDeviceId = deviceAllocationService.allocateDevice(availableDevices, linkTask.getId(), "首次选区"); + // 由 DeviceStats 使用快照完成分配与内存分组更新 + selectedDeviceId = deviceStats.allocateFromSnapshot(deviceStatus, linkTask.getId(), "首次选区"); if (selectedDeviceId == null) { - log.error("设备分配失败: 所有空闲设备都在冷却期内或被占用 总空闲设备数={}", availableDevices.size()); + int candidateSize = deviceStatus.getAvailableDevices() != null ? deviceStatus.getAvailableDevices().size() : 0; + log.error("设备分配失败: 所有空闲设备都在冷却期内或被占用 总空闲设备数={}", candidateSize); throw new RuntimeException("所有设备都在冷却期内或被占用,请稍后再试"); } diff --git a/src/main/java/com/gameplatform/server/task/DeviceStatusCheckTask.java b/src/main/java/com/gameplatform/server/task/DeviceStatusCheckTask.java index 33ff59b..e09f3f3 100644 --- a/src/main/java/com/gameplatform/server/task/DeviceStatusCheckTask.java +++ b/src/main/java/com/gameplatform/server/task/DeviceStatusCheckTask.java @@ -27,7 +27,7 @@ public class DeviceStatusCheckTask { /** * 每分钟检查一次空闲设备,并更新相关链接任务状态 */ - @Scheduled(fixedRate = 60000) // 每60秒执行一次 +// @Scheduled(fixedRate = 60000) // 每60秒执行一次 public void checkIdleDevicesAndUpdateTasks() { log.debug("开始定时检查空闲设备"); diff --git a/src/main/java/com/gameplatform/server/task/DeviceStatusTransitionCleanupTask.java b/src/main/java/com/gameplatform/server/task/DeviceStatusTransitionCleanupTask.java new file mode 100644 index 0000000..2c06fc2 --- /dev/null +++ b/src/main/java/com/gameplatform/server/task/DeviceStatusTransitionCleanupTask.java @@ -0,0 +1,60 @@ +package com.gameplatform.server.task; + +import com.gameplatform.server.mapper.history.DeviceStatusTransitionMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 设备状态变更记录清理任务 + * 定期清理24小时前的设备状态变更历史记录,避免表数据过大影响性能 + */ +@Component +public class DeviceStatusTransitionCleanupTask { + private static final Logger log = LoggerFactory.getLogger(DeviceStatusTransitionCleanupTask.class); + + private final DeviceStatusTransitionMapper deviceStatusTransitionMapper; + + public DeviceStatusTransitionCleanupTask(DeviceStatusTransitionMapper deviceStatusTransitionMapper) { + this.deviceStatusTransitionMapper = deviceStatusTransitionMapper; + } + + /** + * 每6小时清理一次24小时前的记录 + * 执行时间:每天的 02:00, 08:00, 14:00, 20:00 + */ + @Scheduled(cron = "0 0 2,8,14,20 * * ?") + public void cleanupOldRecords() { + try { + log.info("开始清理设备状态变更历史记录..."); + + int deletedCount = deviceStatusTransitionMapper.deleteOldRecords(); + + if (deletedCount > 0) { + log.info("设备状态变更记录清理完成: 删除了{}条24小时前的记录", deletedCount); + } else { + log.debug("设备状态变更记录清理完成: 无需要清理的记录"); + } + + } catch (Exception e) { + log.error("设备状态变更记录清理失败: {}", e.getMessage(), e); + } + } + + /** + * 手动触发清理任务(用于测试或紧急清理) + * 可通过管理接口或其他方式调用 + */ + public int manualCleanup() { + try { + log.info("手动触发设备状态变更记录清理..."); + int deletedCount = deviceStatusTransitionMapper.deleteOldRecords(); + log.info("手动清理完成: 删除了{}条记录", deletedCount); + return deletedCount; + } catch (Exception e) { + log.error("手动清理失败: {}", e.getMessage(), e); + throw e; + } + } +} diff --git a/src/main/java/com/gameplatform/server/task/MachineCooldownCleanupTask.java b/src/main/java/com/gameplatform/server/task/MachineCooldownCleanupTask.java index ff5c34f..591891b 100644 --- a/src/main/java/com/gameplatform/server/task/MachineCooldownCleanupTask.java +++ b/src/main/java/com/gameplatform/server/task/MachineCooldownCleanupTask.java @@ -23,7 +23,7 @@ public class MachineCooldownCleanupTask { /** * 每30分钟清理一次过期的冷却记录 */ - @Scheduled(fixedRate = 30 * 60 * 1000) // 30分钟 +// @Scheduled(fixedRate = 30 * 60 * 1000) // 30分钟 public void cleanupExpiredCooldowns() { try { int sizeBefore = machineCooldownService.getCooldownQueueSize(); diff --git a/src/main/java/com/gameplatform/server/task/UsingLinkCheckTask.java b/src/main/java/com/gameplatform/server/task/UsingLinkCheckTask.java index 0b8499e..8288851 100644 --- a/src/main/java/com/gameplatform/server/task/UsingLinkCheckTask.java +++ b/src/main/java/com/gameplatform/server/task/UsingLinkCheckTask.java @@ -28,7 +28,7 @@ public class UsingLinkCheckTask { /** * 每30秒检测一次USING状态的链接 */ - @Scheduled(fixedRate = 30000) // 每30秒执行一次 +// @Scheduled(fixedRate = 30000) // 每30秒执行一次 public void checkUsingLinksAndHandleLoginStatus() { log.debug("开始定时检查USING状态的链接"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74831fe..19dc840 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,14 +3,30 @@ spring: name: gameplatform-server datasource: - url: jdbc:mysql://192.140.164.137:3306/login_task_db?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true + url: jdbc:mysql://120.46.74.24:3306/login_task_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&sessionVariables=innodb_lock_wait_timeout=30 username: login_task_db password: 3MaXfeWJ4d6cGMrL driver-class-name: com.mysql.cj.jdbc.Driver hikari: - maximum-pool-size: 10 - minimum-idle: 2 + maximum-pool-size: 50 + minimum-idle: 10 connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + leak-detection-threshold: 60000 + validation-timeout: 5000 + connection-test-query: SELECT 1 + # 连接池健康监控和自动重连 + initialization-fail-timeout: 1 + register-mbeans: true + # 自动重连配置 + auto-commit: true + task: + detection: + poll: + size: 4 + transaction: + default-timeout: 30 mybatis-plus: mapper-locations: classpath:mapper/**/*.xml @@ -39,16 +55,16 @@ logging: root: info com.gameplatform.server: debug # 保持整体调试 # 仅保留设备解析最终汇总(INFO),其余降级 - com.gameplatform.server.service.device.DeviceStatusService: info - com.gameplatform.server.service.device.DeviceStatusCheckService: info - # 脚本客户端与定时任务降噪 - com.gameplatform.server.service.external.ScriptClient: warn - com.gameplatform.server.task.DeviceStatusCheckTask: warn - com.gameplatform.server.task.UsingLinkCheckTask: warn - # 完成检测服务降噪(屏蔽debug“置信度低”之类日志) - com.gameplatform.server.service.detection.GameCompletionDetectionService: warn - # 设备任务更新服务:只保留警告/错误(不输出“开始处理设备/点数已更新为”等调试信息) - com.gameplatform.server.service.link.DeviceTaskUpdateService: warn +# com.gameplatform.server.service.device.DeviceStatusService: info +# com.gameplatform.server.service.device.DeviceStatusCheckService: info +# # 脚本客户端与定时任务降噪 +# com.gameplatform.server.service.external.ScriptClient: warn +# com.gameplatform.server.task.DeviceStatusCheckTask: warn +# com.gameplatform.server.task.UsingLinkCheckTask: warn +# # 完成检测服务降噪(屏蔽debug“置信度低”之类日志) +# com.gameplatform.server.service.detection.GameCompletionDetectionService: warn +# # 设备任务更新服务:只保留警告/错误(不输出“开始处理设备/点数已更新为”等调试信息) +# com.gameplatform.server.service.link.DeviceTaskUpdateService: warn # Mapper 与 SQL 调用降噪(屏蔽 MyBatis 的参数/SQL DEBUG) com.gameplatform.server.mapper: warn com.baomidou.mybatisplus: warn diff --git a/src/main/resources/db/migration/V20250915__create_device_status_transition.sql b/src/main/resources/db/migration/V20250915__create_device_status_transition.sql new file mode 100644 index 0000000..6300470 --- /dev/null +++ b/src/main/resources/db/migration/V20250915__create_device_status_transition.sql @@ -0,0 +1,17 @@ +-- Device status transition audit table +CREATE TABLE IF NOT EXISTS device_status_transition ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(64) NOT NULL, + prev_status VARCHAR(32) NULL, + new_status VARCHAR(32) NOT NULL, + reason VARCHAR(255) NULL, + snapshot_val VARCHAR(64) NULL, + series VARCHAR(16) NULL, + index_no INT NULL, + occurred_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_device_created (device_id, created_at), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + diff --git a/src/main/resources/mapper/agent/LinkTaskMapper.xml b/src/main/resources/mapper/agent/LinkTaskMapper.xml index c3dd212..169af7b 100644 --- a/src/main/resources/mapper/agent/LinkTaskMapper.xml +++ b/src/main/resources/mapper/agent/LinkTaskMapper.xml @@ -82,6 +82,14 @@ WHERE id = #{id} + + + UPDATE link_task + SET completed_points = #{newPoints}, updated_at = NOW() + WHERE id = #{id} + AND (completed_points IS NULL OR completed_points < #{newPoints}) + + + SELECT id, device_id, prev_status, new_status, reason, snapshot_val, series, index_no, occurred_at, created_at + FROM device_status_transition + WHERE device_id = #{deviceId} + ORDER BY created_at DESC + LIMIT #{size} + + + + DELETE FROM device_status_transition + WHERE created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR) + + + +