总体架构
架构思想:
分层:
Controller → Service → Entity,这三层架构体系,
Controller 层 不直接操作数据库,而是通过 subjectService 去拿数据。
业务逻辑集中在 Service 层,Controller 只负责接收请求、调用服务、返回结果。
统一的返回结果:
不直接返回裸的 List 或 对象,而是包一层 RestResponse。
成功返回 RestResponse.ok(数据)。
统一格式,前端处理简单。
后期可以很方便统一加异常码、消息、分页信息。
合理的使用对象映射:
Subject 是实体类(Entity),对应数据库。
SubjectVM、SubjectEditRequestVM 是视图模型(VM),对应前端页面。
通过 modelMapper.map(d, SubjectVM.class) 进行转换,不暴露数据库结构。进行反序列化,更加安全
使用流式编程:
1 2 3 4 5 6
| List<SubjectVM> subjectVMS = subjects.stream().map(d -> { SubjectVM subjectVM = modelMapper.map(d, SubjectVM.class); subjectVM.setId(String.valueOf(d.getId())); return subjectVM; }).collect(Collectors.toList());
|
使用 stream().map(...).collect(...),
一次性把 List<Subject> 转换成 List<SubjectVM>,代码简洁、可读性高。
common方法模板
根据id查询
1 2 3 4 5 6 7 8 9 10 11 12
| @PostMapping("/read/{id}") public RestResponse<ExamPaperReadVM> read(@PathVariable Integer id) { ExamPaperAnswer answer = examPaperAnswerService.selectById(id); ExamPaperReadVM vm = new ExamPaperReadVM(); vm.setPaper(examPaperService.examPaperToVM(answer.getExamPaperId())); vm.setAnswer(examPaperAnswerService.examPaperAnswerToVM(answer.getId())); return RestResponse.ok(vm); }
|
查询实体->转成视图vm->结果
新增
1 2 3 4 5 6 7 8 9
| @PostMapping("/add") public RestResponse<Integer> add(@RequestBody SubjectEditRequestVM model) { Subject entity = modelMapper.map(model, Subject.class); subjectService.insert(entity); return RestResponse.ok(entity.getId()); }
|
vm->转为实体->插入返回id
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @PostMapping("/answerSubmit") public RestResponse answerSubmit(@RequestBody @Valid ExamPaperSubmitVM vm) { User user = getCurrentUser(); ExamPaperAnswerInfo info = examPaperAnswerService.calculateExamPaperAnswer(vm, user); if (info == null) { return RestResponse.fail(2, "试卷不能重复做"); }
ExamPaperAnswer answer = info.getExamPaperAnswer(); String scoreVm = ExamUtil.scoreToVM(answer.getUserScore()); eventPublisher.publishEvent(new CalculateExamPaperAnswerCompleteEvent(info)); eventPublisher.publishEvent(new UserEvent(new UserEventLog( user.getId(), user.getUserName(), user.getRealName(), new Date(), user.getUserName() + " 提交试卷:" + info.getExamPaper().getName() + " 得分:" + scoreVm + " 耗时:" + ExamUtil.secondToVM(answer.getDoTime()) )));
return RestResponse.ok(scoreVm); }
|
修改
1 2 3 4 5 6 7 8 9
| @PostMapping("/edit") public RestResponse<Void> edit(@RequestBody SubjectEditRequestVM model) { Subject entity = modelMapper.map(model, Subject.class); subjectService.updateById(entity); return RestResponse.ok(); }
|
跟新增逻辑差不多
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @PostMapping("/edit") public RestResponse edit(@RequestBody @Valid ExamPaperSubmitVM vm) { if (vm.getAnswerItems().stream().anyMatch(i -> i.getDoRight()==null && i.getScore()==null)) { return RestResponse.fail(2, "有未批改题目"); } ExamPaperAnswer answer = examPaperAnswerService.selectById(vm.getId()); if (ExamPaperAnswerStatusEnum.fromCode(answer.getStatus()) == ExamPaperAnswerStatusEnum.Complete) { return RestResponse.fail(3, "试卷已完成"); }
String score = examPaperAnswerService.judge(vm); User user = getCurrentUser(); eventPublisher.publishEvent(new UserEvent(new UserEventLog( user.getId(), user.getUserName(), user.getRealName(), new Date(), user.getUserName() + "批改试卷" + answer.getPaperName() + "得分" + score )));
return RestResponse.ok(score); }
|
删除
1 2 3 4 5 6
| @PostMapping("/delete/{id}") public RestResponse<Void> delete(@PathVariable Integer id) { subjectService.deleteById(id); return RestResponse.ok(); }
|
分页查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @PostMapping("/pageList") public RestResponse<PageInfo<ExamPaperAnswerPageResponseVM>> pagelist( @RequestBody @Valid ExamPaperAnswerPageVM model) {
model.setCreateUser(getCurrentUser().getId()); PageInfo<ExamPaperAnswer> pageInfo = examPaperAnswerService.studentPage(model);
PageInfo<ExamPaperAnswerPageResponseVM> page = PageInfoHelper.copyMap(pageInfo, e -> { ExamPaperAnswerPageResponseVM vm = modelMapper.map(e, ExamPaperAnswerPageResponseVM.class); vm.setSubjectName(subjectService.selectById(vm.getSubjectId()).getName()); vm.setDoTime(ExamUtil.secondToVM(e.getDoTime())); vm.setSystemScore(ExamUtil.scoreToVM(e.getSystemScore())); vm.setUserScore(ExamUtil.scoreToVM(e.getUserScore())); vm.setPaperScore(ExamUtil.scoreToVM(e.getPaperScore())); vm.setCreateTime(DateTimeUtil.dateFormat(e.getCreateTime())); return vm; });
return RestResponse.ok(page); }
|
分页查询 → map 转 VM → RestResponse
上传文件
controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @RestController @RequestMapping("/upload") public class FileUploadController {
@Autowired private OSS ossClient;
@Value("${aliyun.oss.bucketName}") private String bucketName;
@Value("${aliyun.oss.endpoint}") private String endpoint;
@PostMapping("/oss") public String uploadFile(@RequestParam("file") MultipartFile file) throws IOException { String originalFilename = file.getOriginalFilename(); String fileName = UUID.randomUUID().toString() + "-" + originalFilename;
ossClient.putObject(bucketName, fileName, file.getInputStream());
String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName; return fileUrl; } }
|
application.yml 配置:
1 2 3 4 5 6 7
| aliyun: oss: endpoint: oss-cn-hangzhou.aliyuncs.com accessKeyId: YOUR_ACCESS_KEY_ID accessKeySecret: YOUR_ACCESS_KEY_SECRET bucketName: your-bucket-name
|
OSSClient 配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Configuration public class OssConfig {
@Value("${aliyun.oss.endpoint}") private String endpoint;
@Value("${aliyun.oss.accessKeyId}") private String accessKeyId;
@Value("${aliyun.oss.accessKeySecret}") private String accessKeySecret;
@Bean public OSS ossClient() { return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); } }
|
拦截器
实现一个自定义的响应内容拦截器(Response Filter),目的是在 Servlet 处理完请求之后、响应给客户端之前,对响应数据进行读取、修改或记录。
ResponseWrapper:
拦截 Servlet 响应的输出数据
保存到内存中的 ByteArrayOutputStream
稍后读取这段响应内容进行处理(例如字符串替换、加密、日志记录等)
ResponseFilter:
这是一个实现了 javax.servlet.Filter 的过滤器,你可以把它理解为一个 HTTP 请求/响应的中间件。
它在请求处理链(Filter Chain)中被调用,介于客户端和 Servlet 之间。
1 2
| 客户端请求 --> Filter --> Controller/Servlet 生成响应 --> Filter 拦截响应内容 --> 客户端返回修改后的响应
|
注解
动态参数用 @PathVariable,将 URL 中的占位符参数绑定到控制器方法的参数上。比如:
1 2 3
| @RequestMapping(value = "/subject/select/{id}", method = RequestMethod.POST) public RestResponse<SubjectEditRequestVM> select(@PathVariable Integer id)
|
@RequestMapping,明确 URL 和 HTTP 方法。
1 2 3
| @RequestMapping(value = "/subject/select/{id}", method = RequestMethod.POST) public RestResponse<SubjectEditRequestVM> select(@PathVariable Integer id)
|
@RestController
等同于 @Controller + @ResponseBody。
将该类标记为 Spring MVC 的控制器,并自动将方法返回值序列化为 JSON(或其他格式)写入 HTTP 响应体。
@RequestBody
- 作用:
- 将 HTTP 请求体中的 JSON(或其他格式)反序列化为方法参数的 Java 对象。
- 使用场景:
- 接收 POST、PUT 等请求中传来的 JSON 数据。
1 2 3 4 5
| @PostMapping("/edit") public RestResponse edit(@RequestBody ExamPaperSubmitVM vm) { ... }
|
@Valid
- 作用:
- 启用对方法参数(通常与
@RequestBody 或表单对象)上的 JSR-303/JSR-380 校验注解(如 @NotNull、@Size)的校验。
- 使用场景:
- 当你在 VM 或 DTO 类上使用了校验注解,需要在 Controller 中自动触发校验,并在验证失败时抛出异常。
1 2
| 、 public RestResponse edit(@RequestBody @Valid ExamPaperSubmitVM vm) { ... }
|
@Autowired
- 作用:
- 将 Spring 容器中的 Bean 自动注入到当前类的字段或构造函数中。
- 使用场景:
- 在 Controller、Service 等类中注入依赖的 service、repository、publisher 等。
1 2 3 4 5
| @Autowired public ExamPaperAnswerController(ExamPaperAnswerService examPaperAnswerService, ...) { this.examPaperAnswerService = examPaperAnswerService; ... }
|
@EventListener / ApplicationEventPublisher)
- 作用:
ApplicationEventPublisher:通过 publishEvent() 发布自定义事件。
@EventListener(可选):在其他 bean 中使用,监听并处理被发布的事件。
- 使用场景:
1
| eventPublisher.publishEvent(new UserEvent(userEventLog));
|
@RequestParam
1. 基本用法(绑定查询参数):
1 2 3 4
| @GetMapping("/hello") public String hello(@RequestParam String name) { return "Hello " + name; }
|
访问 /hello?name=Tom,控制器会自动把 name=Tom 绑定到方法参数 name 上。
2. 设置参数名称和默认值:
1 2 3 4
| @GetMapping("/hello") public String hello(@RequestParam(value = "name", required = false, defaultValue = "Guest") String name) { return "Hello " + name; }
|
value:绑定的参数名
required=false:表示可以不传
defaultValue="Guest":如果没传,则使用默认值
实体类
设计原则
职责单一
每个实体类只负责映射一张表或一组业务概念数据,不要在同一个类里混合多种无关数据。
注解规范
- 持久层:使用 MyBatis‑Plus 时,类上加
@TableName("..."),主键字段加 @TableId;
- JPA:使用
@Entity、@Table、@Id 等;二者二选一,保持全项目一致。
字段类型
- 日期/时间:建议使用
java.time.LocalDateTime,而非过时的 java.util.Date;
- 枚举字段:可设计成
String 存储 code,再在业务层或实体层加上枚举转换方法。
Lombok 简化
- 使用
@Data 或者更精细的 @Getter/@Setter、@Builder、@NoArgsConstructor/@AllArgsConstructor,减少模板代码;
- 如果需要链式调用,可以加
@Accessors(chain = true)。
字段校验
- 对于输入层(DTO/VM)使用
@NotNull、@Size 等进行校验;
- 实体层一般不加校验注解,保持纯粹的映射。
编码规范
- 字段命名:
camelCase;
- 类命名:
PascalCase,与表/业务概念一一对应;
- 避免在实体里写过多逻辑方法,仅保持必要的枚举转换、辅助判断。
公共字段
若多数表都有 createTime、updateTime,可抽 BaseEntity:
1 2 3 4 5 6 7
| @Data public class BaseEntity { protected LocalDateTime createTime; protected LocalDateTime updateTime; }
|
然后其他的类继承BaseEntity即可
模板
数据库表映射
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Data @NoArgsConstructor @AllArgsConstructor @Builder @Accessors(chain = true) @TableName("t_{表名}") public class {EntityName} {
@TableId(type = IdType.ASSIGN_UUID) private String id;
private String code;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
}
|
@Data:自动生成 getter/setter、toString、equals、hashCode。
@Builder + @Accessors(chain = true):支持链式构建,更清晰。
@TableName:指定表名;@TableId:指定主键生成策略。
LocalDateTime:最新的 Java 时间 API。
POJO 对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| */ @Data @NoArgsConstructor @AllArgsConstructor @Builder public class QuestionObject {
private String titleContent;
private String analyze;
private List<QuestionItemObject> options;
private String correct; }
|
这种对象不加 ORM 注解,仅加 Lombok。
字段名称尽量自解释;
用 List<QuestionItemObject> 等复合类型时,可结合 Jackson 自动序列化。
redis配合数据库设计
配合策略
1.使用 Hash 存任务信息,避免多次 Redis 请求
1 2 3 4 5 6 7 8 9
| Map<Object,Object> meta = redisTemplate.opsForHash().entries(TASK_PREFIX+taskId); Map<String, String> meta = new HashMap<>(); meta.put("code", code); meta.put("status", "Open"); meta.put("start", String.valueOf(startTs)); meta.put("end", String.valueOf(endTs)); redisTemplate.opsForHash().putAll("sign:task:" + taskId, meta); redisTemplate.expire("sign:task:" + taskId, Duration.between(LocalDateTime.now(), end));
|
避免多个 Redis key;
sign() 时一次读取全部字段,无需多次调用;
更加一致和规范。
2.异步 + 队列方式持久化
定时任务写回(适合非实时要求)
- 每分钟、每 5 分钟定时从 Redis 中读取新增数据批量写入 MySQL;
- 可结合 Bitmap/ZSet 等结构做更多统计。
使用 Redis Stream 或 MQ 异步写回
1 2
| redisTemplate.opsForStream().add("stream:sign-record", Map.of("taskId", taskId, "studentId", studentId));
|
异步消费者从中读取写入数据库。
这样加快请求的返回时间,不影响主线程的工作
3.高并发保护:加锁 + 幂等校验
使用 isMember() 防止重复签到;
高并发场景下,仍可能发生并发写 Redis;
可加上 Redisson 分布式锁或 Lua 脚本实现原子性:
1 2 3 4 5 6 7 8
| -- Lua 示例:只有未签到时才添加,并返回 true if redis.call("SISMEMBER", KEYS[1], ARGV[1]) == 0 then redis.call("SADD", KEYS[1], ARGV[1]) return 1 else return 0 end
|
4.缓存失效策略
所有 Redis 数据都应设置过期时间:
- 任务缓存:根据
endTime 动态设置;
- 签到记录:可设置为任务结束后几天;
防止 Redis 缓存积压,资源不释放。
比如验证码可以使用这个策略,设置验证码的过期时间