| 技术 | 版本 | 用途 |
|---|---|---|
| Java | 17 | 开发语言 |
| Spring Boot | 3.5.15 | 应用框架 |
| Spring Boot Starter Web | 3.5.15 | Web MVC + 嵌入式 Tomcat |
| MyBatis Spring Boot Starter | 3.0.5 | ORM 框架 |
| MySQL Connector/J | (spring-boot-starter-parent 管理) | MySQL JDBC 驱动 |
| SpringDoc OpenAPI | 2.8.16 | API 文档 (Swagger UI) |
| Maven | (wrapper 3.9+) | 构建工具 |
| MySQL | 8.0 | 数据库 |
构建命令:./mvnw spring-boot:run — 启动开发服务器(端口 8080, context-path /)
server/
├── pom.xml # Maven 项目描述文件
├── sql/
│ └── init.sql # 数据库初始化脚本(建库建表+种子数据)
├── src/main/java/space/anyi/petServiceManagementSystem/server/
│ ├── ServerApplication.java # Spring Boot 启动入口
│ │
│ ├── config/ # 配置类
│ │ ├── CorsConfig.java # 跨域 CORS 配置,允许 credentials + 任意 origin pattern
│ │ ├── WebMvcConfig.java # 注册 AuthInterceptor 和 AdminInterceptor 拦截器
│ │ ├── PasswordConfig.java # MD5 + SALT 密码加密组件(@Component)
│ │ └── GlobalExceptionHandler.java # @RestControllerAdvice 全局异常处理
│ │
│ ├── interceptor/ # 拦截器
│ │ ├── AuthInterceptor.java # HttpSession 登录认证(排除 /auth/**, /swagger-ui/**)
│ │ └── AdminInterceptor.java # admin 角色校验(匹配 /admin/**)
│ │
│ ├── common/ # 通用基础设施
│ │ ├── R.java # 统一响应 R<T> {code, message, data}
│ │ ├── ResultCode.java # 状态码常量 (200/400/401/403/404/409/500)
│ │ ├── BusinessException.java # 业务异常 (code=400)
│ │ ├── UnauthorizedException.java # 未授权异常 (code=401)
│ │ ├── ForbiddenException.java # 无权限异常 (code=403)
│ │ ├── ResourceNotFoundException.java # 资源不存在异常 (code=404)
│ │ └── PageVo.java # 分页结果封装 {data, total, pageNum, pageSize}
│ │
│ ├── entity/ # 数据实体(对应数据库表)
│ │ ├── User.java # 用户 user 表
│ │ ├── Pet.java # 宠物 pet 表
│ │ ├── VaccinationRecord.java # 疫苗记录 vaccination_record 表
│ │ ├── DewormingRecord.java # 驱虫记录 deworming_record 表
│ │ ├── ServicePackage.java # 套餐 package 表(避 java.lang.Package 冲突)
│ │ ├── BoardingRoom.java # 寄养房型 boarding_room 表
│ │ ├── BoardingRoomStatus.java # 房态 boarding_room_status 表
│ │ ├── Appointment.java # 预约订单 appointment 表
│ │ └── Schedule.java # 排班 schedule 表
│ │
│ ├── dto/ # 请求参数 DTO
│ │ ├── auth/
│ │ │ ├── RegisterDto.java # 注册 (account, password, username) + @Valid 校验
│ │ │ └── LoginDto.java # 登录 (account, password)
│ │ ├── user/
│ │ │ ├── UserDto.java # 新增/编辑用户
│ │ │ ├── UpdateUserPasswordDto.java # 修改密码 (id, oldPassword, newPassword)
│ │ │ ├── UpdateUserAvatarDto.java # 修改头像 (id, avatar)
│ │ │ └── UpdateUserStatusDto.java # 启用/禁用 (id, enable)
│ │ ├── pet/
│ │ │ ├── PetDto.java # 新增/编辑宠物
│ │ │ ├── VaccinationDto.java # 新增疫苗记录
│ │ │ └── DewormingDto.java # 新增驱虫记录
│ │ ├── appointment/
│ │ │ ├── AppointmentDto.java # 提交预约
│ │ │ ├── ReviewDto.java # 审核 (action + rejectReason)
│ │ │ └── HandleCancelDto.java # 处理取消 (action)
│ │ ├── room/
│ │ │ └── RoomStatusDto.java # 修改房态 (date, status)
│ │ ├── servicePackage/
│ │ │ └── PackageDto.java # 新增/编辑套餐
│ │ └── schedule/
│ │ └── ScheduleDto.java # 新增排班
│ │
│ ├── vo/ # 返回结果 VO
│ │ ├── user/
│ │ │ ├── UserVo.java # 用户基本信息
│ │ │ └── AdminUserDetailVo.java # 管理员查看用户详情(含宠物列表)
│ │ ├── pet/
│ │ │ ├── PetVo.java # 宠物信息(含疫苗/驱虫记录列表)
│ │ │ ├── VaccinationVo.java # 疫苗记录
│ │ │ └── DewormingVo.java # 驱虫记录
│ │ ├── appointment/
│ │ │ └── AppointmentVo.java # 预约订单详情
│ │ ├── room/
│ │ │ ├── BoardingRoomVo.java # 寄养房型信息
│ │ │ └── RoomStatusVo.java # 房态(含 appointmentId)
│ │ ├── servicePackage/
│ │ │ └── PackageVo.java # 套餐信息
│ │ └── schedule/
│ │ └── ScheduleVo.java # 排班信息
│ │
│ ├── mapper/ # MyBatis Mapper 接口
│ │ ├── UserMapper.java
│ │ ├── PetMapper.java
│ │ ├── VaccinationRecordMapper.java
│ │ ├── DewormingRecordMapper.java
│ │ ├── PackageMapper.java
│ │ ├── BoardingRoomMapper.java
│ │ ├── BoardingRoomStatusMapper.java
│ │ ├── AppointmentMapper.java
│ │ └── ScheduleMapper.java
│ │
│ ├── service/ # 服务接口
│ │ ├── AuthService.java
│ │ ├── UserService.java
│ │ ├── PetService.java
│ │ ├── AppointmentService.java
│ │ ├── BoardingRoomService.java
│ │ ├── PackageService.java
│ │ ├── ScheduleService.java
│ │ └── impl/ # 服务实现
│ │ ├── AuthServiceImpl.java
│ │ ├── UserServiceImpl.java
│ │ ├── PetServiceImpl.java
│ │ ├── AppointmentServiceImpl.java
│ │ ├── BoardingRoomServiceImpl.java
│ │ ├── PackageServiceImpl.java
│ │ └── ScheduleServiceImpl.java
│ │
│ └── util/
│ └── PageUtil.java # 分页工具类(利用 Supplier 封装列表+总数查询)
│
└── src/main/resources/
├── application.yaml # Spring Boot 配置文件
└── mapper/ # MyBatis XML 映射文件
├── UserMapper.xml
├── PetMapper.xml
├── VaccinationRecordMapper.xml
├── DewormingRecordMapper.xml
├── PackageMapper.xml
├── BoardingRoomMapper.xml
├── BoardingRoomStatusMapper.xml
├── AppointmentMapper.xml
└── ScheduleMapper.xml
erDiagram
user ||--o{ pet : "拥有"
user ||--o{ appointment : "发起"
pet ||--o{ vaccination_record : "疫苗记录"
pet ||--o{ deworming_record : "驱虫记录"
pet ||--o{ appointment : "被预约"
appointment ||--|| package : "选择"
appointment ||--o| boarding_room : "使用房型(寄养)"
appointment ||--o{ boarding_room_status : "生成房态(审核通过后)"
boarding_room ||--o{ boarding_room_status : "房态记录"
user {
BIGINT id PK
VARCHAR account UK "登录账号"
VARCHAR username
VARCHAR password "MD5+SALT"
VARCHAR email
VARCHAR phone
VARCHAR avatar
VARCHAR role "user|admin"
TINYINT enable "0禁用 1启用"
DATETIME created_at
DATETIME updated_at
}
pet {
BIGINT id PK
BIGINT user_id FK
VARCHAR name
VARCHAR species "DOG|CAT|OTHER"
VARCHAR breed
VARCHAR gender "MALE|FEMALE"
DATE birth_date
DECIMAL weight
VARCHAR avatar
TEXT remark
DATETIME created_at
DATETIME updated_at
}
vaccination_record {
BIGINT id PK
BIGINT pet_id FK
VARCHAR vaccine_name
DATE vaccine_date
VARCHAR hospital
VARCHAR doctor
TEXT remark
DATETIME created_at
}
deworming_record {
BIGINT id PK
BIGINT pet_id FK
VARCHAR deworming_name
DATE deworming_date
VARCHAR hospital
VARCHAR doctor
TEXT remark
DATETIME created_at
}
package {
BIGINT id PK
VARCHAR name
VARCHAR type "BOARDING|GROOMING"
DECIMAL price
INT duration "寄养天数(仅寄养)"
TEXT description
TINYINT is_active "0禁用 1启用"
DATETIME created_at
DATETIME updated_at
}
boarding_room {
BIGINT id PK
VARCHAR room_name
VARCHAR room_type "STANDARD|VIP|DELUXE"
INT capacity "可容纳宠物数"
DECIMAL price_per_day "每晚价格"
TINYINT is_active "0禁用 1启用"
TEXT description
DATETIME created_at
DATETIME updated_at
}
boarding_room_status {
BIGINT id PK
BIGINT room_id FK "关联房型"
DATE date "日期"
VARCHAR status "AVAILABLE|OCCUPIED|MAINTENANCE"
BIGINT appointment_id "关联预约(系统写入)"
DATETIME created_at
}
appointment {
BIGINT id PK
BIGINT user_id FK
BIGINT pet_id FK
BIGINT package_id FK
BIGINT room_id FK "寄养房型(寄养必填)"
VARCHAR type "BOARDING|GROOMING"
VARCHAR status "PENDING|CONFIRMED|CANCELLED|CANCEL_REQUESTED"
DATE start_date "寄养开始/洗护预约日"
DATE end_date "寄养结束"
DECIMAL total_price
DECIMAL deposit "定金=总价*30%"
TINYINT deposit_paid "0未支付 1已支付"
TEXT cancel_reason
VARCHAR reject_reason
TEXT remark
DATETIME created_at
DATETIME updated_at
}
schedule {
BIGINT id PK
VARCHAR employee_name
DATE work_date
VARCHAR time_slot "MORNING|AFTERNOON|FULL_DAY"
TEXT remark
DATETIME created_at
}
UNIQUE 索引: boarding_room_status 表使用 UNIQUE KEY uk_room_date (room_id, date) 确保每个房型每天只有一条记录。通过 INSERT ... ON DUPLICATE KEY UPDATE 实现插入或更新(upsert):
INSERT INTO boarding_room_status (room_id, date, status, appointment_id, created_at)
VALUES (#{roomId}, #{date}, #{status}, #{appointmentId}, NOW())
ON DUPLICATE KEY UPDATE status = #{status}, appointment_id = #{appointmentId}
索引策略:
| 表 | 索引 | 用途 |
|---|---|---|
| user | uk_account (account) | 登录/注册唯一性校验 |
| pet | idx_user_id (user_id) | 快速查询某用户的所有宠物 |
| vaccination_record | idx_pet_id (pet_id) | 查询某宠物疫苗记录 |
| deworming_record | idx_pet_id (pet_id) | 查询某宠物驱虫记录 |
| appointment | idx_user_id (user_id) | 查询某用户预约列表 |
| appointment | idx_status (status) | 管理员按状态筛选订单 |
| appointment | idx_package_id (package_id) | 统计套餐预约量 |
| boarding_room_status | uk_room_date (room_id, date) | 唯一约束+日期范围查询 |
| boarding_room_status | idx_appointment_id (appointment_id) | 取消订单时释放房态 |
| schedule | idx_work_date (work_date) | 按月查询排班 |
| package | idx_type (type) | 按类型筛选套餐 |
bagin 数据类型: price_per_day, total_price, deposit 等金额字段使用 DECIMAL(10,2) 避免浮点精度丢失。
public class R<T> {
private int code; // 状态码
private String message; // 提示信息
private T data; // 响应数据
public static <T> R<T> ok(T data) // 成功返回 data (code=200, message="success")
public static <T> R<T> ok() // 成功无数据 (code=200)
public static <T> R<T> error(int code, String message) // 指定错误码
public static <T> R<T> error(String message) // 默认 400 错误
}
响应示例:
// 成功
{ "code": 200, "message": "success", "data": { "id": 1, "username": "张三" } }
// 分页
{ "code": 200, "message": "success", "data": { "data": [...], "total": 50, "pageNum": 1, "pageSize": 10 } }
// 业务错误
{ "code": 400, "message": "账号已存在", "data": null }
// 未登录
{ "code": 401, "message": "未登录或Session已过期", "data": null }
// 无权限
{ "code": 403, "message": "无权访问,需要管理员权限", "data": null }
public class BusinessException extends RuntimeException {
private int code; // 默认 400
public BusinessException(String message) // code=400
public BusinessException(int code, String message) // 自定义 code
}
继承体系:
RuntimeException
├── BusinessException (400) — 业务校验失败
├── UnauthorizedException (401) — 未登录/Session 过期
├── ForbiddenException (403) — 无管理员权限
└── ResourceNotFoundException (404) — 资源不存在
拦截所有 Controller 抛出的异常,统一转换为 R 响应格式:
| 异常类型 | HTTP Status | code | 处理逻辑 |
|---|---|---|---|
| BusinessException | 400 | 400 | 提取 message 返回业务提示 |
| UnauthorizedException | 401 | 401 | 返回 "未登录或Session已过期" |
| ForbiddenException | 403 | 403 | 返回 "无权访问" |
| ResourceNotFoundException | 404 | 404 | 返回 "资源不存在" |
| MethodArgumentNotValidException | 400 | 400 | 提取 @Valid 校验失败信息 |
| BindException | 400 | 400 | 提取参数绑定失败信息 |
| MissingServletRequestParameterException | 400 | 400 | 返回 "缺少必填参数" |
| DuplicateKeyException | 409 | 409 | 返回 "数据重复" |
| Exception (兜底) | 500 | 500 | 打印堆栈后返回 "服务器内部错误" |
public class PageVo<T> {
private List<T> data; // 当前页数据
private long total; // 总记录数
private int pageNum; // 当前页码(从1开始)
private int pageSize; // 每页大小
}
PageUtil 工具类封装分页查询范式,通过 Supplier 解耦列表查询和总数查询:
public static <T> PageVo<T> page(int pageNum, int pageSize,
Supplier<List<T>> queryList, Supplier<Long> queryCount) {
// 1. 执行列表查询: queryList.get()
// 2. 执行总数查询: queryCount.get()
// 3. 封装 PageVo 返回
}
算法步骤:
pageNum, pageSize 及筛选参数offset = (pageNum - 1) * pageSizeLIMIT #{offset}, #{pageSize} 分页查询SELECT COUNT(*) 获取总记录数PageVo 返回| 项目 | 说明 |
|---|---|
| 会话存储 | 服务端内存 Session,由 JSESSIONID Cookie 关联 |
| Cookie 属性 | HttpOnly=true, SameSite=Lax |
| 登录时机 | 登录/注册成功后调用 request.getSession().setAttribute("user", userVo) |
| Session 内容 | UserVo 对象(含 id, role, username 等) |
| 注销方式 | request.getSession().invalidate() |
| 超时配置 | server.servlet.session.timeout=1800 (30分钟) |
配置路径: 拦截所有 /api/**,排除 /auth/**、/swagger-ui/**、/v3/api-docs/**
算法步骤:
request.getSession(false) 获取 Session(不自动创建)UnauthorizedException("未登录")UserVo user = (UserVo) session.getAttribute("user")UnauthorizedException("未登录或Session已过期")将 userId 和 role 设置到 request attribute,供 Controller 通过 request.getAttribute("userId") 获取
sequenceDiagram
participant Client as 客户端
participant Interceptor as AuthInterceptor
participant Session as HttpSession
participant Controller as Controller
Client->>Interceptor: HTTP 请求(携带 JSESSIONID Cookie)
Interceptor->>Session: request.getSession(false)
alt Session 为空
Session-->>Interceptor: null
Interceptor-->>Client: 401 Unauthorized
else Session 存在
Interceptor->>Session: getAttribute("user")
alt user 为空
Session-->>Interceptor: null
Interceptor-->>Client: 401 Unauthorized
else user 存在
Interceptor->>Interceptor: request.setAttribute("userId", user.id)
Interceptor->>Interceptor: request.setAttribute("role", user.role)
Interceptor->>Controller: 放行
Controller-->>Client: 正常响应
end
end
配置路径: 匹配 /admin/**(在 AuthInterceptor 之后执行,所以 request attribute 中已有 role)
算法步骤:
rolerole != "admin" → 抛出 ForbiddenException("无权访问,需要管理员权限")使用 MD5 + 固定盐值 加密:
SALT = "PetService2026!@#";
encrypt(password):
1. toEncrypt = password + SALT
2. MessageDigest.getInstance("MD5") 计算摘要
3. 将 16 字节摘要转为 32 位小写十六进制字符串
4. 返回密文
matches(rawPassword, encryptedPassword):
1. 对 rawPassword 执行 encrypt
2. 与 encryptedPassword 比较(equals)
registry.addMapping("/**")
.allowedOriginPatterns("*") // 允许所有来源(开发阶段)
.allowCredentials(true) // 允许携带 Cookie(必须)
.allowedMethods("GET","POST","PUT","DELETE","OPTIONS")
.allowedHeaders("*");
注意:
allowCredentials(true)与allowedOrigins("*")不能同时使用,使用allowedOriginPatterns("*")替代。
路由映射: @RequestMapping("/auth")
| 方法 | 路径 | 请求体 | 返回 | 校验 | 说明 |
|---|---|---|---|---|---|
| POST | /auth/register | RegisterDto | R<UserVo> | @Valid | 注册并自动登录 |
| POST | /auth/login | LoginDto | R<UserVo> | @Valid | 登录 |
| POST | /auth/logout | - | R<Void> | - | 登出(销毁 Session) |
RegisterDto (src/dto/auth/RegisterDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| account | String | @NotBlank @Size(min=4, max=20) | 登录账号 |
| password | String | @NotBlank @Size(min=4, max=20) | 明文密码 |
| username | String | @NotBlank @Size(min=2, max=32) | 显示用户名 |
LoginDto (src/dto/auth/LoginDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| account | String | @NotBlank | 登录账号 |
| password | String | @NotBlank | 明文密码 |
UserVo (src/vo/user/UserVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 用户 ID |
| username | String | 用户名 |
| account | String | 登录账号 |
| String | 邮箱 | |
| phone | String | 联系方式 |
| avatar | String | 头像 URL |
| role | String | 角色 (user/admin) |
| enable | Integer | 状态 (0禁用 1启用) |
register(RegisterDto dto, HttpServletRequest request):
userMapper.selectByAccount(dto.getAccount()) 检查账号是否已存在BusinessException("账号已存在")password = passwordConfig.encrypt(dto.getPassword()), role = "user", enable = 1userMapper.insert(user) 插入数据库toUserVo(user) 转为 UserVorequest.getSession().setAttribute("user", vo) 自动登录login(LoginDto dto, HttpServletRequest request):
userMapper.selectByAccount(dto.getAccount()) 查找用户BusinessException("账号或密码错误")user.getEnable() == 0 → 抛出 BusinessException("账号已被禁用")passwordConfig.matches(dto.getPassword(), user.getPassword()) 校验密码BusinessException("账号或密码错误")sequenceDiagram
participant F as 前端
participant C as AuthController
participant S as AuthServiceImpl
participant M as UserMapper
participant DB as MySQL
participant Session as HttpSession
F->>+C: POST /auth/login { account, password }
C->>+S: login(dto, request)
S->>+M: selectByAccount(account)
M->>+DB: SELECT * FROM user WHERE account=?
DB-->>-M: User 实体
M-->>-S: User or null
alt 用户不存在
S-->>F: BusinessException("账号或密码错误")
else 用户被禁用
S-->>F: BusinessException("账号已被禁用")
else 密码错误
S-->>F: BusinessException("账号或密码错误")
else 登录成功
S->>Session: setAttribute("user", userVo)
Session-->>S: OK
S-->>-C: UserVo
C-->>-F: R<UserVo> { code:200, data: { id, username, role, ... } }
end
路由映射: @RequestMapping("/user")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /user | query: pageNum, pageSize, username, account, role, enable | R<PageVo<UserVo>> | 分页查询用户列表 |
| GET | /user/{id} | path: id | R<UserVo> | 按 ID 查询用户 |
| POST | /user | UserDto | R<Boolean> | 新增用户 |
| PUT | /user | UserDto | R<Boolean> | 修改用户 |
| DELETE | /user | query: ids (逗号分隔) | R<Boolean> | 批量删除用户 |
| PUT | /user/updateStatus | UpdateUserStatusDto | R<Void> | 启用/禁用用户 |
| PUT | /user/updatePassword | UpdateUserPasswordDto | R<Void> | 修改密码 |
| PUT | /user/updateAvatar | UpdateUserAvatarDto | R<Void> | 修改头像 |
UserDto (src/dto/user/UserDto.java)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | Long | 编辑时必填 | 用户 ID |
| account | String | 新增时必填 | 登录账号 |
| username | String | 是 | 用户名 |
| password | String | 新增时必填 | 密码 |
| String | 否 | 邮箱 | |
| phone | String | 否 | 电话 |
| avatar | String | 否 | 头像 URL |
| role | String | 否 | 角色 (默认 user) |
| enable | Integer | 否 | 状态 (默认 1) |
UpdateUserPasswordDto
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | Long | - | 用户 ID |
| oldPassword | String | @NotBlank | 旧密码 |
| newPassword | String | @NotBlank | 新密码 |
UpdateUserAvatarDto
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | Long | - | 用户 ID |
| avatar | String | @NotBlank | 头像 URL |
UpdateUserStatusDto
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | Long | - | 用户 ID |
| enable | Integer | @NotNull | 0=禁用 1=启用 |
queryByPage(pageNum, pageSize, username, account, role, enable):
offset = (pageNum - 1) * pageSizeuserMapper.selectByPage(offset, pageSize, username, account, role, enable) 查询当前页userMapper.countByCondition(username, account, role, enable) 查询总数PageUtil.page() 封装并返回add(UserDto dto):
userMapper.selectByAccount(dto.getAccount()) 若存在则抛异常userMapper.insert(user) 插入edit(UserDto dto):
userMapper.update(user) 动态更新非空字段updatePassword(UpdateUserPasswordDto dto):
userMapper.selectById(dto.getId())passwordConfig.matches(dto.getOldPassword(), user.getPassword())userMapper.updatePassword(id, encrypt(newPassword))deleteByIds(List<Long> ids):
userMapper.deleteByIds(ids) 批量物理删除flowchart TD
A[前端请求 GET /api/user] --> B{接收参数}
B --> C[pageNum=1, pageSize=10]
B --> D[username=张三, role=user, enable=1]
C --> E[Service 层]
D --> E
E --> F[计算 offset=(pageNum-1)*pageSize]
F --> G[Mapper 层]
G --> H[selectByPage: LIMIT offset, pageSize + WHERE 条件]
G --> I[countByCondition: SELECT COUNT(*) + WHERE 条件]
H --> J[List< User > 当前页数据]
I --> K[long 总记录数]
J --> L[PageUtil.page 封装]
K --> L
L --> M[PageVo<UserVo>]
M --> N[返回前端: { data: [...], total: 50, pageNum: 1, pageSize: 10 }]
路由映射: @RequestMapping("/pets")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /pets | - | R<List<PetVo>> | 获取当前用户宠物列表 |
| POST | /pets | PetDto | R<PetVo> | 新增宠物 |
| GET | /pets/{id} | path: id | R<PetVo> | 获取宠物详情 |
| PUT | /pets/{id} | path: id + PetDto | R<Boolean> | 修改宠物信息 |
| DELETE | /pets/{id} | path: id | R<Boolean> | 删除宠物(级联删除疫苗/驱虫) |
| GET | /pets/{id}/vaccinations | path: id | R<List<VaccinationVo>> | 获取疫苗记录 |
| POST | /pets/{id}/vaccinations | path: id + VaccinationDto | R<VaccinationVo> | 新增疫苗记录 |
| GET | /pets/{id}/deworming | path: id | R<List<DewormingVo>> | 获取驱虫记录 |
| POST | /pets/{id}/deworming | path: id + DewormingDto | R<DewormingVo> | 新增驱虫记录 |
所有接口从 request attribute 获取当前登录用户 ID,校验宠物归属权。
PetDto (src/dto/pet/PetDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| name | String | @NotBlank | 宠物名称 |
| species | String | @NotBlank | 物种 (DOG/CAT/OTHER) |
| breed | String | 否 | 品种 |
| gender | String | 否 | MALE/FEMALE |
| birthDate | LocalDate | 否 | 出生日期 |
| weight | Double | 否 | 体重(kg) |
| avatar | String | 否 | 头像 URL |
| remark | String | 否 | 备注 |
VaccinationDto (src/dto/pet/VaccinationDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| vaccineName | String | @NotBlank | 疫苗名称 |
| vaccineDate | LocalDate | @NotNull | 接种日期 |
| hospital | String | 否 | 接种机构 |
| doctor | String | 否 | 接种兽医 |
| remark | String | 否 | 备注 |
DewormingDto (src/dto/pet/DewormingDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| dewormingName | String | @NotBlank | 驱虫名称 |
| dewormingDate | LocalDate | @NotNull | 驱虫日期 |
| hospital | String | 否 | 驱虫机构 |
| doctor | String | 否 | 驱虫兽医 |
| remark | String | 否 | 备注 |
PetVo (src/vo/pet/PetVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 主键 |
| userId | Long | 所属用户 ID |
| name | String | 宠物名称 |
| species | String | 物种 |
| breed | String | 品种 |
| gender | String | 性别 |
| birthDate | LocalDate | 出生日期 |
| weight | Double | 体重 |
| avatar | String | 头像 |
| remark | String | 备注 |
| createdAt | LocalDateTime | 创建时间 |
| vaccinations | List<VaccinationVo> | 疫苗记录列表(仅详情接口返回) |
| dewormings | List<DewormingVo> | 驱虫记录列表(仅详情接口返回) |
VaccinationVo / DewormingVo — 对应实体各字段 + createdAt。
add(PetDto dto, Long userId):
userId,拷贝 dto 属性petMapper.insert(pet) — MyBatis 通过 useGeneratedKeys 回写 idtoPetVo(pet) 转换后返回delete(Long id, Long userId):
pet.getUserId().equals(userId))vaccinationRecordMapper.deleteByPetId(id)dewormingRecordMapper.deleteByPetId(id)petMapper.deleteById(id)checkPetOwnership(petId, userId) — 归属校验工具方法:
petMapper.selectById(petId) 查询宠物pet.getUserId() != userId → 抛出 BusinessException("宠物不存在或无权访问")sequenceDiagram
participant F as 前端
participant C as PetController
participant S as PetServiceImpl
participant M as PetMapper
participant VRM as VaccinationRecordMapper
participant DB as MySQL
F->>+C: POST /api/pets { name, species, breed, gender }
Note over F,C: Header 携带 JSESSIONID
C->>C: request.getAttribute("userId")
C->>+S: add(petDto, userId)
S->>S: 创建 Pet 实体,设置 userId
S->>+M: insert(pet)
M->>+DB: INSERT INTO pet ...
DB-->>-M: auto-increment id
M-->>-S: 1 (影响行数)
S-->>-C: PetVo { id, name, species, ... }
C-->>-F: R<PetVo> { code:200, data: {...} }
Note over F: 用户添加疫苗记录
F->>+C: POST /api/pets/{petId}/vaccinations { vaccineName, vaccineDate }
C->>C: request.getAttribute("userId")
C->>+S: addVaccination(petId, dto, userId)
S->>+M: selectById(petId)
M-->>-S: Pet
S->>S: checkPetOwnership(petId, userId)
S->>S: 创建 VaccinationRecord 实体
S->>+VRM: insert(record)
VRM->>+DB: INSERT INTO vaccination_record ...
DB-->>-VRM: auto-increment id
VRM-->>-S: 1
S-->>-C: VaccinationVo { id, vaccineName, vaccineDate, ... }
C-->>-F: R<VaccinationVo> { code:200, data: {...} }
路由映射: @RequestMapping("/boarding-rooms")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /boarding-rooms | - | R<List<BoardingRoomVo>> | 获取所有启用的房型 |
| GET | /boarding-rooms/{id}/status | query: month (yyyy-MM) | R<List<RoomStatusVo>> | 获取指定房型某月状态 |
请求示例: GET /api/boarding-rooms/1/status?month=2026-07
响应示例:
{
"code": 200,
"message": "success",
"data": [
{ "id": null, "roomId": 1, "date": "2026-07-01", "status": "AVAILABLE", "appointmentId": null },
{ "id": 5, "roomId": 1, "date": "2026-07-02", "status": "OCCUPIED", "appointmentId": 3 },
{ "id": null, "roomId": 1, "date": "2026-07-03", "status": "AVAILABLE", "appointmentId": null }
]
}
BoardingRoomVo (src/vo/room/BoardingRoomVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 房型 ID |
| roomName | String | 房型名称 |
| roomType | String | 分类 (STANDARD/VIP/DELUXE) |
| capacity | Integer | 可容纳宠物数 |
| pricePerDay | BigDecimal | 每晚价格 |
| description | String | 房型描述 |
RoomStatusVo (src/vo/room/RoomStatusVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 状态记录 ID (null 表示无记录) |
| roomId | Long | 房型 ID |
| date | LocalDate | 日期 |
| status | String | AVAILABLE / OCCUPIED / MAINTENANCE |
| appointmentId | Long | 关联预约 ID (null 表示无) |
路由映射: @RequestMapping("/admin/boarding-rooms")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /admin/boarding-rooms | - | R<List<BoardingRoomVo>> | 获取所有房型(含已禁用) |
| POST | /admin/boarding-rooms | BoardingRoomCreateDto | R<BoardingRoomVo> | 新增房型 |
| PUT | /admin/boarding-rooms/{id} | BoardingRoomCreateDto | R<Boolean> | 修改房型信息 |
| DELETE | /admin/boarding-rooms/{id} | path: id | R<Boolean> | 删除房型(逻辑删除) |
| GET | /admin/boarding-rooms/{id}/status | query: month | R<List<RoomStatusVo>> | 获取房型某月状态(管理员,含 appointmentId) |
| PUT | /admin/boarding-rooms/{roomId}/status | RoomStatusDto | R<Boolean> | 手动修改某日房态 |
| PUT | /admin/boarding-rooms/{roomId}/status/batch | BatchRoomStatusDto | R<Boolean> | 批量修改日期范围房态 |
| GET | /admin/boarding-rooms/today-status | - | R<TodayStatusVo> | 今日所有房型入住概览 |
RoomStatusDto (src/dto/room/RoomStatusDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| date | LocalDate | @NotNull | 日期 |
| status | String | @NotBlank | AVAILABLE / OCCUPIED / MAINTENANCE |
BatchRoomStatusDto(新增 DTO)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| startDate | LocalDate | @NotNull | 开始日期 |
| endDate | LocalDate | @NotNull | 结束日期 |
| status | String | @NotBlank | 目标状态 |
BoardingRoomCreateDto(新增 DTO)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| roomName | String | @NotBlank | 房型名称 |
| roomType | String | 否 | 房型分类 |
| capacity | Integer | 否 | 容纳数 |
| pricePerDay | BigDecimal | @NotNull | 每晚价格 |
| description | String | 否 | 描述 |
TodayStatusVo(新增 VO)
| 字段 | 类型 | 说明 |
|---|---|---|
| totalRooms | int | 总房型数 |
| availableCount | int | 空闲房间数 |
| occupiedCount | int | 入住房间数 |
| maintenanceCount | int | 维护房间数 |
| todayGuests | List<TodayGuestVo> | 今日入住宠物列表 |
TodayGuestVo(新增 VO)
| 字段 | 类型 | 说明 |
|---|---|---|
| roomName | String | 房型名称 |
| petName | String | 宠物名称 |
| ownerName | String | 主人姓名 |
listRooms():
boardingRoomMapper.selectActive() 查询 is_active = 1 的所有房型BoardingRoomVo 列表返回getStatus(Long roomId, String month):
month = "2026-07" 为 YearMonth,计算 startDate = 当月1日, endDate = 当月最后一天boardingRoomStatusMapper.selectByRoomAndDateRange(roomId, startDate, endDate) 查询已有记录Map<LocalDate, BoardingRoomStatus> 用于快速查找AVAILABLEList<RoomStatusVo>updateStatus(Long roomId, RoomStatusDto dto):
BoardingRoomStatus 实体:roomId, date, statusboardingRoomStatusMapper.insertOrUpdate(status) — 使用 ON DUPLICATE KEY UPDATE 实现 upsertappointment_id,手动修改时自动置为 NULL(由 SQL 中的 appointment_id = #{appointmentId} 控制,传入 null 时清空)批量修改状态(batchUpdateStatus):
startDate <= endDate,差值不超过 31 天insertOrUpdate 或批量 insertsequenceDiagram
participant A as 管理员前端
participant C as AdminBoardingRoomController
participant S as BoardingRoomServiceImpl
participant M as BoardingRoomStatusMapper
participant DB as MySQL
A->>+C: PUT /api/admin/boarding-rooms/1/status
Note over A,C: { date: "2026-07-15", status: "MAINTENANCE" }
C->>+S: updateStatus(roomId=1, dto)
S->>S: 创建 BoardingRoomStatus 实体
Note over S: roomId=1, date=2026-07-15, status=MAINTENANCE, appointmentId=null
S->>+M: insertOrUpdate(status)
M->>+DB: INSERT INTO boarding_room_status ...
Note over DB: ON DUPLICATE KEY UPDATE<br/>status='MAINTENANCE',<br/>appointment_id=NULL
DB-->>-M: 1 (inserted or updated)
M-->>-S: 1
S-->>-C: true
C-->>-A: R<Boolean> { code:200, data: true }
flowchart TD
Start[商家选择修改房态] --> Input{输入参数}
Input --> RoomId[房型 ID]
Input --> Date[日期或日期范围]
Input --> TargetStatus[目标状态: AVAILABLE/OCCUPIED/MAINTENANCE]
Date --> CheckDate{是否过去的日期?}
CheckDate -->|是| RejectPast[拒绝: 不可修改历史数据]
CheckDate -->|否| CheckConflict{目标状态是 AVAILABLE?}
CheckConflict -->|是| CheckOrder{该日期有关联订单?}
CheckOrder -->|是| RejectOrder[拒绝: 存在关联订单,<br/>请先在订单管理中处理]
CheckOrder -->|否| DoUpdate
CheckConflict -->|否| DoUpdate[执行 UPDATE]
DoUpdate --> Upsert[INSERT ... ON DUPLICATE KEY UPDATE]
Upsert --> Result{执行结果}
Result -->|成功| Success[返回 true]
Result -->|失败| Error[抛出 BusinessException]
请求/响应示例:
新增房型:
// POST /api/admin/boarding-rooms
// Request:
{ "roomName": "豪华大间", "roomType": "VIP", "capacity": 2, "pricePerDay": 200.00, "description": "独立空调、24小时监控" }
// Response:
{ "code": 200, "message": "success", "data": { "id": 5, "roomName": "豪华大间", "roomType": "VIP", "capacity": 2, "pricePerDay": 200.00, "description": "独立空调、24小时监控" } }
批量修改:
// PUT /api/admin/boarding-rooms/1/status/batch
// Request:
{ "startDate": "2026-07-10", "endDate": "2026-07-15", "status": "MAINTENANCE" }
// Response:
{ "code": 200, "message": "success", "data": true }
今日概览:
// GET /api/admin/boarding-rooms/today-status
// Response:
{
"code": 200,
"message": "success",
"data": {
"totalRooms": 5,
"availableCount": 2,
"occupiedCount": 3,
"maintenanceCount": 0,
"todayGuests": [
{ "roomName": "标准单间", "petName": "旺财", "ownerName": "张三" },
{ "roomName": "豪华套房", "petName": "咪咪", "ownerName": "李四" }
]
}
}
路由映射: @RequestMapping("/packages")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /packages | query: type (BOARDING/GROOMING) | R<List<PackageVo>> | 获取套餐列表,可按类型筛选 |
| GET | /packages/{id} | path: id | R<PackageVo> | 获取套餐详情 |
请求/响应示例:
// GET /api/packages?type=BOARDING
// Response:
{
"code": 200,
"message": "success",
"data": [
{ "id": 1, "name": "标准寄养", "type": "BOARDING", "price": 100.00, "duration": 1, "description": "基础寄养服务", "createdAt": "2026-06-01T10:00:00" },
{ "id": 2, "name": "豪华寄养", "type": "BOARDING", "price": 200.00, "duration": 1, "description": "豪华寄养服务", "createdAt": "2026-06-01T10:00:00" }
]
}
路由映射: @RequestMapping("/admin/packages")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| POST | /admin/packages | PackageDto | R<PackageVo> | 新增套餐 |
| PUT | /admin/packages/{id} | PackageDto | R<Boolean> | 修改套餐 |
| DELETE | /admin/packages/{id} | path: id | R<Boolean> | 删除套餐(逻辑删除) |
PackageDto (src/dto/servicePackage/PackageDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| name | String | @NotBlank | 套餐名称 |
| type | String | @NotBlank | BOARDING / GROOMING |
| price | BigDecimal | @NotNull | 价格 |
| duration | Integer | 否 | 寄养天数(仅寄养套餐) |
| description | String | 否 | 描述 |
| isActive | Integer | 否 | 是否启用 (默认 1) |
PackageVo (src/vo/servicePackage/PackageVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 套餐 ID |
| name | String | 名称 |
| type | String | 类型 |
| price | BigDecimal | 价格 |
| duration | Integer | 天数 |
| description | String | 描述 |
| createdAt | LocalDateTime | 创建时间 |
list(String type):
packageMapper.selectByType(type) — SQL 中 WHERE type = #{type} AND is_active = 1create(PackageDto dto):
isActive 为空,默认设为 1packageMapper.insert(pkg) — 返回自动生成的 iddelete(Long id):
packageMapper.deleteById(id) — SQL: UPDATE package SET is_active = 0 WHERE id = #{id}路由映射: @RequestMapping("/appointments")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| POST | /appointments | AppointmentDto | R<AppointmentVo> | 提交预约 |
| GET | /appointments | - | R<List<AppointmentVo>> | 获取我的预约列表 |
| GET | /appointments/{id} | path: id | R<AppointmentVo> | 获取预约详情 |
| POST | /appointments/{id}/cancel | body: { reason } | R<Boolean> | 申请取消 |
| POST | /appointments/{id}/pay-deposit | - | R<Boolean> | 模拟支付定金 |
路由映射: @RequestMapping("/admin/appointments")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /admin/appointments | query: pageNum, pageSize, status, type | R<PageVo<AppointmentVo>> | 管理员分页查询全部预约 |
| PUT | /admin/appointments/{id}/review | ReviewDto | R<Boolean> | 审核预约(通过/拒绝) |
| PUT | /admin/appointments/{id}/handle-cancel | HandleCancelDto | R<Boolean> | 处理取消申请 |
AppointmentDto (src/dto/appointment/AppointmentDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| petId | Long | @NotNull | 宠物 ID |
| packageId | Long | @NotNull | 套餐 ID |
| roomId | Long | 寄养必填 | 寄养房型 ID |
| startDate | LocalDate | 寄养必填 | 开始日期 |
| endDate | LocalDate | 寄养必填 | 结束日期 |
| remark | String | 否 | 备注 |
注意:type 和 totalPrice 不由前端传入,由服务端根据套餐信息自动推导和计算。
ReviewDto (src/dto/appointment/ReviewDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| action | String | @NotBlank | APPROVE / REJECT |
| rejectReason | String | 拒绝时建议填写 | 拒绝原因 |
HandleCancelDto (src/dto/appointment/HandleCancelDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| action | String | @NotBlank | AGREE / REJECT |
AppointmentVo (src/vo/appointment/AppointmentVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 预约 ID |
| userId | Long | 用户 ID |
| petId | Long | 宠物 ID |
| petName | String | 宠物名称(关联查询) |
| packageId | Long | 套餐 ID |
| packageName | String | 套餐名称(关联查询) |
| roomId | Long | 房型 ID |
| roomName | String | 房型名称(关联查询) |
| type | String | BOARDING / GROOMING |
| status | String | PENDING / CONFIRMED / CANCELLED / CANCEL_REQUESTED / COMPLETED |
| startDate | LocalDate | 开始日期 |
| endDate | LocalDate | 结束日期 |
| totalPrice | BigDecimal | 总价 |
| deposit | BigDecimal | 定金金额 |
| depositPaid | Boolean | 是否支付定金 |
| cancelReason | String | 取消原因 |
| rejectReason | String | 拒绝原因 |
| remark | String | 备注 |
| createdAt | LocalDateTime | 创建时间 |
a) create(AppointmentDto dto, Long userId) — 创建预约(@Transactional)
1. 套餐校验:
package = packageMapper.selectById(dto.getPackageId())
若 package == null || package.isActive == 0 → 抛异常 "套餐不存在或已下架"
2. 宠物校验:
pet = petMapper.selectById(dto.getPetId())
若 pet == null || pet.userId != userId → 抛异常 "宠物不存在或无权访问"
3. 创建 Appointment 实体:
appointment.userId = userId
appointment.petId = dto.petId
appointment.packageId = dto.packageId
appointment.type = package.type
appointment.status = "PENDING"
appointment.depositPaid = false
appointment.remark = dto.remark
4. 若 type == BOARDING(寄养):
a) 校验 roomId, startDate, endDate 不为空
b) room = boardingRoomMapper.selectById(dto.roomId),校验 room != null && room.isActive == 1
c) 设置 appointment.roomId, startDate, endDate
d) days = ChronoUnit.DAYS.between(startDate, endDate) — 计算天数
e) 若 days <= 0 → 抛异常 "结束日期必须晚于开始日期"
f) 房间可用性校验:
statusList = boardingRoomStatusMapper.selectByRoomAndDateRange(roomId, startDate, endDate-1)
遍历 statusList,若有任意 status != "AVAILABLE" → 抛异常 "所选日期范围内房型不可用"
g) totalPrice = room.pricePerDay * days(服务端计算,防止篡改)
5. 若 type == GROOMING(洗护):
appointment.startDate = dto.startDate
appointment.totalPrice = package.price
6. deposit = totalPrice * 0.3(定金 = 总价 × 30%)
appointment.deposit = deposit
appointment.totalPrice = totalPrice
7. appointmentMapper.insert(appointment) — INSERT 并回写 id
8. 返回 toAppointmentVo(appointment)
b) list(Long userId):
appointmentMapper.selectByUserId(userId) — 按创建时间倒序c) getById(Long id, Long userId):
d) cancel(Long id, String reason, Long userId) — 取消订单:
1. 查询并校验归属权
2. 校验状态:仅 "PENDING" 和 "CONFIRMED" 允许取消
3. 若 status == "PENDING":
- 直接取消:appointmentMapper.updateCancelReason(id, reason)
- SQL:UPDATE appointment SET cancel_reason=?, status='CANCELLED' WHERE id=?
- 若有 boarding_room_status 关联 → 释放房间(releaseRoomStatus)
4. 若 status == "CONFIRMED":
- 进入取消待审:appointmentMapper.updateCancelReason(id, reason)
- SQL:UPDATE appointment SET cancel_reason=?, status='CANCEL_REQUESTED' WHERE id=?
- 等待商家审核处理
e) payDeposit(Long id, Long userId) — 支付定金:
1. 查询并校验归属权
2. 校验:depositPaid 必须为 false(防止重复支付)
3. 校验:status 必须为 "PENDING"
4. appointmentMapper.updateDepositPaid(id, true)
5. 返回 true
f) listAll(pageNum, pageSize, status, type) — 管理员查询:
1. 计算 offset = (pageNum - 1) * pageSize
2. appointmentMapper.selectAllByPage(offset, pageSize, status, type) — 动态 WHERE
3. appointmentMapper.countAll(status, type) — 总数
4. PageUtil.page() 封装返回
g) review(Long id, ReviewDto dto) — 审核预约(@Transactional):
1. 查询订单,校验 status == "PENDING"
2. 若 action == "APPROVE":
a) appointmentMapper.updateStatus(id, "CONFIRMED")
b) 若 type == BOARDING && roomId != null && 日期齐全:
- 生成 [startDate, endDate) 之间的所有日期列表
- 为每个日期创建 BoardingRoomStatus:
roomId, date, status="OCCUPIED", appointmentId=id
- boardingRoomStatusMapper.insertBatch(statusList)
- 此处使用普通 INSERT(非 ON DUPLICATE),因为审核时已确认房间可用
c) 返回 true
3. 若 action == "REJECT":
a) 设置 appointment.status = "CANCELLED"
b) 设置 appointment.rejectReason = dto.rejectReason
c) appointmentMapper.update(appointment) — 动态更新
d) 不修改 boarding_room_status(从未写入过)
e) 返回 true
h) handleCancel(Long id, HandleCancelDto dto) — 处理取消申请(@Transactional):
1. 查询订单,校验 status == "CANCEL_REQUESTED"
2. 若 action == "AGREE":
a) appointmentMapper.updateStatus(id, "CANCELLED")
b) 若 type == BOARDING:
- boardingRoomStatusMapper.deleteByAppointmentId(id)
- SQL: DELETE FROM boarding_room_status WHERE appointment_id = #{id}
- 释放所有关联的房间状态记录
c) 返回 true
3. 若 action == "REJECT":
a) appointmentMapper.updateStatus(id, "CONFIRMED") — 恢复原状态
b) 不修改房间状态
c) 返回 true
注意:现有实现采用物理删除而非 UPDATE status='AVAILABLE' + appointment_id=NULL,因为审核通过时已确认无冲突,取消时直接删除更简洁。
stateDiagram-v2
[*] --> PENDING: 创建预约
PENDING --> CONFIRMED: 管理员审核通过
PENDING --> CANCELLED: 用户直接取消
PENDING --> CANCELLED: 管理员拒绝审核
CONFIRMED --> CANCEL_REQUESTED: 用户申请取消
CONFIRMED --> COMPLETED: 服务完成
CANCEL_REQUESTED --> CANCELLED: 商家同意取消
CANCEL_REQUESTED --> CONFIRMED: 商家拒绝取消
CANCELLED --> [*]: 终态
COMPLETED --> [*]: 终态
状态转换规则:
| 当前状态 | 允许操作 | 目标状态 | 说明 |
|---|---|---|---|
| PENDING | 用户取消 | CANCELLED | 直接取消,房间未锁 |
| PENDING | 支付定金 | PENDING | depositPaid=true |
| PENDING | 管理员审核通过 | CONFIRMED | 锁定房间 |
| PENDING | 管理员拒绝 | CANCELLED | 写入 rejectReason |
| CONFIRMED | 用户申请取消 | CANCEL_REQUESTED | 等待商家处理 |
| CONFIRMED | 服务完成 | COMPLETED | 后台操作 |
| CANCEL_REQUESTED | 商家同意 | CANCELLED | 释放房间,退定金 |
| CANCEL_REQUESTED | 商家拒绝 | CONFIRMED | 恢复原状 |
sequenceDiagram
participant U as 用户
participant F as 前端
participant C as AppointmentController
participant S as AppointmentServiceImpl
participant M as Mappers
participant DB as MySQL
U->>F: 选择寄养套餐/房型/日期/宠物
F->>F: 前端连续性校验
F->>+C: POST /api/appointments
Note over F,C: { petId:2, packageId:1, roomId:3,<br/>startDate:2026-07-01, endDate:2026-07-04 }
C->>+S: create(dto, userId)
S->>+M: packageMapper.selectById(1)
M->>DB: SELECT * FROM package WHERE id=1
DB-->>M: ServicePackage { type:BOARDING, price:100, isActive:1 }
M-->>-S: Package exists
S->>+M: petMapper.selectById(2)
M->>DB: SELECT * FROM pet WHERE id=2
DB-->>M: Pet { userId: userId, name:旺财 }
M-->>-S: Pet exists & owned
S->>+M: boardingRoomMapper.selectById(3)
M->>DB: SELECT * FROM boarding_room WHERE id=3
DB-->>M: BoardingRoom { pricePerDay:100, isActive:1 }
M-->>-S: Room exists & active
S->>+M: boardingRoomStatusMapper.selectByRoomAndDateRange(3, 2026-07-01, 2026-07-03)
M->>DB: SELECT * FROM boarding_room_status<br/>WHERE room_id=3 AND date BETWEEN '2026-07-01' AND '2026-07-03'
DB-->>M: [status=AVAILABLE, status=AVAILABLE, ...]
M-->>-S: All AVAILABLE → 可用
S->>S: days = 3, totalPrice = 100*3 = 300, deposit = 300*0.3 = 90
S->>+M: appointmentMapper.insert(appointment)
M->>DB: INSERT INTO appointment ...
DB-->>M: id generated
M-->>-S: 1
S-->>-C: AppointmentVo { id:10, status:PENDING, deposit:90, ... }
C-->>-F: R<AppointmentVo> { code:200, data: {...} }
F-->>U: 显示订单信息,引导支付
sequenceDiagram
participant U as 用户
participant C as AppointmentController
participant AC as AdminAppointmentController
participant S as AppointmentServiceImpl
participant DB as MySQL
%% PENDING 状态取消
U->>C: POST /appointments/10/cancel { reason:"行程变更" }
C->>S: cancel(10, "行程变更", userId)
S->>DB: SELECT * FROM appointment WHERE id=10
S->>S: status == "PENDING" → 直接取消
S->>DB: UPDATE appointment SET status='CANCELLED', cancel_reason='行程变更' WHERE id=10
S-->>C: true
C-->>U: { code:200, data: true }
%% CONFIRMED 状态申请取消
U->>C: POST /appointments/11/cancel { reason:"宠物生病" }
C->>S: cancel(11, "宠物生病", userId)
S->>DB: SELECT * FROM appointment WHERE id=11
S->>S: status == "CONFIRMED" → 转为等待审核
S->>DB: UPDATE appointment SET status='CANCEL_REQUESTED', cancel_reason='宠物生病' WHERE id=11
S-->>C: true
C-->>U: { code:200, data: true }
%% 商家处理取消
商家->>AC: PUT /admin/appointments/11/handle-cancel { action:"AGREE" }
AC->>S: handleCancel(11, dto)
S->>DB: SELECT * FROM appointment WHERE id=11
S->>S: status == "CANCEL_REQUESTED" → 处理
S->>DB: UPDATE appointment SET status='CANCELLED' WHERE id=11
S->>DB: DELETE FROM boarding_room_status WHERE appointment_id=11
S-->>AC: true
AC-->>商家: { code:200, data: true }
请求/响应示例:
创建预约:
// POST /api/appointments
// Request (寄养):
{ "petId": 2, "packageId": 1, "roomId": 3, "startDate": "2026-07-01", "endDate": "2026-07-04", "remark": "猫咪胆小,请多关照" }
// Request (洗护):
{ "petId": 3, "packageId": 4, "startDate": "2026-07-05", "remark": "下午2点到店" }
// Response:
{ "code": 200, "message": "success", "data": { "id": 10, "userId": 1, "petId": 2, "petName": "旺财", "packageId": 1, "packageName": "标准寄养", "roomId": 3, "roomName": "标准单间", "type": "BOARDING", "status": "PENDING", "startDate": "2026-07-01", "endDate": "2026-07-04", "totalPrice": 300.00, "deposit": 90.00, "depositPaid": false, "createdAt": "2026-07-01T10:30:00" } }
支付定金:
// POST /api/appointments/10/pay-deposit
// Response:
{ "code": 200, "message": "success", "data": true }
审核预约:
// PUT /api/admin/appointments/10/review
// Request (approved):
{ "action": "APPROVE" }
// Request (rejected):
{ "action": "REJECT", "rejectReason": "该日期房间已被其他订单占用" }
// Response:
{ "code": 200, "message": "success", "data": true }
用户取消:
// POST /api/appointments/10/cancel
// Request:
{ "reason": "行程变更" }
// Response:
{ "code": 200, "message": "success", "data": true }
处理取消申请:
// PUT /api/admin/appointments/10/handle-cancel
// Request (agree):
{ "action": "AGREE" }
// Request (reject):
{ "action": "REJECT" }
// Response:
{ "code": 200, "message": "success", "data": true }
路由映射: @RequestMapping("/admin/schedules")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /admin/schedules | query: month (yyyy-MM) | R<List<ScheduleVo>> | 按月获取排班列表 |
| POST | /admin/schedules | ScheduleDto | R<ScheduleVo> | 新增排班 |
| DELETE | /admin/schedules/{id} | path: id | R<Boolean> | 删除排班 |
ScheduleDto (src/dto/schedule/ScheduleDto.java)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| employeeName | String | @NotBlank | 员工姓名 |
| workDate | LocalDate | @NotNull | 排班日期 |
| timeSlot | String | @NotBlank | MORNING / AFTERNOON / FULL_DAY |
| remark | String | 否 | 备注 |
ScheduleVo (src/vo/schedule/ScheduleVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 排班 ID |
| employeeName | String | 员工姓名 |
| workDate | LocalDate | 排班日期 |
| timeSlot | String | 时段 |
| remark | String | 备注 |
| createdAt | LocalDateTime | 创建时间 |
list(String month):
scheduleMapper.selectByMonth(startDate, endDate) — SQL: WHERE work_date BETWEEN ? AND ?create(ScheduleDto dto):
scheduleMapper.countConflict(dto.getEmployeeName(), dto.getWorkDate(), dto.getTimeSlot())delete(Long id):
scheduleMapper.deleteById(id) — 物理删除// GET /api/admin/schedules?month=2026-07
// Response:
{ "code": 200, "message": "success", "data": [
{ "id": 1, "employeeName": "张设计师", "workDate": "2026-07-01", "timeSlot": "MORNING", "remark": null, "createdAt": "2026-06-28T10:00:00" },
{ "id": 2, "employeeName": "李美容师", "workDate": "2026-07-01", "timeSlot": "AFTERNOON", "remark": null, "createdAt": "2026-06-28T10:00:00" }
] }
// POST /api/admin/schedules
// Request:
{ "employeeName": "张设计师", "workDate": "2026-07-05", "timeSlot": "FULL_DAY" }
// Response:
{ "code": 200, "message": "success", "data": { "id": 3, "employeeName": "张设计师", "workDate": "2026-07-05", "timeSlot": "FULL_DAY", "createdAt": "2026-07-01T12:00:00" } }
路由映射: @RequestMapping("/admin/users")
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /admin/users | query: pageNum, pageSize, username, account, role, enable | R<PageVo<UserVo>> | 分页查询注册用户列表 |
| GET | /admin/users/{id} | path: id | R<AdminUserDetailVo> | 获取用户详情及宠物档案 |
AdminUserDetailVo (src/vo/user/AdminUserDetailVo.java)
| 字段 | 类型 | 说明 |
|---|---|---|
| user | UserVo | 用户基本信息 |
| pets | List<PetVo> | 该用户的所有宠物(含疫苗/驱虫记录) |
getAdminDetail(Long id):
userMapper.selectById(id),不存在则抛异常petMapper.selectByUserId(id)vaccinationRecordMapper.selectByPetId(pet.getId()) → List<VaccinationRecord>dewormingRecordMapper.selectByPetId(pet.getId()) → List<DewormingRecord>queryByPage — 复用 UserServiceImpl.queryByPage 方法,参数同 UserController。
spring:
application:
name: PetServiceManagementSystem
datasource:
url: jdbc:mysql://localhost:3306/pet_service_managemen_system?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: space.anyi.petServiceManagementSystem.server.entity
configuration:
map-underscore-to-camel-case: true
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
server:
port: 8080
servlet:
context-path: /
session:
timeout: 1800 # Session 超时时间(秒)= 30分钟
cookie:
http-only: true # 禁止客户端 JS 读取 Cookie
same-site: lax # 防止 CSRF 攻击
配置要点说明:
| 配置项 | 说明 |
|---|---|
map-underscore-to-camel-case: true |
数据库下划线命名自动映射为 Java 驼峰命名 |
type-aliases-package |
MyBatis XML 中可直接使用类名(如 resultType="User") |
session.timeout=1800 |
HttpSession 30 分钟无操作自动过期 |
http-only: true |
防止 XSS 攻击窃取 Session Cookie |
same-site: lax |
限制第三方网站跨站请求携带 Cookie |
Controller 层 (@RestController)
职责:接收 HTTP 请求、参数校验(@Valid)、调用 Service、封装 R 响应
规则:不写业务逻辑,只做参数校验和结果封装
Service 层 (@Service)
职责:业务逻辑编排、事务管理(@Transactional)、校验规则、调用 Mapper
规则:所有业务异常抛出 BusinessException
Mapper 层 (@Mapper + XML)
职责:SQL 语句执行、ORM 映射
规则:复杂查询在 XML 中编写,简单查询使用注解
Entity 层
职责:数据库表映射 POJO
规则:字段与表列一一对应,不包含业务逻辑
DTO / VO
DTO:请求参数封装(可含 @Valid 注解)
VO:响应数据封装(不暴露实体敏感字段)
| 场景 | 事务注解 | 说明 |
|---|---|---|
| 注册 | @Transactional(rollbackFor = Exception.class) |
账号唯一性校验 + 插入为用户原子操作 |
| 创建预约 | @Transactional(rollbackFor = Exception.class) |
套餐校验 + 宠物校验 + 房间校验 + 插入订单 |
| 支付定金 | @Transactional(rollbackFor = Exception.class) |
校验 + 更新支付状态 |
| 审核预约 | @Transactional(rollbackFor = Exception.class) |
更新订单 + 批量生成房态 |
| 取消订单 | @Transactional(rollbackFor = Exception.class) |
更新订单 + 释放房态 |
| 手动改房态 | @Transactional(rollbackFor = Exception.class) |
UPSERT 操作 |
| 规范 | 说明 |
|---|---|
| 统一前缀 | 所有接口以 /api 为前缀(通过 nginx 或 context-path 配置) |
| 统一响应 | 所有接口返回 R<T> 格式 |
| 分页参数 | pageNum 从 1 开始,pageSize 默认 10 |
| 日期格式 | 请求 yyyy-MM-dd,响应 yyyy-MM-dd / yyyy-MM-dd'T'HH:mm:ss |
| 认证方式 | HttpSession (Cookie-based),前端需设置 withCredentials: true |
| 权限控制 | 普通接口需登录(AuthInterceptor),/admin/** 接口需管理员(AdminInterceptor) |
| 参数校验 | 使用 @Valid + @NotBlank/@NotNull/@Size 等 Jakarta Validation 注解 |
| 测试目标 | 框架 | 覆盖内容 |
|---|---|---|
| Service 实现类 | JUnit 5 + Mockito | 核心业务逻辑(创建预约校验、状态转换、房间可用性判断) |
| 工具类 | JUnit 5 | PageUtil、PasswordConfig(MD5 加密/匹配) |
| 异常路径 | JUnit 5 + Mockito | BusinessException 的各个触发场景 |
| 测试目标 | 框架 | 覆盖内容 |
|---|---|---|
| Controller 接口 | Spring Boot Test + MockMvc | 所有 API 的请求/响应验证 |
| Mapper 映射 | MyBatis Spring Boot Test | SQL 语句正确性、结果映射 |
| 数据库交互 | @MybatisTest + H2 内存数据库 | CRUD 操作、唯一约束、事务回滚 |
1. 认证模块
- 注册成功并自动登录(Session 中有 user 属性)
- 注册已存在的账号(抛 BusinessException)
- 登录成功(Session 有 user)
- 登录失败(密码错误/账号禁用)
- 未登录访问受保护接口(返回 401)
2. 预约模块(核心)
- 创建寄养预约成功(校验通过)
- 创建预约时房间不可用(房间状态校验)
- 创建预约时宠物冲突(时间重叠校验)
- 支付定金成功(depositPaid=true)
- 重复支付(抛 BusinessException)
- 审核通过并自动锁定房间(生成 OCCUPIED 记录)
- 审核拒绝(房间未锁定)
- PENDING 状态用户直接取消(状态变为 CANCELLED)
- CONFIRMED 状态用户申请取消(状态变为 CANCEL_REQUESTED)
- 商家同意取消(状态变为 CANCELLED + 房间释放)
- 商家拒绝取消(状态恢复为 CONFIRMED)
3. 宠物房间管理
- 手动修改房态(UPSERT 正确)
- 批量修改房态(事务回滚测试)
- 查询某月房态(无记录的天默认 AVAILABLE)
- 新增/修改/删除房型
4. 用户管理
- 分页查询(正确计算 offset)
- 新增用户(MD5 加密密码)
- 修改密码(旧密码校验)