# VolunteerHub 志愿者报名平台 — Paper 2 Section D 模拟题参考工程
> 这是**一道完整的 20 分模拟题**(结构、分值、问法与 C25 真题 Section D 一致,但业务完全不同),
> 加上一份可运行的 Spring Boot 参考实现。
---
# 题目 / Question(中英双语)
**EN —** A non-profit organisation runs community volunteer events. Volunteers sign up for events; one volunteer may join many events, and one event has many volunteers. Every sign-up records **when** the volunteer signed up and **how many hours** he or she contributed. Organisers need a page that lists every event together with its volunteers, the number of sign-ups, the total contributed hours and the **fill rate** (number of sign-ups ÷ capacity). Volunteers also need a REST endpoint to sign up for an event.
The system is built with **Spring Boot, Spring Data JPA and Thymeleaf**. Controllers interact directly with the JPA repositories (no service layer in the original paper). **You do NOT need to write import statements or getters/setters. State any assumptions.**
**中 —** 公益组织举办社区志愿活动:一个志愿者可以报名多个活动,一个活动有多个志愿者。每条报名记录**报名日期**与**服务时数**。运营页要列出每个活动及其志愿者、报名人数、总服务时数、**满员率**(报名人数 ÷ 容量)。志愿者还需要一个报名用的 REST 接口。
系统使用 **Spring Boot + Spring Data JPA + Thymeleaf**;Controller 直接调用 JPA Repository(原卷不要求 Service 层)。**不需要写 import,也不需要写 getter/setter;可声明假设。**
---
## 五个小问 / Five sub-questions
| # | 题目 / Question | 分值 | 代码位置 |
|---|---|---|---|
| **a** | **EN** — Design and write the Java code for the entity models `Volunteer`, `Event` and `Signup`. You may add attributes and methods where necessary.
**中** — 设计并写出 `Volunteer`、`Event`、`Signup` 三个实体;可按需增加属性与方法。 | 6 | `model/Volunteer.java`、`model/Event.java`、`model/Signup.java` |
| **b** | **EN** — Write the JPA Repository code with the necessary JPA Queries (if any) to support the use case *Display Events and their Volunteers*.
**中** — 写出支持“显示活动及其志愿者”页面的 JPA Repository 代码(如有需要,附上查询)。 | 2 | `repository/EventRepository.java` |
| **c** | **EN** — Write the Controller code for the use case *Display Events and their Volunteers*.
**中** — 写出该用例的 Controller 代码。 | 2 | `web/EventController.java` |
| **d** | **EN** — Write the HTML with Thymeleaf: for each event show its title, date, location, capacity, number of sign-ups, total hours and fill rate as a percentage; for each volunteer show the name and the hours contributed to that event.
**中** — 用 Thymeleaf 写页面:每个活动显示标题、日期、地点、容量、报名人数、总时数、满员率(百分比);每个志愿者显示姓名与该活动中的服务时数。 | 5 | `src/main/resources/templates/events.html` |
| **e** | **EN** — A REST API allows a volunteer to sign up: `POST /api/events/{eventId}/signup`, body contains `volunteerId` and `hours`. If the event does not exist, or it is already full, return **422 Unprocessable Entity**; otherwise return **201 Created**.
**中** — 报名 REST 接口:`POST /api/events/{eventId}/signup`,请求体含 `volunteerId` 与 `hours`;活动不存在或已满员返回 **422**,成功返回 **201**。 | 5 | `api/SignupRestController.java` + `service/SignupService.java` |
> **关于 Service**:原卷明确“Service 层以后再加”,所以考试答案不需要 Service。
> 本工程额外写了 `service/` 两个文件,是用**枚举**表达业务结果的练习版(`SignupResult` + `SignupService`),
> 用来对照“Controller 直连 Repository”与“分层”两种写法的差别。
---
## 文件结构 / Project layout
```
volunteer-hub-练习/
├── README.md ← 本文件(题目 + 说明)
├── pom.xml ← Spring Boot 3.3 / Java 17
├── src/main/java/com/example/volunteer/
│ ├── VolunteerHubApplication.java ← 启动类(含题目总述)
│ ├── model/
│ │ ├── Volunteer.java ← (a) 反向端 @OneToMany
│ │ ├── Event.java ← (a)(d) 三个计算属性 + cascade/orphanRemoval
│ │ └── Signup.java ← (a) 中间实体,两个 @ManyToOne
│ ├── repository/
│ │ ├── EventRepository.java ← (b) @Query + join fetch + distinct
│ │ ├── VolunteerRepository.java ← (e) 校验志愿者
│ │ └── SignupRepository.java ← (e) save + existsBy…
│ ├── service/
│ │ ├── SignupResult.java ← 业务结果枚举(6 种)
│ │ └── SignupService.java ← 五条业务规则 + @Transactional
│ ├── web/EventController.java ← (c) @Controller + Model + 视图名
│ ├── api/SignupRestController.java ← (e) @RestController + 422/201
│ └── config/DataInitializer.java ← 示例数据(非考点)
├── src/main/resources/
│ ├── application.properties ← H2 + JPA 配置
│ └── templates/events.html ← (d) Thymeleaf 页面
└── relations-examples/ ← 【本题之外】6 种关系映射示例
├── README.md
├── 01-one-to-many/ ← 1:N(Post / Comment)
├── 02-many-to-many/ ← M:N 无属性(Student / Course)
├── 03-many-to-many-with-attributes/ ← M:N 带属性(Employee / Project / Assignment)
├── 04-one-to-one/ ← 1:1(Employee / Desk)
├── 05-self-reference/ ← 自关联(Category)
└── 06-two-relations/ ← 同一对实体两条关系(Doctor / Department)
```
---
## 怎么跑 / How to run
```bash
cd volunteer-hub-练习
mvn spring-boot:run
```
- 页面:
- 报名接口:
```bash
curl -i -X POST http://localhost:8080/api/events/1/signup \
-H "Content-Type: application/json" \
-d '{"volunteerId":1,"hours":3}'
# 期望:HTTP/1.1 201 Created
curl -i -X POST http://localhost:8080/api/events/999/signup \
-H "Content-Type: application/json" \
-d '{"volunteerId":1,"hours":3}'
# 期望:HTTP/1.1 422 Unprocessable Entity
```
H2 控制台:(JDBC URL `jdbc:h2:mem:volunteerdb`,用户 `sa`,密码空)
---
## 答案要点 / Marking points
| 问 | 关键判断 | 常见失分 |
|---|---|---|
| a | **M:N 带字段 → 拆 `Signup` 实体**,不是 `@ManyToMany` | 写成 `@ManyToMany`,丢掉 `hours` |
| b | 2 分只需一个接口;正式写法用 `left join fetch` + `distinct` | JPQL 写成 SQL;用 `join` 丢掉无报名的活动 |
| c | `@Controller` + `Model` + 返回视图名 | 用 `@RestController`;键名与模板不一致 |
| d | 报名数 / 总时数 / 满员率×100 / 志愿者名与时数 | 漏项;满员率没乘 100 |
| e | 两个 422 条件 + 201,`@RestController` + `ResponseEntity` | 返回 404/400;忘判满员;`findById().get()` 未判空 |
---
## 说明 / Notes
- 每份 Java 文件的头部注释里都写着它对应的**中英双语题目**,方便对照“题目要求 ↔ 代码实现”。
- `relations-examples/` 不参与编译(在 `src` 之外),每题独立,可直接复制注解。
- 本机当前没有 JDK / Maven(`javac`、`mvn` 均未安装),因此**未做编译验证**;代码按 Spring Boot 3.3 + Java 17 编写,若你的环境是 Boot 2.x,需把 `jakarta.persistence` 改回 `javax.persistence`。
---
# 追加:样卷补充五块(13 分,真题里没有)
原工程只覆盖**真题风格**的五问。为了覆盖 **C25 样卷**(Minishop)多出来的考点,追加了以下内容;
详细讲解见 [`docs/样卷补充-五块.md`](docs/样卷补充-五块.md)。
| # | 块 | 分值 | 新增文件 |
|---|---|---|---|
| 1 | Q4(b) 聚合查询(TOP N + HAVING) | 2 | `repository/SignupRepository#findTopVolunteers`、`service/SignupQueryService#topVolunteers`、`service/VolunteerHours` |
| 2 | Q4(c) 动态条件查询 | 3 | `dto/SignupSearch`、`service/SignupQueryService#search`、`service/SignupView`、`api/QueryApiController` |
| 3 | Q4(e) 表单 HTML + PRG | 3 | `dto/EventForm`、`web/EventFormController`、`templates/event-form.html` |
| 4 | Q5(a) 校验分层问答 | 3 | `dto/EventForm` 的 Bean Validation 注解 + 文档第 4 节(可直接背的英文答案) |
| 5 | Q5(b) 数据层单元测试 | 2 | `src/test/java/com/example/volunteer/SignupRepositoryTest`(`@DataJpaTest`)、`EventControllerTest`(`@WebMvcTest`) |
## 新增接口 / New endpoints
```bash
# 样卷 Q4(b):累计时数 ≥ 2 的前 5 名志愿者
curl "http://localhost:8080/api/stats/top-volunteers?minHours=2&limit=5"
# → [{"volunteerId":1,"name":"Alice Tan","totalHours":6}, …]
# 样卷 Q4(c):任意组合查询(每个参数都可省略)
curl "http://localhost:8080/api/signups?volunteerName=ali&from=2026-11-01"
curl "http://localhost:8080/api/signups?eventId=1"
curl "http://localhost:8080/api/signups"
```
## 新增页面 / New page
- `GET /events/new` → 表单页(`templates/event-form.html`)
- `POST /events` → 提交;校验失败回显红字,成功 `302 → /events`
## 跑测试 / Run the tests
```bash
mvn test
```
| 测试类 | 注解 | 验证什么 |
|---|---|---|
| `SignupRepositoryTest` | `@DataJpaTest` | 创建报名(id 生成、exists 查询)、累计时数聚合 |
| `EventControllerTest` | `@WebMvcTest` | `/events` 返回 200、视图名 `events`、model 里有 `events` |
## 更新后的目录(只列新增部分)
```
volunteer-hub-练习/
├── docs/
│ └── 样卷补充-五块.md ← 五块的讲解 + Q5(a) 可背答案
├── src/main/java/com/example/volunteer/
│ ├── dto/
│ │ ├── EventForm.java ← @NotBlank/@Min/@FutureOrPresent(Q5a 的落地)
│ │ └── SignupSearch.java ← 四个可选条件(Q4c)
│ ├── service/
│ │ ├── SignupQueryService.java ← 聚合 + Specification 动态查询
│ │ ├── VolunteerHours.java ← 聚合结果投影
│ │ └── SignupView.java ← 动态查询结果投影
│ ├── api/QueryApiController.java ← 两个查询接口的 HTTP 出口
│ └── web/EventFormController.java ← GET 表单 / POST + @Valid + PRG
├── src/main/resources/templates/event-form.html
└── src/test/java/com/example/volunteer/
├── SignupRepositoryTest.java
└── EventControllerTest.java
```