ARTICLE DETAIL

资讯详情

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

Spring Boot + Vue 经方药食平台:数据库建模、接口设计与联调实践

Spring Boot + Vue 经方药食平台:数据库建模、接口设计与联调实践 简介这是一份基于Springboot与Vue的经方偏方药方食疗药食两用服务平台毕业设计资源面向Java方向毕业生、课程设计学生以及需要快速搭建前后端分离项目的开发者。平台围绕传统中医药食两用信息管理整合方剂、偏方与食疗相关知识可用于论文支撑和答辩演示。资源包共772个文件涵盖124个Java后端源码、44个Vue组件、155个JavaScript脚本、48个CSS样式及42个HTML页面同时包含SQL数据库初始化脚本、MP4演示视频、Markdown部署文档和bat一键脚本整体压缩包约44.38MB。其中bat脚本涵盖安装、运行、构建三步可辅助本地环境一键启动SQL脚本提供数据结构与初始数据演示视频方便快速了解系统效果。项目已在Windows10/11下严格调试部署教程齐全曾获导师认可、答辩评分97分。目前已有89人学习本资源适合需要完整源码、数据库设计及演示材料的同学参考或二次开发。1. 先想清楚经方药食服务平台要解决什么把经方、偏方、食疗方和药食两用目录放进同一个 Spring Boot Vue 平台真正考验人的不是增删改查而是数据模型。第一次做这类毕业设计的人很容易先建 user 表再把 CRUD 写完等到设计方剂时才发现经方里的三两是剂量单位偏方里的取汁外敷是用法食疗方里的食材又自带性味归经三张表根本对不齐。以我现在的习惯动手前先画关系图方剂是主体材料与方剂多对多材料表单独放是否药食两用标记收藏和浏览记录再围着这三张表转。这一层想清楚后面的 Spring Boot 接口和 Vue 页面不过是把模型翻译成 JSON 和卡片流。这篇就按数据库建模、后端接口、前端页面、联调验收的顺序往下拆适合用它做过毕设或者拿 Spring Boot Vue 练手的人照着这套思路可以把自己的数据库迁移过来。2. 数据库建模药、方、食三张核心表怎么拆2.1 方剂主表经方、偏方、食疗方不该建三张表很多初学者拿到需求第一反应是建经方表、偏方表、食疗方表各一套然后给每个表写五个增删改查接口。实际这三类方在信息结构上没有本质差异名称、组成、功效、主治、用法、禁忌、出处都是同一套字段分开建表只会让前端列表页写三套组件、后端写三套控制器多出来的是纯维护成本。用一个 prescriptions 主表加 type 字段区分就够type 取值 1 经方、2 偏方、3 食疗方。这样还方便以后做同一个关键字跨类型搜索。常见表结构如下字段类型说明idbigint主键自增namevarchar(100)方名按名称模糊搜索时命中typetinyint1 经方2 偏方3 食疗方category_idbigint分类关联 category 表如解表、清热syndromevarchar(500)主治或适用症状逗号分隔存文本function_desctext功效描述详情页主要展示内容usage_methodtext用法如煎服、炖煮、外敷taboovarchar(500)禁忌不能省略sourcevarchar(200)出处如《伤寒论》cover_urlvarchar(255)封面图地址create_bybigint录入人 IDcreate_timedatetime创建时间statustinyint0 待审核1 已发布2 已下线每个字段都要有 COMMENT 注释这是数据库课程设计评审时最容易拿到的分数点。syndrome 存逗号分隔文本而不是独立关联表因为绝大多数查询只做 like不会按症状做精确匹配拆成表反而让查询多两次 join演示场景下不值得。2.2 材料与关联表剂量该存在哪张表方剂和材料是多对多关系桂枝汤里有桂枝、芍药、甘草等多味药一味山药又可能出现在十几张食疗方里。所以材料信息单独放 material 表方剂和材料的对应关系放 prescription_material 关联表剂量写在关联表里。剂量字段用 varchar 而不是 decimal因为三两取汁去核这类文字没法用数字准确表达。CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 材料名如 山药, alias_name VARCHAR(100) COMMENT 别名如 淮山, nature VARCHAR(50) COMMENT 性味如 甘、平, meridian VARCHAR(100) COMMENT 归经如 脾、肺、肾, is_food TINYINT DEFAULT 0 COMMENT 1 表示药食两用食材0 表示仅药材, source_desc VARCHAR(255) COMMENT 来源或产地, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_name (name), KEY idx_is_food (is_food) ) COMMENT 材料表; CREATE TABLE prescription_material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, prescription_id BIGINT NOT NULL COMMENT 方剂 ID, material_id BIGINT NOT NULL COMMENT 材料 ID, dose VARCHAR(50) COMMENT 剂量或处理方式如 三两、15g、去核, remark VARCHAR(100) COMMENT 炮制说明如 炙、炒, UNIQUE KEY uk_prescription_material (prescription_id, material_id) ) COMMENT 方剂材料关联表;is_food 是整个平台的业务核心药食两用四个字就落在这个字段上。material 数据建议按药食两用目录的名录录入常见的有山药、山楂、肉桂、枸杞子、金银花等。prescription_material 用唯一索引防止同一味材料被重复录入同一张方剂接口在写关联数据时如果抛出 DuplicateKeyException通常是重复提交而不是数据库故障。2.3 分类表与初始化数据脚本顺序决定能不能开箱即用分类表 category 只需要四个字段id、name、parent_id、sort。parent_id 为 0 是一级分类比如解表剂补益剂二级分类可以继续挂到一级分类下面。查询时不用在 Java 里写递归一次性查出全表再按 parent_id 组装成树即可数据量几百条完全够用。初始化数据要按依赖顺序执行先建 material 和 category再建 prescription最后写 prescription_material 关联数据。数据库脚本文件里如果按反向顺序执行会出现外键找不到主表的报错。实际操作时建议初始数据控制在 30 到 50 条桂枝汤这种知名经方放第一条山药、枸杞子这类药食两用食材保持五条以上保证演示搜索时每个关键字都有命中结果。提示不要只导结构不导数据。空库搜索桂枝没有结果演示观感会大打折扣。3. Spring Boot 后端接口检索、鉴权与统一返回3.1 先写 Result 再写控制器前后端分离的第一步后端工程在 IDEA 里创建时选 Spring Web 和 MySQL Driver 两个起步依赖就够MyBatis-Plus、JWT 按需引入。工程结构按 controller / service / mapper / entity / common / config 分包不要把所有类堆在同一个包里。第一个要写的类不是用户控制器而是统一返回结果 Result 否则每个接口返回值都不一样前端 axios 拦截器没法统一判断成功失败。Data public class ResultT { private Integer code; // 200 成功401 未登录500 业务异常 private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message ok; r.data data; return r; } public static T ResultT fail(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }所有控制器方法返回类型统一写成 Result?成功返回 Result.ok(data)业务失败返回 Result.fail(500, msg)。前端 response 拦截器只需要判断 code 200 走业务非 200 统一弹提示不用为单个接口写 try catch。这套结构在 java 面试八股文里也常被问核心点是泛型和静态工厂方法而不是简单返回 Map。3.2 JWT 登录让收藏接口知道请求是谁平台有普通用户和管理员两种角色管理员负责审核方剂发布普通用户负责浏览、收藏和检索。登录接口用 JWT 而不是 session原因很简单Vue 打包后是静态文件大概率部署在 Nginx接口部署在后端服务器session 跨域要处理 Cookie 域JWT 只需要前端把 token 存在 localStorage请求时放在 Authorization 头里。public class JwtUtil { private static final SecretKey KEY Keys.hmacShaKeyFor(replace-this-with-a-long-random-key.getBytes()); public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }token 有效期设 7 天过期后拦截器返回 401前端响应拦截器嗅探到 401 后清掉 localStorage 并跳转登录页。密码校验必须用 BCryptPasswordEncoder数据库保存的是 bcrypt 哈希而不是明文登录时用 matches 方法校验。答辩时被问到用户密码怎么存回答 bcrypt 加盐哈希比说 MD5 加密要专业得多MD5 撞库成本太低属于扣分回答。3.3 方剂分页检索keyword、type、categoryId 三个条件组合列表接口是平台访问量最高的接口设计为 GET /api/prescriptions参数是 pageNum、pageSize、keyword、type、categoryId。其中 keyword 同时匹配方名和主治症状type 精确过滤类型categoryId 精确过滤分类。public PageResultPrescriptionVO pageList(Integer pageNum, Integer pageSize, String keyword, Integer type, Long categoryId) { LambdaQueryWrapperPrescription wrapper new LambdaQueryWrapper(); wrapper.eq(type ! null, Prescription::getType, type) .eq(categoryId ! null, Prescription::getCategoryId, categoryId) .and(StringUtils.hasText(keyword), w - w .like(Prescription::getName, keyword) .or().like(Prescription::getSyndrome, keyword)); wrapper.orderByDesc(Prescription::getCreateTime); PagePrescription page prescriptionMapper.selectPage( new Page(pageNum, pageSize), wrapper); return PageResult.convert(page); }这里有两个细节。第一keyword 为空时用 hasText 判断而不是 !isEmpty否则前端没输入内容也拼接 like 条件MySQL 会对全表做一次无效模糊匹配。第二name 和 syndrome 的两个 like 要用条件构造器内部的 and 包住写成 wrapper.like(...).or().like(...) 会把 or 提升到整个 query 级别导致前面 type 条件失效出来的数据混进其他类型。like 条件由 MyBatis-Plus 参数化处理不会像字符串拼接那样引入 SQL 注入风险这一点在 springboot 面试题里经常被追问。3.4 药食两用推荐基于性味归经的轻量召回平台里可以加一个推荐接口用户打开某个药食两用食材详情时接口返回同性味的其他食材。常见做法是按第一个性味标签做 like 匹配因为数据库里存的甘、平这类字符串不需要分词直接做模糊匹配就够用。算法复杂度低答辩时能讲明白逻辑也够演示。public ListMaterial similarMaterials(Long materialId, int limit) { Material current materialMapper.selectById(materialId); if (current null || current.getNature() null) { return Collections.emptyList(); } String firstNature current.getNature().split(、)[0]; return materialMapper.selectList(new LambdaQueryWrapperMaterial() .eq(Material::getIsFood, 1) .like(Material::getNature, firstNature) .ne(Material::getId, materialId) .last(limit limit)); }limit 直接拼进 last 是因为 MyBatis-Plus 的 last 方法不做参数绑定这里的 limit 来自后端常量而不是用户输入所以不存在注入风险。如果接口设计成接收 limit 参数那就要用 Page 参数替换不要把前端传入的字符串拼进 last。推荐逻辑可以再叠一层归经权重把归经有重叠的材料排在前面但演示阶段第一版按性味召回已经够用了。3.5 application.yml 配置springboot 版本太高的两个坑配置文件里最容易被忽略的是 Spring Boot 2.6 之后的路径匹配策略。如果集成 Swagger、Knife4j 或 SpringDoc启动报 Failed to start bean documentationPluginsBootstrapper九成是 PathPattern 解析器不兼容造成的需要在 application.yml 里显式声明 ant_path_matcher。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/med_food_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver mvc: pathmatch: matching-strategy: ant_path_matcher mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted数据库密码用环境变量 ${DB_PASSWORD:root} 带上默认值意思是环境变量不存在时用 root本地可以免配置启动部署到服务器时改环境变量即可。map-underscore-to-camel-case 必须打开否则数据库表里的 create_by 映射不到实体字段 createBy查询结果里创建人永远是 null。逻辑删除字段不需要在所有表都建只需要在有删除需求的表上加 deleted 字段全局配置保证所有表的逻辑删除行为一致。4. Vue 前端页面路由参数、Axios 封装与列表组件4.1 vue-router 路由划分详情页的 id 从哪来前端是独立的 Vue 项目首次运行时先 npm install 装依赖环境变量文件 .env.development 里配好接口地址后启动 dev server。路由结构建议主布局嵌套子路由菜单和顶部栏只写一次切换页面时只有 content 区域更新。const routes [ { path: /, redirect: /home }, { path: /login, component: () import(/views/Login.vue) }, { path: /home, component: () import(/views/Layout.vue), children: [ { path: , redirect: /prescriptions }, { path: prescriptions, component: () import(/views/prescription/List.vue) }, { path: prescriptions/detail/:id, component: () import(/views/prescription/Detail.vue) }, { path: materials, component: () import(/views/material/MaterialList.vue) }, { path: favorites, component: () import(/views/user/Favorites.vue) } ] } ]列表页跳详情页用 this.$router.push({ path: /prescriptions/detail/ row.id }) 携带 id详情页在 created 钩子里通过 this.$route.params.id 拿到参数。这里有一个 vue 路由参数的高频坑如果用户从详情页跳到另一个详情页组件实例被复用created 不会再次执行页面会显示上一个 id 的数据。解决方法是 watch 一下 $route 参数变化时重新调用 loadDetail。实际项目里可以把这段 watch 写进详情页的公共 mixin每个详情页都不用重复踩坑。4.2 Axios 封装token 注入与 401 统一处理请求层统一放到 src/utils/request.js用 axios.create 生成实例request 拦截器注入 Authorizationresponse 拦截器处理业务码和 401。这样登录态只在两个拦截器里各写一次所有接口共享同一套规则。import axios from axios const service axios.create({ baseURL: /api, 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( res { const { code, message, data } res.data if (code 200) return Promise.resolve(data) if (code 401) { localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(登录已过期)) } return Promise.reject(new Error(message)) }, err Promise.reject(err) )baseURL 配成 /api 而不是 http://localhost:8080/api是为了走 Vite 代理避免开发环境跨域。代理配置写在 vite.config.js 的 server.proxy 里target 指向 http://localhost:8080changeOrigin 设为 true。如果打包后页面接口 404先在 Network 面板看请求 URL 是否完整落到接口地址前端代理只在 dev 环境生效打包后需要 Nginx 的 location /api 反向代理两个配置不是一回事。4.3 分类侧栏与卡片列表列表页的数据流列表页纵向分两块左侧分类栏右侧卡片列表。分类栏数据来自 /api/categories卡片列表数据来自 /api/prescriptions。点击分类时把 categoryId 置入响应式变量重新调用 loadList 并回到第一页。核心状态只有三个currentCategory、keyword、currentPage。template div classprescription-page aside classcategory-side div v-forcat in categories :keycat.id :class{ active: currentCategory cat.id } clickchangeCategory(cat.id) {{ cat.name }} /div /aside section classprescription-list el-input v-modelkeyword placeholder输入方名或症状 clearable keyup.enterloadList(1) / div v-foritem in list :keyitem.id classcard clickgoDetail(item.id) h3{{ item.name }}/h3 label{{ item.typeText }}/label span v-fortag in item.syndrome.split(、) :keytag classtag {{ tag }} /span /div el-empty v-iflist.length 0 description没有匹配的方剂 / /section /div /templatesyndrome 字段是后端返回的逗号分隔文本前端 split 成数组渲染标签这是双方约定好的格式。后端如果返回 null前端 split 直接报错所以接口层要保证 null 时返回空字符串。卡片点击事件要单独定义 goDetail 方法而不是在模板里写很长的方法链方便后续加埋点或权限判断。分页组件用 el-paginationtotal 来自后端返回的 total 字段pageSize 变化时重置 currentPage 为 1否则翻到第 3 页再切换每页条数会出现页数越界。4.4 详情页数据回显与药食两用标签联动详情页需要同时展示方剂信息和材料列表还要带一个推荐食材区域。页面加载时并行请求两个接口一个取方剂详情一个取相关材料。材料列表里 isFood 1 的项显示药食两用标签点击标签可以跳转到对应食材详情形成浏览闭环。这个交互是演示视频里最容易出效果的功能点。async loadDetail() { const id this.$route.params.id const [detail, materials] await Promise.all([ api.getPrescriptionDetail(id), api.getMaterialsByPrescription(id) ]) this.detail detail this.materials materials.map(m ({ ...m, isFoodText: m.isFood 1 ? 药食两用 : 仅药材 })) }Promise.all 并行请求可以避免串行等待详情页打开速度更快。后端返回的材料字段名如果是 material_name 而前端用的是 name需要在后端 VO 里先对齐字段不要指望前端去适配每一个后端字段。这类前后端字段不一致问题在联调时最常见解决方案是后端定义 VO 时直接用前端需要的字段名而不是把 Entity 直接序列化返回。5. 联调与验收三个容易翻车的位置和自测顺序5.1 数据库初始化顺序检查拿到源码里的 SQL 脚本后不要整个文件直接执行。建表语句有外键依赖material 和 category 必须建在 prescription 之前prescription_material 关联表必须建在最后。如果脚本里没有显式写 DROP TABLE IF EXISTS重复执行会报 already exists建议在脚本最前面统一添加。导入命令如下导入完成后用 show tables 核对表数量。mysql -uroot -p med_food_platform script/init.sql mysql -uroot -p -e USE med_food_platform; SHOW TABLES;5.2 接口验收自测清单下面的表格是启动后端后建议先跑一遍的最小验证集。登录、列表、详情、收藏四个接口全通就足以支撑演示主流程。接口预期结果最容易出的问题POST /api/auth/login返回 token密码用明文比对导致 401GET /api/prescriptions?keyword桂枝返回包含桂枝的经方参数为空时也拼 like全表扫描GET /api/prescriptions/detail/1返回方剂和材料列表关联查询没有带出 materialsPOST /api/favorites/1返回收藏成功未带 token 被拦截器拦下GET /api/prescriptions/recommend返回同性味食材nature 为空导致 split 越界5.3 演示录像顺序与前端跨域检查演示视频的顺序建议从数据导入开始先展示数据库表结构和数据量再启动后端最后启动前端。页面操作线路定成登录 → 按症状搜索桂枝 → 进详情看材料 → 切到药食两用标签 → 收藏 → 个人中心看收藏列表每个操作页面停留两秒以上方便录屏时看清。录像前检查浏览器控制台有没有红色报错重点是登录接口是否返回 code 200以及请求头是否带上了 token。跨域或代理问题在开发环境修改 vite.config.js 后需要重启 dev server 才生效不是保存就立刻生效的。前端代理命中错误时先看 Network 面板里请求有没有落到接口上再决定是调整 vite 配置还是后端跨域配置。本文还有配套的精品资源点击获取
返回列表