# 宠物服务管理系统 — 服务端详细设计文档 ## 1. 技术栈 | 技术 | 版本 | 用途 | |------|------|------| | 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 /) ## 2. 项目结构 ``` 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 {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 ``` ## 3. 数据模型 ### 3.1 实体关系图 (E-R) ```mermaid 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 } ``` ### 3.2 核心 SQL 脚本设计要点 **UNIQUE 索引:** `boarding_room_status` 表使用 `UNIQUE KEY uk_room_date (room_id, date)` 确保每个房型每天只有一条记录。通过 `INSERT ... ON DUPLICATE KEY UPDATE` 实现插入或更新(upsert): ```sql 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)` 避免浮点精度丢失。 ## 4. 通用基础设施 ### 4.1 统一响应 R\ ```java public class R { private int code; // 状态码 private String message; // 提示信息 private T data; // 响应数据 public static R ok(T data) // 成功返回 data (code=200, message="success") public static R ok() // 成功无数据 (code=200) public static R error(int code, String message) // 指定错误码 public static R error(String message) // 默认 400 错误 } ``` **响应示例:** ```json // 成功 { "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 } ``` ### 4.2 业务异常 BusinessException ```java 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) — 资源不存在 ``` ### 4.3 全局异常处理 GlobalExceptionHandler 拦截所有 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 | 打印堆栈后返回 "服务器内部错误" | ### 4.4 分页实现 PageVo + PageUtil ```java public class PageVo { private List data; // 当前页数据 private long total; // 总记录数 private int pageNum; // 当前页码(从1开始) private int pageSize; // 每页大小 } ``` `PageUtil` 工具类封装分页查询范式,通过 `Supplier` 解耦列表查询和总数查询: ```java public static PageVo page(int pageNum, int pageSize, Supplier> queryList, Supplier queryCount) { // 1. 执行列表查询: queryList.get() // 2. 执行总数查询: queryCount.get() // 3. 封装 PageVo 返回 } ``` **算法步骤:** 1. Controller 接收 `pageNum`, `pageSize` 及筛选参数 2. Service 计算 `offset = (pageNum - 1) * pageSize` 3. MyBatis XML 中使用 `LIMIT #{offset}, #{pageSize}` 分页查询 4. 同时执行 `SELECT COUNT(*)` 获取总记录数 5. PageUtil 将两部分结果封装为 `PageVo` 返回 ## 5. 认证与安全 ### 5.1 HttpSession 认证机制 | 项目 | 说明 | |------|------| | 会话存储 | 服务端内存 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分钟) | ### 5.2 AuthInterceptor 拦截器 **配置路径:** 拦截所有 `/api/**`,排除 `/auth/**`、`/swagger-ui/**`、`/v3/api-docs/**` **算法步骤:** 1. 调用 `request.getSession(false)` 获取 Session(不自动创建) 2. 若 session == null → 抛出 `UnauthorizedException("未登录")` 3. 从 session 获取 `UserVo user = (UserVo) session.getAttribute("user")` 4. 若 user == null → 抛出 `UnauthorizedException("未登录或Session已过期")` 5. 将 `userId` 和 `role` 设置到 request attribute,供 Controller 通过 `request.getAttribute("userId")` 获取 ```mermaid 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 ``` ### 5.3 AdminInterceptor 拦截器 **配置路径:** 匹配 `/admin/**`(在 AuthInterceptor 之后执行,所以 request attribute 中已有 role) **算法步骤:** 1. 从 request attribute 获取 `role` 2. 若 `role != "admin"` → 抛出 `ForbiddenException("无权访问,需要管理员权限")` 3. 放行 ### 5.4 密码加密方案 使用 **MD5 + 固定盐值** 加密: ```java 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) ``` ### 5.5 跨域配置 ```java registry.addMapping("/**") .allowedOriginPatterns("*") // 允许所有来源(开发阶段) .allowCredentials(true) // 允许携带 Cookie(必须) .allowedMethods("GET","POST","PUT","DELETE","OPTIONS") .allowedHeaders("*"); ``` > 注意:`allowCredentials(true)` 与 `allowedOrigins("*")` 不能同时使用,使用 `allowedOriginPatterns("*")` 替代。 ## 6. 模块详细设计 ### 6.1 认证模块 (AuthController) **路由映射:** `@RequestMapping("/auth")` #### Controller 定义 | 方法 | 路径 | 请求体 | 返回 | 校验 | 说明 | |------|------|--------|------|------|------| | POST | /auth/register | RegisterDto | R\ | @Valid | 注册并自动登录 | | POST | /auth/login | LoginDto | R\ | @Valid | 登录 | | POST | /auth/logout | - | R\ | - | 登出(销毁 Session) | #### DTO / VO 定义 **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 | 登录账号 | | email | String | 邮箱 | | phone | String | 联系方式 | | avatar | String | 头像 URL | | role | String | 角色 (user/admin) | | enable | Integer | 状态 (0禁用 1启用) | #### Service 算法 **register(RegisterDto dto, HttpServletRequest request):** 1. 调用 `userMapper.selectByAccount(dto.getAccount())` 检查账号是否已存在 2. 若存在 → 抛出 `BusinessException("账号已存在")` 3. 创建 User 对象,设置 `password = passwordConfig.encrypt(dto.getPassword())`, `role = "user"`, `enable = 1` 4. 调用 `userMapper.insert(user)` 插入数据库 5. 调用 `toUserVo(user)` 转为 UserVo 6. 调用 `request.getSession().setAttribute("user", vo)` 自动登录 7. 返回 UserVo **login(LoginDto dto, HttpServletRequest request):** 1. 调用 `userMapper.selectByAccount(dto.getAccount())` 查找用户 2. 若查不到 → 抛出 `BusinessException("账号或密码错误")` 3. 检查 `user.getEnable() == 0` → 抛出 `BusinessException("账号已被禁用")` 4. 调用 `passwordConfig.matches(dto.getPassword(), user.getPassword())` 校验密码 5. 密码不匹配 → 抛出 `BusinessException("账号或密码错误")` 6. 转为 UserVo,存入 Session 7. 返回 UserVo #### 时序图:登录流程 ```mermaid 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 { code:200, data: { id, username, role, ... } } end ``` ### 6.2 用户模块 (UserController) **路由映射:** `@RequestMapping("/user")` #### Controller 定义 | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /user | query: pageNum, pageSize, username, account, role, enable | R\\> | 分页查询用户列表 | | GET | /user/{id} | path: id | R\ | 按 ID 查询用户 | | POST | /user | UserDto | R\ | 新增用户 | | PUT | /user | UserDto | R\ | 修改用户 | | DELETE | /user | query: ids (逗号分隔) | R\ | 批量删除用户 | | PUT | /user/updateStatus | UpdateUserStatusDto | R\ | 启用/禁用用户 | | PUT | /user/updatePassword | UpdateUserPasswordDto | R\ | 修改密码 | | PUT | /user/updateAvatar | UpdateUserAvatarDto | R\ | 修改头像 | #### DTO / VO 定义 **UserDto** (src/dto/user/UserDto.java) | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | id | Long | 编辑时必填 | 用户 ID | | account | String | 新增时必填 | 登录账号 | | username | String | 是 | 用户名 | | password | String | 新增时必填 | 密码 | | email | 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=启用 | #### Service 算法 **queryByPage(pageNum, pageSize, username, account, role, enable):** 1. 计算 `offset = (pageNum - 1) * pageSize` 2. 调用 `userMapper.selectByPage(offset, pageSize, username, account, role, enable)` 查询当前页 3. 调用 `userMapper.countByCondition(username, account, role, enable)` 查询总数 4. 通过 `PageUtil.page()` 封装并返回 **add(UserDto dto):** 1. 校验账号唯一性:`userMapper.selectByAccount(dto.getAccount())` 若存在则抛异常 2. 创建 User 对象,密码 MD5 加密 3. `userMapper.insert(user)` 插入 **edit(UserDto dto):** 1. 修改 User 对象(密码为空字符串时不修改密码) 2. `userMapper.update(user)` 动态更新非空字段 **updatePassword(UpdateUserPasswordDto dto):** 1. 查询用户:`userMapper.selectById(dto.getId())` 2. 校验旧密码:`passwordConfig.matches(dto.getOldPassword(), user.getPassword())` 3. 更新新密码:`userMapper.updatePassword(id, encrypt(newPassword))` **deleteByIds(List\ ids):** 1. 调用 `userMapper.deleteByIds(ids)` 批量物理删除 #### 流程图:分页查询 ```mermaid 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] M --> N[返回前端: { data: [...], total: 50, pageNum: 1, pageSize: 10 }] ``` ### 6.3 宠物模块 (PetController) **路由映射:** `@RequestMapping("/pets")` #### Controller 定义 | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /pets | - | R\\> | 获取当前用户宠物列表 | | POST | /pets | PetDto | R\ | 新增宠物 | | GET | /pets/{id} | path: id | R\ | 获取宠物详情 | | PUT | /pets/{id} | path: id + PetDto | R\ | 修改宠物信息 | | DELETE | /pets/{id} | path: id | R\ | 删除宠物(级联删除疫苗/驱虫) | | GET | /pets/{id}/vaccinations | path: id | R\\> | 获取疫苗记录 | | POST | /pets/{id}/vaccinations | path: id + VaccinationDto | R\ | 新增疫苗记录 | | GET | /pets/{id}/deworming | path: id | R\\> | 获取驱虫记录 | | POST | /pets/{id}/deworming | path: id + DewormingDto | R\ | 新增驱虫记录 | > 所有接口从 request attribute 获取当前登录用户 ID,校验宠物归属权。 #### DTO / VO 定义 **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\ | 疫苗记录列表(仅详情接口返回) | | dewormings | List\ | 驱虫记录列表(仅详情接口返回) | **VaccinationVo / DewormingVo** — 对应实体各字段 + createdAt。 #### Service 算法 **add(PetDto dto, Long userId):** 1. 创建 Pet 实体,设置 `userId`,拷贝 dto 属性 2. `petMapper.insert(pet)` — MyBatis 通过 `useGeneratedKeys` 回写 id 3. 调用 `toPetVo(pet)` 转换后返回 **delete(Long id, Long userId):** 1. 查询宠物并校验归属权(`pet.getUserId().equals(userId)`) 2. 级联删除:`vaccinationRecordMapper.deleteByPetId(id)` 3. 级联删除:`dewormingRecordMapper.deleteByPetId(id)` 4. `petMapper.deleteById(id)` **checkPetOwnership(petId, userId) — 归属校验工具方法:** 1. `petMapper.selectById(petId)` 查询宠物 2. 若宠物不存在或 `pet.getUserId() != userId` → 抛出 `BusinessException("宠物不存在或无权访问")` #### 时序图:新增宠物并添加疫苗记录 ```mermaid 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 { 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 { code:200, data: {...} } ``` ### 6.4 寄养房间模块 #### 6.4.1 用户侧 (BoardingRoomController) **路由映射:** `@RequestMapping("/boarding-rooms")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /boarding-rooms | - | R\\> | 获取所有启用的房型 | | GET | /boarding-rooms/{id}/status | query: month (yyyy-MM) | R\\> | 获取指定房型某月状态 | **请求示例:** `GET /api/boarding-rooms/1/status?month=2026-07` **响应示例:** ```json { "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 表示无) | #### 6.4.2 管理员侧 (AdminBoardingRoomController) **路由映射:** `@RequestMapping("/admin/boarding-rooms")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /admin/boarding-rooms | - | R\\> | 获取所有房型(含已禁用) | | POST | /admin/boarding-rooms | BoardingRoomCreateDto | R\ | 新增房型 | | PUT | /admin/boarding-rooms/{id} | BoardingRoomCreateDto | R\ | 修改房型信息 | | DELETE | /admin/boarding-rooms/{id} | path: id | R\ | 删除房型(逻辑删除) | | GET | /admin/boarding-rooms/{id}/status | query: month | R\\> | 获取房型某月状态(管理员,含 appointmentId) | | PUT | /admin/boarding-rooms/{roomId}/status | RoomStatusDto | R\ | 手动修改某日房态 | | PUT | /admin/boarding-rooms/{roomId}/status/batch | BatchRoomStatusDto | R\ | 批量修改日期范围房态 | | GET | /admin/boarding-rooms/today-status | - | R\ | 今日所有房型入住概览 | #### DTO / VO 定义 **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**(新增 VO) | 字段 | 类型 | 说明 | |------|------|------| | roomName | String | 房型名称 | | petName | String | 宠物名称 | | ownerName | String | 主人姓名 | #### Service 算法 **listRooms():** 1. 调用 `boardingRoomMapper.selectActive()` 查询 `is_active = 1` 的所有房型 2. 转为 `BoardingRoomVo` 列表返回 **getStatus(Long roomId, String month):** 1. 解析 `month = "2026-07"` 为 `YearMonth`,计算 `startDate = 当月1日`, `endDate = 当月最后一天` 2. 调用 `boardingRoomStatusMapper.selectByRoomAndDateRange(roomId, startDate, endDate)` 查询已有记录 3. 将查询结果构建为 `Map` 用于快速查找 4. 遍历当月每一天: - 若 statusMap 中存在该日期 → 使用数据库中的 status - 若不存在 → 默认返回 `AVAILABLE` 5. 返回 `List` **updateStatus(Long roomId, RoomStatusDto dto):** 1. 创建 `BoardingRoomStatus` 实体:`roomId, date, status` 2. 调用 `boardingRoomStatusMapper.insertOrUpdate(status)` — 使用 `ON DUPLICATE KEY UPDATE` 实现 upsert 3. 若原记录有关联 `appointment_id`,手动修改时自动置为 NULL(由 SQL 中的 `appointment_id = #{appointmentId}` 控制,传入 null 时清空) **批量修改状态(batchUpdateStatus):** 1. 校验 `startDate <= endDate`,差值不超过 31 天 2. 在事务中循环每个日期,调用 `insertOrUpdate` 或批量 insert 3. 对于 OCCUPIED 状态的日期,appointment_id 置为 NULL(手动修改) #### 时序图:管理员修改房间状态 ```mermaid sequenceDiagram 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
status='MAINTENANCE',
appointment_id=NULL DB-->>-M: 1 (inserted or updated) M-->>-S: 1 S-->>-C: true C-->>-A: R { code:200, data: true } ``` #### 流程图:房间状态变更逻辑 ```mermaid 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[拒绝: 存在关联订单,
请先在订单管理中处理] CheckOrder -->|否| DoUpdate CheckConflict -->|否| DoUpdate[执行 UPDATE] DoUpdate --> Upsert[INSERT ... ON DUPLICATE KEY UPDATE] Upsert --> Result{执行结果} Result -->|成功| Success[返回 true] Result -->|失败| Error[抛出 BusinessException] ``` **请求/响应示例:** **新增房型:** ```json // 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小时监控" } } ``` **批量修改:** ```json // 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 } ``` **今日概览:** ```json // 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": "李四" } ] } } ``` ### 6.5 套餐模块 #### 6.5.1 用户侧 (PackageController) **路由映射:** `@RequestMapping("/packages")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /packages | query: type (BOARDING/GROOMING) | R\\> | 获取套餐列表,可按类型筛选 | | GET | /packages/{id} | path: id | R\ | 获取套餐详情 | **请求/响应示例:** ```json // 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" } ] } ``` #### 6.5.2 管理员侧 (AdminPackageController) **路由映射:** `@RequestMapping("/admin/packages")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | POST | /admin/packages | PackageDto | R\ | 新增套餐 | | PUT | /admin/packages/{id} | PackageDto | R\ | 修改套餐 | | DELETE | /admin/packages/{id} | path: id | R\ | 删除套餐(逻辑删除) | #### DTO / VO 定义 **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 | 创建时间 | #### Service 算法 **list(String type):** 1. 若 type 为空 → 查询所有套餐;若 type 非空 → 按 type 筛选 2. `packageMapper.selectByType(type)` — SQL 中 `WHERE type = #{type} AND is_active = 1` 3. 转为 PackageVo 列表返回 **create(PackageDto dto):** 1. 若 `isActive` 为空,默认设为 1 2. 创建 ServicePackage 实体,拷贝属性 3. `packageMapper.insert(pkg)` — 返回自动生成的 id 4. 转 PackageVo 返回 **delete(Long id):** 1. 调用 `packageMapper.deleteById(id)` — SQL: `UPDATE package SET is_active = 0 WHERE id = #{id}` 2. 逻辑删除,不物理删除 ### 6.6 预约订单模块(最复杂模块) #### 6.6.1 用户侧 (AppointmentController) **路由映射:** `@RequestMapping("/appointments")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | POST | /appointments | AppointmentDto | R\ | 提交预约 | | GET | /appointments | - | R\\> | 获取我的预约列表 | | GET | /appointments/{id} | path: id | R\ | 获取预约详情 | | POST | /appointments/{id}/cancel | body: { reason } | R\ | 申请取消 | | POST | /appointments/{id}/pay-deposit | - | R\ | 模拟支付定金 | #### 6.6.2 管理员侧 (AdminAppointmentController) **路由映射:** `@RequestMapping("/admin/appointments")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /admin/appointments | query: pageNum, pageSize, status, type | R\\> | 管理员分页查询全部预约 | | PUT | /admin/appointments/{id}/review | ReviewDto | R\ | 审核预约(通过/拒绝) | | PUT | /admin/appointments/{id}/handle-cancel | HandleCancelDto | R\ | 处理取消申请 | #### DTO / VO 定义 **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 | 创建时间 | #### Service 算法 **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):** 1. `appointmentMapper.selectByUserId(userId)` — 按创建时间倒序 2. 转为 AppointmentVo 列表返回 **c) getById(Long id, Long userId):** 1. 查询并校验归属权 2. 转为 AppointmentVo 返回 **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,因为审核通过时已确认无冲突,取消时直接删除更简洁。 #### 状态机:预约订单状态变迁 ```mermaid 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 | 恢复原状 | #### 时序图:寄养预约创建流程 ```mermaid 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,
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
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 { code:200, data: {...} } F-->>U: 显示订单信息,引导支付 ``` #### 时序图:取消 + 处理取消流程 ```mermaid 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 } ``` **请求/响应示例:** **创建预约:** ```json // 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" } } ``` **支付定金:** ```json // POST /api/appointments/10/pay-deposit // Response: { "code": 200, "message": "success", "data": true } ``` **审核预约:** ```json // PUT /api/admin/appointments/10/review // Request (approved): { "action": "APPROVE" } // Request (rejected): { "action": "REJECT", "rejectReason": "该日期房间已被其他订单占用" } // Response: { "code": 200, "message": "success", "data": true } ``` **用户取消:** ```json // POST /api/appointments/10/cancel // Request: { "reason": "行程变更" } // Response: { "code": 200, "message": "success", "data": true } ``` **处理取消申请:** ```json // PUT /api/admin/appointments/10/handle-cancel // Request (agree): { "action": "AGREE" } // Request (reject): { "action": "REJECT" } // Response: { "code": 200, "message": "success", "data": true } ``` ### 6.7 排班模块 (AdminScheduleController) **路由映射:** `@RequestMapping("/admin/schedules")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /admin/schedules | query: month (yyyy-MM) | R\\> | 按月获取排班列表 | | POST | /admin/schedules | ScheduleDto | R\ | 新增排班 | | DELETE | /admin/schedules/{id} | path: id | R\ | 删除排班 | #### DTO / VO 定义 **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 | 创建时间 | #### Service 算法 **list(String month):** 1. 若 month 非空 → 解析为 YearMonth,计算 startDate(当月1日)和 endDate(当月最后一天) 2. 若 month 为空 → 查询全部(使用 LocalDate.MIN / MAX 作为日期范围) 3. `scheduleMapper.selectByMonth(startDate, endDate)` — SQL: `WHERE work_date BETWEEN ? AND ?` 4. 转为 ScheduleVo 列表返回 **create(ScheduleDto dto):** 1. 冲突检测:`scheduleMapper.countConflict(dto.getEmployeeName(), dto.getWorkDate(), dto.getTimeSlot())` 2. 若 count > 0 → 抛异常 "该员工在此时间段已有排班" 3. 创建 Schedule 实体,插入数据库 4. 返回 ScheduleVo **delete(Long id):** 1. `scheduleMapper.deleteById(id)` — 物理删除 #### 请求/响应示例 ```json // 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" } } ``` ### 6.8 管理员用户管理 (AdminUserController) **路由映射:** `@RequestMapping("/admin/users")` | 方法 | 路径 | 参数 | 返回 | 说明 | |------|------|------|------|------| | GET | /admin/users | query: pageNum, pageSize, username, account, role, enable | R\\> | 分页查询注册用户列表 | | GET | /admin/users/{id} | path: id | R\ | 获取用户详情及宠物档案 | #### VO 定义 **AdminUserDetailVo** (src/vo/user/AdminUserDetailVo.java) | 字段 | 类型 | 说明 | |------|------|------| | user | UserVo | 用户基本信息 | | pets | List\ | 该用户的所有宠物(含疫苗/驱虫记录) | #### Service 算法 **getAdminDetail(Long id):** 1. 查询用户:`userMapper.selectById(id)`,不存在则抛异常 2. 查询宠物列表:`petMapper.selectByUserId(id)` 3. 为每个宠物查询疫苗记录和驱虫记录: - `vaccinationRecordMapper.selectByPetId(pet.getId())` → List\ - `dewormingRecordMapper.selectByPetId(pet.getId())` → List\ 4. 组装 AdminUserDetailVo 返回 **queryByPage — 复用 UserServiceImpl.queryByPage 方法,参数同 UserController。** ## 7. 配置文件 (application.yaml) ```yaml 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 | ## 8. 模块协作规范 ### 8.1 分层职责 ``` Controller 层 (@RestController) 职责:接收 HTTP 请求、参数校验(@Valid)、调用 Service、封装 R 响应 规则:不写业务逻辑,只做参数校验和结果封装 Service 层 (@Service) 职责:业务逻辑编排、事务管理(@Transactional)、校验规则、调用 Mapper 规则:所有业务异常抛出 BusinessException Mapper 层 (@Mapper + XML) 职责:SQL 语句执行、ORM 映射 规则:复杂查询在 XML 中编写,简单查询使用注解 Entity 层 职责:数据库表映射 POJO 规则:字段与表列一一对应,不包含业务逻辑 DTO / VO DTO:请求参数封装(可含 @Valid 注解) VO:响应数据封装(不暴露实体敏感字段) ``` ### 8.2 事务管理 | 场景 | 事务注解 | 说明 | |------|---------|------| | 注册 | `@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 操作 | ### 8.3 API 规范 | 规范 | 说明 | |------|------| | 统一前缀 | 所有接口以 `/api` 为前缀(通过 nginx 或 context-path 配置) | | 统一响应 | 所有接口返回 `R` 格式 | | 分页参数 | `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 注解 | ## 9. 测试策略 ### 单元测试 | 测试目标 | 框架 | 覆盖内容 | |---------|------|---------| | 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 加密密码) - 修改密码(旧密码校验) ```