diff --git a/LogBook.md b/LogBook.md index 0b3ce38..900a111 100644 --- a/LogBook.md +++ b/LogBook.md @@ -46,4 +46,64 @@ - 更正了 `springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttServiceImpl.java` 的实现,确保 `sendCommand` 方法的逻辑完整和正确。 - 实现 `TaskTimeoutHandler.java`,使用 `@Scheduled` 定时调用 `RobotTaskService.findAndMarkTimedOutTasks` 处理任务超时。 - 在 `MyApplication.java` 中添加 `@EnableScheduling` 以启用定时任务。 -- 在 `application.yml` 中添加了 `mqtt.task.timeoutSeconds` 和 `mqtt.task.timeoutCheckRateMs` 配置项。 \ No newline at end of file +- 在 `application.yml` 中添加了 `mqtt.task.timeoutSeconds` 和 `mqtt.task.timeoutCheckRateMs` 配置项。 + +--- + +**第二阶段 (MQTT 集成) 已于 YYYY-MM-DD 完成。** + +所有核心功能点,包括MQTT连接、消息发布/订阅、RobotTask状态跟踪和基础超时处理已实现。 +依赖于 ChargingSession 的超时后联动处理已明确推至第三阶段。 + +--- + +## YYYY-MM-DD (请替换为当前日期) - 第三阶段后端开发 + +- **核心业务实体与服务实现**: + - 创建了枚举类: `RobotStatusEnum`, `ParkingSpotStatusEnum`, `ChargingSessionStatusEnum`, `PaymentStatusEnum`. + - 创建了数据库实体: `ChargingRobot`, `ParkingSpot`, `ChargingSession`. + - 创建了对应的Mapper接口: `ChargingRobotMapper`, `ParkingSpotMapper`, `ChargingSessionMapper`. + - 创建了Service接口: `ChargingRobotService`, `ParkingSpotService`, `ChargingSessionService`. + - 创建了Service实现类: `ChargingRobotServiceImpl`, `ParkingSpotServiceImpl`, `ChargingSessionServiceImpl`. + - `ChargingSessionServiceImpl` 中实现了充电请求、机器人分配、状态流转 (到达、开始/结束充电)、费用计算、支付、取消、超时处理等核心逻辑。 +- **API 控制器实现**: + - 创建了 `ChargingRobotAdminController` 用于管理员管理充电机器人 (CRUD, 列表查询, 状态类型)。 + - 创建了 `ParkingSpotAdminController` 用于管理员管理车位 (CRUD, 列表查询, 状态类型)。 + - 创建了 `ChargingSessionController` 用于用户发起充电请求、查询历史会话、支付、取消会话。 + - 创建了相关的DTOs (如 `ChargingRobotAddRequest`, `ParkingSpotQueryRequest`, `ChargingRequest`, `PaymentRequest`) 和 VO (`ChargingSessionVO`). +- **MQTT与任务处理联动**: + - 更新了 `MqttMessageHandlerImpl`,使其在收到机器人状态ACK后,能调用 `ChargingSessionService` 更新相关充电会话的状态。 + - 更新了 `TaskTimeoutHandler`,使其在检测到与会话关联的任务超时后,能调用 `ChargingSessionService` 处理会话超时逻辑。 + - 在 `ChargingSessionService` 中补充了 `getQueryWrapper` 方法用于支持分页和条件查询。 + +- **主要实现功能点**: + - 管理员可以增删改查充电机器人和车位。 + - 用户可以请求在特定车位充电。 + - 系统能够尝试分配空闲机器人,并向其发送移动指令 (通过MQTT,并记录RobotTask)。 + - 系统能够根据机器人通过MQTT反馈的状态(到达、开始充电、结束充电)更新充电会话的生命周期。 + - 充电结束后,系统能计算费用,并允许用户支付。 + - 用户可以在特定阶段取消充电会话。 + - 机器人任务超时会影响关联的充电会话状态。 + +- **补充后端功能 (根据阶段计划调整)**: + - 在 `ChargingSessionAdminController.java` 中添加了管理员分页查询所有充电会话的接口 (`POST /admin/session/list/page`)。 + - 在 `ChargingSessionController.java` 中添加了用户"优雅停止充电"的接口 (`POST /session/stop`)。 + - 此接口会向机器人发送 `STOP_CHARGE` 指令,并通过 `ChargingSessionServiceImpl.stopChargingByUser` 方法创建相应的 `RobotTask`。 + - 会话的最终完成和计费依赖 `MqttMessageHandlerImpl` 收到机器人对 `STOP_CHARGE` 指令的成功ACK后,调用 `chargingSessionService.handleChargingEnd` 处理。 + +- **下一步**: + - 进行详细的单元测试和集成测试。 + - 完善错误处理、日志记录和边界条件。 + - 更新API文档。 + - **开始第三阶段前端开发**。 + - 根据 `stage_3_core_charging_logic.md` 检查业务流程覆盖情况。 + +## 后端开发 - 第三阶段核心充电逻辑完成 + +* **状态**: 后端核心业务逻辑、服务实现、MQTT集成及主要API Controller已完成开发并通过多轮编译错误修复。 + * 充电全流程 (请求、分配、移动、到达、开始、结束、计费、支付、取消、超时) 已实现。 + * 机器人和车位的状态管理服务已实现并集成到主流程。 + * 用户余额扣减实现原子性操作。 + * MQTT消息处理机制已建立,可处理任务ACK和常规状态上报。 +* **决策**: 经过讨论,充电过程中的实时时长更新 (`currentChargingDurationSeconds`) 功能在本阶段不实现,最终计费依赖充电结束时上报的 `totalDurationSeconds`。 +* **后续**: 后端已为前端开发提供基础。建议在前端大规模开发前,后端进行核心API的冒烟测试,并完善API文档(如使用Swagger)。 \ No newline at end of file diff --git a/springboot-init-main/doc/development_stages/stage_3_core_charging_logic.md b/springboot-init-main/doc/development_stages/stage_3_core_charging_logic.md index 641d8e2..11c60a2 100644 --- a/springboot-init-main/doc/development_stages/stage_3_core_charging_logic.md +++ b/springboot-init-main/doc/development_stages/stage_3_core_charging_logic.md @@ -11,14 +11,73 @@ ## 2. 后端开发详解 1. **数据库初始化**: - * **创建 `charging_robot`, `parking_spot`, `charging_session` 表**: 执行 `development_plan.md` 中定义的相应 DDL 语句。 + * **创建 `charging_robot`, `parking_spot`, `charging_session` 表**: 执行以下 DDL 语句。 ```sql - -- (DDL for charging_robot) - CREATE TABLE `charging_robot` (...) COMMENT='充电机器人表'; - -- (DDL for parking_spot) - CREATE TABLE `parking_spot` (...) COMMENT='车位表'; - -- (DDL for charging_session) - CREATE TABLE `charging_session` (...) COMMENT='充电记录表'; + -- DDL for charging_robot + CREATE TABLE `charging_robot` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `robot_uid` VARCHAR(100) NOT NULL UNIQUE COMMENT '机器人唯一标识符 (例如,硬件序列号)', + `status` VARCHAR(50) NOT NULL DEFAULT 'IDLE' COMMENT '机器人状态 (IDLE, MOVING, CHARGING, ERROR, MAINTENANCE, OFFLINE)', + `current_location` VARCHAR(255) COMMENT '当前位置描述 (例如,坐标或区域ID)', + `battery_level` TINYINT UNSIGNED COMMENT '电池电量百分比 (0-100)', + `current_task_id` BIGINT COMMENT '当前执行的任务ID (关联 robot_task.id)', + `last_heartbeat_time` DATETIME COMMENT '最后心跳时间', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)' + ) COMMENT='充电机器人表'; + + -- (索引建议,可根据实际查询需求调整) + -- CREATE INDEX `idx_robot_uid` ON `charging_robot` (`robot_uid`); + -- CREATE INDEX `idx_robot_status` ON `charging_robot` (`status`); + + -- DDL for parking_spot + CREATE TABLE `parking_spot` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `spot_uid` VARCHAR(100) NOT NULL UNIQUE COMMENT '车位唯一标识符 (例如,车位号)', + `status` VARCHAR(50) NOT NULL DEFAULT 'AVAILABLE' COMMENT '车位状态 (AVAILABLE, OCCUPIED_BY_CAR, RESERVED, CHARGING, MAINTENANCE, UNAVAILABLE)', + `location_description` VARCHAR(255) COMMENT '位置描述 (例如,楼层、区域)', + `robot_assignable` BOOLEAN DEFAULT TRUE COMMENT '此车位是否可指派机器人前往', + `current_session_id` BIGINT COMMENT '当前占用此车位的充电会话ID (关联 charging_session.id)', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)' + ) COMMENT='车位表'; + + -- (索引建议) + -- CREATE INDEX `idx_spot_uid` ON `parking_spot` (`spot_uid`); + -- CREATE INDEX `idx_spot_status` ON `parking_spot` (`status`); + + -- DDL for charging_session + CREATE TABLE `charging_session` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID,充电会话唯一标识', + `user_id` BIGINT NOT NULL COMMENT '用户ID (关联 user.id)', + `robot_id` BIGINT COMMENT '服务的机器人ID (关联 charging_robot.id)', + `robot_uid_snapshot` VARCHAR(100) COMMENT '服务机器人的UID快照 (冗余字段,方便查询历史)', + `spot_id` BIGINT NOT NULL COMMENT '车位ID (关联 parking_spot.id)', + `spot_uid_snapshot` VARCHAR(100) NOT NULL COMMENT '车位UID快照 (冗余字段)', + `status` VARCHAR(50) NOT NULL DEFAULT 'REQUESTED' COMMENT '充电会话状态 (REQUESTED, ROBOT_ASSIGNED, ROBOT_MOVING, CHARGING_STARTED, CHARGING_IN_PROGRESS, COMPLETED, CANCELLED_BY_USER, CANCELLED_BY_SYSTEM, ERROR, ROBOT_TASK_TIMEOUT)', + `request_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '用户请求充电时间', + `robot_assigned_time` DATETIME COMMENT '机器人分配时间', + `robot_arrival_time` DATETIME COMMENT '机器人到达车位时间', + `charge_start_time` DATETIME COMMENT '充电开始时间', + `charge_end_time` DATETIME COMMENT '充电结束时间', + `total_duration_seconds` INT COMMENT '总充电时长 (秒)', + `energy_consumed_kwh` DECIMAL(10, 3) COMMENT '消耗电量 (kWh)', + `cost` DECIMAL(10, 2) COMMENT '本次充电费用 (元)', + `payment_status` VARCHAR(50) DEFAULT 'PENDING' COMMENT '支付状态 (PENDING, PAID, FAILED)', + `error_code` VARCHAR(100) COMMENT '错误码 (如果会话出错)', + `error_message` TEXT COMMENT '错误信息', + `related_robot_task_id` BIGINT COMMENT '启动本次会话或关键步骤的 RobotTask ID', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间', + `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)' + ) COMMENT='充电记录表'; + + -- (外键约束和索引建议) + -- ALTER TABLE `charging_session` ADD CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`); + -- CREATE INDEX `idx_session_user` ON `charging_session` (`user_id`); + -- ... (其他索引) ``` 2. **机器人与车位管理 (可选,主要面向管理员)**: * **Entity**: 创建 `ChargingRobot.java`, `ParkingSpot.java`。 diff --git a/springboot-init-main/pom.xml b/springboot-init-main/pom.xml index 5392d40..cc09b81 100644 --- a/springboot-init-main/pom.xml +++ b/springboot-init-main/pom.xml @@ -97,6 +97,11 @@ org.eclipse.paho.client.mqttv3 1.2.5 + + org.apache.commons + commons-collections4 + 4.4 + diff --git a/springboot-init-main/sql/mqtt_power.sql b/springboot-init-main/sql/mqtt_power.sql new file mode 100644 index 0000000..155b659 --- /dev/null +++ b/springboot-init-main/sql/mqtt_power.sql @@ -0,0 +1,126 @@ +/* + Navicat Premium Data Transfer + + Source Server : yuyun + Source Server Type : MySQL + Source Server Version : 50744 + Source Host : yuyun-us1.stormrain.cn:3306 + Source Schema : mqtt_power + + Target Server Type : MySQL + Target Server Version : 50744 + File Encoding : 65001 + + Date: 17/05/2025 21:14:59 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for charging_robot +-- ---------------------------- +DROP TABLE IF EXISTS `charging_robot`; +CREATE TABLE `charging_robot` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `robot_uid` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '机器人唯一标识符 (例如,硬件序列号)', + `status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL DEFAULT 'IDLE' COMMENT '机器人状态 (IDLE, MOVING, CHARGING, ERROR, MAINTENANCE, OFFLINE)', + `current_location` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL DEFAULT NULL COMMENT '当前位置描述 (例如,坐标或区域ID)', + `battery_level` tinyint(3) UNSIGNED NULL DEFAULT NULL COMMENT '电池电量百分比 (0-100)', + `current_task_id` bigint(20) NULL DEFAULT NULL COMMENT '当前执行的任务ID (关联 robot_task.id)', + `last_heartbeat_time` datetime NULL DEFAULT NULL COMMENT '最后心跳时间', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + `is_deleted` tinyint(1) NULL DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `robot_uid`(`robot_uid`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_german2_ci COMMENT = '充电机器人表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Table structure for charging_session +-- ---------------------------- +DROP TABLE IF EXISTS `charging_session`; +CREATE TABLE `charging_session` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID,充电会话唯一标识', + `user_id` bigint(20) NOT NULL COMMENT '用户ID (关联 user.id)', + `robot_id` bigint(20) NULL DEFAULT NULL COMMENT '服务的机器人ID (关联 charging_robot.id)', + `robot_uid_snapshot` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL DEFAULT NULL COMMENT '服务机器人的UID快照 (冗余字段,方便查询历史)', + `spot_id` bigint(20) NOT NULL COMMENT '车位ID (关联 parking_spot.id)', + `spot_uid_snapshot` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '车位UID快照 (冗余字段)', + `status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL DEFAULT 'REQUESTED' COMMENT '充电会话状态 (REQUESTED, ROBOT_ASSIGNED, ROBOT_MOVING, CHARGING_STARTED, CHARGING_IN_PROGRESS, COMPLETED, CANCELLED_BY_USER, CANCELLED_BY_SYSTEM, ERROR, ROBOT_TASK_TIMEOUT)', + `request_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '用户请求充电时间', + `robot_assigned_time` datetime NULL DEFAULT NULL COMMENT '机器人分配时间', + `robot_arrival_time` datetime NULL DEFAULT NULL COMMENT '机器人到达车位时间', + `charge_start_time` datetime NULL DEFAULT NULL COMMENT '充电开始时间', + `charge_end_time` datetime NULL DEFAULT NULL COMMENT '充电结束时间', + `total_duration_seconds` int(11) NULL DEFAULT NULL COMMENT '总充电时长 (秒)', + `energy_consumed_kwh` decimal(10, 3) NULL DEFAULT NULL COMMENT '消耗电量 (kWh)', + `cost` decimal(10, 2) NULL DEFAULT NULL COMMENT '本次充电费用 (元)', + `payment_status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL DEFAULT 'PENDING' COMMENT '支付状态 (PENDING, PAID, FAILED)', + `error_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL DEFAULT NULL COMMENT '错误码 (如果会话出错)', + `error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL COMMENT '错误信息', + `related_robot_task_id` bigint(20) NULL DEFAULT NULL COMMENT '启动本次会话或关键步骤的 RobotTask ID', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间', + `is_deleted` tinyint(1) NULL DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_german2_ci COMMENT = '充电记录表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Table structure for parking_spot +-- ---------------------------- +DROP TABLE IF EXISTS `parking_spot`; +CREATE TABLE `parking_spot` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `spot_uid` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '车位唯一标识符 (例如,车位号)', + `status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL DEFAULT 'AVAILABLE' COMMENT '车位状态 (AVAILABLE, OCCUPIED_BY_CAR, RESERVED, CHARGING, MAINTENANCE, UNAVAILABLE)', + `location_description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL DEFAULT NULL COMMENT '位置描述 (例如,楼层、区域)', + `robot_assignable` tinyint(1) NULL DEFAULT 1 COMMENT '此车位是否可指派机器人前往', + `current_session_id` bigint(20) NULL DEFAULT NULL COMMENT '当前占用此车位的充电会话ID (关联 charging_session.id)', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + `is_deleted` tinyint(1) NULL DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `spot_uid`(`spot_uid`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_german2_ci COMMENT = '车位表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Table structure for robot_task +-- ---------------------------- +DROP TABLE IF EXISTS `robot_task`; +CREATE TABLE `robot_task` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `robot_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '机器人ID', + `command_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '命令类型 (MOVE_TO_SPOT, START_CHARGE, STOP_CHARGE, QUERY_STATUS)', + `command_payload` text CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL COMMENT '命令参数 (JSON格式)', + `status` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL DEFAULT 'PENDING' COMMENT '任务状态 (PENDING, SENT, ACKNOWLEDGED_SUCCESS, ACKNOWLEDGED_FAILURE, TIMED_OUT)', + `sent_time` datetime NULL DEFAULT NULL COMMENT '命令发送时间', + `ack_time` datetime NULL DEFAULT NULL COMMENT '命令确认时间', + `related_session_id` bigint(20) NULL DEFAULT NULL COMMENT '关联的充电会话ID (可选)', + `error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NULL COMMENT '失败或超时的错误信息', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `is_delete` tinyint(1) NULL DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_robot_status`(`robot_id`, `status`) USING BTREE COMMENT '机器人和状态索引,便于查询' +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_german2_ci COMMENT = '机器人指令任务表' ROW_FORMAT = Dynamic; + +-- ---------------------------- +-- Table structure for user +-- ---------------------------- +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '用户名', + `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL COMMENT '密码 (加密存储)', + `role` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_german2_ci NOT NULL DEFAULT 'user' COMMENT '角色 (user/admin)', + `balance` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '账户余额', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `is_deleted` tinyint(1) NULL DEFAULT 0 COMMENT '逻辑删除标志 (0:未删, 1:已删)', + `isDeleted` tinyint(1) NULL DEFAULT 0 COMMENT '是否删除(0-未删, 1-已删)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `username`(`username`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_german2_ci COMMENT = '用户表' ROW_FORMAT = Dynamic; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingRobotAdminController.java b/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingRobotAdminController.java new file mode 100644 index 0000000..99af0f4 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingRobotAdminController.java @@ -0,0 +1,155 @@ +package com.yupi.project.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.yupi.project.annotation.AuthCheck; +import com.yupi.project.common.BaseResponse; +import com.yupi.project.common.DeleteRequest; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.common.ResultUtils; +import com.yupi.project.constant.UserConstant; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.exception.ThrowUtils; +import com.yupi.project.model.dto.charging_robot.ChargingRobotAddRequest; +import com.yupi.project.model.dto.charging_robot.ChargingRobotQueryRequest; +import com.yupi.project.model.dto.charging_robot.ChargingRobotUpdateRequest; +import com.yupi.project.model.entity.ChargingRobot; +import com.yupi.project.model.enums.RobotStatusEnum; +import com.yupi.project.service.ChargingRobotService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 充电机器人管理接口 (管理员权限) + */ +@RestController +@RequestMapping("/admin/robot") +@Slf4j +@Api(tags = "充电机器人管理接口 (管理员)") +public class ChargingRobotAdminController { + + @Resource + private ChargingRobotService chargingRobotService; + + @ApiOperation("添加充电机器人") + @PostMapping("/add") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse addChargingRobot(@RequestBody ChargingRobotAddRequest addRequest, HttpServletRequest request) { + if (addRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + // 参数校验 + if (StringUtils.isBlank(addRequest.getRobotUid())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "机器人UID不能为空"); + } + if (addRequest.getStatus() == null || RobotStatusEnum.getEnumByValue(addRequest.getStatus()) == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的机器人状态"); + } + + ChargingRobot robot = chargingRobotService.registerRobot(addRequest.getRobotUid(), RobotStatusEnum.getEnumByValue(addRequest.getStatus())); + if (robot == null || robot.getId() == null) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加机器人失败"); + } + // 可以选择性地更新其他字段,如初始电量等 + if (addRequest.getBatteryLevel() != null) { + chargingRobotService.updateBatteryLevel(robot.getId(), addRequest.getBatteryLevel()); + } + if (StringUtils.isNotBlank(addRequest.getCurrentLocation())) { + ChargingRobot updateRobot = new ChargingRobot(); + updateRobot.setId(robot.getId()); + updateRobot.setCurrentLocation(addRequest.getCurrentLocation()); + chargingRobotService.updateById(updateRobot); + } + + return ResultUtils.success(robot.getId()); + } + + @ApiOperation("删除充电机器人") + @PostMapping("/delete") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse deleteChargingRobot(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) { + if (deleteRequest == null || deleteRequest.getId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + boolean b = chargingRobotService.removeById(deleteRequest.getId()); + return ResultUtils.success(b); + } + + @ApiOperation("更新充电机器人信息") + @PostMapping("/update") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse updateChargingRobot(@RequestBody ChargingRobotUpdateRequest updateRequest, HttpServletRequest request) { + if (updateRequest == null || updateRequest.getId() == null || updateRequest.getId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + ChargingRobot chargingRobot = new ChargingRobot(); + BeanUtils.copyProperties(updateRequest, chargingRobot); + + if (StringUtils.isNotBlank(updateRequest.getStatus()) && RobotStatusEnum.getEnumByValue(updateRequest.getStatus()) == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的机器人状态值"); + } + if (updateRequest.getBatteryLevel() != null && (updateRequest.getBatteryLevel() < 0 || updateRequest.getBatteryLevel() > 100)) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的电池电量值"); + } + + boolean result = chargingRobotService.updateById(chargingRobot); + ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); + return ResultUtils.success(true); + } + + @ApiOperation("根据ID获取充电机器人信息") + @GetMapping("/get") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse getChargingRobotById(long id, HttpServletRequest request) { + if (id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + ChargingRobot chargingRobot = chargingRobotService.getById(id); + ThrowUtils.throwIf(chargingRobot == null, ErrorCode.NOT_FOUND_ERROR); + return ResultUtils.success(chargingRobot); + } + + @ApiOperation("分页获取充电机器人列表") + @PostMapping("/list/page") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> listChargingRobotsByPage(@RequestBody ChargingRobotQueryRequest queryRequest, HttpServletRequest request) { + if (queryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + long current = queryRequest.getCurrent(); + long size = queryRequest.getPageSize(); + Page page = new Page<>(current, size); + QueryWrapper queryWrapper = new QueryWrapper<>(); + if (StringUtils.isNotBlank(queryRequest.getRobotUid())) { + queryWrapper.like("robot_uid", queryRequest.getRobotUid()); + } + if (StringUtils.isNotBlank(queryRequest.getStatus())) { + queryWrapper.eq("status", queryRequest.getStatus()); + } + if (StringUtils.isNotBlank(queryRequest.getSortField())) { + queryWrapper.orderBy(true, queryRequest.getSortOrder().equals("ascend"), queryRequest.getSortField()); + } + chargingRobotService.page(page, queryWrapper); + return ResultUtils.success(page); + } + + @ApiOperation("获取所有机器人状态类型") + @GetMapping("/status/types") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> getRobotStatusTypes() { + List statusTypes = Arrays.stream(RobotStatusEnum.values()) + .map(RobotStatusEnum::getValue) + .collect(Collectors.toList()); + return ResultUtils.success(statusTypes); + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingSessionAdminController.java b/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingSessionAdminController.java new file mode 100644 index 0000000..7d32c23 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingSessionAdminController.java @@ -0,0 +1,83 @@ +package com.yupi.project.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.yupi.project.annotation.AuthCheck; +import com.yupi.project.common.BaseResponse; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.common.ResultUtils; +import com.yupi.project.constant.UserConstant; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.model.dto.charging_session.ChargingSessionQueryRequest; +import com.yupi.project.model.entity.ChargingSession; +import com.yupi.project.model.entity.User; +import com.yupi.project.model.vo.ChargingSessionVO; +import com.yupi.project.service.ChargingSessionService; +import com.yupi.project.service.UserService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 充电会话管理接口 (管理员权限) + */ +@RestController +@RequestMapping("/admin/session") +@Slf4j +@Api(tags = "充电会话管理接口 (管理员)") +public class ChargingSessionAdminController { + + @Resource + private ChargingSessionService chargingSessionService; + + @Resource + private UserService userService; // 可能需要用于填充用户信息 + + @ApiOperation("管理员分页获取所有充电会话列表") + @PostMapping("/list/page") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> listAllChargingSessionsByPage(@RequestBody ChargingSessionQueryRequest queryRequest, HttpServletRequest request) { + if (queryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + // 管理员查询,不强制设置userId + long current = queryRequest.getCurrent(); + long size = queryRequest.getPageSize(); + + Page sessionPage = chargingSessionService.page(new Page<>(current, size), + chargingSessionService.getQueryWrapper(queryRequest)); + + Page sessionVOPage = new Page<>(sessionPage.getCurrent(), sessionPage.getSize(), sessionPage.getTotal()); + + // 转换VO并填充用户信息 + List voList = sessionPage.getRecords().stream().map(session -> { + ChargingSessionVO vo = ChargingSessionVO.objToVo(session); + if (session.getUserId() != null) { + User user = userService.getById(session.getUserId()); + if (user != null) { + vo.setUser(user); // UserVO 转换会在 setUser 方法内进行 + } + } + // TODO: 填充机器人和车位快照对应的实体信息 (如果VO中需要) + return vo; + }).collect(Collectors.toList()); + + sessionVOPage.setRecords(voList); + + return ResultUtils.success(sessionVOPage); + } + + // TODO: 管理员可能需要的其他接口,例如: + // - 管理员手动取消某个会话 (不同于用户取消) + // - 查看特定会话的详细日志或关联任务 + // - 重试某个失败的支付等 + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingSessionController.java b/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingSessionController.java new file mode 100644 index 0000000..2431a5a --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/controller/ChargingSessionController.java @@ -0,0 +1,136 @@ +package com.yupi.project.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.yupi.project.annotation.AuthCheck; +import com.yupi.project.common.BaseResponse; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.common.ResultUtils; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.exception.ThrowUtils; +import com.yupi.project.model.dto.charging_session.ChargingRequest; +import com.yupi.project.model.dto.charging_session.ChargingSessionQueryRequest; +import com.yupi.project.model.dto.charging_session.PaymentRequest; +import com.yupi.project.model.entity.ChargingSession; +import com.yupi.project.model.entity.User; +import com.yupi.project.model.enums.ChargingSessionStatusEnum; +import com.yupi.project.model.vo.ChargingSessionVO; +import com.yupi.project.service.ChargingSessionService; +import com.yupi.project.service.UserService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 充电会话接口 (用户权限) + */ +@RestController +@RequestMapping("/session") +@Slf4j +@Api(tags = "充电会话接口 (用户)") +public class ChargingSessionController { + + @Resource + private ChargingSessionService chargingSessionService; + + @Resource + private UserService userService; + + @ApiOperation("用户请求充电") + @PostMapping("/request") + @AuthCheck // 需要登录 + public BaseResponse requestCharging(@RequestBody ChargingRequest chargingRequest, HttpServletRequest request) { + if (chargingRequest == null || chargingRequest.getSpotId() == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数错误,必须指定车位ID"); + } + User loginUser = userService.getCurrentUser(request); + ChargingSession session = chargingSessionService.requestCharging(loginUser, chargingRequest); + return ResultUtils.success(session.getId()); + } + + @ApiOperation("用户获取自己的充电会话列表 (分页)") + @PostMapping("/my/list/page") + @AuthCheck // 需要登录 + public BaseResponse> listMyChargingSessionsByPage(@RequestBody ChargingSessionQueryRequest queryRequest, HttpServletRequest request) { + if (queryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + User loginUser = userService.getCurrentUser(request); + queryRequest.setUserId(loginUser.getId()); // 强制查询当前用户的 + + long current = queryRequest.getCurrent(); + long size = queryRequest.getPageSize(); + + // 限制爬虫 + ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR); + Page sessionPage = chargingSessionService.page(new Page<>(current, size), + chargingSessionService.getQueryWrapper(queryRequest)); + + Page sessionVOPage = new Page<>(sessionPage.getCurrent(), sessionPage.getSize(), sessionPage.getTotal()); + List voList = sessionPage.getRecords().stream().map(ChargingSessionVO::objToVo).collect(Collectors.toList()); + sessionVOPage.setRecords(voList); + + return ResultUtils.success(sessionVOPage); + } + + @ApiOperation("用户获取单个充电会话详情") + @GetMapping("/get") + @AuthCheck + public BaseResponse getChargingSessionById(long id, HttpServletRequest request) { + if (id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + User loginUser = userService.getCurrentUser(request); + ChargingSession session = chargingSessionService.getById(id); + if (session == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR); + } + // 权限校验,只能看自己的 + if (!session.getUserId().equals(loginUser.getId())) { + throw new BusinessException(ErrorCode.NO_AUTH_ERROR); + } + return ResultUtils.success(ChargingSessionVO.objToVo(session)); + } + + @ApiOperation("用户取消充电会话") + @PostMapping("/cancel") + @AuthCheck + public BaseResponse cancelChargingSession(@RequestBody PaymentRequest cancelRequest, HttpServletRequest request) { // 复用PaymentRequest只取sessionId + if (cancelRequest == null || cancelRequest.getSessionId() == null || cancelRequest.getSessionId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + User loginUser = userService.getCurrentUser(request); + boolean result = chargingSessionService.cancelChargingSession(cancelRequest.getSessionId(), loginUser.getId(), "用户主动取消", ChargingSessionStatusEnum.CANCELLED_BY_USER); + return ResultUtils.success(result); + } + + @ApiOperation("用户请求停止当前充电") + @PostMapping("/stop") + @AuthCheck + public BaseResponse stopCharging(@RequestBody PaymentRequest stopRequest, HttpServletRequest request) { // 复用PaymentRequest只取sessionId + if (stopRequest == null || stopRequest.getSessionId() == null || stopRequest.getSessionId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + User loginUser = userService.getCurrentUser(request); + boolean result = chargingSessionService.stopChargingByUser(stopRequest.getSessionId(), loginUser.getId()); + return ResultUtils.success(result); + } + + @ApiOperation("用户支付充电费用") + @PostMapping("/pay") + @AuthCheck + public BaseResponse payChargingSession(@RequestBody PaymentRequest paymentRequest, HttpServletRequest request) { + if (paymentRequest == null || paymentRequest.getSessionId() == null || paymentRequest.getSessionId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + User loginUser = userService.getCurrentUser(request); + boolean result = chargingSessionService.processPayment(paymentRequest.getSessionId(), loginUser.getId()); + return ResultUtils.success(result); + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/controller/ParkingSpotAdminController.java b/springboot-init-main/src/main/java/com/yupi/project/controller/ParkingSpotAdminController.java new file mode 100644 index 0000000..cece047 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/controller/ParkingSpotAdminController.java @@ -0,0 +1,149 @@ +package com.yupi.project.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.yupi.project.annotation.AuthCheck; +import com.yupi.project.common.BaseResponse; +import com.yupi.project.common.DeleteRequest; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.common.ResultUtils; +import com.yupi.project.constant.UserConstant; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.exception.ThrowUtils; +import com.yupi.project.model.dto.parking_spot.ParkingSpotAddRequest; +import com.yupi.project.model.dto.parking_spot.ParkingSpotQueryRequest; +import com.yupi.project.model.dto.parking_spot.ParkingSpotUpdateRequest; +import com.yupi.project.model.entity.ParkingSpot; +import com.yupi.project.model.enums.ParkingSpotStatusEnum; +import com.yupi.project.service.ParkingSpotService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 车位管理接口 (管理员权限) + */ +@RestController +@RequestMapping("/admin/spot") +@Slf4j +@Api(tags = "车位管理接口 (管理员)") +public class ParkingSpotAdminController { + + @Resource + private ParkingSpotService parkingSpotService; + + @ApiOperation("添加车位") + @PostMapping("/add") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse addParkingSpot(@RequestBody ParkingSpotAddRequest addRequest, HttpServletRequest request) { + if (addRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + if (StringUtils.isBlank(addRequest.getSpotUid())) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "车位UID不能为空"); + } + // robotAssignable 默认为true,如果 DTO 中是 Boolean,则 null 会被处理 + + ParkingSpot spot = parkingSpotService.addParkingSpot( + addRequest.getSpotUid(), + addRequest.getLocationDescription(), + addRequest.getRobotAssignable() == null ? true : addRequest.getRobotAssignable() + ); + + if (spot == null || spot.getId() == null) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加车位失败"); + } + return ResultUtils.success(spot.getId()); + } + + @ApiOperation("删除车位") + @PostMapping("/delete") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse deleteParkingSpot(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) { + if (deleteRequest == null || deleteRequest.getId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + // 检查车位是否正在使用等逻辑可以在Service层实现,这里直接删除 + boolean b = parkingSpotService.removeById(deleteRequest.getId()); + return ResultUtils.success(b); + } + + @ApiOperation("更新车位信息") + @PostMapping("/update") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse updateParkingSpot(@RequestBody ParkingSpotUpdateRequest updateRequest, HttpServletRequest request) { + if (updateRequest == null || updateRequest.getId() == null || updateRequest.getId() <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + ParkingSpot parkingSpot = new ParkingSpot(); + BeanUtils.copyProperties(updateRequest, parkingSpot); + + if (StringUtils.isNotBlank(updateRequest.getStatus()) && ParkingSpotStatusEnum.getEnumByValue(updateRequest.getStatus()) == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的车位状态值"); + } + + boolean result = parkingSpotService.updateById(parkingSpot); + ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); + return ResultUtils.success(true); + } + + @ApiOperation("根据ID获取车位信息") + @GetMapping("/get") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse getParkingSpotById(long id, HttpServletRequest request) { + if (id <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + ParkingSpot parkingSpot = parkingSpotService.getById(id); + ThrowUtils.throwIf(parkingSpot == null, ErrorCode.NOT_FOUND_ERROR); + return ResultUtils.success(parkingSpot); + } + + @ApiOperation("分页获取车位列表") + @PostMapping("/list/page") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> listParkingSpotsByPage(@RequestBody ParkingSpotQueryRequest queryRequest, HttpServletRequest request) { + if (queryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR); + } + long current = queryRequest.getCurrent(); + long size = queryRequest.getPageSize(); + Page page = new Page<>(current, size); + QueryWrapper queryWrapper = new QueryWrapper<>(); + + if (StringUtils.isNotBlank(queryRequest.getSpotUid())) { + queryWrapper.like("spot_uid", queryRequest.getSpotUid()); + } + if (StringUtils.isNotBlank(queryRequest.getStatus())) { + queryWrapper.eq("status", queryRequest.getStatus()); + } + if (queryRequest.getRobotAssignable() != null) { + queryWrapper.eq("robot_assignable", queryRequest.getRobotAssignable()); + } + if (StringUtils.isNotBlank(queryRequest.getSortField())) { + queryWrapper.orderBy(true, queryRequest.getSortOrder().equals("ascend"), queryRequest.getSortField()); + } + + parkingSpotService.page(page, queryWrapper); + return ResultUtils.success(page); + } + + @ApiOperation("获取所有车位状态类型") + @GetMapping("/status/types") + @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) + public BaseResponse> getParkingSpotStatusTypes() { + List statusTypes = Arrays.stream(ParkingSpotStatusEnum.values()) + .map(ParkingSpotStatusEnum::getValue) + .collect(Collectors.toList()); + return ResultUtils.success(statusTypes); + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/exception/ThrowUtils.java b/springboot-init-main/src/main/java/com/yupi/project/exception/ThrowUtils.java new file mode 100644 index 0000000..4292f98 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/exception/ThrowUtils.java @@ -0,0 +1,45 @@ +package com.yupi.project.exception; + +import com.yupi.project.common.ErrorCode; + +/** + * 抛异常工具类 + * + */ +public final class ThrowUtils { + + /** + * 条件成立则抛异常 + * + * @param condition + * @param runtimeException + */ + public static void throwIf(boolean condition, RuntimeException runtimeException) { + if (condition) { + throw runtimeException; + } + } + + /** + * 条件成立则抛异常 + * + * @param condition + * @param errorCode + */ + public static void throwIf(boolean condition, ErrorCode errorCode) { + throwIf(condition, new BusinessException(errorCode)); + } + + /** + * 条件成立则抛异常 + * + * @param condition + * @param errorCode + * @param message + */ + public static void throwIf(boolean condition, ErrorCode errorCode, String message) { + throwIf(condition, new BusinessException(errorCode, message)); + } + + private ThrowUtils() {} +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mapper/ChargingRobotMapper.java b/springboot-init-main/src/main/java/com/yupi/project/mapper/ChargingRobotMapper.java new file mode 100644 index 0000000..f7789c7 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mapper/ChargingRobotMapper.java @@ -0,0 +1,14 @@ +package com.yupi.project.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yupi.project.model.entity.ChargingRobot; + +/** +* @author Yupi +* @description 针对表【charging_robot(充电机器人表)】的数据库操作Mapper +* @createDate 2023-12-03 10:00:00 +* @Entity com.yupi.project.model.entity.ChargingRobot +*/ +public interface ChargingRobotMapper extends BaseMapper { + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mapper/ChargingSessionMapper.java b/springboot-init-main/src/main/java/com/yupi/project/mapper/ChargingSessionMapper.java new file mode 100644 index 0000000..21d00b2 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mapper/ChargingSessionMapper.java @@ -0,0 +1,14 @@ +package com.yupi.project.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yupi.project.model.entity.ChargingSession; + +/** +* @author Yupi +* @description 针对表【charging_session(充电记录表)】的数据库操作Mapper +* @createDate 2023-12-03 10:00:00 +* @Entity com.yupi.project.model.entity.ChargingSession +*/ +public interface ChargingSessionMapper extends BaseMapper { + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mapper/ParkingSpotMapper.java b/springboot-init-main/src/main/java/com/yupi/project/mapper/ParkingSpotMapper.java new file mode 100644 index 0000000..d1c1934 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mapper/ParkingSpotMapper.java @@ -0,0 +1,14 @@ +package com.yupi.project.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yupi.project.model.entity.ParkingSpot; + +/** +* @author Yupi +* @description 针对表【parking_spot(车位表)】的数据库操作Mapper +* @createDate 2023-12-03 10:00:00 +* @Entity com.yupi.project.model.entity.ParkingSpot +*/ +public interface ParkingSpotMapper extends BaseMapper { + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mapper/UserMapper.java b/springboot-init-main/src/main/java/com/yupi/project/mapper/UserMapper.java index 4efdd78..9433118 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/mapper/UserMapper.java +++ b/springboot-init-main/src/main/java/com/yupi/project/mapper/UserMapper.java @@ -2,6 +2,8 @@ package com.yupi.project.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.yupi.project.model.entity.User; +import org.apache.ibatis.annotations.Param; +import java.math.BigDecimal; /** * @description 针对表【user(用户表)】的数据库操作Mapper @@ -10,4 +12,11 @@ import com.yupi.project.model.entity.User; */ public interface UserMapper extends BaseMapper { + /** + * 更新用户余额 + * @param userId 用户ID + * @param amountChange 要变更的金额 (元), 正数表示增加,负数表示减少 + * @return 影响的行数 + */ + int updateUserBalance(@Param("userId") Long userId, @Param("amountChange") BigDecimal amountChange); } \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotAddRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotAddRequest.java new file mode 100644 index 0000000..0749207 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotAddRequest.java @@ -0,0 +1,32 @@ +package com.yupi.project.model.dto.charging_robot; + +import lombok.Data; +import java.io.Serializable; + +/** + * 添加充电机器人请求 + */ +@Data +public class ChargingRobotAddRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 机器人唯一标识符 + */ + private String robotUid; + + /** + * 初始状态 (来自 RobotStatusEnum 的 value) + */ + private String status; + + /** + * 初始电池电量百分比 (0-100) + */ + private Integer batteryLevel; + + /** + * 初始当前位置描述 + */ + private String currentLocation; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotQueryRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotQueryRequest.java new file mode 100644 index 0000000..e355671 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotQueryRequest.java @@ -0,0 +1,27 @@ +package com.yupi.project.model.dto.charging_robot; + +import com.yupi.project.common.PageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serializable; + +/** + * 充电机器人查询请求 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class ChargingRobotQueryRequest extends PageRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 机器人唯一标识符 (模糊查询) + */ + private String robotUid; + + /** + * 状态 (精确查询, 来自 RobotStatusEnum 的 value) + */ + private String status; + + // 可以根据需要添加其他查询条件,如电量范围等 +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotUpdateRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotUpdateRequest.java new file mode 100644 index 0000000..31df61f --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_robot/ChargingRobotUpdateRequest.java @@ -0,0 +1,39 @@ +package com.yupi.project.model.dto.charging_robot; + +import lombok.Data; +import java.io.Serializable; + +/** + * 更新充电机器人请求 + */ +@Data +public class ChargingRobotUpdateRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 机器人ID (主键) + */ + private Long id; + + /** + * 机器人唯一标识符 (一般不允许修改,但提供以便查询) + */ + private String robotUid; + + /** + * 状态 (来自 RobotStatusEnum 的 value) + */ + private String status; + + /** + * 当前位置描述 + */ + private String currentLocation; + + /** + * 电池电量百分比 (0-100) + */ + private Integer batteryLevel; + + // currentTaskId 不建议直接通过此接口修改,应由业务流程驱动 +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/ChargingRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/ChargingRequest.java new file mode 100644 index 0000000..31df4b3 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/ChargingRequest.java @@ -0,0 +1,28 @@ +package com.yupi.project.model.dto.charging_session; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 用户发起充电请求的 DTO + */ +@Data +public class ChargingRequest implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 请求充电的车位ID (parking_spot.id) + */ + private Long spotId; + + /** + * 用户期望充电时长 (分钟) - 可选,系统也可有默认值或按需充电 + */ + private Integer expectedDurationMinutes; + + // 可以根据需求添加其他参数,例如: + // private String vehiclePlate; // 车牌号,用于某些特定场景 + // private String preferredRobotType; // 偏好的机器人类型 (如果支持多种) +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/ChargingSessionQueryRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/ChargingSessionQueryRequest.java new file mode 100644 index 0000000..dca7adb --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/ChargingSessionQueryRequest.java @@ -0,0 +1,62 @@ +package com.yupi.project.model.dto.charging_session; + +import com.yupi.project.common.PageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serializable; +import java.util.List; + +/** + * 充电会话查询请求 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class ChargingSessionQueryRequest extends PageRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 会话ID + */ + private Long id; + + /** + * 用户ID (管理员接口可能需要) + */ + private Long userId; + + /** + * 机器人ID + */ + private Long robotId; + + /** + * 车位ID + */ + private Long spotId; + + /** + * 机器人UID快照 (模糊查询) + */ + private String robotUidSnapshot; + + /** + * 车位UID快照 (模糊查询) + */ + private String spotUidSnapshot; + + /** + * 会话状态 (精确查询, 来自 ChargingSessionStatusEnum 的 value) + */ + private String status; + + /** + * 多个会话状态 (用于查询) + */ + private List orStatusList; + + /** + * 支付状态 (精确查询, 来自 PaymentStatusEnum 的 value) + */ + private String paymentStatus; + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/PaymentRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/PaymentRequest.java new file mode 100644 index 0000000..87b5ad2 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/charging_session/PaymentRequest.java @@ -0,0 +1,17 @@ +package com.yupi.project.model.dto.charging_session; + +import lombok.Data; +import java.io.Serializable; + +/** + * 支付/取消请求 (主要用于传递sessionId) + */ +@Data +public class PaymentRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 充电会话ID + */ + private Long sessionId; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/mqtt/RobotStatusMessage.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/mqtt/RobotStatusMessage.java index ea6ba42..dd3f005 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/model/dto/mqtt/RobotStatusMessage.java +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/mqtt/RobotStatusMessage.java @@ -5,9 +5,17 @@ import lombok.Data; @Data public class RobotStatusMessage { - private Long taskId; - private String status; // e.g., "PROCESSING", "COMPLETED", "FAILED" - private String message; - private String errorCode; + // Fields for Task ACK / specific message context + private Long taskId; // ID of the task this message might be an ACK for + private String status; // Status related to the taskId (e.g., "PROCESSING", "COMPLETED", "FAILED") + private String message; // General message, or error message for a task + private String errorCode; // Error code for a task failure + + // Fields for general robot status reporting (heartbeat, unsolicited status) + private String robotUid; // UID of the robot sending this status + private String actualRobotStatus; // The robot's current operational status (e.g., from RobotStatusEnum) + private String location; // Robot's current location + private Integer batteryLevel; // Robot's current battery level + private Long activeTaskId; // ID of the task the robot is currently busy with (if any) } \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotAddRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotAddRequest.java new file mode 100644 index 0000000..727cc5d --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotAddRequest.java @@ -0,0 +1,27 @@ +package com.yupi.project.model.dto.parking_spot; + +import lombok.Data; +import java.io.Serializable; + +/** + * 添加车位请求 + */ +@Data +public class ParkingSpotAddRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 车位唯一标识符 + */ + private String spotUid; + + /** + * 位置描述 + */ + private String locationDescription; + + /** + * 此车位是否可指派机器人前往 (默认为 true) + */ + private Boolean robotAssignable; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotQueryRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotQueryRequest.java new file mode 100644 index 0000000..bde326b --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotQueryRequest.java @@ -0,0 +1,31 @@ +package com.yupi.project.model.dto.parking_spot; + +import com.yupi.project.common.PageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serializable; + +/** + * 车位查询请求 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class ParkingSpotQueryRequest extends PageRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 车位唯一标识符 (模糊查询) + */ + private String spotUid; + + /** + * 状态 (精确查询, 来自 ParkingSpotStatusEnum 的 value) + */ + private String status; + + /** + * 此车位是否可指派机器人前往 (精确查询) + */ + private Boolean robotAssignable; + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotUpdateRequest.java b/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotUpdateRequest.java new file mode 100644 index 0000000..de9f048 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/dto/parking_spot/ParkingSpotUpdateRequest.java @@ -0,0 +1,39 @@ +package com.yupi.project.model.dto.parking_spot; + +import lombok.Data; +import java.io.Serializable; + +/** + * 更新车位请求 + */ +@Data +public class ParkingSpotUpdateRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 车位ID (主键) + */ + private Long id; + + /** + * 车位唯一标识符 + */ + private String spotUid; + + /** + * 状态 (来自 ParkingSpotStatusEnum 的 value) + */ + private String status; + + /** + * 位置描述 + */ + private String locationDescription; + + /** + * 此车位是否可指派机器人前往 + */ + private Boolean robotAssignable; + + // currentSessionId 不建议直接通过此接口修改,应由业务流程驱动 +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/entity/ChargingRobot.java b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ChargingRobot.java new file mode 100644 index 0000000..35a6d32 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ChargingRobot.java @@ -0,0 +1,71 @@ +package com.yupi.project.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 充电机器人表 + * + * @TableName charging_robot + */ +@TableName(value = "charging_robot") +@Data +public class ChargingRobot implements Serializable { + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 机器人唯一标识符 (例如,硬件序列号) + */ + private String robotUid; + + /** + * 机器人状态 (IDLE, MOVING, CHARGING, ERROR, MAINTENANCE, OFFLINE) + */ + private String status; + + /** + * 当前位置描述 (例如,坐标或区域ID) + */ + private String currentLocation; + + /** + * 电池电量百分比 (0-100) + */ + private Integer batteryLevel; + + /** + * 当前执行的任务ID (关联 robot_task.id) + */ + private Long currentTaskId; + + /** + * 最后心跳时间 + */ + private Date lastHeartbeatTime; + + /** + * 注册时间 + */ + private Date createTime; + + /** + * 最后更新时间 + */ + private Date updateTime; + + /** + * 逻辑删除标志 (0:未删, 1:已删) + */ + @TableLogic + private Integer isDeleted; + + @TableField(exist = false) + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/entity/ChargingSession.java b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ChargingSession.java new file mode 100644 index 0000000..bf7b198 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ChargingSession.java @@ -0,0 +1,132 @@ +package com.yupi.project.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 充电记录表 + * + * @TableName charging_session + */ +@TableName(value = "charging_session") +@Data +public class ChargingSession implements Serializable { + /** + * 主键ID,充电会话唯一标识 + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 用户ID (关联 user.id) + */ + private Long userId; + + /** + * 服务的机器人ID (关联 charging_robot.id) + */ + private Long robotId; + + /** + * 服务机器人的UID快照 + */ + private String robotUidSnapshot; + + /** + * 车位ID (关联 parking_spot.id) + */ + private Long spotId; + + /** + * 车位UID快照 + */ + private String spotUidSnapshot; + + /** + * 充电会话状态 + */ + private String status; + + /** + * 用户请求充电时间 + */ + private Date requestTime; + + /** + * 机器人分配时间 + */ + private Date robotAssignedTime; + + /** + * 机器人到达车位时间 + */ + private Date robotArrivalTime; + + /** + * 充电开始时间 + */ + private Date chargeStartTime; + + /** + * 充电结束时间 + */ + private Date chargeEndTime; + + /** + * 总充电时长 (秒) + */ + private Integer totalDurationSeconds; + + /** + * 消耗电量 (kWh) + */ + private BigDecimal energyConsumedKwh; + + /** + * 本次充电费用 (元) + */ + private BigDecimal cost; + + /** + * 支付状态 + */ + private String paymentStatus; + + /** + * 错误码 + */ + private String errorCode; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 关联的RobotTask ID + */ + private Long relatedRobotTaskId; + + /** + * 记录创建时间 + */ + private Date createTime; + + /** + * 记录更新时间 + */ + private Date updateTime; + + /** + * 逻辑删除标志 (0:未删, 1:已删) + */ + @TableLogic + private Integer isDeleted; + + @TableField(exist = false) + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/entity/ParkingSpot.java b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ParkingSpot.java new file mode 100644 index 0000000..0dee532 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/entity/ParkingSpot.java @@ -0,0 +1,66 @@ +package com.yupi.project.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 车位表 + * + * @TableName parking_spot + */ +@TableName(value = "parking_spot") +@Data +public class ParkingSpot implements Serializable { + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 车位唯一标识符 (例如,车位号) + */ + private String spotUid; + + /** + * 车位状态 (AVAILABLE, OCCUPIED_BY_CAR, RESERVED, CHARGING, MAINTENANCE, UNAVAILABLE) + */ + private String status; + + /** + * 位置描述 (例如,楼层、区域) + */ + private String locationDescription; + + /** + * 此车位是否可指派机器人前往 + */ + private Boolean robotAssignable; + + /** + * 当前占用此车位的充电会话ID (关联 charging_session.id) + */ + private Long currentSessionId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 最后更新时间 + */ + private Date updateTime; + + /** + * 逻辑删除标志 (0:未删, 1:已删) + */ + @TableLogic + private Integer isDeleted; + + @TableField(exist = false) + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/entity/RobotTask.java b/springboot-init-main/src/main/java/com/yupi/project/model/entity/RobotTask.java index c51509f..107a65e 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/model/entity/RobotTask.java +++ b/springboot-init-main/src/main/java/com/yupi/project/model/entity/RobotTask.java @@ -17,34 +17,42 @@ public class RobotTask implements Serializable { @TableId(type = IdType.AUTO) private Long id; + @TableField("robot_id") private String robotId; // Store enum as String in DB, but handle as Enum in Java // Consider using MyBatis-Plus EnumTypeHandler.VARCHAR if direct mapping is problematic, // or ensure the String value from the enum is used for persistence. + @TableField("command_type") private CommandTypeEnum commandType; + @TableField("command_payload") private String commandPayload; // Store enum as String in DB + @TableField("status") private RobotTaskStatusEnum status; + @TableField("sent_time") private Date sentTime; + @TableField("ack_time") private Date ackTime; - private Long relatedSessionId; + @TableField("related_session_id") + private Long relatedSessionId; // 关联的充电会话ID (对应数据库表中的related_session_id) + @TableField("error_message") private String errorMessage; - @TableField(fill = FieldFill.INSERT) + @TableField(value = "create_time", fill = FieldFill.INSERT) private Date createTime; - @TableField(fill = FieldFill.INSERT_UPDATE) + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) private Date updateTime; @TableLogic // If soft delete is desired, though not specified in DDL - @TableField(select = false) // By default, don't select the delete flag unless explicitly queried + @TableField(value = "is_delete", select = false) // By default, don't select the delete flag unless explicitly queried private Integer isDelete; // Common practice for @TableLogic private static final long serialVersionUID = 1L; diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/enums/ChargingSessionStatusEnum.java b/springboot-init-main/src/main/java/com/yupi/project/model/enums/ChargingSessionStatusEnum.java new file mode 100644 index 0000000..c00fb41 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/enums/ChargingSessionStatusEnum.java @@ -0,0 +1,48 @@ +package com.yupi.project.model.enums; + +import lombok.Getter; + +/** + * 充电会话状态枚举 + */ +@Getter +public enum ChargingSessionStatusEnum { + + REQUESTED("REQUESTED", "已请求"), // 用户发起充电请求 + ROBOT_ASSIGNED("ROBOT_ASSIGNED", "机器人已分配"), // 系统已分配机器人 + ROBOT_EN_ROUTE("ROBOT_EN_ROUTE", "机器人前往中"), // 机器人正在前往车位 (MQTT: move ack) + ROBOT_ARRIVED("ROBOT_ARRIVED", "机器人已到达"), // 机器人已到达指定车位 (MQTT: arrive ack) + CHARGING_STARTED("CHARGING_STARTED", "充电进行中"), // 机器人开始充电 (MQTT: start_charge ack) + CHARGING_COMPLETED("CHARGING_COMPLETED", "充电已完成"), // 机器人完成充电 (MQTT: stop_charge ack) + PAYMENT_PENDING("PAYMENT_PENDING", "待支付"), // 充电完成,等待用户支付 + PAID("PAID", "已支付"), // 用户已支付 + CANCELLED_BY_USER("CANCELLED_BY_USER", "用户取消"), // 用户在特定阶段取消 + CANCELLED_BY_SYSTEM("CANCELLED_BY_SYSTEM", "系统取消"), // 例如,无可用机器人,机器人故障等 + ERROR("ERROR", "会话错误"); // 充电过程中发生未知错误 + + private final String value; + private final String description; + + ChargingSessionStatusEnum(String value, String description) { + this.value = value; + this.description = description; + } + + /** + * 根据 value 获取枚举 + * + * @param value + * @return + */ + public static ChargingSessionStatusEnum getEnumByValue(String value) { + if (value == null) { + return null; + } + for (ChargingSessionStatusEnum anEnum : ChargingSessionStatusEnum.values()) { + if (anEnum.value.equals(value)) { + return anEnum; + } + } + return null; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/enums/CommandTypeEnum.java b/springboot-init-main/src/main/java/com/yupi/project/model/enums/CommandTypeEnum.java index 430a960..8f8db35 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/model/enums/CommandTypeEnum.java +++ b/springboot-init-main/src/main/java/com/yupi/project/model/enums/CommandTypeEnum.java @@ -13,10 +13,36 @@ public enum CommandTypeEnum { QUERY_STATUS("QUERY_STATUS", "查询状态"); private final String value; - private final String description; + private final String ackValue; - CommandTypeEnum(String value, String description) { + CommandTypeEnum(String value, String ackValue) { this.value = value; - this.description = description; + this.ackValue = ackValue; + } + + public String getValue() { + return value; + } + + public String getAckValue() { + return ackValue; + } + + /** + * 根据ackValue获取枚举 + * + * @param ackValue + * @return + */ + public static CommandTypeEnum fromAck(String ackValue) { + if (ackValue == null) { + return null; + } + for (CommandTypeEnum anEnum : CommandTypeEnum.values()) { + if (ackValue.equalsIgnoreCase(anEnum.getAckValue())) { + return anEnum; + } + } + return null; } } \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/enums/ParkingSpotStatusEnum.java b/springboot-init-main/src/main/java/com/yupi/project/model/enums/ParkingSpotStatusEnum.java new file mode 100644 index 0000000..63274d8 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/enums/ParkingSpotStatusEnum.java @@ -0,0 +1,43 @@ +package com.yupi.project.model.enums; + +import lombok.Getter; + +/** + * 车位状态枚举 + */ +@Getter +public enum ParkingSpotStatusEnum { + + AVAILABLE("AVAILABLE", "可用"), + OCCUPIED_BY_CAR("OCCUPIED_BY_CAR", "有车占用"), + RESERVED("RESERVED", "已预订"), + CHARGING("CHARGING", "充电中"), // 指此车位正在进行充电服务 + MAINTENANCE("MAINTENANCE", "维护中"), + UNAVAILABLE("UNAVAILABLE", "不可用"); + + private final String value; + private final String description; + + ParkingSpotStatusEnum(String value, String description) { + this.value = value; + this.description = description; + } + + /** + * 根据 value 获取枚举 + * + * @param value + * @return + */ + public static ParkingSpotStatusEnum getEnumByValue(String value) { + if (value == null) { + return null; + } + for (ParkingSpotStatusEnum anEnum : ParkingSpotStatusEnum.values()) { + if (anEnum.value.equals(value)) { + return anEnum; + } + } + return null; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/enums/PaymentStatusEnum.java b/springboot-init-main/src/main/java/com/yupi/project/model/enums/PaymentStatusEnum.java new file mode 100644 index 0000000..18ec441 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/enums/PaymentStatusEnum.java @@ -0,0 +1,42 @@ +package com.yupi.project.model.enums; + +import lombok.Getter; + +/** + * 支付状态枚举 + */ +@Getter +public enum PaymentStatusEnum { + + PENDING("PENDING", "待支付"), + PAID("PAID", "已支付"), + FAILED("FAILED", "支付失败"), + REFUNDED("REFUNDED", "已退款"), + NOT_REQUIRED("NOT_REQUIRED", "无需支付"); // 例如免费活动或内部测试 + + private final String value; + private final String description; + + PaymentStatusEnum(String value, String description) { + this.value = value; + this.description = description; + } + + /** + * 根据 value 获取枚举 + * + * @param value + * @return + */ + public static PaymentStatusEnum getEnumByValue(String value) { + if (value == null) { + return null; + } + for (PaymentStatusEnum anEnum : PaymentStatusEnum.values()) { + if (anEnum.value.equals(value)) { + return anEnum; + } + } + return null; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotStatusEnum.java b/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotStatusEnum.java new file mode 100644 index 0000000..67eacde --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotStatusEnum.java @@ -0,0 +1,43 @@ +package com.yupi.project.model.enums; + +import lombok.Getter; + +/** + * 机器人状态枚举 + */ +@Getter +public enum RobotStatusEnum { + + IDLE("IDLE", "空闲"), + MOVING("MOVING", "移动中"), + CHARGING("CHARGING", "充电服务中"), + ERROR("ERROR", "故障"), + MAINTENANCE("MAINTENANCE", "维护中"), + OFFLINE("OFFLINE", "离线"); + + private final String value; + private final String description; + + RobotStatusEnum(String value, String description) { + this.value = value; + this.description = description; + } + + /** + * 根据 value 获取枚举 + * + * @param value + * @return + */ + public static RobotStatusEnum getEnumByValue(String value) { + if (value == null) { + return null; + } + for (RobotStatusEnum anEnum : RobotStatusEnum.values()) { + if (anEnum.value.equals(value)) { + return anEnum; + } + } + return null; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotTaskStatusEnum.java b/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotTaskStatusEnum.java index ef67f6b..74f7edc 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotTaskStatusEnum.java +++ b/springboot-init-main/src/main/java/com/yupi/project/model/enums/RobotTaskStatusEnum.java @@ -36,4 +36,22 @@ public enum RobotTaskStatusEnum { .findFirst() .orElse(null); } + + /** + * 判断是否为最终状态 (COMPLETED, FAILED, TIMED_OUT) + * @param status 要检查的状态 + * @return 如果是最终状态则为 true + */ + public static boolean isFinalStatus(RobotTaskStatusEnum status) { + if (status == null) return false; + return status == COMPLETED || status == FAILED || status == TIMED_OUT; + } + + /** + * 判断当前枚举实例是否为最终状态 + * @return 如果是最终状态则为 true + */ + public boolean isFinalStatus() { + return this == COMPLETED || this == FAILED || this == TIMED_OUT; + } } \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/vo/ChargingSessionVO.java b/springboot-init-main/src/main/java/com/yupi/project/model/vo/ChargingSessionVO.java new file mode 100644 index 0000000..d318e7b --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/vo/ChargingSessionVO.java @@ -0,0 +1,97 @@ +package com.yupi.project.model.vo; + +import com.yupi.project.model.entity.ChargingSession; +import com.yupi.project.model.entity.User; +import com.yupi.project.model.enums.ChargingSessionStatusEnum; +import com.yupi.project.model.enums.PaymentStatusEnum; +import com.yupi.project.model.vo.UserVO; +import lombok.Data; +import org.springframework.beans.BeanUtils; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 充电会话视图对象 + */ +@Data +public class ChargingSessionVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private Long userId; + private UserVO user; // 关联的用户信息 (脱敏后) + private Long robotId; + // private ChargingRobotVO robot; // 关联的机器人信息 (可选) + private String robotUidSnapshot; + private Long spotId; + // private ParkingSpotVO spot; // 关联的车位信息 (可选) + private String spotUidSnapshot; + private String status; + private String statusText; // 状态文本描述 + private Date requestTime; + private Date robotAssignedTime; + private Date robotArrivalTime; + private Date chargeStartTime; + private Date chargeEndTime; + private Integer totalDurationSeconds; + private BigDecimal energyConsumedKwh; + private BigDecimal cost; + private String paymentStatus; + private String paymentStatusText; // 支付状态文本描述 + private String errorCode; + private String errorMessage; + private Long relatedRobotTaskId; + private Date createTime; + private Date updateTime; + + /** + * 包装类转对象 + * + * @param vo + * @return + */ + public static ChargingSession voToObj(ChargingSessionVO vo) { + if (vo == null) { + return null; + } + ChargingSession obj = new ChargingSession(); + BeanUtils.copyProperties(vo, obj); + return obj; + } + + /** + * 对象转包装类 + * + * @param obj + * @return + */ + public static ChargingSessionVO objToVo(ChargingSession obj) { + if (obj == null) { + return null; + } + ChargingSessionVO vo = new ChargingSessionVO(); + BeanUtils.copyProperties(obj, vo); + // 根据枚举值设置 statusText 和 paymentStatusText + if (obj.getStatus() != null) { + ChargingSessionStatusEnum statusEnum = ChargingSessionStatusEnum.getEnumByValue(obj.getStatus()); + if (statusEnum != null) { + vo.setStatusText(statusEnum.getDescription()); + } + } + if (obj.getPaymentStatus() != null) { + PaymentStatusEnum paymentStatusEnum = PaymentStatusEnum.getEnumByValue(obj.getPaymentStatus()); + if (paymentStatusEnum != null) { + vo.setPaymentStatusText(paymentStatusEnum.getDescription()); + } + } + return vo; + } + + // 如果需要关联 UserVO 等,可以添加 set方法 + public void setUser(User user) { + if (user != null) { + this.user = UserVO.objToVo(user); // 假设 UserVO 也有类似的转换方法 + } + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/model/vo/UserVO.java b/springboot-init-main/src/main/java/com/yupi/project/model/vo/UserVO.java new file mode 100644 index 0000000..a87ed40 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/model/vo/UserVO.java @@ -0,0 +1,88 @@ +package com.yupi.project.model.vo; + +import com.yupi.project.model.entity.User; +import lombok.Data; +import org.springframework.beans.BeanUtils; + +import java.io.Serializable; +import java.util.Date; + +/** + * 用户视图 + * + */ +@Data +public class UserVO implements Serializable { + /** + * id + */ + private Long id; + + /** + * 用户名 + */ + private String userName; + + /** + * 用户头像 + */ + private String userAvatar; + + /** + * 用户简介 + */ + private String userProfile; + + /** + * 用户角色: user/admin/ban + */ + private String userRole; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; + + /** + * 余额 (分) + */ + private Long balance; // 新增余额字段,与User实体对应 + + private static final long serialVersionUID = 1L; + + /** + * 包装类转对象 + * + * @param userVO + * @return + */ + public static User voToObj(UserVO userVO) { + if (userVO == null) { + return null; + } + User user = new User(); + BeanUtils.copyProperties(userVO, user); + return user; + } + + /** + * 对象转包装类 + * + * @param user + * @return + */ + public static UserVO objToVo(User user) { + if (user == null) { + return null; + } + UserVO userVO = new UserVO(); + BeanUtils.copyProperties(user, userVO); + // 注意:此处不应复制敏感信息如 userPassword + return userVO; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/MqttMessageHandler.java b/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/MqttMessageHandler.java new file mode 100644 index 0000000..91e5b87 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/MqttMessageHandler.java @@ -0,0 +1,17 @@ +package com.yupi.project.mqtt.handler; + +/** + * MQTT消息处理器接口 + */ +public interface MqttMessageHandler { + + /** + * 处理来自MQTT的状态更新消息 + * + * @param topic 消息主题 + * @param payload 消息内容 + */ + void handleStatusUpdate(String topic, String payload); + + // 可以根据需要添加其他类型的消息处理方法 +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/MqttMessageHandlerImpl.java b/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/MqttMessageHandlerImpl.java new file mode 100644 index 0000000..7846b3f --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/MqttMessageHandlerImpl.java @@ -0,0 +1,111 @@ +package com.yupi.project.mqtt.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yupi.project.model.entity.RobotTask; +import com.yupi.project.model.enums.CommandTypeEnum; +import com.yupi.project.model.enums.RobotTaskStatusEnum; +import com.yupi.project.model.enums.ChargingSessionStatusEnum; +import com.yupi.project.service.ChargingSessionService; +import com.yupi.project.service.RobotTaskService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Date; +import java.util.Map; + +@Component +@Slf4j +public class MqttMessageHandlerImpl implements MqttMessageHandler { + + @Resource + private RobotTaskService robotTaskService; + + @Resource + private ChargingSessionService chargingSessionService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handleStatusUpdate(String topic, String payload) { + log.info("收到MQTT状态消息 - Topic: {}, Payload: {}", topic, payload); + try { + // 1. 解析Payload + Map payloadMap = objectMapper.readValue(payload, Map.class); + String robotUid = extractRobotUidFromTopic(topic); + String commandTypeStr = (String) payloadMap.get("command_ack"); + CommandTypeEnum commandType = CommandTypeEnum.fromAck(commandTypeStr); + Long originalTaskId = ((Number) payloadMap.get("task_id")).longValue(); + boolean success = (Boolean) payloadMap.getOrDefault("success", false); + String message = (String) payloadMap.getOrDefault("message", ""); + + if (robotUid == null || commandType == null || originalTaskId == null) { + log.error("MQTT状态消息解析失败:robotUid, commandType,或taskId为空. Topic: {}, Payload: {}", topic, payload); + return; + } + + // 2. 更新RobotTask状态 + RobotTask task = robotTaskService.getById(originalTaskId); + if (task == null) { + log.warn("未找到与MQTT消息关联的机器人任务: taskId={}, robotUid={}", originalTaskId, robotUid); + return; + } + if (!RobotTaskStatusEnum.SENT.getValue().equals(task.getStatus())){ + log.warn("任务 {} 状态为 {}, 非SENT状态,不再处理ACK消息。", originalTaskId, task.getStatus()); + return; + } + robotTaskService.markTaskAsAcknowledged(originalTaskId, success, success ? null : message, new Date()); + log.info("机器人任务 {} (robotUid: {}) 已确认为: {}, 消息: '{}'", originalTaskId, robotUid, success ? "成功" : "失败", message); + + // 3. 如果任务成功,则根据任务类型更新 ChargingSession + if (success) { + Long sessionId = task.getRelatedSessionId(); + if (sessionId == null) { + log.warn("机器人任务 {} 成功,但未关联充电会话ID,不处理会话状态更新。", originalTaskId); + return; + } + + switch (commandType) { + case MOVE_TO_SPOT: + log.info("机器人 {} 到达车位指令已确认 (任务ID: {}), 更新会话 {} 状态。", robotUid, originalTaskId, sessionId); + chargingSessionService.handleRobotArrival(sessionId, originalTaskId); + break; + case START_CHARGE: + log.info("机器人 {} 开始充电指令已确认 (任务ID: {}), 更新会话 {} 状态。", robotUid, originalTaskId, sessionId); + chargingSessionService.handleChargingStart(sessionId, originalTaskId); + break; + case STOP_CHARGE: + BigDecimal energyConsumed = payloadMap.containsKey("energy_kwh") ? new BigDecimal(payloadMap.get("energy_kwh").toString()) : BigDecimal.ZERO; + int durationSeconds = payloadMap.containsKey("duration_s") ? ((Number) payloadMap.get("duration_s")).intValue() : 0; + log.info("机器人 {} 停止充电指令已确认 (任务ID: {}), 更新会话 {} 状态。电量: {}kWh, 时长: {}s", + robotUid, originalTaskId, sessionId, energyConsumed, durationSeconds); + chargingSessionService.handleChargingEnd(sessionId, originalTaskId, energyConsumed, durationSeconds); + break; + default: + log.info("收到机器人任务 {} (类型:{}) 的ACK,但此类型不直接更新充电会话。", originalTaskId, commandType); + break; + } + } else { + Long sessionId = task.getRelatedSessionId(); + if (sessionId != null) { + log.warn("机器人任务 {} (类型:{}) 失败,将关联的会话 {} 标记为错误。错误: {}", originalTaskId, commandType, sessionId, message); + chargingSessionService.cancelChargingSession(sessionId, null, + "机器人任务失败: " + commandType + " - " + message, + ChargingSessionStatusEnum.ERROR); + } + } + + } catch (Exception e) { + log.error("处理MQTT状态消息时发生错误 - Topic: {}, Payload: {}", topic, payload, e); + } + } + + private String extractRobotUidFromTopic(String topic) { + String[] parts = topic.split("/"); + if (parts.length > 0) { + return parts[parts.length - 1]; + } + return null; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/TaskTimeoutHandler.java b/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/TaskTimeoutHandler.java new file mode 100644 index 0000000..a64d00b --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/mqtt/handler/TaskTimeoutHandler.java @@ -0,0 +1,52 @@ +import com.yupi.project.model.entity.RobotTask; +import com.yupi.project.service.ChargingSessionService; +import com.yupi.project.service.RobotTaskService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.List; + +@Component +@Slf4j +public class TaskTimeoutHandler { + + @Resource + private RobotTaskService robotTaskService; + + @Resource + private ChargingSessionService chargingSessionService; + + @Value("${mqtt.task.timeoutSeconds:300}") + private int taskTimeoutSeconds; + + @Scheduled(fixedRateString = "${mqtt.task.timeoutCheckRateMs:60000}") + public void checkForTimedOutTasks() { + log.debug("开始检查超时的机器人任务 (超时阈值: {}s)", taskTimeoutSeconds); + List timedOutTasks = robotTaskService.findAndMarkTimedOutTasks(taskTimeoutSeconds); + + if (timedOutTasks.isEmpty()) { + log.debug("没有发现超时的机器人任务。"); + return; + } + + log.warn("发现 {} 个超时的机器人任务。", timedOutTasks.size()); + for (RobotTask task : timedOutTasks) { + log.warn("任务ID: {} (RobotUID: {}, Command: {}, Status: {}, SentTime: {}) 已超时。", + task.getId(), task.getRobotId(), task.getCommandType(), task.getStatus(), task.getSentTime()); + + if (task.getRelatedSessionId() != null) { + log.info("任务 {} 超时与充电会话 {} 相关联,将通知会话服务处理。", task.getId(), task.getRelatedSessionId()); + try { + chargingSessionService.handleSessionTaskTimeout(task.getId()); + } catch (Exception e) { + log.error("调用 chargingSessionService.handleSessionTaskTimeout 失败 for task ID {}: ", task.getId(), e); + } + } else { + log.warn("任务 {} 超时,但未关联任何充电会话。", task.getId()); + } + } + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/ChargingRobotService.java b/springboot-init-main/src/main/java/com/yupi/project/service/ChargingRobotService.java new file mode 100644 index 0000000..5ffb614 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/ChargingRobotService.java @@ -0,0 +1,89 @@ +package com.yupi.project.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.yupi.project.model.entity.ChargingRobot; +import com.yupi.project.model.enums.RobotStatusEnum; + +import java.util.Date; +import java.util.List; + +/** +* @author Yupi +* @description 针对表【charging_robot(充电机器人表)】的数据库操作Service +* @createDate 2023-12-03 10:00:00 +*/ +public interface ChargingRobotService extends IService { + + /** + * 注册一个新的充电机器人 + * + * @param robotUid 机器人唯一ID + * @param initialStatus 初始状态 + * @return 创建的机器人实体,如果已存在则返回null或抛出异常 + */ + ChargingRobot registerRobot(String robotUid, RobotStatusEnum initialStatus); + + /** + * 根据机器人UID查找机器人 + * + * @param robotUid 机器人唯一ID + * @return 机器人实体,未找到则返回null + */ + ChargingRobot findByRobotUid(String robotUid); + + /** + * 更新机器人电量 + * + * @param robotId 机器人DB主键ID + * @param batteryLevel 电量百分比 + * @return 是否更新成功 + */ + boolean updateBatteryLevel(Long robotId, Integer batteryLevel); + + /** + * 更新机器人心跳时间 + * + * @param robotId 机器人DB主键ID + * @param heartbeatTime 心跳时间 + * @return 是否更新成功 + */ + boolean updateHeartbeatTime(Long robotId, Date heartbeatTime); + + /** + * 查找指定状态的机器人列表 + * + * @param status 机器人状态 + * @return 机器人列表 + */ + List findRobotsByStatus(RobotStatusEnum status); + + /** + * 分配一个空闲的机器人用于执行任务 + * (此方法需要考虑并发和选择策略) + * + * @return 分配到的机器人实体,若无空闲则返回null + */ + ChargingRobot assignIdleRobot(); + + /** + * 释放机器人(例如任务完成或取消后) + * + * @param robotId 机器人ID + * @return 操作是否成功 + */ + boolean releaseRobot(Long robotId); + + /** + * 更新机器人的实时状态。 + * 此方法用于处理机器人通过MQTT等方式上报的常规状态信息(如心跳)。 + * + * @param robotUID 机器人唯一标识符 + * @param status 机器人上报的当前状态 (枚举) + * @param location 机器人上报的当前位置 (可选) + * @param batteryLevel 机器人上报的电池电量 (可选, 0-100) + * @param currentTaskId 机器人当前正在执行的任务ID (可选, 如果上报了) + * @param lastHeartbeatTime 收到此状态消息的时间,可视为最后心跳时间 + * @return 更新是否成功 + */ + boolean updateRobotStatus(String robotUID, RobotStatusEnum status, String location, Integer batteryLevel, Long currentTaskId, Date lastHeartbeatTime); +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/ChargingSessionService.java b/springboot-init-main/src/main/java/com/yupi/project/service/ChargingSessionService.java new file mode 100644 index 0000000..dcc7988 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/ChargingSessionService.java @@ -0,0 +1,134 @@ +package com.yupi.project.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.yupi.project.model.dto.charging_session.ChargingRequest; +import com.yupi.project.model.entity.ChargingSession; +import com.yupi.project.model.entity.User; +import com.yupi.project.model.enums.ChargingSessionStatusEnum; +import com.yupi.project.model.dto.charging_session.ChargingSessionQueryRequest; + +import java.math.BigDecimal; +import java.util.List; + +/** +* @author Yupi +* @description 针对表【charging_session(充电记录表)】的数据库操作Service +* @createDate 2023-12-03 10:00:00 +*/ +public interface ChargingSessionService extends IService { + + /** + * 用户请求充电 + * + * @param currentUser 当前登录用户 + * @param chargingRequest 充电请求参数 (如 车位ID等) + * @return 创建的充电会话实体 + */ + ChargingSession requestCharging(User currentUser, ChargingRequest chargingRequest); + + /** + * 系统处理充电请求,分配机器人 + * (通常在 requestCharging 内部被调用,或由异步任务触发) + * + * @param sessionId 充电会话ID + * @return 更新后的充电会话实体,或null如果无法分配 + */ + ChargingSession assignRobotToSession(Long sessionId); + + /** + * 更新充电会话状态 (机器人已到达) + * + * @param sessionId 充电会话ID + * @param robotTaskId 关联的机器人任务ID (例如,机器人移动任务) + * @return 是否成功 + */ + boolean handleRobotArrival(Long sessionId, Long robotTaskId); + + /** + * 更新充电会话状态 (充电开始) + * + * @param sessionId 充电会话ID + * @param robotTaskId 关联的机器人任务ID (例如,开始充电任务) + * @return 是否成功 + */ + boolean handleChargingStart(Long sessionId, Long robotTaskId); + + /** + * 更新充电会话状态 (充电结束) + * + * @param sessionId 充电会话ID + * @param robotTaskId 关联的机器人任务ID (例如,结束充电任务) + * @param energyConsumedKwh 消耗电量 + * @param durationSeconds 充电时长 + * @return 是否成功 + */ + boolean handleChargingEnd(Long sessionId, Long robotTaskId, BigDecimal energyConsumedKwh, int durationSeconds); + + /** + * 计算费用并更新会话 (在充电结束后调用) + * + * @param sessionId 充电会话ID + * @return 是否成功 + */ + boolean calculateCostAndFinalizeSession(Long sessionId); + + /** + * 用户支付充电费用 + * + * @param sessionId 充电会话ID + * @param userId 用户ID (用于校验) + * @return 是否成功 + */ + boolean processPayment(Long sessionId, Long userId); + + /** + * 取消充电会话 + * + * @param sessionId 充电会话ID + * @param userId (可选) 如果是用户发起的取消,则为用户ID + * @param reason 取消原因/发起方 + * @return 是否成功 + */ + boolean cancelChargingSession(Long sessionId, Long userId, String reason, ChargingSessionStatusEnum cancelStatus); + + /** + * 根据用户ID查找其充电记录 + * + * @param userId 用户ID + * @return 充电会话列表 + */ + List findUserSessions(Long userId); + + /** + * 根据机器人任务ID查找关联的充电会话 + * @param robotTaskId 机器人任务ID + * @return 充电会话 + */ + ChargingSession findSessionByRobotTaskId(Long robotTaskId); + + /** + * 处理充电会话相关的机器人任务超时 + * @param robotTaskId 超时的机器人任务ID + */ + void handleSessionTaskTimeout(Long robotTaskId); + + /** + * 用户请求停止当前正在进行的充电 + * (会向机器人发送停止指令,等待机器人确认后再完成计费) + * + * @param sessionId 充电会话ID + * @param userId 用户ID (用于校验) + * @return 是否成功发起停止请求 (不代表已完成) + */ + boolean stopChargingByUser(Long sessionId, Long userId); + + /** + * 获取查询包装类 + * + * @param queryRequest + * @return + */ + QueryWrapper getQueryWrapper(ChargingSessionQueryRequest queryRequest); + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/ParkingSpotService.java b/springboot-init-main/src/main/java/com/yupi/project/service/ParkingSpotService.java new file mode 100644 index 0000000..85e9d3d --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/ParkingSpotService.java @@ -0,0 +1,79 @@ +package com.yupi.project.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.yupi.project.model.entity.ParkingSpot; +import com.yupi.project.model.enums.ParkingSpotStatusEnum; + +import java.util.List; + +/** +* @author Yupi +* @description 针对表【parking_spot(车位表)】的数据库操作Service +* @createDate 2023-12-03 10:00:00 +*/ +public interface ParkingSpotService extends IService { + + /** + * 添加新的车位信息 + * + * @param spotUid 车位唯一ID + * @param locationDescription 位置描述 + * @param robotAssignable 是否可指派机器人 + * @return 创建的车位实体 + */ + ParkingSpot addParkingSpot(String spotUid, String locationDescription, boolean robotAssignable); + + /** + * 根据车位UID查找车位 + * + * @param spotUid 车位唯一ID + * @return 车位实体,未找到则返回null + */ + ParkingSpot findBySpotUid(String spotUid); + + /** + * 更新车位状态 + * + * @param spotId 车位DB主键ID + * @param newStatus 新状态 + * @param currentSessionId (可选) 当前关联的充电会话ID + * @return 是否更新成功 + */ + boolean updateParkingSpotStatus(Long spotId, ParkingSpotStatusEnum newStatus, Long currentSessionId); + + /** + * 查找所有可用的、可指派机器人的车位 + * + * @return 可用车位列表 + */ + List findAvailableAndAssignableSpots(); + + /** + * 占用一个车位(例如,用户预订或开始充电会话时) + * + * @param spotId 车位ID + * @param sessionId 关联的充电会话ID + * @param targetStatus 目标状态 (如 OCCUPIED_BY_CAR, RESERVED, CHARGING) + * @return 操作是否成功 + */ + boolean occupySpot(Long spotId, Long sessionId, ParkingSpotStatusEnum targetStatus); + + /** + * 释放一个车位 (例如,充电结束或取消预订) + * + * @param spotId 车位ID + * @return 操作是否成功 + */ + boolean releaseSpot(Long spotId); + + /** + * 更新车位状态及当前关联的充电会话ID。 + * + * @param spotUID 车位唯一标识符 + * @param newStatus 新的车位状态 + * @param currentSessionId 当前占用此车位的充电会话ID (如果车位变为空闲,则应传入 null) + * @return 更新是否成功 + */ + boolean updateSpotStatus(String spotUID, ParkingSpotStatusEnum newStatus, Long currentSessionId); + +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/RobotTaskService.java b/springboot-init-main/src/main/java/com/yupi/project/service/RobotTaskService.java index 300b805..f7e1b4b 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/service/RobotTaskService.java +++ b/springboot-init-main/src/main/java/com/yupi/project/service/RobotTaskService.java @@ -92,14 +92,13 @@ public interface RobotTaskService extends IService { boolean markTaskAsCompleted(Long taskId, Date ackTime, String message); /** - * 将任务标记为失败。 + * 标记任务为失败 * - * @param taskId 任务ID - * @param ackTime 失败确认时间 (可选) - * @param errorCode 错误码 (可选) - * @param errorMessage 错误信息 (可选) - * @return 是否成功标记 + * @param taskId 任务ID + * @param errorMessage 错误信息 + * @param failedTime 失败时间 + * @return 是否成功 */ - boolean markTaskAsFailed(Long taskId, Date ackTime, String errorCode, String errorMessage); + boolean markTaskAsFailed(Long taskId, String errorMessage, Date failedTime); } \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/UserService.java b/springboot-init-main/src/main/java/com/yupi/project/service/UserService.java index e8cd041..2dd4017 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/service/UserService.java +++ b/springboot-init-main/src/main/java/com/yupi/project/service/UserService.java @@ -57,20 +57,22 @@ public interface UserService extends IService { User getSafetyUser(User originUser); /** - * 扣减用户余额 (需要保证线程安全和数据一致性) + * 扣减用户余额 + * * @param userId 用户ID - * @param amount 扣减金额 (正数) - * @return 操作是否成功 + * @param amount 要扣减的金额 (注意:这里假设amount是正数) + * @return 操作是否成功 (例如,余额不足则失败) */ - boolean deductBalance(Long userId, BigDecimal amount); + boolean decreaseBalance(Long userId, BigDecimal amount); /** - * 增加用户余额 (需要保证线程安全和数据一致性) + * 增加用户余额 + * * @param userId 用户ID - * @param amount 增加金额 (正数) + * @param amount 要增加的金额 * @return 操作是否成功 */ - boolean addBalance(Long userId, BigDecimal amount); + boolean increaseBalance(Long userId, BigDecimal amount); /** * 获取用户列表 (仅管理员) diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/ChargingRobotServiceImpl.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ChargingRobotServiceImpl.java new file mode 100644 index 0000000..9b12d45 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ChargingRobotServiceImpl.java @@ -0,0 +1,189 @@ +package com.yupi.project.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.mapper.ChargingRobotMapper; +import com.yupi.project.model.entity.ChargingRobot; +import com.yupi.project.model.enums.RobotStatusEnum; +import com.yupi.project.service.ChargingRobotService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; + +/** +* @author Yupi +* @description 针对表【charging_robot(充电机器人表)】的数据库操作Service实现 +* @createDate 2023-12-03 10:00:00 +*/ +@Service +@Slf4j +public class ChargingRobotServiceImpl extends ServiceImpl + implements ChargingRobotService { + + @Override + @Transactional + public ChargingRobot registerRobot(String robotUid, RobotStatusEnum initialStatus) { + if (findByRobotUid(robotUid) != null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "机器人UID已存在: " + robotUid); + } + ChargingRobot robot = new ChargingRobot(); + robot.setRobotUid(robotUid); + robot.setStatus(initialStatus.getValue()); + robot.setCreateTime(new Date()); + robot.setUpdateTime(new Date()); + boolean saved = this.save(robot); + if (!saved) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "机器人注册失败"); + } + log.info("机器人注册成功: {}", robot); + return robot; + } + + @Override + public ChargingRobot findByRobotUid(String robotUid) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("robot_uid", robotUid); + return this.getOne(queryWrapper); + } + + @Override + @Transactional + public boolean updateRobotStatus(String robotUID, RobotStatusEnum status, String location, + Integer batteryLevel, Long currentTaskId, Date lastHeartbeatTime) { + if (StringUtils.isBlank(robotUID) || status == null) { + log.warn("更新机器人状态失败:robotUID 或 status 为空。robotUID: {}, status: {}", robotUID, status); + return false; + } + + ChargingRobot robot = this.findByRobotUid(robotUID); + if (robot == null) { + log.warn("更新机器人状态失败:未找到 UID 为 {} 的机器人。", robotUID); + return false; + } + + ChargingRobot robotToUpdate = new ChargingRobot(); + robotToUpdate.setId(robot.getId()); + boolean changed = false; + + if (status != null && !status.getValue().equals(robot.getStatus())) { + robotToUpdate.setStatus(status.getValue()); + changed = true; + } + + if (location != null && !location.equals(robot.getCurrentLocation())) { + robotToUpdate.setCurrentLocation(location); + changed = true; + } + + if (batteryLevel != null && !batteryLevel.equals(robot.getBatteryLevel())) { + robotToUpdate.setBatteryLevel(batteryLevel); + changed = true; + } + + if (currentTaskId != null && !currentTaskId.equals(robot.getCurrentTaskId())) { + robotToUpdate.setCurrentTaskId(currentTaskId); + changed = true; + } + + if (lastHeartbeatTime != null && (robot.getLastHeartbeatTime() == null || !lastHeartbeatTime.equals(robot.getLastHeartbeatTime()))){ + robotToUpdate.setLastHeartbeatTime(lastHeartbeatTime); + changed = true; + } + + if (!changed) { + log.info("机器人 {} 的状态与数据库一致 (状态: {}, 位置: {}, 电量: {}, 任务ID: {}, 心跳: {}),无需更新。", + robotUID, status, location, batteryLevel, currentTaskId, lastHeartbeatTime); + return true; + } + + boolean success = this.updateById(robotToUpdate); + if (success) { + log.info("成功更新机器人 {} 的状态。新状态: {}, 位置: {}, 电量: {}, 当前任务ID: {}, 心跳: {}", + robotUID, status, location, batteryLevel, currentTaskId, lastHeartbeatTime); + } else { + log.error("更新机器人 {} 的状态失败 (数据库操作失败)。", robotUID); + } + return success; + } + + @Override + public boolean updateBatteryLevel(Long robotId, Integer batteryLevel) { + ChargingRobot robot = this.getById(robotId); + if (robot == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "机器人不存在"); + } + if (batteryLevel < 0 || batteryLevel > 100) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的电池电量值"); + } + robot.setBatteryLevel(batteryLevel); + robot.setUpdateTime(new Date()); + return this.updateById(robot); + } + + @Override + public boolean updateHeartbeatTime(Long robotId, Date heartbeatTime) { + ChargingRobot robot = this.getById(robotId); + if (robot == null) { + // 心跳通常来自已注册机器人,如果找不到可以只记录日志不抛异常,或根据策略决定 + log.warn("尝试更新心跳失败,机器人不存在: {}", robotId); + return false; + } + robot.setLastHeartbeatTime(heartbeatTime); + robot.setUpdateTime(new Date()); // 可选,心跳更新是否算作整体记录更新 + return this.updateById(robot); + } + + @Override + public List findRobotsByStatus(RobotStatusEnum status) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("status", status.getValue()); + return this.list(queryWrapper); + } + + @Override + @Transactional // 确保原子性 + public ChargingRobot assignIdleRobot() { + // 简单策略:查找第一个空闲的机器人并尝试锁定 + // 后续可优化:考虑负载均衡、机器人位置、电量等因素 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("status", RobotStatusEnum.IDLE.getValue()) + .orderByAsc("update_time") // 尝试获取最近更新为空闲的,或根据其他策略排序 + .last("FOR UPDATE"); // 行级锁,防止并发问题 + + List idleRobots = this.list(queryWrapper); + if (idleRobots.isEmpty()) { + log.warn("没有可用的空闲机器人"); + return null; + } + + ChargingRobot assignedRobot = idleRobots.get(0); // 取第一个 + // 更新机器人状态为MOVING或ASSIGNED,并关联任务(如果此时有任务ID) + // 这里暂时只更新为MOVING,具体任务ID关联由调用方处理 + boolean success = updateRobotStatus(assignedRobot.getRobotUid(), RobotStatusEnum.MOVING, null, null, null, null); + if (success) { + log.info("成功分配机器人: {} (ID: {})", assignedRobot.getRobotUid(), assignedRobot.getId()); + return assignedRobot; + } else { + log.error("分配机器人 {} 失败,未能更新其状态", assignedRobot.getRobotUid()); + // 可能需要重试或选择其他机器人 + throw new BusinessException(ErrorCode.OPERATION_ERROR, "分配机器人失败,无法更新状态"); + } + } + + @Override + public boolean releaseRobot(Long robotId) { + ChargingRobot robot = getById(robotId); + if (robot == null) { + log.warn("尝试释放机器人失败,机器人不存在: {}", robotId); + return false; + } + // 将机器人状态设置为空闲,并清除当前任务ID + return updateRobotStatus(robot.getRobotUid(), RobotStatusEnum.IDLE, null, null, null, null); // 第二个参数传null以清除任务ID + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/ChargingSessionServiceImpl.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ChargingSessionServiceImpl.java new file mode 100644 index 0000000..058df91 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ChargingSessionServiceImpl.java @@ -0,0 +1,582 @@ +package com.yupi.project.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.mapper.ChargingSessionMapper; +import com.yupi.project.model.dto.charging_session.ChargingRequest; +import com.yupi.project.model.dto.charging_session.ChargingSessionQueryRequest; +import com.yupi.project.model.entity.*; +import com.yupi.project.model.enums.*; +import com.yupi.project.service.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** +* @author Yupi +* @description 针对表【charging_session(充电记录表)】的数据库操作Service实现 +* @createDate 2023-12-03 10:00:00 +*/ +@Service +@Slf4j +public class ChargingSessionServiceImpl extends ServiceImpl + implements ChargingSessionService { + + @Resource + private ParkingSpotService parkingSpotService; + + @Resource + private ChargingRobotService chargingRobotService; + + @Resource + private UserService userService; + + @Resource + private RobotTaskService robotTaskService; // 用于创建和关联机器人任务 + + @Resource + @Lazy // 避免循环依赖 MqttService -> ChargingSessionService -> MqttService (通过TaskTimeoutHandler) + private MqttService mqttService; // 用于发送指令给机器人 + + // 计费相关配置 (后续可以移到配置类或数据库) + @Value("${charging.price.perHour:10.0}") // 每小时10元 + private BigDecimal pricePerHour; + + @Value("${charging.minChargeAmount:0.5}") // 最低消费0.5元 + private BigDecimal minChargeAmount; + + @Override + @Transactional + public ChargingSession requestCharging(User currentUser, ChargingRequest chargingRequest) { + if (currentUser == null || chargingRequest == null || chargingRequest.getSpotId() == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数错误"); + } + + // 1. 校验用户账户状态,例如余额是否充足(如果需要预付费或押金) + // (此处简化,暂不校验余额) + + // 2. 校验车位是否存在且可用 + ParkingSpot spot = parkingSpotService.getById(chargingRequest.getSpotId()); + if (spot == null || !spot.getRobotAssignable() || !ParkingSpotStatusEnum.AVAILABLE.getValue().equals(spot.getStatus())) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "车位不可用或不存在"); + } + + // 3. 创建充电会话记录 + ChargingSession session = new ChargingSession(); + session.setUserId(currentUser.getId()); + session.setSpotId(spot.getId()); + session.setSpotUidSnapshot(spot.getSpotUid()); + session.setStatus(ChargingSessionStatusEnum.REQUESTED.getValue()); + session.setRequestTime(new Date()); + session.setPaymentStatus(PaymentStatusEnum.PENDING.getValue()); // 初始为待支付 + session.setCreateTime(new Date()); + session.setUpdateTime(new Date()); + + boolean saved = this.save(session); + if (!saved) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "创建充电会话失败"); + } + log.info("用户 {} 请求在车位 {} (ID:{}) 开始充电会话 (ID:{})", currentUser.getId(), spot.getSpotUid(), spot.getId(), session.getId()); + + // 4. 尝试分配机器人 (可以同步或异步) + // 此处为简化,采用同步调用。实际项目中可能需要异步处理并通知用户分配结果。 + ChargingSession updatedSession = assignRobotToSession(session.getId()); + if (updatedSession == null) { + // 如果没有可用机器人,标记会话为系统取消 + this.cancelChargingSession(session.getId(), null, "无可用机器人", ChargingSessionStatusEnum.CANCELLED_BY_SYSTEM); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "无可用机器人,充电请求失败"); + } + + return updatedSession; + } + + @Override + @Transactional + public ChargingSession assignRobotToSession(Long sessionId) { + ChargingSession session = this.getById(sessionId); + if (session == null || !ChargingSessionStatusEnum.REQUESTED.getValue().equals(session.getStatus())) { + log.warn("分配机器人失败,会话不存在或状态不正确: sessionId={}", sessionId); + return null; // 或抛出异常 + } + + // 1. 查找并分配一个空闲机器人 + ChargingRobot assignedRobot = chargingRobotService.assignIdleRobot(); // assignIdleRobot 内部已将机器人状态改为MOVING + if (assignedRobot == null) { + log.warn("分配机器人失败,没有空闲机器人可供会话 {} 使用", sessionId); + return null; + } + + // 2. 更新车位状态为"已预订"或"机器人前往中" + boolean spotOccupied = parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.RESERVED, session.getId()); + if (!spotOccupied) { + log.error("分配机器人后,占用车位 {} (UID: {}) 失败 for session {}", session.getSpotId(), session.getSpotUidSnapshot(), sessionId); + // 需要回滚机器人状态 + chargingRobotService.releaseRobot(assignedRobot.getId()); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "占用车位失败"); + } + + // 3. 创建机器人任务:移动到指定车位 + // Payload 需要根据与硬件的约定来构建 + String moveToSpotPayload = String.format("{\"command\":\"MOVE\", \"target_spot_uid\":\"%s\"}", session.getSpotUidSnapshot()); + RobotTask moveTask = robotTaskService.createTask(assignedRobot.getRobotUid(), CommandTypeEnum.MOVE_TO_SPOT, moveToSpotPayload, sessionId); + if (moveTask == null) { + log.error("为会话 {} 创建机器人移动任务失败", sessionId); + chargingRobotService.releaseRobot(assignedRobot.getId()); + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.AVAILABLE, null); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "创建机器人移动任务失败"); + } + + // 4. 更新充电会话信息 + session.setRobotId(assignedRobot.getId()); + session.setRobotUidSnapshot(assignedRobot.getRobotUid()); + session.setStatus(ChargingSessionStatusEnum.ROBOT_ASSIGNED.getValue()); + session.setRobotAssignedTime(new Date()); + session.setRelatedRobotTaskId(moveTask.getId()); // 关联当前任务 + session.setUpdateTime(new Date()); + this.updateById(session); + + // 5. 发送MQTT指令给机器人 + try { + mqttService.sendCommand(assignedRobot.getRobotUid(), CommandTypeEnum.MOVE_TO_SPOT, moveToSpotPayload, moveTask.getId()); + robotTaskService.markTaskAsSent(moveTask.getId(), new Date()); + log.info("已向机器人 {} 发送移动指令 for session {}, 任务ID: {}", assignedRobot.getRobotUid(), sessionId, moveTask.getId()); + } catch (Exception e) { + log.error("发送MQTT移动指令失败 for session {}, 任务ID: {}: ", sessionId, moveTask.getId(), e); + // 此处可能需要更复杂的错误处理,如重试、标记任务失败等 + // 为了简化,如果发送失败,也回滚,并标记会话错误 + robotTaskService.markTaskAsFailed(moveTask.getId(), "MQTT发送失败: " + e.getMessage(), new Date()); + chargingRobotService.releaseRobot(assignedRobot.getId()); + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.AVAILABLE, null); + session.setStatus(ChargingSessionStatusEnum.ERROR.getValue()); + session.setErrorMessage("分配机器人并发送指令失败"); + this.updateById(session); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "发送MQTT指令失败"); + } + + log.info("成功为会话 {} 分配机器人 {} (任务ID: {})", sessionId, assignedRobot.getRobotUid(), moveTask.getId()); + return session; + } + + @Override + @Transactional + public boolean handleRobotArrival(Long sessionId, Long robotTaskId) { + ChargingSession session = this.getById(sessionId); + if (session == null) { + log.warn("处理机器人到达事件失败,会话不存在: sessionId={}", sessionId); + return false; + } + // 校验会话状态和关联的任务ID + if (!ChargingSessionStatusEnum.ROBOT_ASSIGNED.getValue().equals(session.getStatus()) || + !robotTaskId.equals(session.getRelatedRobotTaskId())) { + log.warn("处理机器人到达事件失败,会话状态 ({}) 或任务ID ({}) 不匹配 (期望任务ID: {}) for session {}", + session.getStatus(), robotTaskId, session.getRelatedRobotTaskId(), sessionId); + return false; + } + + session.setStatus(ChargingSessionStatusEnum.ROBOT_ARRIVED.getValue()); + session.setRobotArrivalTime(new Date()); + session.setUpdateTime(new Date()); + // 接下来可以创建"开始充电"的机器人任务,或等待用户指令/自动开始 + // 此处简化,认为到达后即可准备开始充电,相关任务由MQTT回调触发创建 + boolean updated = this.updateById(session); + if(updated) { + log.info("机器人已到达,会话 {} 状态更新为 ROBOT_ARRIVED. 关联任务ID: {}", sessionId, robotTaskId); + // 可选:更新机器人状态为IDLE(在车位旁等待)或 CHARGING_READY + chargingRobotService.updateRobotStatus(session.getRobotUidSnapshot(), RobotStatusEnum.IDLE, null, null, null, new Date()); + // 可选:更新车位状态为 CHARGING (如果机器人到达即代表车位被用于充电) + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.CHARGING, session.getId()); + } + return updated; + } + + @Override + @Transactional + public boolean handleChargingStart(Long sessionId, Long robotTaskId) { + ChargingSession session = this.getById(sessionId); + if (session == null) { + log.warn("处理充电开始事件失败,会话不存在: sessionId={}", sessionId); + return false; + } + // 校验状态,应该在 ROBOT_ARRIVED 之后 + if (!ChargingSessionStatusEnum.ROBOT_ARRIVED.getValue().equals(session.getStatus()) || + !robotTaskId.equals(session.getRelatedRobotTaskId())) { + log.warn("处理充电开始事件失败,会话状态 ({}) 或任务ID ({}) 不匹配 (期望任务ID: {}) for session {}", + session.getStatus(), robotTaskId, session.getRelatedRobotTaskId(), sessionId); + return false; + } + + session.setStatus(ChargingSessionStatusEnum.CHARGING_STARTED.getValue()); + session.setChargeStartTime(new Date()); + session.setUpdateTime(new Date()); + boolean updated = this.updateById(session); + if (updated) { + log.info("充电开始,会话 {} 状态更新为 CHARGING_STARTED. 关联任务ID: {}", sessionId, robotTaskId); + // 更新机器人状态为 CHARGING + chargingRobotService.updateRobotStatus(session.getRobotUidSnapshot(), RobotStatusEnum.CHARGING, null, null, robotTaskId, new Date()); + // 确保车位状态为 CHARGING + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.CHARGING, session.getId()); + } + return updated; + } + + @Override + @Transactional + public boolean handleChargingEnd(Long sessionId, Long robotTaskId, BigDecimal energyConsumedKwh, int durationSeconds) { + ChargingSession session = this.getById(sessionId); + if (session == null) { + log.warn("处理充电结束事件失败,会话不存在: sessionId={}", sessionId); + return false; + } + // 校验状态 + if (!ChargingSessionStatusEnum.CHARGING_STARTED.getValue().equals(session.getStatus()) || + !robotTaskId.equals(session.getRelatedRobotTaskId())) { + log.warn("处理充电结束事件失败,会话状态 ({}) 或任务ID ({}) 不匹配 (期望任务ID: {}) for session {}", + session.getStatus(), robotTaskId, session.getRelatedRobotTaskId(), sessionId); + return false; + } + + session.setStatus(ChargingSessionStatusEnum.CHARGING_COMPLETED.getValue()); + session.setChargeEndTime(new Date()); + session.setEnergyConsumedKwh(energyConsumedKwh); + session.setTotalDurationSeconds(durationSeconds); + session.setUpdateTime(new Date()); + boolean updated = this.updateById(session); + + if (updated) { + log.info("充电结束,会话 {} 状态更新为 CHARGING_COMPLETED. 电量:{} kWh, 时长:{}s. 关联任务ID: {}", + sessionId, energyConsumedKwh, durationSeconds, robotTaskId); + // 释放机器人和车位 + chargingRobotService.updateRobotStatus(session.getRobotUidSnapshot(), RobotStatusEnum.IDLE, null, null, null, new Date()); + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.AVAILABLE, null); // 车位变为空闲 + + // 执行计费和最终化会话逻辑 + this.calculateCostAndFinalizeSession(sessionId); + } + return updated; + } + + @Override + @Transactional + public boolean calculateCostAndFinalizeSession(Long sessionId) { + ChargingSession session = this.getById(sessionId); + if (session == null || session.getChargeEndTime() == null || session.getChargeStartTime() == null) { + log.error("计算费用失败,会话 {} 不存在或充电时间不完整", sessionId); + return false; + } + + long durationMillis = session.getChargeEndTime().getTime() - session.getChargeStartTime().getTime(); + double hours = (double) durationMillis / TimeUnit.HOURS.toMillis(1); + + // 费用 = 时长(小时) * 每小时单价 + BigDecimal cost = pricePerHour.multiply(BigDecimal.valueOf(hours)).setScale(2, RoundingMode.HALF_UP); + + // 应用最低消费 + if (cost.compareTo(minChargeAmount) < 0) { + cost = minChargeAmount; + } + session.setCost(cost); + session.setStatus(ChargingSessionStatusEnum.PAYMENT_PENDING.getValue()); // 更新为待支付 + session.setPaymentStatus(PaymentStatusEnum.PENDING.getValue()); + session.setUpdateTime(new Date()); + + boolean updated = this.updateById(session); + if(updated){ + log.info("会话 {} 费用计算完成: {}元. 状态更新为 PAYMENT_PENDING", sessionId, cost); + // 此处可以触发通知用户支付 + } + + // 在计费完成后,如果会话已标记为COMPLETED,但尚未支付,并且车位状态不是AVAILABLE,则将其设置为空闲 + // 这主要覆盖了 handleChargingEnd 中可能未完全释放车位的情况(例如,如果计费失败) + // 或者如果 calculateCostAndFinalizeSession 是独立调用的。 + String sessionStatus = session.getStatus(); + String paymentStatus = session.getPaymentStatus(); + + if (ChargingSessionStatusEnum.CHARGING_COMPLETED.getValue().equals(sessionStatus) || + PaymentStatusEnum.PAID.getValue().equals(paymentStatus) || + PaymentStatusEnum.FAILED.getValue().equals(paymentStatus)) { + + ParkingSpot spot = parkingSpotService.getById(session.getSpotId()); + if (spot != null && !ParkingSpotStatusEnum.AVAILABLE.getValue().equals(spot.getStatus())) { + log.info("在 finalizeSession for session {} 时,车位 {} (UID: {}) 状态为 {},将其更新为 AVAILABLE。", + sessionId, spot.getId(), spot.getSpotUid(), spot.getStatus()); + parkingSpotService.updateSpotStatus(spot.getSpotUid(), ParkingSpotStatusEnum.AVAILABLE, null); + } + } + return updated; + } + + @Override + @Transactional + public boolean processPayment(Long sessionId, Long userId) { + ChargingSession session = this.getById(sessionId); + if (session == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "充电会话不存在"); + } + if (!session.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权操作他人充电记录"); + } + if (!ChargingSessionStatusEnum.PAYMENT_PENDING.getValue().equals(session.getStatus()) || + !PaymentStatusEnum.PENDING.getValue().equals(session.getPaymentStatus())) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "会话状态非待支付,无法处理支付"); + } + + BigDecimal cost = session.getCost(); + if (cost == null || cost.compareTo(BigDecimal.ZERO) <= 0) { + log.info("会话 {} 费用为0或无效,标记为已支付 (无需支付)", sessionId); + session.setPaymentStatus(PaymentStatusEnum.PAID.getValue()); + session.setStatus(ChargingSessionStatusEnum.PAID.getValue()); // 主状态也更新为已支付 + session.setUpdateTime(new Date()); + return this.updateById(session); + } + + // 模拟扣款 + boolean paymentSuccess = userService.decreaseBalance(userId, cost); + if (!paymentSuccess) { + log.warn("用户 {} 为会话 {} 支付 {}元 失败,余额不足或操作失败", userId, sessionId, cost); + session.setPaymentStatus(PaymentStatusEnum.FAILED.getValue()); + // 可以考虑是否将会话状态改为ERROR或保持PAYMENT_PENDING + this.updateById(session); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "支付失败,余额不足或系统错误"); + } + + session.setPaymentStatus(PaymentStatusEnum.PAID.getValue()); + session.setStatus(ChargingSessionStatusEnum.PAID.getValue()); // 主状态也更新为已支付 + session.setUpdateTime(new Date()); + boolean updated = this.updateById(session); + if (updated) { + log.info("用户 {} 为会话 {} 成功支付 {}元", userId, sessionId, cost); + } + return updated; + } + + @Override + @Transactional + public boolean cancelChargingSession(Long sessionId, Long userId, String reason, ChargingSessionStatusEnum newStatus) { + ChargingSession session = this.getById(sessionId); + if (session == null) { + log.warn("尝试取消会话 {} 失败: 会话不存在", sessionId); + return false; + } + + // 检查是否可以取消 (例如,不能取消已完成或已支付的会话) + ChargingSessionStatusEnum currentStatus = ChargingSessionStatusEnum.getEnumByValue(session.getStatus()); + if (currentStatus == ChargingSessionStatusEnum.CHARGING_COMPLETED || + currentStatus == ChargingSessionStatusEnum.PAYMENT_PENDING || + currentStatus == ChargingSessionStatusEnum.PAID || + currentStatus == ChargingSessionStatusEnum.CANCELLED_BY_SYSTEM || + currentStatus == ChargingSessionStatusEnum.CANCELLED_BY_USER) { + log.warn("会话 {} 当前状态 {} 不可取消", sessionId, currentStatus); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "当前状态不可取消"); + } + + if (userId != null && !session.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权取消他人会话"); + } + + session.setStatus(newStatus.getValue()); + session.setErrorMessage("会话取消: " + reason); + session.setChargeEndTime(new Date()); // 标记一个结束时间 + session.setUpdateTime(new Date()); + + boolean updated = this.updateById(session); + if (updated) { + log.info("充电会话 {} 已被取消,原因: {}. 操作者ID(若有): {}", sessionId, reason, userId); + // 如果机器人已分配或在执行任务,需要处理机器人和车位状态 + if (session.getRobotId() != null) { + chargingRobotService.releaseRobot(session.getRobotId()); + // 如果有关联的MQTT任务,可能需要发送取消指令或标记任务为取消 + if (session.getRelatedRobotTaskId() != null) { + RobotTask task = robotTaskService.getById(session.getRelatedRobotTaskId()); + if (task != null && + (task.getStatus() == RobotTaskStatusEnum.PENDING || task.getStatus() == RobotTaskStatusEnum.SENT || task.getStatus() == RobotTaskStatusEnum.PROCESSING)) { + robotTaskService.markTaskAsFailed(task.getId(), "充电会话被取消: " + reason, new Date()); + log.info("因会话 {} 取消,关联的机器人任务 {} 已标记为失败。", sessionId, task.getId()); + } + } + } + // 释放车位 (如果它被此会话占用) + ParkingSpot spot = parkingSpotService.findBySpotUid(session.getSpotUidSnapshot()); + if (spot != null && sessionId.equals(spot.getCurrentSessionId()) && !ParkingSpotStatusEnum.AVAILABLE.getValue().equals(spot.getStatus())) { + log.info("取消会话 {} 时,释放车位 {} (UID: {})。", sessionId, spot.getId(), spot.getSpotUid()); + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.AVAILABLE, null); + } + } + return updated; + } + + @Override + public List findUserSessions(Long userId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("user_id", userId); + queryWrapper.orderByDesc("request_time"); + return this.list(queryWrapper); + } + + @Override + public ChargingSession findSessionByRobotTaskId(Long robotTaskId) { + if (robotTaskId == null) return null; + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("related_robot_task_id", robotTaskId); + // 通常一个任务只关联一个会话的特定阶段 + return this.getOne(queryWrapper, false); // false: 不抛异常如果找到多个 + } + + @Override + @Transactional + public void handleSessionTaskTimeout(Long robotTaskId) { + log.warn("处理机器人任务超时,任务ID: {}", robotTaskId); + RobotTask task = robotTaskService.getById(robotTaskId); + if (task == null) { + log.error("任务超时处理失败:任务 {} 未找到", robotTaskId); + return; + } + + ChargingSession session = findSessionByRobotTaskId(robotTaskId); + if (session == null) { + log.warn("任务 {} 超时,但未找到关联的充电会话。可能任务与会话解耦或已被处理。", robotTaskId); + // 如果任务不直接关联会话,或者会话已结束,可能不需要进一步操作。 + // 但如果机器人仍占用,需要释放 + ChargingRobot robot = chargingRobotService.findByRobotUid(task.getRobotId()); // task.getRobotId()是robotUid + if(robot != null && !RobotStatusEnum.IDLE.getValue().equals(robot.getStatus())){ + log.info("超时任务 {} 的机器人 {} 将被释放。", robotTaskId, robot.getRobotUid()); + chargingRobotService.releaseRobot(robot.getId()); + } + return; + } + + log.info("任务 {} (类型: {}) 超时,关联会话ID: {}", robotTaskId, task.getCommandType(), session.getId()); + // 根据当前会话状态和任务类型决定如何处理 + // 例如:如果是移动任务超时,可能意味着机器人未能到达 + // 如果是充电任务超时,可能意味着充电异常 + // 此处统一将会话标记为错误,并释放资源 + session.setStatus(ChargingSessionStatusEnum.ERROR.getValue()); + session.setErrorMessage("机器人任务超时 (TaskID: " + robotTaskId + ", Type: " + task.getCommandType() + ")"); + session.setChargeEndTime(new Date()); // 标记一个结束时间 + session.setUpdateTime(new Date()); + this.updateById(session); + + if (session.getRobotId() != null) { + chargingRobotService.releaseRobot(session.getRobotId()); + } + if (session.getSpotId() != null) { + parkingSpotService.releaseSpot(session.getSpotId()); + } + log.warn("会话 {} 因任务 {} 超时而被标记为错误并释放了资源。", session.getId(), robotTaskId); + + // 如果会话被标记为错误或取消,并且之前占用了车位,则释放车位 + if (session != null && session.getSpotUidSnapshot() != null && + (ChargingSessionStatusEnum.ERROR.getValue().equals(session.getStatus()) || + ChargingSessionStatusEnum.CANCELLED_BY_SYSTEM.getValue().equals(session.getStatus()) || + ChargingSessionStatusEnum.CANCELLED_BY_USER.getValue().equals(session.getStatus()))) { + + ParkingSpot spot = parkingSpotService.findBySpotUid(session.getSpotUidSnapshot()); + if (spot != null && session.getId().equals(spot.getCurrentSessionId()) && !ParkingSpotStatusEnum.AVAILABLE.getValue().equals(spot.getStatus())) { + log.info("任务 {} 超时导致会话 {} 结束,释放车位 {} (UID: {})。", robotTaskId, session.getId(), spot.getId(), spot.getSpotUid()); + parkingSpotService.updateSpotStatus(session.getSpotUidSnapshot(), ParkingSpotStatusEnum.AVAILABLE, null); + } + } + } + + @Override + public QueryWrapper getQueryWrapper(ChargingSessionQueryRequest queryRequest) { + if (queryRequest == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数为空"); + } + QueryWrapper queryWrapper = new QueryWrapper<>(); + Long id = queryRequest.getId(); + Long userId = queryRequest.getUserId(); + Long robotId = queryRequest.getRobotId(); + Long spotId = queryRequest.getSpotId(); + String robotUidSnapshot = queryRequest.getRobotUidSnapshot(); + String spotUidSnapshot = queryRequest.getSpotUidSnapshot(); + String status = queryRequest.getStatus(); + List orStatusList = queryRequest.getOrStatusList(); + String paymentStatus = queryRequest.getPaymentStatus(); + String sortField = queryRequest.getSortField(); + String sortOrder = queryRequest.getSortOrder(); + + queryWrapper.eq(id != null && id > 0, "id", id); + queryWrapper.eq(userId != null && userId > 0, "user_id", userId); + queryWrapper.eq(robotId != null && robotId > 0, "robot_id", robotId); + queryWrapper.eq(spotId != null && spotId > 0, "spot_id", spotId); + queryWrapper.like(StringUtils.isNotBlank(robotUidSnapshot), "robot_uid_snapshot", robotUidSnapshot); + queryWrapper.like(StringUtils.isNotBlank(spotUidSnapshot), "spot_uid_snapshot", spotUidSnapshot); + queryWrapper.eq(StringUtils.isNotBlank(status), "status", status); + queryWrapper.eq(StringUtils.isNotBlank(paymentStatus), "payment_status", paymentStatus); + + if (CollectionUtils.isNotEmpty(orStatusList)) { + queryWrapper.and(qw -> { + for (String orStatus : orStatusList) { + qw.or().eq("status", orStatus); + } + }); + } + queryWrapper.orderBy(StringUtils.isNotBlank(sortField), + sortOrder.equals(com.yupi.project.constant.CommonConstant.SORT_ORDER_ASC), sortField); + return queryWrapper; + } + + @Override + @Transactional + public boolean stopChargingByUser(Long sessionId, Long userId) { + ChargingSession session = this.getById(sessionId); + if (session == null) { + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "充电会话不存在"); + } + if (!session.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权操作他人充电记录"); + } + + if (!ChargingSessionStatusEnum.CHARGING_STARTED.getValue().equals(session.getStatus())) { + log.warn("用户 {} 尝试停止一个非充电中状态的会话 {} (当前状态: {})", userId, sessionId, session.getStatus()); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "当前会话并非正在充电状态,无法停止"); + } + + if (StringUtils.isBlank(session.getRobotUidSnapshot())) { + log.error("会话 {} 缺少机器人UID快照,无法发送停止指令", sessionId); + throw new BusinessException(ErrorCode.SYSTEM_ERROR, "会话数据异常,缺少机器人UID快照"); + } + + String stopChargePayload = String.format("{\"command\":\"STOP_CHARGE\", \"session_id\":%d}", sessionId); + RobotTask stopTask = robotTaskService.createTask(session.getRobotUidSnapshot(), CommandTypeEnum.STOP_CHARGE, stopChargePayload, sessionId); + if (stopTask == null) { + log.error("为会话 {} 创建机器人停止充电任务失败", sessionId); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "创建机器人停止任务失败"); + } + + session.setRelatedRobotTaskId(stopTask.getId()); + session.setUpdateTime(new Date()); + this.updateById(session); + + boolean mqttSent = false; + try { + mqttSent = mqttService.sendCommand(session.getRobotUidSnapshot(), CommandTypeEnum.STOP_CHARGE, stopChargePayload, stopTask.getId()); + } catch (Exception e) { + log.error("发送MQTT停止充电指令失败 for session {}, 任务ID: {}: {}", sessionId, stopTask.getId(), e.getMessage(), e); + robotTaskService.markTaskAsFailed(stopTask.getId(), "MQTT发送失败: " + e.getMessage(), new Date()); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "发送MQTT停止指令失败: " + e.getMessage()); + } + + if (!mqttSent) { + log.error("发送MQTT停止充电指令失败 for session {}, 任务ID: {}", sessionId, stopTask.getId()); + robotTaskService.markTaskAsFailed(stopTask.getId(), "MQTT发送失败", new Date()); + throw new BusinessException(ErrorCode.OPERATION_ERROR, "发送MQTT停止指令失败"); + } + + log.info("用户 {} 已请求停止充电会话 {}。等待机器人确认。", userId, sessionId); + return true; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttMessageHandler.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttMessageHandler.java index 8b9b0b5..39dc596 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttMessageHandler.java +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/MqttMessageHandler.java @@ -3,13 +3,16 @@ package com.yupi.project.service.impl; import com.fasterxml.jackson.databind.ObjectMapper; import com.yupi.project.config.properties.MqttProperties; import com.yupi.project.model.dto.mqtt.RobotStatusMessage; -import com.yupi.project.model.enums.RobotTaskStatusEnum; +import com.yupi.project.model.enums.RobotStatusEnum; // For robot's own status +import com.yupi.project.model.enums.RobotTaskStatusEnum; // For task status from message import com.yupi.project.service.RobotTaskService; +import com.yupi.project.service.ChargingRobotService; // Added import import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.paho.client.mqttv3.IMqttMessageListener; import org.eclipse.paho.client.mqttv3.MqttMessage; import org.springframework.stereotype.Component; +import org.apache.commons.lang3.StringUtils; import java.nio.charset.StandardCharsets; import java.util.Date; @@ -20,6 +23,7 @@ import java.util.Date; public class MqttMessageHandler implements IMqttMessageListener { private final RobotTaskService robotTaskService; + private final ChargingRobotService chargingRobotService; // Added final for RequiredArgsConstructor private final MqttProperties mqttProperties; private final ObjectMapper objectMapper; // For JSON parsing @@ -36,72 +40,94 @@ public class MqttMessageHandler implements IMqttMessageListener { String payload = new String(message.getPayload(), StandardCharsets.UTF_8); log.info("MQTT Message Arrived. Topic: {}, QoS: {}, Payload: {}", topic, message.getQos(), payload); - // Extract robotId from topic. Example: "robot/status/+/robot123" or "robot/command_response/robot123" - // This logic might need adjustment based on the exact topic structure. String[] topicParts = topic.split("/"); if (topicParts.length == 0) { log.error("Received message on invalid topic format: {}", topic); return; } - String robotId = topicParts[topicParts.length - 1]; // Assuming robotId is the last part - - // Determine message type based on topic structure (e.g., status update vs command response) - // For now, let's assume all messages on subscribed topics are status updates. - // A more robust solution would use different listeners for different topic patterns or inspect the payload. + String robotUID = topicParts[topicParts.length - 1]; if (topic.startsWith(mqttProperties.getStatusTopicBase())) { - handleRobotStatusUpdate(robotId, payload); + handleRobotStatusUpdate(robotUID, payload); } else { log.warn("Received message on unhandled topic structure: {}. Ignoring.", topic); } } - private void handleRobotStatusUpdate(String robotId, String payloadJson) { + private void handleRobotStatusUpdate(String robotUIDFromTopicSource, String payloadJson) { try { RobotStatusMessage statusMessage = objectMapper.readValue(payloadJson, RobotStatusMessage.class); - log.info("Parsed RobotStatusMessage for robot {}: {}", robotId, statusMessage); + log.info("Parsed RobotStatusMessage for robot {}: {}", robotUIDFromTopicSource, statusMessage); - if (statusMessage.getTaskId() == null) { - log.warn("RobotStatusMessage for robot {} does not contain a taskId. Payload: {}", robotId, payloadJson); - // Optionally, handle general status updates not tied to a specific task - return; - } + // Scenario 1: Message is related to a specific task (has taskId) + if (statusMessage.getTaskId() != null) { + log.debug("Handling task-specific status update for task ID: {} from robot {}", statusMessage.getTaskId(), robotUIDFromTopicSource); + RobotTaskStatusEnum taskNewStatus = RobotTaskStatusEnum.fromValue(statusMessage.getStatus()); + if (taskNewStatus == null) { + log.error("Invalid task status '{}' received for task {} from robot {}. Payload: {}", + statusMessage.getStatus(), statusMessage.getTaskId(), robotUIDFromTopicSource, payloadJson); + return; + } + boolean taskUpdated; // Defined outside switch + switch (taskNewStatus) { + case PROCESSING: + taskUpdated = robotTaskService.markTaskAsProcessing(statusMessage.getTaskId(), new Date(), statusMessage.getMessage()); + break; + case COMPLETED: + taskUpdated = robotTaskService.markTaskAsCompleted(statusMessage.getTaskId(), new Date(), statusMessage.getMessage()); + break; + case FAILED: + String combinedTaskErrorMessage = statusMessage.getErrorCode() != null ? + statusMessage.getErrorCode() + ": " + statusMessage.getMessage() : + statusMessage.getMessage(); + taskUpdated = robotTaskService.markTaskAsFailed(statusMessage.getTaskId(), combinedTaskErrorMessage, new Date()); + break; + default: + log.warn("Received unhandled RobotTaskStatusEnum: {} for task {} from robot {}. Ignoring.", + taskNewStatus, statusMessage.getTaskId(), robotUIDFromTopicSource); + return; + } + if (taskUpdated) { + log.info("Successfully updated task {} to status {} for robot {} based on MQTT message.", + statusMessage.getTaskId(), taskNewStatus, robotUIDFromTopicSource); + } else { + log.warn("Failed to update task {} to status {} for robot {} (or task not found/invalid state transition). Message: {}", + statusMessage.getTaskId(), taskNewStatus, robotUIDFromTopicSource, statusMessage.getMessage()); + } + } + else if (statusMessage.getActualRobotStatus() != null) { + log.debug("Handling general status update from robot {}", robotUIDFromTopicSource); + + String actualRobotUIDToUse = StringUtils.isNotBlank(statusMessage.getRobotUid()) ? statusMessage.getRobotUid() : robotUIDFromTopicSource; + if (StringUtils.isBlank(actualRobotUIDToUse)) { + log.warn("Cannot determine a valid robot UID for general status update (from topic: {}, from message body: {}). Ignoring update.", + robotUIDFromTopicSource, statusMessage.getRobotUid()); + return; + } - // Update the RobotTask based on the status message - RobotTaskStatusEnum newStatus = RobotTaskStatusEnum.fromValue(statusMessage.getStatus()); - if (newStatus == null) { - log.error("Invalid status '{}' received from robot {}. Payload: {}", statusMessage.getStatus(), robotId, payloadJson); - return; - } + RobotStatusEnum robotStatus = RobotStatusEnum.getEnumByValue(statusMessage.getActualRobotStatus()); + if (robotStatus == null) { + log.warn("Received unknown status value '{}' from robot {}. Message: {}", + statusMessage.getActualRobotStatus(), actualRobotUIDToUse, statusMessage); + } - boolean updated; - switch (newStatus) { - case PROCESSING: - updated = robotTaskService.markTaskAsProcessing(statusMessage.getTaskId(), new Date(), statusMessage.getMessage()); - break; - case COMPLETED: - updated = robotTaskService.markTaskAsCompleted(statusMessage.getTaskId(), new Date(), statusMessage.getMessage()); - break; - case FAILED: - updated = robotTaskService.markTaskAsFailed(statusMessage.getTaskId(), new Date(), statusMessage.getErrorCode(), statusMessage.getMessage()); - break; - default: - log.warn("Received unhandled RobotTaskStatusEnum: {} for task {} from robot {}. Ignoring.", - newStatus, statusMessage.getTaskId(), robotId); - return; // Don't attempt to update with SENT or PENDING from robot - } + String location = statusMessage.getLocation(); + Integer batteryLevel = statusMessage.getBatteryLevel(); + Long currentRobotTask = statusMessage.getActiveTaskId(); - if (updated) { - log.info("Successfully updated task {} to status {} for robot {} based on MQTT message.", - statusMessage.getTaskId(), newStatus, robotId); - } else { - log.warn("Failed to update task {} to status {} for robot {} (or task not found/invalid state transition). " + - "Message: {}", statusMessage.getTaskId(), newStatus, robotId, statusMessage.getMessage()); + log.info("Processing general status update for robot {}: Status={}, Location={}, Battery={}, ActiveTaskID={}. (Original topic UID: {})", + actualRobotUIDToUse, robotStatus, location, batteryLevel, currentRobotTask, robotUIDFromTopicSource); + + chargingRobotService.updateRobotStatus(actualRobotUIDToUse, robotStatus, location, batteryLevel, currentRobotTask, new Date()); + } + else { + log.warn("RobotStatusMessage for robot {} has no taskId and no general status fields. Payload: {}. Ignoring.", + robotUIDFromTopicSource, payloadJson); } } catch (Exception e) { log.error("Error processing robot status update for robot {}. Payload: {}. Error: {}", - robotId, payloadJson, e.getMessage(), e); + robotUIDFromTopicSource, payloadJson, e.getMessage(), e); } } } \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/ParkingSpotServiceImpl.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ParkingSpotServiceImpl.java new file mode 100644 index 0000000..b933f15 --- /dev/null +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/ParkingSpotServiceImpl.java @@ -0,0 +1,170 @@ +package com.yupi.project.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.yupi.project.common.ErrorCode; +import com.yupi.project.exception.BusinessException; +import com.yupi.project.mapper.ParkingSpotMapper; +import com.yupi.project.model.entity.ParkingSpot; +import com.yupi.project.model.enums.ParkingSpotStatusEnum; +import com.yupi.project.service.ParkingSpotService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; + +/** +* @author Yupi +* @description 针对表【parking_spot(车位表)】的数据库操作Service实现 +* @createDate 2023-12-03 10:00:00 +*/ +@Service +@Slf4j +public class ParkingSpotServiceImpl extends ServiceImpl + implements ParkingSpotService { + + @Override + @Transactional + public ParkingSpot addParkingSpot(String spotUid, String locationDescription, boolean robotAssignable) { + if (findBySpotUid(spotUid) != null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "车位UID已存在: " + spotUid); + } + ParkingSpot spot = new ParkingSpot(); + spot.setSpotUid(spotUid); + spot.setLocationDescription(locationDescription); + spot.setRobotAssignable(robotAssignable); + spot.setStatus(ParkingSpotStatusEnum.AVAILABLE.getValue()); // 默认可用 + spot.setCreateTime(new Date()); + spot.setUpdateTime(new Date()); + boolean saved = this.save(spot); + if (!saved) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "添加车位失败"); + } + log.info("车位添加成功: {}", spot); + return spot; + } + + @Override + public ParkingSpot findBySpotUid(String spotUid) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("spot_uid", spotUid); + return this.getOne(queryWrapper); + } + + @Override + @Transactional + public boolean updateParkingSpotStatus(Long spotId, ParkingSpotStatusEnum newStatus, Long currentSessionId) { + ParkingSpot spot = this.getById(spotId); + if (spot == null) { + log.warn("旧版 updateParkingSpotStatus: 车位不存在, spotId: {}", spotId); + // throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "车位不存在"); + // 为了与新方法的行为保持一致(新方法如果UID找不到会返回false),这里也返回false + return false; + } + // 调用新的基于UID的方法 + return this.updateSpotStatus(spot.getSpotUid(), newStatus, currentSessionId); + } + + @Override + public List findAvailableAndAssignableSpots() { + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq("status", ParkingSpotStatusEnum.AVAILABLE.getValue()); + queryWrapper.eq("robot_assignable", true); + return this.list(queryWrapper); + } + + @Override + @Transactional + public boolean occupySpot(Long spotId, Long sessionId, ParkingSpotStatusEnum targetStatus) { + ParkingSpot spot = this.getById(spotId); + if (spot == null) { + log.warn("OccupySpot: 车位不存在, spotId: {}", spotId); + // throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "车位不存在"); + return false; // 与新 updateSpotStatus 行为一致 + } + // 校验车位是否可被占用 (例如必须是AVAILABLE状态) + if (!ParkingSpotStatusEnum.AVAILABLE.getValue().equals(spot.getStatus())) { + log.warn("尝试占用一个非可用车位: spotId={}, currentStatus={}", spotId, spot.getStatus()); + // throw new BusinessException(ErrorCode.OPERATION_ERROR, "车位当前状态不可占用"); + return false; // 状态不符,占用失败 + } + if (!spot.getRobotAssignable()) { + log.warn("尝试占用一个不可指派机器人的车位: spotId={}", spotId); + // throw new BusinessException(ErrorCode.OPERATION_ERROR, "车位不可指派机器人"); + return false; // 不可指派,占用失败 + } + + // return updateParkingSpotStatus(spotId, targetStatus, sessionId); // 旧调用 + return this.updateSpotStatus(spot.getSpotUid(), targetStatus, sessionId); // 新调用 + } + + @Override + @Transactional + public boolean releaseSpot(Long spotId) { + ParkingSpot spot = this.getById(spotId); + if (spot == null) { + log.warn("尝试释放车位失败,车位不存在: {}", spotId); + return false; + } + // 只有非AVAILABLE的车位才需要被释放回AVAILABLE + // 新的 updateSpotStatus 内部会检查是否真的需要更新,所以这里的检查可以简化或移除 + // if (ParkingSpotStatusEnum.AVAILABLE.getValue().equals(spot.getStatus())) { + // log.info("车位 {} (UID: {}) 本身已是可用状态,无需释放。", spotId, spot.getSpotUid()); + // return true; + // } + // return updateParkingSpotStatus(spotId, ParkingSpotStatusEnum.AVAILABLE, null); // 旧调用 + return this.updateSpotStatus(spot.getSpotUid(), ParkingSpotStatusEnum.AVAILABLE, null); // 新调用 + } + + @Override + @Transactional + public boolean updateSpotStatus(String spotUID, ParkingSpotStatusEnum newStatus, Long currentSessionId) { + if (StringUtils.isBlank(spotUID) || newStatus == null) { + log.warn("更新车位状态失败:spotUID 或 newStatus 为空。spotUID: {}, newStatus: {}", spotUID, newStatus); + return false; + } + + ParkingSpot parkingSpot = this.findBySpotUid(spotUID); + if (parkingSpot == null) { + log.warn("更新车位状态失败:未找到 UID 为 {} 的车位。", spotUID); + return false; + } + + ParkingSpot spotToUpdate = new ParkingSpot(); + spotToUpdate.setId(parkingSpot.getId()); + boolean changed = false; + + // Compare enum with String value from entity + if (newStatus != null && !newStatus.getValue().equals(parkingSpot.getStatus())) { + spotToUpdate.setStatus(newStatus.getValue()); // Set String value to entity + changed = true; + } + + if (currentSessionId == null && parkingSpot.getCurrentSessionId() != null) { + spotToUpdate.setCurrentSessionId(null); + changed = true; + } else if (currentSessionId != null && !currentSessionId.equals(parkingSpot.getCurrentSessionId())) { + spotToUpdate.setCurrentSessionId(currentSessionId); + changed = true; + } + + if (!changed) { + log.info("车位 {} 的状态与数据库一致 (状态: {}, 会话ID: {}),无需更新。", + spotUID, newStatus, currentSessionId == null ? "null" : currentSessionId); + return true; // Data is consistent, operation considered successful + } + + // updateTime will be auto-filled + boolean success = this.updateById(spotToUpdate); + if (success) { + log.info("成功更新车位 {} 的状态。新状态: {}, 当前会话ID: {}", + spotUID, newStatus, currentSessionId == null ? "null" : currentSessionId); + } else { + log.error("更新车位 {} 的状态失败 (数据库操作失败)。", spotUID); + } + return success; + } +} \ No newline at end of file diff --git a/springboot-init-main/src/main/java/com/yupi/project/service/impl/RobotTaskServiceImpl.java b/springboot-init-main/src/main/java/com/yupi/project/service/impl/RobotTaskServiceImpl.java index ebf8c7c..a133ac2 100644 --- a/springboot-init-main/src/main/java/com/yupi/project/service/impl/RobotTaskServiceImpl.java +++ b/springboot-init-main/src/main/java/com/yupi/project/service/impl/RobotTaskServiceImpl.java @@ -295,38 +295,24 @@ public class RobotTaskServiceImpl extends ServiceImpl return safetyUser; } - @Override - @Transactional - public boolean deductBalance(Long userId, BigDecimal amount) { - if (userId == null || userId <= 0 || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数错误"); - } - boolean updateResult = this.update() - .setSql("balance = balance - " + amount.doubleValue()) - .eq("id", userId) - .ge("balance", amount) - .update(); - - if (!updateResult) { - User user = this.getById(userId); - if (user == null || user.getIsDeleted() == 1) { - throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在"); - } else if (user.getBalance().compareTo(amount) < 0) { - throw new BusinessException(ErrorCode.OPERATION_ERROR, "余额不足"); - } else { - log.warn("Deduct balance failed due to concurrent update for userId: {}", userId); - throw new BusinessException(ErrorCode.OPERATION_ERROR, "扣款失败,请重试"); - } - } - log.info("Deducted {} from balance for user {}", amount, userId); - return true; - } - - @Override - @Transactional - public boolean addBalance(Long userId, BigDecimal amount) { - if (userId == null || userId <= 0 || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数错误"); - } - boolean updateResult = this.update() - .setSql("balance = balance + " + amount.doubleValue()) - .eq("id", userId) - .update(); - - if (!updateResult) { - User user = this.getById(userId); - if (user == null || user.getIsDeleted() == 1) { - throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在"); - } - log.error("Add balance failed unexpectedly for userId: {}", userId); - throw new BusinessException(ErrorCode.SYSTEM_ERROR, "充值失败,请稍后重试"); - } - log.info("Added {} to balance for user {}", amount, userId); - return true; - } - @Override public List listUsers() { List userList = this.list(new QueryWrapper().eq("isDeleted", 0)); @@ -389,4 +339,60 @@ public class UserServiceImpl extends ServiceImpl ); return true; } + + @Override + @Transactional + public boolean decreaseBalance(Long userId, BigDecimal amountToDecrease) { + if (userId == null || amountToDecrease == null || amountToDecrease.compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户ID或扣款金额无效"); + } + + User user = this.getById(userId); + if (user == null) { + // 即使SQL会因为用户不存在而不更新,这里提前检查可以提供更明确的错误信息 + log.warn("尝试为不存在的用户ID {} 扣款 {} 失败。", userId, amountToDecrease); + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在,无法扣款"); + } + + // 数据库层面的原子操作将处理余额是否足够 + // The amountChange passed to mapper is negative for decrease + int updatedRows = baseMapper.updateUserBalance(userId, amountToDecrease.negate()); + + if (updatedRows > 0) { + log.info("用户 {} 余额扣款 {} 成功 (通过原子操作)。", userId, amountToDecrease); + // 获取更新后的余额用于日志记录是可选的,且可能引入额外查询。为简单起见,此处省略。 + return true; + } else { + // updatedRows == 0 表示扣款未成功,主要原因是余额不足(由SQL的WHERE子句保证)或用户ID无效(可能性较低,因已预查) + log.warn("用户 {} 余额扣款 {} 失败。原子操作更新行数为0,通常表示余额不足或用户不存在。", userId, amountToDecrease); + // 可以选择再次查询用户以确认具体原因,但对于调用方来说,false已足够 + return false; + } + } + + @Override + @Transactional + public boolean increaseBalance(Long userId, BigDecimal amountToIncrease) { + if (userId == null || amountToIncrease == null || amountToIncrease.compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户ID或增加金额无效"); + } + + // Ensure user exists before attempting to update + User user = this.getById(userId); + if (user == null) { + log.warn("尝试为不存在的用户 {} 增加余额 {} 失败。", userId, amountToIncrease); + throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在,无法增加余额"); + } + + int updatedRows = baseMapper.updateUserBalance(userId, amountToIncrease); + + if (updatedRows > 0) { + log.info("用户 {} 余额增加 {} 成功。当前余额: {}", userId, amountToIncrease, user.getBalance().add(amountToIncrease)); + return true; + } else { + // This case should ideally not happen if user existence is checked before, unless user is deleted concurrently. + log.warn("用户 {} 余额增加 {} 失败,更新数据库记录数为0。", userId, amountToIncrease); + return false; + } + } } \ No newline at end of file diff --git a/springboot-init-main/src/main/resources/mapper/UserMapper.xml b/springboot-init-main/src/main/resources/mapper/UserMapper.xml index f9e87c8..e54cd5d 100644 --- a/springboot-init-main/src/main/resources/mapper/UserMapper.xml +++ b/springboot-init-main/src/main/resources/mapper/UserMapper.xml @@ -23,4 +23,13 @@ userPassword,createTime,updateTime, isDelete + + + UPDATE user + SET balance = balance + #{amountChange} + WHERE id = #{userId} + + AND balance >= #{amountChange.abs()} + +