ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java供需信息发布平台:Spring Boot+MyBatis实战与避坑指南

Java供需信息发布平台:Spring Boot+MyBatis实战与避坑指南 简介这是一款基于Java开发的产品供需信息发布与管理平台设计源码面向需要学习Java桌面应用开发、用户权限管理及供需信息匹配的开发者也适合作为毕业设计或课程设计的参考项目。平台围绕用户注册与登录、用户中心、需求信息发布、供应信息发布等模块构建可为食品配料、包装、代工、招商等企业提供产品展示窗口同时帮助采购方快速找到供应商实现精准化营销。资源包共62个文件包括17个Java源文件、18个class文件、13个XML配置文件、5个IML工程文件以及图片、Git忽略文件等辅助文件压缩包约514KB。其中Java源文件覆盖登录界面、管理框架、用户管理、信息发布等核心逻辑XML负责系统配置IML便于在IntelliJ IDEA中直接导入项目。目前已有275人学习适合用于理解项目分层、数据库连接与界面事件处理稍作调整即可用于开发个性化的供需平台或企业宣传系统。1. 基于Java的供需信息发布与管理平台难点从来不在匹配算法很多人第一次接触“基于Java开发的产品供需信息发布与管理平台设计源码”会以为核心难点是那个“供需匹配算法”。等我带着项目需求去啃完一批同类源码后发现真正让开发者熬夜翻车的是信息发布的状态流转、并发上下架和后台多角色权限。这个标题指的就是一套把供给方、需求方、平台管理员三者串起来的Web系统前端负责发布产品与求购信息后端负责审核、展示、匹配和管理。适合的读者很明确准备做校园二手、企业采购对接、原料供求这类业务的人以及想在Spring Boot MyBatis上练手完整业务闭环的Java开发。它不是一个炫技项目而是能直接用起来的业务系统。下面我从数据模型开始把一条能落地的实现路径完整拆给你。2. 技术选型与数据模型Spring Boot MyBatis 的取舍和核心表设计2.1 为什么用 Spring Boot MyBatis而不是 JPA 全家桶供需信息发布平台最典型的查询场景是“多条件组合筛选”比如按分类、价格区间、状态、关键词去捞数据。这种需求在 MyBatis 里写动态 SQL 非常直观条件多一个就加一个if条件少一个也不需要动 Java 代码。我一般不会在读写频繁、SQL 要经常动态拼条件的中后台项目里首选 JPA因为 JPA 的 Specification 写起来绕性能出问题后还不容易定位。Spring Boot MyBatis 是这类平台最常见的技术组合网上能拿到的多商户商城、二手交易系统源码也基本都是这个骨架你后续对比学习成本低。面向对象编程 java 的习惯是把用户、角色、供需信息抽象成实体但落到数据库里我会保留一定的“宽表”思维。比如把供给方和需求方的公共字段尽量对齐这样匹配查询时不用来回 join 三张表。MyBatis 源码层面的拦截器机制也给行级权限留了扩展口后面讲后台数据隔离时你会看到同一套 Mapper 只要在 SQL 里拼一个当前用户可见范围的条件就能实现不推荐在 service 层手工过滤。2.2 供需状态机用字段管理比用流程表管理更省心信息发布平台里最容易被新手写坏的是状态字段。一个产品供给信息至少有草稿、待审核、已发布、已下架、已成交这几种状态如果再加一个“审核驳回”整个流转就是一张状态图。常见做法是直接在业务表上放一个status字段存当前状态再单独放一张审核日志表记录流转历史。不要一上来就设计一张大而全的“信息流程表”那样每次读当前状态都要查最新一条记录后续统计和维护都别扭。我习惯把状态定义成数据库注释和 Java 枚举双份维护数据库存 intJava 里用枚举做转换。这样写 SQL 时可以用status 2直接过滤写业务逻辑时又能拿到枚举的合法流转校验。需要注意的是不要让业务代码随意 setStatus所有状态变化尽量收敛到审核、下架、成交这几个明确的 service 方法里。否则源码里搜setStatus会搜出十几个调用点改一个状态规则就得满仓排查这是我在改一套二手交易源码时留下的血泪经验。2.3 用户表、供需表和审核日志表的建表 SQL 与字段说明为了把“产品供需信息发布与管理平台”的核心数据关系讲透这里给出三张最核心的建表语句。第一张是用户表第二张是产品供给表第三张是需求发布表审核日志表在后面接口实现里补。CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL COMMENT BCrypt加密后的密文, phone VARCHAR(20) DEFAULT NULL, role_type TINYINT NOT NULL DEFAULT 2 COMMENT 0-管理员 1-供给方 2-需求方, tenant_id BIGINT DEFAULT 1 COMMENT 租户/组织ID用于行级权限隔离, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT平台用户表; CREATE TABLE product_supply ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 发布人ID, title VARCHAR(128) NOT NULL, category_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL, spec VARCHAR(256) DEFAULT NULL COMMENT 规格型号, quantity INT DEFAULT 1, price DECIMAL(10,2) NOT NULL COMMENT 单价, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-草稿 1-待审核 2-已发布 3-已下架 4-已成交, audit_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-未提交 1-审核通过 2-审核驳回, expire_time DATETIME DEFAULT NULL COMMENT 自动下架时间, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_status (user_id, status), KEY idx_category_time (category_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT产品供给信息表; CREATE TABLE product_demand ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 需求方用户ID, title VARCHAR(128) NOT NULL, category_id BIGINT NOT NULL, demand_desc TEXT COMMENT 需求描述, budget_min DECIMAL(10,2) DEFAULT NULL, budget_max DECIMAL(10,2) DEFAULT NULL, quantity INT DEFAULT 1, deadline DATETIME DEFAULT NULL COMMENT 需求截止时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 同供给表状态, audit_status TINYINT NOT NULL DEFAULT 0, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_deadline (user_id, deadline) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT产品需求信息表;建表时有几个参数需要说明白。status和audit_status分开是因为“是否审核通过”和“当前是否展示”是两回事管理员驳回后发布人可以修改重新提交此时status可以留在草稿态。deleted字段是逻辑删除供需信息涉及交易凭证物理删除容易引起纠纷平台类项目几乎都保留逻辑删除。expire_time是给自动下架用的很多源码里这个字段被忽略后面第六章我会专门讲它的落地。索引设计上(user_id, status)覆盖“我的发布列表”查询(category_id, create_time)覆盖分类浏览场景这两个联合索引在数据量到十万级时体感明显。3. 核心功能落地从发布接口到供需匹配的完整实现3.1 发布与编辑事务里写供需表状态机校验不能省有了表结构先看最核心的发布接口。这里我把 Controller 和 Service 分层贴出来Controller 只负责参数接收和身份获取业务规则全部收在 Service 里方便后续加事务和日志。RestController RequestMapping(/api/post) public class PostController { private final SupplyPostService supplyPostService; public PostController(SupplyPostService supplyPostService) { this.supplyPostService supplyPostService; } PostMapping(/supply) public ResultLong publishSupply(RequestBody SupplyPostVO vo, RequestAttribute(userId) Long userId) { return Result.ok(supplyPostService.publishSupply(vo, userId)); } }Controller 里注解RequestAttribute(userId)是从登录拦截器写入的上下文里取当前用户不要在业务方法里自己解析 Token那样每个接口都要重复一段解析逻辑源码会显得很脏。下面看 Service 实现。Service public class SupplyPostServiceImpl implements SupplyPostService { private final SupplyPostMapper supplyPostMapper; private final AuditLogMapper auditLogMapper; public SupplyPostServiceImpl(SupplyPostMapper supplyPostMapper, AuditLogMapper auditLogMapper) { this.supplyPostMapper supplyPostMapper; this.auditLogMapper auditLogMapper; } Override Transactional(rollbackFor Exception.class) public Long publishSupply(SupplyPostVO vo, Long userId) { if (vo.getPrice() null || vo.getPrice().compareTo(BigDecimal.ZERO) 0) { throw new BizException(单价必须大于0); } if (vo.getQuantity() null || vo.getQuantity() 0) { throw new BizException(数量必须大于0); } SupplyPost post new SupplyPost(); BeanUtils.copyProperties(vo, post); post.setUserId(userId); post.setStatus(PostStatus.PENDING_AUDIT.getCode()); post.setAuditStatus(AuditStatus.SUBMITTED.getCode()); supplyPostMapper.insert(post); AuditLog log new AuditLog(); log.setTargetType(SUPPLY); log.setTargetId(post.getId()); log.setOperatorId(userId); log.setAction(CREATE); log.setReason(发布供给信息); auditLogMapper.insert(log); return post.getId(); } }这段代码有两个容易被忽略的参数细节。Transactional(rollbackFor Exception.class)必须写成 Exception.class 而不是默认的 RuntimeException因为很多自定义的 BizException 是继承 Exception 的不指定的话事务不会回滚。BeanUtils.copyProperties会把 VO 里同名字段直接拷进实体但user_id、status这类字段绝不会出现在 VO 里必须在这里手动设置防止调用方提交伪造字段直接把自己的信息状态改成已发布。这一点在 Java 面试题里经常被拿来问“如何防止前端越权”实际做法就是在服务端重设所有不可信字段。3.2 审核与多条件查询MyBatis 动态 SQL 是这套平台的灵魂管理员后台的供需信息列表几乎必然带查询条件按状态筛、按分类筛、按关键词搜。此时 MyBatis 的动态 SQL 优势就体现出来了。下面这段 XML 是供给信息的后台分页查询注意我加了行级权限的判断。select idselectSupplyPage resultTypecom.supply.pojo.SupplyPost select id, user_id, title, category_id, product_name, spec, quantity, price, status, audit_status, expire_time, create_time from product_supply where if teststatus ! null and status #{status} /if if testcategoryId ! null and category_id #{categoryId} /if if testkeyword ! null and keyword ! and (title like concat(%, #{keyword}, %) or product_name like concat(%, #{keyword}, %)) /if if testtenantId ! null and user_id in ( select id from sys_user where tenant_id #{tenantId} ) /if and deleted 0 /where order by create_time desc /select这里的where标签会自动去掉第一个条件前面的 and比手工写 where 11 安全得多。concat(%, #{keyword}, %)是 MySQL 写法不要直接写%#{keyword}%那样会导致 MyBatis 解析报错很多人第一次写动态 SQL 都会在这里翻车。tenant_id那段子查询就是行级权限 java 实现的一种方式同一个平台如果接入了多家企业客户管理员只能看到自己租户下的信息这个条件会由拦截器统一注入而不是每个 XML 里复制粘贴。有个容易被忽略的细节分页尽量不要用 PageHelper 在这种多条件查询上裸奔因为 PageHelper 的线程局部变量在异常场景下会残留导致下一条 SQL 被莫名其妙分页。我更推荐手写 limit 参数或者用 MyBatis-Plus 的分页插件。源码阅读阶段你会在 Mapper 接口里看到PageSupplyPost selectPage(...)之类的写法本质上就是帮你拼了 limit原理懂了以后排查分页问题会快很多。3.3 供需匹配按分类和关键词打分排序算法怎么落地供需匹配是这类平台的亮点功能但不要一开始就搞机器学习真正能用的第一版往往是打分排序。常见做法是给每条候选信息算一个综合分按分数倒序展示。下面这段代码把“分类命中、关键词命中、时间衰减”三个因素揉进了一个打分函数。public ListSupplyPostVO matchSupplyForDemand(Long demandId, int limit) { DemandPost demand demandMapper.selectById(demandId); if (demand null) { throw new BizException(需求不存在); } ListSupplyPost candidates supplyPostMapper.selectAllPublished(); return candidates.stream() .map(post - new MatchResult(post, calculateScore(post, demand))) .sorted(Comparator.comparingDouble(MatchResult::getScore) .reversed() .thenComparing(MatchResult::getCreateTime)) .limit(limit) .map(this::toVO) .collect(Collectors.toList()); } private double calculateScore(SupplyPost supply, DemandPost demand) { double score 0; if (supply.getCategoryId().equals(demand.getCategoryId())) { score 50; } if (supply.getProductName().contains(demand.getKeyword()) || demand.getTitle().contains(supply.getProductName())) { score 30; } long ageHours Duration.between(supply.getCreateTime(), LocalDateTime.now()).toHours(); score - Math.log1p(ageHours) * 2; return score; }打分权重是拍脑袋定的但拍完之后要能讲清楚为什么分类命中给 50 分是因为供需平台里用户第一诉求是“我要的东西正好有人卖”分类是最强信号关键词命中给 30 分是为了区分同分类下的不同物品时间衰减用log1p而不是线性减分是因为最新发布的几条信息权重差异要明显但三天前的信息也不至于直接消失。Comparator.comparingDouble(...).reversed()这里有个 java 排序经典坑reversed()只能作用于前一个比较器如果你先按时间再按分数排序写反了会出现同分项时间乱序。3.4 管理后台的数据隔离同一套查询怎么做到只看自己的数据平台管理员和普通用户看到的数据范围必须不一样。普通用户只能看已发布的供给信息管理员能看到待审核和已下架的记录企业租户管理员只能看本租户的数据。这个需求如果直接在业务代码里写 if-else每个接口都要判断角色很容易漏。常见做法是在 MyBatis 拦截器里统一注入数据权限条件或者像 3.2 节那样在 XML 里用if testtenantId ! null控制。我更推荐在查询入参里增加一个DataScope对象由 service 层统一填充管理员填 null 表示不过滤普通用户填自己的 userId租户管理员填 tenantId。这样 SQL 里的条件片段是显式的排查问题时有据可依。拦截器方案虽然“高级”但一旦 XML 里有自定义where逻辑拦截器改 SQL 的位置很容易和动态 SQL 冲突玄学报错会多到你怀疑人生。源码里如果看到这两种方案并存优先理解显式传参的那条链路它是这个平台最容易二次开发的地方。4. 平台开发避坑5个最常见的翻车现场与排查思路4.1 并发下架时状态被覆盖成交量统计对不上现象同一个供给信息被管理员下架的同时买家点击“成交”最终状态变成了已下架但统计报表里成交量却多了 1。原因两个请求同时读到了 status2 已发布各自执行 update 时没有校验当前状态。后执行的 update 把先执行的结果覆盖了。解决更新时把旧状态写进 where 条件让数据库来保证原子性。例如update product_supply set status 4 where id ? and status 2如果返回影响行数为 0说明状态已经被别人改掉业务层抛异常提示“请刷新后重试”。这套乐观锁思路同样适用于审核场景是供需平台最值得提前加固的地方。4.2 附件上传后图片间歇性 404现象本地开发时上传的产品图能访问重启应用后图片全部 404刷新几次又好了。原因常见做法是把图片保存在了src/main/resources/static/upload/下打包后文件落在 classpath 里每次重新部署会被清理或替换而且多实例部署时只有一台机器有文件。解决把上传目录放到应用外部比如/data/supply/upload再用一个 WebMvcConfigurer 做静态资源映射或者直接用 Nginx 的alias指向磁盘目录。文件名不要用原文件名服务端改成 UUID 加后缀避免中文名和路径穿越问题。我自己吃过一次亏后所有上传类源码都会先搜upload目录配置不会再相信默认静态路径。4.3 MyBatis 查询结果有同名字段被覆盖现象联表查询时两张表都有create_time返回给前端的时间变成同一个值。原因resultType 是直接映射联表查询同名字段后者会把前者覆盖MyBatis 不会自动区分前缀。解决SQL 里给字段起别名例如s.create_time as supply_create_time或者查询时不要用select *只挑需要的列。排查时可以打开 MyBatis 的 SQL 日志把最终执行的 SQL 复制到数据库客户端里跑一遍看看结果集到底是不是重复列。4.4 事务回滚失效审核日志写进去了但状态没变现象调用审核接口日志表新增一条记录但供需表状态还是待审核。原因同类内部调用导致 Spring AOP 代理失效。常见做法是this.audit()调用本类另一个带Transactional的方法事务注解不会生效。解决把事务边界拆到独立 Service或者在当前类注入自己的代理对象但最干净的做法是让 Controller 直接调用带事务的 Service 方法。这个坑在 Java 面试题里反复出现在真实项目里也几乎每周都有人踩排查时先看调用链里有没有 this 调用。4.5 前端显示的时间比数据库慢 8 小时现象数据库存的是 12:00接口返回给前端变成 04:00。原因JDBC 连接串没指定时区应用服务器和数据库默认时区不一致Jackson 序列化 LocalDateTime 时又用了默认时区。解决JDBC URL 里加serverTimezoneGMT%2B8同时明确 MyBatis 使用map-underscore-to-camel-case和日期类型映射。如果项目里大量用java.util.Date还要在 application.yml 里统一 Jackson 时区配置见第五章。这种问题最迷惑的点是同一个接口在测试环境正常、生产环境偏移原因就是两台机器时区不同。5. 把源码跑起来目录结构、环境配置与二次开发入口5.1 先看目录结构别急着点启动拿到一套基于 Java 开发的产品供需信息发布与管理平台设计源码我一般不会直接去点运行按钮而是先花十分钟看包结构。如果代码组织是下面这种分层后续扩展基本是舒服的。src/main/java/com/supply ├── controller # 接口层只做参数接收和结果返回 ├── service # 业务层事务和业务规则在这层 ├── mapper # MyBatis Mapper 接口 ├── pojo │ ├── entity # 数据库对应实体 │ ├── vo # 前端交互对象 │ └── dto # 查询和传输对象 ├── config # 拦截器、静态资源映射、Web 配置 └── common # 统一返回结果、异常、枚举 src/main/resources ├── mapper # XML 存放目录 └── application.yml需要特别注意的是很多源码会在 pojo/entity 里直接用 Lombok 的Data如果本地环境没装 Lombok 插件启动会直接报错找不到 getter/setter。这里顺带提一下 IDEA 插件开发环境装好 Lombok、MyBatisX 这两个插件读代码和跳转 SQL 会顺手很多。MyBatisX 能让你从 Mapper 接口直接跳到 XML 对应语句排查动态 SQL 时省掉一半的时间。5.2 本地虚拟机多端口 Nginx 开发环境多站点自定义域名配置供需平台通常有用户端和管理后台两个入口本地开发时我习惯不用同一个端口硬切而是用 Nginx 做多站点转发。先把本机 hosts 配上两个域名再给 Nginx 写两个 server 块各自转发到不同后端端口。# /etc/nginx/conf.d/supply.conf server { listen 80; server_name user.supply.local; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } server { listen 80; server_name admin.supply.local; location / { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }对应的本地 hosts 写在开发机里把这两个域名指到 127.0.0.1如果源码部署在虚拟机里就把 Nginx 装到虚拟机、hosts 指向虚拟机 IP。这样做的价值是前后端分离后前端跨域问题基本消失Cookie 也能按域名隔离。注意 Nginx 转发时一定要带上Host头否则 Spring Boot 里的重定向和相对路径会走到 127.0.0.1导致登录跳转过一次就失败。5.3 换库换表最该改的三个配置参数跑通源码前application.yml 有三个参数必须确认少改一个都可能启动即报错。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/supply_platform?useUnicodetruecharacterEncodingutf8serverTimezoneGMT%2B8useSSLfalse username: root password: 你的密码 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl第一个是连接串里的serverTimezoneGMT%2B8不配就会出现上一章说的 8 小时偏移。第二个是map-underscore-to-camel-case如果为 false数据库里的user_id字段无法自动映射到userId查询结果里 userId 全是 null这种问题最气人因为不报错。第三个是log-impl配成 StdOutImpl 后控制台会打印每一条执行的 SQL 和参数排查动态 SQL 拼错时这条日志比什么都好用。改完这三个参数再建好第二章的数据库表项目基本就能跑起来。6. 让平台活起来的一个小技巧延迟下架与供需推送的落地公式供需平台上线后最容易收到两类反馈失效信息一直挂着和有了新匹配不能第一时间通知。这里给一个轻量级的落地公式发布时把到期时间塞进 Redis 的有序集合用定时任务批量捞到期 ID 做下架同时把匹配到的供需摘要通过 WebSocket 推给订阅用户。Component public class SupplyExpireTask { private static final String EXPIRE_KEY supply:expire; private final StringRedisTemplate redisTemplate; private final SupplyPostMapper supplyPostMapper; public SupplyExpireTask(StringRedisTemplate redisTemplate, SupplyPostMapper supplyPostMapper) { this.redisTemplate redisTemplate; this.supplyPostMapper supplyPostMapper; } Scheduled(cron 0 * * * * ?) public void closeExpired() { long now System.currentTimeMillis(); SetString expiredIds redisTemplate.opsForZSet() .rangeByScore(EXPIRE_KEY, 0, now, 0, 100); if (expiredIds null || expiredIds.isEmpty()) { return; } for (String id : expiredIds) { supplyPostMapper.updateStatus(Long.valueOf(id), PostStatus.OFF_SHELF.getCode()); redisTemplate.opsForZSet().remove(EXPIRE_KEY, id); } } }这段逻辑里最关键的是发布时写入有序集合的那一步redisTemplate.opsForZSet().add(EXPIRE_KEY, postId.toString(), expireTime.getTime())用到期时间戳做 score。定时任务每分钟扫一次当前时间之前的 ID扫到的就是已经过期的信息直接改状态。为什么不用扫表因为信息量大以后每分钟扫一次全表做时间比较对数据库压力太大Redis ZSET 天然按 score 排序一次命令就能捞出所有过期 ID。批量限制 100 是为了避免一次任务处理太多导致数据库连接被占满处理完一批后下一分钟会接着捞剩余部分。这个方案也有坑如果 Redis 数据丢失过期信息不会被下架。常见的补偿手段是启动时全量扫描一遍数据库把未下架且未过期的信息重新灌回 ZSET。我在做类似平台时习惯把这个任务和 WebSocket 推送配合使用下架动作触发时向订阅了该分类的用户推送“您关注的产品已下架”的提醒新信息审核通过时再推送一条“有新的供给信息匹配您的需求”。这样平台才算真正“活”起来。我自己的习惯是第一版先把“发布→审核→匹配→下架”这条主链路用最简单的方式跑通再加定时任务和推送不要一开始就上消息队列。消息队列的引入会让源码复杂度翻倍而大部分供需平台的信息时效性并不需要秒级精度分钟级已经足够。希望这套拆解能在你动手做或者二次开发时帮上忙至少让你少熬夜排查几个状态和时区的怪问题。本文还有配套的精品资源点击获取
返回列表