☰
Java全栈供需平台实战:从数据建模到撮合查询的完整方案
2026/10/8 7:43:08 网站建设 项目流程

简介:这是一套基于Java开发的产品供需信息发布与管理平台设计源码,面向食品配料、包装、代工、招商等企业及寻找供应商的用户,帮助其实现需求与供应信息的数字化发布、精准匹配与高效管理。资源包共62个文件,约514KB,以18个class编译文件、17个java源文件、13个xml配置文件为主,另含iml项目文件、jpg界面图片及classpath、gitignore等工程配置,完整覆盖从源码到编译产物的项目结构。系统核心功能包括用户注册登录、用户中心管理、需求信息发布与供应信息发布,通过XML配置与类路径设置保障平台稳定运行,界面图片提升视觉体验。src与bin目录分别存放源代码与字节码,便于理解项目构建与执行流程。目前已有275人学习下载,适合Java初学者与课程设计者参考,可快速掌握信息发布类平台的架构设计与功能实现思路。

1. 供需平台不是"发帖+列表":Java 全栈方案到底解决什么问题

很多团队接到"产品供需信息发布与管理平台"这个需求时,第一反应是把它当成一个带后台的论坛:用户发一条供应信息,再发一条需求信息,列表页一拉,搜索框一放,就算完事。真上线跑两周就会发现,供需平台的核心难点根本不在"发布",而在"撮合"——同一条钢材供应信息,可能同时匹配到三个采购需求,谁先看到、谁先联系、状态怎么流转、过期怎么下架、重复发布怎么合并,这些才是决定平台能不能用的地方。用 Java 做这套系统,最大的价值不是语言本身,而是 Spring Boot + MyBatis-Plus 这套组合能把"信息实体 + 状态机 + 权限 + 检索"四件事用相对统一的工程范式落地,后期加字段、加角色、加审核流不会推倒重来。

这篇笔记面向的是手里已经有一个"基于 Java 开发的产品供需信息发布与管理平台设计源码"标题、但不确定从哪下手的人:可能是课程设计要交差的学生,也可能是接了个中小型 B2B 信息平台私活的工程师。我会按"数据模型怎么定 → 后端接口怎么写 → 检索和状态怎么处理 → 部署和踩坑"的顺序,把一套能跑起来的最小闭环讲清楚。源码工程本身不在我手上,所以下面出现的类名、表名、配置都是我按这类平台最常见的做法给出的可复现方案,你照着改字段就能用。

2. 先把供需两张表的关系理清:实体建模与状态机设计

供需平台翻车最多的环节不是代码写错,而是建模阶段把"供应"和"需求"当成两张互不相干的表。等到要做匹配推荐时,发现两张表的字段命名、分类体系、地区编码全对不上,只能写一堆 if-else 硬转。所以第一步必须把公共字段抽出来,让供应和需求共享同一套元数据。

2.1 供应与需求为什么建议共用一张主表加类型字段

常见做法有两种:一种是supply_info和demand_info两张独立表;另一种是一张biz_product_info主表,用info_type字段区分 1=供应、2=需求。我一般推荐后者,原因很实际:供需平台后期一定会做"一条供应匹配多条需求"的撮合,如果分两张表,每次匹配都要跨表 union,索引很难走;共用主表后,匹配本质就是同表内按category_id + region_code做自连接,SQL 清爽很多。

代价是字段会有冗余——比如供应有"起订量",需求有"采购量",语义相近但单位可能不同。解决办法是把这类差异字段放进扩展 JSON 列ext_attrs,主表只保留所有类型都需要的公共字段。下面这张表是我实际用过的字段划分,你可以直接对照建表:

字段名类型说明是否公共
idbigint主键,雪花 ID公共
info_typetinyint1 供应 / 2 需求公共
titlevarchar(120)信息标题公共
category_idint分类,关联分类表公共
region_codevarchar(12)行政区划编码公共
contact_user_idbigint发布人公共
statustinyint状态机字段公共
expire_timedatetime过期时间公共
ext_attrsjson类型专属字段差异

status这个字段是整套系统的灵魂,必须一开始就定死状态流转,不然后面审核、下架、成交全靠猜。我用的状态机是:0 待审核 → 1 已发布 → 2 已下架 → 3 已成交 → 4 审核驳回。注意 3 和 2 是并列终态,成交后不能再下架,下架后不能再成交,这个约束要写在业务层,不能只靠前端按钮控制。

2.2 用 MyBatis-Plus 生成建表语句并落地实体类

热词里有人搜"mybatisplus 根据 java 实体类生成创建表的 sql 语句",这在这类平台里确实是个提效点:先把实体类写出来,再反推 DDL,比手写建表少出错。MyBatis-Plus 本身不直接生成 DDL,但可以借助它的TableInfoHelper拿到实体元信息,自己拼 SQL。下面这段代码是我常用的做法:

import com.baomidou.mybatisplus.core.metadata.TableInfo; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.toolkit.StringUtils; public class DdlGenerator { // 传入实体 Class,输出对应的 CREATE TABLE 语句 public static String generate(Class<?> entityClass) { TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass); if (tableInfo == null) { throw new IllegalStateException("实体未被 MyBatis-Plus 扫描到,检查 @TableName 注解"); } StringBuilder sb = new StringBuilder(); sb.append("CREATE TABLE `").append(tableInfo.getTableName()).append("` (\n"); // 主键单独处理,注意自增和雪花 ID 的区别 sb.append(" `").append(tableInfo.getKeyColumn()).append("` bigint NOT NULL COMMENT '主键',\n"); tableInfo.getFieldList().forEach(field -> { sb.append(" `").append(field.getColumn()).append("` ") .append(guessSqlType(field.getPropertyType())).append(" COMMENT '") .append(StringUtils.isBlank(field.getComment()) ? field.getProperty() : field.getComment()) .append("',\n"); }); sb.append(" PRIMARY KEY (`").append(tableInfo.getKeyColumn()).append("`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); return sb.toString(); } // 简易类型映射,生产环境建议用完整的 TypeHandler 映射表 private static String guessSqlType(Class<?> type) { if (type == String.class) return "varchar(255)"; if (type == Integer.class || type == int.class) return "int"; if (type == Long.class || type == long.class) return "bigint"; if (type == java.util.Date.class || type == java.time.LocalDateTime.class) return "datetime"; if (type == java.math.BigDecimal.class) return "decimal(12,2)"; return "varchar(255)"; } }

逻辑说明:TableInfoHelper.getTableInfo会读取实体上的@TableName、@TableField注解,拿到表名、列名、注释。参数上要注意两点:一是实体必须已经被 MyBatis-Plus 的MapperScan扫描到,否则getTableInfo返回 null,这是最常见的翻车点;二是guessSqlType只是演示用的简易映射,真实项目里 Boolean、枚举、JSON 字段都要单独处理,否则生成的 DDL 类型会不对。生成出来的 SQL 建议人工过一遍再加索引,别直接执行。

2.3 状态流转用枚举加校验,别散落在 Service 里

状态机如果写成if (status == 1) { ... }散落在各个 Service 方法里,三个月后没人敢改。我一般定义一个枚举加一个转移校验器:

public enum InfoStatus { PENDING(0, "待审核"), PUBLISHED(1, "已发布"), OFFLINE(2, "已下架"), DEAL(3, "已成交"), REJECTED(4, "审核驳回"); private final int code; private final String desc; InfoStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } // 定义合法流转:key 是当前状态,value 是允许到达的状态 private static final Map<InfoStatus, Set<InfoStatus>> TRANSFER = Map.of( PENDING, Set.of(PUBLISHED, REJECTED), PUBLISHED, Set.of(OFFLINE, DEAL), OFFLINE, Set.of(PUBLISHED), DEAL, Set.of(), REJECTED, Set.of(PENDING) ); public static boolean canTransfer(InfoStatus from, InfoStatus to) { return TRANSFER.getOrDefault(from, Set.of()).contains(to); } }

这样任何状态变更前先调canTransfer,不合法直接抛业务异常。参数上唯一要留意的是Map.of在 Java 8 不可用,如果项目锁在 JDK 8,换成静态初始化块。把状态约束集中到一处后,后面加"重新上架""申诉"这类流程只需要改这个 Map,不用满项目找 if。

3. 发布与检索接口怎么写:从 Controller 到分页查询

建模定完,接下来是让平台真正能用的两个接口:发布和检索。这两个接口写得好不好,直接决定用户愿不愿意用——发布要防重复、防脏词、防超期;检索要快、要能按分类和地区筛、要能分页。这一章把这两条链路拆开讲。

3.1 发布接口的参数校验与防重复提交

发布接口最容易出的问题是同一个人连点两次提交,库里出现两条一模一样的信息。前端禁用按钮只能防君子,后端必须做幂等。我的做法是:用title + contact_user_id + category_id做业务唯一键,插入前先查,同时给这个组合加唯一索引兜底。

@PostMapping("/info/publish") public Result<Long> publish(@RequestBody @Valid InfoPublishDTO dto) { // 1. 基础校验由 @Valid 完成,这里做业务级校验 if (dto.getExpireTime().isBefore(LocalDateTime.now().plusDays(1))) { return Result.fail("过期时间至少要在 1 天以后"); } // 2. 防重复:同一用户 5 分钟内不允许发布标题完全相同的信息 Long dupCount = infoMapper.selectCount(new LambdaQueryWrapper<BizProductInfo>() .eq(BizProductInfo::getContactUserId, dto.getUserId()) .eq(BizProductInfo::getTitle, dto.getTitle()) .ge(BizProductInfo::getCreateTime, LocalDateTime.now().minusMinutes(5))); if (dupCount > 0) { return Result.fail("请勿重复发布相同信息"); } // 3. 落库,状态置为待审核 BizProductInfo entity = InfoConverter.INSTANCE.toEntity(dto); entity.setStatus(InfoStatus.PENDING.getCode()); infoMapper.insert(entity); return Result.ok(entity.getId()); }

逻辑说明:@Valid负责非空、长度这类基础校验,业务校验单独写。防重复这里用的是"时间窗口 + 标题"策略,比全局唯一更宽松,避免用户改一个字就发不出去。参数上expireTime我强制至少 1 天,是因为见过太多人填了当天过期,信息刚审核通过就下架,体验很差。如果你要做更严格的防刷,可以叠加 Redis 的setnx做短时锁,但别用数据库唯一索引硬卡,否则用户改标题重发会被误伤。

3.2 分类加地区的组合检索与分页优化

检索接口是供需平台的压力集中点。用户会按分类、地区、关键词、信息类型组合筛,还要分页。如果直接like %关键词%全表扫,几万条数据就开始卡。我的做法是:分类和地区走索引等值查询,关键词走全文或前缀匹配,分页用游标而不是大 offset。

public IPage<InfoVO> search(InfoSearchDTO dto) { LambdaQueryWrapper<BizProductInfo> wrapper = new LambdaQueryWrapper<>(); // 只查已发布且未过期的信息,这是检索的硬前提 wrapper.eq(BizProductInfo::getStatus, InfoStatus.PUBLISHED.getCode()) .gt(BizProductInfo::getExpireTime, LocalDateTime.now()); // 分类和地区走等值,能命中联合索引 wrapper.eq(dto.getCategoryId() != null, BizProductInfo::getCategoryId, dto.getCategoryId()) .eq(StringUtils.hasText(dto.getRegionCode()), BizProductInfo::getRegionCode, dto.getRegionCode()) .eq(dto.getInfoType() != null, BizProductInfo::getInfoType, dto.getInfoType()); // 关键词用右模糊,避免 % 开头导致索引失效 wrapper.likeRight(StringUtils.hasText(dto.getKeyword()), BizProductInfo::getTitle, dto.getKeyword()); wrapper.orderByDesc(BizProductInfo::getCreateTime); return infoMapper.selectPage(new Page<>(dto.getPageNo(), dto.getPageSize()), wrapper); }

逻辑说明:likeRight生成的是title like '关键词%',能走索引;如果用户习惯搜中间词,就得考虑上全文索引或搜索引擎,别硬用like %...%。参数上pageSize一定要在 DTO 里限制上限,比如最大 50,否则有人传 10000 直接把数据库拖垮。联合索引建议建在(status, expire_time, category_id, region_code)上,顺序别乱,等值字段在前、范围字段在后是基本原则。

3.3 过期信息的下架:定时任务还是惰性判断

信息过期后要不要立刻下架,是个容易被忽略的设计点。两种做法:定时任务扫全表把过期状态改掉,或者查询时用expire_time > now()过滤、状态字段不动。我倾向后者为主、定时任务为辅:查询时过滤保证用户永远看不到过期信息,定时任务每天凌晨跑一次把过期信息状态改成下架,用于统计和通知。

@Scheduled(cron = "0 30 2 * * ?") public void offlineExpired() { // 每次批量处理 500 条,避免大事务锁表 int affected; do { affected = infoMapper.update(null, new LambdaUpdateWrapper<BizProductInfo>() .eq(BizProductInfo::getStatus, InfoStatus.PUBLISHED.getCode()) .lt(BizProductInfo::getExpireTime, LocalDateTime.now()) .last("LIMIT 500") .set(BizProductInfo::getStatus, InfoStatus.OFFLINE.getCode())); } while (affected > 0); }

逻辑说明:LIMIT 500分批是为了避免一次性更新几十万行导致主从延迟和锁等待。参数上 cron 选在凌晨 2:30,避开业务高峰。注意LambdaUpdateWrapper的last方法拼接的是原生 SQL,字段名要写数据库列名而不是 Java 属性名,这里容易写错。

4. 权限、审核与消息通知:让平台从能用变成可运营

一个只有发布和检索的平台,本质上还是个公告板。真正让它变成"可运营"的,是审核流、角色权限和状态变更通知。这三块做不好,运营人员就得天天手动改数据库。

4.1 基于角色的权限控制怎么落到接口层

供需平台的角色通常有三类:普通用户(发信息、看信息)、企业用户(可认证、信息权重高)、管理员(审核、下架、封禁)。用 Spring Security 或 Shiro 都行,我一般用轻量的拦截器 + 注解方案,避免引入太重。核心是给每个接口标上所需角色,拦截器统一校验。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); // 允许访问的角色编码 } // 拦截器里读取注解并比对当前登录用户角色 public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { if (!(handler instanceof HandlerMethod)) return true; RequireRole anno = ((HandlerMethod) handler).getMethodAnnotation(RequireRole.class); if (anno == null) return true; Set<String> userRoles = UserContext.getRoles(); for (String role : anno.value()) { if (userRoles.contains(role)) return true; } throw new BizException("无权限访问该接口"); }

逻辑说明:注解方案的好处是权限声明和接口写在一起,看代码就知道谁能调。参数上UserContext用 ThreadLocal 存当前用户信息,记得在afterCompletion里 remove,否则线程池复用会串数据,这是血泪经验。角色编码建议用常量类管理,别在注解里写魔法字符串。

4.2 审核流的两种实现与选择

审核流有两种常见实现:一是简单状态位,管理员点通过或驳回,信息状态直接改;二是工作流引擎,支持多级审核、会签。中小平台我强烈建议用第一种,工作流引擎(Activiti、Flowable)的引入成本远大于收益,除非你明确要做多级审批。简单审核流的接口大概长这样:

@PostMapping("/admin/audit") @RequireRole({"ADMIN"}) public Result<Void> audit(@RequestBody AuditDTO dto) { BizProductInfo info = infoMapper.selectById(dto.getInfoId()); if (info == null) return Result.fail("信息不存在"); InfoStatus target = dto.getPass() ? InfoStatus.PUBLISHED : InfoStatus.REJECTED; if (!InfoStatus.canTransfer(InfoStatus.PENDING, target)) { return Result.fail("当前状态不允许审核"); } info.setStatus(target.getCode()); info.setAuditRemark(dto.getRemark()); infoMapper.updateById(info); // 审核结果异步通知发布人 noticeService.sendAuditResult(info.getContactUserId(), info.getId(), dto.getPass()); return Result.ok(); }

逻辑说明:审核前先查状态,防止重复审核或审核已下架的信息。参数上auditRemark在驳回时必填,通过时可选,这个约束要写在 DTO 的分组校验里。通知走异步,别在审核事务里同步发短信或站内信,否则第三方接口一慢,审核接口就超时。

4.3 状态变更通知用事件解耦

信息从待审核变已发布、从已发布变已成交,这些节点都该通知相关人。如果每个 Service 方法里都塞一段发通知的代码,耦合会很严重。用 Spring 的事件机制解耦是常见做法:

// 定义事件 public class InfoStatusChangedEvent extends ApplicationEvent { private final Long infoId; private final int fromStatus; private final int toStatus; // 构造和 getter 省略 } // 状态变更处发布事件 applicationEventPublisher.publishEvent(new InfoStatusChangedEvent(id, from, to)); // 监听器里处理通知,标记 @Async 异步执行 @Async @EventListener public void onStatusChanged(InfoStatusChangedEvent event) { // 根据 from/to 组合决定通知文案和渠道 noticeService.dispatch(event); }

逻辑说明:事件解耦后,状态变更逻辑只管改状态,通知逻辑独立演进。参数上@Async需要配合@EnableAsync生效,线程池要自定义,别用默认的,否则高并发下会创建过多线程。注意事件监听默认是同步的,不加@Async会阻塞主流程。

5. 部署与联调避坑:那些让平台上线第一天就崩的细节

代码写完只是开始,部署和联调阶段才是真正暴露问题的地方。这一章记录几个我在供需平台项目里反复遇到的坑,每条都按现象、原因、解决来写,你对照排查能省不少时间。

5.1 常见问题排查清单

现象一:本地跑得好好的,部署到服务器后接口全部 404。原因:打包时spring-boot-maven-plugin没配置,打出来的是普通 jar 而不是可执行 jar,或者server.servlet.context-path在生产和本地配置不一致。 解决:检查pom.xml里有没有spring-boot-maven-plugin的repackage目标,用java -jar启动后看控制台有没有打印实际端口和 context-path,别凭记忆猜。

现象二:分页查询第一页正常,翻到后面越来越慢。原因:用了limit 100000, 20这种大 offset,MySQL 要扫描前 10 万行再丢弃。 解决:改成基于id或create_time的游标分页,前端传上一页最后一条的 id,SQL 用where id < lastId order by id desc limit 20。供需平台的信息流场景非常适合游标分页。

现象三:中文关键词搜不到结果,英文能搜到。原因:数据库连接字符集不是 utf8mb4,或者建表时用了 latin1,中文存进去就乱码。 解决:连接串加characterEncoding=utf8&useUnicode=true,建表统一DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci,已经建错的表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4修。

现象四:定时任务在本地跑,部署到多台服务器后同一任务执行了多次。原因:多实例部署时每个实例都启动了定时任务,没有分布式锁。 解决:用 Redis 或数据库做分布式锁,任务执行前抢锁,抢不到直接返回。或者干脆把定时任务拆成独立服务,只部署一个实例。

现象五:用户上传的图片在本地能显示,线上 404。原因:文件存到了应用服务器的本地磁盘,多实例部署时请求被负载均衡打到另一台机器上。 解决:文件统一存对象存储,数据库只存 URL。如果非要用本地磁盘,至少要做 NFS 共享,但这不是长久之计。

5.2 联调阶段的环境配置检查

联调时最耗时的往往不是代码 bug,而是环境不一致。我一般会准备一份检查清单,每次部署前过一遍:JDK 版本是否和编译版本一致(见过用 JDK 17 编译、服务器装 JDK 8 的)、数据库时区是否和 JVM 时区一致(不一致会导致expire_time判断差 8 小时)、Redis 是否设置了密码、文件上传目录是否有写权限。这些看起来是小事,但每一条都能让你排查半天。特别是时区问题,供需平台的过期判断强依赖时间,时区差 8 小时会让信息提前或延后 8 小时下架,用户投诉都找不到原因。

6. 让供需匹配真正跑起来:一个可验证的撮合查询技巧

平台做到发布、检索、审核都通了,其实还停留在"信息展示"层面。供需平台真正的价值在于撮合——把一条供应信息和可能感兴趣的需求方连起来。这一章给一个不需要引入推荐系统、纯靠 SQL 就能跑起来的撮合查询,你可以先验证效果,再决定要不要上更复杂的方案。

核心思路是:给定一条供应信息,找出同分类、同地区、状态为已发布、且发布时间在有效期内的需求信息,按匹配度排序。匹配度可以简单定义为分类相同得高分、地区相同得高分、关键词有重叠加分。下面这段 SQL 是可直接执行的版本:

-- 给定供应信息 id = 1001,找出最匹配的需求 SELECT d.id, d.title, d.contact_user_id, (CASE WHEN d.category_id = s.category_id THEN 40 ELSE 0 END + CASE WHEN d.region_code = s.region_code THEN 30 ELSE 0 END + CASE WHEN d.title LIKE CONCAT('%', SUBSTRING(s.title, 1, 4), '%') THEN 20 ELSE 0 END ) AS match_score FROM biz_product_info s JOIN biz_product_info d ON d.info_type = 2 AND d.status = 1 AND d.expire_time > NOW() AND d.category_id = s.category_id WHERE s.id = 1001 AND s.info_type = 1 ORDER BY match_score DESC, d.create_time DESC LIMIT 20;

逻辑说明:这条 SQL 用自连接把供应和需求放在一起算分,分类相同给 40 分,地区相同给 30 分,标题前四个字有重叠给 20 分,满分 90。参数上SUBSTRING(s.title, 1, 4)取标题前四个字做模糊匹配是个粗糙但有效的启发式,比全字段分词简单得多;如果你的标题格式规范(比如都以产品名开头),这个策略命中率会很高。LIMIT 20是防止一次返回太多,实际产品里可以做成"查看全部匹配"的分页。

这个查询在数据量几万条时性能没问题,因为info_type + status + category_id上有联合索引,自连接的两边都能走索引。数据量上到几十万后,建议把撮合结果离线算好存到一张match_result表里,定时刷新,查询时直接读结果表。我一般会先上线这个 SQL 版本,观察用户点击率,如果撮合确实被用起来了,再投入做离线计算,避免一上来就过度设计。

验证撮合效果有个简单办法:找十条真实的供应信息,手动跑一遍这个查询,看返回的需求里有多少是"看起来确实相关"的。如果十条里有六条以上相关,说明分类和地区体系建得没问题,可以继续;如果大部分不相关,问题多半出在分类粒度太粗或地区编码不统一,得回头改建模,而不是改算法。这个习惯帮我省过好几次返工——先验证数据质量,再优化匹配逻辑,顺序反了就是白费力气。

最后说个我自己的教训:做这类平台,我早期总想着把功能做全,审核、通知、撮合、统计一起上,结果每个都半成品,上线后到处是洞。后来改成先把"发布 → 检索 → 审核"这条主链路做扎实,撮合和统计作为第二阶段,反而上线更稳、迭代更快。供需平台的核心竞争力从来不是功能多,而是信息真实、检索准、状态清楚。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询