ARTICLE DETAIL

资讯详情

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

Java全栈供需平台实战:从数据建模到撮合查询的完整方案

Java全栈供需平台实战:从数据建模到撮合查询的完整方案 简介这是一套基于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) ENGINEInnoDB DEFAULT CHARSETutf8mb4;); 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 MapInfoStatus, SetInfoStatus 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 ResultLong 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 LambdaQueryWrapperBizProductInfo() .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 IPageInfoVO search(InfoSearchDTO dto) { LambdaQueryWrapperBizProductInfo 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 LambdaUpdateWrapperBizProductInfo() .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; SetString 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 ResultVoid 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这种大 offsetMySQL 要扫描前 10 万行再丢弃。 解决改成基于id或create_time的游标分页前端传上一页最后一条的 idSQL 用where id lastId order by id desc limit 20。供需平台的信息流场景非常适合游标分页。现象三中文关键词搜不到结果英文能搜到。原因数据库连接字符集不是 utf8mb4或者建表时用了 latin1中文存进去就乱码。 解决连接串加characterEncodingutf8useUnicodetrue建表统一DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_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 版本观察用户点击率如果撮合确实被用起来了再投入做离线计算避免一上来就过度设计。验证撮合效果有个简单办法找十条真实的供应信息手动跑一遍这个查询看返回的需求里有多少是看起来确实相关的。如果十条里有六条以上相关说明分类和地区体系建得没问题可以继续如果大部分不相关问题多半出在分类粒度太粗或地区编码不统一得回头改建模而不是改算法。这个习惯帮我省过好几次返工——先验证数据质量再优化匹配逻辑顺序反了就是白费力气。最后说个我自己的教训做这类平台我早期总想着把功能做全审核、通知、撮合、统计一起上结果每个都半成品上线后到处是洞。后来改成先把发布 → 检索 → 审核这条主链路做扎实撮合和统计作为第二阶段反而上线更稳、迭代更快。供需平台的核心竞争力从来不是功能多而是信息真实、检索准、状态清楚。希望帮到你。本文还有配套的精品资源点击获取
返回列表