在Snowy后台无需编码实现自动化生成CRUD操作流程

文章目录

    • 一、写在前面
      • [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框架的开发环境,建议先阅读我之前的这两篇文章:


一、写在前面

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(refreactive)。

1.3 开发前的准备

在开始写代码之前,你需要确认以下几点:

  • 后端项目(snowy-web-app)能正常启动,访问 http://localhost:82/doc.html 能看到接口文档。
  • 前端项目(snowy-admin-web)能正常启动,访问 http://localhost:81 能看到登录页面,并能用 superAdmin/123456 登录。
  • 数据库连接正常,SYS_USERSYS_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_CUSTOMERBIZ 表示业务模块)。
  • 功能包名 :去掉数据库表前缀,全部小写,如 customer
  • 实体类名 :帕斯卡命名法(首字母大写),如 BizCustomer
  • 参数类名 :以功能 + 场景结尾,如 BizCustomerPageParamBizCustomerAddOrUpdateParam

为什么要这么细的包结构?

其实一开始我也觉得有点"过度设计",但后来发现,当你的功能模块越来越多时,这种统一的包结构能让你快速定位代码------你一看 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_DELETE
  • remove 时自动将 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_USERSYS_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 重启项目并验证

代码生成后,必须重启前后端服务,否则新代码不会被加载。

  1. 停止后端 Spring Boot 应用(IDEA 红色方块)
  2. 重新运行 Application.java
  3. 等待控制台出现 Application is running!
  4. 重新运行前端 npm run dev
  5. 前端页面刷新(或重新登录 superAdmin

如果一切正常,接下来授予权限。这里给"超级管理员"授权,操作步骤:系统 - 权限管理 - 角色管理 - 授权 - 授权权限,选中/biz/customer,然后点击保存

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

至此,基础的CRUD功能就都有了,你可以自己去体验一下。

4.4 功能测试与调试

项目启动后,可以访问:

访问 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.vuescript 中添加:

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) 自动将 targetCountryListcityList 发送到后端。

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

⚠️ 因此,个人建议:后续如果要合并官方新的更新,一定要谨慎!稍有不慎可能就会整个项目出问题了。

相关推荐
ffqws_1 小时前
MyBatis 动态 SQL 详解:从原理到实战
java·sql·mybatis
-星空下无敌1 小时前
IDEA 2025.3.1最新最全下载、安装、配置及使用教程(保姆级教程)
java·ide·intellij-idea
JAVA面经实录9171 小时前
Spring Boot + Spring AI 一体化实战全文档
java·人工智能·spring boot·spring
希望永不加班1 小时前
SpringBoot 接口签名验证(AppKey/Secret)
java·spring boot·后端·spring
MoonBit月兔1 小时前
MoonBit 作为重大成果亮相广东省人工智能应用对接大会,展示 AI 原生编程语言最新进展
开发语言·人工智能·moonbit
c++之路1 小时前
C++ 预处理器
开发语言·c++
ConardLi2 小时前
开源我的 GPT-Image2 生图 Skill,附大量玩法指南
前端·人工智能·后端
fengxin_rou2 小时前
RabbitMQ安装教程:windows本地安装和docker部署
java·分布式·后端·rabbitmq
哔哩哔哩技术2 小时前
GPU隔离技术的分析与改进
后端