上一篇文章我们完成了项目概述和技术选型,今天我想详细聊聊后端架构的设计与实现。说实话,后端开发是整个项目的核心,也是我们花费时间最多、踩坑最密集的部分。
从用户认证到考勤逻辑,从数据库设计到API接口,每一个环节都有值得分享的经验和教训。
一、实体类设计:数据库的"骨架"
1.1 用户实体设计
用户实体是整个系统的基础,我们设计了以下字段:
@Data
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 30)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(length = 50)
private String realName;
private Long departmentId;
private Long roleId = 3L; // 默认普通员工
private Integer status = 1; // 1-启用,0-禁用
@Column(length = 500)
private String faceImageUrl; // 人脸照片URL
@CreationTimestamp
private LocalDateTime createdAt;
}
设计思考:
使用
@Data注解简化getter/setter用户名和邮箱添加唯一约束
角色ID默认值为3(普通员工)
状态字段使用Integer而不是Boolean,便于扩展
人脸照片存储URL而不是二进制数据
1.2 考勤记录实体
考勤记录实体比较复杂,包含了很多业务逻辑字段:
@Data
@Entity
@Table(name = "attendance_records")
public class AttendanceRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private LocalDate attendanceDate;
private LocalDateTime checkInTime;
private LocalDateTime checkOutTime;
private LocalDateTime expectedCheckOutTime;
private String checkInIp;
private String checkOutIp;
private BigDecimal workHours;
private BigDecimal overtimeHours;
private Integer status; // 1-正常,2-迟到,3-早退,4-缺卡
private Boolean isLate;
private Boolean isEarlyLeave;
private Integer lateMinutes;
private Integer earlyLeaveMinutes;
private String remark;
private Boolean isSupplement = false;
private Integer supplementStatus;
private String supplementReason;
private Long approverId;
private LocalDateTime approvalTime;
private String approvalRemark;
@CreationTimestamp
private LocalDateTime createdAt;
}
踩坑经验:
时间字段类型选择:开始使用
Date,后来改为LocalDateTime,更符合Java 8的时间API工作时长计算:使用
BigDecimal而不是Double,避免精度问题状态字段设计:使用Integer而不是枚举,便于数据库查询
1.3 其他实体类
// 请假记录实体
@Data
@Entity
@Table(name = "leave_records")
public class LeaveRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private String leaveType; // 事假、病假、年假
private LocalDate startDate;
private LocalDate endDate;
private BigDecimal leaveDays;
private String reason;
private Integer status; // 0-待审批,1-已批准,2-已拒绝
private Long approverId;
private LocalDateTime approvalTime;
private String approvalRemark;
@CreationTimestamp
private LocalDateTime createdAt;
}
// 补卡申请实体
@Data
@Entity
@Table(name = "supplement_records")
public class SupplementRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private LocalDate targetDate;
private Integer checkType; // 1-签到,2-签退
private LocalDateTime checkTime;
private String reason;
private Integer status; // 0-待审批,1-已批准,2-已拒绝
private Long approverId;
private LocalDateTime approvalTime;
private String approvalRemark;
@CreationTimestamp
private LocalDateTime createdAt;
}
二、Repository层:数据访问的"桥梁"
2.1 基础Repository设计
我们使用Spring Data JPA的Repository接口,大大简化了数据访问代码:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
// 根据部门查询用户
List<User> findByDepartmentId(Long departmentId);
// 根据状态查询用户
List<User> findByStatus(Integer status);
}
public interface AttendanceRecordRepository extends JpaRepository<AttendanceRecord, Long> {
// 查询用户当天的考勤记录
Optional<AttendanceRecord> findByUserIdAndAttendanceDate(Long userId, LocalDate date);
// 查询用户某个月的考勤记录
List<AttendanceRecord> findByUserIdAndAttendanceDateBetween(
Long userId, LocalDate startDate, LocalDate endDate);
// 统计用户某个月的考勤情况
@Query("SELECT COUNT(a) FROM AttendanceRecord a WHERE a.userId = :userId " +
"AND a.attendanceDate BETWEEN :startDate AND :endDate " +
"AND a.status = :status")
Long countByUserIdAndDateRangeAndStatus(
@Param("userId") Long userId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("status") Integer status);
}
2.2 自定义查询方法
对于一些复杂的查询,我们使用@Query注解:
public interface LeaveRecordRepository extends JpaRepository<LeaveRecord, Long> {
// 查询用户待审批的请假记录
List<LeaveRecord> findByUserIdAndStatus(Long userId, Integer status);
// 查询部门所有请假记录(部门经理使用)
@Query("SELECT l FROM LeaveRecord l WHERE l.userId IN " +
"(SELECT u.id FROM User u WHERE u.departmentId = :departmentId)")
List<LeaveRecord> findByDepartmentId(@Param("departmentId") Long departmentId);
// 统计用户某段时间的请假天数
@Query("SELECT COALESCE(SUM(l.leaveDays), 0) FROM LeaveRecord l " +
"WHERE l.userId = :userId AND l.status = 1 " +
"AND l.startDate >= :startDate AND l.endDate <= :endDate")
BigDecimal sumLeaveDaysByUserIdAndDateRange(
@Param("userId") Long userId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}
三、Service层:业务逻辑的"大脑"
3.1 用户认证服务
用户认证服务是整个系统的入口,我们实现了注册、登录、验证码等功能:
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final CaptchaCache captchaCache;
private final MailService mailService;
private final JwtUtil jwtUtil;
@Transactional
public Long register(RegisterRequest request) {
// 验证用户名唯一性
if (userRepository.existsByUsername(request.getUsername())) {
throw new RuntimeException("用户名已存在");
}
// 验证邮箱唯一性
if (userRepository.existsByEmail(request.getEmail())) {
throw new RuntimeException("邮箱已被注册");
}
// 验证验证码
if (!captchaCache.verify(request.getEmail(), request.getCaptcha())) {
throw new RuntimeException("验证码错误或已过期");
}
// 创建用户
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setEmail(request.getEmail());
user.setRealName(request.getRealName());
user.setDepartmentId(request.getDepartmentId());
user.setRoleId(3L); // 默认普通员工
user.setStatus(1); // 启用状态
User savedUser = userRepository.save(user);
return savedUser.getId();
}
public String login(LoginRequest request) {
// 查询用户
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
// 验证密码
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}
// 验证用户状态
if (user.getStatus() == 0) {
throw new RuntimeException("用户已被禁用");
}
// 生成JWT Token
return jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRoleId());
}
public void sendCaptcha(String email) {
// 生成4位随机验证码
String captcha = String.valueOf((int)(Math.random() * 9000) + 1000);
// 保存到缓存(5分钟过期)
captchaCache.save(email, captcha);
// 发送邮件
mailService.sendCaptchaEmail(email, captcha);
}
}
3.2 考勤服务
考勤服务是系统的核心,实现了打卡、统计、补卡等功能:
@Service
@RequiredArgsConstructor
public class AttendanceService {
private final AttendanceRecordRepository attendanceRecordRepository;
private final UserRepository userRepository;
@Transactional
public AttendanceRecord checkIn(CheckInRequest request, Long userId) {
LocalDate today = LocalDate.now();
LocalDateTime now = LocalDateTime.now();
// 检查是否已打卡
Optional<AttendanceRecord> existingRecord =
attendanceRecordRepository.findByUserIdAndAttendanceDate(userId, today);
if (existingRecord.isPresent()) {
AttendanceRecord record = existingRecord.get();
if (record.getCheckInTime() != null) {
throw new RuntimeException("今日已签到");
}
// 更新签到时间
record.setCheckInTime(now);
record.setCheckInIp(request.getIp());
// 计算是否迟到
calculateLateStatus(record);
return attendanceRecordRepository.save(record);
} else {
// 创建新的考勤记录
AttendanceRecord record = new AttendanceRecord();
record.setUserId(userId);
record.setAttendanceDate(today);
record.setCheckInTime(now);
record.setCheckInIp(request.getIp());
record.setStatus(1); // 默认正常
// 计算是否迟到
calculateLateStatus(record);
// 设置预期签退时间(8小时后)
record.setExpectedCheckOutTime(now.plusHours(8));
return attendanceRecordRepository.save(record);
}
}
@Transactional
public AttendanceRecord checkOut(CheckInRequest request, Long userId) {
LocalDate today = LocalDate.now();
LocalDateTime now = LocalDateTime.now();
// 查询今日考勤记录
AttendanceRecord record = attendanceRecordRepository
.findByUserIdAndAttendanceDate(userId, today)
.orElseThrow(() -> new RuntimeException("请先签到"));
if (record.getCheckOutTime() != null) {
throw new RuntimeException("今日已签退");
}
// 更新签退时间
record.setCheckOutTime(now);
record.setCheckOutIp(request.getIp());
// 计算工作时长
calculateWorkHours(record);
// 计算是否早退
calculateEarlyLeaveStatus(record);
// 计算加班时长
calculateOvertimeHours(record);
return attendanceRecordRepository.save(record);
}
private void calculateLateStatus(AttendanceRecord record) {
LocalDateTime checkInTime = record.getCheckInTime();
LocalTime lateThreshold = LocalTime.of(10, 0); // 10:00后算迟到
if (checkInTime.toLocalTime().isAfter(lateThreshold)) {
record.setIsLate(true);
record.setStatus(2); // 迟到
// 计算迟到分钟数
long lateMinutes = Duration.between(lateThreshold, checkInTime.toLocalTime()).toMinutes();
record.setLateMinutes((int) lateMinutes);
} else {
record.setIsLate(false);
}
}
private void calculateWorkHours(AttendanceRecord record) {
if (record.getCheckInTime() != null && record.getCheckOutTime() != null) {
Duration duration = Duration.between(record.getCheckInTime(), record.getCheckOutTime());
double hours = duration.toMinutes() / 60.0;
record.setWorkHours(BigDecimal.valueOf(hours).setScale(2, RoundingMode.HALF_UP));
}
}
private void calculateOvertimeHours(AttendanceRecord record) {
if (record.getWorkHours() != null) {
BigDecimal standardHours = BigDecimal.valueOf(8);
if (record.getWorkHours().compareTo(standardHours) > 0) {
BigDecimal overtime = record.getWorkHours().subtract(standardHours);
record.setOvertimeHours(overtime.setScale(2, RoundingMode.HALF_UP));
} else {
record.setOvertimeHours(BigDecimal.ZERO);
}
}
}
public Map<String, Object> getMonthlyStats(Long userId, String month) {
// 解析月份
YearMonth yearMonth = YearMonth.parse(month);
LocalDate startDate = yearMonth.atDay(1);
LocalDate endDate = yearMonth.atEndOfMonth();
// 查询该月的考勤记录
List<AttendanceRecord> records = attendanceRecordRepository
.findByUserIdAndAttendanceDateBetween(userId, startDate, endDate);
// 统计计算
long workDays = records.stream()
.filter(r -> r.getCheckInTime() != null && r.getCheckOutTime() != null)
.count();
long lateCount = records.stream()
.filter(r -> Boolean.TRUE.equals(r.getIsLate()))
.count();
long earlyLeaveCount = records.stream()
.filter(r -> Boolean.TRUE.equals(r.getIsEarlyLeave()))
.count();
BigDecimal totalWorkHours = records.stream()
.map(AttendanceRecord::getWorkHours)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalOvertimeHours = records.stream()
.map(AttendanceRecord::getOvertimeHours)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 计算平均工时
BigDecimal avgWorkHours = workDays > 0
? totalWorkHours.divide(BigDecimal.valueOf(workDays), 2, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
// 组装返回结果
Map<String, Object> stats = new HashMap<>();
stats.put("workDays", workDays);
stats.put("lateCount", lateCount);
stats.put("earlyLeaveCount", earlyLeaveCount);
stats.put("totalWorkHours", totalWorkHours);
stats.put("totalOvertimeHours", totalOvertimeHours);
stats.put("avgWorkHours", avgWorkHours);
stats.put("attendanceRate", workDays * 100.0 / endDate.getDayOfMonth());
return stats;
}
}
四、Controller层:API的"门面"
4.1 统一响应格式
我们设计了统一的响应格式,便于前端处理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Map<String, Object> handleRuntimeException(RuntimeException e) {
Map<String, Object> response = new HashMap<>();
response.put("ok", false);
response.put("code", 400);
response.put("message", e.getMessage());
return response;
}
@ExceptionHandler(Exception.class)
public Map<String, Object> handleException(Exception e) {
Map<String, Object> response = new HashMap<>();
response.put("ok", false);
response.put("code", 500);
response.put("message", "服务器内部错误");
// 生产环境应该记录日志而不是返回详细错误信息
return response;
}
}
// 成功响应的工具方法
public class ResponseUtil {
public static Map<String, Object> success(Object data) {
Map<String, Object> response = new HashMap<>();
response.put("ok", true);
response.put("code", 200);
response.put("data", data);
response.put("message", "成功");
return response;
}
public static Map<String, Object> success(String message) {
Map<String, Object> response = new HashMap<>();
response.put("ok", true);
response.put("code", 200);
response.put("message", message);
return response;
}
}
4.2 认证控制器
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/send-captcha")
public Map<String, Object> sendCaptcha(@RequestParam String email) {
authService.sendCaptcha(email);
return ResponseUtil.success("验证码发送成功");
}
@PostMapping("/register")
public Map<String, Object> register(@Valid @RequestBody RegisterRequest request) {
Long userId = authService.register(request);
return ResponseUtil.success(Map.of("userId", userId));
}
@PostMapping("/login")
public Map<String, Object> login(@Valid @RequestBody LoginRequest request) {
String token = authService.login(request);
return ResponseUtil.success(Map.of("token", token));
}
}
4.3 考勤控制器
@RestController
@RequestMapping("/api/attendance")
@RequiredArgsConstructor
public class AttendanceController {
private final AttendanceService attendanceService;
@PostMapping("/check")
public Map<String, Object> checkIn(@Valid @RequestBody CheckInRequest request,
@RequestAttribute Long userId) {
AttendanceRecord record;
if (request.getType() == 1) {
// 签到
record = attendanceService.checkIn(request, userId);
} else if (request.getType() == 2) {
// 签退
record = attendanceService.checkOut(request, userId);
} else {
throw new RuntimeException("无效的打卡类型");
}
return ResponseUtil.success(record);
}
@GetMapping("/today")
public Map<String, Object> getTodayAttendance(@RequestAttribute Long userId) {
LocalDate today = LocalDate.now();
Optional<AttendanceRecord> record = attendanceService
.getAttendanceRecord(userId, today);
return ResponseUtil.success(record.orElse(null));
}
@GetMapping("/monthly-stats")
public Map<String, Object> getMonthlyStats(@RequestAttribute Long userId,
@RequestParam String month) {
Map<String, Object> stats = attendanceService.getMonthlyStats(userId, month);
return ResponseUtil.success(stats);
}
@GetMapping("/recent-records")
public Map<String, Object> getRecentRecords(@RequestAttribute Long userId,
@RequestParam(defaultValue = "10") int limit) {
List<AttendanceRecord> records = attendanceService
.getRecentRecords(userId, limit);
return ResponseUtil.success(records);
}
}
五、安全配置:系统的"防护墙"
5.1 JWT工具类
@Component
public class JwtUtil {
private final String SECRET_KEY = "sailTrack2025SecretKeyForJWTSigning";
private final long EXPIRATION_TIME = 3600000; // 1小时
public String generateToken(Long userId, String username, Long roleId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("roleId", roleId);
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
public Claims extractClaims(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
extractClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public Long extractUserId(String token) {
Claims claims = extractClaims(token);
return claims.get("userId", Long.class);
}
public Long extractRoleId(String token) {
Claims claims = extractClaims(token);
return claims.get("roleId", Long.class);
}
}
5.2 JWT拦截器
@Component
public class JwtInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
public JwtInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 放行公开接口
String path = request.getRequestURI();
if (path.startsWith("/api/auth/") || path.startsWith("/api/test/")) {
return true;
}
// 获取Token
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new RuntimeException("未提供认证Token");
}
String token = authHeader.substring(7);
// 验证Token
if (!jwtUtil.validateToken(token)) {
throw new RuntimeException("Token无效或已过期");
}
// 提取用户信息并设置到请求属性中
Long userId = jwtUtil.extractUserId(token);
Long roleId = jwtUtil.extractRoleId(token);
request.setAttribute("userId", userId);
request.setAttribute("roleId", roleId);
return true;
}
}
5.3 拦截器配置
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final JwtInterceptor jwtInterceptor;
public WebConfig(JwtInterceptor jwtInterceptor) {
this.jwtInterceptor = jwtInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/**", "/api/test/**");
}
@Bean
public CorsFilter corsFilter() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setAllowedOrigins(Arrays.asList(
"http://127.0.0.1:5502",
"http://localhost:3000"
));
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(source);
}
}
六、测试与验证
6.1 单元测试
@SpringBootTest
class AuthServiceTest {
@Autowired
private AuthService authService;
@Autowired
private UserRepository userRepository;
@Test
void testRegister() {
RegisterRequest request = new RegisterRequest();
request.setUsername("testuser");
request.setPassword("password123");
request.setEmail("test@example.com");
request.setCaptcha("1234");
request.setRealName("测试用户");
request.setDepartmentId(1L);
// 模拟验证码验证
// ...
Long userId = authService.register(request);
assertNotNull(userId);
Optional<User> user = userRepository.findById(userId);
assertTrue(user.isPresent());
assertEquals("testuser", user.get().getUsername());
}
@Test
void testLogin() {
LoginRequest request = new LoginRequest();
request.setUsername("testuser");
request.setPassword("password123");
String token = authService.login(request);
assertNotNull(token);
assertTrue(token.length() > 0);
}
}
6.2 接口测试
使用Postman或curl测试接口:
# 发送验证码
curl -X POST "http://localhost:8080/api/auth/send-captcha?email=test@example.com"
# 注册用户
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "password123",
"email": "test@example.com",
"captcha": "1234",
"realName": "测试用户",
"departmentId": 1
}'
# 用户登录
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "password123"
}'
# 使用Token访问受保护接口
curl -X GET http://localhost:8080/api/attendance/today \
-H "Authorization: Bearer <your_token>"
七、总结与反思
7.1 技术收获
通过后端开发,我学到了:
第一,分层架构的重要性。清晰的Controller-Service-Repository分层让代码结构清晰,便于维护和测试。
第二,异常处理的统一性。全局异常处理器让错误处理更加规范,前端可以统一处理错误响应。
第三,JWT认证的实践。理解了无状态认证的原理和实现方式。
第四,事务管理的关键性。使用@Transactional注解确保数据一致性。
7.2 踩坑经验
时间处理问题:Java 8的时间API比旧的Date类好用很多,但需要注意时区问题。
BigDecimal精度问题:金融计算一定要用BigDecimal,避免浮点数精度问题。
JWT密钥安全:生产环境一定要使用安全的密钥,不能硬编码在代码中。
跨域配置:前后端分离项目一定要配置CORS,否则前端无法访问API。
7.3 性能优化思考
虽然当前版本功能完整,但还有优化空间:
缓存优化:验证码缓存可以使用Redis替代内存缓存。
数据库索引:为常用查询字段添加索引,提高查询性能。
连接池配置:优化数据库连接池参数。
异步处理:邮件发送等耗时操作可以异步执行。
八、下一步计划
后端核心功能已经完成,但还有几个重要模块需要实现:
人脸识别服务:集成阿里云人脸识别API
邮件服务优化:支持HTML模板邮件
文件上传功能:支持人脸照片上传到OSS
数据导出功能:导出考勤报表为Excel
在下一篇文章中,我将介绍前端界面的开发过程,包括:
Vue.js 3.x项目搭建
Element Plus组件使用
前后端API对接
数据可视化图表实现
说实话,前端开发也有很多值得分享的经验,特别是Vue 3的组合式API和响应式系统,让代码组织更加灵活。
技术如人生,后端开发就像搭建房子的地基,虽然不直接面对用户,但决定了整个系统的稳定性和扩展性。每一个实体类、每一个Service方法、每一个API接口,都需要仔细设计和测试。
如果你也在学习Spring Boot开发,希望这篇文章能给你一些启发。代码虽然重要,但架构设计和开发思想更重要。
默认评论
Halo系统提供的评论