最近做了个让我自己挺满意的项目:一套经方药食两用服务平台,技术栈选了 SpringBoot3 + Vue3 + MyBatisPlus + MySQL,前后端完全分离。做这个系统的起因其实很简单——身边不少朋友开始研究药食同源,想知道哪些普通食材能入日常膳食,但市面上的信息要么散落在各类养生文章里,要么就是经方资料库那种纯文本档案,缺少一个能把"方、药、食材、体质、节气"串起来结构化查询的工具。于是我用业余时间从零搭了这套系统,从数据库建模到前端交互全部自己来,顺便把整个设计过程和大大小小的坑都记录了下来。
如果你的需求也集中在"内容管理 + 组合检索 + 权限控制"这类典型业务系统,或者对中医药信息数字化感兴趣,这篇分享应该能帮你少走不少弯路。我尽量把每一步的设计理由和实际踩坑过程都写清楚,而不是只贴代码。
1. 药食两用平台的定位:先想清楚做的是工具,不是医生
开始写代码之前,我想了很多:这个平台的用户到底是谁?核心使用场景是什么?如果只是简单做两个 CRUD 页面,那和普通的图书管理系统有什么区别?
1.1 这个系统到底解决什么问题
我把需求拆成了三层。第一层是数据管理:经方条目、药食同源食材、方剂与食材的关联关系,这些基础数据需要一个后台来维护,而不是直接改数据库。第二层是结构化解构:一条经方不是简单的一段文字,它包含方名、出处、组成、主治描述、使用注意等多个字段;一个食材也不是只有名字,它有性味、归经、功效、适用体质、禁忌人群等属性。第三层是组合检索:用户根据当前季节、自身体质、想调理的方向,筛选出合适的经方和食材组合。
这个定位决定了系统的核心价值不是展示信息,而是建立结构化数据模型。比如我想查"冬季、阳虚体质、适合的温补食材",如果数据库里存的是整段散文,那这条需求根本没法实现。所以从表结构设计开始,就要把所有业务属性拆成可查询的字段。
1.2 项目边界:为什么不做诊断,只做数据管理
做这类题材容易踩红线,我一开始就把边界划死了:系统只做文化与营养参考数据的结构化管理和检索,不提供任何诊断、治疗建议,所有展示内容都标注"信息仅供学习参考,具体健康问题请咨询专业人士"。这个设计从功能和文案两个层面同时落实——功能上没有在线问诊模块,数据表里专门加了一个"数据来源"字段,每条经方和食材都标记了出处,方便追溯。
边界想清楚后,后面所有功能设计都顺了。我不需要做症状描述到方剂的自动匹配算法,只需要做基于标签体系的筛选。用户选择体质标签、季节标签,系统返回匹配的方剂和食材列表,判断逻辑是透明、可解释的,这也符合内容类平台的合规需求。
2. 技术栈选型:SpringBoot + Vue3 + MyBatis 的实际理由
这一节说说为什么是这套组合。SpringBoot3 + Vue3 是当下前后端分离的主流搭配,选择它不算冒险,但 MyBatis 和 JPA 之间的取舍,以及 MyBatis 和 MyBatisPlus 的关系,值得多聊几句。
2.1 前后端分离的利弊权衡
前后端分离是被业务逼出来的。后台管理需要维护经方和食材数据,前端展示要服务普通用户检索,两者虽然共用同一套数据库,但发布节奏、用户群体完全不同。分开部署后,后台管理和前台展示可以独立发布,后端接口只要保证兼容,前端随便改。
当然分离也有代价,最常见的就是跨域和联调问题。我开发时用 Vite 的 proxy 解决本地跨域,生产环境用 Nginx 做反向代理,把 /api 前缀的请求转发到后端服务,这个配置我在后面第6节详写。前端和后端的契约我用了最朴素的方案——维护一份 Markdown 格式的接口文档,没有上 Swagger,因为项目后期接口数量不到三十个,文档维护成本相对可控。
2.2 为什么坚持用 MyBatis 而不是 JPA
JPA 在关联查询上确实省事,但我不选它的核心原因是:经方和食材的关联查询条件组合太灵活。比如筛选条件可能有性味(可能多个)、归经(可能多个)、适用体质(多个)、季节(单个),条件可以自由增减,这种动态 SQL 场景正是 MyBatis 的强项——用 标签拼条件,比 JPA 的 Specification 直观得多,也更容易控制生成的 SQL。
MyBatisPlus 则主要看中它的 CRUD 方法和分页插件,省去了大量单表操作的重复编写。复杂查询我自己写 XML,简单增删改查直接用封装好的方法,两不耽误。
2.3 版本选型和环境清单
我用的具体版本是:SpringBoot 3.1.5,JDK 17(SpringBoot3 必须 JDK17+),MySQL 8.0.34,MyBatisPlus 3.5.5(专门适配 SpringBoot3 的版本),Vue 3.3 + Vite 5,Element Plus 2.4。认证方案用的是 Sa-Token,比 Spring Security 上手快得多,对中小型项目很友好。
提示:如果之前用过 SpringBoot2,注意 SpringBoot3 的 javax 包全部改成了 jakarta,MyBatisPlus 也要用
mybatis-plus-spring-boot3-starter,否则启动直接报 ClassNotFound。
3. 数据库表设计:经方、食材、配伍关系怎么建模
数据库设计是这套系统最核心的部分,也最能看出一个项目是否经过认真思考。前后端代码可以改,但表结构一旦上线再改,代价极大。
3.1 核心表的字段设计
我一共设计了六张核心业务表:经方表、药材表、药食同源食材表、方剂组成表(经方与药材的关联)、食材性味归经表、标签表(体质、季节、功效方向)。外加用户表、角色表和收藏表。
经方表的关键字段如下:
CREATE TABLE jingfang_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', fang_name VARCHAR(100) NOT NULL COMMENT '方名', source_book VARCHAR(100) COMMENT '出处古籍', category VARCHAR(50) COMMENT '分类:解表/清热/温里等', composition_text TEXT COMMENT '组成原文', dosage_text TEXT COMMENT '原方剂量描述', indications_text TEXT COMMENT '主治原文', usage_note VARCHAR(255) COMMENT '使用注意', data_source VARCHAR(100) COMMENT '数据来源', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_fang_name (fang_name), KEY idx_category (category) ) COMMENT '经方主表';注意我把"组成"和"主治"都存成了原文文本,同时通过方剂组成表建立结构化的药材关联。这样做是为了兼顾两种查询场景:按药名反查方剂(需要结构化关联),以及查看方剂详情时展示原文(需要完整文本)。如果只存结构化的药材列表,原文的用词细节就丢了。
食材表的设计思路类似,但属性维度更多:
CREATE TABLE food_material ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', food_name VARCHAR(100) NOT NULL COMMENT '食材名', property VARCHAR(20) COMMENT '性味:温/热/寒/凉/平', flavor VARCHAR(50) COMMENT '五味:酸苦甘辛咸', meridians VARCHAR(200) COMMENT '归经,逗号分隔', efficacy VARCHAR(500) COMMENT '功效描述', suitable_physique VARCHAR(200) COMMENT '适合体质标签ID,逗号分隔', taboo_physique VARCHAR(200) COMMENT '不适宜体质标签ID', seasonal_tags VARCHAR(100) COMMENT '季节标签:春/夏/秋/冬', is_medicine_food TINYINT DEFAULT 1 COMMENT '是否药食同源目录内', reference_note TEXT COMMENT '古籍或权威资料参考描述', status TINYINT DEFAULT 1, KEY idx_food_name (food_name), KEY idx_property (property) ) COMMENT '药食同源食材表';"归经"和"适合体质"我用了逗号分隔字符串存储,这个选择有争议,我在后面说明理由。
3.2 多对多关系表:方剂组成和标签绑定
方剂组成表是典型的多对多关联,但我在里面加了"角色"字段——同样是药材,在方剂里可能扮演君、臣、佐、使不同角色,这个信息对理解经方结构有价值:
CREATE TABLE jingfang_composition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, fang_id BIGINT NOT NULL COMMENT '经方ID', herb_id BIGINT NOT NULL COMMENT '药材ID', herb_role VARCHAR(20) COMMENT '角色:君/臣/佐/使', raw_dosage VARCHAR(100) COMMENT '原方剂量的原文描述', KEY idx_fang_id (fang_id), KEY idx_herb_id (herb_id) ) COMMENT '经方组成关联表';标签体系我用了独立的标签表和绑定表,没有直接在业务表里存冗余字段(体质字段是例外,下面会说)。一套标签可以同时服务食材筛选、经方筛选和后续扩展的节气养生内容。标签绑定表通用结构是biz_type+biz_id+tag_id三字段,一套表通吃所有业务模块。
3.3 SQL 细节和索引设计
这套系统的查询特点是:筛选条件多,但数据量不大(千级到万级)。所以索引设计遵循两个原则:一是高频查询字段建单列索引,比如方名模糊查询、食材名查重;二是组合查询不建过多联合索引,因为条件组合太多,联合索引利用率低,不如靠单列索引 + 覆盖查询解决。
关于逗号分隔存标签字段,我承认它不太符合关系数据库规范,但我权衡后保留了这个设计,原因有两条:食材的适用体质一般只有两三个标签,单独建关联表反而增加查询复杂度;后端的查询逻辑可以做简单的 LIKE 匹配,结合 MyBatis 的动态 SQL 实现"包含任一标签即命中"的效果。如果是企业级项目且标签数量增长明显,确实应该改成关联表,但在当前规模下这个设计够用且直观。
注意:所有表都加了
status软删除字段,业务删除一律走 UPDATE 而不是 DELETE。做内容管理类的系统,误删是灾难性的,软删除是底线。
4. 后端核心:认证、组合检索与校验逻辑
后端采用经典的 Controller-Service-Mapper 三层架构,Sa-Token 做登录认证和权限控制,全局异常统一处理,接口返回统一封装成 Result 对象。下面挑我认为最能代表这套系统特点的三个点展开说。
4.1 登录认证:Sa-Token 的接入和权限控
Sa-Token 接入流程非常顺。引入依赖后,配置拦截器,登录成功后StpUtil.login(userId),之后每次请求携带 token,后端从 token 解析出用户信息:
@Configuration public class SaTokenConfigure implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin())) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register", "/api/public/**"); } }权限控制我用了注解@SaCheckRole("admin"),标注在增删改接口上。普通用户登录后只能查询和收藏,管理员才能操作经方和食材数据。这个权限粒度对于内容管理平台足够了。
密码存储用了 BCrypt 加密,没有用 MD5——这是老生常谈,但我发现很多项目到上线还是 MD5 裸奔,真的不应该。BCrypt 每次加密结果不同,即使两个用户密码相同,库里的哈希值也不一样。
4.2 组合检索:MyBatis 动态 SQL 最合适的场景
组合检索的需求是:用户可能只选体质,可能只选季节,可能同时选体质+季节+功效方向,条件为空时不参与筛选。这个逻辑用 MyBatis 的<where>+<if>标签实现非常简洁:
<select id="searchFood" resultType="com.example.entity.FoodMaterialVO"> SELECT * FROM food_material <where> <if test="property != null and property != ''"> property = #{property} </if> <if test="physiqueTagId != null"> AND suitable_physique LIKE CONCAT('%', #{physiqueTagId}, '%') </if> <if test="seasonTag != null and seasonTag != ''"> AND seasonal_tags LIKE CONCAT('%', #{seasonTag}, '%') </if> <if test="keyword != null and keyword != ''"> AND food_name LIKE CONCAT('%', #{keyword}, '%') </if> AND status = 1 </where> ORDER BY id DESC </select>这里有个小坑:<where>标签会自动去掉开头第一个 AND,但如果<if>条件都不成立,查询就变成了SELECT * FROM food_material WHERE status = 1,这样没问题。不过如果把status = 1也放进<if>里,就可能导致条件拼接错误。我习惯把必选条件放在<where>里且不包<if>,让它始终存在,这样最稳妥。
分页用了 MyBatisPlus 的分页插件,在配置类里注册PaginationInnerInterceptor后,直接Page对象传入 Mapper 方法即可。实测百万级数据量下分页性能没问题,当前数据量更是毫无压力。
4.3 禁忌冲突校验:安全边界的关键实现
这是我觉得最有业务价值的模块。用户在挑选食材组合时,系统要检查是否存在配伍禁忌。实现不复杂,我在食材表里设计了taboo_physique字段,同时在食材之间维护了一张禁忌关系表:
CREATE TABLE food_conflict_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, food_a_id BIGINT NOT NULL COMMENT '食材A', food_b_id BIGINT NOT NULL COMMENT '食材B', conflict_desc VARCHAR(255) COMMENT '冲突说明', KEY idx_food_a (food_a_id), KEY idx_food_b (food_b_id) ) COMMENT '食材配伍禁忌表';后端在用户提交组合的时候,对组合内食材做两两配对,查询禁忌表,命中则返回冲突提示。逻辑写在 Service 层:
public List<ConflictResult> checkFoodCombination(List<Long> foodIds) { List<ConflictResult> conflicts = new ArrayList<>(); for (int i = 0; i < foodIds.size(); i++) { for (int j = i + 1; j < foodIds.size(); j++) { Long a = foodIds.get(i); Long b = foodIds.get(j); FoodConflictRule rule = conflictRuleMapper.selectConflict(a, b); if (rule != null) { conflicts.add(new ConflictResult(rule.getFoodAName(), rule.getFoodBName(), rule.getConflictDesc())); } } } return conflicts; }这个接口的设计重点不在代码,而在返回的提示文案。所有冲突提示我都写得比较谨慎,比如"古籍记载两者不宜同用,建议错开时间食用",而不是"吃了会中毒"这类极端表述。内容类平台的措辞安全比代码逻辑更重要。
5. 前端 Vue3 的关键实现:筛选交互和动态表单
前端部分我用 Vue3 组合式 API + Element Plus,状态管理用 Pinia,路由用 Vue Router。页面模块分为门户检索端和后台管理端两个部分,通过路由懒加载拆分,首屏只加载门户页面。
5.1 页面模块和路由权限
门户端有首页、经方库、食材库、我的收藏、个人中心五个页面。后台管理端有经方管理、食材管理、标签管理、用户管理四个页面。
路由权限通过前置守卫实现:路由 meta 里标记requiresAuth和role,跳转前检查登录状态和角色:
router.beforeEach((to, from, next) => { const userStore = useUserStore(); if (to.meta.requiresAuth && !userStore.token) { next('/login'); } else if (to.meta.role && userStore.role !== to.meta.role) { next('/'); } else { next(); } });后台管理端和门户端的 UI 完全隔离,通过不同的布局组件实现,避免管理界面的表格样式干扰前端展示的阅读体验。
5.2 检索交互:防抖与组合条件
食材库页面是核心检索页面。左侧是筛选区,包含性味单选、体质多选、季节多选和功效方向多选;右侧是食材卡片列表。每次筛选条件变化,触发一次查询请求。
高频交互需要注意防抖。我直接在事件处理函数里封装了 debounce 函数,避免每次点击筛选器都立刻发请求,等用户停顿 500ms 后再请求,减少后端压力:
function debounce(fn, delay = 500) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } const search = debounce(async () => { const params = { property: form.property, physiqueTags: form.physiqueTags.join(','), seasonTags: form.seasonTags.join(','), keyword: form.keyword, page: currentPage.value, pageSize: 12 }; const res = await api.searchFood(params); foodList.value = res.data.records; total.value = res.data.total; }, 300);详情页用抽屉组件展示,左侧是食材或经方的结构化信息,右侧是相关的古籍原文引用和配伍关系图(用简单的列表展示组成药材),视觉效果不错,实现成本也不高。
5.3 管理端表格与动态属性
后台管理端直接用了 Element Plus 的 el-table。字段标签和编辑表单是一一对应的,我通过一个字段配置数组来生成表单,避免每个字段手写一堆 el-form-item:
const herbFormFields = [ { prop: 'herbName', label: '名称', type: 'input', required: true }, { prop: 'property', label: '性味', type: 'select', options: ['温','热','寒','凉','平'] }, { prop: 'meridians', label: '归经', type: 'input' }, { prop: 'efficacy', label: '功效', type: 'textarea' }, ];字段配置数组驱动表单的好处是:新增字段时只需要改配置,不用复制粘贴几套表单代码。这个技巧在字段较多的内容管理系统中非常实用。
上传图片功能我实现了一个简单的文件上传接口,后端存到本地目录,Nginx 映射静态资源路径,这里不展开,实际业务中如果图片量大,建议接 OSS 对象存储。
6. 部署实录:本地跑通的完整流程
如果你拿到代码想本地跑起来,按下面的顺序操作,我已经把坑都踩平了。
6.1 初始化数据库和数据导入
先创建数据库jfc_food_service,字符集选择 utf8mb4,排序规则utf8mb4_0900_ai_ci。执行项目里自带的sql/init.sql脚本,它会建库、建表、插入基础权限数据和示例数据。
导入数据的顺序很关键。因为表之间有外键逻辑关联,虽然不是强制约束,但插入时还是会校验。先插标签表,再插经方表、药材表,最后插方剂组成表和食材表。如果一次性导不进去,建议打开 MySQL 的foreign_key_checks设置单独导入:
SET FOREIGN_KEY_CHECKS = 0; source sql/init.sql; SET FOREIGN_KEY_CHECKS = 1;示例数据我整理了一部分经典方剂条目和常见药食同源食材,大约覆盖了两百条经方记录和三百条食材记录,足够开发调试时使用。
6.2 前后端运行配置和联调
后端配置文件application.yml里重点改三项:数据库连接地址、用户名密码、Sa-Token 的 token 名称和超时时间。后端启动成功后访问http://localhost:8080/api/public/health返回 JSON 即成功。
前端在vite.config.js里配置代理,把/api转发到http://localhost:8080:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }前端通过npm install安装依赖,npm run dev启动开发服务器。如果下载依赖慢,用国内镜像源替换.npmrc的 registry 配置。
联调时最容易出现的是字段名不一致问题。我坚持后端返回的 VO 字段名和前端接口文档保持完全一致,并且所有时间字段统一用字符串返回,避免前后端时区差异导致的显示偏移。
6.3 Nginx 部署的细节
生产环境我部署在一台轻量服务器上,后端用java -jar启动,前端npm run build构建后把 dist 目录放到 Nginx 下。
Nginx 配置核心是 location 转发和 history 路由兜底:
server { listen 80; server_name localhost; root /var/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /var/www/uploads/; } }try_files $uri $uri/ /index.html这行必须写,否则 Vue Router 的 history 模式刷新页面时会 404。/api/代理会把前端请求转发到后端服务,注意proxy_pass后面的 URI 不带路径,代理时 Nginx 会把/api前缀原样传给后端,所以后端接口路径必须也以/api开头,保持一致的规则。
部署过程中容易忽略的是后端上传文件的目录权限。如果用了文件上传功能,记得给 Nginx 运行用户(通常是 www-data)设置上传目录的读写权限。
7. 复盘:我踩过的坑和可以继续做的地方
最后聊聊实际开发中的高频问题,以及这套系统后续可以怎么扩展。
7.1 问题排查表
我把开发过程中遇到的问题按类型整理成了一张表,如果后面有人遇到类似情况,可以直接对照排查:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 后端启动报 ClassNotFound | SpringBoot3 用了 jakarta 包,但依赖引入了 SpringBoot2 版本的库 | 统一使用适配 SpringBoot3 的 starter 版本 |
| 前端请求接口 404 | Vite 代理没生效,或者 Nginx 没配置 try_files | 检查代理配置和后端接口路径前缀是否一致 |
| 列表页筛选后 URL 没有变化,刷新丢失条件 | 筛选条件只存在组件内部 state | 把筛选条件同步到 URL query 参数,或存 Pinia |
| MySQL 导出导入时报错 | 字符集不一致,排序规则冲突 | 统一使用 utf8mb4 + utf8mb4_0900_ai_ci |
| 上传图片后访问 403 | Nginx 对 uploads 目录权限不足 | 修改目录所有者或增加执行权限 |
其中"筛选条件刷新丢失"这个问题我后来改成了通过 URL query 同步条件,这样分享链接给别人的时候,对方打开看到的是同一个结果列表,体验好了很多。
7.2 后续扩展思路
这套系统现在满足了内容管理、检索、权限、收藏等核心需求,后续有两条明显的扩展线。
第一是内容生态化。目前只有经方和食材两类内容,可以扩展出节气养生专题、体质自测问卷、食材四季菜谱等模块。新增内容类型抽象出来,由于标签绑定表是通用的,扩展新模块不需要改表结构,只需要新增业务表和对应的管理页面即可。
第二是知识图谱化。当前的经方组成、食材归经、体质关联关系其实天然是图结构,后续可以把关系数据导出成 JSON 喂给前端可视化库,画出经方-药材-食材-功效的知识图谱,交互体验会比现在的列表式展示直观很多。
用户反馈方面,我后面想增加一个"收藏夹对比"功能,允许用户把多个食材放到对比列表里,并自动调用禁忌校验接口,滚动显示冲突提示。这个需求技术上没难度,主要是一次前端交互的优化。
做这个项目的整体感受是:如果一个系统集中在内容数字化和组合检索上,SpringBoot + Vue3 + MyBatis 这套方案的可维护性和开发效率非常均衡。其中数据库表设计花的时间最多,但也是最值回票价的——后期所有功能都是建表时的设计红利。如果你想做一个类似的内容管理平台,建议在动手写代码前先认真画一遍实体关系图,把每个字段的查询场景想清楚,后面的路会顺很多。