This commit is contained in:
2025-05-13 21:30:06 +08:00
commit 60abc68c60
115 changed files with 12478 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

19
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="mqtt-charging-system" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="mqtt-charging-system" options="-parameters" />
</option>
</component>
</project>

6
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/springboot-init-main/src/main/java" charset="UTF-8" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

20
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
</component>
</project>

14
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/springboot-init-main/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="corretto-1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/mqtt_power.iml" filepath="$PROJECT_DIR$/.idea/mqtt_power.iml" />
</modules>
</component>
</project>

9
.idea/mqtt_power.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

31
LogBook.md Normal file
View File

@@ -0,0 +1,31 @@
# 项目变更日志 - 第二阶段MQTT 集成
## 2023-12-02: 第二阶段启动 - MQTT 集成
- **状态**: 第一阶段开发已完成,相关日志已存档至 `LogBook_Phase1.md`
- **当前任务**: 开始第二阶段开发,重点是 MQTT 的集成。
- **依据文档**: `springboot-init-main/doc/development_stages/stage_2_mqtt_integration.md`
- **已完成**:
1. **数据库初始化**: 在 `mqtt_power` 数据库中成功创建了 `robot_task` 表。
2. **MQTT Broker 确定与配置**:
- 确认使用公共 MQTT Broker: `broker.emqx.io:1883`
- 更新 `springboot-init-main/src/main/resources/application.yml` 添加了 Broker 连接信息,并为 `client-id-prefix``command-topic-base``status-topic-base` 添加了项目唯一前缀 (如 `yupi_mqtt_power_project/`) 以确保在公共环境中的唯一性。
- 创建了 `springboot-init-main/src/main/java/com/yupi/project/config/properties/MqttProperties.java` 来映射 MQTT 配置。
3. **更新开发文档**:
- 修改了 `springboot-init-main/doc/development_stages/stage_2_mqtt_integration.md`,反映了公共 Broker 的使用、Topic 唯一性策略以及应用层鉴权的重要性。
4. **实现 MQTT 客户端核心配置 (`MqttConfig.java` 和 `MqttCallbackHandler.java`)**:
- 创建了 `com.yupi.project.mqtt.MqttCallbackHandler` 类,实现 `MqttCallbackExtended`接口,用于处理连接事件和初步的消息接收(日志记录)。在 `connectComplete` 中实现订阅状态主题 `yupi_mqtt_power_project/robot/status/+` 的逻辑。
- 创建了 `com.yupi.project.config.MqttConfig` 配置类:
- 注入 `MqttProperties``MqttCallbackHandler`
- 定义了 `MqttConnectOptions` Bean配置连接参数如自动重连、Clean Session、超时等
- 定义了 `MqttClient` Bean使用唯一的客户端ID并设置 `MqttCallbackHandler`
- 使用 `@PostConstruct` 实现了应用启动时自动连接到 MQTT Broker 的逻辑。
- 使用 `@PreDestroy` 实现了应用关闭时断开 MQTT 连接的逻辑。
- **下一步计划 (依据 `stage_2_mqtt_integration.md`)**:
1. **实现 `RobotTask` 管理 (`RobotTaskService`)**:
- 创建 `RobotTask` 实体、Mapper 接口。
- 定义并实现 `RobotTaskService` 接口中的方法 (如创建任务、更新任务状态、查找任务等)。
2. **实现消息发布 (`MqttService`)**: 用于向机器人发送指令,并与 `RobotTaskService` 交互记录任务。
3. **实现消息处理 (`MqttMessageHandler`)**: 详细处理从机器人接收到的状态更新,并与 `RobotTaskService` 交互更新任务状态。
4. **实现任务超时处理 (`TaskTimeoutHandler`)**: 定时检查并处理超时的 MQTT 任务。

297
LogBook_Phase1.md Normal file
View File

@@ -0,0 +1,297 @@
# 项目变更日志
## 2023-11-16
- 初始化查看springboot-init-main项目
- 项目分析详情如下:
- 这是一个基于Spring Boot 2.7.0的初始化项目模板
- 集成了MyBatis-Plus、Redis、MySQL等核心组件
- 遵循经典的MVC架构设计
## 2023-11-17
- 启用MyBatis-Plus的驼峰命名转换功能
- 修改application.yml文件中的`map-underscore-to-camel-case`配置项为`true`
- 准备基于此模板开发全新项目
## 2023-11-18
- **项目启动MQTT 智能充电桩系统**
- 基于 `springboot-init` 模板进行二次开发。
- 修改项目标识 (`pom.xml`, `application.yml`) 为 `mqtt-charging-system`
- 添加 `org.eclipse.paho.client.mqttv3` 依赖用于 MQTT 通信。
- **核心功能规划:**
1. 用户登录(管理员/普通用户)
2. MQTT 集成控制硬件JSON 消息)
3. 简易充电桩系统(激活码充值、时长计费)
- **创建需求文档**: 在 `doc/requirements.md` 中整理并记录了详细的功能和非功能需求。
## 2023-11-19
- **调整需求文档**: 根据反馈更新 `doc/requirements.md`
- 明确系统为 **移动式** 充电系统,设备为 **充电机器人**
- 调整充电流程,加入用户 **选择车位****机器人移动** 的步骤。
- 更新 MQTT Topic 和 Payload 示例以反映新流程。
- 调整数据库设计,引入 `charging_robot``parking_spot` 表。
- 强调平台侧重于业务逻辑和核心指令交互,不关注机器人底层实现。
## 2023-11-20
- **优化数据库设计**: 根据建议调整 `requirements.md` 中的数据库结构。
- 将用户余额字段合并到 `user` 表。
- 移除 `role``user_role` 表,在 `user` 表中直接添加 `role` 字段。
## 2023-11-21
- **格式化开题报告**: 调整 `doc/kaiti.md` 文件格式,提高可读性。
- **细化需求并规划 MQTT**:
- 结合 `kaiti.md` 内容,更新 `doc/requirements.md`
- 明确平台与硬件ESP32-CAM的职责边界。
- 提出具体的 MQTT Topic 结构 (`robot/command/{clientId}`, `robot/status/{clientId}`) 和 JSON Payload 示例。
- 强调需要用户提供最终的 MQTT 服务器信息、认证凭据以及与硬件端确认的 Topic/Payload 约定。
## 2023-11-22
- **编写开发方案**: 在 `doc/development_plan.md` 中创建了详细的开发计划。
- 包含系统架构、开发阶段划分、模块实现细节、数据库初步 DDL、高层 API 设计和 MQTT 契约强调。
- 明确后续开发依赖于用户提供 MQTT 连接信息和最终的消息格式约定。
## 2023-11-23
- **引入机器人任务表**: 为了提高 MQTT 通信的健壮性,决定引入 `robot_task` 表。
- **目的**: 跟踪发送给机器人的命令状态,防止向未响应的机器人发送新命令。
- **更新文档**:
-`development_plan.md` 中添加了 `robot_task` 表的 DDL。
- 修改了 `development_plan.md` 中的 MQTT 发送/接收流程,集成任务状态检查和更新逻辑。
-`development_plan.md` 中增加了任务超时处理的计划。
-`requirements.md` 的数据库设计部分补充了 `robot_task` 表说明。
## 2023-11-24
- **创建分阶段开发计划**:
-`doc/development_stages/` 目录下创建了详细的阶段性开发计划文件。
- 每个文件 (`stage_1_*.md``stage_4_*.md`) 包含该阶段的目标、前后端详细开发步骤、界面设计要点(针对移动端,蓝白科技感风格)和交付物。
- 旨在为后续的编码工作提供更具体的指导。
## 2023-11-25 开始第一阶段开发:基础架构与用户管理
- **后端实现**:
- 创建 `User` 实体类。
- 创建 `UserMapper` 接口。
- 创建 `SecurityConfig` 提供 `PasswordEncoder`
- 创建 `UserService` 接口及 `UserServiceImpl` 实现类,包含注册、登录、登出、获取当前用户、余额操作(增加/扣减,含事务和并发处理)等逻辑。
- 创建 `UserConstant` 定义常量。
- 创建 `UserLoginRequest``UserRegisterRequest` DTO。
- 创建 `UserController` 实现用户相关 API (/register, /login, /logout, /current)。
- 添加 `CorsConfig` 进行全局跨域配置。
- **下一步**: 需要进行数据库表结构初始化,并可以开始测试后端用户相关接口。
## 2023-11-26 配置数据库并转向前端开发
- **更新数据库配置**: 根据用户提供的信息,修改了 `application.yml` 中的数据库连接URL、用户名和密码。
- **下一步**: 开始进行 **阶段一****前端页面** 编写,实现登录、注册(如果需要)及基础导航功能。
### 2023-11-27: 初始化前端项目结构
* 在根目录下创建了 `charging_app` 目录作为前端移动应用项目。
* 选择了 React Native (使用 Expo) 作为前端技术栈,因为它提供了良好的跨平台能力和开发体验。
* 创建了标准的前端项目结构,包括 `src` 目录下的 `screens`, `components`, `navigation`, `services`, `contexts`, `utils`, `assets` 子目录。
* 添加了核心依赖配置文件 `package.json`
* 配置了 TypeScript `tsconfig.json`启用了JSX和严格模式。
* 创建了应用入口文件 `App.tsx`,集成了 `SafeAreaProvider``AuthProvider`
* 实现了认证上下文 `AuthContext.tsx`,用于管理用户登录状态、用户信息,并提供了登录、注册、登出方法,使用 `expo-secure-store` 存储会话状态。
* 配置了 API 请求服务 `api.ts` (使用 Axios),设置了基础 URL、超时和 `withCredentials`,并添加了基础的请求/响应拦截器逻辑。
* 创建了应用导航器 `AppNavigator.tsx`,使用 `@react-navigation` 实现:
* 根据认证状态切换认证流程(登录/注册)和主应用界面。
* 认证后,根据用户角色 (`user`/`admin`) 导航到不同的主界面 (通过底部 Tab 导航)。
* 添加了加载状态指示器。
* 创建了基本的屏幕占位符:`LoginScreen.tsx`, `RegisterScreen.tsx`, `UserHomeScreen.tsx`, `AdminHomeScreen.tsx`
**下一步**:
1.`charging_app` 目录下运行 `npm install``yarn install` 来安装所有前端依赖项。
2. 实现登录页面的UI和功能逻辑。
3. 测试前后端用户登录流程。
### 2023-11-28: 实现登录页面UI与逻辑
* 根据 `stage_1_setup_user_management.md` 中的设计要求,更新了 `charging_app/src/screens/LoginScreen.tsx`
* 添加了用户名和密码的 `TextInput` 组件,以及一个主要的 "登录" `Button`
* 添加了一个 `TouchableOpacity` 作为导航到注册页面的链接。
* 实现了 `handleLogin` 函数,该函数:
* 执行基本的非空输入验证。
* 调用 `AuthContext` 中的 `login` 方法。
* 管理 `isLoading` 状态,在请求期间显示 `ActivityIndicator`
* 管理 `error` 状态,在登录失败时显示错误文本和 `Alert` 提示。
* 应用了符合设计指南的样式 (科技蓝主色调,简洁布局,圆角输入框和按钮)。
*`LoginScreen` 添加了正确的 `navigation` prop类型定义 (`StackNavigationProp`)。
**下一步**:
1. 启动后端服务 (`springboot-init-main`)。
2. 在模拟器或真实设备上运行前端应用 (`charging_app`,例如使用 `npx expo start`)。
3. 测试完整的登录流程,包括成功登录和失败(错误凭据)的情况。
4. (可选) 实现注册页面 `RegisterScreen.tsx` 的UI和逻辑。
### 2023-11-26: 更新数据库配置与确认前端方案
* 根据用户提供的最新信息,更新了后端 `springboot-init-main/src/main/resources/application.yml` 文件中的数据库连接配置 (URL, username, password)。
* 确认了移动端 App 的开发将使用跨平台框架。
### 2023-11-29: 前端技术方向重大调整从原生App转向手机版网页
* **核心决策**根据最新需求项目前端将从React Native移动应用调整为手机版网页应用。
* **技术选型变更**放弃React Native + Expo计划采用 **Next.js** (基于React) 作为新的前端框架。
* 理由Next.js更适合构建服务端渲染或静态生成的网页应用并能良好支持响应式设计以适应手机浏览器。
* **影响评估**
* 现有的 `charging_app` React Native项目将被新的Next.js项目替代。
* UI组件、样式、导航系统需要完全重写。
* 认证逻辑 (`AuthContext`) 和API服务 (`api.ts`) 的核心部分可以迁移和调整,但与原生特性相关的部分(如 `expo-secure-store`需要替换为Web等效方案`localStorage`)。
* 所有前端相关的开发阶段文档(如 `stage_1_*.md`)需要更新以反映新的技术栈和实现方法。
**下一步**:
1. 删除或归档现有的 `charging_app` (React Native) 项目目录。
2. 初始化新的Next.js前端项目 (例如 `charging_web_app`)。
3. 迁移和调整 `AuthContext``api.ts` 的核心逻辑。
4. 重新实现登录页面的UI和功能使其适应Web浏览器。
5. 更新 `stage_1_setup_user_management.md` 文档。
### 2023-11-30: 初始化Next.js项目并实现Web登录页
*`charging_web_app` 目录下手动安装了 `axios` 依赖。
* 更新了 `springboot-init-main/doc/development_stages/stage_1_setup_user_management.md` 文档的前端部分以适配Next.js技术栈和Web开发实践。
* 创建了Next.js项目的根布局文件 `charging_web_app/src/app/layout.tsx`,并在此布局中集成了 `AuthProvider`,确保全局认证状态管理。
* 创建了新的Web登录页面 `charging_web_app/src/app/login/page.tsx`
* 标记为客户端组件 (`'use client';`)。
* 使用Tailwind CSS构建了适应Web的登录表单UI (卡片布局、输入框、按钮、注册链接)。
* 集成了 `useAuth` 钩子,实现了表单提交、调用 `login` 方法、处理加载和错误状态的逻辑。
* 添加了 `useEffect` 钩子,用于在用户已认证时自动重定向到相应的主页。
**下一步**:
1. 配置Next.js开发环境代理 (在 `next.config.mjs` 中添加 `rewrites`),将 `/api/*` 请求转发到后端服务 (`http://localhost:7529/api/*`)。
2. 启动后端服务 (`springboot-init-main`)。
3. 启动Next.js前端开发服务器 (`cd charging_web_app && npm run dev`)。
4. 在浏览器中访问登录页面 (通常是 `http://localhost:3000/login`) 并测试登录流程。
5. (可选) 实现注册页面 `src/app/register/page.tsx`
### 2023-12-01: 实现Web注册页面
* 创建了注册页面组件 `charging_web_app/src/app/register/page.tsx`
* 使用Tailwind CSS构建了注册表单UI包含用户名、密码和确认密码字段风格与登录页保持一致。
* 实现了前端输入验证逻辑,包括非空、密码一致性和最小长度检查。
* 集成了 `useAuth` 钩子,在表单提交时调用 `register` 方法。
* 实现了加载状态(按钮显示加载动画)、错误状态(显示错误信息)和成功状态(显示成功消息并延迟跳转)的处理。
* 添加了返回登录页面的链接。
**下一步**:
1. 测试完整的注册流程,包括成功和失败场景。
2. 根据用户角色,创建基础的主页路由和组件(例如 `src/app/(authenticated)/dashboard/page.tsx``src/app/(authenticated)/admin/dashboard/page.tsx`),并实现基本的权限访问控制逻辑(例如在布局或页面中检查 `isAuthenticated``user.role`)。
3. 解决之前提到的后端 `/api/user/current` 接口返回 500 错误的问题。
## YYYY-MM-DD (请替换为当前日期)
- **移除后端 Redis 依赖**
-`springboot-init-main/pom.xml` 中移除了 `spring-boot-starter-data-redis``spring-session-data-redis` 依赖。
- 修改 `springboot-init-main/src/main/resources/application.yml`
- 移除了 `spring.redis` 配置块。
-`spring.session.store-type` 的值从 `redis` 修改为 `none`,使 Session 存储回退到默认的内存方式。
- 此变更旨在简化项目依赖,如果后续需要分布式 Session 管理或缓存,可以重新引入 Redis 或其他替代方案。
## YYYY-MM-DD (请替换为当前日期)
- **添加 Spring Security 依赖**
- 为了解决 `PasswordEncoder` 相关的编译错误,在 `springboot-init-main/pom.xml` 中添加了 `spring-boot-starter-security` 依赖。
## 2023-12-02: 完成第一阶段核心功能开发 (用户中心与管理员功能)
**后端 (`springboot-init-main`)**:
- **`UserService` / `UserServiceImpl`**:
- 新增 `listUsers()` 方法,用于获取所有(未删除的)用户信息,并进行脱敏处理。
- **`UserController`**:
- 启用并完善 `/api/user/list` 接口,使其调用 `userService.listUsers()`,并添加了基于 `UserRoleEnum.ADMIN` 的显式权限检查 (作为双重保险)。
- **`SecurityConfig.java`**:
- 更新了 `authorizeRequests` 配置:
- `/api/user/current` 设置为需要 `authenticated()`
- `/api/user/list` 设置为需要 `hasAuthority(UserRoleEnum.ADMIN.getValue())`,确保只有 "admin" 角色的用户可以访问。
- `/api/user/login``/api/user/register` 保持 `permitAll()`
**前端 (`charging_web_app`)**:
- **`AuthContext.tsx`**:
-`login` 方法中,登录成功后,根据 `user.role` (期望为 "admin" 或 "user") 将用户重定向到 `/admin/dashboard``/dashboard`
- 优化了 `checkAuth` 方法的日志和加载状态处理。
- **路由与权限控制**:
- 创建了路由组目录 `src/app/(authenticated)/`
- 创建了 `src/app/(authenticated)/layout.tsx` (`AuthenticatedLayout`),用于保护该组下的所有路由:
- 使用 `useAuth` 检查认证状态 (`isAuthenticated`, `isLoading`)。
- 如果用户未认证,则使用 `router.replace('/login')` 重定向到登录页。
- 在加载期间显示 `LoadingSpinner` 组件。
- 创建了 `src/components/LoadingSpinner.tsx` 提供一个简单的加载动画组件。
- **普通用户主页**:
- 创建了 `src/app/(authenticated)/dashboard/page.tsx` (`DashboardPage`)
- 显示欢迎信息、用户ID、角色和余额。
- 提供登出按钮,调用 `auth.logout()`
- 如果非普通用户(如管理员)意外访问,则重定向到其对应的 dashboard。
- **管理员主页**:
- 创建了 `src/app/(authenticated)/admin/dashboard/page.tsx` (`AdminDashboardPage`)
- 显示管理员欢迎信息。
- 提供导航链接到"用户管理"页面 (`/admin/user-management`)。
- 提供登出按钮。
- 如果非管理员用户意外访问,则重定向到 `/dashboard`
- **管理员用户管理页面**:
- 创建了 `src/app/(authenticated)/admin/user-management/page.tsx` (`UserManagementPage`)
- 页面加载时,如果用户是管理员,则调用后端 `/api/user/list` 接口获取用户列表。
- 将获取到的用户列表ID, 用户名, 角色, 余额)以表格形式展示。
- 处理加载状态和错误状态如无权限访问或API调用失败
- 提供返回管理员主页的链接。
- 实现了严格的权限检查,非管理员访问会重定向。
**下一步**:
1. **全面测试**
- 启动后端 (`springboot-init-main`) 和前端 (`charging_web_app`) 服务。
- 测试普通用户注册、登录、查看用户中心、登出。
- 测试管理员用户登录、查看管理员控制台、访问用户管理列表、登出。
- 验证所有页面的权限控制和重定向逻辑是否按预期工作。
- 检查浏览器控制台和后端日志,确保没有错误。
2. 根据 `stage_1_setup_user_management.md` 的要求,完成 **接口测试报告**
3. 如果一切顺利,第一阶段的核心功能(用户管理、用户中心、管理员用户列表)即可视为完成。
## 2023-12-02 (续): 完成管理员对用户的增删改功能
**后端 (`springboot-init-main`)**:
- **DTOs**:
- 创建 `UserAdminAddRequest.java` 用于管理员添加用户 (username, password, role, balance)。
- 创建 `UserAdminUpdateRequest.java` 用于管理员更新用户 (id, username?, password?, role?, balance?)。
- **`UserService` / `UserServiceImpl`**:
- 实现 `adminAddUser(UserAdminAddRequest req)`: 校验参数、检查用户名唯一性、加密密码、保存新用户。
- 实现 `adminUpdateUser(UserAdminUpdateRequest req)`: 查找用户、校验参数、按需更新用户名(检查冲突)、密码(加密)、角色、余额。
- **`UserController`**:
- 添加 `POST /user/admin/add` 接口,调用 `userService.adminAddUser`
- 添加 `PUT /user/admin/update` 接口,调用 `userService.adminUpdateUser`
- 在各接口中添加了管理员权限校验和必要的参数校验。
- `adminDeleteUser` 接口中增加了防止管理员删除自己的逻辑。
- `adminUpdateUser` 接口中增加了防止管理员将自己角色修改为非管理员的逻辑。
- **`SecurityConfig.java`**:
-`/user/admin/add` (POST) 和 `/user/admin/update` (PUT) 配置了仅管理员 (`hasAuthority('admin')`)可访问的规则。
**前端 (`charging_web_app`)**:
- **`UserManagementPage.tsx`**:
- 添加了 "新增用户" 按钮。
- 为每行用户数据添加了 "编辑" 按钮。
- 实现了基本的模态框使用内联JSX和Tailwind CSS用于用户添加和编辑
- 包含用户名、密码(新增时必填,编辑时可选用于重置)、角色(下拉选择)、余额的表单字段。
- 实现了表单数据的状态管理 (`userFormData`, `editingUser`)。
- 实现了打开/关闭模态框的状态 (`isAddUserModalOpen`, `isEditUserModalOpen`)。
- 实现了 `handleAddUser` 函数,调用 `POST /api/user/admin/add` API成功后关闭模态框并刷新用户列表。
- 实现了 `handleUpdateUser` 函数,调用 `PUT /api/user/admin/update` API成功后关闭模态框并刷新用户列表。
- `handleDeleteUser` 函数保持不变,用于删除用户。
- 编辑按钮对当前登录的管理员如果其角色也是admin进行了禁用处理以防止直接修改自身特别是角色
**下一步**:
1. **全面细致的测试**:覆盖所有增删改查操作的成功与失败场景,包括边界条件和权限验证。
2. **UI/UX 优化**后续可以考虑使用成熟的UI组件库替换当前的简易模态框和表单提升用户体验。
3. 完成第一阶段的接口测试报告和项目总结。
## 2023-12-02 (续): 优化用户管理弹窗UI
**前端 (`charging_web_app`)**:
- **依赖安装**:
-`charging_web_app` 项目中安装了 `@headlessui/react` 依赖包。
- **`UserManagementPage.tsx`**:
- 导入了 Headless UI 的 `Dialog``Transition` 组件。
- 使用这两个组件重构了新增用户和编辑用户的模态框:
- 实现了更平滑的进入和离开动画。
- 弹窗背景覆盖层修改为 `bg-black/30 backdrop-blur-sm`,以实现背景模糊效果。
- 统一并优化了模态框内部表单字段用户名、密码、角色、余额的样式使用了Tailwind CSS。
- 调整了按钮(取消、确认新增/修改)的样式,并为提交按钮添加了 `isLoadingAction` 状态来显示加载动画和禁用状态。
- 在模态框中添加了错误信息显示区域,用于反馈操作失败的原因。
- 修复了 `isLoadingAction` 未定义的 linting 错误。
**下一步**:
1. **重启前端开发服务器**
2. **测试新的弹窗样式和交互**:确认动画、背景模糊、表单样式、按钮状态和错误提示是否符合预期。
3. 如果效果满意第一阶段的用户管理前端UI优化基本完成。可以准备正式结束第一阶段。

41
charging_web_app/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// 其他 Next.js 配置...
// 添加 rewrites 配置用于开发环境代理
async rewrites() {
return [
{
source: '/api/:path*', // 匹配所有以 /api 开头的路径
destination: 'http://localhost:7529/api/:path*', // 代理到后端服务地址
},
];
},
};
export default nextConfig;

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7021
charging_web_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "charging_web_app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^2.2.3",
"axios": "^1.9.0",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,69 @@
'use client';
import React from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
const AdminDashboardPage: React.FC = () => {
const { user, logout, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated || !user) {
// AuthenticatedLayout 应该已经处理了重定向
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
// 如果用户不是管理员则重定向到普通用户dashboard
if (user.role !== 'admin') {
router.replace('/dashboard');
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
}
return (
<div className="container mx-auto p-4 pt-20">
<div className="bg-white shadow-md rounded-lg p-8 max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
</h1>
<p className="text-xl mb-4 text-gray-700">
, <span className="font-semibold">{user.username}</span>!
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<Link href="/admin/user-management" legacyBehavior>
<a className="block p-6 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-md transition duration-150 ease-in-out text-center">
<h2 className="text-xl font-semibold"></h2>
<p></p>
</a>
</Link>
{/* 可以添加更多管理员功能模块链接 */}
<div className="block p-6 bg-gray-200 text-gray-500 rounded-lg shadow-md text-center">
<h2 className="text-xl font-semibold"> ()</h2>
<p></p>
</div>
</div>
<button
onClick={async () => {
await logout();
}}
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
>
</button>
</div>
</div>
);
};
export default AdminDashboardPage;

View File

@@ -0,0 +1,453 @@
'use client';
import React, { useEffect, useState, useCallback, ChangeEvent, FormEvent, Fragment } from 'react';
import { useAuth, User } from '@/contexts/AuthContext'; // 导入 User 类型
import { useRouter } from 'next/navigation';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
import Link from 'next/link';
import { Dialog, Transition } from '@headlessui/react'; // Added Dialog and Transition
// 假设的角色枚举,与后端 UserRoleEnum 对应
const ROLES = [
{ label: '普通用户', value: 'user' },
{ label: '管理员', value: 'admin' },
// { label: '封禁用户', value: 'ban' }, // 根据需要添加
];
interface UserFormData {
username: string;
password?: string; // 编辑时可选,新增时必须
role: string;
balance: string; // 表单中通常是字符串,提交时转换
}
const UserManagementPage: React.FC = () => {
const { user: currentUser, isLoading: authLoading, isAuthenticated } = useAuth();
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [pageLoading, setPageLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- Modals State (简单示例,实际应用推荐使用组件库) ---
const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false);
const [isEditUserModalOpen, setIsEditUserModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [userFormData, setUserFormData] = useState<Partial<UserFormData>>({}); // 使用 Partial 允许部分字段
const [isLoadingAction, setIsLoadingAction] = useState(false); // Added isLoadingAction state
const fetchUsers = useCallback(async () => {
setPageLoading(true);
setError(null);
try {
console.log('UserManagement: Fetching user list...');
const response = await api.get<User[]>('/user/list'); // 假设API直接返回User数组或BaseResponse<User[]>
// 根据实际API响应结构调整
// 如果是 BaseResponse<List<User>>,则需要 response.data.data
if (response.data && Array.isArray((response.data as any).data)) {
setUsers((response.data as any).data);
console.log('UserManagement: Users fetched successfully:', (response.data as any).data.length);
} else if (Array.isArray(response.data)) { // 如果直接返回数组
setUsers(response.data);
console.log('UserManagement: Users fetched successfully (direct array): inconceivable, response.data is BaseResponse, not array itself', response.data.length);
}
else {
console.error('UserManagement: Unexpected response structure for user list.', response.data);
const apiError = response.data as any;
if (apiError && apiError.code === 50000 && apiError.message) {
setError(`获取用户列表失败:${apiError.message}`);
} else {
setError('获取用户列表失败:响应格式不正确或未知错误');
}
setUsers([]);
}
} catch (err: any) {
console.error('UserManagement: Error fetching users:', err);
const errorMessage = err.response?.data?.message || err.message || '获取用户列表时发生错误';
setError(errorMessage);
if (err.response?.status === 403) {
// setError('您没有权限访问此资源。正在重定向...'); // 提示已存在,不再重复
// setTimeout(() => router.replace('/admin/dashboard'), 2000);
} else if (err.response?.status === 401) {
// setError('会话已过期或无效,请重新登录。正在重定向...');
// setTimeout(() => router.replace('/login'), 2000);
}
} finally {
setPageLoading(false);
}
}, []); // 空依赖数组因为router通常不需要作为依赖
useEffect(() => {
if (!authLoading && (!isAuthenticated || !currentUser || currentUser.role !== 'admin')) {
// 如果认证加载完成,但用户未认证、不是管理员,或用户信息不存在
console.log('UserManagement: Auth check failed or not admin, redirecting.');
router.replace(currentUser && currentUser.role === 'user' ? '/dashboard' : '/login');
return;
}
if (isAuthenticated && currentUser && currentUser.role === 'admin') {
fetchUsers();
}
}, [authLoading, isAuthenticated, currentUser, router, fetchUsers]); // 依赖项包含 router
// 新增删除用户处理函数
const handleDeleteUser = async (userId: number, username: string) => {
if (window.confirm(`确定要删除用户 "${username}" (ID: ${userId}) 吗?此操作不可恢复。`)) {
try {
setError(null); // 清除之前的错误
await api.delete(`/user/delete/${userId}`);
alert(`用户 "${username}" 删除成功!`);
// 刷新用户列表
fetchUsers();
// 或者从当前 users state 中移除该用户,以避免重新请求整个列表
// setUsers(currentUsers => currentUsers.filter(u => u.id !== userId));
} catch (err: any) {
console.error('Error deleting user:', err);
const errorMessage = err.response?.data?.message || err.message || '删除用户失败';
setError(errorMessage);
alert(`删除用户 "${username}" 失败: ${errorMessage}`);
}
}
};
// --- Add User Modal Logic ---
const handleOpenAddUserModal = () => {
setUserFormData({ role: 'user', balance: '0' }); // 默认值
setIsAddUserModalOpen(true);
};
const handleAddUser = async (e: FormEvent) => {
e.preventDefault();
if (!userFormData.username || !userFormData.password || !userFormData.role) {
alert('用户名、密码和角色不能为空!');
return;
}
setIsLoadingAction(true); // Set loading true
setError(null);
try {
const payload = {
username: userFormData.username,
password: userFormData.password,
role: userFormData.role,
balance: parseFloat(userFormData.balance || '0'),
};
await api.post('/user/admin/add', payload);
alert('用户添加成功!');
setIsAddUserModalOpen(false);
setEditingUser(null);
await fetchUsers();
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '添加用户失败';
alert(`添加用户失败: ${errorMessage}`);
setError(errorMessage);
} finally {
setIsLoadingAction(false); // Set loading false
}
};
// --- Edit User Modal Logic ---
const handleOpenEditUserModal = (userToEdit: User) => {
setEditingUser(userToEdit);
setUserFormData({
username: userToEdit.username,
role: userToEdit.role,
balance: userToEdit.balance.toString(),
password: '' // 密码字段留空,除非用户输入新密码
});
setIsEditUserModalOpen(true);
};
const handleUpdateUser = async (e: FormEvent) => {
e.preventDefault();
if (!editingUser || !userFormData.username || !userFormData.role) {
alert('用户信息不完整!');
return;
}
setIsLoadingAction(true); // Set loading true
setError(null);
try {
const payload: any = {
id: editingUser.id,
username: userFormData.username,
role: userFormData.role,
balance: parseFloat(userFormData.balance || '0'),
};
if (userFormData.password && userFormData.password.length > 0) {
payload.password = userFormData.password;
}
await api.put('/user/admin/update', payload);
alert('用户信息更新成功!');
setIsEditUserModalOpen(false);
setEditingUser(null);
await fetchUsers();
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '更新用户信息失败';
alert(`更新用户信息失败: ${errorMessage}`);
setError(errorMessage);
} finally {
setIsLoadingAction(false); // Set loading false
}
};
const handleFormInputChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setUserFormData(prev => ({ ...prev, [name]: value }));
};
const closeModal = () => {
setIsAddUserModalOpen(false);
setIsEditUserModalOpen(false);
// Reset form data if needed, or do it on open
setUserFormData({ username: '', password: '', role: 'user', balance: '0' });
setEditingUser(null);
};
if (authLoading || pageLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
// 如果在 authLoading 后仍然未认证或不是管理员AuthenticatedLayout 或此组件的 useEffect 会处理重定向
// 但为了防止在重定向前渲染内容,可以加一个判断
if (!isAuthenticated || !currentUser || currentUser.role !== 'admin') {
// 此处应该已经被重定向,显示加载动画避免闪烁
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
return (
<div className="container mx-auto p-4 pt-10">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800"></h1>
<div>
<button
onClick={handleOpenAddUserModal}
className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg mr-3 transition duration-150 ease-in-out"
>
</button>
<Link href="/admin/dashboard" legacyBehavior>
<a className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-150 ease-in-out">
</a>
</Link>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-400 rounded-md">
{error}
</div>
)}
{/* Add/Edit User Modal using Headless UI */}
<Transition appear show={isAddUserModalOpen || isEditUserModalOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 mb-4"
>
{isAddUserModalOpen ? '新增用户' : `编辑用户: ${editingUser?.username || ''}`}
</Dialog.Title>
{error && <p className="mb-3 text-sm text-red-600 bg-red-100 p-2 rounded">{error}</p>} {/* Display error messages in modal */}
<form onSubmit={isAddUserModalOpen ? handleAddUser : handleUpdateUser}>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium text-gray-700"></label>
<input
type="text"
name="username"
id="username"
value={userFormData.username}
onChange={handleFormInputChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
{/* Password field: only required for add, optional for edit (to change password) */}
{(isAddUserModalOpen || isEditUserModalOpen) && (
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
{isAddUserModalOpen ? '' : '(如需重置请输入新密码)'}
</label>
<input
type="password"
name="password"
id="password"
value={userFormData.password}
onChange={handleFormInputChange}
required={isAddUserModalOpen} // Only required for new users
minLength={isAddUserModalOpen ? 6 : undefined} // Min length for new users
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder={isEditUserModalOpen ? "留空则不修改密码" : ""}
/>
</div>
)}
<div className="mb-4">
<label htmlFor="role" className="block text-sm font-medium text-gray-700"></label>
<select
name="role"
id="role"
value={userFormData.role}
onChange={handleFormInputChange}
required
disabled={isEditUserModalOpen && editingUser?.id === currentUser?.id && currentUser?.role === 'admin'} // Prevent admin from changing own role easily
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="user">User</option>
<option value="admin">Admin</option>
{/* <option value="ban">Ban</option> */}
</select>
{isEditUserModalOpen && editingUser?.id === currentUser?.id && currentUser?.role === 'admin' && (
<p className="mt-1 text-xs text-red-600"></p>
)}
</div>
<div className="mb-6">
<label htmlFor="balance" className="block text-sm font-medium text-gray-700"></label>
<input
type="number"
name="balance"
id="balance"
value={userFormData.balance}
onChange={handleFormInputChange}
required
step="0.01" // Allow decimal for currency
min="0"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="mt-6 flex items-center justify-end space-x-3">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
onClick={closeModal}
>
</button>
<button
type="submit"
disabled={isLoadingAction} // Use isLoadingAction here
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:opacity-50"
>
{isLoadingAction ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : isAddUserModalOpen ? '确认新增' : '确认修改'}
</button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
<div className="bg-white shadow-md rounded-lg overflow-x-auto">
<table className="min-w-full leading-normal">
<thead>
<tr>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
ID
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{users.length > 0 ? (
users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<p className="text-gray-900 whitespace-no-wrap">{u.id}</p>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<p className="text-gray-900 whitespace-no-wrap">{u.username}</p>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<span
className={`px-2 py-1 font-semibold leading-tight rounded-full text-xs
${u.role === 'admin' ? 'bg-green-100 text-green-700' : ''}
${u.role === 'user' ? 'bg-blue-100 text-blue-700' : ''}
${u.role === 'ban' ? 'bg-red-100 text-red-700' : ''}
`}
>
{u.role}
</span>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<p className="text-gray-900 whitespace-no-wrap">¥{u.balance.toFixed(2)}</p>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<button
onClick={() => handleOpenEditUserModal(u)}
className="text-indigo-600 hover:text-indigo-900 mr-3 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={currentUser?.id === u.id && u.role === 'admin'} // 通常不允许管理员直接编辑自己(特别是角色),或者需要更复杂的逻辑
>
</button>
<button
onClick={() => handleDeleteUser(u.id, u.username)}
className="text-red-600 hover:text-red-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={currentUser?.id === u.id}
>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-5 py-5 border-b border-gray-200 bg-white text-center text-sm">
{pageLoading ? '正在加载用户...' : (error ? '加载用户失败' : '没有找到用户数据')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default UserManagementPage;

View File

@@ -0,0 +1,67 @@
'use client';
import React from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import LoadingSpinner from '@/components/LoadingSpinner';
const DashboardPage: React.FC = () => {
const { user, logout, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated || !user) {
// AuthenticatedLayout 应该已经处理了重定向
// 但作为备用,可以显示加载或 null
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
// 如果用户是管理员但意外访问了普通用户dashboard则重定向到管理员dashboard
// 这一步是可选的因为AuthContext中的login已经做了角色判断和重定向
// 但作为额外的保护层防止用户通过直接输入URL访问不匹配的dashboard
if (user.role === 'admin') {
router.replace('/admin/dashboard');
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
}
return (
<div className="container mx-auto p-4 pt-20">
<div className="bg-white shadow-md rounded-lg p-8 max-w-md mx-auto">
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
, {user.username}!
</h1>
<div className="mb-6">
<p className="text-lg text-gray-700">
<span className="font-semibold">ID:</span> {user.id}
</p>
<p className="text-lg text-gray-700">
<span className="font-semibold">:</span> {user.role}
</p>
<p className="text-lg text-gray-700">
<span className="font-semibold">:</span> ¥{user.balance.toFixed(2)}
</p>
</div>
<button
onClick={async () => {
await logout();
// logout 函数内部会处理路由跳转到 /login
}}
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
>
</button>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,46 @@
'use client';
import React, { useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading, user } = useAuth(); // user is available if needed
const router = useRouter();
useEffect(() => {
// Only redirect if loading is complete and user is not authenticated
if (!isLoading && !isAuthenticated) {
console.log('AuthenticatedLayout: User not authenticated, redirecting to login.');
router.replace('/login');
}
}, [isAuthenticated, isLoading, router]);
// If still loading, show spinner
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
// If loading is complete but user is not authenticated,
// useEffect will handle redirection. Render null or spinner to avoid flashing content.
if (!isAuthenticated) {
// This state should ideally be brief as useEffect redirects.
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner /> {/* Or return null; */}
</div>
);
}
// If authenticated, render children
return <>{children}</>;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "MQTT 智能充电桩",
description: "MQTT 智能充电桩控制平台",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import React, { useState, FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated, user } = useAuth();
const router = useRouter();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username || !password) {
setError('用户名和密码不能为空');
return;
}
setIsLoading(true);
setError(null);
try {
await login(username, password);
// 登录成功后AuthContext会更新状态下面useEffect会处理跳转
// 或者在这里直接根据角色跳转
// const targetPath = response.data.data.role === 'admin' ? '/admin/dashboard' : '/dashboard';
// router.push(targetPath);
} catch (err: any) {
setError(typeof err === 'string' ? err : '登录时发生错误,请检查凭据或网络连接。');
} finally {
setIsLoading(false);
}
};
// 如果用户已认证,则重定向到对应的主页
React.useEffect(() => {
if (isAuthenticated && user) {
const targetPath = user.role === 'admin' ? '/admin/dashboard' : '/dashboard';
console.log(`User already authenticated (${user.username}, ${user.role}). Redirecting to ${targetPath}...`);
router.replace(targetPath); // 使用replace避免用户回退到登录页
}
}, [isAuthenticated, user, router]);
// 如果仍在加载认证状态或已经认证通过(等待跳转),可以显示加载中
// if (isLoadingInitialAuth || isAuthenticated) {
// return <div className="flex justify-center items-center min-h-screen">加载中...</div>;
// }
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-3xl font-bold text-blue-600">
MQTT
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入用户名"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入密码"
/>
</div>
{error && (
<p className="text-center text-sm text-red-600">{error}</p>
)}
<div>
<button
type="submit"
disabled={isLoading}
className={`flex w-full justify-center rounded-md border border-transparent ${isLoading ? 'bg-blue-300' : 'bg-blue-600 hover:bg-blue-700'} py-2 px-4 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out`}
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : null}
{isLoading ? '登录中...' : '登 录'}
</button>
</div>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import React, { useState, FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
export default function RegisterPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [checkPassword, setCheckPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
const router = useRouter();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username || !password || !checkPassword) {
setError('所有字段均为必填项');
setSuccessMessage(null);
return;
}
if (password !== checkPassword) {
setError('两次输入的密码不一致');
setSuccessMessage(null);
return;
}
// 可选:添加更复杂的密码策略校验
if (password.length < 6) {
setError('密码长度不能少于6位');
setSuccessMessage(null);
return;
}
setIsLoading(true);
setError(null);
setSuccessMessage(null);
try {
await register(username, password, checkPassword);
setSuccessMessage('注册成功!正在跳转到登录页面...');
// 注册成功后延迟跳转到登录页
setTimeout(() => {
router.push('/login');
}, 2000); // 延迟2秒
} catch (err: any) {
setError(typeof err === 'string' ? err : '注册时发生错误,请稍后重试或联系管理员。');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-3xl font-bold text-blue-600">
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入用户名"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入至少6位密码"
/>
</div>
<div>
<label
htmlFor="checkPassword"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="checkPassword"
name="checkPassword"
type="password"
autoComplete="new-password"
required
value={checkPassword}
onChange={(e) => setCheckPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请再次输入密码"
/>
</div>
{error && (
<p className="text-center text-sm text-red-600">{error}</p>
)}
{successMessage && (
<p className="text-center text-sm text-green-600">{successMessage}</p>
)}
<div>
<button
type="submit"
disabled={isLoading || !!successMessage} // 成功后也禁用按钮
className={`flex w-full justify-center rounded-md border border-transparent ${isLoading || successMessage ? 'bg-blue-300 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'} py-2 px-4 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out mt-2`}
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : null}
{isLoading ? '注册中...' : '注 册'}
</button>
</div>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
{' '}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
const LoadingSpinner: React.FC = () => {
return (
<div className="flex flex-col items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-10 w-10 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="mt-2 text-lg text-gray-700">...</p>
</div>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,159 @@
'use client'; // 标记为客户端组件因为使用了useState, useEffect, useContext和localStorage
import React, { createContext, useState, useContext, useEffect, ReactNode, useCallback } from 'react';
import { useRouter } from 'next/navigation'; // 使用Next.js的路由
import { api } from '@/services/api';
// 用户类型定义 (与之前一致)
export interface User {
id: number;
username: string;
role: string; // 确保这里的 'admin' 和后端 UserRoleEnum.ADMIN.getValue() 一致
balance: number;
}
// 认证上下文类型
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (username: string, password: string, checkPassword: string) => Promise<void>;
checkAuth: () => Promise<void>; // 新增:检查认证状态的函数
}
// 创建认证上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 认证提供者属性
interface AuthProviderProps {
children: ReactNode;
}
// 认证提供者组件
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); // 初始为true在checkAuth完成后设为false
const router = useRouter();
// 检查认证状态 (例如: 页面加载或需要权限时调用)
const checkAuth = useCallback(async () => {
console.log('Checking authentication status...');
// 不需要在这里 setIsLoading(true),因为初始值就是 true
try {
const response = await api.get('/user/current');
if (response.data.code === 0 && response.data.data) {
setUser(response.data.data);
console.log('User authenticated:', response.data.data.username, 'Role:', response.data.data.role);
} else {
setUser(null);
console.log('User not authenticated or API error. Message:', response.data.message);
}
} catch (error: any) {
console.error('Error checking auth:', error);
setUser(null);
// 如果 /user/current 返回 401/403说明session无效或没有权限这通常是预期行为不需要特殊处理
} finally {
setIsLoading(false); // 无论成功与否,结束加载状态
console.log('Auth check finished. isLoading set to false.');
}
}, []); // 移除了 router 依赖,因为它不直接使用 router
// 组件挂载时执行一次认证检查
useEffect(() => {
checkAuth();
}, [checkAuth]);
// 登录函数
const login = async (username: string, password: string) => {
setIsLoading(true);
try {
const response = await api.post('/user/login', { username, password });
if (response.data.code === 0 && response.data.data) {
const loggedInUser = response.data.data as User;
setUser(loggedInUser);
console.log('Login successful for:', loggedInUser.username, 'Role:', loggedInUser.role);
// 登录成功后跳转
// 确保后端返回的 role 字符串与这里的判断条件一致 (e.g., 'admin')
if (loggedInUser.role === 'admin') {
router.push('/admin/dashboard');
} else {
router.push('/dashboard');
}
} else {
throw new Error(response.data.message || '登录失败');
}
} catch (error: any) {
console.error('登录出错:', error);
setUser(null);
throw error.message || error || '登录时发生未知错误';
} finally {
setIsLoading(false);
}
};
// 注销函数
const logout = async () => {
// setIsLoading(true); // 可以考虑在登出操作期间也显示加载状态
try {
await api.post('/user/logout');
console.log('Logout successful.');
} catch (error: any) {
console.error('注销API调用出错:', error);
} finally {
setUser(null);
setIsLoading(false);
router.push('/login');
}
};
// 注册函数
const register = async (username: string, password: string, checkPassword: string) => {
setIsLoading(true);
try {
const response = await api.post('/user/register', {
username,
password,
checkPassword
});
if (response.data.code !== 0) {
throw new Error(response.data.message || '注册失败');
}
console.log('Registration successful for:', username);
// 注册成功后通常提示用户去登录,并跳转到登录页
alert('注册成功!请登录。'); // 可以用更友好的提示组件替换 alert
router.push('/login');
} catch (error: any) {
console.error('注册出错:', error);
throw error.message || error || '注册时发生未知错误';
} finally {
setIsLoading(false);
}
};
const value = {
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
register,
checkAuth // 提供检查函数
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
// 自定义钩子便于使用上下文
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth必须在AuthProvider内部使用');
}
return context;
};

View File

@@ -0,0 +1,64 @@
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
// 创建axios实例
export const api = axios.create({
// baseURL: 'http://localhost:7529/api', // 本地开发时后端API基础URL
baseURL: '/api', // 使用相对路径依赖Next.js代理或部署时反向代理
withCredentials: true, // 允许携带Cookie以支持Session认证
timeout: 10000, // 请求超时时间
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
api.interceptors.request.use(
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig> => {
// 可以在这里添加通用请求前的处理逻辑
// 例如: 添加认证token (如果使用Token认证)
// if (typeof window !== 'undefined') { // 确保在浏览器环境中
// const token = localStorage.getItem('auth_token');
// if (token) {
// config.headers = config.headers || {};
// config.headers.Authorization = `Bearer ${token}`;
// }
// }
return config;
},
(error: any) => {
return Promise.reject(error);
}
);
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse): AxiosResponse | Promise<AxiosResponse> => {
// 可以在这里统一处理接口成功响应
// 例如:检查后端自定义的成功码
// if (response.data && response.data.code !== 0) {
// return Promise.reject(new Error(response.data.message || 'Error'));
// }
return response;
},
(error: AxiosError) => {
// 可以在这里统一处理接口错误响应
if (error.response) {
// 服务器返回错误状态码
console.error('API Error Response:', error.response.data);
if (error.response.status === 401) {
// 未授权,可以进行跳转到登录页等操作
console.error('用户未登录或会话已过期');
// **重要**: 在这里不能直接导航应该在调用处或Context中处理
// 通常会触发登出逻辑或设置一个状态让UI跳转
}
} else if (error.request) {
// 请求已发送但未收到响应
console.error('网络请求失败,请检查网络连接:', error.request);
} else {
// 请求配置出错
console.error('请求配置错误:', error.message);
}
// 抛出包含更多信息的错误,便于上层处理
return Promise.reject(error.response?.data || error.message || 'An unknown error occurred');
}
);

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,233 @@
# MQTT 智能充电机器人系统 - 开发方案
## 1. 引言
本文档旨在为 "MQTT 智能充电机器人系统" 提供详细的开发方案。该方案基于已确认的需求文档 (`requirements.md`) 和技术选型,旨在指导后端平台的开发工作。项目目标是构建一个稳定、可扩展的后端服务,用于管理用户、调度充电机器人、处理充电业务逻辑,并通过 MQTT 与机器人硬件进行通信。
## 2. 系统架构概览
系统采用典型的 **前后端分离 + 物联网 (IoT)** 架构:
* **后端平台 (本项目核心)**: 基于 Spring Boot负责业务逻辑处理、数据持久化、用户认证授权、与 MQTT Broker 交互。
* **MQTT Broker**: 外部公共服务(如 EMQX作为消息中间件负责平台与机器人之间的消息传递。**需要用户提供具体的连接信息和认证凭据。**
* **充电机器人 (硬件)**: 基于 ESP32-CAM负责执行指令、采集状态、通过 MQTT 与 Broker 通信。**本项目不涉及硬件的具体开发。**
* **前端应用 (可选)**: Web 或 App通过调用后端 API 与系统交互。**本项目不包含前端开发。**
**关键技术栈:**
* **后端**: Spring Boot 2.7.0, Java 1.8
* **数据访问**: MyBatis-Plus, MySQL
* **缓存**: Redis (用于 Session 管理等)
* **消息队列**: MQTT (使用 Eclipse Paho Java Client)
* **API 文档**: Knife4j
## 3. 开发阶段与模块划分
我们将开发过程划分为以下几个主要阶段/模块:
**阶段一:基础架构与用户管理**
1. **环境配置**: 确认 JDK, Maven, MySQL, Redis 环境。
2. **数据库初始化**:
* 根据 `requirements.md` 中的设计,创建数据库 `mqtt_charging_system` (或使用现有 `my_db` 并调整)。
* 编写并执行 SQL DDL 脚本,创建 `user`, `charging_robot`, `parking_spot`, `charging_session`, `activation_code` 表(详细见第 4 节)。
3. **用户模块实现**:
* 创建 `User` 实体类 (`model/entity/User.java`),包含余额、角色等字段。
* 创建 `UserMapper` 接口 (`mapper/UserMapper.java`)。
* 创建 `UserService` 接口及实现 (`service/UserService.java`, `service/impl/UserServiceImpl.java`),包含用户注册、查询、余额更新等方法。
* 实现密码存储:使用 Spring Security 的 `BCryptPasswordEncoder` 对密码进行加密。在 `config` 包下配置 `PasswordEncoder` Bean。
* 创建 `UserController` (`controller/UserController.java`),提供登录接口。
4. **认证与授权**:
* 实现登录接口 `/api/user/login`
* 登录成功后,使用 Spring Session (已配置为 Redis 存储) 存储用户会话信息。
* 后续请求通过 Session 进行用户身份验证。
* 实现简单的基于角色的访问控制:在需要权限的 Controller 方法或 Service 方法上,通过检查当前登录用户的 `role` 字段进行判断。可以创建一个简单的 AOP 切面或直接在方法内判断。
**阶段二MQTT 集成**
1. **配置 MQTT 连接**:
*`application.yml` 中添加 MQTT Broker 的 `url`, `username`, `password`, 以及用于命令和状态的 `commandTopic`, `statusTopic` 基础路径。**这些值需要用户提供。**
* 创建 `MqttProperties.java` (`config/properties/MqttProperties.java`) 类,使用 `@ConfigurationProperties` 读取配置。
2. **实现 MQTT 客户端**:
* 创建 `MqttConfig.java` (`config/MqttConfig.java`)。
* 配置 `MqttConnectOptions` Bean设置用户名、密码、自动重连、清理会话等。
* 配置 `MqttClient` Bean并在应用启动后连接到 Broker。
* **重点**: 实现 `MqttCallbackExtended` 接口,处理连接成功 (`connectComplete`)、连接丢失 (`connectionLost`)、消息到达 (`messageArrived`) 事件。
*`connectComplete` 中,订阅状态 Topic (`robot/status/+`)。`+` 是通配符,用于接收所有机器人的状态。
*`connectionLost` 中,记录日志并依赖 Paho 客户端的自动重连机制。
*`messageArrived`将接收到的消息JSON 字符串)传递给专门的消息处理服务。
3. **实现消息发布与处理**:
* 创建 `RobotTaskService.java` (`service/RobotTaskService.java`) 用于管理 `robot_task` 表的 CRUD 和状态检查。
* 创建 `MqttService.java` (`service/MqttService.java`)。
* 提供 `sendCommand(String robotId, String commandType, String payloadJson, Long sessionId)` 方法:
* **调用 `RobotTaskService` 检查 `robotId` 是否有状态为 `SENT` 的任务。如果有,则不允许发送,直接返回错误或特定状态。**
* 如果允许发送,**调用 `RobotTaskService``robot_task` 表创建 `PENDING` 状态的任务记录。**
* 构造实际的 MQTT Topic (`robot/command/{robotId}`)
* 调用 Paho 客户端的 `publish` 方法发送 MQTT 消息。
* **发送成功后,立即调用 `RobotTaskService` 将对应任务状态更新为 `SENT` 并记录 `sent_time`。**
* 创建 `MqttMessageHandler.java` (`service/MqttMessageHandler.java` 或类似名称)。
* 提供 `handleStatusUpdate(String topic, String payload)` 方法,由 `MqttCallback``messageArrived` 调用。
* 在此方法中:
* 解析 `topic` 获取 `robotId`
* 使用 Gson (或 Jackson) 将 `payload` (JSON) 解析为 `RobotStatusDTO`
* **根据收到的状态,调用 `RobotTaskService` 查找并更新对应的 `SENT` 任务状态为 `ACKNOWLEDGED_SUCCESS` 或 `ACKNOWLEDGED_FAILURE`,记录 `ack_time`。**
* **在任务状态更新成功后**,再根据 DTO 中的 `status` 字段,调用相应的业务逻辑(如 `ChargingService` 更新机器人状态、处理充电完成事件等)。
**阶段三:核心充电业务逻辑**
1. **机器人与车位管理**:
* 创建 `ChargingRobot``ParkingSpot` 实体、Mapper、Service、Controller。
* 提供基础的 CRUD 接口供管理员使用(可选,根据 `requirements.md`)。
* `ChargingRobotService` 需要包含更新机器人状态(如 `idle`, `moving`, `charging`, `error`)和位置的方法。
2. **充电会话管理**:
* 创建 `ChargingSession` 实体、Mapper、Service。
3. **充电流程实现**:
* 创建 `ChargingController.java` (`controller/ChargingController.java`)。
* 实现 `/api/charging/request` 接口:
* 接收用户请求(包含 `spotId`)。
* 验证用户登录状态和余额。
* 调用 `ChargingRobotService` 查找可用机器人。
* **调用 `MqttService.sendCommand` 发送 `move_to_spot` 指令 (该方法内部会检查任务表并创建任务记录)。如果返回错误(机器人忙),则告知用户。**
* (可选)创建或更新 `ChargingSession` 记录为 `pending``robot_moving` 状态。
*`MqttMessageHandler` 中处理状态更新:
* **首先更新 `robot_task` 表状态。**
* `arrived_at_spot` (确认 `move_to_spot` 成功后): 更新机器人DB状态**调用 `MqttService.sendCommand` 发送 `start_charge` 指令。**
* `charging` (确认 `start_charge` 可能隐含的响应,或只是状态更新): 记录开始时间,更新 `ChargingSession`
* `charge_complete` (确认充电结束,可能是主动上报或响应 `stop_charge`): 记录结束时间、时长,**触发计费**,更新用户余额,更新 `ChargingSession`
* `error`: 记录错误,更新机器人和 `ChargingSession` 状态。
* 实现 `/api/charging/stop` 接口(用户手动停止):
* **调用 `MqttService.sendCommand` 发送 `stop_charge` 指令。**
* 后续处理逻辑依赖于 `charge_complete``error` 消息的处理。
4. **计费逻辑**:
*`UserService` 或单独的 `BillingService` 中实现计费方法。
* 根据 `ChargingSession` 的总时长和预设的单价计算费用。
* 调用 `UserService` 更新用户余额 (注意并发安全,可使用数据库行锁或乐观锁)。
**阶段四:激活码与完善**
1. **激活码模块**:
* 创建 `ActivationCode` 实体、Mapper、Service。
* 实现激活码生成逻辑(管理员功能,可在 Service 中提供)。
* 创建 `ActivationCodeController.java` (`controller/ActivationCodeController.java`)。
* 实现 `/api/codes/redeem` 接口:接收激活码,验证有效性,调用 `UserService` 更新用户余额,将激活码标记为已使用。
2. **API 文档**:
* 在所有 Controller 和 DTO 上添加 Knife4j (Swagger) 注解 (`@Api`, `@ApiOperation`, `@ApiModelProperty` 等)。
3. **测试**:
* 编写单元测试 (JUnit) 覆盖 Service 层核心逻辑。
* 进行接口测试(使用 Postman 或类似工具)。
* **难点**: MQTT 交互的测试,可能需要 Mock `MqttClient` 或搭建本地 MQTT Broker 进行集成测试。
4. **错误处理与日志**:
* 完善全局异常处理 (`GlobalExceptionHandler.java`)。
* 在关键业务点添加详细日志(使用 SLF4j
* **新增:实现任务超时处理逻辑。**
* 创建 `TaskTimeoutHandler.java` (`service/TaskTimeoutHandler.java` 或类似名称)。
* 使用 `@Scheduled` 注解创建一个定时任务,定期执行。
* 在定时任务中,调用 `RobotTaskService` 查找状态为 `SENT` 且超时的任务。
* 对于超时的任务,更新其状态为 `TIMED_OUT`,记录错误信息。
* 根据业务需要,可能需要同步更新关联的 `charging_robot``charging_session` 的状态。
## 4. 数据库 Schema (初步 DDL)
```sql
-- 用户表
CREATE TABLE `user` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`username` VARCHAR(255) NOT NULL UNIQUE COMMENT '用户名',
`password` VARCHAR(255) NOT NULL COMMENT '密码 (加密存储)',
`role` VARCHAR(50) NOT NULL DEFAULT 'user' COMMENT '角色 (user/admin)',
`balance` DECIMAL(10, 2) NOT NULL DEFAULT 0.00 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 TABLE `charging_robot` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`robot_id` VARCHAR(100) NOT NULL UNIQUE COMMENT '机器人唯一标识符 (对应MQTT clientId)',
`status` VARCHAR(50) DEFAULT 'idle' COMMENT '状态 (idle, moving, charging, error, offline)',
`location` VARCHAR(255) COMMENT '当前位置描述 (如 base, near_P001, P001)',
`battery_level` INT 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 '逻辑删除标志'
) COMMENT='充电机器人表';
-- 车位表
CREATE TABLE `parking_spot` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`spot_id` VARCHAR(100) NOT NULL UNIQUE COMMENT '车位唯一标识符',
`location_desc` VARCHAR(255) COMMENT '位置描述',
`status` VARCHAR(50) DEFAULT 'available' COMMENT '状态 (available, occupied, maintenance)',
`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 '逻辑删除标志'
) COMMENT='车位表';
-- 充电记录表
CREATE TABLE `charging_session` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`robot_id` VARCHAR(100) NOT NULL COMMENT '机器人ID',
`spot_id` VARCHAR(100) NOT NULL COMMENT '车位ID',
`start_time` DATETIME COMMENT '充电开始时间',
`end_time` DATETIME COMMENT '充电结束时间',
`duration_seconds` INT COMMENT '总充电时长 (秒)',
`cost` DECIMAL(10, 2) COMMENT '本次消费金额',
`status` VARCHAR(50) COMMENT '会话状态 (pending, robot_moving, charging, completed, error, cancelled)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间'
) COMMENT='充电记录表';
-- 激活码表
CREATE TABLE `activation_code` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`code` VARCHAR(100) NOT NULL UNIQUE COMMENT '激活码字符串',
`value` DECIMAL(10, 2) NOT NULL COMMENT '充值金额',
`is_used` TINYINT(1) DEFAULT 0 COMMENT '是否已使用 (0:未使用, 1:已使用)',
`user_id` BIGINT COMMENT '使用者ID (使用后)',
`use_time` DATETIME COMMENT '使用时间',
`expire_time` DATETIME COMMENT '过期时间 (可选)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) COMMENT='激活码表';
```
*注:以上 DDL 仅为初步设计,字段类型、长度、索引等可能需要根据实际情况调整。*
## 5. API 设计 (高层规划)
* **认证**:
* `POST /api/user/login`
* `POST /api/user/logout` (可选, 使 Session 失效)
* **用户**:
* `GET /api/user/current` (获取当前用户信息,包括余额)
* `GET /api/users` (管理员 - 获取用户列表)
* **充电**:
* `POST /api/charging/request` (用户 - 发起充电请求,参数: `spotId`)
* `POST /api/charging/stop` (用户 - 请求停止当前充电)
* `GET /api/charging/history` (用户 - 查看自己的充电记录)
* `GET /api/charging/sessions` (管理员 - 查看所有充电记录)
* **激活码**:
* `POST /api/codes/redeem` (用户 - 使用激活码充值,参数: `code`)
* `POST /api/codes` (管理员 - 生成激活码,可选)
* **管理 (可选)**:
* `GET /api/robots`
* `POST /api/robots`
* `GET /api/spots`
* `POST /api/spots`
## 6. MQTT 消息契约
再次强调,`requirements.md` 中定义的 MQTT Topic 和 Payload 结构为 **关键接口**。必须与硬件开发团队 **最终确认并严格遵守** 此约定。任何变动都需要双方同步更新。
## 7. 后续步骤
1. **等待用户提供**:
* MQTT Broker 的详细连接信息 (URL, Port)。
* 用于指令 (`robot/command/{clientId}`) 和状态 (`robot/status/{clientId}`) Topic 的用户名和密码。
* 与硬件团队确认最终的 MQTT Topic 和 Payload 结构。
2. **开始开发**: 在获取 MQTT 信息后,可以并行开始:
* **阶段一**: 数据库初始化、用户模块开发、**`robot_task` 相关基础 Service 开发**。
* **阶段二**: MQTT 配置、基础连接、订阅实现、**集成 `robot_task` 检查与更新逻辑**、**任务超时处理实现**。
3. 按照开发阶段逐步推进。

View File

@@ -0,0 +1,152 @@
# 开发阶段一:基础架构与用户管理
## 1. 目标
* 搭建项目基础运行环境。
* 初始化数据库及核心用户表。
* 实现用户实体、数据访问、业务逻辑和基础 API。
* 实现安全的密码存储和用户登录认证机制 (基于 Session)。
* 构建移动端 App 的基础框架和登录/注册页面。
* 实现基于角色的基础页面导航。
## 2. 后端开发详解
1. **环境配置确认**:
* 确保本地开发环境已安装并配置好 JDK 1.8, Maven, MySQL (5.7+), Redis。
* 确认 Maven `settings.xml` 配置正确(如果使用私服)。
* 在 IDE (如 IntelliJ IDEA) 中正确导入项目,确保依赖下载无误。
2. **数据库初始化**:
* 连接 MySQL 数据库。
* 创建数据库,例如 `mqtt_charging_system` (字符集推荐 `utf8mb4`)。
* **创建 `user` 表**: 执行 `development_plan.md` 中定义的 `user` 表 DDL 语句。
```sql
CREATE TABLE `user` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`username` VARCHAR(255) NOT NULL UNIQUE COMMENT '用户名',
`password` VARCHAR(255) NOT NULL COMMENT '密码 (加密存储)',
`role` VARCHAR(50) NOT NULL DEFAULT 'user' COMMENT '角色 (user/admin)',
`balance` DECIMAL(10, 2) NOT NULL DEFAULT 0.00 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='用户表';
```
* **配置数据源**: 检查并确认 `application.yml` 中的 `spring.datasource` 配置正确连接到创建的数据库。
3. **用户模块实现**:
* **Entity**: 创建 `src/main/java/com/yupi/project/model/entity/User.java`。使用 Lombok (`@Data`) 简化代码,使用 MyBatis-Plus 注解 (`@TableName`, `@TableId`, `@TableField`) 映射数据库字段,特别是驼峰与下划线。
* **Mapper**: 创建 `src/main/java/com/yupi/project/mapper/UserMapper.java` 接口,继承 `BaseMapper<User>`。确保 `MyApplication.java` 中的 `@MapperScan` 包含此路径。
* **Service**:
* 创建 `src/main/java/com/yupi/project/service/UserService.java` 接口,继承 `IService<User>`,并定义业务方法,如 `userLogin(String username, String password)`, `userRegister(String username, String password, String checkPassword)`, `getCurrentUser(HttpServletRequest request)` 等。
* 创建 `src/main/java/com/yupi/project/service/impl/UserServiceImpl.java` 实现类,注入 `UserMapper`。
* 在注册逻辑中,添加参数校验(用户名、密码长度,两次密码是否一致),检查用户名是否已存在。
* 在登录逻辑中,查询用户,校验密码。
* **密码加密**:
* 创建 `src/main/java/com/yupi/project/config/SecurityConfig.java` (或类似名称)。
* 在该配置类中声明一个 `PasswordEncoder` Bean:
```java
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
```
* 在 `UserServiceImpl` 中注入 `PasswordEncoder`,在注册时对密码进行加密 (`passwordEncoder.encode(rawPassword)`),在登录时进行比对 (`passwordEncoder.matches(rawPassword, encodedPassword)`)。
* **Controller**:
* 创建 `src/main/java/com/yupi/project/controller/UserController.java`。
* 注入 `UserService`。
* 实现 `POST /api/user/login` 接口,调用 `UserService.userLogin`,登录成功后将用户信息存入 Session (Spring Session 会自动处理)。定义清晰的请求体 DTO (`UserLoginRequest`) 和响应体 (`BaseResponse<User>`)。
* 实现 `POST /api/user/register` 接口 (可选,或仅限管理员创建),调用 `UserService.userRegister`。定义请求体 DTO (`UserRegisterRequest`)。
* 实现 `GET /api/user/current` 接口,用于前端获取当前登录用户的信息(从 Session 中获取)。
* 实现 `POST /api/user/logout` 接口,用于用户登出(使 Session 失效)。
* **常量与异常**: 在 `constant` 包定义用户会话 Key。在 `exception` 包定义业务异常(如 `BusinessException`),并在 Service 层适当抛出(如用户名已存在、密码错误),由 `GlobalExceptionHandler` 统一处理。
4. **认证与授权 (Session)**:
* 确认 `pom.xml` 中包含 `spring-session-data-redis` 和 `spring-boot-starter-data-redis` 依赖。
* 确认 `application.yml` 中 Redis 配置正确。
* Spring Boot 会自动配置 Spring Session 使用 Redis。登录成功后将用户信息脱敏后如移除密码存入 `HttpSession`。
* `session.setAttribute(UserConstant.USER_LOGIN_STATE, safeUser);`
* 在需要登录才能访问的接口方法中,从 `HttpServletRequest` 获取 `HttpSession`,再获取用户信息。可以封装一个获取当前用户的方法在 `UserService` 中。
* **基础角色检查**: 在需要区分管理员和普通用户的接口方法内部(或 Service 方法内部),获取当前登录用户的 `role` 字段进行判断。
```java
User currentUser = userService.getCurrentUser(request);
if (!"admin".equals(currentUser.getRole())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// ... admin logic ...
```
(更高级的权限控制可以使用 Spring Security 或 AOP本阶段可先用简单方式
## 3. 前端 (手机版网页) 开发详解
* **技术选型**: **Next.js (基于 React)**。使用 Tailwind CSS 进行样式设计,确保响应式布局,优先适配移动端浏览器。
* **目标**: 实现 Web App 基础框架、登录页、注册页(如果需要)、用户主页(区分角色)。
* **UI 风格**: (与之前一致但实现方式变为HTML/CSS)
* **主色调**: 科技蓝 (#0A7AFF 或类似) 与白色 (#FFFFFF)。
* **辅助色**: 浅灰 (#F0F0F0)、深灰 (#808080)。
* **字体**: 无衬线字体,如系统默认字体,或引入网络字体如 Inter, Noto Sans SC。
* **图标**: 简洁、线条化的科技风格图标 (可使用 SVG图标库如 Heroicons, Feather Icons, 或 Iconfont CSS)。
* **布局**: 简洁、留白充足控件圆角化。使用Flexbox和Grid进行响应式布局。
* **动效**: 轻微、平滑的CSS过渡和动画增强科技感。
* **整体**: 保持所有页面风格统一。
* **项目结构 (Next.js App Router)**:
* `charging_web_app/src/app/`:页面路由和布局。
* `layout.tsx`: 全局根布局 (包含 `AuthProvider`)。
* `login/page.tsx`: 登录页面。
* `register/page.tsx`: 注册页面。
* `(authenticated)/dashboard/page.tsx`: 普通用户主页 (示例路径,使用路由组进行权限控制)。
* `(authenticated)/admin/dashboard/page.tsx`: 管理员主页 (示例路径)。
* `charging_web_app/src/components/`: 可复用UI组件。
* `charging_web_app/src/contexts/`: React Context (如 `AuthContext.tsx`)。
* `charging_web_app/src/services/`: API服务 (如 `api.ts`)。
* `charging_web_app/src/hooks/`: 自定义React Hooks。
* `charging_web_app/src/lib/` (或 `utils`): 工具函数。
* `charging_web_app/public/`: 静态资源。
* `tailwind.config.ts`, `postcss.config.js`: Tailwind CSS 配置。
* **页面设计与流程 (Web)**:
1. **登录页 (`src/app/login/page.tsx`)**:
* **UI**: 蓝色顶部Banner或背景元素白色内容区域。App Logo (可选)、用户名输入框 (`<input type="text">`)、密码输入框 (`<input type="password">`)、"登录"按钮 (`<button>`)(蓝色填充)、"注册"链接 (`<Link href="/register">`)。输入框和按钮使用Tailwind CSS进行样式化具有圆角和科技感边框。
* **交互**: 输入校验非空。点击登录按钮显示加载指示器如旋转SVG图标或文字提示
* **API 调用**: 调用后端 `POST /api/user/login` (通过 `AuthContext` 中的 `login` 方法)。
* **响应处理**:
* 成功:`AuthContext` 更新用户状态。页面导航由 `AuthContext` 或页面逻辑处理 (例如,使用 `useRouter().push('/dashboard')`)。会话主要由后端的HttpOnly Cookie管理。
* 失败:隐藏加载指示器,显示错误提示(如页面内红色文本)。
2. **注册页 (`src/app/register/page.tsx`)**: (如果开放注册)
* **UI**: 与登录页风格一致。包含用户名、密码、确认密码输入框,"注册"按钮。
* **交互**: 输入校验(非空、密码一致性、格式要求等)。点击注册按钮。
* **API 调用**: 调用后端 `POST /api/user/register` (通过 `AuthContext` 中的 `register` 方法)。
* **响应处理**: 成功后可提示注册成功并跳转到登录页;失败则提示错误。
3. **用户主页 (根据角色不同,示例路径 `src/app/(authenticated)/dashboard/page.tsx` 或 `src/app/(authenticated)/admin/dashboard/page.tsx`)**:
* **UI**:
* **普通用户**: 响应式导航栏移动端可以是汉堡菜单或底部Tab模拟。页面内容可包含欢迎信息、账户余额等。整体蓝白风格卡片式布局。
* **管理员**: 响应式导航栏(侧边栏或顶部菜单)。列表和数据展示为主,使用表格或卡片。
* **功能**: 根据角色展示不同功能入口。
* **API 调用**: 进入时 `AuthContext` 会尝试通过 `checkAuth` 获取最新用户信息。
4. **导航与权限控制**:
* 使用Next.js App Router进行页面路由。
* 在根布局 `src/app/layout.tsx` 中包裹 `AuthProvider`。
* 可以使用路由组 `(authenticated)` 结合中间件 ( `middleware.ts` ) 或在布局/页面组件中通过 `useAuth()` 检查认证状态和用户角色,实现页面级权限控制和未登录重定向。
* **状态管理**: 主要使用 `AuthContext` 管理用户登录状态和用户信息。对于其他全局状态,可按需引入 Zustand 或 Valtio 等轻量级状态管理库。
* **网络请求**: 已封装 `src/services/api.ts` (Axios实例),处理 API Base URL、请求头如 `withCredentials` 以支持Session Cookie、加载状态、错误处理。
* **Next.js代理**: 为了避免CORS问题和隐藏后端API实际地址可以在 `next.config.mjs` 中配置rewrites代理将前端的 `/api/*` 请求转发到后端Spring Boot服务的地址 (例如 `http://localhost:7529/api/*`)。
## 4. 本阶段交付物
* 可运行的后端 Spring Boot 项目。
* 包含 `user` 表的数据库。
* 实现用户注册(可选)、登录、获取当前用户信息、登出功能的后端 API。
* 密码加密存储。
* 基于 Redis 的 Session 认证机制。
* **基础的Next.js手机版网页前端框架 (`charging_web_app`)。**
* **实现登录、注册(可选)页面及功能的手机版网页。**
* **根据用户角色导航到不同主页框架的手机版网页 (基础实现)。**
* 简要的接口测试报告(如 Postman 截图,或浏览器开发者工具网络请求截图)。
## 5. 注意事项
* 密码绝不能明文存储或传输。
* API 响应中避免返回用户密码等敏感信息。
* 做好基础的参数校验。
* 前后端接口地址、请求/响应格式需约定一致。
* **确保Next.js项目正确配置了对后端API的代理或在生产环境中通过Nginx等进行反向代理。**
* **Tailwind CSS的正确配置和使用以实现响应式和移动优先的UI。**

View File

@@ -0,0 +1,156 @@
# 开发阶段二MQTT 集成
## 1. 目标
* 在后端配置并建立与 MQTT Broker 的稳定连接。
* 实现 MQTT 消息的订阅 (接收机器人状态) 和发布 (发送控制指令)。
* 集成 `robot_task` 表逻辑,确保命令按顺序发送并跟踪状态。
* 实现任务超时处理机制。
* (前端暂无重大变更,主要为后端集成工作)。
## 2. 先决条件
* **MQTT Broker 信息**:
* **地址**: 本项目将使用公共 MQTT Broker: `broker.emqx.io`
* **TCP 端口**: `1883`
* **连接认证**: 此公共 Broker 通常允许匿名连接,即连接时不需要提供用户名和密码。
* **Topic 与 Payload 约定**: 必须与硬件团队最终确认 MQTT Topic 结构和 JSON Payload 格式。
* **Topic 唯一性**: 由于使用公共 BrokerTopic 必须包含项目唯一标识前缀 (例如: `yupi_mqtt_power_project/`),以避免与其他用户冲突。最终结构如: `[项目唯一前缀]/robot/command/{clientId}``[项目唯一前缀]/robot/status/{clientId}`
* **应用层鉴权**: 鉴于公共 Broker 的特性,强烈建议在消息 Payload层面实现应用层鉴权机制以确保消息的合法性和安全性。例如机器人上报状态时携带特定令牌后端发送指令时也包含可供机器人验证的令牌或签名。
## 3. 后端开发详解
1. **数据库初始化**:
* **创建 `robot_task` 表**: 执行 `development_plan.md` 中定义的 `robot_task` 表 DDL 语句。
```sql
CREATE TABLE `robot_task` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`robot_id` VARCHAR(100) NOT NULL COMMENT '机器人ID',
`command_type` VARCHAR(50) NOT NULL COMMENT '命令类型 (MOVE_TO_SPOT, START_CHARGE, STOP_CHARGE, QUERY_STATUS)',
`command_payload` TEXT COMMENT '命令参数 (JSON格式)',
`status` VARCHAR(50) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态 (PENDING, SENT, ACKNOWLEDGED_SUCCESS, ACKNOWLEDGED_FAILURE, TIMED_OUT)',
`sent_time` DATETIME COMMENT '命令发送时间',
`ack_time` DATETIME COMMENT '命令确认时间',
`related_session_id` BIGINT COMMENT '关联的充电会话ID (可选)',
`error_message` TEXT COMMENT '失败或超时的错误信息',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_robot_status` (`robot_id`, `status`) COMMENT '机器人和状态索引,便于查询'
) COMMENT='机器人指令任务表';
```
2. **配置 MQTT 连接 (`application.yml`)**:
* 添加 MQTT 相关配置项 (根据用户提供的信息填写):
```yaml
mqtt:
broker-url: tcp://broker.emqx.io:1883 # 公共 Broker 地址
username: # 通常为空,此公共 Broker 无需连接认证
password: # 通常为空
client-id-prefix: backend-yupi-mqtt-power- # 项目唯一客户端ID前缀
default-qos: 1
connection-timeout: 30
keep-alive-interval: 60
command-topic-base: yupi_mqtt_power_project/robot/command # 包含项目唯一前缀的指令Topic基础路径
status-topic-base: yupi_mqtt_power_project/robot/status # 包含项目唯一前缀的状态Topic基础路径
```
* 创建 `src/main/java/com/yupi/project/config/properties/MqttProperties.java` 类,使用 `@ConfigurationProperties(prefix = "mqtt")` 和 Lombok (`@Data`) 来映射这些配置。
3. **实现 MQTT 客户端 (`MqttConfig.java`)**:
* 创建 `src/main/java/com/yupi/project/config/MqttConfig.java`。
* **注入 `MqttProperties`**。
* **创建 `MqttConnectOptions` Bean**:
* 设置 `userName`, `password` (如果配置了)。
* 设置 `isAutomaticReconnect(true)`。
* 设置 `isCleanSession(true)` (或根据需要设置为 `false` 以接收离线消息)。
* 设置 `connectionTimeout`, `keepAliveInterval`。
* **创建 `MqttClient` Bean**:
* 生成唯一的 `clientId` (例如: `properties.getClientIdPrefix() + UUID.randomUUID().toString()`)。
* 创建 `MqttClient` 实例 (`new MqttClient(properties.getBrokerUrl(), clientId, new MemoryPersistence())`)。
* **创建 `MqttCallbackExtended` Bean (核心)**:
* 创建一个类实现 `MqttCallbackExtended` (例如, `MqttCallbackHandler`)。
* **注入 `MqttMessageHandler` 服务** (将在下一步创建)。
* 实现 `connectComplete(boolean reconnect, String serverURI)` 方法:
* 在此方法中订阅状态主题: `client.subscribe(properties.getStatusTopicBase() + "/+", properties.getDefaultQos());` (`+` 是单层通配符)。记录订阅成功日志。
* 如果是重连 (`reconnect == true`),可能需要执行一些状态同步逻辑(例如查询所有机器人的状态)。
* 实现 `connectionLost(Throwable cause)` 方法:
* 记录连接丢失的错误日志。
* Paho 客户端的 `automaticReconnect` 会尝试重连。
* 实现 `messageArrived(String topic, MqttMessage message)` 方法:
* 获取 `payload` (`new String(message.getPayload())`)。
* **应用层鉴权**: 在此步骤或 `mqttMessageHandler.handleStatusUpdate` 内部,应校验 `payload` 中携带的机器人身份标识或令牌,确保消息来源可靠。
* 调用 `mqttMessageHandler.handleStatusUpdate(topic, payload)` 处理消息。
* **注意**: 此方法应快速完成,避免阻塞回调线程。复杂的处理应异步进行或在 `handleStatusUpdate` 内部处理。
* 实现 `deliveryComplete(IMqttDeliveryToken token)` 方法 (可选): 消息发布完成后的回调,通常用于 QoS 1 或 2 的确认。
* **连接 MQTT Broker**:
* 为 `MqttClient` Bean 设置初始化方法 (`initMethod="connectBroker"`) 或使用 `@PostConstruct`。
* 在连接方法中:
* 设置 `MqttCallback` (`client.setCallback(mqttCallbackHandlerBean)`)。
* 调用 `client.connect(mqttConnectOptionsBean)`。
* 添加适当的异常处理 (如 `MqttException`)。
4. **实现 `RobotTask` 管理 (`RobotTaskService`)**:
* 创建 `src/main/java/com/yupi/project/model/entity/RobotTask.java`。
* 创建 `src/main/java/com/yupi/project/mapper/RobotTaskMapper.java`。
* 创建 `src/main/java/com/yupi/project/service/RobotTaskService.java` 接口,定义方法如:
* `hasPendingSentTask(String robotId)`: 检查是否有 `SENT` 状态的任务。
* `createTask(String robotId, String commandType, String payloadJson, Long sessionId)`: 创建 `PENDING` 任务。
* `markTaskAsSent(Long taskId)`: 更新任务状态为 `SENT`。
* `findSentTask(String robotId, String expectedResponseType)`: 根据机器人响应查找对应的 `SENT` 任务 (需要逻辑判断响应类型对应哪个命令)。
* `markTaskAsAcknowledged(Long taskId, boolean success, String errorMessage)`: 更新任务状态为 `ACKNOWLEDGED_SUCCESS` 或 `ACKNOWLEDGED_FAILURE`。
* `findAndMarkTimedOutTasks(int timeoutSeconds)`: 查找并标记超时任务。
* 创建 `src/main/java/com/yupi/project/service/impl/RobotTaskServiceImpl.java` 实现类。
5. **实现消息发布 (`MqttService`)**:
* 创建 `src/main/java/com/yupi/project/service/MqttService.java` 接口,定义 `sendCommand(...)` 方法。
* 创建 `src/main/java/com/yupi/project/service/impl/MqttServiceImpl.java` 实现类。
* 注入 `MqttClient`, `MqttProperties`, `RobotTaskService`。
* 实现 `sendCommand(...)` 方法:
* 调用 `robotTaskService.hasPendingSentTask(robotId)` 检查。
* 调用 `robotTaskService.createTask(...)` 创建任务。
* 构造 Topic (`properties.getCommandTopicBase() + "/" + robotId`)。
* **应用层鉴权**: 构造 `payloadJson` 时,应包含可供机器人验证的令牌或签名,确保指令的合法性。
* 调用 `mqttClient.publish(topic, payloadJson.getBytes(), properties.getDefaultQos(), false)` 发送。
* 发送成功后,调用 `robotTaskService.markTaskAsSent(taskId)`。
* 添加 `MqttException` 处理。
6. **实现消息处理 (`MqttMessageHandler`)**:
* 创建 `src/main/java/com/yupi/project/service/MqttMessageHandler.java` (接口和实现)。
* 注入 `RobotTaskService`, `Gson` (或其他 JSON 库), 以及后续阶段需要的 Service (如 `ChargingService`, `ChargingRobotService`)。
* 实现 `handleStatusUpdate(String topic, String payload)` 方法:
* 解析 `topic` 提取 `robotId`。
* 解析 `payload` (JSON) 为 `RobotStatusDTO` (需要创建此 DTO)。
* **核心**: 根据 `RobotStatusDTO` 的内容判断这是对哪个命令的响应 (或者是一个主动状态上报)。调用 `robotTaskService.findSentTask(...)` 查找对应的 `SENT` 任务。
* 如果找到任务,调用 `robotTaskService.markTaskAsAcknowledged(...)` 更新任务状态。
* **只有在任务确认后**,才根据 `RobotStatusDTO.status` 调用后续业务逻辑 Service 的方法 (例如: `chargingService.handleRobotArrival(robotId, spotId)`, `chargingService.handleChargeCompletion(robotId, duration)`)。
* 处理 JSON 解析异常、任务未找到等情况。
7. **实现任务超时处理 (`TaskTimeoutHandler`)**:
* 创建 `src/main/java/com/yupi/project/schedule/TaskTimeoutHandler.java` (或放在 service 包)。
* 注入 `RobotTaskService`, 以及可能需要更新状态的 `ChargingRobotService`, `ChargingSessionService`。
* 添加 `@Component` 和 `@EnableScheduling` (在主启动类或配置类上)。
* 创建一个方法,使用 `@Scheduled(fixedRate = 60000)` (例如每分钟执行一次)。
* 在该方法中:
* 调用 `robotTaskService.findAndMarkTimedOutTasks(timeoutSeconds)` (例如超时设为 120 秒)。
* 获取超时的任务列表。
* 对每个超时任务,根据业务逻辑更新关联的 `charging_robot` (如设为 `offline` 或 `error`) 和 `charging_session` (如设为 `error`) 的状态。
* 添加日志记录。
## 4. 前端 (移动端 App) 开发详解
* **本阶段前端无核心功能变更。**
* 可以考虑在界面上预留位置,用于未来展示机器人的连接状态或基本信息,但这依赖于后端是否通过 API 提供这些聚合信息,或者前端是否也直接订阅 MQTT (通常不推荐前端直接连 Broker)。
* 主要工作是等待后端完成 MQTT 集成,为下一阶段的核心业务功能提供基础。
## 5. 本阶段交付物
* 包含 `robot_task` 表的数据库。
* 配置完成并能在启动时自动连接 MQTT Broker 的后端服务。
* 实现 MQTT 状态消息订阅和命令消息发布的功能。
* 集成 `robot_task` 表的命令发送检查和状态跟踪逻辑。
* 实现任务超时检查和处理的定时任务。
* 必要的单元测试 (例如 `RobotTaskService` 的逻辑)。
* 描述 MQTT 配置和运行状态的简要文档或日志。
## 6. 注意事项
* MQTT 连接信息 (特别是密码,如果未来使用私有 Broker) 应妥善保管,避免硬编码在代码中,使用配置文件管理。
* **Topic 设计与唯一性**: 在公共 Broker 环境下Topic 设计必须包含项目唯一标识前缀,以防止命名冲突。所有参与方(后端、机器人)都必须使用此约定。
* **Payload 格式**: 必须严格遵守与硬件端的约定。
* **应用层鉴权**: 由于使用公共 Broker强烈建议在消息 Payload 中实现双向的应用层鉴权机制。后端需验证机器人状态的来源,机器人需验证指令的来源。
* `MqttCallback` 中的 `messageArrived` 必须快速返回,避免阻塞。
* 超时时间的设置需要根据实际网络情况和机器人响应时间进行调整。
* 健壮的错误处理对于 MQTT 集成至关重要。

View File

@@ -0,0 +1,142 @@
# 开发阶段三:核心充电业务逻辑
## 1. 目标
* 实现充电机器人和车位的基础管理功能 (如果需要管理员界面)。
* 实现完整的充电请求、机器人调度、充电开始/结束、状态跟踪和计费流程。
* 将业务逻辑与 MQTT 消息处理紧密结合。
* 在移动端实现发起充电、查看充电状态和历史记录的核心用户功能。
* 在移动端为管理员提供基础的监控界面(可选)。
## 2. 后端开发详解
1. **数据库初始化**:
* **创建 `charging_robot`, `parking_spot`, `charging_session` 表**: 执行 `development_plan.md` 中定义的相应 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='充电记录表';
```
2. **机器人与车位管理 (可选,主要面向管理员)**:
* **Entity**: 创建 `ChargingRobot.java`, `ParkingSpot.java`。
* **Mapper**: 创建 `ChargingRobotMapper.java`, `ParkingSpotMapper.java`。
* **Service**: 创建 `ChargingRobotService.java`, `ParkingSpotService.java` 接口和实现类。
* `ChargingRobotService` 需包含方法:`findAvailableRobot()`, `updateRobotStatus(String robotId, String status, String location, Integer batteryLevel)`。
* `ParkingSpotService` 需包含方法:`updateSpotStatus(String spotId, String status)`。
* **Controller (可选)**: 创建 `ChargingRobotController.java`, `ParkingSpotController.java`,提供 CRUD 接口 (`/api/robots`, `/api/spots`),并进行管理员角色检查。
3. **充电会话管理**:
* **Entity**: 创建 `ChargingSession.java`。
* **Mapper**: 创建 `ChargingSessionMapper.java`。
* **Service**: 创建 `ChargingSessionService.java` 接口和实现类。包含创建、更新状态、记录时间/费用、查询用户历史记录等方法。
4. **充电流程实现**:
* **创建 `ChargingService` (核心业务编排)**: 创建接口 `ChargingService.java` 和实现 `ChargingServiceImpl.java`。
* 注入 `UserService`, `ChargingRobotService`, `ParkingSpotService`, `ChargingSessionService`, `MqttService`, `RobotTaskService`。
* 定义核心方法,如:
* `requestCharging(String userId, String spotId)`: 处理用户充电请求。
* `handleRobotArrival(String robotId, String spotId)`: 处理机器人到达事件。
* `handleChargingStart(String robotId, String spotId)`: 处理充电开始事件。
* `handleChargeUpdate(String robotId, int durationSeconds)`: 处理充电中状态更新 (可选)。
* `handleChargeCompletion(String robotId, String spotId, int totalDurationSeconds)`: 处理充电完成事件。
* `handleRobotError(String robotId, String errorCode, String message)`: 处理机器人错误。
* `stopChargingByUser(String userId)`: 处理用户停止请求。
* **修改 `MqttMessageHandler`**:
* 在其 `handleStatusUpdate` 方法中,当 `robot_task` 确认成功后,根据收到的机器人状态 (`RobotStatusDTO.status`),调用 `ChargingService` 中对应的 `handleXxx` 方法。
* 例如:收到 `arrived_at_spot` 状态 -> 调用 `chargingService.handleRobotArrival(...)`。
* 例如:收到 `charge_complete` 状态 -> 调用 `chargingService.handleChargeCompletion(...)`。
* **实现 `ChargingServiceImpl` 核心逻辑**:
* `requestCharging`:
1. 检查用户余额。
2. 查找可用机器人 (`chargingRobotService.findAvailableRobot()`)。
3. 查找车位信息。
4. 创建 `ChargingSession` 记录 (状态 `PENDING`)。
5. 调用 `mqttService.sendCommand(robotId, "move_to_spot", payload, sessionId)` 发送移动指令。
6. 如果发送成功,更新 `ChargingSession` 状态为 `ROBOT_MOVING`,更新机器人/车位DB状态。
* `handleRobotArrival`:
1. 更新机器人DB状态。
2. 调用 `mqttService.sendCommand(robotId, "start_charge", null, sessionId)` 发送开始充电指令。
* `handleChargingStart`:
1. 更新机器人DB状态。
2. 更新 `ChargingSession` 状态为 `CHARGING`,记录 `start_time`。
* `handleChargeCompletion`:
1. 更新机器人DB状态为 `IDLE`。
2. 更新车位DB状态为 `AVAILABLE`。
3. 记录 `end_time`, `totalDurationSeconds` 到 `ChargingSession`。
4. **调用计费逻辑** (见下一点)。
5. 更新 `ChargingSession` 状态为 `COMPLETED`。
* `handleRobotError`: 更新相关实体的状态为 `ERROR`,记录错误信息。
* `stopChargingByUser`: 查找用户当前的 `CHARGING` 会话,获取 `robotId`,调用 `mqttService.sendCommand(robotId, "stop_charge", null, sessionId)`。后续计费在收到 `charge_complete` 时处理。
* **创建 `ChargingController`**:
* 注入 `ChargingService`。
* 实现 `POST /api/charging/request` 接口: 调用 `chargingService.requestCharging(...)`。
* 实现 `POST /api/charging/stop` 接口: 调用 `chargingService.stopChargingByUser(...)`。
* 实现 `GET /api/charging/history` 接口: 调用 `chargingSessionService.getUserHistory(...)`。
* 实现 `GET /api/charging/sessions` 接口 (管理员): 调用 `chargingSessionService.getAllSessions(...)`。
5. **计费逻辑**:
* 在 `ChargingServiceImpl.handleChargeCompletion` 中实现。
* 定义计费单价 (元/秒 或 元/小时,需转换)。可以配置在 `application.yml` 中或作为常量。
* 计算费用: `cost = totalDurationSeconds * pricePerSecond`。
* 调用 `userService.deductBalance(userId, cost)`。 **此方法需要处理并发**,例如使用 SQL `UPDATE user SET balance = balance - ? WHERE id = ? AND balance >= ?` 来保证原子性扣款。
* 将计算出的 `cost` 记录到 `ChargingSession`。
## 3. 前端 (移动端 App) 开发详解
* **目标**: 实现用户发起充电、查看状态、停止充电、查看历史的核心交互。
* **页面设计与流程**:
1. **首页/充电页 (`ChargeScreen.js`)**: (普通用户 Tab 之一)
* **UI**:
* 顶部显示用户当前余额 (`GET /api/user/current`)。
* 地图或列表展示附近可用的充电车位 (`GET /api/spots?status=available`,需要后端支持此查询)。车位用蓝色空闲图标表示。
* (可选)显示可用机器人的数量或状态 (`GET /api/robots?status=idle`)。
* 选择车位后,弹窗或按钮显示 "发起充电"。
* **交互**: 用户点击选择一个可用车位,然后点击 "发起充电" 按钮。
* **API 调用**: 点击按钮后,调用 `POST /api/charging/request`,参数为 `{ spotId: selectedSpotId }`。
* **响应处理**: 成功则提示 "机器人正在前往...",并导航到充电状态页;失败(如机器人忙、余额不足)则提示用户。
2. **充电状态页 (`ChargingStatusScreen.js`)**:
* **UI**:
* 醒目位置显示当前充电状态(如 "机器人移动中", "正在充电", "充电完成", "错误"),可以使用不同颜色和图标(蓝色移动、绿色充电、灰色完成、红色错误)。
* 显示目标车位 ID。
* 显示使用的机器人 ID。
* 如果是 "正在充电" 状态,显示已充电时长(需要后端通过 WebSocket 或轮询 API 提供实时数据,本阶段可先只显示开始时间或总状态)。
* 显示 "停止充电" 按钮。
* 科技感的进度条或动画效果展示充电过程。
* **交互**: 用户可以点击 "停止充电" 按钮。
* **API 调用**:
* 进入页面时,需要获取当前正在进行的充电会话信息(后端需要提供接口,如 `GET /api/charging/current`)。
* (难点)实时状态更新:
* **方案一 (轮询)**: 前端定时 (如 5-10 秒) 调用 `GET /api/charging/current` 刷新状态和时长。
* **方案二 (WebSocket)**: 后端在状态变更时通过 WebSocket 推送消息给前端。这需要额外集成 WebSocket。**本方案优先考虑轮询**。
* 点击 "停止充电" 时,调用 `POST /api/charging/stop`。
* **响应处理**: "停止充电" 请求成功后,等待状态更新为 "充电完成" 或 "错误"。
3. **充电历史页 (`HistoryScreen.js`)**: (普通用户 Tab 之一, "我的" 页面内)
* **UI**: 列表展示用户的充电记录。每条记录显示车位 ID、机器人 ID、开始/结束时间、时长、费用。按时间倒序排列。蓝白卡片风格。
* **交互**: 下拉刷新,上拉加载更多。
* **API 调用**: 进入页面时调用 `GET /api/charging/history` (支持分页参数)。
4. **管理员监控页 (`AdminMonitorScreen.js`)**: (管理员 Tab 之一)
* **UI**:
* 列表展示所有机器人状态 (`GET /api/robots`)。
* 列表展示所有车位状态 (`GET /api/spots`)。
* 列表展示所有进行中/最近的充电会话 (`GET /api/charging/sessions`)。
* 数据使用不同颜色区分状态 (如 idle/available-蓝色, moving/charging/occupied-橙色/绿色, error/maintenance-红色)。
* **交互**: 实时刷新数据(同样可通过轮询或 WebSocket
## 4. 本阶段交付物
* 包含 `charging_robot`, `parking_spot`, `charging_session` 表的数据库。
* 实现充电请求处理、机器人状态更新、充电会话管理、计费逻辑的后端服务。
* 充电相关核心业务 API (`/api/charging/*`)。
* (可选) 机器人和车位管理的 CRUD API。
* 移动端 App 实现选择车位、发起充电、查看充电状态(轮询)、停止充电、查看历史记录的功能。
* (可选) 移动端 App 实现管理员监控界面。
* 覆盖核心业务逻辑的单元测试。
* 接口测试报告。
## 5. 注意事项
* 计费逻辑和余额扣减必须保证数据一致性和准确性,考虑并发问题。
* 状态转换逻辑要清晰、完备,覆盖所有正常和异常情况。
* 前后端对于充电状态的定义和展示需要统一。
* 实时状态更新的实现方式(轮询/WebSocket需要权衡复杂度和实时性要求。

View File

@@ -0,0 +1,102 @@
# 开发阶段四:激活码与完善
## 1. 目标
* 实现激活码生成、管理和兑换充值功能。
* 完善 API 文档。
* 增强系统测试覆盖率。
* 优化错误处理和日志记录。
* 对移动端 App 进行 UI 细节打磨和体验优化。
## 2. 后端开发详解
1. **数据库初始化**:
* **创建 `activation_code` 表**: 执行 `development_plan.md` 中定义的 `activation_code` 表 DDL 语句。
```sql
CREATE TABLE `activation_code` (...) COMMENT='激活码表';
```
2. **激活码模块实现**:
* **Entity**: 创建 `ActivationCode.java`。
* **Mapper**: 创建 `ActivationCodeMapper.java`。
* **Service**: 创建 `ActivationCodeService.java` 接口和实现类。
* `generateCodes(int count, BigDecimal value, Date expireTime)`: (管理员) 批量生成激活码。生成逻辑可以使用 UUID 或自定义规则,确保唯一性。将生成的 Code 和 Value 存入数据库。
* `redeemCode(String userId, String code)`: (用户) 兑换激活码。
1. 查询 `code` 是否存在、是否未使用 (`is_used = 0`)、是否过期 (如果 `expire_time` 不为空)。
2. 如果有效,获取其 `value`。
3. 调用 `userService.addBalance(userId, value)` 增加用户余额 (注意并发)。
4. 将激活码标记为已使用 (`is_used = 1`), 记录 `user_id`, `use_time`。
5. 返回成功或失败信息。
* (可选) `listCodes(Page page, ActivationCodeQuery query)`: (管理员) 查询激活码列表。
* **Controller**: 创建 `ActivationCodeController.java`。
* 实现 `POST /api/codes/redeem` 接口: 调用 `activationCodeService.redeemCode(...)`。
* (可选) 实现 `POST /api/codes` 接口 (管理员): 调用 `activationCodeService.generateCodes(...)`。
* (可选) 实现 `GET /api/codes` 接口 (管理员): 调用 `activationCodeService.listCodes(...)`。
3. **API 文档完善 (Knife4j/Swagger)**:
* 检查所有 Controller 类和方法,确保添加了 `@Api`, `@ApiOperation` 注解,描述清晰。
* 检查所有请求 DTO 和响应 VO (视图对象),确保添加了 `@ApiModel`, `@ApiModelProperty` 注解,描述字段含义和是否必需。
* 启动项目,访问 Knife4j 文档地址 (通常是 `/doc.html`),检查文档是否完整、准确。
4. **测试增强**:
* **单元测试**: 使用 JUnit 和 Mockito (或 PowerMock) 对 Service 层的核心业务逻辑进行测试,特别是计费、余额变更、激活码兑换、任务状态处理等。
* **集成测试 (可选但推荐)**:
* 使用 Spring Boot Test (`@SpringBootTest`) 测试 Service 层与数据库的交互。
* **MQTT 集成测试**: 搭建本地 MQTT Broker (如 Docker 版 EMQX 或 Mosquitto),编写测试用例模拟机器人发送状态消息,验证 `MqttMessageHandler` 和后续业务逻辑是否正确处理。
* **接口测试**: 使用 Postman 或 Apifox 等工具,对所有 API 进行测试,覆盖正常和异常场景。
5. **错误处理与日志优化**:
* **全局异常处理**: 检查 `GlobalExceptionHandler.java`,确保捕获了常见的业务异常 (`BusinessException`) 和系统异常,并返回统一格式的错误响应给前端。
* **日志**: 使用 SLF4j + Logback (Spring Boot 默认)。
* 在关键路径 (如用户登录、发起充电、收到 MQTT 消息、计费、余额变更、任务超时) 添加 `info` 级别的日志。
* 在异常捕获处添加 `error` 级别的日志,包含堆栈信息。
* 考虑异步记录日志 (如果性能要求高)。
* 配置 `logback-spring.xml`,区分不同环境的日志级别和输出目的地(控制台、文件)。
## 3. 前端 (移动端 App) 开发详解
* **目标**: 实现激活码兑换功能,优化用户体验和界面细节。
* **页面设计与流程**:
1. **激活码兑换页 (`RedeemCodeScreen.js`)**: (普通用户,通常在 "我的" 页面内入口)
* **UI**: 简洁风格。一个输入框用于输入激活码,一个 "兑换" 按钮 (蓝色)。可以加一些提示文字说明。
* **交互**: 输入激活码,点击兑换按钮,显示加载状态。
* **API 调用**: 调用 `POST /api/codes/redeem`,参数 `{ code: enteredCode }`。
* **响应处理**: 成功则提示 "兑换成功,增加余额 X 元",并更新本地用户余额显示;失败则提示错误原因(如无效码、已使用、已过期)。
2. **用户中心/我的页面 (`ProfileScreen.js`)**:
* **UI**: 优化布局,清晰展示用户名、角色、**账户余额**。
* 提供 "充电历史"、"激活码兑换" 等功能的入口。
* 提供 "退出登录" 按钮。
* 整体风格保持蓝白科技感。
3. **UI 细节打磨**:
* 检查所有页面的 UI 元素对齐、间距、颜色、字体是否符合设计规范。
* 优化加载状态的显示(如骨架屏、更平滑的加载动画)。
* 优化错误提示方式,使其更友好、清晰。
* 确保在不同尺寸和分辨率的手机屏幕上显示良好(响应式布局)。
* 增加必要的过渡动画,提升操作流畅感和科技感。
4. **管理员激活码管理页 (`AdminCodeManagementScreen.js`)**: (管理员)
* **UI**:
* 提供生成激活码的功能入口(输入数量、金额、过期时间)。
* 列表展示已生成的激活码,包含码字、金额、状态(未使用/已使用/已过期)、使用者、使用时间等信息。
* 支持搜索、筛选、分页。
* **API 调用**: 调用 `/api/codes` (POST 生成, GET 查询)。
* **代码优化**:
* 检查代码结构,封装可复用组件。
* 处理潜在的内存泄漏。
* 优化性能,特别是列表渲染。
## 4. 本阶段交付物
* 包含 `activation_code` 表的数据库。
* 实现激活码生成、查询、兑换功能的后端服务及 API。
* 完善的 Knife4j API 文档。
* 更高覆盖率的单元测试和集成测试报告。
* 优化后的错误处理和日志配置。
* 移动端 App 实现激活码兑换功能。
* 移动端 App 整体 UI/UX 优化版本。
* (可选) 移动端 App 实现管理员激活码管理界面。
* 最终的接口测试报告。
## 5. 注意事项
* 激活码生成要保证唯一性。
* 激活码兑换和余额增加操作要保证原子性。
* API 文档应与实际代码保持同步。
* 测试应尽可能覆盖边界条件和异常情况。

View File

@@ -0,0 +1,37 @@
## 项目目标与核心技术
本项目的主要设计目标是打造一款能够 **自主导航、避障、充电和远程控制** 的智能机器人。
* **核心控制器**: ESP32-CAM提供计算与无线通信能力。
* **通信方式**: 通过 Wi-Fi 连接 MQTT 服务器(提及使用 **EMQX**),实现与云端(即我们的后端平台)的实时数据交互。
* **导航与避障**: 配备红外循迹模块和超声波避障模块,实现自主导航和避障。
* **运动控制**: 舵机控制系统用于调整运动方向与停车位置。
* **充电控制**: 继电器控制充电桩(或充电接口)的开关。
* **报警系统**: 蜂鸣器用于状态提示或报警。
## 远程控制与监控
* 机器人通过 MQTT 协议将实时状态和数据上传到云端。
* 用户可以通过 **手机APP或网页**(由我们的后端平台支持)随时查看并远程控制机器人的运行。
## 技术挑战 (硬件/嵌入式侧重点)
* 智能导航与避障算法实现 (利用红外与超声波)。
* 充电与停车的精准对接。
* MQTT 通信的稳定性和安全性 (硬件端)。
* 声光报警的准确性。
## 技术路线总结
* **系统定位**: 基于 MQTT 协议的停车场自助充电机器人。
* **核心硬件**: ESP32-CAM。
* **关键技术**: 红外循迹、超声波避障、舵机控制、MQTT 通信。
* **核心功能**: 自主导航、定点停车、自助充电、远程监控。
* **价值**: 提升停车场管理效率和用户体验,实现智能化与自动化。
## 设计关键内容
* **硬件集成**: ESP32-CAM 与各传感器、执行器的集成。
* **控制算法**: 导航、避障、停靠、充电逻辑 (嵌入式实现)。
* **数据传输与管理**: 通过 Wi-Fi 和 MQTT 与远程控制端(后端平台)进行数据交互。
* **安全与提醒**: 通过 MQTT 上报状态,蜂鸣器进行本地报警。

View File

@@ -0,0 +1,124 @@
# MQTT 智能充电桩系统 - 需求文档
## 1. 项目概述
本项目旨在开发一个基于 Spring Boot 的 **移动式** 智能充电管理系统。系统作为后端平台,通过 MQTT 协议与 **基于 ESP32-CAM 的充电机器人** 硬件设备进行通信,实现远程控制、状态监控以及 **调度机器人到指定车位进行充电**。同时,系统提供用户管理、充值计费等功能。**平台软件不负责机器人底层的导航、避障、循迹等具体算法实现**,仅发送高级指令并接收处理后的状态。
## 2. 功能需求
### 2.1 用户管理与认证
* **用户角色**: 系统需支持两种用户角色:
* 管理员:拥有系统所有管理权限。
* 普通用户:可进行充电、充值等操作。
* **用户登录**: 提供安全的用户名密码登录机制。
* 区分管理员和普通用户登录接口或逻辑。
* **用户信息管理** (管理员权限):
* 查看用户列表。
* (可选)添加、编辑、删除用户信息。
### 2.2 MQTT 集成与设备控制
* **MQTT 连接**:
* 连接至 **指定的公共 MQTT 服务器 (如 EMQX需提供具体地址和端口)**
* 支持为特定的 MQTT 主题Topic设置用户名和密码进行认证 **(需提供)**。
* 配置用于指令下发和状态上报的 Topic。
* **消息格式**:
* 与充电机器人通过 JSON 字符串格式进行消息交互。
* **设备控制** (平台侧重点):
* 向充电机器人发送 **高级控制指令**(见 MQTT Topic 规划)。
* 平台 **不处理** 原始传感器数据(如红外、超声波读数)。
* **状态接收** (平台侧重点):
* 接收充电机器人上报的 **综合状态信息**(见 MQTT Topic 规划)。
### 2.3 智能充电核心业务
* **充电机器人管理** (管理员权限):
* 可选添加、查看、管理充电机器人设备信息设备ID、当前状态、位置
* **车位管理** (管理员权限):
* 可选管理可用的充电车位信息车位ID、位置描述
* **激活码充值**:
* 普通用户可以使用激活码为自己的账户充值。
* 系统需验证激活码的有效性,并将对应金额或时长添加到用户账户。
* 需要设计激活码生成和管理机制(管理员)。
* **充电计费**:
* 根据实际充电时长进行计费。
* 需要明确计费单价(例如:元/小时)。
* 充电结束后,从用户账户扣除相应费用。
* 用户账户余额不足时,应有相应处理(如:无法启动充电、自动停止充电)。
* **充电流程** (平台侧重点):
1. 用户登录系统。
2. 用户 **选择目标充电车位** `{parkingSpotId}`
3. 用户发起充电请求。
4. 系统检查用户余额。
5. 系统选择一个可用的充电机器人 `{robotId}` (如果系统管理多个机器人)。
6. 系统通过 MQTT 发送 **移动指令 (`move_to_spot`)** 给机器人 `{robotId}`,目标为车位 `{parkingSpotId}`
7. 机器人移动到位后,通过 MQTT 上报 `arrived_at_spot` 状态。
8. 系统通过 MQTT 发送 **启动充电指令 (`start_charge`)** 给机器人。
9. 机器人开始充电并通过 MQTT 上报 `charging` 状态(及充电时长)。
10. 用户发起停止充电请求 或 机器人上报 `charge_complete` / `error` 状态。
11. 系统通过 MQTT 发送 **停止充电指令 (`stop_charge`)** 给机器人。
12. 系统根据 MQTT 上报的 **总充电时长** 计算费用并扣费。
13. 记录充电日志。
### 2.4 充电记录
* 记录用户的充电历史,包括:用户、**使用的机器人**、**充电车位**、开始时间、结束时间、充电时长、消费金额。
* 普通用户可查看自己的充电记录。
* 管理员可查看所有充电记录。
## 3. 非功能需求
* **可用性**: 系统应稳定可靠,提供持续服务。
* **安全性**: 用户密码需加密存储MQTT 通道需进行安全认证;防止未授权访问。
* **可维护性**: 代码结构清晰,遵循高内聚低耦合原则,易于维护和扩展。
* **性能**: 系统应能及时响应用户请求和处理 MQTT 消息。
## 4. 技术选型(初步)
* 后端框架: Spring Boot
* 持久层: MyBatis-Plus
* 数据库: MySQL
* 缓存: Redis
* **MQTT Broker**: 外部公共服务 (如 EMQX, **需用户提供实例信息**)
* MQTT 客户端库: Eclipse Paho MQTT Client
* API 文档: Knife4j
* 数据交换格式: JSON
* **机器人控制器 (参考)**: ESP32-CAM
## 5. MQTT 通道Topic规划 (需要用户确认并提供细节)
* **基础 Topic 结构**: (建议)
* 指令下发: `robot/command/{clientId}`
* 状态上报: `robot/status/{clientId}`
* *注:`{clientId}` 通常是认证时使用的客户端ID可以等同于机器人ID `{robotId}`,需要与硬件端约定。*
* **指令下发 Topic**: `robot/command/{clientId}` (平台 -> 机器人)
* **认证**: **需要** (用户名/密码由用户提供)
* **Payload (JSON 示例)**:
* 移动到车位: `{"action": "move_to_spot", "spotId": "P001"}`
* 开始充电: `{"action": "start_charge"}`
* 停止充电: `{"action": "stop_charge"}`
* 查询状态: `{"action": "query_status"}` (可选)
* **状态上报 Topic**: `robot/status/{clientId}` (机器人 -> 平台)
* **认证**: **需要** (用户名/密码由用户提供)
* **Payload (JSON 示例)**:
* 空闲状态: `{"robotId": "R001", "status": "idle", "location": "base", "battery": 95}`
* 移动中: `{"robotId": "R001", "status": "moving", "targetSpot": "P001", "battery": 90}`
* 到达车位: `{"robotId": "R001", "status": "arrived_at_spot", "spotId": "P001", "battery": 88}`
* 充电中: `{"robotId": "R001", "status": "charging", "spotId": "P001", "durationSeconds": 120, "battery": 92}`
* 充电完成: `{"robotId": "R001", "status": "charge_complete", "spotId": "P001", "totalDurationSeconds": 3600, "battery": 100}`
* 错误状态: `{"robotId": "R001", "status": "error", "errorCode": "NAV_BLOCKED", "message": "Navigation path blocked", "location": "near_P002"}`
* 通用状态响应 (对 query_status): `{"robotId": "R001", "status": "idle", "location": "base", "battery": 95}`
* **重要提示**: 上述 Topic 结构和 Payload 内容为 **建议**最终需要与硬件ESP32开发侧 **详细约定****统一**。平台将根据约定的格式进行开发。
## 6. 数据库设计 (已精简)
* **用户表 (`user`)**: 包含用户基本信息、**密码**、**角色 (如: admin, user)**、**账户余额**。
* 充电机器人表 (`charging_robot`)
* 车位表 (`parking_spot`)
* 充电记录表 (`charging_session`)
* 激活码表 (`activation_code`)
* **机器人任务表 (`robot_task`)**: 用于跟踪发送给机器人的指令及其状态 (pending, sent, acknowledged_success, acknowledged_failure, timed_out),防止重复发送指令给未响应的机器人。

316
springboot-init-main/mvnw vendored Normal file
View File

@@ -0,0 +1,316 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Maven Start Up Batch script
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
#
# Optional ENV vars
# -----------------
# M2_HOME - location of maven2's installed home dir
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
if [ -f /usr/local/etc/mavenrc ] ; then
. /usr/local/etc/mavenrc
fi
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
case "`uname`" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
export JAVA_HOME="`/usr/libexec/java_home`"
else
export JAVA_HOME="/Library/Java/Home"
fi
fi
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=`java-config --jre-home`
fi
fi
if [ -z "$M2_HOME" ] ; then
## resolve links - $0 may be a link to maven's home
PRG="$0"
# need this for relative symlinks
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG="`dirname "$PRG"`/$link"
fi
done
saveddir=`pwd`
M2_HOME=`dirname "$PRG"`/..
# make it fully qualified
M2_HOME=`cd "$M2_HOME" && pwd`
cd "$saveddir"
# echo Using m2 at $M2_HOME
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --unix "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$M2_HOME" ] &&
M2_HOME="`(cd "$M2_HOME"; pwd)`"
[ -n "$JAVA_HOME" ] &&
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="`which javac`"
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=`which readlink`
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
if $darwin ; then
javaHome="`dirname \"$javaExecutable\"`"
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
else
javaExecutable="`readlink -f \"$javaExecutable\"`"
fi
javaHome="`dirname \"$javaExecutable\"`"
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="`\\unset -f command; \\command -v java`"
fi
fi
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
return 1
fi
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=`cd "$wdir/.."; pwd`
fi
# end of workaround
done
echo "${basedir}"
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
echo "$(tr -s '\n' ' ' < "$1")"
fi
}
BASE_DIR=`find_maven_basedir "$(pwd)"`
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found .mvn/wrapper/maven-wrapper.jar"
fi
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
fi
if [ -n "$MVNW_REPOURL" ]; then
jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
else
jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
fi
while IFS="=" read key value; do
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
esac
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
if [ "$MVNW_VERBOSE" = true ]; then
echo "Downloading from: $jarUrl"
fi
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
if $cygwin; then
wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
fi
if command -v wget > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found wget ... using wget"
fi
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
else
wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
fi
elif command -v curl > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found curl ... using curl"
fi
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
curl -o "$wrapperJarPath" "$jarUrl" -f
else
curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
fi
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Falling back to using Java to download"
fi
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
# For Cygwin, switch paths to Windows format before running javac
if $cygwin; then
javaClass=`cygpath --path --windows "$javaClass"`
fi
if [ -e "$javaClass" ]; then
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Compiling MavenWrapperDownloader.java ..."
fi
# Compiling the Java class
("$JAVA_HOME/bin/javac" "$javaClass")
fi
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
# Running the downloader
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Running MavenWrapperDownloader.java ..."
fi
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
if [ "$MVNW_VERBOSE" = true ]; then
echo $MAVEN_PROJECTBASEDIR
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --path --windows "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
fi
# Provide a "standardized" way to retrieve the CLI args that will
# work with both Windows and non-Windows executions.
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
export MAVEN_CMD_LINE_ARGS
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
exec "$JAVACMD" \
$MAVEN_OPTS \
$MAVEN_DEBUG_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.home=${M2_HOME}" \
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"

188
springboot-init-main/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,188 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM https://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Maven Start Up Batch script
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM M2_HOME - location of maven2's installed home dir
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
if "%MVNW_VERBOSE%" == "true" (
echo Found %WRAPPER_JAR%
)
) else (
if not "%MVNW_REPOURL%" == "" (
SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
)
if "%MVNW_VERBOSE%" == "true" (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %DOWNLOAD_URL%
)
powershell -Command "&{"^
"$webclient = new-object System.Net.WebClient;"^
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
"}"^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
"}"
if "%MVNW_VERBOSE%" == "true" (
echo Finished downloading %WRAPPER_JAR%
)
)
@REM End of extension
@REM Provide a "standardized" way to retrieve the CLI args that will
@REM work with both Windows and non-Windows executions.
set MAVEN_CMD_LINE_ARGS=%*
%MAVEN_JAVA_EXE% ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath %WRAPPER_JAR% ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%"=="on" pause
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
cmd /C exit /B %ERROR_CODE%

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yupi</groupId>
<artifactId>mqtt-charging-system</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mqtt-charging-system</name>
<description>MQTT 智能充电桩系统</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.9.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<!-- https://doc.xiaominfo.com/knife4j/documentation/get_start.html-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/junit/junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<!-- MQTT Client -->
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version> <!-- 使用稳定版本 -->
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,15 @@
package com.yupi.project;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.yupi.project.mapper")
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}

View File

@@ -0,0 +1,32 @@
package com.yupi.project.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 权限校验
*
* @author yupi
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
/**
* 有任何一个角色
*
* @return
*/
String[] anyRole() default "";
/**
* 必须有某个角色
*
* @return
*/
String mustRole() default "";
}

View File

@@ -0,0 +1,77 @@
package com.yupi.project.aop;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.yupi.project.annotation.AuthCheck;
import com.yupi.project.common.ErrorCode;
import com.yupi.project.exception.BusinessException;
import com.yupi.project.model.entity.User;
import com.yupi.project.model.enums.UserRoleEnum;
import com.yupi.project.service.UserService;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 权限校验 AOP
*
* @author yupi
*/
@Aspect
@Component
public class AuthInterceptor {
@Resource
private UserService userService;
/**
* 执行拦截
*
* @param joinPoint
* @param authCheck
* @return
*/
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
List<String> anyRole = Arrays.stream(authCheck.anyRole()).filter(StringUtils::isNotBlank).collect(Collectors.toList());
String mustRole = authCheck.mustRole();
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 当前登录用户
User user = userService.getCurrentUser(request);
// 拥有任意权限即通过
if (CollectionUtils.isNotEmpty(anyRole)) {
String userRole = user.getRole();
if (!anyRole.contains(userRole)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
// 必须有所有权限才通过
if (StringUtils.isNotBlank(mustRole)) {
UserRoleEnum mustUserRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
if (mustUserRoleEnum == null) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
String userRole = user.getRole();
if (UserRoleEnum.ADMIN.equals(mustUserRoleEnum)) {
if (!mustRole.equals(userRole)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
}
// 通过权限校验,放行
return joinPoint.proceed();
}
}

View File

@@ -0,0 +1,56 @@
package com.yupi.project.aop;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
/**
* 请求响应日志 AOP
*
* @author yupi
**/
@Aspect
@Component
@Slf4j
public class LogInterceptor {
/**
* 执行拦截
*/
@Around("execution(* com.yupi.project.controller.*.*(..))")
public Object doInterceptor(ProceedingJoinPoint point) throws Throwable {
// 计时
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 获取请求路径
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest httpServletRequest = ((ServletRequestAttributes) requestAttributes).getRequest();
// 生成请求唯一 id
String requestId = UUID.randomUUID().toString();
String url = httpServletRequest.getRequestURI();
// 获取请求参数
Object[] args = point.getArgs();
String reqParam = "[" + StringUtils.join(args, ", ") + "]";
// 输出请求日志
log.info("request startid: {}, path: {}, ip: {}, params: {}", requestId, url,
httpServletRequest.getRemoteHost(), reqParam);
// 执行原方法
Object result = point.proceed();
// 输出响应日志
stopWatch.stop();
long totalTimeMillis = stopWatch.getTotalTimeMillis();
log.info("request end, id: {}, cost: {}ms", requestId, totalTimeMillis);
return result;
}
}

View File

@@ -0,0 +1,35 @@
package com.yupi.project.common;
import lombok.Data;
import java.io.Serializable;
/**
* 通用返回类
*
* @param <T>
* @author yupi
*/
@Data
public class BaseResponse<T> implements Serializable {
private int code;
private T data;
private String message;
public BaseResponse(int code, T data, String message) {
this.code = code;
this.data = data;
this.message = message;
}
public BaseResponse(int code, T data) {
this(code, data, "");
}
public BaseResponse(ErrorCode errorCode) {
this(errorCode.getCode(), null, errorCode.getMessage());
}
}

View File

@@ -0,0 +1,20 @@
package com.yupi.project.common;
import lombok.Data;
import java.io.Serializable;
/**
* 删除请求
*
* @author yupi
*/
@Data
public class DeleteRequest implements Serializable {
/**
* id
*/
private Long id;
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,42 @@
package com.yupi.project.common;
/**
* 错误码
*
* @author yupi
*/
public enum ErrorCode {
SUCCESS(0, "ok"),
PARAMS_ERROR(40000, "请求参数错误"),
NOT_LOGIN_ERROR(40100, "未登录"),
NO_AUTH_ERROR(40101, "无权限"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
FORBIDDEN_ERROR(40300, "禁止访问"),
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败");
/**
* 状态码
*/
private final int code;
/**
* 信息
*/
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}

View File

@@ -0,0 +1,33 @@
package com.yupi.project.common;
import com.yupi.project.constant.CommonConstant;
import lombok.Data;
/**
* 分页请求
*
* @author yupi
*/
@Data
public class PageRequest {
/**
* 当前页号
*/
private long current = 1;
/**
* 页面大小
*/
private long pageSize = 10;
/**
* 排序字段
*/
private String sortField;
/**
* 排序顺序(默认升序)
*/
private String sortOrder = CommonConstant.SORT_ORDER_ASC;
}

View File

@@ -0,0 +1,51 @@
package com.yupi.project.common;
/**
* 返回工具类
*
* @author yupi
*/
public class ResultUtils {
/**
* 成功
*
* @param data
* @param <T>
* @return
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}
/**
* 失败
*
* @param errorCode
* @return
*/
public static BaseResponse error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}
/**
* 失败
*
* @param code
* @param message
* @return
*/
public static BaseResponse error(int code, String message) {
return new BaseResponse(code, null, message);
}
/**
* 失败
*
* @param errorCode
* @return
*/
public static BaseResponse error(ErrorCode errorCode, String message) {
return new BaseResponse(errorCode.getCode(), null, message);
}
}

View File

@@ -0,0 +1,38 @@
package com.yupi.project.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
/**
* Knife4j 接口文档配置
* https://doc.xiaominfo.com/knife4j/documentation/get_start.html
*
* @author yupi
*/
@Configuration
@EnableSwagger2
@Profile("dev")
public class Knife4jConfig {
@Bean
public Docket defaultApi2() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(new ApiInfoBuilder()
.title("project-backend")
.description("project-backend")
.version("1.0")
.build())
.select()
// 指定 Controller 扫描包路径
.apis(RequestHandlerSelectors.basePackage("com.yupi.project.controller"))
.paths(PathSelectors.any())
.build();
}
}

View File

@@ -0,0 +1,91 @@
package com.yupi.project.config;
import com.yupi.project.config.properties.MqttProperties;
import com.yupi.project.mqtt.MqttCallbackHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.UUID;
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MqttConfig {
private final MqttProperties mqttProperties;
private final MqttCallbackHandler mqttCallbackHandler;
@Autowired // Autowire the MqttClient bean defined below
private MqttClient mqttClient;
@Bean
public MqttConnectOptions mqttConnectOptions() {
MqttConnectOptions options = new MqttConnectOptions();
if (StringUtils.hasText(mqttProperties.getUsername())) {
options.setUserName(mqttProperties.getUsername());
}
if (StringUtils.hasText(mqttProperties.getPassword())) {
options.setPassword(mqttProperties.getPassword().toCharArray());
}
options.setAutomaticReconnect(true); // Enable automatic reconnect
options.setCleanSession(true); // Start with a clean session
options.setConnectionTimeout(mqttProperties.getConnectionTimeout());
options.setKeepAliveInterval(mqttProperties.getKeepAliveInterval());
// options.setWill(...) // Configure Last Will and Testament if needed
return options;
}
@Bean // Bean method name will be the bean name by default: "mqttClientBean"
public MqttClient mqttClientBean(MqttConnectOptions mqttConnectOptions) throws MqttException {
String clientId = mqttProperties.getClientIdPrefix() + UUID.randomUUID().toString().replace("-", "");
MqttClient client = new MqttClient(mqttProperties.getBrokerUrl(), clientId, new MemoryPersistence());
client.setCallback(mqttCallbackHandler);
// Pass the client instance to the handler so it can subscribe on connectComplete
mqttCallbackHandler.setMqttClient(client);
return client;
}
@PostConstruct
public void connect() {
try {
// Use the autowired mqttClient field
if (this.mqttClient != null && !this.mqttClient.isConnected()) {
log.info("Attempting to connect to MQTT broker: {} with client ID: {}", mqttProperties.getBrokerUrl(), this.mqttClient.getClientId());
this.mqttClient.connect(mqttConnectOptions()); // mqttConnectOptions() provides the bean
// Subscription logic is now in MqttCallbackHandler.connectComplete
} else if (this.mqttClient == null) {
log.error("MqttClient (autowired) is null, cannot connect.");
}
} catch (MqttException e) {
log.error("Error connecting to MQTT broker: ", e);
// Consider retry logic or application failure based on requirements
}
}
@PreDestroy
public void disconnect() {
try {
// Use the autowired mqttClient field
if (this.mqttClient != null && this.mqttClient.isConnected()) {
log.info("Disconnecting from MQTT broker: {}", this.mqttClient.getServerURI());
this.mqttClient.disconnect();
log.info("Successfully disconnected from MQTT broker.");
}
if (this.mqttClient != null) {
this.mqttClient.close();
}
} catch (MqttException e) {
log.error("Error disconnecting from MQTT broker: ", e);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.yupi.project.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis Plus 配置
*
* @author yupi
*/
@Configuration
@MapperScan("com.yupi.project.mapper")
public class MyBatisPlusConfig {
/**
* 拦截器配置
*
* @return
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

View File

@@ -0,0 +1,73 @@
package com.yupi.project.config;
import com.yupi.project.model.enums.UserRoleEnum;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* 安全相关配置 (集成CORS)
*/
@Configuration
@EnableWebSecurity // 启用Spring Security的Web安全支持
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
// 使用 BCrypt 进行密码加密
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 集成CORS配置
.cors()
.and()
// 明确禁用CSRF保护
.csrf().disable()
.authorizeRequests(
// 允许对登录和注册接口的匿名访问 (路径不带 /api 前缀)
authorize -> authorize
.antMatchers("/user/login", "/user/register", "/error").permitAll()
.antMatchers("/user/current").authenticated()
// 使用 hasAuthority 确保匹配 UserRoleEnum.ADMIN.getValue() 即 "admin"
.antMatchers("/user/list").hasAuthority(UserRoleEnum.ADMIN.getValue())
.antMatchers(HttpMethod.DELETE, "/user/delete/**").hasAuthority(UserRoleEnum.ADMIN.getValue())
.antMatchers(HttpMethod.POST, "/user/admin/add").hasAuthority(UserRoleEnum.ADMIN.getValue())
.antMatchers(HttpMethod.PUT, "/user/admin/update").hasAuthority(UserRoleEnum.ADMIN.getValue())
// 其他所有请求都需要认证
.anyRequest().authenticated()
)
// 禁用默认的表单登录页面
.formLogin().disable()
// 禁用HTTP Basic认证
.httpBasic().disable();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// TODO: 在生产环境请显式指定允许的源,而不是 "*"
configuration.setAllowedOriginPatterns(Arrays.asList("*")); // 允许所有源模式
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*")); // 允许所有头
configuration.setAllowCredentials(true); // 允许发送 Cookie
configuration.setMaxAge(3600L); // 预检请求的有效期
// configuration.setExposedHeaders(Arrays.asList("YourCustomHeader")); // 如果需要暴露自定义头
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 对所有路径应用配置
return source;
}
}

View File

@@ -0,0 +1,22 @@
package com.yupi.project.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "mqtt")
public class MqttProperties {
private String brokerUrl;
private String username;
private String password;
private String clientIdPrefix;
private int defaultQos;
private int connectionTimeout;
private int keepAliveInterval;
private String commandTopicBase;
private String statusTopicBase;
}

View File

@@ -0,0 +1,19 @@
package com.yupi.project.constant;
/**
* 通用常量
*
* @author yupi
*/
public interface CommonConstant {
/**
* 升序
*/
String SORT_ORDER_ASC = "ascend";
/**
* 降序
*/
String SORT_ORDER_DESC = " descend";
}

View File

@@ -0,0 +1,33 @@
package com.yupi.project.constant;
/**
* 用户常量
*
* @author yupi
*/
public interface UserConstant {
/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "userLoginState";
/**
* 系统用户 id虚拟用户
*/
long SYSTEM_USER_ID = 0;
// region 权限
/**
* 默认角色
*/
String DEFAULT_ROLE = "user";
/**
* 管理员角色
*/
String ADMIN_ROLE = "admin";
// endregion
}

View File

@@ -0,0 +1,152 @@
package com.yupi.project.controller;
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.model.dto.user.UserLoginRequest;
import com.yupi.project.model.dto.user.UserRegisterRequest;
import com.yupi.project.model.entity.User;
import com.yupi.project.model.enums.UserRoleEnum;
import com.yupi.project.service.UserService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 用户接口
*/
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
// --- 登录注册相关 --- //
@PostMapping("/register")
public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
if (userRegisterRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String username = userRegisterRequest.getUsername();
String password = userRegisterRequest.getPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
if (StringUtils.isAnyBlank(username, password, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数不能为空");
}
long result = userService.userRegister(username, password, checkPassword);
return ResultUtils.success(result);
}
@PostMapping("/login")
public BaseResponse<User> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
if (userLoginRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String username = userLoginRequest.getUsername();
String password = userLoginRequest.getPassword();
if (StringUtils.isAnyBlank(username, password)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数不能为空");
}
User user = userService.userLogin(username, password, request);
return ResultUtils.success(user);
}
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
boolean result = userService.userLogout(request);
return ResultUtils.success(result);
}
@GetMapping("/current")
public BaseResponse<User> getCurrentUser(HttpServletRequest request) {
User currentUser = userService.getCurrentUser(request);
// getCurrentUser 内部已处理未登录情况,并返回脱敏用户
return ResultUtils.success(currentUser);
}
// --- 管理员功能 --- //
@GetMapping("/list")
public BaseResponse<List<User>> listUsers(HttpServletRequest request) {
// 权限校验 (在 SecurityConfig 中配置,这里可以简化或移除,但保留显式检查作为双重保险)
User currentUser = userService.getCurrentUser(request); // 获取当前用户以检查角色
if (currentUser == null || !UserRoleEnum.ADMIN.getValue().equals(currentUser.getRole())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无管理员权限");
}
List<User> userList = userService.listUsers(); // 调用新的 service 方法,它已经处理了脱敏
return ResultUtils.success(userList);
}
// 新增管理员删除用户接口
@DeleteMapping("/delete/{userId}")
public BaseResponse<Boolean> adminDeleteUser(@PathVariable Long userId, HttpServletRequest request) {
// 权限校验 (虽然 SecurityConfig 会拦截,但 Controller 层再校验一次更安全)
User currentUser = userService.getCurrentUser(request);
if (currentUser == null || !UserRoleEnum.ADMIN.getValue().equals(currentUser.getRole())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无管理员权限");
}
if (userId == null || userId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户ID参数错误");
}
// 防止管理员误删自己 (可选逻辑)
if (currentUser.getId().equals(userId)) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "不能删除当前登录的管理员账户");
}
boolean result = userService.adminDeleteUser(userId);
return ResultUtils.success(result);
}
// 新增管理员添加用户接口
@PostMapping("/admin/add")
public BaseResponse<Long> adminAddUser(@RequestBody com.yupi.project.model.dto.user.UserAdminAddRequest addRequest, HttpServletRequest request) {
// 权限校验
User currentUser = userService.getCurrentUser(request);
if (currentUser == null || !UserRoleEnum.ADMIN.getValue().equals(currentUser.getRole())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无管理员权限");
}
if (addRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数不能为空");
}
long newUserId = userService.adminAddUser(addRequest);
return ResultUtils.success(newUserId);
}
// 新增管理员更新用户接口
@PutMapping("/admin/update")
public BaseResponse<Boolean> adminUpdateUser(@RequestBody com.yupi.project.model.dto.user.UserAdminUpdateRequest updateRequest, HttpServletRequest request) {
// 权限校验
User currentUser = userService.getCurrentUser(request);
if (currentUser == null || !UserRoleEnum.ADMIN.getValue().equals(currentUser.getRole())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无管理员权限");
}
if (updateRequest == null || updateRequest.getId() == null || updateRequest.getId() <=0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数错误必须提供用户ID");
}
// 防止管理员通过此接口修改自己的角色把自己改成普通用户或修改关键的ADMIN账户 (可选)
// 如果要更新的用户是当前登录的管理员并且尝试修改角色为非admin则拒绝
if (currentUser.getId().equals(updateRequest.getId()) &&
StringUtils.isNotBlank(updateRequest.getRole()) &&
!UserRoleEnum.ADMIN.getValue().equals(updateRequest.getRole())) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "管理员不能将自己的角色修改为非管理员");
}
boolean result = userService.adminUpdateUser(updateRequest);
return ResultUtils.success(result);
}
}

View File

@@ -0,0 +1,32 @@
package com.yupi.project.exception;
import com.yupi.project.common.ErrorCode;
/**
* 自定义异常类
*
* @author yupi
*/
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,30 @@
package com.yupi.project.exception;
import com.yupi.project.common.BaseResponse;
import com.yupi.project.common.ErrorCode;
import com.yupi.project.common.ResultUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器
*
* @author yupi
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("businessException: " + e.getMessage(), e);
return ResultUtils.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("runtimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, e.getMessage());
}
}

View File

@@ -0,0 +1,13 @@
package com.yupi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yupi.project.model.entity.User;
/**
* @description 针对表【user(用户表)】的数据库操作Mapper
* @createDate 2023-11-24 10:00:00
* @Entity com.yupi.project.model.entity.User
*/
public interface UserMapper extends BaseMapper<User> {
}

View File

@@ -0,0 +1,34 @@
package com.yupi.project.model.dto.user;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 管理员添加用户请求体
*/
@Data
public class UserAdminAddRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 用户角色 (例如 "user", "admin")
*/
private String role;
/**
* 初始余额 (可选)
*/
private BigDecimal balance;
}

View File

@@ -0,0 +1,41 @@
package com.yupi.project.model.dto.user;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 管理员更新用户请求体
*/
@Data
public class UserAdminUpdateRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID (必须)
*/
private Long id;
/**
* 用户名 (可选,如果提供则尝试更新)
*/
private String username;
/**
* 新密码 (可选,如果提供则重置密码)
*/
private String password;
/**
* 用户角色 (可选,如果提供则尝试更新)
*/
private String role;
/**
* 余额 (可选,如果提供则尝试更新)
*/
private BigDecimal balance;
// isDeleted 不应由管理员直接通过此接口修改,应通过删除接口
}

View File

@@ -0,0 +1,18 @@
package com.yupi.project.model.dto.user;
import lombok.Data;
import java.io.Serializable;
/**
* 用户登录请求体
*/
@Data
public class UserLoginRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
private String username;
private String password;
}

View File

@@ -0,0 +1,21 @@
package com.yupi.project.model.dto.user;
import lombok.Data;
import java.io.Serializable;
/**
* 用户注册请求体
*/
@Data
public class UserRegisterRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
private String username;
private String password;
private String checkPassword;
}

View File

@@ -0,0 +1,60 @@
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(value ="user")
@Data
public class User implements Serializable {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码 (加密存储)
*/
private String password;
/**
* 角色 (user/admin)
*/
private String role;
/**
* 账户余额
*/
private BigDecimal balance;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
/**
* 逻辑删除标志 (0:未删, 1:已删)
*/
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

View File

@@ -0,0 +1,62 @@
package com.yupi.project.model.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.ObjectUtils;
/**
* 用户角色枚举
*
* @author yupi
*/
public enum UserRoleEnum {
USER("user", "用户"),
ADMIN("admin", "管理员"),
BAN("ban", "被封号");
private final String value;
private final String text;
UserRoleEnum(String value, String text) {
this.value = value;
this.text = text;
}
/**
* 获取值列表
*
* @return
*/
public static List<String> getValues() {
return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
}
/**
* 根据 value 获取枚举
*
* @param value
* @return
*/
public static UserRoleEnum getEnumByValue(String value) {
if (ObjectUtils.isEmpty(value)) {
return null;
}
for (UserRoleEnum anEnum : UserRoleEnum.values()) {
if (anEnum.value.equals(value)) {
return anEnum;
}
}
return null;
}
public String getValue() {
return value;
}
public String getText() {
return text;
}
}

View File

@@ -0,0 +1,77 @@
package com.yupi.project.mqtt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.springframework.stereotype.Component;
// import com.yupi.project.service.MqttMessageHandler; // Will be uncommented and used later
import com.yupi.project.config.properties.MqttProperties;
@Slf4j
@Component
@RequiredArgsConstructor
public class MqttCallbackHandler implements MqttCallbackExtended {
// private final MqttMessageHandler mqttMessageHandler; // Will be uncommented and used later
private final MqttProperties mqttProperties;
private MqttClient mqttClient; // Setter needed or passed in constructor/method
public void setMqttClient(MqttClient mqttClient) {
this.mqttClient = mqttClient;
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
log.info("MQTT connection {} to broker: {}", reconnect ? "re-established" : "established", serverURI);
// Subscribe to the status topic upon connection/reconnection
try {
if (mqttClient != null && mqttClient.isConnected()) {
String statusTopic = mqttProperties.getStatusTopicBase() + "/+"; // Subscribe to all robot statuses
mqttClient.subscribe(statusTopic, mqttProperties.getDefaultQos());
log.info("Subscribed to MQTT topic: {}", statusTopic);
} else {
log.warn("MQTT client not available or not connected, cannot subscribe to topic.");
}
} catch (Exception e) {
log.error("Error subscribing to MQTT topic after connection complete: ", e);
}
}
@Override
public void connectionLost(Throwable cause) {
log.error("MQTT connection lost!", cause);
// Automatic reconnect is handled by MqttConnectOptions
}
@Override
public void messageArrived(String topic, MqttMessage message) throws Exception {
String payload = new String(message.getPayload());
log.debug("MQTT message arrived. Topic: {}, QoS: {}, Payload:\n{}", topic, message.getQos(), payload);
// TODO: Implement application-level authentication/validation of the message payload here or in MqttMessageHandler
// try {
// mqttMessageHandler.handleStatusUpdate(topic, payload); // Will be uncommented and used later
// } catch (Exception e) {
// log.error("Error handling MQTT message for topic {}: ", topic, e);
// }
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
// This callback is invoked when a message published by this client has been successfully delivered
// Used for QoS 1 and 2. For QoS 0, it's called after the message has been handed to the network.
try {
if (token.isComplete() && token.getMessage() != null) {
log.trace("MQTT message delivery complete. Message ID: {}, Payload: {}", token.getMessageId(), new String(token.getMessage().getPayload()));
} else if (token.isComplete()){
log.trace("MQTT message delivery complete. Message ID: {}", token.getMessageId());
}
} catch (Exception e) {
log.error("Error in MQTT deliveryComplete callback: ", e);
}
}
}

View File

@@ -0,0 +1,105 @@
package com.yupi.project.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yupi.project.model.entity.User;
import javax.servlet.http.HttpServletRequest;
import java.math.BigDecimal;
import java.util.List;
/**
* @description 针对表【user(用户表)】的数据库操作Service
* @createDate 2023-11-24 10:05:00
*/
public interface UserService extends IService<User> {
/**
* 用户注册
*
* @param username 用户名
* @param password 密码
* @param checkPassword 校验密码
* @return 新用户 id
*/
long userRegister(String username, String password, String checkPassword);
/**
* 用户登录
*
* @param username 用户名
* @param password 密码
* @param request HttpServletRequest 用于操作 session
* @return 脱敏后的用户信息
*/
User userLogin(String username, String password, HttpServletRequest request);
/**
* 获取当前登录用户
*
* @param request HttpServletRequest
* @return 当前登录用户,未登录则返回 null
*/
User getCurrentUser(HttpServletRequest request);
/**
* 用户注销
*
* @param request HttpServletRequest
* @return 是否成功
*/
boolean userLogout(HttpServletRequest request);
/**
* 获取脱敏的用户信息
*
* @param originUser 原始用户信息
* @return 脱敏后的用户信息
*/
User getSafetyUser(User originUser);
/**
* 扣减用户余额 (需要保证线程安全和数据一致性)
* @param userId 用户ID
* @param amount 扣减金额 (正数)
* @return 操作是否成功
*/
boolean deductBalance(Long userId, BigDecimal amount);
/**
* 增加用户余额 (需要保证线程安全和数据一致性)
* @param userId 用户ID
* @param amount 增加金额 (正数)
* @return 操作是否成功
*/
boolean addBalance(Long userId, BigDecimal amount);
/**
* 获取用户列表 (仅管理员)
*
* @return 脱敏后的用户列表
*/
List<User> listUsers();
/**
* 管理员删除用户 (逻辑删除)
*
* @param userId 用户ID
* @return 操作是否成功
*/
boolean adminDeleteUser(Long userId);
/**
* 管理员添加新用户
*
* @param addRequest 包含新用户信息的请求体
* @return 新用户的ID
*/
long adminAddUser(com.yupi.project.model.dto.user.UserAdminAddRequest addRequest);
/**
* 管理员更新用户信息
*
* @param updateRequest 包含待更新用户信息的请求体
* @return 操作是否成功
*/
boolean adminUpdateUser(com.yupi.project.model.dto.user.UserAdminUpdateRequest updateRequest);
}

View File

@@ -0,0 +1,392 @@
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.constant.UserConstant;
import com.yupi.project.exception.BusinessException;
import com.yupi.project.mapper.UserMapper;
import com.yupi.project.model.entity.User;
import com.yupi.project.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.math.BigDecimal;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.List;
import java.util.stream.Collectors;
import java.util.ArrayList;
/**
* 用户服务实现类
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService {
@Resource
private UserMapper userMapper;
@Resource
private PasswordEncoder passwordEncoder;
// 用户名校验正则允许字母、数字、下划线长度4到16位
private static final String USERNAME_PATTERN = "^[a-zA-Z0-9_]{4,16}$";
// 密码校验正则至少包含字母和数字长度至少6位
private static final String PASSWORD_PATTERN = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{6,}$";
@Override
public long userRegister(String username, String password, String checkPassword) {
// 1. 参数校验
if (StringUtils.isAnyBlank(username, password, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数不能为空");
}
if (!Pattern.matches(USERNAME_PATTERN, username)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名不符合规范4-16位字母、数字、下划线");
}
if (password.length() < 6) { // 使用更宽松的校验,正则校验留给前端
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码长度不能小于6位");
}
if (!password.equals(checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
synchronized (username.intern()) {
// 2. 检查用户名是否已存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
long count = this.count(queryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名已存在");
}
// 3. 密码加密
String encodedPassword = passwordEncoder.encode(password);
// 4. 创建用户
User userToSave = new User();
userToSave.setUsername(username);
userToSave.setPassword(encodedPassword);
userToSave.setRole(UserConstant.DEFAULT_ROLE); // 默认角色 'user'
userToSave.setBalance(BigDecimal.ZERO); // 初始余额为0
boolean saveResult = this.save(userToSave);
if (!saveResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
}
return userToSave.getId();
}
}
@Override
public User userLogin(String username, String password, HttpServletRequest request) {
// 1. 参数校验
if (StringUtils.isAnyBlank(username, password)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名或密码不能为空");
}
if (!Pattern.matches(USERNAME_PATTERN, username)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名格式错误");
}
// 2. 查询用户
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
User user = this.getOne(queryWrapper);
// 3. 校验密码 和 用户存在性
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名或密码错误");
}
// 4. 用户脱敏
User safetyUser = getSafetyUser(user);
// 5. 创建 Authentication 对象并设置到 SecurityContext
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
if (StringUtils.isNotBlank(user.getRole())) { // 确保角色不为空
authorities.add(new SimpleGrantedAuthority(user.getRole()));
}
Authentication authentication = new UsernamePasswordAuthenticationToken(safetyUser, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
return safetyUser;
}
@Override
public User getCurrentUser(HttpServletRequest request) {
// 优先从 SecurityContextHolder 获取认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated() && !(authentication.getPrincipal() instanceof String && authentication.getPrincipal().equals("anonymousUser"))) {
Object principal = authentication.getPrincipal();
if (principal instanceof User) {
return (User) principal; // principal 已经是 safetyUser
} else if (principal instanceof org.springframework.security.core.userdetails.User) {
// 如果 principal 是 Spring Security 的 User (不太可能在这里,因为我们设置的是 safetyUser)
// 需要转换或重新查询
// For now, assume it's our User object based on login logic
}
}
// 如果 SecurityContextHolder 中没有,尝试从 session (旧逻辑,作为后备或移除)
Object userObj = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
if (userObj instanceof User) {
// 最好在这里也验证一下数据库中的用户状态或者确保session中的信息足够可信
return (User) userObj;
}
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
@Override
public boolean userLogout(HttpServletRequest request) {
SecurityContextHolder.clearContext(); // 清除安全上下文
if (request.getSession(false) != null) { // 获取session但不创建新的
request.getSession(false).invalidate(); // 使session无效
}
return true;
}
@Override
public User getSafetyUser(User originUser) {
if (originUser == null) {
return null;
}
User safetyUser = new User();
BeanUtils.copyProperties(originUser, safetyUser, "password");
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<User> listUsers() {
List<User> userList = this.list(new QueryWrapper<User>().eq("isDeleted", 0));
return userList.stream().map(this::getSafetyUser).collect(Collectors.toList());
}
// 新增管理员删除用户实现
@Override
public boolean adminDeleteUser(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户ID参数错误");
}
User user = this.getById(userId);
if (user == null || user.getIsDeleted() == 1) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在或已被删除");
}
boolean result = this.removeById(userId);
if (!result) {
log.warn("Attempted to delete user that might already be deleted or does not exist, userId: {}", userId);
User checkUser = this.getById(userId);
if(checkUser == null || checkUser.getIsDeleted() == 1){
return true;
}
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除用户失败,数据库操作异常");
}
log.info("User with id: {} logically deleted by admin.", userId);
return true;
}
// 新增管理员添加用户实现
@Override
@Transactional // 确保操作的原子性
public long adminAddUser(com.yupi.project.model.dto.user.UserAdminAddRequest addRequest) {
String username = addRequest.getUsername();
String password = addRequest.getPassword();
String role = addRequest.getRole();
BigDecimal balance = addRequest.getBalance();
// 1. 参数校验
if (StringUtils.isAnyBlank(username, password, role)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名、密码和角色不能为空");
}
if (!Pattern.matches(USERNAME_PATTERN, username)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名不符合规范4-16位字母、数字、下划线");
}
if (password.length() < 6) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码长度不能小于6位");
}
// 校验角色是否合法 (例如:必须是 UserRoleEnum 中的值)
try {
com.yupi.project.model.enums.UserRoleEnum.valueOf(role.toUpperCase());
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的用户角色");
}
if (balance != null && balance.compareTo(BigDecimal.ZERO) < 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "初始余额不能为负数");
}
synchronized (username.intern()) {
// 2. 检查用户名是否已存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
if (this.count(queryWrapper) > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名已存在");
}
// 3. 密码加密
String encodedPassword = passwordEncoder.encode(password);
// 4. 创建用户
User newUser = new User();
newUser.setUsername(username);
newUser.setPassword(encodedPassword);
newUser.setRole(role); // 直接使用管理员指定的角色
newUser.setBalance(balance != null ? balance : BigDecimal.ZERO); // 如果未提供余额默认为0
boolean saveResult = this.save(newUser);
if (!saveResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "添加用户失败,数据库错误");
}
log.info("Admin added new user: {}, ID: {}, Role: {}", username, newUser.getId(), role);
return newUser.getId();
}
}
// 新增管理员更新用户实现
@Override
@Transactional
public boolean adminUpdateUser(com.yupi.project.model.dto.user.UserAdminUpdateRequest updateRequest) {
Long userId = updateRequest.getId();
if (userId == null || userId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户ID不能为空");
}
User existingUser = this.getById(userId);
if (existingUser == null || existingUser.getIsDeleted() == 1) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在或已被删除");
}
boolean needsUpdate = false;
User userToUpdate = new User();
userToUpdate.setId(userId);
// 更新用户名 (如果提供)
if (StringUtils.isNotBlank(updateRequest.getUsername())) {
String newUsername = updateRequest.getUsername();
if (!Pattern.matches(USERNAME_PATTERN, newUsername)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "新用户名不符合规范");
}
// 检查新用户名是否与现有其他用户冲突
if (!existingUser.getUsername().equals(newUsername)) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", newUsername).ne("id", userId);
if (this.count(queryWrapper) > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "新用户名已被其他用户使用");
}
userToUpdate.setUsername(newUsername);
needsUpdate = true;
}
}
// 更新密码 (如果提供)
if (StringUtils.isNotBlank(updateRequest.getPassword())) {
String newPassword = updateRequest.getPassword();
if (newPassword.length() < 6) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "新密码长度不能小于6位");
}
userToUpdate.setPassword(passwordEncoder.encode(newPassword));
needsUpdate = true;
}
// 更新角色 (如果提供)
if (StringUtils.isNotBlank(updateRequest.getRole())) {
String newRole = updateRequest.getRole();
try {
com.yupi.project.model.enums.UserRoleEnum.valueOf(newRole.toUpperCase());
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的新用户角色");
}
userToUpdate.setRole(newRole);
needsUpdate = true;
}
// 更新余额 (如果提供)
if (updateRequest.getBalance() != null) {
BigDecimal newBalance = updateRequest.getBalance();
if (newBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "余额不能为负数");
}
userToUpdate.setBalance(newBalance);
needsUpdate = true;
}
if (!needsUpdate) {
log.info("No fields to update for user ID: {}", userId);
return true; // 没有字段需要更新,也视为成功
}
boolean updateResult = this.updateById(userToUpdate);
if (!updateResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新用户信息失败,数据库操作异常");
}
log.info("User ID: {} updated by admin. Fields updated: username={}, role={}, balance={}, password_changed={}",
userId,
userToUpdate.getUsername() != null,
userToUpdate.getRole() != null,
userToUpdate.getBalance() != null,
userToUpdate.getPassword() != null
);
return true;
}
}

View File

@@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/my_db
username: root
password: 123456

View File

@@ -0,0 +1,62 @@
spring:
application:
name: mqtt-charging-system
# DataSource Config
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://yuyun-us1.stormrain.cn:3306/mqtt_power?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: mysql_a4MQ4P
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
# session 失效时间(秒)
session:
timeout: 86400
server:
port: 7529
servlet:
context-path: /api
session:
timeout: 86400 # 设置session的过期时间单位为秒这里设置为1天
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
# Logging configuration
logging:
level:
# Set root logger level (e.g., INFO, WARN, ERROR, DEBUG)
root: INFO
# Set specific package levels
com.yupi.project: DEBUG # Example: Set your project's base package to DEBUG
org.springframework.web: INFO # Set Spring Web logging level
org.springframework.security: DEBUG # Enable Spring Security DEBUG logging
org.mybatis: INFO # Set MyBatis logging level
# ... other specific loggers
#file:
#name: logs/application.log # Log file name
#path: ./logs # Log file path
#pattern:
#console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
#file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
# ===================================================================
# MQTT Configurations
# ===================================================================
mqtt:
broker-url: tcp://broker.emqx.io:1883
username: # Public broker, no credentials specified for connection
password: # Public broker, no credentials specified for connection
client-id-prefix: backend-yupi-mqtt-power- # Unique client ID prefix for our project
default-qos: 1 # Default Quality of Service (0, 1, 2)
connection-timeout: 30 # Connection timeout in seconds
keep-alive-interval: 60 # Keep alive interval in seconds
command-topic-base: yupi_mqtt_power_project/robot/command # Prefixed base topic for sending commands
status-topic-base: yupi_mqtt_power_project/robot/status # Prefixed base topic for receiving status

View File

@@ -0,0 +1 @@
我的项目 by 程序员鱼皮 https://github.com/liyupi

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yupi.project.mapper.UserMapper">
<resultMap id="BaseResultMap" type="com.yupi.project.model.entity.User">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="userName" column="userName" jdbcType="VARCHAR"/>
<result property="userAccount" column="userAccount" jdbcType="VARCHAR"/>
<result property="userAvatar" column="userAvatar" jdbcType="VARCHAR"/>
<result property="gender" column="gender" jdbcType="TINYINT"/>
<result property="userRole" column="userRole" jdbcType="VARCHAR"/>
<result property="userPassword" column="userPassword" jdbcType="VARCHAR"/>
<result property="createTime" column="createTime" jdbcType="TIMESTAMP"/>
<result property="updateTime" column="updateTime" jdbcType="TIMESTAMP"/>
<result property="isDelete" column="isDelete" jdbcType="TINYINT"/>
</resultMap>
<sql id="Base_Column_List">
id,userName,userAccount,
userAvatar,gender,userRole,
userPassword,createTime,updateTime,
isDelete
</sql>
</mapper>

View File

@@ -0,0 +1,57 @@
{
"groups": [
{
"name": "mqtt",
"type": "com.yupi.project.config.properties.MqttProperties",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
}
],
"properties": [
{
"name": "mqtt.broker-url",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.client-id-prefix",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.command-topic-base",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.connection-timeout",
"type": "java.lang.Integer",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.default-qos",
"type": "java.lang.Integer",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.keep-alive-interval",
"type": "java.lang.Integer",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.password",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.status-topic-base",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
},
{
"name": "mqtt.username",
"type": "java.lang.String",
"sourceType": "com.yupi.project.config.properties.MqttProperties"
}
],
"hints": []
}

View File

@@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/my_db
username: root
password: 123456

View File

@@ -0,0 +1,62 @@
spring:
application:
name: mqtt-charging-system
# DataSource Config
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://yuyun-us1.stormrain.cn:3306/mqtt_power?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: mysql_a4MQ4P
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
# session 失效时间(秒)
session:
timeout: 86400
server:
port: 7529
servlet:
context-path: /api
session:
timeout: 86400 # 设置session的过期时间单位为秒这里设置为1天
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
# Logging configuration
logging:
level:
# Set root logger level (e.g., INFO, WARN, ERROR, DEBUG)
root: INFO
# Set specific package levels
com.yupi.project: DEBUG # Example: Set your project's base package to DEBUG
org.springframework.web: INFO # Set Spring Web logging level
org.springframework.security: DEBUG # Enable Spring Security DEBUG logging
org.mybatis: INFO # Set MyBatis logging level
# ... other specific loggers
#file:
#name: logs/application.log # Log file name
#path: ./logs # Log file path
#pattern:
#console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
#file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
# ===================================================================
# MQTT Configurations
# ===================================================================
mqtt:
broker-url: tcp://broker.emqx.io:1883
username: # Public broker, no credentials specified for connection
password: # Public broker, no credentials specified for connection
client-id-prefix: backend-yupi-mqtt-power- # Unique client ID prefix for our project
default-qos: 1 # Default Quality of Service (0, 1, 2)
connection-timeout: 30 # Connection timeout in seconds
keep-alive-interval: 60 # Keep alive interval in seconds
command-topic-base: yupi_mqtt_power_project/robot/command # Prefixed base topic for sending commands
status-topic-base: yupi_mqtt_power_project/robot/status # Prefixed base topic for receiving status

View File

@@ -0,0 +1 @@
我的项目 by 程序员鱼皮 https://github.com/liyupi

Some files were not shown because too many files have changed in this diff Show More