文章目录
-
- 一、写在前面
-
- [1.1 本次开发的功能目标](#1.1 本次开发的功能目标)
- [1.2 技术栈回顾](#1.2 技术栈回顾)
- [1.3 开发前的准备](#1.3 开发前的准备)
- 二、官方开发规范解读
-
- [2.1 插件化开发架构](#2.1 插件化开发架构)
- [2.2 包结构规范](#2.2 包结构规范)
- [2.3 逻辑删除机制](#2.3 逻辑删除机制)
- [2.4 接口文档注解规范](#2.4 接口文档注解规范)
- [2.5 接口文档分组配置](#2.5 接口文档分组配置)
- 三、数据库设计
-
- [3.1 数据库的设计哲学](#3.1 数据库的设计哲学)
- [3.2 创建客户信息表](#3.2 创建客户信息表)
- 四、自动生成前后端代码
-
- [4.1 后台配置操作](#4.1 后台配置操作)
- [4.2 生成效果](#4.2 生成效果)
- [4.3 重启项目并验证](#4.3 重启项目并验证)
- [4.4 功能测试与调试](#4.4 功能测试与调试)
- 五、自定义接口开发
-
- [5.1 表结构变更](#5.1 表结构变更)
- [5.2 下拉筛选项数据获取](#5.2 下拉筛选项数据获取)
- [5.3 分页查询支持多选筛选](#5.3 分页查询支持多选筛选)
- [5.4 前端调整](#5.4 前端调整)
- [5.5 验证效果](#5.5 验证效果)
- 六、使用感受
作者笔记:本文基于Snowy v3.6.4,不用写一行代码,仅通过后台配置和代码生成器,从零完成一个完整的"客户信息管理"模块。包含数据库设计、自动生成前后端代码(含CRUD、导入导出、字段校验、权限控制)等基础功能。
然后,我自己又增加了一些自定义的查询逻辑和手动调试代码的思路,仅供参考。
在开始之前,如果你还没搭建好Snowy框架的开发环境,建议先阅读我之前的这两篇文章:
- macOS本地环境搭建:《Java Snowy v3.6.2 前后端本地环境搭建全流程(踩坑实录)》
- Linux服务器部署:《Java Snowy 框架生产环境安全部署全流程(服务器篇)》
一、写在前面
1.1 本次开发的功能目标
Snowy框架提供了代码生成器,可以一键生成基础CRUD代码。我现在要开发的"客户信息管理系统"是一个比较典型的后台业务模块,核心功能如下:
| 功能模块 | 具体说明 |
|---|---|
| 客户列表 | 分页展示客户信息,支持按姓名、电话、地址进行模糊搜索 |
| 新增客户 | 表单录入,字段校验:姓名、性别、联系电话为必填,手机号格式需符合正则 |
| 编辑客户 | 回显已有数据,修改后保存,同时校验手机号唯一性(排除自身) |
| 删除客户 | 逻辑删除(框架自动将 DELETE_FLAG 更新为 DELETED,数据不会真正被删) |
| 批量删除 | 勾选多条记录,一键删除,同样为逻辑删除 |
| Excel 导出 | 将当前查询条件下的客户列表导出为 Excel 文件(支持分页全部导出,非仅当前页) |
| 权限控制 | 不同角色(如管理员、普通员工)拥有不同的操作权限(增、删、改、查、导出) |
1.2 技术栈回顾
| 层面 | 技术 |
|---|---|
| 后端 | Spring Boot 3.x + MyBatis-Plus + Sa-Token + HuTool + EasyExcel |
| 前端 | Vue 3 + Ant Design Vue 4 + Vite |
| 数据库 | MySQL 8.0 |
| 缓存 | Redis |
如果你还不熟悉这些技术,建议先花点时间了解 MyBatis-Plus 的基本用法(尤其是
LambdaQueryWrapper和自动填充)以及 Vue 3 的组合式 API(ref、reactive)。
1.3 开发前的准备
在开始写代码之前,你需要确认以下几点:
- 后端项目(snowy-web-app)能正常启动,访问
http://localhost:82/doc.html能看到接口文档。 - 前端项目(snowy-admin-web)能正常启动,访问
http://localhost:81能看到登录页面,并能用superAdmin/123456登录。 - 数据库连接正常,
SYS_USER、SYS_ROLE等系统表已存在。 - Redis 已启动,并且配置在
application.properties中正确指向。
如果上述有任何一项不满足,请先参考之前的文章把基础环境跑通。
从本篇开始,我将按照snowy官方的代码结构,把前后端代码放到一个目录里面。
二、官方开发规范解读
在开始写代码之前,我们先快速梳理一下Snowy框架的核心开发规范。这部分很重要,理解了它,你就能理解Snowy为什么这样设计。
2.1 插件化开发架构
Snowy 采用 Maven 多模块 + 插件化 的架构,每个业务功能都独立成一个插件(plugin),插件之间通过 API 接口通信,而不是直接引用对方的实现类。
这样做的好处是:各模块之间耦合度低,你可以单独升级某个插件而不影响其他模块;同时,多人协作时每个人负责一个插件,代码冲突的概率也大大降低。
官方规范原文:
本框架采用了 Maven 管理多模块,以插件化的方式进行开发,降低了各模块耦合度。插件基本规范如下:
snowy-plugin-xxx插件拥有对应的snowy-plugin-xxx-api插件接口,用于给其他插件调用;snowy-plugin-xxx-api插件接口引用了snowy-common模块,便于插件使用基础规则;snowy-plugin-xxx插件被主启动模块snowy-web-app引用,在启动时加载该插件。注意:插件之间的互相调用仅允许通过引用对方的 api 接口方式,严禁为了图方便将对方插件直接引入。
业务代码应该放在哪里?
官方文档中专门提到了一个给开发者预留的业务插件:
业务功能插件: snowy-plugin-biz ------ 该插件是给客户预留的业务插件,也就是说你可以将业务代码写在该插件内。目前该插件包含了组织管理、岗位管理、人员管理等功能。
所以,我们的客户管理功能直接写在 snowy-plugin-biz 模块中 ,无需创建新插件。这个模块已经在项目中存在,我们只需要在它的 modular 包下创建自己的功能包(如 customer)即可。
2.2 包结构规范
以 snowy-plugin-sys 为例,一个标准插件的包结构如下:
snowy-plugin-biz
└── src/main/java/vip.xiaonuo.biz
├── core
│ ├── config # 模块配置包(如 MyBatis-Plus 配置、Sa-Token 配置等)
│ └── xxx # 枚举、工具类、监听器等
└── modular
└── customer # 功能包(以功能名命名,小写)
├── controller # 控制器包:接收前端请求,调用 Service
├── entity # 实体包:与数据库表一一对应
├── mapper # Mapper 包:数据库操作接口
│ └── mapping # MyBatis XML 文件包
├── param # 参数包:接收前端传来的参数(分页、新增、编辑等)
├── provider # API 接口实现包(用于其他插件调用本插件的服务)
├── result # 结果集包:返回给前端的专用 VO(非实体类直接返回时使用)
└── service # Service 包
└── impl # Service 实现类包
命名规范要点:
- 数据库表名 :
模块缩写_表含义,全部使用大写字母,如BIZ_CUSTOMER(BIZ表示业务模块)。 - 功能包名 :去掉数据库表前缀,全部小写,如
customer。 - 实体类名 :帕斯卡命名法(首字母大写),如
BizCustomer。 - 参数类名 :以功能 + 场景结尾,如
BizCustomerPageParam、BizCustomerAddOrUpdateParam。
为什么要这么细的包结构?
其实一开始我也觉得有点"过度设计",但后来发现,当你的功能模块越来越多时,这种统一的包结构能让你快速定位代码------你一看
param包就知道所有参数类放哪里,一看result包就知道所有自定义返回结果放哪里。团队协作时,不需要问同事"这个类应该放哪个包"。
2.3 逻辑删除机制
Snowy框架内置了MyBatis-Plus的逻辑删除功能,配置如下:
properties
mybatis-plus.global-config.db-config.logic-delete-field=DELETE_FLAG
mybatis-plus.global-config.db-config.logic-delete-value=DELETED
mybatis-plus.global-config.db-config.logic-not-delete-value=NOT_DELETE
这意味着 :只要数据库表中有 DELETE_FLAG 字段,并且你的实体继承了 CommonEntity,MyBatis-Plus 就会:
save时自动将DELETE_FLAG设为NOT_DELETEremove时自动将DELETE_FLAG设为DELETED- 查询时自动加上
WHERE DELETE_FLAG = 'NOT_DELETE'
我们不用手动处理逻辑删除,框架已经帮我们做好了。
2.4 接口文档注解规范
Snowy 使用 Knife4j(基于 Swagger 3)自动生成接口文档。为了让我们的接口能正确显示在 http://localhost:82/doc.html 上,需要遵循以下注解规范:
| 注解位置 | 注解类型 | 示例 | 作用 |
|---|---|---|---|
| Controller 类 | @Tag(name = "客户管理") |
@Tag(name = "客户管理") |
给接口分组,显示在文档的"分组"下拉框中 |
| Controller 方法 | @Operation(summary = "分页查询客户") |
@Operation(summary = "分页查询客户") |
描述接口的作用 |
| Controller 方法 | @SaCheckPermission("biz:customer:page") |
@SaCheckPermission("biz:customer:page") |
权限校验(非文档相关,但通常写在方法上) |
| 参数类字段 | @Schema(description = "客户姓名", requiredMode = Schema.RequiredMode.REQUIRED) |
@Schema(description = "客户姓名") |
描述字段,requiredMode 标记是否必填 |
| 实体类字段 | @Schema(description = "客户姓名") |
@Schema(description = "客户姓名") |
描述字段,用于返回结果时的说明 |
一个容易忽略的点 :参数类字段上的 @Schema(requiredMode = ...) 只是告诉 Knife4j"这个字段在文档中应该标红显示为必填",它不会 真正校验参数是否为空。真正的参数校验需要通过 @NotBlank、@NotNull 等注解配合 Controller 上的 @Valid 来完成。
2.5 接口文档分组配置
Snowy 的接口文档默认会按插件(plugin)分组展示。如果你的接口没有出现在文档中,或者没有正确分组,需要检查 snowy-web-app/src/main/resources/application.properties 中是否配置了你的插件包扫描路径:
properties
# 示例:扫描 biz 插件下的所有 controller
springdoc.group-configs[0].group=biz
springdoc.group-configs[0].display-name=biz
springdoc.group-configs[0].packages-to-scan=vip.xiaonuo.biz
我这里使用的是 snowy-plugin-biz 插件,理论上框架已经配置好了扫描路径。但如果发现文档里没有出现"客户管理"的接口,可以检查一下这个配置。
三、数据库设计
关于数据库设计部分,我一直在纠结:到底要不要完全照搬 Snowy 官方的设计风格?
一开始我觉得 Snowy 的数据库设计有些"反常规"------ID 用 varchar(20) 不自增、表名和字段名全大写、删除标记用字符串存 NOT_DELETE 而不是 0/1......这些跟我以前做单体项目的习惯完全不一样。
后来我认真读了几遍官方文档,又去 Gitee 仓库看了 issues 里其他开发者的讨论,才逐渐理解了这套设计背后的逻辑。与其说这是"反常规",不如说这是为了"分布式、跨数据库、开箱即用"做的取舍。
3.1 数据库的设计哲学
在开始建表之前,我看了下Snowy官方示例表(如SYS_USER、SYS_ROLE)的设计思路,一开始看到很多"反常规"的设计:
1、Java snowy 框架的ID为什么使用varchar(20) ,而不用int或者bigint 而且没有自增?
2、还有,为什么它这个数据库的表名和字段名都用大写?
3、为什么"删除标记"要用 `DELETE_FLAG` varchar(255),存储的是"NOT_DELETE",为什么不用tinyint(1) 存储1或者0,这样效率会更高吧?
4、为什么 `CREATE_TIME` datetime 没有默认使用 DEFAULT CURRENT_TIMESTAMP?
5、观察自带的 SYS_USER 表,清一色的都是varchar类型,这样涉及是否合理?
6、SYS_USER表的AVATAR字段,直接存储了"data:image/jpg;base64,/9j/4AAQSkZJRgABA....." 很长的文件二进制字符串,为什么不用本地文件存储,然后在数据库存储文件路径?
7、`POSITION_JSON` longtext,如果改为JSON类型是否更好呢?
https://gitee.com/xiaonuobase/snowy 官方仓库Star 33.2K,这么多人使用,我觉得它的这个设计应该属于符合大众审美的吧?但为什么会有上面我收集到的我认为"不太合理"的设计点?
后来查阅官方文档并咨询了DeepSeek,才逐渐理解这套设计背后的深思熟虑,这些设计理念其实是值得我们去思考和学习的。官方仓库Star 33.2K,这么多人使用,自然有一定道理的。
1. ID为什么用varchar(20)且不自增?
传统的MySQL自增ID(
bigint auto_increment)在单机项目中简单好用,但在分布式环境(微服务、分库分表)下会存在全局唯一性风险。Snowy采用**雪花算法(Snowflake ID)**生成主键,这是一个20位左右的纯数字,用varchar(20)存储正好。好处:
- 分布式下全局唯一,无需依赖数据库自增。
- 配合MyBatis-Plus的
@TableId(type = IdType.ASSIGN_ID)自动填充,开发完全无感。- 避免不同数据库(MySQL、Oracle、PostgreSQL)自增语法的差异,提高跨数据库兼容性。
2. 表名和字段名为什么都用大写?
这个问题其实是血的教训:MySQL在Windows下不区分大小写,但在Linux下严格区分。很多项目在Windows开发没问题,部署到Linux生产环境后经常出现"表不存在"的错误。Snowy强制使用大写命名,并配合代码生成器统一处理,彻底杜绝了这类问题。
同时,这也让SQL语句在日志和工具中看起来更整齐、更易读。
3. 逻辑删除标记为什么用DELETE_FLAG varchar(255)存字符串?
通常我们会用
tinyint(1)存0/1,但Snowy的设计更巧妙。字符串NOT_DELETE/DELETED不仅可读性更好,更重要的是解决了逻辑删除与唯一索引冲突的问题。举个例子:客户的
PHONE字段需要唯一索引。当某条记录被逻辑删除后(DELETE_FLAG='DELETED'),如果再用相同的手机号插入新客户,数据库会因为唯一索引冲突而报错。但Snowy的方案允许同一手机号对应多条不同DELETE_FLAG值的记录,因为唯一索引可以建在(PHONE, DELETE_FLAG)组合上,这样既保证了未删除记录的唯一性,又允许已删除记录重复。框架通过MyBatis-Plus的
@TableLogic自动处理增删改查的过滤,开发者不用手动写WHERE DELETE_FLAG='NOT_DELETE',非常方便。
4. CREATE_TIME为什么不设置DEFAULT CURRENT_TIMESTAMP?
Snowy的做法是:所有时间字段由MyBatis-Plus的自动填充机制(
@TableField(fill = FieldFill.INSERT))在Java层面赋值。这样做的好处是:
- 摆脱数据库函数依赖,确保MySQL、Oracle、SQL Server等不同数据库行为一致。
- 便于后续扩展,如从分布式ID生成器中获取统一时间戳。
- 避免数据库时区配置混乱导致的时间不一致问题。
5. 为什么大多数字段都用varchar,包括手机号、年龄等数值?
Snowy的设计哲学是优先保证灵活性和容错性 。手机号用
varchar可以兼容带空格、横杠等格式;年龄用varchar也不会因为用户输入"保密"而报错。统一用varchar还能简化Java代码中的类型处理,避免频繁的类型转换。当然,这并不是绝对的,如果你的业务对性能或数据完整性要求极高,完全可以按需修改。但作为通用快速开发框架,Snowy优先考虑的是"能适应各种奇怪输入"。
6. 头像为什么直接用Base64存数据库,而不是存文件路径?
这是一个典型的简化部署 的权衡。直接把图片以Base64字符串存入数据库,意味着项目不需要配置文件服务器或OSS对象存储,在任何一台服务器上都能开箱即用,特别适合中小型项目或演示环境。数据库备份恢复就是所有数据的备份恢复,不用担心文件丢失。
当然,生产环境如果图片量大,性能会有影响。Snowy也支持自定义文件上传,只是内置了这种最简单的方案。
7. JSON类型为什么用LONGTEXT而不用MySQL的JSON类型?
核心原因:跨数据库兼容性 。
JSON类型是MySQL 5.7才引入的,PostgreSQL、Oracle等支持方式各有不同。而LONGTEXT几乎是所有关系型数据库通用的类型,Snowy作为一款支持多数据库(MySQL、PostgreSQL、Oracle、SQL Server、达梦等)的框架,必须优先考虑通用性。在Java代码中,通过Fastjson或Jackson序列化/反序列化处理JSON数据,用
LONGTEXT存储完全能满足需求。
结论 :Snowy的数据库设计虽然看起来"不标准",但每一处设计都是为了分布式、跨数据库、开箱即用这三大目标服务。理解这些设计哲学后,我在后续开发中就能更自觉地遵循规范,而不是强行用自己习惯的方式去挑战框架。如果你对某些设计仍有疑虑,建议先"入乡随俗",等深入掌握框架后再思考是否调整。
3.2 创建客户信息表
根据官方规范,表名使用大写,前缀 BIZ_ 表示业务模块。
sql
-- 客户信息表
CREATE TABLE BIZ_CUSTOMER (
`ID` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '主键id',
`NAME` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '客户姓名',
`GENDER` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '客户性别',
`BIRTHDAY` date DEFAULT NULL COMMENT '出生日期',
`PHONE` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '联系电话',
`ADDRESS` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '客户地址',
`SORT_CODE` int DEFAULT NULL COMMENT '排序',
`REMARK` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '备注',
`EXT_JSON` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '扩展信息',
`DELETE_FLAG` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '删除标志',
`CREATE_TIME` datetime DEFAULT NULL COMMENT '创建时间',
`CREATE_USER` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`UPDATE_TIME` datetime DEFAULT NULL COMMENT '更新时间',
`UPDATE_USER` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='客户信息表';
关键点说明:
ID:使用VARCHAR(20),由MyBatis-Plus的雪花算法自动生成,不需要数据库自增CREATE_TIME/UPDATE_TIME:框架自动填充CREATE_USER/UPDATE_USER:框架自动填充(当前登录用户ID)DELETE_FLAG:逻辑删除标识,框架自动处理
四、自动生成前后端代码
这一节我们直接用 Snowy 自带的代码生成器 ,5 分钟生成完整的前后端代码。不需要手动写 Controller、Service、Mapper,也不需要手动创建 Vue 页面。
4.1 后台配置操作
用 superAdmin 登录后台,左侧菜单 "在线开发" → "代码生成" ,点击右上角 "新建"。
| 配置项 | 填写值 | 说明 |
|---|---|---|
| 选择数据表 | BIZ_CUSTOMER |
我们刚建好的客户表 |
| 主键生成 | 默认"ID" | 雪花算法自动生成 |
| 生成方式 | 项目内 | 直接生成到 IDEA 项目,不用解压 |
| 所属模块 | 业务功能 |
对应 snowy-plugin-biz |
| 业务名 | customer |
包名、前端文件夹名 |
| 类名 | BizCustomer |
实体类、Service 的前缀 |
| 作者 | 你的名字 | 会出现在代码注释里 |
操作界面如下:

然后,点击"继续",进入字段配置页面。这里可以控制每个字段在页面上的行为:
| 字段 | 组件类型 | 查询方式 | 列表显示 | 增改 | 必填 | 说明 |
|---|---|---|---|---|---|---|
| NAME | 输入框 | 模糊匹配 | ✅ | ✅ | ✅ | 姓名,模糊搜索,必填 |
| GENDER | 单选按钮 | 精确匹配 | ✅ | ✅ | ✅ | 性别 |
| BIRTHDAY | 日期选择器 | 范围查询 | ✅ | ✅ | ❌ | 生日 |
| PHONE | 输入框 | 模糊匹配 | ✅ | ✅ | ✅ | 电话 |
| ADDRESS | 输入框 | 模糊匹配 | ✅ | ✅ | ❌ | 地址 |
| SORT_CODE | 数字输入框 | 不查询 | ❌ | ✅ | ❌ | 排序 |
| REMARK | 文本域 | 不查询 | ❌ | ✅ | ❌ | 备注 |
| EXT_JSON | 不生成 | - | ❌ | ❌ | ❌ | 扩展字段,暂不用 |
| DELETE_FLAG 等 | 不生成 | - | ❌ | ❌ | ❌ | 框架自动处理 |
操作界面如下:

4.2 生成效果
点击 "继续" 后,代码生成器会自动:
后端代码 :在 snowy-plugin/snowy-plugin-biz 模块生成
snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/
├── controller/ # 控制器层:接收前端请求
│ └── BizCustomerController.java
├── entity/ # 实体层:映射数据库表
│ └── BizCustomer.java
├── enums/ # 枚举类:如性别、状态等固定选项
│ └── BizCustomerEnum.java
├── mapper/ # 数据访问层:数据库操作接口
│ ├── BizCustomerMapper.java
│ └── mapping/
│ └── BizCustomerMapper.xml
├── param/ # 参数类:接收前端传入的参数
│ ├── BizCustomerPageParam.java # 分页查询参数
│ ├── BizCustomerAddParam.java # 新增参数
│ ├── BizCustomerEditParam.java # 编辑参数
│ └── BizCustomerIdParam.java # 删除参数(单个/批量)
├── result/ # 结果类:自定义返回给前端的数据结构
│ └── BizCustomerResult.java
└── service/ # 业务逻辑层
├── BizCustomerService.java
└── impl/
└── BizCustomerServiceImpl.java
前端代码 :在 snowy-admin-web/src/views/biz/customer 生成:
snowy-admin-web/src/views/biz/customer/
├── index.vue # 列表页:搜索表单 + 数据表格 + 分页 + 操作按钮
├── form.vue # 表单弹窗:新增/编辑客户信息
└── importModel.vue # 导入弹窗(可选,不需要可删除)
这里生成的代码,你可以自行查看和修改。
4.3 重启项目并验证
代码生成后,必须重启前后端服务,否则新代码不会被加载。
- 停止后端 Spring Boot 应用(IDEA 红色方块)
- 重新运行
Application.java - 等待控制台出现
Application is running! - 重新运行前端
npm run dev - 前端页面刷新(或重新登录
superAdmin)
如果一切正常,接下来授予权限。这里给"超级管理员"授权,操作步骤:系统 - 权限管理 - 角色管理 - 授权 - 授权权限,选中/biz/customer,然后点击保存。

接下来,查看 业务 标签的左侧菜单栏会出现 "客户信息表",点进去就能看到 列表页,支持分页、搜索、新增、编辑、删除、导出、导入。

至此,基础的CRUD功能就都有了,你可以自己去体验一下。
4.4 功能测试与调试
项目启动后,可以访问:
-
后端接口:http://localhost:82
-
API 文档(Knife4j):http://localhost:82/doc.html 用户名:
admin,密码:123456 -
Druid 监控台:http://localhost:82/druid 用户名:
admin,密码:123456
访问 http://localhost:82/doc.html,在 Knife4j 文档中找到"客户管理"分组,测试:
POST /biz/customer/page(分页查询)POST /biz/customer/add(新增)POST /biz/customer/edit(编辑)POST /biz/customer/delete(删除)POST /biz/customer/export(导出)

五、自定义接口开发
自动生成的代码已经自动完成了 BIZ_CUSTOMER 表的基础增删改查,但实际业务中,可能需要更加复杂的查询逻辑。假设我现在需要在客户信息中增加"留学国家"和"所在城市"两个字段,并且要求:
- 留学国家 :从数据库已有记录的
TARGET_COUNTRY字段中提取所有逗号分隔的国名,去重、排序后作为下拉选项,支持多选,后端按"包含任一国家"的条件检索(find_in_set的OR查询)。 - 所在城市 :从
CITY字段提取所有不重复的城市名,以省‑市级联的下拉选项,支持多选,后端使用IN条件检索。
5.1 表结构变更
首先,在 BIZ_CUSTOMER 表中增加两个新字段:
sql
ALTER TABLE BIZ_CUSTOMER
ADD COLUMN `TARGET_COUNTRY` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '留学国家',
ADD COLUMN `CITY` varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所在市';
提示 :字段名使用大写(符合 Snowy 规范),
TARGET_COUNTRY用于存储逗号分隔的多个国家(例如美国,英国,加拿大),CITY存储单一城市名。
然后为已有记录补一些测试数据(示例):
sql
UPDATE BIZ_CUSTOMER SET TARGET_COUNTRY = '美国,英国', CITY = '北京' WHERE ID = '2047261229755428865';
UPDATE BIZ_CUSTOMER SET TARGET_COUNTRY = '加拿大,澳大利亚', CITY = '上海' WHERE ID = '2047261386303631362';
UPDATE BIZ_CUSTOMER SET TARGET_COUNTRY = '日本,英国', CITY = '深圳' WHERE ID = '2047264855093706753';
5.2 下拉筛选项数据获取
修改实体类和参数类,实体 BizCustomer 增加两个属性:
java
// snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/entity/BizCustomer.java
@Schema(description = "留学国家(多个用逗号分隔)")
private String targetCountry;
@Schema(description = "所在城市")
private String city;
分页参数类 BizCustomerPageParam 增加用于接收多选值的字段:
关键点:字段名必须与前端传递的参数名完全一致,否则无法绑定。
java
// snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/param/BizCustomerPageParam.java
import java.util.List;
@Schema(description = "留学国家(多选)")
private List<String> targetCountryList;
@Schema(description = "所在城市(多选)")
private List<String> cityList;
编写获取留学国家选项的接口:Mapper 接口(BizCustomerMapper)新增方法:
java
//snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/mapper/BizCustomerMapper.java
import java.util.List;
/**
* 查询所有非空的 TARGET_COUNTRY 原始值(逗号分隔字符串)
*/
@Select("SELECT DISTINCT TARGET_COUNTRY FROM BIZ_CUSTOMER WHERE TARGET_COUNTRY IS NOT NULL AND TARGET_COUNTRY != ''")
List<String> selectRawTargetCountries();
/**
* 查询所有非空的城市
*/
@Select("SELECT DISTINCT CITY FROM BIZ_CUSTOMER WHERE CITY IS NOT NULL AND CITY != '' ORDER BY CITY")
List<String> selectRawCities();
Service 接口 (BizCustomerService)新增方法:
java
// snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/service/BizCustomerService.java
/**
* 获取下拉选项(统一入口)
* @param type 类型:targetCountry / city
* @return List<Map<String, Object>> 格式 [{id, name}]
*/
List<Map<String, Object>> getOptions(String type);
Service 实现:
java
// snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/service/impl/BizCustomerServiceImpl.java
import java.util.*;
@Override
public List<Map<String, Object>> getOptions(String type) {
if ("targetCountry".equals(type)) {
// 获取原始数据(如 "美国,英国")
List<String> rawList = baseMapper.selectRawTargetCountries();
Set<String> countrySet = new HashSet<>();
for (String item : rawList) {
if (StrUtil.isBlank(item)) continue;
String[] parts = item.split(",");
for (String part : parts) {
String trimmed = part.trim();
if (StrUtil.isNotBlank(trimmed)) {
countrySet.add(trimmed);
}
}
}
List<String> sorted = new ArrayList<>(countrySet);
Collections.sort(sorted);
return sorted.stream()
.map(name -> {
Map<String, Object> map = new HashMap<>();
map.put("value", name);
map.put("label", name);
return map;
})
.collect(Collectors.toList());
}
else if ("city".equals(type)) {
List<String> cityList = baseMapper.selectRawCities();
return cityList.stream()
.map(city -> {
Map<String, Object> map = new HashMap<>();
map.put("value", name);
map.put("label", name);
return map;
})
.collect(Collectors.toList());
}
return Collections.emptyList();
}
Controller 暴露接口:
java
// snowy-plugin/snowy-plugin-biz/src/main/java/vip/xiaonuo/biz/modular/customer/controller/BizCustomerController.java
@Operation(summary = "获取下拉选项(留学国家/城市)")
@GetMapping("/options")
public CommonResult<List<Map<String, Object>>> getOptions(@RequestParam String type) {
// 注意:这里使用 CommonResult.data(...),Snowy 框架中带数据响应的方法。
return CommonResult.data(bizCustomerService.getOptions(type));
}
5.3 分页查询支持多选筛选
修改 BizCustomerServiceImpl.page 方法,严格遵循官方风格。在 Service实现 中改造 page 方法:
java
@Override
public Page<BizCustomer> page(BizCustomerPageParam param) {
QueryWrapper<BizCustomer> queryWrapper = new QueryWrapper<BizCustomer>().checkSqlInjection();
// 原有条件
// ....
// ========== 新增:多选留学国家 ==========
if (ObjectUtil.isNotEmpty(param.getTargetCountryList())) {
// 构建 OR 条件:FIND_IN_SET(国名, TARGET_COUNTRY) > 0
queryWrapper.and(wrapper -> {
for (String country : param.getTargetCountryList()) {
wrapper.or().apply("FIND_IN_SET({0}, TARGET_COUNTRY) > 0", country);
}
});
}
// ========== 新增:多选城市 ==========
if (ObjectUtil.isNotEmpty(param.getCityList())) {
queryWrapper.lambda().in(BizCustomer::getCity, param.getCityList());
}
return this.page(CommonPageRequest.defaultPage(), queryWrapper);
}
关键点说明:
- 使用
QueryWrapper而非LambdaQueryWrapper,与官方保持一致。 FIND_IN_SET({0}, TARGET_COUNTRY)中的{0}是 MyBatis-Plus 的占位符,可防止 SQL 注入。- 多选城市直接使用
in方法,简洁高效。
特别注意:
- 数据库字段名是
target_country(小写加下划线),MyBatis-Plus 会映射到实体属性targetCountry,但在apply中必须使用数据库真实列名。 FIND_IN_SET只能匹配逗号分隔的字符串,且要求两边不能有空格。如果数据有空格,可用REPLACE(target_country, ' ', '')临时处理。- 多选关系使用
OR连接:只要记录的国家字段包含任意一个选中值即匹配。
5.4 前端调整
统一接口的调用方式(snowy-admin-web/src/api/biz/bizCustomerApi.js):
javascript
// 获取下拉选项(统一入口)
getOptions(type) {
return request('options', { type }, 'get')
},
Snowy 的前端
request函数会自动解包,返回的直接是data部分,因此使用时无需再判断res.code。
加载下拉数据:在 snowy-admin-web/src/views/biz/customer/index.vue 的 script 中添加:
javascript
import { ref, onMounted } from 'vue'
// 加载下拉选项
const targetCountryOptions = ref([])
const cityOptions = ref([])
const loadOptions = (type, targetRef) => {
bizCustomerApi.getOptions(type).then(res => {
targetRef.value = res || []
console.log(`${type} 加载成功:`, targetRef.value)
}).catch(err => {
console.error('请求异常:', err)
})
}
loadOptions('targetCountry', targetCountryOptions)
loadOptions('city', cityOptions)
搜索表单绑定字段:这是最关键的一步。前端使用的字段名必须与后端 BizCustomerPageParam 中的字段名完全一致。
vue
<!-- 留学国家多选 -->
<a-form-item label="留学国家" name="targetCountryList">
<a-select v-model:value="searchFormState.targetCountryList" placeholder="请选择留学国家" :options="targetCountryOptions" mode="multiple"/>
</a-form-item>
<!-- 所在城市多选(注意字段名为 cityList) -->
<a-form-item label="所在城市" name="cityList">
<a-select v-model:value="searchFormState.cityList" placeholder="请选择所在城市" :options="cityOptions" mode="multiple"/>
</a-form-item>
loadData 函数无需额外映射,因为字段名已经与后端一致,直接传递即可。原有的 loadData 函数会通过 Object.assign(parameter, searchFormParam) 自动将 targetCountryList 和 cityList 发送到后端。
javascript
const loadData = (parameter) => {
const searchFormParam = cloneDeep(searchFormState.value)
// ... 其他日期范围处理
return bizCustomerApi.bizCustomerPage(Object.assign(parameter, searchFormParam))
}
5.5 验证效果
启动后端,访问 /biz/customer/options?type=targetCountry,应返回去重排序后的国家列表,例如 ["美国","英国","加拿大","澳大利亚","日本"]。
打开前端页面,留学国家下拉框可见多选区域,选择"美国"和"英国"后点击查询,后端会返回那些 TARGET_COUNTRY 字段包含"美国"或"英国"的客户。城市多选同理,选择"上海"后查询,返回 CITY = '上海' 的记录。

查看控制台:

至此,我们仅用少量的后端扩展和前端适配,就为自动生成的 CRUD 页面增加了动态多选下拉查询功能。Snowy 框架的灵活性让我们可以持续迭代业务需求。
扩展想法 :如果以后需要更规范的省‑市二级联动,可以单独维护一张"省市区字典表",并提供树形结构接口,前端使用
<a-cascader>组件。本文不再展开,读者可自行实践。
六、使用感受
不得不说,Snowy 框架已经封装了足够强大的自动生成代码的完整流程,让开发人员可以把更多精力放在核心业务逻辑上,而不是重复写增删改查的模板代码。
通过 Snowy 自带的代码生成器,我们可以轻松地实现:
- 一键生成前后端完整代码(从 Controller 到 Vue 页面,一气呵成);
- 自动创建菜单和按钮权限,接下来只需要手动给指定角色授权即可;
- 标准的参数校验、分页查询、导入导出、逻辑删除等常用功能;
- 统一的项目结构,团队协作时无需反复沟通代码应该放哪里
如果你正在使用 Snowy 开发后台管理系统,强烈建议先打开代码生成器试一试。你会发现,原本需要半天甚至一天才能写完的基础 CRUD,现在只需要几分钟就能完成,而且还不用担心手写代码的规范问题。
当然,代码生成器不是万能的------复杂的多表关联、特殊的业务校验仍然需要手动编写。但它已经帮我们省掉了最枯燥、最重复的那部分工作,让我们能把时间花在真正体现业务价值的地方。
本文源代码已发布到我的Gitee仓库:https://gitee.com/rxbook/java-snowy-demo
参考链接:Snowy 代码生成器官方文档
本文基于 Snowy v3.6.4,开发环境为 macOS + IntelliJ IDEA + MySQL 8.0。
一个小彩蛋
这里插入一个本人亲历的合并官方更新的问题的踩坑经历:
2026年04月23日,在研究snowy代码自动生成和撰写本文的过程中,领导说希望能把今天官方发布的最新的 v3.6.4 版本分支合并到项目中,然后我就拉取了官方的最新的代码,合并到了我们自己的代码仓库,解决了冲突,然后运行后发现了请求后端的接口莫名其妙的变了。从 v3.6.3 升级到 v3.6.4 之后,前端启动后会从调用
http://127.0.0.1:82转发到http://127.0.0.1:81/api。这是之前 v3.6.3 的:
更新到 v3.6.4 之后:
问了官方群和DeepSeek,都没有得到回复,后来我把官方的 v3.6.4 的更新点一个一个排查,才发现了
snowy-admin-web/src/utils/request.js这个文件的url = sysConfig.API_URL + convertUrl(url)这行被删了,导致请求的后端接口都转发到http://127.0.0.1:81/api了。由于公司项目前后端配置了不同的域名,前端
web.xx.com调用后端api.xx.com,之前一直运行好好的,今天合并了官方的3.6.4 的更新,发现 前端web.xx.com调用后端web.xx.com/api我都蒙圈了。虽说可以在Nginx配置代理,但是排查过程也挺费劲的。我把这行恢复之后,请求的后端的接口就变回了
:82。⚠️ 因此,个人建议:后续如果要合并官方新的更新,一定要谨慎!稍有不慎可能就会整个项目出问题了。


