尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Spring Boot开发团队财务管理系统Procura完整实录

Spring Boot开发团队财务管理系统Procura完整实录 分享一套团队财务管理系统“Procura”的完整开发实录。当业务方提出“我们不想再用 Excel 记账、月底对账全靠人工核对”时自研一个轻量 Finance Manager 系统成了性价比最高的选择。本文会从数据库设计、Spring Security 登录认证、收支记录、预算预警到报表统计逐一拆解并给出可复制的代码与配置适合有一定 Java 基础、想上手 Spring Boot 完整项目的开发者也适合直接用来支撑内部工具或课程设计二次改造。1. 从业务场景看 Finance Manager 的定位1.1 Finance Manager 究竟解决什么问题很多人第一反应是记账 App 到处都是为什么还要自己写一个财务管理系统答案是“场景不一样”。个人记账软件更强调便捷、图表好看而团队财务管理系统通常要解决三件事情第一多人共用一套账本账不能记串第二每一笔支出都要归属到具体分类方便月底按部门或项目复盘第三预算需要提前设定快要超支时系统要能给出提示而不是月底对账才发现超标。Procura 这个项目代号取的就是“处理、管理”的含义我们把它定位成一套面向小团队的财务管理后台。核心用户分为管理员和普通成员管理员负责维护分类、查看全局报表普通成员负责录入日常收支、查看自己的账单与预算执行情况。1.2 系统角色与功能边界在动手写代码之前我建议先把功能边界画清楚否则很容易做成一个“大而全但都做不深”的系统。Procura 第一版只做四个核心模块用户认证登录、注册、退出密码使用 BCrypt 加密存储接口通过 JWT 进行无状态认证。分类管理收入分类和支出分类例如餐饮、交通、工资、报销、团建等。账单管理记录每一笔收入、支出或转账支持分页、条件查询、逻辑删除。预算与报表按月份和分类设置预算金额统计当月各类目支出输出月度汇总、分类排行。不建议第一版就做审批流、多账本、复杂对账等能力。先把“记一笔账、查一个数、控一次预算”的闭环跑通后续再扩展。1.3 技术选型与分层架构技术选型需要结合团队实际不能为了追逐新框架而引入过高的维护成本。Procura 采用经典 Java Web 分层架构Controller - Service - Mapper - MySQL \ | | \ | -- MyBatis Plus \ -- Spring Security JWT \ Result / GlobalExceptionHandlerSpring Boot提供基础容器、自动配置和生态支持。Spring Security负责认证和授权配合 JWT 实现无状态登录。MyBatis Plus简化单表 CRUD提供分页插件、逻辑删除、Wrapper 查询。MySQL存储用户、分类、账单、预算数据。Lombok减少实体类的 getter/setter 模板代码。这套组合的优点是资料丰富、上手快、单表操作几乎不用写 SQL缺点是复杂报表仍然需要手写 XML SQL所以报表部分我们保留了 Mapper XML 的写法。2. 环境准备与项目初始化2.1 开发环境说明本文示例使用的组合是 JDK 17 Spring Boot 3.1.x Spring Security 6 MyBatis Plus 3.5.3 MySQL 8.0。版本需要根据你的实际环境调整下面重点讲配置思路。这里特别提醒一点Spring Boot 3 从javax.*迁移到了jakarta.*同时 Spring Security 6 彻底废弃了WebSecurityConfigurerAdapter如果你的项目还是 Spring Boot 2.7 JDK 8代码中的包名和安全配置写法都需要对应调整。为了避免踩版本坑建议新项目直接使用 Spring Boot 3。2.2 创建项目与依赖配置你可以通过 IDEA 的 Spring Initializr 创建项目也可以直接手工创建 Maven 工程。项目结构如下procura-finance ├── pom.xml └── src/main ├── java/com/procura │ ├── ProcuraApplication.java │ ├── common # Result、BusinessException、JwtUtils │ ├── config # SecurityConfig、MybatisPlusConfig │ ├── controller # AuthController、FinRecordController │ ├── dto # 登录请求、统计结果 │ ├── entity # SysUser、FinCategory、FinRecord、FinBudget │ ├── mapper # 数据访问接口 │ ├── security # JwtAuthenticationFilter │ └── service # 业务逻辑 └── resources ├── application.yml └── mapper/FinRecordMapper.xml核心 Maven 依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.1.5/version relativePath/ /parent properties java.version17/java.version mybatis-plus.version3.5.3.2/mybatis-plus.version jjwt.version0.11.5/jjwt.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version${jjwt.version}/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version${jjwt.version}/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version${jjwt.version}/version scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意Spring Boot 3 项目不能使用mybatis-plus-boot-starter必须使用官方提供的mybatis-plus-spring-boot3-starter否则会出现自动装配失败或类找不到的问题。jjwt拆分成了api/impl/jackson三个模块0.9.x 和 0.11.x 的 API 差异较大我们下面代码全部按 0.11.x 编写。2.3 基础配置文件src/main/resources/application.yml配置数据源、MyBatis Plus 和 JWT 参数server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/procura_finance?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/*.xml procura: jwt: # 生产环境务必改成环境变量或配置中心注入不要使用默认值 secret: procura-finance-manager-secret-key-2024-change-me expire: 86400expire的单位是秒86400 表示 token 有效期 24 小时。logic-delete-field告诉 MyBatis Plus 所有实体类的 deleted 字段都参与逻辑删除这样调用deleteById时会自动转换成UPDATE ... SET deleted1而不是物理删除。mapper-locations指向自定义统计 SQL 的 XML 文件位置。3. 数据库设计财务系统的地基3.1 用户表设计用户表是认证和授权的核心username需要唯一约束password列长度不要设计太短因为 BCrypt 哈希结果长度为 60 字符建议使用VARCHAR(100)。role字段先按简单角色设计管理员 ADMIN 和成员 MEMBER后续如果需要更细粒度权限再引入权限表。CREATE DATABASE IF NOT EXISTS procura_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE procura_finance; CREATE TABLE sys_user ( id BIGINT NOT NULL COMMENT 用户ID由应用层雪花算法生成, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt密文密码, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, role VARCHAR(20) NOT NULL DEFAULT MEMBER COMMENT 角色ADMIN/MEMBER, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;关于主键这里没有使用数据库自增而是采用 MyBatis Plus 的ASSIGN_ID策略生成雪花 ID。原因是团队财务系统后续可能涉及数据迁移、分库分表或同步到数仓逻辑主键的容错性更强也能避免自增 ID 被业务方猜测。ID 虽然是BIGINT但应用层需要设置实体注解。3.2 分类表与账单表分类表和账单表是业务核心两张表存在逻辑关联一条账单必须归属到一个分类。分类表中的type字段区分收入分类和支出分类避免把餐饮和工资混在一起统计。CREATE TABLE fin_category ( id BIGINT NOT NULL, user_id BIGINT NOT NULL COMMENT 所属用户ID, parent_id BIGINT DEFAULT NULL COMMENT 父分类IDNULL表示一级分类, name VARCHAR(50) NOT NULL COMMENT 分类名称, type TINYINT NOT NULL COMMENT 分类类型1收入 2支出, sort INT NOT NULL DEFAULT 0 COMMENT 排序值, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_user_type (user_id, type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收支分类表; CREATE TABLE fin_record ( id BIGINT NOT NULL, user_id BIGINT NOT NULL COMMENT 记账人ID, category_id BIGINT NOT NULL COMMENT 分类ID, type TINYINT NOT NULL COMMENT 类型1收入 2支出 3转账, amount DECIMAL(12, 2) NOT NULL COMMENT 金额, record_time DATETIME NOT NULL COMMENT 业务发生时间, remark VARCHAR(200) DEFAULT NULL COMMENT 备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_user_time (user_id, record_time), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收支记录表;金额字段必须使用DECIMAL(12,2)不能用double或float这一点在财务系统里没有商量余地。record_time表示业务实际发生时间和create_time区分开因为补录历史账单时两者并不相等。查询索引主要针对(user_id, record_time)因为最常出现的查询场景是“某个人某段时间的账单”。3.3 预算表设计与索引建议预算表需要同时支持总预算和分类预算。总预算的category_id记为 NULL分类预算则记录具体分类 ID。budget_month使用VARCHAR(7)存储YYYY-MM格式比使用DATE更直观避免月度查询时做边界计算。CREATE TABLE fin_budget ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, category_id BIGINT DEFAULT NULL COMMENT 分类IDNULL表示总预算, budget_amount DECIMAL(12, 2) NOT NULL COMMENT 预算金额, budget_month VARCHAR(7) NOT NULL COMMENT 预算月份格式YYYY-MM, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_user_category_month (user_id, category_id, budget_month) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预算表;这里有一个 MySQL 特性需要留意唯一索引中如果category_id为 NULL则不会触发唯一约束也就是说同一个用户同一月份可以插入多条总预算记录。解决办法是在业务层做幂等判断查询当月总预算是否存在如果已存在则更新而不是直接插入。数据库只负责存储业务规则必须放在 Service 层。预算表不建议使用物理外键因为外键会带来插入性能损耗也会让逻辑删除和后续分库变得困难用逻辑关联即可。4. 登录认证与权限控制4.1 密码加密方案密码存储绝对不能使用明文或 MD5。MD5 虽然不可逆但彩虹表攻击成本很低财务系统一旦被拖库密码就会批量泄露。这里使用 Spring Security 提供的BCryptPasswordEncoder它内部会生成随机盐每次加密同一个明文得到的密文都不会相同验证时通过matches方法完成。SecurityConfig中定义编码器package com.procura.config; import com.procura.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }Spring Security 默认开启 CSRF 防护但 JWT 无状态接口并不依赖 Session也不存在 CSRF Token 传递的场景所以这里显式关闭。SessionCreationPolicy.STATELESS表示不创建 Session这是前后端分离项目最常用的配置。4.2 JWT 工具类与认证过滤器JWT 由三部分组成Header、Payload、Signature。我们只在 Payload 中放入userId和username不放入密码、手机号等敏感信息token 过期时间由服务端控制。使用 jjwt 0.11.x 的写法如下package com.procura.common; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtUtils { Value(${procura.jwt.secret}) private String secret; Value(${procura.jwt.expire}) private Long expire; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(username, username); Date now new Date(); Date expireDate new Date(now.getTime() expire * 1000); return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(expireDate) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); } }注意HS256 签名算法要求 secret 至少 256 位也就是 32 字节以上配置字符串过短会启动时直接报错。生产环境建议把 secret 放到环境变量或配置中心避免提交到 Git。JWT 认证过滤器需要继承OncePerRequestFilter从请求头Authorization中提取Bearer token解析成功后把用户信息放入SecurityContextHolder。package com.procura.security; import com.procura.common.JwtUtils; import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; public JwtAuthenticationFilter(JwtUtils jwtUtils) { this.jwtUtils jwtUtils; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (StringUtils.hasText(token)) { try { Claims claims jwtUtils.parseToken(token); Object userIdObj claims.get(userId); String username claims.get(username, String.class); if (userIdObj ! null) { Long userId Long.valueOf(userIdObj.toString()); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList()); SecurityContextHolder.getContext().setAuthentication(authentication); request.setAttribute(userId, userId); } } catch (Exception e) { // token 无效不设置认证信息后续访问受保护接口会返回 401 } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }过滤器不直接抛异常而是放行后让 Spring Security 判断是否已认证这样登录和注册接口才能正常匿名访问。4.3 注册登录接口与系统初始化登录接口负责接收用户名密码校验通过后签发 JWT。注册接口需要检查用户名是否重复密码通过passwordEncoder.encode加密后再入库。package com.procura.controller; import com.procura.common.JwtUtils; import com.procura.common.Result; import com.procura.dto.LoginRequest; import com.procura.dto.RegisterRequest; import com.procura.entity.SysUser; import com.procura.mapper.SysUserMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.procura.common.BusinessException; import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/api/auth) public class AuthController { Resource private SysUserMapper userMapper; Resource private PasswordEncoder passwordEncoder; Resource private JwtUtils jwtUtils; PostMapping(/register) public ResultVoid register(RequestBody RegisterRequest request) { Long count userMapper.selectCount( new LambdaQueryWrapperSysUser().eq(SysUser::getUsername, request.getUsername())); if (count ! null count 0) { throw new BusinessException(用户名已存在); } SysUser user new SysUser(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setNickname(request.getNickname()); user.setRole(MEMBER); userMapper.insert(user); return Result.ok(null); } PostMapping(/login) public ResultMapString, String login(RequestBody LoginRequest request) { SysUser user userMapper.selectOne( new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, request.getUsername()) .eq(SysUser::getStatus, 1)); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException(用户名或密码错误); } String token jwtUtils.generateToken(user.getId(), user.getUsername()); MapString, String data new HashMap(); data.put(token, token); data.put(nickname, user.getNickname()); data.put(role, user.getRole()); return Result.ok(data); } }为了首次启动不依赖手工插入用户可以在ApplicationRunner中初始化管理员账号。生产环境建议把初始化账号的密码通过启动参数传入避免默认密码泄露。package com.procura.config; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.procura.entity.SysUser; import com.procura.mapper.SysUserMapper; import jakarta.annotation.Resource; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; Component public class DataInitializer implements ApplicationRunner { Resource private SysUserMapper userMapper; Resource private PasswordEncoder passwordEncoder; Override public void run(ApplicationArguments args) { Long count userMapper.selectCount( new LambdaQueryWrapperSysUser().eq(SysUser::getUsername, admin)); if (count null || count 0) { SysUser admin new SysUser(); admin.setUsername(admin); admin.setPassword(passwordEncoder.encode(admin123)); admin.setNickname(系统管理员); admin.setRole(ADMIN); userMapper.insert(admin); } } }统一返回结构Result和全局异常处理可以让接口响应格式保持一致前端只需要判断code是否为 200 即可package com.procura.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT fail(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }5. 核心业务功能实现5.1 收支记录模块收支记录是系统使用频率最高的接口设计时要把“当前登录用户是谁”放在服务端判断不能信任前端传入的userId否则普通成员可以随意往别人账本里插入数据。实体类使用 MyBatis Plus 注解映射package com.procura.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baom
返回列表