
简介基于Java Swing与MySQL数据库的员工工资管理系统面向Java初学者与在校学生可应用于课程设计、毕业设计或自学练手。系统分为管理员与普通用户两种角色覆盖员工信息的添加、修改、删除部门调整薪资设置与查询以及统计报表等常用功能功能链路完整模块划分清晰。整个资源包共163个文件压缩后约944KB其中16个源代码文件、30个界面设计文件便于阅读和二次开发109个编译后的字节码文件用于运行验证另包含数据库脚本、可执行打包文件、项目配置文件与说明图片导入开发工具后即可运行对照。目前已有3332人学习下载属于同类桌面管理系统中较受欢迎的参考项目。下载后能同时获得完整源代码与数据库脚本并通过该实战项目理解界面布局、事件处理、数据库连接、权限控制等关键知识点体量适中、完成度较高适合Java学习者逐步拆解与扩展。1. Java 员工工资管理系统别把它当玩具项目它是你理解企业应用的第一块完整拼图项目标题Java 员工工资管理系统。很多人的第一反应是这不就是课程设计吗但真话是工资管理系统里藏着企业应用的几乎所有基本操作——数据建模、权限隔离、计算规则、报表导出、并发安全。你用它把 Java 基础时期的代码水平拉高一个档次远比背面试题有用。这篇文章我用 Spring Boot MyBatis MySQL 走一遍最常见的落地路径怎么建表、后端怎么写、页面怎么控制权限以及那些能让 Debug 拖到凌晨的隐藏坑。适合两类人想把 Java 基础落到项目上的初学者和做内部功能却被玩具系统设计坑过的工程师。2. 从需求到表结构先把工资系统的数据模型立住2.1 员工、部门、工资三张核心表字段别照抄网上旧例很多网上的 Java 员工工资管理系统 还停留在 JSP Servlet 时代表结构也是十几年前的样子。这里我按现在常见的 Spring Boot MyBatis 项目来拆。工资系统最核心的就三张表员工表 employee、部门表 department、工资表 salary。有人还会加一张用户表 account用来做登录和权限这个后面讲到权限再补。先看员工表。一个常见的坑是把部门名称直接存进员工表等部门改名的时候你就要写全表更新。我一般会存 department_id用逻辑关联而不是物理外键。字段至少要覆盖工号 employee_no统一编号一定要建唯一索引、姓名 name、性别 gender、手机号 phone、入职日期 hire_date、岗位 position以及状态 status——注意这个字段很关键离职员工你不能直接删否则历史工资记录就没法对应了。工资表是核心中的核心。很多人会为了省事只存实发金额这会让后续的统计和审计全部翻车。工资表里必须从原始数据开始存基本工资 base_salary、岗位津贴 position_allowance、绩效奖金 performance_bonus、加班费 overtime_pay、社保 social_insurance、公积金 housing_fund、个人所得税 income_tax、应发总金额 gross_salary、扣款合计 deduction_total、实发金额 net_salary。注意应发和实发不要只让程序临时算完存一个值建议把计算中间结果都落库这样出错了能回溯。部门表就简单了id、name、manager_id负责人员工ID、create_time。你甚至可以不加但有了部门表做按部门汇总工资的报表时就不用从员工表里刮部门名了。下面给一个可以直接执行的 MySQL 建表脚本注意字符集和排序规则CREATE DATABASE IF NOT EXISTS salary_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE salary_db; CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 部门ID, name VARCHAR(50) NOT NULL COMMENT 部门名称, manager_id BIGINT DEFAULT NULL COMMENT 负责人员工ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT部门表; CREATE TABLE employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 员工ID, employee_no VARCHAR(20) NOT NULL COMMENT 工号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT COMMENT 性别1男 2女, phone VARCHAR(20) COMMENT 手机号, department_id BIGINT COMMENT 部门ID, position VARCHAR(50) COMMENT 岗位, hire_date DATE COMMENT 入职日期, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1在职 2离职, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_employee_no (employee_no), KEY idx_department_id (department_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表; CREATE TABLE salary ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 工资记录ID, employee_id BIGINT NOT NULL COMMENT 员工ID, salary_month VARCHAR(7) NOT NULL COMMENT 工资月份如2026-05, base_salary DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 基本工资, position_allowance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 岗位津贴, performance_bonus DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 绩效奖金, overtime_pay DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 加班费, social_insurance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 社保, housing_fund DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 公积金, income_tax DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 个人所得税, gross_salary DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 应发工资, deduction_total DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 扣款合计, net_salary DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 实发工资, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_employee_month (employee_id, salary_month), KEY idx_salary_month (salary_month) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT工资表;逻辑说明工资表用 employee_id salary_month 做联合唯一索引这相当于一张表天然阻止了同一个员工同一个月被录两次的问题这是很多旧例子里没有的设计。薪资字段全部用 DECIMAL(10,2) 而不是 double/float因为浮点类型在计算金额时会有精度问题后面避坑章专门讲。这里你直接用 DECIMAL等于提前买了后悔药。参数说明salary_month 设计成 VARCHAR(7)存 2026-05 这样的格式而不是 DATETIME。为什么因为工资是按月结算的月份天然适合做字符串比较、分组和索引如果你存日期很多时候还要 DATE_FORMAT 转一下才能分组反而绕。DECIMAL(10,2) 表示最大 99999999.99对绝大多数企业够用了如果你们有集团级大额可以调成 (12,2)。这里还要注意一个现实问题员工表和工资表是一对多的关系但在工资系统里我们通常不建物理外键而是只在 Java 层面做逻辑关联。为什么因为物理外键在批量导入、历史数据迁移时会成为绊脚石尤其在你要分别备份两张表的时候。所以上面建表脚本里我只加了 KEY 索引没有 FOREIGN KEY这是企业里比较常见的取舍。另外时间字段建议统一由 MySQL 的 DEFAULT CURRENT_TIMESTAMP 生成而不是 Java 传 new Date()。这能避免多台应用服务器时钟不一致导致写入时间错乱。像工资月份这种业务时间就由前台选择和记录创建时间分开存。这样后续对账、审计都能说清楚每个动作发生在什么时候。再补充一个查询视角。工资系统最常见的查询是某个月所有人工资和某个员工历史工资。第一条靠 salary_month 索引第二条靠 employee_id 索引两个索引分别建别为了迁就某一条 SQL 建乱七八糟的联合索引。联合索引要留给 (employee_id, salary_month) 这个唯一键它同时服务于查询某员工某月工资这种精确匹配。哪个查询最频繁哪个组合才值得建索引这是数据库入门里比背 java 八股文实用得多的判断题。2.2 工资计算规则怎么落库把公式写进代码还是写进表数据表立住之后下一个问题就是工资的计算规则放哪里常见做法有两种。一种是硬编码在 Java Service 里适合规则简单、一个公司一套算法的场景另一种是建一张 rule 表把科目、优先级、计算公式模板存进去做成可配置。对一个员工工资管理系统来说我建议先选第一种等确实有多个分公司、不同算法的时候再升级成第二种别一上来就给自己挖配置引擎的坑。那规则本身怎么表达拿国内最常见的应发 基本 岗位津贴 绩效 加班费扣款 社保 公积金 个税实发 应发 - 扣款来说个税的计算是唯一有点分支逻辑的。个税是累进税率但很多初学者直接写 if (salary 5000) ...这在新个税起征点 5000 之后还有个专项扣除的问题。为了让大家先把框架搭起来这里用一个简化版本应发不超过 5000 不缴税超过部分按 10% 预扣等你们接真实财务需求时再去替换成累进税率表。存储层面在没有独立税务模块的情况下我一般会把是否已发/是否已锁定放在工资表里。加一个 status 字段0 草稿、1 已确认、2 已发放。发放之后就不再允许修改这是工资系统的底线。要不要用乐观锁建议在 salary 表加一个 version INT 字段更新时校验防止两个管理员同时改同一份工资单。这个字段在 MyBatis 里就是乐观锁插件的事后面会提一句。这个阶段最后落一个 DDL 变更ALTER TABLE salary ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已确认 2已发放;逻辑说明version 是给 UPDATE 用的。操作时先 SELECT 拿到 version0更新时写 WHERE id? AND version0如果影响行数为 0 就说明数据被别人改了。在 Spring Boot 里可以用 MyBatis-Plus 的 Version 注解自动完成不需要手写。status 则是业务层的状态机只有 0 状态的记录才允许编辑。参数说明把长度类的参数当作约束条件而不是放任自由。比如员工手机号在程序里没人管但数据库别懒。状态字段不要用字符串 YES/NO用 TINYINT 语义更清晰Java 侧用枚举映射。这里再说一个常见误用有人喜欢给 salary 表加一个是否删除的 delete_flag工资记录属于审计数据要做删除也是软删除硬删除一条工资单会让历史对账直接黑匣子。所以删工资记录这个功能第一版我建议干脆不做。3. Spring Boot MyBatis 搭建后端让 CRUD 和工资计算跑起来3.1 项目骨架和 pom.xml别用 IDE 自动生成的默认包名从零开始一个 Spring Boot 项目IDE 向导几秒钟就生成了但很多人栽在包名上。包名别用 com.example后面你写 AOP、写拦截器、写自动化测试都会踩 classpath 扫描的坑。我用的是 com.company.salary。你在公司里遵循域名反写项目名就好。版本上Spring Boot 3.x 目前是主流注意它要求 JDK 17 以上如果你还在用 Java 8就老老实实选 Spring Boot 2.7.x两者在 MyBatis 依赖坐标上略有差异。pom.xml 是一个避坑重点。很多初学者喜欢把网上抄来的依赖堆一整页其实一个工资系统用到的就这几个web、mybatis-spring-boot-starter、mysql-connector-j、lombok、validation。注意 Spring Boot 3 里 mysql 驱动坐标变成了com.mysql:mysql-connector-j不再是mysql:mysql-connector-javaMyBatis 的 starter 从 3.0 开始也改成了org.mybatis.spring.boot:mybatis-spring-boot-starter别拿 2.x 的版本和 Spring Boot 3.x 混用启动时会直接报 DataSource 相关的错误。下面是一个可用的 pom.xml 核心片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies逻辑说明spring-boot-starter-parent 统一管理大多数依赖版本所以 web、mysql 都不写 version。MyBatis starter 是社区维护的parent 管不到必须显式写 version。Lombok 在编译期生成 getter/setter能省掉大量样板代码但 IDE 里要装对应插件否则编译报错找不到方法。参数说明如果你是 Java 8 Spring Boot 2.7把 parent 换成 2.7.18mybatis-spring-boot-starter 用 2.3.2mysql 坐标用mysql:mysql-connector-java代码基本兼容。这是一个很多人不看的版本匹配问题等到启动报一堆 class not found 再回来补就很狼狈。3.2 分层写代码Controller 别一股脑做计算Spring Boot 项目一般分 controller / service / mapper 三层。工资系统的计算逻辑一定要放在 Service 层Controller 只负责参数接收和校验。我之前见过有人把计算公式写进 Controller结果接口加了 AOP 日志后参数一打印就暴露了员工薪资这是很尴尬的。正确的做法是Controller 调 Service 的一个 method比如calculateAndSave(salaryDTO)让 Service 里把规则写清楚。下面先给实体类的核心代码用 MyBatis 的注解方式避免写一堆 XMLData public class Salary { private Long id; private Long employeeId; private String salaryMonth; private BigDecimal baseSalary; private BigDecimal positionAllowance; private BigDecimal performanceBonus; private BigDecimal overtimePay; private BigDecimal socialInsurance; private BigDecimal housingFund; private BigDecimal incomeTax; private BigDecimal grossSalary; private BigDecimal deductionTotal; private BigDecimal netSalary; private Integer version; private Integer status; }逻辑说明BigDecimal 对应数据库的 DECIMAL不要用 Double。Data是 Lombok 提供的自动生成 getter/setter/toString。注意 toString 里可能包含敏感字段如果你不想让日志打印工资可以重写只保留 id 和 salaryMonth。参数说明实体类字段尽量和数据库列名用驼峰对应MyBatis 默认开启mapUnderscoreToCamelCase的话数据库的base_salary能自动映射到baseSalary。你需要在 application.yml 里显式打开mybatis: configuration: map-underscore-to-camel-case: true然后我们写 Service 的计算方法Service public class SalaryService { Autowired private SalaryMapper salaryMapper; Autowired private EmployeeMapper employeeMapper; private static final BigDecimal THRESHOLD new BigDecimal(5000); private static final BigDecimal TAX_RATE new BigDecimal(0.10); Transactional public Salary calculateAndSave(SalaryDTO dto) { Salary salary new Salary(); // 基础信息 salary.setEmployeeId(dto.getEmployeeId()); salary.setSalaryMonth(dto.getSalaryMonth()); salary.setBaseSalary(dto.getBaseSalary()); salary.setPositionAllowance(dto.getPositionAllowance()); salary.setPerformanceBonus(dto.getPerformanceBonus()); salary.setOvertimePay(dto.getOvertimePay()); // 1. 应发工资 基本 津贴 绩效 加班 BigDecimal gross dto.getBaseSalary() .add(dto.getPositionAllowance()) .add(dto.getPerformanceBonus()) .add(dto.getOvertimePay()); salary.setGrossSalary(gross); // 2. 五险一金和个税简化版本依公司规则改 BigDecimal social dto.getBaseSalary().multiply(new BigDecimal(0.10)); BigDecimal fund dto.getBaseSalary().multiply(new BigDecimal(0.08)); BigDecimal taxBase gross.subtract(social).subtract(fund); BigDecimal tax taxBase.subtract(THRESHOLD).max(BigDecimal.ZERO).multiply(TAX_RATE); salary.setSocialInsurance(social); salary.setHousingFund(fund); salary.setIncomeTax(tax); // 3. 实发 应发 - 社保 - 公积金 - 个税 BigDecimal deduction social.add(fund).add(tax); salary.setDeductionTotal(deduction); salary.setNetSalary(gross.subtract(deduction)); salary.setStatus(0); salary.setVersion(0); salaryMapper.insert(salary); return salary; } }逻辑说明Transactional保证插入一条工资记录的过程要么全成功要么全回滚不会出现算了一半断电导致数据错乱。注意计算顺序先应发再扣款最后实发。每一步都用 BigDecimal 的方法而不是操作符这是 Java 面试常问的题目——0.1 0.2的 double 结果不是 0.3。参数说明社保比例 0.10 和公积金 0.08 是演示值真实环境应该是从配置项读取或者存到一个 system_config 表。写死比例一时爽等财务说要改基数就变成全项目翻代码。我一般会用一个ConfigurationProperties类来接收以salary.insurance-rate开头的外部配置这样运维改 YAML 就够不需要重新打包。下一步是 Mapper 接口。直接用注解写 SQLMapper public interface SalaryMapper { Insert(INSERT INTO salary(employee_id, salary_month, base_salary, position_allowance, performance_bonus, overtime_pay, social_insurance, housing_fund, income_tax, gross_salary, deduction_total, net_salary, version, status) VALUES(#{employeeId}, #{salaryMonth}, #{baseSalary}, #{positionAllowance}, #{performanceBonus}, #{overtimePay}, #{socialInsurance}, #{housingFund}, #{incomeTax}, #{grossSalary}, #{deductionTotal}, #{netSalary}, default, default)) int insert(Salary salary); Select(SELECT * FROM salary WHERE employee_id #{employeeId} AND salary_month #{salaryMonth}) Salary findByEmployeeAndMonth(Param(employeeId) Long employeeId, Param(salaryMonth) String salaryMonth); }逻辑说明用注解写简单 SQL 足够但带动态查询的复杂报表注解会很难维护。到时候再用 XML 也不迟。MyBatis 要求接口上有Mapper或者启动类上有MapperScan二选一别两个都搞然后怀疑自己代码有 BUG。参数说明default让数据库自动填充 version 和 status 的默认值Java 侧不用再显式赋值。使用Param是为了让 MyBatis 能识别多参数不加的话报Parameter employeeId not found。application.yml 里还要配数据源。这里有个环境变量的小知识点密码不要写在 YAML 里提交到 Git用${MYSQL_PASSWORD:root}这种占位符默认值只给本地开发用。生产环境你会有密码管理工具这里不展开。配置完成后启动 Spring Boot 的 main 方法控制台看到 Tomcat started 不代表系统能用你还得去执行几条 SQL 验证表是否建好了。很多人把建表脚本放在 navicat 里跑一遍就不管了我建议用 Flyway 做版本化建表这样员工工资管理系统换个环境部署表结构能自动创建这是血泪经验换来的。4. 前端页面与权限控制员工和管理员看到的内容不一样4.1 用 Thymeleaf 渲染工资条别再写 JSP 了Java 服务端渲染页面传统是 JSP但 Spring Boot 3 对 JSP 的支持很别扭常见的做法是 Thymeleaf。它的模板语法简单还能和 HTML 静态页无缝切换很适合做工资管理系统这种表单密集型页面。后端 Controller 返回一个视图名Thymeleaf 解析模板把数据填进去。先看 Controller 怎么写Controller RequestMapping(/salary) public class SalaryController { Autowired private SalaryService salaryService; Autowired private EmployeeMapper employeeMapper; GetMapping(/list) public String list(RequestParam(defaultValue 2026-05) String month, Model model) { ListSalaryVO list salaryService.findByMonth(month); model.addAttribute(salaryList, list); model.addAttribute(month, month); return salary/list; } }逻辑说明Controller而不是RestController因为这里要返回视图。Model 里放的数据在模板中直接用salaryList访问。分页我们先不做列表数据量小的时候不碍事真到上万条再去加 PageHelper 或 MyBatis-Plus 分页。参数说明月份参数用RequestParam(defaultValue 2026-05)避免用户不传月份时接口报错。注意2026-05是例子运行时要改成当前月份别照抄。然后是模板里的核心片段!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title工资列表/title /head body h1 th:text工资月份 ${month}工资月份/h1 table border1 tr th工号/th th姓名/th th应发工资/th th扣款合计/th th实发工资/th th状态/th /tr tr th:eachs : ${salaryList} td th:text${s.employeeNo}工号/td td th:text${s.employeeName}姓名/td td th:text${s.grossSalary}0.00/td td th:text${s.deductionTotal}0.00/td td th:text${s.netSalary}0.00/td td th:text${s.status}0/td /tr /table /body /html逻辑说明th:each相当于 Java 的 foreachth:text替换标签体的内容。Thymeleaf 要求 HTML 文件放在src/main/resources/templates/salary/list.html目录下否则视图解析器找不到。这里我故意把字段名写成employeeNo、employeeName意味着你要在 SalaryVO 里补上这两个关联字段或者直接在 SQL 里 join 员工表取出来。参数说明状态字段直接显示 0/1/2 很难看可以做一个枚举工具类在模板里用三元表达式th:text${s.status 0 ? 草稿 : (s.status 1 ? 已确认 : 已发放)}。但更优雅的是后端先转成字符串模板只负责展示。模板里别写复杂逻辑这是前后端分离的雏形思维。另外如果你的系统想做成前后端分离Thymeleaf 这层可以不要后端只出 JSON前端用 Vue/React 接。但既然这个系统定位是 Java 员工工资管理系统用服务端渲染反而是最省事的路径——不需要单独起 Node 服务也不需要做跨域配置。等到产品提出要 App 端或者集成到别的系统再考虑拆分。这个判断标准很简单你的业务是不是一个后台管理型页面是的话Thymeleaf 够用了。4.2 基于 Session 的登录和角色判断权限是工资系统的安全底线工资管理系统必须有权限控制。员工只能看自己的工资条管理员能看所有人财务还能编辑。常见做法是登录后把用户对象放进 Session再用 Spring MVC 的拦截器校验角色。直接用 Spring Security 当然也可以但那个学习曲线很高对一个小系统来说拦截器足够而且更能让你理解权限过滤机制。先写登录逻辑Service public class AuthService { Autowired private AccountMapper accountMapper; public Account login(String username, String password) { Account account accountMapper.findByUsername(username); if (account null) { throw new BusinessException(用户不存在); } // 简单 MD5 校验真实项目要用 BCrypt if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(account.getPassword())) { throw new BusinessException(密码错误); } return account; } }逻辑说明密码没加密之前在数据库里是明文这是致命问题。上面代码用了DigestUtils.md5DigestAsHex只是给你演示MD5 早就不安全了现在至少用 BCrypt比如 Spring Security 的BCryptPasswordEncoder。登录成功后Controller 里把 account 放进 sessionPostMapping(/login) public String doLogin(String username, String password, HttpSession session) { Account account authService.login(username, password); session.setAttribute(loginUser, account); return redirect:/salary/list; }然后写一个拦截器public class RoleInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(/login); return false; } return true; } }逻辑说明getSession(false)拿不到已有 session 就直接放行到登录页避免新建一个无意义的空 session。拦截器注册在 WebMvcConfigurer 里设置要拦截的路径Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RoleInterceptor()) .addPathPatterns(/salary/**, /employee/**) .excludePathPatterns(/login, /static/**); } }参数说明addPathPatterns和excludePathPatterns的路径匹配规则是 Ant 风格/**表示多级路径。这里把静态资源排除掉不然 HTML 里引用的 CSS、JS 也被拦截。角色判断可以在拦截器里做如果请求的是管理员接口再查 session 里的 account.role 是不是 admin不是就返回 403。这比在每个 Controller 里写 if 判断要整洁得多。权限这块还有一个容易忽略的点工资的查看权限不只要看角色还要看数据范围。财务主管能看整个部门的工资普通财务只能看自己负责的几条线。这就是所谓的行级权限网上有一堆方案简化做法是在员工表里加一个 data_scope 字段查询时自动拼接条件。不过对于第一版系统先做通角色权限行级权限等真有需求再上别一开始就设计得很重。Session 的过期时间默认 30 分钟工资系统里常有管理员填了几分钟表单点保存发现被踢回登录页。不要盲目调大超时而是把草稿数据存在前端 localStorage登录后自动回填。这是用户体验和安全的平衡。表单提交还有 CSRF 的问题Spring Security 默认开启 CSRF但用拦截器方案没这层保护。我建议在表单加一个_csrf隐藏字段自己生成一个 token 存在 session 和页面提交时比对。在内部系统里这个可以被忽略但系统一旦开放到外部网络就绕不过去了。5. 工资系统避坑记录从 MySQL 乱码到金额精度丢失的五个真坑5.1 中文乱码Console 正常页面全问号现象运行 Spring Boot 后在浏览器打开工资列表员工姓名全部显示成问号而数据库工号正常。因为字段类型、页面编码都是 UTF-8搞了一下午不知道问题在哪。原因数据库连接 URL 里没加 characterEncoding 参数。MySQL 驱动的默认编码在旧版本上不是 UTF-8而是从系统环境读取Windows 中文系统下经常就是 GBK。虽然建表时指定了 utf8mb4但连接层面的字符集被搞错了服务器往数据库写入时就已经是乱码查回来自然也是乱码。解决在 application.yml 的 datasource url 上显式加参数。spring: datasource: url: jdbc:mysql://localhost:3306/salary_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai逻辑说明useUnicodetrue和characterEncodingutf8是配对使用的只写其中一个经常不生效。serverTimezone不填的话新版驱动会报时区异常别照着 GMT 时间写直接Asia/Shanghai。这三个参数是 Java 连接 MySQL 的固定组合。如果连接需要 SSL 报错可以再加useSSLfalse但注意这只是在开发环境省事生产环境不建议关闭 SSL。参数说明JDBC URL 里如果还出现autoReconnecttrue这是老项目攒下来的习惯实际上在连接池场景下不需要反而可能掩盖真正的问题。5.2 金额精度丢失double 把 0.1 0.2 算成了 0.30000000000000004现象工资计算里用 double 存基本工资和扣款结果一条工资单的实发金额差了几分钱财务对账怎么都对不上。原因这是 Java 浮点的经典问题也是 Java 基础面试里常被问的题。double 是双精度二进制浮点数能精确表达整数但表达小数时很多值只是近似值。0.1 在二进制里就是无限循环小数所以 double 运算会丢失精度。工资这种一分钱都要算清楚的数据用 double 等于在系统里埋定时炸弹。解决从数据库到 Java 实体、再到计算过程全程用 DECIMAL 和 BigDecimal。实体类用 BigDecimalSQL 里用 DECIMAL(10,2)计算时用 BigDecimal 的 add/subtract/multiply 方法。还有一个隐藏坑用 BigDecimal 除法时必须指定精度和舍入模式比如divide(new BigDecimal(12), 2, RoundingMode.HALF_UP)否则除不尽时会抛 ArithmeticException。这个报错信息很魔幻动不动就是 Non-terminating decimal expansion新手根本看不懂。提示还有一个小坑BigDecimal 构造时不要用new BigDecimal(0.1)这会把 double 的近似值原封不动转进来要用new BigDecimal(0.1)字符串构造。这个细节在代码审查里经常是争议点也是我说宁可代码多写几行别省出精度问题的原因。5.3 会话过期后表单重复提交点一次保存生成了两条工资记录现象管理员在工资编辑页面停留久了点保存没反应再点一次菜单发现同一员工同一月生成了两条工资单虽然建立了唯一索引但重复提交的 bug 还是会发生。原因这里有两层问题。第一前端按钮没有做防重复提交用户双击按钮发送了两次请求第二后端没有做幂等处理。唯一索引只能挡住同时插入同一个月的数据但如果你第一张单状态是草稿第二张单如果也插入唯一索引在那时已经挡住一条可如果前端先调了保存又调了确认处理逻辑不严谨就可能出现一条草稿一条已确认。解决前端在表单提交时把按钮置灰并加一个请求锁。后端用Transactional加唯一索引兜底同时 Service 里在插入前先查一次是否存在同员工同月记录存在就抛异常。更稳妥的做法是给保存接口设计成幂等客户端生成一个 requestId后端用 requestId 做去重表。这里给一个小建议不要只依赖前端防重复后端一定是最后一道闸。long count salaryMapper.countByEmployeeAndMonth(employeeId, month); if (count 0) { throw new BusinessException(该员工本月工资单已存在请勿重复提交); }逻辑说明查一次再插入不是绝对安全因为并发下两次查询都可能返回 0所以必须配上数据库唯一索引。数据库索引才是最终兜底代码检查是提前友好报错。这种双重保险在支付、工资这种敏感业务里是基本素养。5.4 日期格式化今年第几周的数据查出来全是上个月的现象工资列表的月份筛选功能用户选2026-05列表却出来几条 2026-04 的数据。用 SQL 的 BETWEEN 查询边界时间段像黑匣子一样怎么排查都觉得 SQL 没错。原因salary_month 字段是 VARCHAR(7)字符串比较只要格式统一就没问题。问题出在接口入参。有的地方用DateTimeFormat把前端传来的2026-05解析成了 LocalDate然后走了日期范围查询Java 的 LocalDate 解析 2026-05 时报错于是程序静默取了当月一号 00:00:00结果把前后几天包进去了。解决工资月份这个字段全链路都用字符串传递不要转成日期。Controller 入参用 StringService 里也只用字符串比较。只有做报表统计需要当月第一天/最后一天时才用静态工具类把2026-05转成2026-05-01和2026-05-31。提供一个工具方法public static String getMonthRangeEnd(String month) { YearMonth ym YearMonth.parse(month); return ym.atEndOfMonth().toString(); }逻辑说明YearMonth.parse(2026-05)能正确解析atEndOfMonth()考虑到闰年和平月比手动拼2026-05-31可靠得多。这个坑的核心教训是业务上明确的月份语义不要混进时间点语义。一句话说清楚工资月份是标签不是时刻。5.5 事务没生效工资算到一半一条存入一条没存现象在 Service 里调另一个方法结果没有事务插入了一半抛异常后前面插入的工资单还在库里数据变得半残。原因Transactional是 AOP 代理实现的同类内部方法调用时走的是 this 调用而不是代理对象所以注解失效。比如 SalaryService 里自己调自己的insertSalary外部调saveAllsaveAll上加的Transactional不生效。解决把需要事务的方法拆到另一个 Service Bean 里或者用 Spring 的 AopContext 获取代理对象。实际上最干净的模式是拆分一个SalaryWriteService负责事务写入一个SalaryQueryService负责查询工资计算和写入的编排在一个方法里内部跨 Bean 调用事务就正常。Service public class SalaryBatchService { Autowired private SalaryWriteService writeService; Transactional public void saveBatch(ListSalaryDTO list) { for (SalaryDTO dto : list) { writeService.calculateAndSave(dto); } } }逻辑说明SalaryBatchService.saveBatch调用另一个 Bean 的writeService.calculateAndSave这个是代理对象事务有效。如果calculateAndSave本身也标了Transactional在同一个事务里会传播合并不会开新事务。这个坑在 Spring Boot 项目里极其常见很多 Java 开发做了几年才撞上。参数说明Transactional默认只回滚 RuntimeException如果你抛的是受检异常需要写rollbackFor Exception.class。这个细节在面试题里出现的频率很高也是实际项目里最容易导致以为回滚了结果没回滚的原因。6. 从能跑到能交付给工资系统加上 Excel 导出和接口验证工资系统做到能增删改查只算走完一半。财务要的就是月底导出工资表给银行发数据。这里最常用的方案是 Apache POI但 POI 的 API 很啰嗦我现在一般用 EasyExcel。下面给一个用 EasyExcel 的简单导出方法Controller 返回一个下载流。GetMapping(/export/{month}) public void export(PathVariable String month, HttpServletResponse response) throws IOException { ListSalaryVO list salaryService.findByMonth(month); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename month .xlsx); EasyExcel.write(response.getOutputStream(), SalaryVO.class) .sheet(工资表) .doWrite(list); }逻辑说明SalaryVO字段决定 Excel 列的顺序可以在字段上用ExcelProperty(工号)指定列名。注意导出前要把数据按员工工号排序否则财务导入银行模板会乱。中文文件名在浏览器里容易乱码我习惯用URLEncoder.encode(month .xlsx, UTF-8)转一下。验证方法上别只测接口通不通。工资系统每次改完都要跑一下对账数据库里实发金额总和与前端页面显示的总计对比再和导出的 Excel 求和对比。三处数字一致才敢点发放。这个习惯救了我不止一次因为每个环节都可能丢一条记录。最后说说这个方向值不值得投入。如果你是在学 Java 基础这个系统比刷题更接近真实开发建模型、写事务、处理权限、做报表每一步都对应企业里的实际技能。如果你已经工作这套思路可以直接复用到任何以数据库为核心的管理后台。只是记住一点业务规则一定不要写死在 Controller 里把工资算法留到 Service 层并且用 DECIMAL 保住精度这是所有用 Java 写工资系统的人都要守住的底线。我自己第一次做这个系统时就因为没有把金额类型当回事翻过车后来每次写金额字段前都要确认一下类型。希望帮到你。本文还有配套的精品资源点击获取