资讯动态

SpringBoot+Vue律师资讯推荐系统:从数据表到冷启动实战

发布时间:2026/9/27 0:04:20 来源:尧图企业网站定制
简介这是一份基于SpringBoot与Vue构建的律师资讯与推荐系统完整源码适合毕业设计、课程设计或希望接触前后端分离实战的Java开发者。系统围绕法律服务场景整合用户登录注册、密码找回、首页浏览、律师搜索、法律咨询、问答互动、知识学习、在线咨询、律师评价、详情查看、个人中心等功能律师端另设咨询管理、知识管理、个人中心等模块覆盖资讯展示到服务闭环的完整业务链。压缩包共135个文件以87个Java源文件为主配合16个XML与2个YAML配置部署15个Log用于排查11个PNG提供素材整体仅5.25MB轻量完整。目前92人学习从中可掌握SpringBootVue整合、双角色模块划分及法律服务推荐逻辑的落地思路适合快速启动同类项目。1. 律师资讯与推荐系统把“找律师”变成一套可运营的服务撮合流程一个用户打开法律服务平台是想看资讯建立信任还是想直接找到能打的律师真实场景里两者同时发生平台还想从浏览行为中猜出下一屏该推什么。这个基于SpringBootVue的律师资讯与推荐系统做的正是把律师库、资讯库、咨询预约和推荐引擎串成同一套可运营的服务闭环。后端用SpringBoot暴露REST接口、管理状态和记录行为日志前端用Vue做路由与组件化推荐部分先按律师标签和用户行为做相似度排序。它不是那种只展示数据的CRUD项目而是一个能根据浏览记录不断调整展示顺序的法律服务撮合系统。适合正在做毕业设计、或者中小团队准备搭一套法律咨询平台的人参考。2. 数据模型先行律师库、资讯库与行为日志怎么建表推荐系统能不能跑起来七成取决于表结构设计。常见的做法是先建六张核心表用户表、律师表、资讯表、预约表、咨询记录表和行为日志表。前四张是业务主表行为日志是推荐系统的“黑匣子”没有它所谓推荐就只是按点击量排序谈不上个性化。2.1 六张核心表字段、约束与一对多的关系设计律师表是整套系统的主心骨。除了姓名、头像、执业年限、办公地点这些基础字段最关键的三个字段是practice_area执业领域、legal_tags标签串和rating评分。执业领域用来做一级分类筛选标签串用来算律师之间的相似度评分则进入热门权重。建表时标签我建议用VARCHAR存逗号分隔的字符串等数据量大了再拆表前期别过度设计。CREATE TABLE lawyer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, avatar VARCHAR(255), practice_area VARCHAR(100) NOT NULL, years_of_experience INT DEFAULT 0, office_location VARCHAR(100), introduction TEXT, legal_tags VARCHAR(255) DEFAULT , rating DECIMAL(2,1) DEFAULT 5.0, case_count INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 0下架 1上架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_area (practice_area), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_area和idx_status是列表页高频查询的索引没有它们律师量一过万就会明显变慢。标签串legal_tags存的是中文领域词比如“婚姻家事,劳动纠纷,合同审查”每一段用英文逗号隔开方便后端直接split。资讯表要单独说因为它和普通博客表的区别在于多了发布状态和关联律师。平台上律师发布的办案心得、法律解读上线前要过审核所以必须有状态机。CREATE TABLE article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, summary VARCHAR(500), content MEDIUMTEXT, lawyer_id BIGINT DEFAULT NULL, category VARCHAR(50) DEFAULT 法律科普, status TINYINT DEFAULT 0 COMMENT 0草稿 1发布 2下架, view_count INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, published_at DATETIME DEFAULT NULL, KEY idx_status (status), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用户表、预约表、咨询记录表按常规设计即可但预约表里要留一个status字段从“待确认”走到“已完成”这是后续做咨询转化率分析的基础。用户表和预约表是一对多律师表和预约表也是一对多外键逻辑放在Service层保障数据库层只保留逻辑外键避免删除时互相牵制。2.2 行为日志表推荐系统的“黑匣子”怎么设计行为日志表是所有推荐算法的输入源。用户在律师详情页停留、收藏了律师、发起了咨询这些动作都要落库。字段不需要多但必须能区分行为类型和打分权重。CREATE TABLE behavior_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, target_type VARCHAR(20) NOT NULL COMMENT lawyer/article, target_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL COMMENT view/collect/consult/share, score INT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id, created_at), KEY idx_target (target_type, target_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;score字段是给不同行为打权重用的浏览算1分收藏算5分发起咨询算10分。写入时机建议用异步前端调接口后把行为丢进队列或者直接插入别让日志写入阻塞主流程。很多新手在这里翻车把行为记录和业务接口串成同步事务用户点一个“收藏”要等日志写完才返回体验极差。2.3 用MyBatis-Plus做分页分页插件配置与两个失效场景列表页和推荐位都要分页。SpringBoot里最省事的方案是MyBatis-Plus的分页插件但它的配置有讲究。直接往容器里塞一个分页拦截器指定数据库类型为MySQL即可。import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(100L)是保护措施防止有人传一个pageSize100000把数据库打爆。配置完这个Bean之后分页查询用Page对象作为第一个参数传入Mapper方法就可以。分页失效最常见的两个场景第一MybatisPlusInterceptor和PaginationInnerInterceptor没有一起配置只引了依赖但没注册Bean结果查出来永远是全量数据第二在同一个Mapper方法里同时做了自定义多表Join分页插件只能拦住单表查询多表Join时如果没写好count语句分页后的总数会变成0。遇到这两种情况优先看控制台打印的SQL如果SQL里没有LIMIT就知道分页没生效直接检查配置类是否被Spring扫描到。提示分页插件的版本要和MyBatis-Plus主版本保持一致。3.4和3.5的拦截器API有差异直接抄老项目的配置容易报ClassNotFoundException。3. SpringBoot后端从分页列表到推荐接口的分层实现后端这部分真正体现“系统”二字。除了常规的增删改查还要把推荐算法、资讯状态机、请求过滤串起来。常见做法是Controller只做参数校验和结果封装Service层放业务逻辑Mapper层做数据访问这样推荐引擎要换算法时不需要动Controller。3.1 推荐接口API设计相似律师与热门律师的返回结构推荐接口对外只暴露两个一个是GET /api/v1/recommend/lawyer?lawyerId3size6用来在律师详情页推荐相似律师另一个是GET /api/v1/recommend/hot?city上海用在首页推荐位。相似度的计算在Service层做这里先用Jaccard相似度即两个律师标签集合的交集除以并集简单直观且对中文标签不需要做向量化训练。import org.springframework.stereotype.Service; import java.util.*; import java.util.stream.Collectors; Service public class RecommendService { public ListLawyer recommendSimilar(Long lawyerId, int size) { Lawyer target lawyerMapper.selectById(lawyerId); if (target null) { return Collections.emptyList(); } SetString targetTags splitTags(target.getLegalTags()); ListLawyer candidates lawyerMapper.selectList( new LambdaQueryWrapperLawyer() .eq(Lawyer::getStatus, 1) .ne(Lawyer::getId, lawyerId) ); return candidates.stream() .map(lawyer - { SetString tags splitTags(lawyer.getLegalTags()); double score jaccard(targetTags, tags); return new AbstractMap.SimpleEntry(lawyer, score); }) .sorted((e1, e2) - Double.compare(e2.getValue(), e1.getValue())) .limit(size) .map(Map.Entry::getKey) .collect(Collectors.toList()); } private SetString splitTags(String tags) { if (tags null || tags.isBlank()) { return Collections.emptySet(); } return Arrays.stream(tags.split(,)) .map(String::trim) .collect(Collectors.toSet()); } private double jaccard(SetString a, SetString b) { if (a.isEmpty() b.isEmpty()) { return 0.0; } SetString union new HashSet(a); union.addAll(b); SetString intersection new HashSet(a); intersection.retainAll(b); return (double) intersection.size() / union.size(); } }这段代码的逻辑很直白查出目标律师的标签遍历所有上架律师算Jaccard分数后按分数倒序取前N条。候选律师全量遍历在数据量少于一万时没有性能压力等超过五万再考虑上ES或向量数据库。接口里size参数要加上限防止有人传1000把响应体撑爆。3.2 资讯管理接口草稿/发布/下架的状态机与数据权限资讯发布不能直接改status字段就完事要有状态流转校验。草稿只能转发布发布只能转下架下架后可以回到草稿。这层逻辑放在Service层用状态机方法统一收口。public Boolean changeArticleStatus(Long articleId, Integer fromStatus, Integer toStatus) { Article article articleMapper.selectById(articleId); if (article null) { throw new BusinessException(资讯不存在); } if (!article.getStatus().equals(fromStatus)) { throw new BusinessException(状态流转不合法); } article.setStatus(toStatus); if (toStatus 1) { article.setPublishedAt(LocalDateTime.now()); } return articleMapper.updateById(article) 0; }调用时传入期望的当前状态和目标状态比如发布接口就是changeArticleStatus(id, 0, 1)。这里有一个容易忽略的点发布操作要顺手把published_at写进去列表页排序靠它而不是靠created_at。数据权限上普通律师只能改自己的资讯管理员可以改所有这个校验要么在Controller层通过RequestAttribute拿当前用户角色要么在Service层用AOP切面统一判断不要散落在各个接口里。3.3 用全局过滤器挡XSS律师履历里的脚本标签是怎么被挡掉的律师简介、资讯内容是富文本用户提交的内容里可能混入script标签。常见做法是加一个全局过滤器把请求参数中危险字符统一转义。import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.IOException; Component public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; chain.doFilter(new XssRequestWrapper(httpRequest), response); } static class XssRequestWrapper extends HttpServletRequestWrapper { public XssRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); return clean(value); } Override public String[] getParameterValues(String name) { String[] values super.getParameterValues(name); if (values null) { return null; } String[] cleaned new String[values.length]; for (int i 0; i values.length; i) { cleaned[i] clean(values[i]); } return cleaned; } private String clean(String value) { if (value null) { return null; } return value.replaceAll(, lt;) .replaceAll(, gt;) .replaceAll(\, quot;) .replaceAll(, #39;); } } }这个过滤器只处理getParameter也就是表单提交和URL参数。如果接口接收的是JSON体那要走getInputStream包装做法类似只是要重写getReader并按同样规则清洗JSON字符串。注意一个坑过滤器会拦截所有请求包括上传文件接口如果项目里有PDF、图片上传要在doFilter里加路径判断白名单跳过/api/v1/upload/**否则上传的文件流会被转义破坏。4. Vue前端从路由骨架到律师推荐位的组件化落地前端的核心工作是把后端接口变成用户看得懂、点得动的界面。律师列表、资讯详情、咨询弹窗、推荐位这些模块在Vue里用路由和组件拆分。工程上建议直接用Vue CLI或Vite初始化项目装上vue-router和axios就够了状态管理如果页面不多用provide/inject或组件props就能撑住不必一上来就引Vuex。4.1 路由与状态管理前后端分离下的页面骨架前后端分离的项目里路由配置决定了页面层级。律师列表页、律师详情页、资讯列表页、资讯详情页、个人中心这五个页面是最低要求。详情页接收路由参数通过this.$route.params.id拿律师ID再请求详情接口。import Vue from vue import Router from vue-router Vue.use(Router) const routes [ { path: /, component: () import(/layout/DefaultLayout.vue), children: [ { path: , name: Home, component: () import(/views/Home.vue) }, { path: lawyers, name: LawyerList, component: () import(/views/lawyer/LawyerList.vue) }, { path: lawyers/:id, name: LawyerDetail, component: () import(/views/lawyer/LawyerDetail.vue) }, { path: articles, name: ArticleList, component: () import(/views/article/ArticleList.vue) }, { path: articles/:id, name: ArticleDetail, component: () import(/views/article/ArticleDetail.vue) } ] } ] const router new Router({ mode: history, routes }) export default router路由懒加载用() import()让每个页面按需加载首屏体积能小一半。mode: history好看但要后端配合做try_files回退否则部署后刷新404。对于毕设项目直接用hash模式能省掉这个配置麻烦。路由参数在详情页里直接用来拼请求const id this.$route.params.id getLawyerDetail(id).then(res { this.lawyer res.data })页面跳转时用$router.push({ name: LawyerDetail, params: { id } })不要用router-link的字符串路径拼参数那样容易把特殊字符丢了。4.2 封装Axios与自定义v-model律师卡片组件的双向交互每次请求都要带Token、处理超时、统一解包响应体这些重复工作应该收进一个Axios实例。import axios from axios const service axios.create({ baseURL: /api/v1, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 0) { return Promise.reject(new Error(res.message || 请求失败)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) } return Promise.reject(error) } ) export function getLawyerDetail(id) { return service.get(/lawyers/${id}) } export function recommendLawyers(params) { return service.get(/recommend/lawyer, { params }) } export default servicetimeout: 10000是常规值接口响应超过10秒直接走错误分支。Token失效时统一踢回登录页这个判断放在拦截器里页面里就不用每个接口都写401判断。自定义v-model是Vue里一个容易忽略但很实用的招式。律师卡片改造成组件后父组件用v-model绑定“已收藏”状态子组件内部处理点击逻辑收藏状态变化时通过事件通知父组件这样列表页只需要维护一个收藏映射对象。template div classlawyer-card img :srclawyer.avatar :altlawyer.name / h3{{ lawyer.name }}/h3 span{{ lawyer.practiceArea }}/span button :class{ active: isActive } clicktoggleFavorite {{ isActive ? 已收藏 : 收藏 }} /button /div /template script export default { name: LawyerCard, model: { prop: checked, event: change }, props: { lawyer: { type: Object, required: true }, checked: { type: Boolean, default: false } }, computed: { isActive() { return this.checked } }, methods: { toggleFavorite() { this.$emit(change, !this.checked) } } } /script父组件用法lawyer-card :lawyeritem v-modelfavoriteMap[item.id] /。子组件只负责把状态变化抛出去真正调收藏接口的逻辑放在父组件里这样父子职责清晰组件复用性也高。4.3 互动体验收藏防抖与咨询弹窗的loading状态互动体验的卡顿往往来自重复点击。收藏按钮如果没做防抖用户连点两下会发两个请求数据库中可能出现重复收藏记录。常见做法是在组件内部用disabled状态拦截。async toggleFavorite() { if (this.submitting) return this.submitting true try { const nextStatus !this.checked nextStatus ? await addFavorite(this.lawyer.id) : await removeFavorite(this.lawyer.id) this.$emit(change, nextStatus) } catch (e) { this.$message.error(操作失败) } finally { this.submitting false } }咨询弹窗的关键是提交时的loading态。弹窗里表单提交后要等后端返回“咨询已创建”按钮上的文字变成“提交中…”并禁用防止用户重复提交同一个咨询请求。弹窗组件内部用visible控制显示父组件监听visible.sync同步开关状态这样从列表页到弹窗的交互就是一个完整闭环。5. 联调与部署避坑5次翻车换来的参数与配置教训前后端分开跑都正常一旦联调或者部署各种“玄学”问题就冒出来了。下面这5个坑是我在类似项目上实打实踩过的每个都按现象、原因、解决的顺序写照着排查能省下大半天时间。5.1 现象分页列表对不上总数正确但内容重复律师列表翻到第二页出现几条第一页已经看过的数据但总数没变。原因是排序字段不唯一。ORDER BY created_at DESC这个字段精确到秒同一秒内插入的多条记录相对顺序不确定数据库分页时MySQL会在重复值之间随机取导致翻页时记录串位。解决方法是排序时把主键作为第二排序条件强制顺序唯一。ORDER BY created_at DESC, id DESC。改Mapper里的排序写法即可不需要动前端分页参数。5.2 现象接口返回的时间比数据库少了8小时前端显示的发布时间总是比数据库里存的时间早8小时。这是时区配置不一致导致的。MySQL连接串里同时存在serverTimezone设置Spring Boot的Jackson序列化LocalDateTime时又用了默认时区两者不匹配就出现偏移。解决方法是统一三处时区连接串写serverTimezoneAsia/Shanghai实体类时间字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)application.yml再配一层spring.jackson.time-zone: GMT8。三处统一后再也没出现过时间错位。5.3 现象推荐位永远是那几个老律师新律师上不去推荐接口上线后排在前面的永远是评分高、案件量大的老律师刚入驻的律师从来没有曝光。原因是热门推荐只按view_count rating * 10加权新律师没有历史行为数据分数永远是0。解决方法是给推荐算法加冷启动补偿新律师在入驻后7天内加分并且随机抽一部分流量给非头部律师。常用做法是写一个权重公式score 行为分 * 时间衰减 新律师加权 随机探索率。在推荐接口里留一个Math.random()的探索位5%的概率随机从候选池里取保证新内容有机会被看到。5.4 现象本地联调正常打包部署后首页白屏本地npm run dev跑得好好的npm run build之后扔到服务器上打开首页一片空白控制台报资源加载404。原因有两类打包配置里静态资源路径用了绝对路径/js/app.js部署在子目录下就会找不到或者前端用了history路由模式服务器没做重定向刷新时404。解决方法是vue.config.js里设publicPath: ./打包资源改为相对路径如果路由用history模式Nginx加一行配置location / { try_files $uri $uri/ /index.html; }。毕设项目图省事可以直接切到hash模式一行代码都不用改nginx。5.5 现象跨域请求时好时坏POST偶尔被拦截前后端分离联调最常见的报错是CORS。有时候GET请求通了POST请求还是被浏览器拦截并且响应头里没有Access-Control-Allow-Origin。原因往往不是后端没配跨域而是过滤器顺序有问题后端有鉴权过滤器或XSS过滤器先处理了请求在它们内部直接把OPTIONS预检请求拦截了根本没走到CORS配置那里。解决方法是把跨域配置写进Spring Boot的WebMvcConfigurer并确保它的执行优先级高于业务过滤器。还可以在XSS过滤器里加一段判断如果请求方法是OPTIONS直接放行。Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; if (OPTIONS.equalsIgnoreCase(httpRequest.getMethod())) { chain.doFilter(request, response); return; } chain.doFilter(new XssRequestWrapper(httpRequest), response); }6. 让推荐系统更“懂”用户冷启动、相似度与效果验证6.1 冷启动新用户的推荐位先推什么新用户没有行为日志推荐接口拿不到个性化输入。常规做法是按城市和浏览入口做规则兜底用户定位在上海推荐位优先展示上海执业律师用户从“婚姻家事”资讯页跳转进来推荐位展示该领域律师。这个策略不需要算法一个SQL就能搞定但体验上比直接展示全站热门好很多。新律师的冷启动在第5章提过这里再补一个细节新律师加权不要做成永久加分时间衰减用指数衰减比如weight 50 * Math.exp(-0.2 * 入驻天数)。7天后权重降到10以下被算法遗忘也合理毕竟平台要的是持续产生内容的律师。6.2 相似度计算的工程简化从标签到向量的三个步骤中小型法律平台不需要训练Embedding模型标签匹配已经够用。三个步骤走完第一步把律师的执业领域、城市、评分档位、案件量档位拼成一个带权重的特征串第二步将特征串拆成标签集合用TF方式给标签赋权执业领域权重最高给5城市给2评分档位给1第三步用余弦相似度计算。Jaccard适合标签很少的场景一旦标签超过10个余弦的区分度更好。不要一上来就上ES或向量数据库几百个律师的体量用Java内存计算毫秒级出结果架构越简单越容易维护。等律师量真的破了五万再考虑把特征向量存进Elasticsearch的dense_vector字段思路不变只是换了存储和检索底座。6.3 效果验证点击率与咨询转化率怎么量化推荐好不好不能靠感觉。上线前在behavior_log里多记两个字段scene推荐位还是搜索页和request_id一次推荐请求的唯一ID。前端在推荐位曝光时调一个/track/exposure接口把展示的律师ID列表发给后端后端记录曝光量。用户点击律师卡片时再记一次带request_id的点击行为。有了曝光量和点击量推荐位点击率就是两者的比值。再往深走用户从推荐位进入详情页后发起了咨询这条链路在appointment表里能查到来源scene就能算出推荐位的咨询转化率。用这两组数据对比改造前后的指标比任何代码评审都有说服力。我自己做这个方向时吃过一个亏一开始只按点击量排序结果头部律师吃掉了80%的曝光长尾律师完全失去活力。后来加了探索机制和冷启动加权整体点击率反而涨了几个点咨询量也跟上来了。推荐系统不是堆算法而是先解决数据、冷启动和验证问题再考虑模型复杂度。这套从数据结构到前后端联调再到推荐的落地方案希望帮到你少走一段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑