这是基于软件体系和结构课程的一次具体实践,针对中小规模的课程作业级别的开发流程,涵盖软件开发中提出需求和需求分析、架构设计、具体编码等核心过程。

过程非常详细,适合两类朋友——想做课程大作业的朋友和初学软件开发的朋友,但最好有一点点的基础(如能自己查资料去配置一些环境,但本项目对于怎么配环境也讲的很细了)。

对于面向课程大作业的朋友们,本文章除了提供项目代码和运行方式,还做到了逐步拆解项目模块,能帮助你们理解项目,应对老师验收拷打;对于初学软件开发的朋友们,你们可以尝试跟着本文章中的步骤去设计、开发一个自己的简单软件,体验软开流程。

项目相关文档和源码都在https://github.com/feiyangyang11/sports-equipment-architecture-project.git

体育器材预约与借还管理系统

本项目前端采用JS+HTML+CSS,后端采用CMake+Oatpp+C++(因为本人是C++选手),数据库使用MySQL。

需求分析

学校体育设施的传统登记方式是采用人工纸质登记,为了提高管理效率和更方便使用器材,就诞生了设计一个体育器材预约与借还管理系统。

为了更加细化需求,我们需要明确目标人群(借还设施的学生、管理设施的体育部门和管理员……)和核心功能(借还登记、数量管理、预约……)。

为了准确、高效地开发,我们敲定了基本要求后,用合适的提示词来交给ai梳理出需求文档具体内容,然后我们对ai给出的需求文档进行review,再对不合适或多余的需求进行校正,或者对ai没考虑到的需求进行补充。

需求文档大体框架如:

建设目标

本项目的建设目标,是围绕校园体育器材管理场景,建立一套可用、可管、可追踪的业务系统。

系统的第一个目标,是把器材查询、预约申请、审核、借出、归还这些核心业务统一到同一套线上流程里,让学生知道“怎么借”,让管理员知道“怎么管”。

第二个目标,是建立清晰的状态和规则约束。在这个系统里,预约不是提交完就结束,而是要经历待审核、审核通过、审核拒绝、已取消、已借出、已完成等状态;借用记录也不是一条普通表数据,而是会经历借出中、已归还、逾期、已关闭等状态变化。这样设计的目的,是避免“已取消的预约还能借出”“库存已经不够却还允许借出”这类常见错误。

第三个目标,是保证数据一致性和管理留痕。器材管理不是单纯的信息展示,而是和库存数量直接相关的管理业务,所以系统必须确保:办理借出时库存会减少,办理归还时库存会回收,预约审核和借还记录之间能互相对应。为此,项目在后端设计中强调分层结构、权限控制和事务更新,尽量保证“规则判断、数据库修改、结果返回”这几件事是稳定且可维护的。

第四个目标,是让这个项目本身具有教学和工程训练价值。是为了学习一个完整软件系统如何从需求走到设计,再从设计落到实现。

相关干系人

  • 学生用户
  • 管理员
  • 体育部门
  • 开发人员

业务范围

  1. 用户登录
  2. 器材分类、库存、状态管理
  3. 学生预约
  4. 管理员审核
  5. 借还办理
  6. 逾期与损坏登记(后续再开发)
  7. 记录与统计
  8. 操作日志(后续再开发)
  9. 其他可扩展功能

功能性需求

功能模块具体描述,略

非功能性需求

数据一致性、可扩展性、可维护性……略

关键数据对象

与数据库表设计息息相关,一个对象对应一张表。表字段的设计可以参考我的也可以自己设计。

  1. User:用户信息表
  2. Equipment:器材信息表
  3. EquipmentCategory:器材分类表
  4. Reservation:预约记录表
  5. BorrowRecord:借还记录表
  6. DamageRecord:损坏记录表
  7. OperationLog:操作日志表

架构设计

        本项目面向“校园体育器材预约与借还管理”场景,核心目标不是单纯把页面和接口做出来,而是让预约、审核、借出、归还、库存变化形成一条清晰且一致的业务链。为此,系统采用 前后端分离 + 单体后端 + MySQL 持久化 的总体架构:frontend 负责页面与交互,backend 负责业务规则和接口,database 负责保存业务数据。之所以没有上微服务,是因为需要先保证项目结构清晰、实现可控、便于调试。

        后端内部采用经典分层设计。controller 层直接面对 HTTP,请求参数、路径参数、请求体 DTO、返回 JSON 都在这一层处理;service 层负责真正的业务规则,例如“只有审核通过的预约才能办理借出”“归还后要同步回收库存”;dao 层只负责访问数据库、执行 SQL 和事务提交;model 表示系统内部实体,dto 表示接口输入输出结构。这样分层的意义在于:显示什么、规则怎么判、数据怎么存 被拆开了,初学者读代码时不会一上来就把 SQL、业务判断和 HTTP 返回混在一起。

        从业务模块看,当前系统可以分成 4 个核心子域:auth 负责登录、当前用户和退出登录;equipment 负责器材分类、分页查询和详情;reservation 负责学生预约与管理员审核;borrow_record 负责借出和归还。它们之间而是按状态机串联:预约从 PENDINGAPPROVED/REJECTED/CANCELED,审核通过后才能进入 BORROWED;借用记录从 BORROWINGRETURNED/OVERDUE/CLOSED

        系统设计里最关键的专业点有两个。第一是 RBAC 权限控制:学生只能查器材、提预约、看自己的记录;管理员才能审核、借出、归还。第二是 事务一致性:像“办理借出”“办理归还”这种操作,不能只改一张表,而要在同一事务里同时更新预约状态、借用记录和库存数量,否则就会出现“记录变了但库存没变”这类错误。

        可以把整个系统理解成一条固定路径:页面发请求 -> controller 接口入口 -> service 校验业务规则 -> dao 操作数据库 -> 再逐层返回结果。这条路径稳定下来之后,项目就具备了后续继续扩展的基础,比如再补统计报表、日志记录或更多管理功能时,不需要推翻整体架构。对于初学者来说,这正是本项目最值得学习的地方:先搭清楚结构,再在结构里逐步增加功能。

编码

        想完成课程作业的朋友可以仔细看这一部分,应对老师的验收拷打;想完整尝试开发的朋友也可以跟着本过程逐步编码、跑通整个项目。

确定开发流程

画流程图(用OnProcess)或者写开发计划文档,确定按什么顺序进行开发。

先写后端?先写前端?先建数据库?先写具体接口?先跑最小实现?

总之,把开发思路先写出来,然后按思路去逐步编码。

开发文档在此略过,下面分享我自己的编码流程

具体编码

文章里没有把所有源码贴出来,因为有些源码太长了,所以建议朋友们前往github拉取代码到本地后配合本文细细品尝。

仓库:https://github.com/feiyangyang11/sports-equipment-architecture-project.git

创建项目框架

创建目录命名为sports-equipment-architecture-project,这是我们项目的根目录

然后在根目录下创建三个目录:

  • sports-equipment-architecture-project\backend 后端
  • sports-equipment-architecture-project\frontend 前端
  • sports-equipment-architecture-project\database 数据库

建数据库表

在此之前需要安装好MySQL环境(b站有教程),我用的是MySQL8.0;用不惯MySQL workbench和MySQL CLI的也可以下载Navicat等数据库可视化软件,我用的是Datagrip(淘宝、咸鱼上都有几块钱的破解版卖)。

进入 sports-equipment-architecture-project\database创建两个目录: 

  • sports-equipment-architecture-project\database\schema 存放建库建表sql
  • sports-equipment-architecture-project\database\seed 存放初始化数据sql

在sports-equipment-architecture-project\database\schema目录下编写01_init.sql(不想自己写的可以去复制我的sql代码),打开 Datagrip 运行这个sql。这一步主要做的是:

  • 创建数据库 sports_equipment_management
  • 创建要用到的七张表 user(用户信息表)、equipment(器材信息表)、equipment_category(器材分类表)、borrow_record(借还记录表)、damage_record(损坏记录表)、reservation(预定记录表)、operation_log(操作日志表),与需求分析中的关键数据对象相呼应

运行后你会看到创建好的库和表:

然后在sports-equipment-architecture-project\database\seed目录下编写01_seed.sql(不想自己写的可以去复制我的sql代码),打开 Datagrip 运行这个sql。这一步主要做的是:

  • 给表中填入一些初始数据,比如管理员和学生账户、一些器材等

运行完你会看到表中有一些数据:

写后端代码

创建目录

进入 sports-equipment-architecture-project\backend

创建四个目录:

  • sports-equipment-architecture-project\backend\build 放构建产物
  • sports-equipment-architecture-project\backend\cmake 放cmake构建脚本
  • sports-equipment-architecture-project\backend\include 放头文件
  • sports-equipment-architecture-project\backend\src 放cpp源文件
  • sports-equipment-architecture-project\backend\config 放配置文件
安装Oatpp框架

我是安装了MSYS2 ,这是一套 64 位 C/C++ 开发环境

它里面有好几个终端环境,比如:

  • MSYS
  • MINGW64
  • UCRT64

我用的是C:\msys64\ucrt64.exe,它的作用是提供一整套 C++ 开发工具(g++、cmake、pacman、各种第三方安装包……)

去官网安装MSYS2 然后运行C:\msys64\ucrt64.exe

更新终端基础组件

pacman -Syu

安装make、Cmake、oatpp

pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-make mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-oatpp

检查是否安装成功,正常的话会看到安装路径

which g++
which cmake
which mingw32-make
pacman -Q mingw-w64-ucrt-x86_64-oatpp
写CMakeLists.txt+跑通最小实现

在此之前需要先安装好CMake,最好是最新版的,可以直接去官网下载安装,没有什么需要特别注意的地方,记得配置环境变量就行。

什么是CMake和CMakeLists.txt?

  • CMake 是一种跨平台的构建系统生成工具,它本身通常不直接把 C++ 源代码编译成程序,而是根据项目的构建规则,生成适合当前环境的底层构建文件(比如Makefile),再调用编译器(比如MSVC、g++)
  • CMakeLists.txt 就是写给 CMake 读取的构建配置脚本

简单理解就是:你的 .cpp 文件就像乐高积木,负责写“程序要实现什么功能”,CMakeLists.txt 就像说明书,负责写“这些代码和依赖应该怎样组织、怎样编译成程序”,而 CMake 负责读取这份说明书,组织真正的编译器把项目构建出来,把积木搭成成品。

怎么写CMakeLists.txt?

进入 sports-equipment-architecture-project\backend

创建CMakeLists.txt,不想自己写的可以直接复制我的:

#强制设定CMake最低版本
cmake_minimum_required(VERSION 3.20)
#设置项目名和语言
project(sports_equipment_backend LANGUAGES CXX)
#强制使用C++17,禁止降级,禁止编译器扩展,保证代码可移植性
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
#去系统里找官方安装好的oatpp并使用它提供的CMake配置,找不到就报错
find_package(oatpp CONFIG REQUIRED)
#创建可执行目标,名字同PROJECT_NAME
add_executable(${PROJECT_NAME})
#把当前阶段真正参与编译的源文件挂到目标上
target_sources(${PROJECT_NAME}
  PRIVATE
    src/app/main.cpp
)
#为PROJECT_NAME目标设定头文件搜索路径,在/include和/src里找
target_include_directories(${PROJECT_NAME}
  PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/include
    ${CMAKE_CURRENT_SOURCE_DIR}/src
)
#给当前目标添加要链接的库
target_link_libraries(${PROJECT_NAME}
  PRIVATE
    oatpp::oatpp
)

然后进入sports-equipment-architecture-project\backend\src创建app目录,在app目录下创建main.cpp写如下代码:

#include "oatpp/core/base/Environment.hpp"

#include <iostream>

int main() {
  oatpp::base::Environment::init();

  std::cout << "sports_equipment_backend bootstrap ok" << std::endl;

  oatpp::base::Environment::destroy();
  return 0;
}

这是为了先写一个能跑通的最小实现,确定我们编译和运行环境已配好。

我用的是vscode+cpp环境来写代码,比较麻烦。如果是用Visual Studio写代码的朋友,打开项目识别到 CMakeLists.txt 就可以直接编译运行程序了(但是oatpp等环境配置又会有点不同,建议还是像我一样用vscode),下面讲vscode怎么运行:

打开终端,执行如下命令:

进入build目录

cd /d/newdir/sports-equipment-architecture-project/backend
rm -rf build

指定CMake的源码目录、构建目录和生成器

执行cmake构建并执行可执行程序sports_equipment_backend.exe

因为我UCRT64里下的CMake有点小问题,所以写了从官网下载的cmake.exe的全路径,否则直接cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_PREFIX_PATH=/ucrt64        和 cmake --build build 就行

"/c/Program Files/CMake/bin/cmake.exe" -S . -B build -G "MinGW Makefiles" -DCMAKE_PREFIX_PATH=/ucrt64
"/c/Program Files/CMake/bin/cmake.exe" --build build
./build/sports_equipment_backend.exe
注册基础组件

在main.cpp同目录下创建AppComponent.hpp,注册四个基础组件到oatpp全局环境,提供网络收发能力和业务路由能力,可以理解为HTTP服务的底座:

  • 监听 8000 端口的 serverConnectionProvider:该组件是用于监听和建立网络连接,0.0.0.0表示监听本机的所有网卡,8000表示监听本机8000端口,第三项表示用的是ipv4。
  • 把请求分发给路由的 serverConnectionHandler:该组件接到从serverConnectionProvider来的http请求解析然后发给httpRouter。如果挂载了其他处理器,还会做别的处理,如鉴权。
  • 保存路由的 httpRouter:该组件保存“URL 路径 -> 处理函数/控制器”的映射关系,当前端http请求到来时,它根据请求行的URL去调用对应处理函数。
  • 把响应转成 JSON 的 apiObjectMapper:把 body 里的 JSON 变成对象,序列化/反序列化

这里用的是Oatpp的宏OATPP_CREATE_COMPONENT来创建并注册组件

不想自己写代码可以直接复制如下:

#ifndef APP_COMPONENT_HPP
#define APP_COMPONENT_HPP

#include "oatpp/core/macro/component.hpp"
#include "oatpp/network/ConnectionHandler.hpp"
#include "oatpp/network/tcp/server/ConnectionProvider.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "oatpp/web/server/HttpConnectionHandler.hpp"
#include "oatpp/web/server/HttpRouter.hpp"

class AppComponent {
public:
  // Provide the TCP listener used by the HTTP server.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>,
                         serverConnectionProvider)
  ([] {
    return oatpp::network::tcp::server::ConnectionProvider::createShared(
        {"0.0.0.0", 8000, oatpp::network::Address::IP_4});
  }());

  // Store route registrations such as GET /health.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>,
                         httpRouter)
  ([] {
    return oatpp::web::server::HttpRouter::createShared();
  }());

  // Convert HTTP requests into controller dispatches through the router.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ConnectionHandler>,
                         serverConnectionHandler)
  ([] {
    OATPP_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, router);
    return oatpp::web::server::HttpConnectionHandler::createShared(router);
  }());

  // Serialize controller responses to JSON.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::data::mapping::ObjectMapper>,
                         apiObjectMapper)
  ([] {
    return oatpp::parser::json::mapping::ObjectMapper::createShared();
  }());
};

#endif
实现第一个接口health

为了验证后端能否正确完成监听和建立连接、处理业务、返回响应等内外交互行为,这里将实现第一个功能接口。

我们选择的是最简单的运维/健康检查接口,它不是真正业务接口,但是会长期保留在项目里

创建sports-equipment-architecture-project\backend\src\controller目录,在里面创建HealthController.hpp。

补充一下,controller层做的是接收HTTP请求、定义接口入口、导向系统内部业务调用、把结果转化为HTTP响应,是连接层与业务层的桥

这里objectmapper给HealthController提供了JSON与DTO对象之间的序列化和反序列化能力;ENDPOINT宏用于定义接口函数,后面的函数体是它的实现,response是一个HTTP响应对象,设置了返回码是200,返回的响应体是status:ok,响应头中添加了Conten-type:application/json

不想自己写的可以直接复制如下:

#ifndef HEALTH_CONTROLLER_HPP
#define HEALTH_CONTROLLER_HPP

#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/web/protocol/http/Http.hpp"
#include "oatpp/web/server/api/ApiController.hpp"

#include OATPP_CODEGEN_BEGIN(ApiController)

class HealthController : public oatpp::web::server::api::ApiController {
public:
  explicit HealthController(
      const std::shared_ptr<oatpp::data::mapping::ObjectMapper>& objectMapper)
      : oatpp::web::server::api::ApiController(objectMapper) {}

public:
  ENDPOINT("GET", "/health", health) {
    auto response = createResponse(Status::CODE_200, "{\"status\":\"ok\"}");
    response->putHeader(oatpp::web::protocol::http::Header::CONTENT_TYPE,
                        "application/json");
    return response;
  }
};

#include OATPP_CODEGEN_END(ApiController)

#endif

然后在main.cpp里用OATPP_COMPONENT(...)取出组件,装配组件,完成环境初始化 -> 组件装配 -> 路由注册 -> 服务启动”这一整条启动链路:

  • 把 HealthController 挂到 router 上
  • 用 connectionProvider 和 connectionHandler 组装出 Server
  • server.run() 进入服务循环
  • 程序开始真正监听 8000 端口并持续对外提供接口

不想自己写的可以复制如下:

#include "app/AppComponent.hpp"
#include "controller/HealthController.hpp"
#include "oatpp/core/base/Environment.hpp"
#include "oatpp/core/macro/component.hpp"
#include "oatpp/network/Server.hpp"

#include <iostream>

int main() {
  oatpp::base::Environment::init();

  AppComponent appComponent;

  OATPP_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, router);
  OATPP_COMPONENT(std::shared_ptr<oatpp::data::mapping::ObjectMapper>,
                  objectMapper);
  OATPP_COMPONENT(
      std::shared_ptr<oatpp::network::ServerConnectionProvider>,
      connectionProvider);
  OATPP_COMPONENT(std::shared_ptr<oatpp::network::ConnectionHandler>,
                  connectionHandler);

  router->addController(HealthController::createShared(objectMapper));

  oatpp::network::Server server(connectionProvider, connectionHandler);

  std::cout << "sports_equipment_backend listening on http://localhost:8000"
            << std::endl;
  server.run();

  oatpp::base::Environment::destroy();
  return 0;
}
跑通第一个接口health

依旧打开 UCRT64终端,跑如下命令:

编译链接--这里的路径是我电脑里CMake的路径,自己跑的朋友要改成自己电脑里的路径

cd /d/newdir/sports-equipment-architecture-project/backend
#如果ucrt64安装的cmake正常,跑下面这条
#cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_PREFIX_PATH=/ucrt64
#如果不正常,那就像我一样跑从cmake官网下的cmake可执行程序
"/c/Program Files/CMake/bin/cmake.exe" -S . -B build -G "MinGW Makefiles" -DCMAKE_PREFIX_PATH=/ucrt64
"/c/Program Files/CMake/bin/cmake.exe" --build build

然后运行可执行程序

./build/sports_equipment_backend.exe

在vscode新开一个终端去测试该后端接口是否可用

curl http://localhost:8000/health

如果看到如下输出,说明可用

或者用浏览器测试,在浏览器输入http://localhost:8000/health,如果看到如下说明可用

跑通第一个HTTP->后端->数据库的接口equipment_categories

上面跑通的第一个接口,不涉及业务处理和数据库访问,只代表外部HTTP->后端接口这条路已经打通,接下来我们尝试打通后端->数据库。

在此之前先安装MySQL驱动。

我这里是在UCRT64终端下载安装的,命令如下:

pacman -Syy
pacman -S --needed mingw-w64-ucrt-x86_64-libmariadbclient
#用下列命令自查
ls /ucrt64/include/mysql/mysql.h
ls /ucrt64/lib/libmariadb.dll.a
定义出入参据传输对象

这里选择实现 equipment_categories 这个业务接口,因为它有访问数据库的需求,同时只涉及简单的查询,适合用来做最小实现。

因为该接口需要参数和响应,所以我们需要设计入参/出参数据对象。

但当前为了跑数据库访问最小实现,就先假设该接口不需要参数,先设计出参数据对象,所以此时该接口返回的会是所有类别的信息。

所以创建目录sports-equipment-architecture-project\backend\src\dto

在该目录下创建request和response目录

进入response目录创建两个DTO(DTO=Data Transfer Object,即数据传输对象):

  • EquipmentCategoryDto.hpp:定义EquipmentCategoryDto类。表示“单个分类项”,含有四个字段(id、categoryCode、categoryName、status)。
  • 和EquipmentCategoryListDto.hpp:定义EquipmentCategoryListDto类。表示“分类列表响应”,只含有一个列表字段items,列表成员就是一堆EquipmentCategoryDto对象

可以这么理解,外部调用equipment_categories这个接口,后端就去数据库找equipment_category这张表查所有数据,把每一行记录存在EquipmentCategoryDto对象里面,然后把所有EquipmentCategoryDto对象都放在EquipmentCategoryListDto的列表里面,然后后端再把这个EquipmentCategoryListDto对象放到HTTP响应里返回给外部。

不想自己写的可以复制如下:

#ifndef EQUIPMENT_CATEGORY_DTO_HPP
#define EQUIPMENT_CATEGORY_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class EquipmentCategoryDto : public oatpp::DTO {
  DTO_INIT(EquipmentCategoryDto, DTO)

  DTO_FIELD(UInt64, id);
  DTO_FIELD(String, categoryCode);
  DTO_FIELD(String, categoryName);
  DTO_FIELD(String, status);
};

#include OATPP_CODEGEN_END(DTO)

#endif
#ifndef EQUIPMENT_CATEGORY_LIST_DTO_HPP
#define EQUIPMENT_CATEGORY_LIST_DTO_HPP

#include "dto/response/EquipmentCategoryDto.hpp"
#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class EquipmentCategoryListDto : public oatpp::DTO {
  DTO_INIT(EquipmentCategoryListDto, DTO)

  DTO_FIELD(List<Object<EquipmentCategoryDto>>, items);
};

#include OATPP_CODEGEN_END(DTO)

#endif
定义数据访问模型

它用来承载数据库的数据,或者可以看成数据库表在后端的一个实体,要与数据表的字段一一对应

创建目录sports-equipment-architecture-project\backend\src\model

进入该目录创建EquipmentCategory.hpp文件,用来定义该接口的数据模型

不想自己写的可以复制如下:

#ifndef EQUIPMENT_CATEGORY_HPP
#define EQUIPMENT_CATEGORY_HPP

#include <cstdint>
#include <string>

class EquipmentCategory {
public:
  std::uint64_t id{};
  std::string categoryCode;
  std::string categoryName;
  std::string description;
  std::int32_t sortOrder{};
  std::string status;
  std::string createdAt;
  std::string updatedAt;
};

#endif
写配置文件和配置读取器

创建数据库配置文件 sports-equipment-architecture-project\backend\config\database.conf。

里面写数据库的ip、端口、用户名、密码等连接配置,如下:

创建配置读取器sports-equipment-architecture-project\backend\src\dao\DatabaseConfig.hpp

这主要是为了从配置文件中读取数据库配置信息,代码较多就不贴在这里了,朋友们可以自己去看代码feiyangyang11/sports-equipment-architecture-project · GitHub

写数据库访问层DAO

这是为了建立后端和数据库连接并发送sql语句和接收结果的,是后端和数据库的桥梁。

DAO=Database Access Object

创建目录sports-equipment-architecture-project\backend\src\dao

进入该目录写EquipmentCategoryDAO.cpp和EquipmentCategoryDAO.hpp

EquipmentCategoryDAO.cpp中实现了listAll()函数,用于全量查询所有器材类别;还实现了一系列工具函数,用于与数据库建连和数据类型转换,这里不细说了。

代码太长不贴出来了,大家可以自行去看feiyangyang11/sports-equipment-architecture-project · GitHub

写业务处理层Service

这是具体处理业务逻辑的一层,用来做参数校验、计算、筛选等操作,其他层更多用来传输数据。

当前接口由于是无参的,所以并不需要处理参数;由于是全量返回,也不需要处理响应数据,所以直接调用DAO的listAll()。注意,这是做了最小实现的缘故,不是所有接口都这么写的。

创建目录sports-equipment-architecture-project\backend\src\service

在该目录下写EquipmentCategoryService.cpp和EquipmentCategoryService.hpp

#include "service/EquipmentCategoryService.hpp"

#include "dao/EquipmentCategoryDAO.hpp"

#include <utility>

EquipmentCategoryService::EquipmentCategoryService(
    DatabaseConfig databaseConfig)
    : m_databaseConfig(std::move(databaseConfig)) {}

std::vector<EquipmentCategory> EquipmentCategoryService::listAll() const {
  EquipmentCategoryDAO equipmentCategoryDAO(m_databaseConfig);
  return equipmentCategoryDAO.listAll();
}
#ifndef EQUIPMENT_CATEGORY_SERVICE_HPP
#define EQUIPMENT_CATEGORY_SERVICE_HPP

#include "dao/DatabaseConfig.hpp"
#include "model/EquipmentCategory.hpp"

#include <vector>

class EquipmentCategoryService {
public:
  explicit EquipmentCategoryService(DatabaseConfig databaseConfig);

  std::vector<EquipmentCategory> listAll() const;

private:
  DatabaseConfig m_databaseConfig;
};

#endif
控制器层Controller

这一层目的主要是处理输入输出、参数解析、序列化/反序列化,前面实现第一个接口有讲,这里就不细讲了。

依旧进入sports-equipment-architecture-project\backend\src\controller目录,创建EquipmentCategoryController.hpp

主要做三件事情:

  • 定义创建EquipmentCategoryController对象的工厂方法createShared()
  • 实现EquipmentCategoryController构造函数,配备序列化/反序列化能力
  • 通过ENDPOINT声明接口的HTTP方法、路由规则和处理函数
更新main.cpp
  1. 配置读取有全局效应,因此应该在main里面通过 DatabaseConfig::load() 读取配置。
  2. 然后显式构造 EquipmentCategoryService 作为业务服务对象,接收databaseConfig作为构造参数,为每个业务对象持有一份配置信息。
  3. 然后将 EquipmentCategoryController 注册到路由表。

注意:每完整实现一个接口,我们都要做第2点和第3点:创建业务对象、注册路由。

更新CMakeLists.txt

主要做的是:

  • 添加新创建的源文件
  • 添加要链接的MySQL相关库和头文件
  • 添加报错日志

更新后的如下:

cmake_minimum_required(VERSION 3.20)

project(sports_equipment_backend LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

find_package(oatpp CONFIG REQUIRED)

find_path(MARIADB_INCLUDE_DIR
  NAMES mysql/mysql.h
  HINTS
    /ucrt64/include
    C:/msys64/ucrt64/include
)

find_library(MARIADB_LIBRARY
  NAMES mariadb mariadbclient
  HINTS
    /ucrt64/lib
    C:/msys64/ucrt64/lib
)

if(NOT MARIADB_INCLUDE_DIR OR NOT MARIADB_LIBRARY)
  message(FATAL_ERROR
    "MariaDB/MySQL client library not found. Install "
    "mingw-w64-ucrt-x86_64-libmariadbclient in MSYS2 UCRT64.")
endif()

set(APP_SOURCES
  src/app/main.cpp
)

set(BUSINESS_SOURCES
  src/dao/EquipmentCategoryDAO.cpp
  src/service/EquipmentCategoryService.cpp
)

add_executable(${PROJECT_NAME}
  ${APP_SOURCES}
  ${BUSINESS_SOURCES}
)

target_include_directories(${PROJECT_NAME} PRIVATE
  ${CMAKE_CURRENT_SOURCE_DIR}/include
  ${CMAKE_CURRENT_SOURCE_DIR}/src
  ${MARIADB_INCLUDE_DIR}
)

target_link_libraries(${PROJECT_NAME} PRIVATE
  oatpp::oatpp
  ${MARIADB_LIBRARY}
)
运行!

打开UCRT64终端,运行如下命令启动后端服务:

cd /d/newdir/sports-equipment-architecture-project/backend
rm -rf build
"/c/Program Files/CMake/bin/cmake.exe" -S . -B build -G "MinGW Makefiles" -DCMAKE_PREFIX_PATH=/ucrt64
"/c/Program Files/CMake/bin/cmake.exe" --build build
./build/sports_equipment_backend.exe

起来后测试我们刚实现的equipment-categories接口

#之前的health接口也可以一起测试
#curl.exe http://localhost:8000/health
curl.exe http://localhost:8000/api/equipment-categories
#或者
curl http://localhost:8000/api/equipment-categories
#或者

如果报错,大概率是你没有把配置文件中的数据库账号和密码改成自己的账号和密码,打开sports-equipment-architecture-project\backend\config\database.conf,把username和password改成自己数据库设定的账号密码。

如果成功输出HTTP格式响应,恭喜你已经跑通HTTP->接口->数据库完整链路。

equipment接口实现&讲解

提供两个接口:

  • 按id查询器材详细信息
  • 分页查询id详细信息(带筛选功能)
ENDPOINT("GET", "/api/equipment/{id}", getById, PATH(UInt64, id))

ENDPOINT("GET", "/api/equipment", queryPage,
           QUERY(UInt32, pageNo, "pageNo", 1),
           QUERY(UInt32, pageSize, "pageSize", 10),
           QUERY(UInt64, categoryId, "categoryId", nullptr))
入参DTO

无入参DTO

但是

出参DTO

两个出参DTO

EquipmentDto:包含一个器材的完整信息

EquipmentPageDto:包含多个器材信息的列表、页码、页大小、总记录数

文件路径:

sports-equipment-architecture-project\backend\src\dto\response\EquipmentDto.hpp

sports-equipment-architecture-project\backend\src\dto\response\EquipmentPageDto.hpp

代码如下:

#ifndef EQUIPMENT_DTO_HPP
#define EQUIPMENT_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class EquipmentDto : public oatpp::DTO {
  DTO_INIT(EquipmentDto, DTO)

  DTO_FIELD(UInt64, id);
  DTO_FIELD(UInt64, categoryId);
  DTO_FIELD(String, equipmentCode);
  DTO_FIELD(String, equipmentName);
  DTO_FIELD(String, specification);
  DTO_FIELD(String, storageLocation);
  DTO_FIELD(UInt32, totalStock);
  DTO_FIELD(UInt32, availableStock);
  DTO_FIELD(String, status);
};

#include OATPP_CODEGEN_END(DTO)

#endif
#ifndef EQUIPMENT_PAGE_DTO_HPP
#define EQUIPMENT_PAGE_DTO_HPP

#include "dto/response/EquipmentDto.hpp"
#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class EquipmentPageDto : public oatpp::DTO {
  DTO_INIT(EquipmentPageDto, DTO)

  DTO_FIELD(UInt32, pageNo);
  DTO_FIELD(UInt32, pageSize);
  DTO_FIELD(UInt64, total);
  DTO_FIELD(List<Object<EquipmentDto>>, items);
};

#include OATPP_CODEGEN_END(DTO)

#endif
数据访问模型model

Equipment成员和数据库表字段一一映射

分页的数据模型参考EquipmentPageDto,其中列表成员类型为Equipment

路径:

sports-equipment-architecture-project\backend\src\model\Equipment.hpp

sports-equipment-architecture-project\backend\src\model\EquipmentPageResult.hpp

代码如下:

#ifndef EQUIPMENT_PAGE_RESULT_HPP
#define EQUIPMENT_PAGE_RESULT_HPP

#include "model/Equipment.hpp"

#include <cstdint>
#include <vector>

class EquipmentPageResult {
public:
  std::uint32_t pageNo{};
  std::uint32_t pageSize{};
  std::uint64_t total{};
  std::vector<Equipment> items;
};

#endif
#ifndef EQUIPMENT_PAGE_DTO_HPP
#define EQUIPMENT_PAGE_DTO_HPP

#include "dto/response/EquipmentDto.hpp"
#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class EquipmentPageDto : public oatpp::DTO {
  DTO_INIT(EquipmentPageDto, DTO)

  DTO_FIELD(UInt32, pageNo);
  DTO_FIELD(UInt32, pageSize);
  DTO_FIELD(UInt64, total);
  DTO_FIELD(List<Object<EquipmentDto>>, items);
};

#include OATPP_CODEGEN_END(DTO)

#endif
数据库访问层DAO

提供数据库访问能力

  • queryPage(...):根据页码、页大小、筛选条件去查询器材详细信息列表,只能查询并返回<=页大小的记录数
  • countAll(...):根据筛选条件查询所有记录中符合条件的记录数量
  • getById(...):根据id查单个器材的详细信息

代码太长不贴出来了,路径:

sports-equipment-architecture-project\backend\src\dao\EquipmentDAO.hpp

sports-equipment-architecture-project\backend\src\dao\EquipmentDAO.cpp

业务处理层Service

调用DAO进行查询,然后拼接返回结果

queryPage(...):把DAO返回的数据拼接成result返回

getById(...):直接返回DAO查询结果

路径:

sports-equipment-architecture-project\backend\src\service\EquipmentService.hpp

sports-equipment-architecture-project\backend\src\service\EquipmentService.cpp

代码如下:

#ifndef EQUIPMENT_SERVICE_HPP
#define EQUIPMENT_SERVICE_HPP

#include "dao/DatabaseConfig.hpp"
#include "model/Equipment.hpp"
#include "model/EquipmentPageResult.hpp"

#include <optional>
#include <vector>

class EquipmentService {
public:
  explicit EquipmentService(DatabaseConfig databaseConfig);

  EquipmentPageResult queryPage(std::uint32_t pageNo,
                                std::uint32_t pageSize,
                                std::optional<std::uint64_t> categoryId) const;
  std::optional<Equipment> getById(std::uint64_t id) const;

private:
  DatabaseConfig m_databaseConfig;
};

#endif
#include "service/EquipmentService.hpp"

#include "dao/EquipmentDAO.hpp"

#include <utility>

EquipmentService::EquipmentService(DatabaseConfig databaseConfig)
    : m_databaseConfig(std::move(databaseConfig)) {}

EquipmentPageResult EquipmentService::queryPage(std::uint32_t pageNo,
                                                std::uint32_t pageSize,
                                                std::optional<std::uint64_t> categoryId) const {
  EquipmentDAO equipmentDAO(m_databaseConfig);
  EquipmentPageResult result;
  result.pageNo = pageNo;
  result.pageSize = pageSize;
  result.total = equipmentDAO.countAll(categoryId);
  result.items = equipmentDAO.queryPage(pageNo, pageSize, categoryId);
  return result;
}

std::optional<Equipment> EquipmentService::getById(std::uint64_t id) const {
  EquipmentDAO equipmentDAO(m_databaseConfig);
  return equipmentDAO.getById(id);
}
Controller层

声明两个接口:

  • 分页查询接口:GET是HTTP的查询方法;URL是/api/equipment,表示路由规则;QUERY从URL里解析参数,如GET /api/equipment?pageNo=1&pageSize=10&categoryId=2就可以解析出这三个字段的值,若解析不出来就填默认值;处理函数是queryPage
  • 单个查询接口:HTTP方法是GET,表示查询;URL是/api/equipment/{id},表示路由规则;PATH从URL的路径占位符 {id} 里取值

路径:

sports-equipment-architecture-project\backend\src\controller\EquipmentController.hpp

auth 接口实现 & 讲解

当前 auth 一共提供 3 个接口:

ENDPOINT("POST", "/api/login", login,
         BODY_DTO(Object<LoginRequestDto>, loginRequestDto))

ENDPOINT("GET", "/api/me", getMe,
         HEADER(String, authorization, "Authorization"))

ENDPOINT("POST", "/api/logout", logout,
         HEADER(String, authorization, "Authorization"))

它们分别是:

  • POST /api/login
    用户名密码登录,返回 token + 当前用户信息
  • GET /api/me
    根据请求头里的 Bearer token 获取当前登录用户信息
  • POST /api/logout
    根据请求头里的 Bearer token 退出登录,使当前 token 失效
入参 DTO

只有 1 个入参 DTO:

  • sports-equipment-architecture-project\backend\src\dto\request\LoginRequestDto.hpp

它用于 POST /api/login 的请求体JSON:

{ "username": "admin01", "password": "Admin@123" }

代码如下:

#ifndef LOGIN_REQUEST_DTO_HPP
#define LOGIN_REQUEST_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class LoginRequestDto : public oatpp::DTO {
  DTO_INIT(LoginRequestDto, DTO)

  DTO_FIELD(String, username);
  DTO_FIELD(String, password);
};

#include OATPP_CODEGEN_END(DTO)

#endif

GET /api/me 和 POST /api/logout 没有 request DTO。
它们的入参不是 JSON 请求体,而是 HTTP 请求头:Authorization: Bearer <token>

这里解释一下,token是用户的身份令牌,前端拿着用户的账号、密码到后端请求登录后,后端生成全局唯一的token返回给前端,后面用户的请求都会带着这个token。

所以Controller用的是 HEADER(...)来解析,不是 BODY_DTO(...)。

出参 DTO

当前 auth 相关有 2 个出参 DTO:

  • sports-equipment-architecture-project\backend\src\dto\response\UserProfileDto.hpp
  • sports-equipment-architecture-project\backend\src\dto\response\LoginResponseDto.hpp

UserProfileDto表示当前用户资料,用于:

  • /api/login 返回里的 user
  • /api/me 的整个响应体

它包含:

  • id
  • username
  • realName
  • role
  • studentNo
  • status

代码如下:

#ifndef USER_PROFILE_DTO_HPP
#define USER_PROFILE_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class UserProfileDto : public oatpp::DTO {
  DTO_INIT(UserProfileDto, DTO)

  DTO_FIELD(UInt64, id);
  DTO_FIELD(String, username);
  DTO_FIELD(String, realName);
  DTO_FIELD(String, role);
  DTO_FIELD(String, studentNo);
  DTO_FIELD(String, status);
};

#include OATPP_CODEGEN_END(DTO)

#endif

LoginResponseDto
表示登录成功后的响应。它包含:

  • token
  • user,类型是 UserProfileDto

代码如下:

#ifndef LOGIN_RESPONSE_DTO_HPP
#define LOGIN_RESPONSE_DTO_HPP

#include "dto/response/UserProfileDto.hpp"
#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class LoginResponseDto : public oatpp::DTO {
  DTO_INIT(LoginResponseDto, DTO)

  DTO_FIELD(String, token);
  DTO_FIELD(Object<UserProfileDto>, user);
};

#include OATPP_CODEGEN_END(DTO)

#endif
数据模型 model

auth 模块内部使用的实体模型是:

  • sports-equipment-architecture-project\backend\src\model\User.hpp

它尽量和数据库 user 表字段对应,包括:

  • id
  • username
  • passwordHash
  • realName
  • role
  • studentNo
  • phone
  • email
  • status
  • lastLoginAt
  • createdAt
  • updatedAt

这个模型是内部实体,不是对外返回结构。
对外返回时会裁剪成 UserProfileDto,所以不会把 passwordHash 这类敏感字段返回给前端。

数据访问层 DAO

auth 模块的 DAO 是:

  • sports-equipment-architecture-project\backend\src\dao\UserDAO.hpp
  • sports-equipment-architecture-project\backend\src\dao\UserDAO.cpp

它提供 3 个数据库访问能力:

std::optional<User> findByCredentials(const std::string& username,
                                      const std::string& password) const;

std::optional<User> findById(std::uint64_t id) const;

void updateLastLoginAt(std::uint64_t id) const;

它们的职责分别是:

  • findByCredentials(...)
    根据用户名和密码查询用户。
    SQL 里用 SHA2(password, 256) 和表里的 password_hash 比较。

  • findById(...)
    根据用户 id 查询完整用户信息。
    GET /api/me 最终就靠它把当前用户查出来。

  • updateLastLoginAt(...)
    登录成功后更新 last_login_at。

DAO 代码比较长,这里不整段贴了。路径就是:

  • sports-equipment-architecture-project\backend\src\dao\UserDAO.hpp
  • sports-equipment-architecture-project\backend\src\dao\UserDAO.cpp
业务处理层 service

auth 的 service 是:

  • UserService.hpp
  • UserService.cpp

它提供 4 个核心能力:

std::optional<User> authenticate(const std::string& username,
                                 const std::string& password) const;

std::string issueToken(std::uint64_t userId);

std::optional<User> getCurrentUser(const std::string& token) const;

bool revokeToken(const std::string& token);

它们的职责分别是:

  • authenticate(...)
    调用 UserDAO::findByCredentials(...) 做用户名密码校验。
  • issueToken(...)
    生成随机 token,把 token -> userId 记到内存会话表里,并更新最后登录时间。
  • getCurrentUser(...)
    根据 token 先从内存会话表里拿到 userId,再调用 UserDAO::findById(...) 查用户。
  • revokeToken(...)
    删除 token,让登录态失效,供 /api/logout 使用。

这层现在的 token 机制是:

  • 存在 unordered_map<std::string, std::uint64_t> 里
  • 内存 token
  • 服务重启后失效
  • 目前没有过期时间

代码文件路径:

  • sports-equipment-architecture-project\backend\src\service\UserService.hpp
  • sports-equipment-architecture-project\backend\src\service\UserService.cpp
控制器层 Controller

auth 的控制器现在统一在:

  • AuthController.hpp

它负责 3 个接口。

POST /api/login

  • 用 BODY_DTO(Object<LoginRequestDto>, loginRequestDto) 接 JSON 请求体
  • 取出 username/password
  • 调用 m_userService->authenticate(...)
  • 成功后调用 m_userService->issueToken(...)
  • 返回 LoginResponseDto

GET /api/me

  • 用 HEADER(String, authorization, "Authorization") 接请求头
  • 从 Authorization: Bearer xxx 里解析 token
  • 调 m_userService->getCurrentUser(token)
  • 返回 UserProfileDto

POST /api/logout

  • 同样从请求头里解析 token
  • 调 m_userService->revokeToken(token)
  • 成功返回JSON:
{"message":"logout success"}

这里还有一个辅助函数

static std::optional<std::string> parseBearerToken(...)

以供 /api/me 和 /api/logout 复用同一套请求头token解析逻辑

reservation 接口实现 & 讲解

当前 reservation 一共提供 7 个接口

ENDPOINT("POST", "/api/reservations", createReservation,
         HEADER(String, authorization, "Authorization"),
         BODY_DTO(Object<CreateReservationRequestDto>, requestDto))

ENDPOINT("GET", "/api/reservations/my", queryMyReservations,
         HEADER(String, authorization, "Authorization"),
         QUERY(UInt32, pageNo, "pageNo", 1),
         QUERY(UInt32, pageSize, "pageSize", 10),
         QUERY(String, status, "status", nullptr))

ENDPOINT("GET", "/api/reservations/my/{id}", getMyReservationById,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id))

ENDPOINT("POST", "/api/reservations/my/{id}/cancel", cancelMyReservation,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id),
         BODY_DTO(Object<CancelReservationRequestDto>, requestDto))

ENDPOINT("GET", "/api/admin/reservations", queryAdminReservations,
         HEADER(String, authorization, "Authorization"),
         QUERY(UInt32, pageNo, "pageNo", 1),
         QUERY(UInt32, pageSize, "pageSize", 10),
         QUERY(String, status, "status", nullptr))

ENDPOINT("POST", "/api/admin/reservations/{id}/approve", approveReservation,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id),
         BODY_DTO(Object<ReviewReservationRequestDto>, requestDto))

ENDPOINT("POST", "/api/admin/reservations/{id}/reject", rejectReservation,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id),
         BODY_DTO(Object<ReviewReservationRequestDto>, requestDto))

它们分别是:

  • POST /api/reservations
    学生提交预约申请
  • GET /api/reservations/my
    学生分页查询自己的预约记录,可按 status 筛选
  • GET /api/reservations/my/{id}
    学生查看自己某一条预约详情
  • POST /api/reservations/my/{id}/cancel
    学生取消自己的预约
  • GET /api/admin/reservations
    管理员分页查询预约申请,可按 status 筛选
  • POST /api/admin/reservations/{id}/approve
    管理员审核通过预约
  • POST /api/admin/reservations/{id}/reject
    管理员拒绝预约
入参 DTO

当前 reservation 相关有 3 个入参 DTO:

  • sports-equipment-architecture-project\backend\src\dto\request\CreateReservationRequestDto.hpp
  • sports-equipment-architecture-project\backend\src\dto\request\CancelReservationRequestDto.hpp
  • sports-equipment-architecture-project\backend\src\dto\request\ReviewReservationRequestDto.hpp

CreateReservationRequestDto
用于 POST /api/reservations 的请求体 JSON:

{
  "equipmentId": 1003,
  "reservationStartAt": "2026-06-06 10:00:00",
  "reservationEndAt": "2026-06-06 11:00:00",
  "quantity": 1,
  "requestNote": "课程练习使用"
}

代码如下:

#ifndef CREATE_RESERVATION_REQUEST_DTO_HPP
#define CREATE_RESERVATION_REQUEST_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class CreateReservationRequestDto : public oatpp::DTO {
  DTO_INIT(CreateReservationRequestDto, DTO)

  DTO_FIELD(UInt64, equipmentId);
  DTO_FIELD(String, reservationStartAt);
  DTO_FIELD(String, reservationEndAt);
  DTO_FIELD(UInt32, quantity);
  DTO_FIELD(String, requestNote);
};

#include OATPP_CODEGEN_END(DTO)

#endif

CancelReservationRequestDto
用于 POST /api/reservations/my/{id}/cancel 的请求体 JSON:

{
  "cancelReason": "临时不需要了"
}

代码如下:

#ifndef CANCEL_RESERVATION_REQUEST_DTO_HPP
#define CANCEL_RESERVATION_REQUEST_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class CancelReservationRequestDto : public oatpp::DTO {
  DTO_INIT(CancelReservationRequestDto, DTO)

  DTO_FIELD(String, cancelReason);
};

#include OATPP_CODEGEN_END(DTO)

#endif

ReviewReservationRequestDto
用于管理员审核通过/拒绝时提交审核备注:

{
  "reviewNote": "库存充足,审核通过"
}

代码如下:

#ifndef REVIEW_RESERVATION_REQUEST_DTO_HPP
#define REVIEW_RESERVATION_REQUEST_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class ReviewReservationRequestDto : public oatpp::DTO {
  DTO_INIT(ReviewReservationRequestDto, DTO)

  DTO_FIELD(String, reviewNote);
};

#include OATPP_CODEGEN_END(DTO)

#endif

另外,很多 reservation 接口还会从请求头里取:Authorization: Bearer <token>
因为 reservation 是登录后接口,要先知道“当前是谁在操作”。

出参 DTO

当前 reservation 相关有 2 个出参 DTO:

  • sports-equipment-architecture-project\backend\src\dto\response\ReservationDto.hpp
  • sports-equipment-architecture-project\backend\src\dto\response\ReservationPageDto.hpp

ReservationDto
表示单条预约记录,用于:

  • 创建预约成功后的返回
  • 查询单条预约详情
  • 管理员审核通过/拒绝后的返回
  • 分页列表里的单项

它包含:

  • id
  • reservationNo
  • studentUserId
  • studentUsername
  • studentRealName
  • equipmentId
  • equipmentCode
  • equipmentName
  • reservationStartAt
  • reservationEndAt
  • quantity
  • status
  • requestNote
  • reviewNote
  • cancelReason
  • reviewedAt
  • createdAt
  • updatedAt

代码如下:

#ifndef RESERVATION_DTO_HPP
#define RESERVATION_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class ReservationDto : public oatpp::DTO {
  DTO_INIT(ReservationDto, DTO)

  DTO_FIELD(UInt64, id);
  DTO_FIELD(String, reservationNo);
  DTO_FIELD(UInt64, studentUserId);
  DTO_FIELD(String, studentUsername);
  DTO_FIELD(String, studentRealName);
  DTO_FIELD(UInt64, equipmentId);
  DTO_FIELD(String, equipmentCode);
  DTO_FIELD(String, equipmentName);
  DTO_FIELD(String, reservationStartAt);
  DTO_FIELD(String, reservationEndAt);
  DTO_FIELD(UInt32, quantity);
  DTO_FIELD(String, status);
  DTO_FIELD(String, requestNote);
  DTO_FIELD(String, reviewNote);
  DTO_FIELD(String, cancelReason);
  DTO_FIELD(String, reviewedAt);
  DTO_FIELD(String, createdAt);
  DTO_FIELD(String, updatedAt);
};

#include OATPP_CODEGEN_END(DTO)

#endif

ReservationPageDto
表示分页查询结果。它包含:

  • pageNo
  • pageSize
  • total
  • items,类型是 ReservationDto 列表

代码如下:

#ifndef RESERVATION_PAGE_DTO_HPP
#define RESERVATION_PAGE_DTO_HPP

#include "dto/response/ReservationDto.hpp"
#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class ReservationPageDto : public oatpp::DTO {
  DTO_INIT(ReservationPageDto, DTO)

  DTO_FIELD(UInt32, pageNo);
  DTO_FIELD(UInt32, pageSize);
  DTO_FIELD(UInt64, total);
  DTO_FIELD(List<Object<ReservationDto>>, items);
};

#include OATPP_CODEGEN_END(DTO)

#endif

数据模型 model

reservation 模块内部使用了 3 个模型:

  • sports-equipment-architecture-project\backend\src\model\Reservation.hpp
  • sports-equipment-architecture-project\backend\src\model\ReservationView.hpp
  • sports-equipment-architecture-project\backend\src\model\ReservationPageResult.hpp

Reservation
尽量对应数据库 reservation 表字段,包括:

  • id
  • reservationNo
  • studentUserId
  • equipmentId
  • reservationStartAt
  • reservationEndAt
  • quantity
  • status
  • requestNote
  • reviewNote
  • cancelReason
  • reviewedBy
  • reviewedAt
  • createdAt
  • updatedAt

它更像“预约表实体”。

ReservationView
表示“查出来给上层使用的预约视图模型”。
因为分页列表和详情查询不仅要预约表字段,还要带出:

  • 学生用户名/姓名
  • 器材编码/名称

所以它比 Reservation 多了这些字段:

  • studentUsername
  • studentRealName
  • equipmentCode
  • equipmentName

ReservationPageResult
表示 service 层内部使用的分页结果对象,包含:

  • pageNo
  • pageSize
  • total
  • items,类型是 std::vector<ReservationView>

ReservationPageResult
表示 service 层内部使用的分页结果对象,包含:

  • pageNo
  • pageSize
  • total
  • items,类型是 std::vector<ReservationView>

代码不贴出来了。

数据模型 model

详见:

sports-equipment-architecture-project\backend\src\model\Reservation.hpp

sports-equipment-architecture-project\backend\src\model\ReservationPageResult.hpp

sports-equipment-architecture-project\backend\src\model\ReservationView.hpp

数据访问层 DAO

reservation 模块的 DAO 是:

  • sports-equipment-architecture-project\backend\src\dao\ReservationDAO.hpp
  • sports-equipment-architecture-project\backend\src\dao\ReservationDAO.cpp

它提供这些数据库访问能力:

std::uint64_t create(const Reservation& reservation) const;

std::uint64_t countMyReservations(
    std::uint64_t studentUserId,
    std::optional<std::string> status) const;

std::vector<ReservationView> queryMyReservationsPage(
    std::uint64_t studentUserId,
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

std::optional<ReservationView> getMyReservationById(
    std::uint64_t studentUserId,
    std::uint64_t reservationId) const;

std::uint64_t countAdminReservations(
    std::optional<std::string> status) const;

std::vector<ReservationView> queryAdminReservationsPage(
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

std::optional<ReservationView> getReservationById(
    std::uint64_t reservationId) const;

std::uint32_t sumReservedQuantityForOverlap(
    std::uint64_t equipmentId,
    const std::string& reservationStartAt,
    const std::string& reservationEndAt) const;

bool cancelMyReservation(std::uint64_t studentUserId,
                         std::uint64_t reservationId,
                         const std::string& cancelReason) const;

bool reviewReservation(std::uint64_t reservationId,
                       std::uint64_t reviewedBy,
                       const std::string& targetStatus,
                       const std::string& reviewNote) const;

它们的职责分别是:

  • create(...)
    插入一条新的预约申请。
  • countMyReservations(...)
    统计某个学生在指定状态筛选下的预约总数。
  • queryMyReservationsPage(...)
    分页查询“我的预约列表”,并连表带出器材和学生展示信息。
  • getMyReservationById(...)
    查询“我自己的某一条预约详情”。
  • countAdminReservations(...)
    统计管理员视角下所有预约记录的总数。
  • queryAdminReservationsPage(...)
    分页查询管理员预约列表。
  • getReservationById(...)
    按预约 id 查单条记录,管理员审核时会用到。
  • sumReservedQuantityForOverlap(...)
    统计同一器材、同一时间段重叠下已占用的预约数量,用于预约/审核前的库存校验。
  • cancelMyReservation(...)
    把学生自己的预约更新成 CANCELED。
  • reviewReservation(...)
    把预约更新成 APPROVED 或 REJECTED,同时写入 review_note、reviewed_by、reviewed_at。

DAO 代码比较长,这里不整段贴了。路径就是:

  • sports-equipment-architecture-project\backend\src\dao\ReservationDAO.hpp
  • sports-equipment-architecture-project\backend\src\dao\ReservationDAO.cpp
业务处理层 service

reservation 的 service 是:

  • sports-equipment-architecture-project\backend\src\service\ReservationService.hpp
  • sports-equipment-architecture-project\backend\src\service\ReservationService.cpp

它提供这些核心能力:

ReservationView createReservation(std::uint64_t studentUserId,
                                  std::uint64_t equipmentId,
                                  const std::string& reservationStartAt,
                                  const std::string& reservationEndAt,
                                  std::uint32_t quantity,
                                  const std::string& requestNote) const;

ReservationPageResult queryMyPage(
    std::uint64_t studentUserId,
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

ReservationPageResult queryAdminPage(
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

std::optional<ReservationView> getMyById(std::uint64_t studentUserId,
                                         std::uint64_t reservationId) const;

std::optional<ReservationView> getById(std::uint64_t reservationId) const;

std::optional<ReservationView> cancelMyReservation(
    std::uint64_t studentUserId,
    std::uint64_t reservationId,
    const std::string& cancelReason) const;

std::optional<ReservationView> approveReservation(
    std::uint64_t adminUserId,
    std::uint64_t reservationId,
    const std::string& reviewNote) const;

std::optional<ReservationView> rejectReservation(
    std::uint64_t adminUserId,
    std::uint64_t reservationId,
    const std::string& reviewNote) const;

另外它还定义了一个业务异常类型:

class ReservationServiceError : public std::runtime_error

这个异常里带有 statusCode,作用是:

  • service 层发现业务规则错误时抛出
  • controller 层再把它映射成 400/404/409 等 HTTP 响应

各方法职责如下:

  • createReservation(...)
    负责学生提交预约时的业务校验和创建逻辑。
    它会检查:

    • studentUserId/equipmentId 是否合法
    • quantity > 0
    • 时间格式是否是 YYYY-MM-DD HH:MM:SS
    • 结束时间必须晚于开始时间
    • requestNote 长度不能超过 255
    • 器材必须存在
    • 器材状态必须是 AVAILABLE
    • 申请数量不能超过当前 available_stock
    • 同时段已占用数量加上本次申请不能超过当前可用库存
  • queryMyPage(...)
    查询学生自己的预约分页结果,可按状态筛选。

  • queryAdminPage(...)
    查询管理员视角下的预约分页结果,可按状态筛选。

  • getMyById(...)
    查询学生自己的某条预约详情。

  • getById(...)
    按预约 id 查记录,给管理员审核场景用。

  • cancelMyReservation(...)
    取消学生自己的预约。
    只允许取消:

    • PENDING
    • APPROVED
  • approveReservation(...)
    管理员审核通过预约。
    它会再次检查:

    • 预约必须存在
    • 状态必须是 PENDING
    • 器材必须存在
    • 器材状态必须仍然是 AVAILABLE
    • 审批时该时段库存仍然不能超卖
  • rejectReservation(...)
    管理员拒绝预约。
    只允许拒绝 PENDING 状态。

控制器层 Controller

reservation 的控制器统一在:

  • ReservationController.hpp

它负责这 7 个接口。

学生侧:

  • POST /api/reservations
  • GET /api/reservations/my
  • GET /api/reservations/my/{id}
  • POST /api/reservations/my/{id}/cancel

管理员侧:

  • GET /api/admin/reservations
  • POST /api/admin/reservations/{id}/approve
  • POST /api/admin/reservations/{id}/reject

这里有两个辅助鉴权函数:

std::shared_ptr<OutgoingResponse> requireActiveStudent(...)
std::shared_ptr<OutgoingResponse> requireActiveAdmin(...)

它们负责:

  • 从 Authorization: Bearer xxx 里取 token
  • 调 m_userService->getCurrentUser(token) 找当前用户
  • 校验用户必须是 ACTIVE
  • 再分别校验:
    • 学生接口要求 role == STUDENT
    • 管理员接口要求 role == ADMIN

如果不满足,就直接返回:

  • 401
  • 403

另外 controller 里还有两个辅助函数:

static std::optional<std::string> parseBearerToken(...)
static Status statusFromError(const ReservationServiceError& error)

作用分别是:

  • 复用 Bearer token 解析逻辑
  • 复用“业务异常 -> HTTP 状态码”的映射逻辑

学生侧接口流程大致是:

  • createReservation
    接 CreateReservationRequestDto
    -> 取当前学生
    -> 调 m_reservationService->createReservation(...)
    -> 返回 ReservationDto

  • queryMyReservations
    接收 pageNo/pageSize/status
    -> 调 queryMyPage(...)
    -> 返回 ReservationPageDto

  • getMyReservationById
    接路径参数 id
    -> 调 getMyById(...)
    -> 返回单条 ReservationDto

  • cancelMyReservation
    接路径参数 id 和 CancelReservationRequestDto
    -> 调 cancelMyReservation(...)
    -> 返回取消后的预约结果

管理员侧流程大致是:

  • queryAdminReservations
    接 pageNo/pageSize/status
    -> 调 queryAdminPage(...)
    -> 返回管理员预约分页列表

  • approveReservation
    接路径参数 id 和 ReviewReservationRequestDto
    -> 调 approveReservation(...)
    -> 返回审核后的预约结果

  • rejectReservation
    接路径参数 id 和 ReviewReservationRequestDto
    -> 调 rejectReservation(...)
    -> 返回审核后的预约结果

borrow 接口实现 & 讲解

当前 borrow 一共提供 6 个接口

ENDPOINT("POST", "/api/admin/borrows", createBorrow,
         HEADER(String, authorization, "Authorization"),
         BODY_DTO(Object<CreateBorrowRequestDto>, requestDto))

ENDPOINT("GET", "/api/borrows/my", queryMyBorrows,
         HEADER(String, authorization, "Authorization"),
         QUERY(UInt32, pageNo, "pageNo", 1),
         QUERY(UInt32, pageSize, "pageSize", 10),
         QUERY(String, status, "status", nullptr))

ENDPOINT("GET", "/api/borrows/my/{id}", getMyBorrowById,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id))

ENDPOINT("GET", "/api/admin/borrows", queryAdminBorrows,
         HEADER(String, authorization, "Authorization"),
         QUERY(UInt32, pageNo, "pageNo", 1),
         QUERY(UInt32, pageSize, "pageSize", 10),
         QUERY(String, status, "status", nullptr))

ENDPOINT("GET", "/api/admin/borrows/{id}", getBorrowById,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id))

ENDPOINT("POST", "/api/admin/borrows/{id}/return", returnBorrow,
         HEADER(String, authorization, "Authorization"),
         PATH(UInt64, id),
         BODY_DTO(Object<ReturnBorrowRequestDto>, requestDto))

它们分别是:

  • POST /api/admin/borrows
    管理员办理借出
  • GET /api/borrows/my
    学生分页查询自己的借用记录,可按 status 筛选
  • GET /api/borrows/my/{id}
    学生查看自己某一条借用详情
  • GET /api/admin/borrows
    管理员分页查询借用记录,可按 status 筛选
  • GET /api/admin/borrows/{id}
    管理员查看某一条借用详情
  • POST /api/admin/borrows/{id}/return
    管理员办理归还
入参 DTO

当前 borrow 相关有 2 个入参 DTO:

  • sports-equipment-architecture-project\backend\src\dto\request\CreateBorrowRequestDto.hpp
  • sports-equipment-architecture-project\backend\src\dto\request\ReturnBorrowRequestDto.hpp

CreateBorrowRequestDto
用于 POST /api/admin/borrows 的请求体 JSON:

{
  "reservationId": 2008,
  "borrowedAt": "2026-06-04 15:00:00",
  "dueAt": "2026-06-06 18:00:00"
}

代码如下:

#ifndef CREATE_BORROW_REQUEST_DTO_HPP
#define CREATE_BORROW_REQUEST_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class CreateBorrowRequestDto : public oatpp::DTO {
  DTO_INIT(CreateBorrowRequestDto, DTO)

  DTO_FIELD(UInt64, reservationId);
  DTO_FIELD(String, borrowedAt);
  DTO_FIELD(String, dueAt);
};

#include OATPP_CODEGEN_END(DTO)

#endif

ReturnBorrowRequestDto
用于 POST /api/admin/borrows/{id}/return 的请求体 JSON:

{
  "returnedAt": "2026-06-05 10:30:00",
  "returnNote": "器材完好归还"
}

代码如下:

#ifndef RETURN_BORROW_REQUEST_DTO_HPP
#define RETURN_BORROW_REQUEST_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class ReturnBorrowRequestDto : public oatpp::DTO {
  DTO_INIT(ReturnBorrowRequestDto, DTO)

  DTO_FIELD(String, returnedAt);
  DTO_FIELD(String, returnNote);
};

#include OATPP_CODEGEN_END(DTO)

#endif

另外,所有 borrow 接口也都需要登录态,请求头里都要带:Authorization: Bearer <token>

因为 borrow 是受限业务接口,要先知道当前是谁在操作。

出参 DTO

当前 borrow 相关有 2 个出参 DTO:

  • sports-equipment-architecture-project\backend\src\dto\response\BorrowRecordDto.hpp
  • sports-equipment-architecture-project\backend\src\dto\response\BorrowRecordPageDto.hpp

BorrowRecordDto
表示单条借用记录,用于:

  • 办理借出成功后的返回
  • 学生/管理员查看单条借用详情
  • 办理归还后的返回
  • 分页列表里的单项

它包含:

  • id
  • borrowNo
  • reservationId
  • reservationNo
  • studentUserId
  • studentUsername
  • studentRealName
  • equipmentId
  • equipmentCode
  • equipmentName
  • quantity
  • borrowedBy
  • borrowedAt
  • dueAt
  • receivedBy
  • returnedAt
  • status
  • returnNote
  • createdAt
  • updatedAt

代码如下:

#ifndef BORROW_RECORD_DTO_HPP
#define BORROW_RECORD_DTO_HPP

#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class BorrowRecordDto : public oatpp::DTO {
  DTO_INIT(BorrowRecordDto, DTO)

  DTO_FIELD(UInt64, id);
  DTO_FIELD(String, borrowNo);
  DTO_FIELD(UInt64, reservationId);
  DTO_FIELD(String, reservationNo);
  DTO_FIELD(UInt64, studentUserId);
  DTO_FIELD(String, studentUsername);
  DTO_FIELD(String, studentRealName);
  DTO_FIELD(UInt64, equipmentId);
  DTO_FIELD(String, equipmentCode);
  DTO_FIELD(String, equipmentName);
  DTO_FIELD(UInt32, quantity);
  DTO_FIELD(UInt64, borrowedBy);
  DTO_FIELD(String, borrowedAt);
  DTO_FIELD(String, dueAt);
  DTO_FIELD(UInt64, receivedBy);
  DTO_FIELD(String, returnedAt);
  DTO_FIELD(String, status);
  DTO_FIELD(String, returnNote);
  DTO_FIELD(String, createdAt);
  DTO_FIELD(String, updatedAt);
};

#include OATPP_CODEGEN_END(DTO)

#endif

BorrowRecordPageDto
表示分页查询结果。它包含:

  • pageNo
  • pageSize
  • total
  • items,类型是 BorrowRecordDto 列表

代码如下:

#ifndef BORROW_RECORD_PAGE_DTO_HPP
#define BORROW_RECORD_PAGE_DTO_HPP

#include "dto/response/BorrowRecordDto.hpp"
#include "oatpp/core/Types.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class BorrowRecordPageDto : public oatpp::DTO {
  DTO_INIT(BorrowRecordPageDto, DTO)

  DTO_FIELD(UInt32, pageNo);
  DTO_FIELD(UInt32, pageSize);
  DTO_FIELD(UInt64, total);
  DTO_FIELD(List<Object<BorrowRecordDto>>, items);
};

#include OATPP_CODEGEN_END(DTO)

#endif
数据访问层 DAO

borrow 模块的 DAO 是:

  • BorrowRecordDAO.hpp
  • BorrowRecordDAO.cpp

它提供这些数据库访问能力:

std::optional<std::uint64_t> createFromApprovedReservation(
    std::uint64_t reservationId,
    std::uint64_t borrowedBy,
    const std::string& borrowedAt,
    const std::string& dueAt) const;

std::optional<std::uint64_t> returnBorrowRecord(
    std::uint64_t borrowRecordId,
    std::uint64_t receivedBy,
    const std::string& returnedAt,
    const std::string& returnNote) const;

std::uint64_t countMyBorrows(std::uint64_t studentUserId,
                             std::optional<std::string> status) const;

std::vector<BorrowRecordView> queryMyBorrowsPage(
    std::uint64_t studentUserId,
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

std::optional<BorrowRecordView> getMyBorrowById(
    std::uint64_t studentUserId,
    std::uint64_t borrowRecordId) const;

std::uint64_t countAdminBorrows(std::optional<std::string> status) const;

std::vector<BorrowRecordView> queryAdminBorrowsPage(
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

std::optional<BorrowRecordView> getBorrowById(
    std::uint64_t borrowRecordId) const;

它们的职责分别是:

  • createFromApprovedReservation(...)
    对已审核通过的预约办理借出。
    它会在事务里同时做三件事:

    • 插入 borrow_record
    • 更新 reservation.status = 'BORROWED'
    • 扣减 equipment.available_stock
  • returnBorrowRecord(...)
    办理归还。
    它会在事务里同时做三件事:

    • 更新 borrow_record.status = 'RETURNED'
    • 更新 reservation.status = 'COMPLETED'
    • 回补 equipment.available_stock
  • countMyBorrows(...)
    统计某个学生在指定状态筛选下的借用总数。

  • queryMyBorrowsPage(...)
    分页查询“我的借用列表”,并连表带出学生、预约、器材展示信息。

  • getMyBorrowById(...)
    查询“我自己的某一条借用详情”。

  • countAdminBorrows(...)
    统计管理员视角下所有借用记录的总数。

  • queryAdminBorrowsPage(...)
    分页查询管理员借用列表。

  • getBorrowById(...)
    按借用记录 id 查单条借用详情。

DAO 代码比较长,这里不整段贴了。路径就是:

sports-equipment-architecture-project\backend\src\dao\BorrowRecordDAO.hpp

sports-equipment-architecture-project\backend\src\dao\BorrowRecordDAO.cpp

数据模型 model

borrow 模块内部使用了 3 个模型:

  • BorrowRecord.hpp
  • BorrowRecordView.hpp
  • BorrowRecordPageResult.hpp

目录:sports-equipment-architecture-project\backend\src\model

BorrowRecord
尽量对应数据库 borrow_record 表字段,包括:

  • id
  • borrowNo
  • reservationId
  • studentUserId
  • equipmentId
  • quantity
  • borrowedBy
  • borrowedAt
  • dueAt
  • receivedBy
  • returnedAt
  • status
  • returnNote
  • createdAt
  • updatedAt

它更像“借用记录表实体”。

BorrowRecordView
表示“查出来给上层使用的借用视图模型”。
因为列表和详情不仅要借用表字段,还要带出:

  • 预约编号
  • 学生用户名/姓名
  • 器材编码/名称

所以它比 BorrowRecord 多了这些展示字段:

  • reservationNo
  • studentUsername
  • studentRealName
  • equipmentCode
  • equipmentName

BorrowRecordPageResult
表示 service 层内部使用的分页结果对象,包含:

  • pageNo
  • pageSize
  • total
  • items,类型是 std::vector<BorrowRecordView>
业务处理层 service

borrow 的 service 是:

  • BorrowRecordService.hpp
  • BorrowRecordService.cpp

它提供这些核心能力:

BorrowRecordView createBorrowRecord(std::uint64_t adminUserId,
                                    std::uint64_t reservationId,
                                    const std::string& borrowedAt,
                                    const std::string& dueAt) const;

BorrowRecordPageResult queryMyPage(
    std::uint64_t studentUserId,
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

BorrowRecordPageResult queryAdminPage(
    std::uint32_t pageNo,
    std::uint32_t pageSize,
    std::optional<std::string> status) const;

std::optional<BorrowRecordView> getMyById(std::uint64_t studentUserId,
                                          std::uint64_t borrowRecordId) const;

std::optional<BorrowRecordView> getById(std::uint64_t borrowRecordId) const;

std::optional<BorrowRecordView> returnBorrowRecord(
    std::uint64_t adminUserId,
    std::uint64_t borrowRecordId,
    const std::string& returnedAt,
    const std::string& returnNote) const;

另外它还定义了一个业务异常类型:

class BorrowRecordServiceError : public std::runtime_error

这个异常里带有 statusCode,作用是:

  • service 层发现业务规则错误时抛出
  • controller 层再把它映射成 400/404/409 等 HTTP 响应

各方法职责如下:

  • createBorrowRecord(...)
    负责管理员办理借出时的业务校验和创建逻辑。
    它会检查:

    • reservationId/adminUserId 是否合法
    • borrowedAt/dueAt 时间格式是否合法
    • dueAt 必须晚于 borrowedAt
    • 预约必须存在
    • 预约状态必须是 APPROVED
  • queryMyPage(...)
    查询学生自己的借用分页结果,可按状态筛选。

  • queryAdminPage(...)
    查询管理员视角下的借用分页结果,可按状态筛选。

  • getMyById(...)
    查询学生自己的某条借用详情。

  • getById(...)
    按借用记录 id 查记录,给管理员查看详情用。

  • returnBorrowRecord(...)
    负责管理员办理归还。
    它会检查:

    • 借用记录必须存在
    • 当前状态必须是 BORROWING
    • returnedAt 时间格式是否合法
    • returnedAt 不能早于 borrowedAt

另外,borrow 模块目前允许的状态筛选值包括:

  • BORROWING
  • RETURNED
  • OVERDUE
  • DAMAGED_PENDING
  • CLOSED
控制器层 Controller

borrow 的控制器统一在:

  • BorrowRecordController.hpp

它负责这 6 个接口。

学生侧:

  • GET /api/borrows/my
  • GET /api/borrows/my/{id}

管理员侧:

  • POST /api/admin/borrows
  • GET /api/admin/borrows
  • GET /api/admin/borrows/{id}
  • POST /api/admin/borrows/{id}/return

这里有两个辅助鉴权函数:

std::shared_ptr<OutgoingResponse> requireActiveStudent(...)
std::shared_ptr<OutgoingResponse> requireActiveAdmin(...)

它们负责:

  • 从 Authorization: Bearer xxx 里取 token
  • 调 m_userService->getCurrentUser(token) 找当前用户
  • 校验用户必须是 ACTIVE
  • 再分别校验:
    • 学生接口要求 role == STUDENT
    • 管理员接口要求 role == ADMIN

如果不满足,就直接返回:

  • 401
  • 403

另外 controller 里也有两个辅助函数:

static std::optional<std::string> parseBearerToken(...)
static Status statusFromError(const BorrowRecordServiceError& error)

学生侧接口流程大致是:

  • queryMyBorrows
    接收 pageNo/pageSize/status
    -> 调 queryMyPage(...)
    -> 返回 BorrowRecordPageDto

  • getMyBorrowById
    接路径参数 id
    -> 调 getMyById(...)
    -> 返回单条 BorrowRecordDto

管理员侧流程大致是:

  • createBorrow
    接 CreateBorrowRequestDto
    -> 取当前管理员
    -> 调 createBorrowRecord(...)
    -> 返回 BorrowRecordDto

  • queryAdminBorrows
    接 pageNo/pageSize/status
    -> 调 queryAdminPage(...)
    -> 返回管理员借用分页列表

  • getBorrowById
    接路径参数 id
    -> 调 getById(...)
    -> 返回单条 BorrowRecordDto

  • returnBorrow
    接路径参数 id 和 ReturnBorrowRequestDto
    -> 调 returnBorrowRecord(...)
    -> 返回归还后的借用记录

更新main.cpp和CMakeLists.txt

完成上述模块后在main.cpp给各个模块注册服务和路由接口,以及在CMakeLists.txt添加源文件

写前端代码

由于本人不会前端,所以基本靠ai coding实现,也无法亲自给出代码详解。

但前端目前代码实现较为简单,在这一步实现了基本框架和代码,但还未具备沟通后端的能力。

前后端联调过程中会补齐这个能力。

所以建议朋友们先参照下方目录+注解和sports-equipment-architecture-project\frontend\前端开发日志.md去阅读代码,其中前端开发日志.md记录了前端代码的完整实现思路,十分建议阅读。

前端目录结构是:

  • pages/ 放页面入口

  • assets/js/ 放逻辑层

  • api.js 抽数据来源

  • mock-api.js 保证先不联调也能完整走流程

  • shell.js 统一业务页面壳层

frontend/
├─ index.html                         前端入口页,用来把访问者引导到登录页或默认业务页。
├─ 前端开发日志.md                     记录前端从方案设计到编码落地的完整开发过程与思路变化。
├─ assets/
│  ├─ css/
│  │  └─ app.css                     全局样式文件,统一页面布局、颜色、表格、表单和组件外观。
│  └─ js/
│     ├─ config.js                   前端运行配置文件,主要定义 API 基地址和运行模式等常量。
│     ├─ storage.js                  封装 localStorage/sessionStorage 的读写操作。
│     ├─ mock-data.js                存放前端开发期使用的模拟业务数据。
│     ├─ mock-api.js                 用模拟数据实现一套假接口,供前端在未联调时使用。
│     ├─ auth.js                     管理登录态,包括 token、当前用户信息的保存、读取和清除。
│     ├─ api.js                      封装真实接口请求,统一处理 fetch、请求头、token 和响应解析。
│     ├─ guard.js                    做页面访问控制,检查是否登录以及用户角色是否有权限进入页面。
│     ├─ shell.js                    构建页面公共外壳,如导航栏、页头、角色菜单和退出登录入口。
│     ├─ utils.js                    提供通用工具函数,如时间格式化、状态映射、参数解析等。
│     ├─ ui.js                       封装常用界面交互逻辑,如提示框、空状态、加载态和弹窗辅助。
│     ├─ login.js                    登录页脚本,负责提交登录请求、保存登录态并完成页面跳转。
│     ├─ student-equipment.js        学生器材列表页脚本,负责分类筛选、分页展示和发起预约入口。
│     ├─ student-reservations.js     学生预约页脚本,负责展示我的预约、查看详情和取消预约。
│     ├─ student-borrows.js          学生借用页脚本,负责展示我的借用记录和查看借用详情。
│     ├─ admin-reservations.js       管理员预约审核页脚本,负责加载预约列表并执行通过/拒绝操作。
│     └─ admin-borrows.js            管理员借还管理页脚本,负责办理借出、查看借用记录和办理归还。
└─ pages/
   ├─ login.html                     登录页面,提供用户名密码输入和登录入口。
   ├─ student-equipment.html         学生器材浏览页面,展示器材列表、分类筛选和预约入口。
   ├─ student-reservations.html      学生预约管理页面,展示个人预约记录及其状态。
   ├─ student-borrows.html           学生借用记录页面,展示个人借用历史和当前借用情况。
   ├─ admin-reservations.html        管理员预约审核页面,处理预约申请的查询、审批和拒绝。
   └─ admin-borrows.html             管理员借还管理页面,处理借出登记、借用查询和归还登记。

前后端联调

什么是CORS

现在前后端如果是这样打开的:

  • 前端:http://127.0.0.1:5500
  • 后端:http://127.0.0.1:8000

虽然都是本机,但在浏览器眼里,这已经是两个不同源了,因为端口不同。这叫 跨域请求

浏览器默认不会随便让一个网页去请求另一个源的接口,除非后端补充 CORS。

CORS=Cross-Origin Resource Sharing,即跨源资源共享。

因为前端对后端做了很多这种请求:

fetch("http://127.0.0.1:8000/api/login", ...)
fetch("http://127.0.0.1:8000/api/equipment", ...)
fetch("http://127.0.0.1:8000/api/reservations", ...)

有的还会带Authorization: Bearer <token>

只要前端页面不是和后端同源,浏览器就会先检查后端有没有返回正确的 CORS 头。
如果没有,控制台就会报跨域错误,请求在浏览器层就被挡掉了。

所以我们先实现最小 CORS,让前后端能基本跑通。后续再补充更严格的机制。

补充后端CORS

oatpp提供了现成的CORS拦截器:

  • AllowOptionsGlobal:统一处理浏览器的 OPTIONS 预检请求
  • AllowCorsGlobal:统一给响应补 CORS 头

我们选择把拦截器加在connectionHandler上面,为什么?因为connectionHandler 是全局性的连接处理器,所有的请求都要经过它。 

AllowOptionsGlobal 给 connectionHandler 加一个 请求拦截器,它专门处理浏览器发来的 OPTIONS 请求。浏览器在真正发送跨域请求之前,会先发一个带有OPTIONS 方法的HTTP请求,向后端征求同意。AllowOptionsGlobal 做的事情就是,如果收到OPTIONS就不往controller走,直接向浏览器回应“同意继续”。

AllowCorsGlobal 会给所有后端发出的 HTTP 响应里统一加上 CORS 相关响应头:

  • Access-Control-Allow-Origin: *
  • Access-Control-Allow-Methods: GET, POST, OPTIONS
  • Access-Control-Allow-Headers: Content-Type, Authorization
  • Access-Control-Max-Age: 86400

需要更改的是sports-equipment-architecture-project\backend\src\app\AppComponent.hpp中的代码

更新后的代码如下:

#ifndef APP_COMPONENT_HPP
#define APP_COMPONENT_HPP

#include "oatpp/core/macro/component.hpp"
#include "oatpp/network/ConnectionHandler.hpp"
#include "oatpp/network/tcp/server/ConnectionProvider.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "oatpp/web/server/HttpConnectionHandler.hpp"
#include "oatpp/web/server/HttpRouter.hpp"
#include "oatpp/web/server/interceptor/AllowCorsGlobal.hpp"

class AppComponent {
public:
  // Provide the TCP listener used by the HTTP server.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>,
                         serverConnectionProvider)
  ([] {
    return oatpp::network::tcp::server::ConnectionProvider::createShared(
        {"0.0.0.0", 8000, oatpp::network::Address::IP_4});
  }());

  // Store route registrations such as GET /health.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>,
                         httpRouter)
  ([] {
    return oatpp::web::server::HttpRouter::createShared();
  }());

  // Convert HTTP requests into controller dispatches through the router.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ConnectionHandler>,
                         serverConnectionHandler)
  ([] {
    OATPP_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, router);
    auto connectionHandler =
        oatpp::web::server::HttpConnectionHandler::createShared(router);

    connectionHandler->addRequestInterceptor(
        std::make_shared<oatpp::web::server::interceptor::AllowOptionsGlobal>());
    connectionHandler->addResponseInterceptor(
        std::make_shared<oatpp::web::server::interceptor::AllowCorsGlobal>(
            "*",
            "GET, POST, OPTIONS",
            "Content-Type, Authorization",
            "86400"));

    return connectionHandler;
  }());

  // Serialize controller responses to JSON.
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::data::mapping::ObjectMapper>,
                         apiObjectMapper)
  ([] {
    return oatpp::parser::json::mapping::ObjectMapper::createShared();
  }());
};

#endif
前端收口

只需要更改两个文件中的代码,去适配后端api:

  • sports-equipment-architecture-project\frontend\assets\js\config.js
  • sports-equipment-architecture-project\frontend\assets\js\api.js

代码太长就不贴出来了。

主要做了如下改变:

  1. 把前端默认数据源从 mock 切到真实后端。
    把 USE_MOCK 从 true 改成了 false,所以现在页面默认会直接请求 http://127.0.0.1:8000。

  2. 把真实联调更需要的异常处理补到 API 层。
    在 api.js 里统一补了两件事:

    • 后端没启动时,给出明确提示:无法连接后端服务...
    • 受保护接口返回 401 时,自动清掉本地登录态并跳回 login.html
  3. 保持页面脚本层基本不动。
    student-*、admin-* 这些页面脚本不需要推翻,只要切换 API 通道就能工作。

第一版项目完成,运行!

  • 保证所需环境已安装好,如MySQL、oatpp、cmake等软件或链接库!
  • 保证sports-equipment-architecture-project\backend\config\database.conf中写的和你自己的数据库连接的账号、密码一致!
  • 保证CMakeLists.txt和main.cpp已是最新,都注册了所有已实现的接口!
  • 保证已运行seed.sql,数据库里已有初始化数据!

依赖配置细节可以阅读sports-equipment-architecture-project\docs\项目依赖与本地运行说明.md

接下来:

  1. 进入sports-equipment-architecture-project\scripts
  2. 执行build-backend.cmd编译后端
  3. 执行run-backend.cmd运行后端
  4. 执行run-frontend.cmd运行前端
  5. 在浏览器网页输入http://127.0.0.1:5500/pages/login.html并访问,即可成功运行

当前版本还是一个非常原始的版本,后续还会不断进行迭代、优化和补充,请读者们持续关注。

未完待续……

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐