Run in the local

​
​
install mysql:
​
​
https://dev.mysql.com/downloads/mysql/
​
​
import db_reggie.sql
​

登录逻辑

一、整体流程

前端 (login.html + login.js)
       │
       │  POST /employee/login
       │  Body: { username, password }
       ▼
Spring Boot Controller (EmployeeController.java)
       │
       ▼
Service Layer (EmployeeServiceImpl.kt)
       │
       ▼
MyBatis-Plus Mapper (EmployeeMapper.kt)
       │
       ▼
MySQL Database

二、前端(发送 POST 请求)

2.1 页面:backend/page/login/login.html

使用 Vue 2 + Element UI,核心代码:

// 第 98-112 行
async handleLogin() {
  this.$refs.loginForm.validate(async (valid) => {
    if (valid) {
      this.loading = true
      // 调用 loginApi 发送 POST 请求
      let res = await loginApi(this.loginForm)
      if (String(res.code) === '1') {
        // 登录成功:保存用户信息到 localStorage
        localStorage.setItem('userInfo', JSON.stringify(res.data))
        // 跳转到后台首页
        window.location.href = '../../index.html'
      } else {
        // 登录失败:显示错误信息
        this.$message.error(res.errMsg)
        this.loading = false
      }
    }
  })
}

表单数据 (loginForm):

{
  username: 'admin',   // 用户名,最大 20 字符
  password: '123456'   // 密码,至少 6 位
}

表单校验规则:

  • username:非空校验(blur 触发)
  • password:长度 >= 6(blur 触发)
  • 回车键可直接触发登录(@keyup.enter.native="handleLogin")

2.2 API 层:backend/api/login.js

function loginApi(data) {
  return $axios({
    'url': '/employee/login',   // 请求路径
    'method': 'post',           // POST 方法
    data                        // 请求体:{ username, password }
  })
}

2.3 通用请求封装:backend/js/request.js

基于 Axios 封装 $axios 实例:

配置项说明
baseURL/
timeout10000 ms
Content-Typeapplication/json;charset=utf-8
请求拦截器:
  • 将 GET 请求的 params 参数拼接到 URL 上
    响应拦截器:
  • res.data.code === 0 && res.data.msg === 'NOTLOGIN' → 清除 localStorage,重定向到登录页
  • 其他情况直接返回 res.data
  • 网络错误 / 超时 / HTTP 状态码异常 → 弹出 Element UI 错误提示

三、后端处理

3.1 请求参数映射

前端字段Employee 属性类型
usernameusernameString
passwordpasswordString (明文传输)

3.2 Controller:EmployeeController.java

@RestController
@RequestMapping("/employee")
public class EmployeeController {
    @Autowired
    private EmployeeService employeeService;
    @PostMapping("/login")
    public Result<Employee> login(@RequestBody Employee employee,
                                  HttpServletRequest request) {
        // 1. 调用 Service 层验证登录
        Result<Employee> result = employeeService.login(employee);
        // 2. 登录成功 → 将员工 ID 存入 Session
        if (result.getCode() == 1) {
            request.getSession().setAttribute("employee",
                result.getData().getId());
        }
        // 3. 返回统一结果
        return result;
    }
}

关键注解:

  • @RestController → 所有方法返回值序列化为 JSON
  • @RequestMapping("/employee") → 统一前缀 /employee
  • @PostMapping("/login") → 处理 POST /employee/login
  • @RequestBody → 将请求体 JSON 反序列化为 Employee 对象

3.3 Service 层:EmployeeServiceImpl.kt

@Service
class EmployeeServiceImpl : EmployeeService,
    ServiceImpl<EmployeeMapper, Employee>() {
    override fun login(employee: Employee): Result<Employee> {
        // 1. 前端传来的密码进行 MD5 加密
        var password = employee.password
        password = DigestUtils.md5DigestAsHex(
            password?.toByteArray() ?: byteArrayOf()
        )
        // 2. 根据 username 查询数据库
        val queryWrapper = QueryWrapper<Employee>().apply {
            eq("username", employee.username)
        }
        val emp: Employee = getOne(queryWrapper)
            ?: return Result.error("登陆失败")
        // 3. 比对 MD5 后的密码
        if (!emp.password.equals(password)) {
            return Result.error("登陆失败,密码错误")
        }
        // 4. 检查账户状态(0 = 禁用)
        if (emp.status == 0) {
            return Result.error("账户禁用")
        }
        // 5. 登录成功,返回员工信息
        return Result.success(emp)
    }
}

登录逻辑步骤:

  1. MD5 加密:对前端传来的明文密码做 MD5 摘要
  2. 数据库查询:通过 MyBatis-Plus 的 QueryWrapper,以 username 为条件查询 employee 表
  3. 密码匹配:比对数据库中的 MD5 密码与步骤 1 的结果
  4. 状态校验:status == 1 正常,status == 0 禁用
  5. 返回结果:成功返回 code=1 + 员工数据,失败返回 code=0 + 错误信息

3.4 统一返回格式:Result.kt

data class Result<T>(
    var code: Int? = null,       // 1=成功, 0=失败
    var msg: String? = null,  // 错误信息
    var data: T? = null,         // 返回数据
    var map: MutableMap<String, Any> = HashMap()
)

响应 JSON 格式:
成功时:

{
  "code": 1,
  "errMsg": null,
  "data": {
    "id": 1,
    "username": "admin",
    "name": "管理员",
    "phone": "13800138000",
    "sex": "1",
    "status": 1
  },
  "map": {}
}

失败时:

{
  "code": 0,
  "errMsg": "登陆失败,密码错误",
  "data": null,
  "map": {}
}

3.5 数据库实体:Employee.kt

data class Employee(
    var id: Long? = null,
    var username: String? = null,    // 登录用户名
    var name: String? = null,        // 真实姓名
    var password: String? = null,    // MD5 加密后的密码
    var phone: String? = null,
    var sex: String? = null,
    var idNumber: String? = null,    // 身份证号
    var status: Int? = null,         // 1=正常, 0=禁用
    var createTime: LocalDateTime? = null,
    var updateTime: LocalDateTime? = null,
    var createUser: Long? = null,
    var updateUser: Long? = null
) : Serializable

四、时序图

浏览器                          Spring Boot                      MySQL
  │                                │                              │
  │  POST /employee/login          │                              │
  │  { username, password }        │                              │
  │ ──────────────────────────────>│                              │
  │                                │                              │
  │                    @RequestBody 反序列化                        │
  │                    EmployeeController.login()                  │
  │                                │                              │
  │                    EmployeeServiceImpl.login()                 │
  │                                │                              │
  │                    MD5(password)                               │
  │                                │                              │
  │                    QueryWrapper.eq("username", ...)            │
  │                                │ ────────────────────────────>│
  │                                │  SELECT * FROM employee      │
  │                                │  WHERE username = ?          │
  │                                │ <────────────────────────────│
  │                                │                              │
  │                   比对 password                                │
  │                   检查 status                                  │
  │                                │                              │
  │                    Session.setAttribute("employee", id)        │
  │                                │                              │
  │  返回 Result(code=1/0, ...)   │                              │
  │ <──────────────────────────────│                              │
  │                                │                              │
  │  前端判断 code:                                              │
  │  - 1 → 存 localStorage, 跳转 index.html                       │
  │  - 0 → 弹窗显示 errMsg                                        │

五、关键文件索引

文件作用
src/main/resources/backend/page/login/login.html登录页面 (Vue 2 + Element UI)
src/main/resources/backend/api/login.js封装登录/登出 API 调用
src/main/resources/backend/js/request.jsAxios 实例,拦截器
src/main/java/.../controller/EmployeeController.java处理 /employee/login POST 请求
src/main/kotlin/.../service/impl/EmployeeServiceImpl.kt登录业务逻辑 (MD5 + DB 查询 + 状态校验)
src/main/kotlin/.../service/EmployeeService.kt服务接口
src/main/kotlin/.../mapper/EmployeeMapper.ktMyBatis-Plus Mapper
src/main/kotlin/.../entity/Employee.kt数据库实体
src/main/kotlin/.../common/Result.kt统一响应格式

注意:当前密码在数据库中以 MD5 存储(已过时),生产环境建议升级为 bcrypt / argon2。数据库中需预先插入 MD5 加密后的密码(如 e10adc3949ba59abbe56e057f20f883e 对应 123456),前端才能登录成功。

LoginCheckFilter

前端

// 响应拦截器  
service.interceptors.response.use(res => {
	if (res.data && res.data.code === 0 && (res.data.errMsg === 'NOTLOGIN' || res.data.msg === 'NOTLOGIN')) {// 返回登录页面
 
		console.log(res.data)
		console.log(res.data.code)
		console.log(res.data.errMsg)
		console.log(res.data.msg)
 
		console.log('---/backend/page/login/login.html---')
		localStorage.removeItem('userInfo')
			// 使用相对路径跳转到登录页面
			window.top.location.href = '../page/login/login.html'
		} else {
			return res.data
		}
},
	error => {
		console.log('err' + error)
		let { message } = error;
		if (message == "Network Error") {
			message = "后端接口连接异常";
		}
		else if (message.includes("timeout")) {
			message = "系统接口请求超时";
		}
		else if (message.includes("Request failed with status code")) {
			message = "系统接口" + message.substr(message.length - 3) + "异常";
		}
		window.ELEMENT.Message({
			message: message,
			type: 'error',
			duration: 5 * 1000
		})
		return Promise.reject(error)
	}
)

Interceptor拦截器

@Slf4j
@Component
public class LoginCheckFilter implements Filter {
    @Resource
    private LoginService loginService;
 
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        log.info("拦截到请求:{}",request.getRequestURI());
 
        String requestURI = request.getRequestURI();
 
//        chain.doFilter(request, response);
 
        // 放行无需登录的请求
        if (loginService.isIgnore(requestURI)) {
            log.info("{} 不需要登录", requestURI);
            chain.doFilter(request, response);
            return;
        }
 
        // 放行已登录用户
        if (loginService.isLogin(request)) {
            log.info("用户已登录,id={}",
            request.getSession().getAttribute("employee"));
            chain.doFilter(request, response);
            return;
        }
        
        // 未登录
        log.info("用户未登录");
 
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JsonUtil.toJson(Result.error("NOTLOGIN"))); // 注意要和前端的格式一样
 
    }
}
data class Result<T>(
    var code: Int? = null, // 1 成功,0 失败
    var msg: String? = null, // 必须和前端对应
    var data: T? = null,
    var map: MutableMap<String, Any> = HashMap()
) {
    companion object {
        @JvmStatic
        fun <T> success(data: T): Result<T> =
            Result(
                code = 1,
                data = data
            )
        @JvmStatic
        fun <T> error(errMsg: String): Result<T> =
            Result(
                code = 0,
                msg = errMsg
            )
    }
 
    fun add(msg: String, value: String): Result<T> {
        map[msg] = value
        return this
    }
}

全局异常捕获

异常处理

// Duplicate entry 'admin' for key 'employee.idx_username'
private val logger = LogFactory.logger<GlobalExceptionHandler>()
@ControllerAdvice(annotations = [RestController::class, Controller::class])
@ResponseBody
class GlobalExceptionHandler {
    @ExceptionHandler(SQLIntegrityConstraintViolationException::class)
    fun exceptionHandler(ex: SQLIntegrityConstraintViolationException):  Result<String> {
        logger.error(ex.message)
        if (ex.message!!.contains("Duplicate entry")){
            val username: String = ex.message!!.split(" ")[2]
            return Result.error<String>("username: $username exits")
        }
        return Result.error("Unknown exception")
    }
}

自定义序列化 Json

https://www.baeldung.com/spring-boot-customize-jackson-objectmapper

Another clean approach is to define a Jackson2ObjectMapperBuilder bean.
Spring Boot actually uses this builder by default when building the ObjectMapper and will automatically pick up the defined one:

@Bean
public Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder() {
    return new Jackson2ObjectMapperBuilder().serializers(LOCAL_DATETIME_SERIALIZER)
      .serializationInclusion(JsonInclude.Include.NON_NULL);
}

It will configure two options by default:

  • disable MapperFeature.DEFAULT_VIEW_INCLUSION
  • disable DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES

But Class Jackson2ObjectMapperBuilder is marked as deprecated since=7.0

By July, 2026, use JsonMapperBuilderCustomizer instead.

在 Spring Framework 7.0 / Spring Boot 4.0(适配 Jackson 3)的规范下,Jackson2ObjectMapperBuilderCustomizer 和 Jackson2ObjectMapperBuilder 均已被弃用。
新版推荐直接使用 JsonMapperBuilderCustomizer(或原生的 Jackson 3 JsonMapper.builder()),Kotlin 代码可以重构如下:

Long → String

@Configuration
class JacksonConfig {
    @Bean
    fun jsonMapperBuilderCustomizer(): JsonMapperBuilderCustomizer {
        return JsonMapperBuilderCustomizer { builder ->
            // 1. 创建 SimpleModule 注册自定义序列化器
            val longToStringModule = SimpleModule().apply {
                addSerializer(Long::class.javaObjectType, ToStringSerializer.instance)
                addSerializer(Long::class.javaPrimitiveType, ToStringSerializer.instance)
            }
            // 2. 将 Module 添加到 Jackson 3 Builder 中
            builder.addModule(longToStringModule)
        }
    }
}

MyBatis-Plus

代码生成器

https://baomidou.com/guides/new-code-generator/

implementation("com.baomidou:mybatis-plus-generator:3.5.17")
// 模板引擎
implementation("org.freemarker:freemarker:2.3.32")
// kotlin-reflect
implementation("org.jetbrains.kotlin:kotlin-reflect")
fun main() {
    // 1. 基础配置
    val url = "jdbc:mysql://localhost:3306/reggie?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true"
    val username = "root"
    val password = "YOUR PASASWORD"
    // 项目根目录
    val projectPath = Paths.get(System.getProperty("user.dir"))
    FastAutoGenerator.create(url, username, password)
        // 全局配置
        .globalConfig { builder ->
            builder.author("ferry") // 设置作者
                .enableKotlin()        // 开启 Kotlin 模式(关键!)
                .outputDir("$projectPath/src/main/kotlin") // 指定输出目录
                .disableOpenDir()      // 生成后不自动打开目录
        }
        // 包配置
        .packageConfig { builder ->
            builder.parent("org.example.reggieversionone") // 设置父包名
                .entity("entity")              // Entity 包名
                .mapper("mapper")              // Mapper 包名
                .service("service")            // Service 包名
                .serviceImpl("service.impl")   // ServiceImpl 包名
                .xml("mapper.xml")             // Mapper XML 包名
        }
        // 策略配置
        .strategyConfig { builder ->
            // 需要生成的表名(不传或用 * 表示全表)
            builder.addInclude("category")
                //.addTablePrefix() // 过滤表前缀
                .controllerBuilder().disable() // 关闭生成controller
                // Entity 策略
                .entityBuilder()
                .enableTableFieldAnnotation() // 生成 @TableField 注解
                .idType(IdType.AUTO)          // 主键策略
                //.enableLombok()               // 如果Kotlin中搭配Lombok或保持兼容
                // 注意:由于上面开启了 .enableKotlin(),MP 会自动将 Entity 输出为 Kotlin 的 data class
                // Mapper 策略
                .mapperBuilder()
                .enableBaseResultMap()  // 生成通用 Query ResultMap
                .enableBaseColumnList() // 生成通用 ColumnList
                // Service 策略
                .serviceBuilder()
                .formatServiceFileName("%sService") // 接口命名去掉默认的 I 前缀 (如 UserService)
                .formatServiceImplFileName("%sServiceImpl")
        }
        // 模板配置 (指定使用 Freemarker)
        .templateEngine(FreemarkerTemplateEngine())
        .execute()
}
  • allowPublicKeyRetrieval=true
  • caching_sha2_password: MySQL 8.0 changed its default password plugin from mysql_native_password to caching_sha2_password for better security.
  • The Conflict: Since you set useSSL=false, the JDBC driver can’t send your password over a secure TLS/SSL tunnel. To authenticate safely, the driver needs to request MySQL’s RSA public key to encrypt the password instead.
  • The Fix: Setting allowPublicKeyRetrieval=true explicitly grants the driver permission to fetch that public key over an unencrypted connection.

Auto-fill-field

https://baomidou.com/guides/auto-fill-field/

@Component
class MyMetaObjectHandler: MetaObjectHandler {
    private val log = LogFactory.logger<MyMetaObjectHandler>()
    override fun insertFill(metaObject: MetaObject?) {
        log.info("开始插入填充...")
        this.strictInsertFill(metaObject, "createTime", LocalDateTime::class.javaObjectType, LocalDateTime.now())
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime::class.javaObjectType, LocalDateTime.now())
//        this.strictInsertFill(metaObject,"updateUser", Long::class.javaObjectType, BaseContext.getCurrentId())
//        this.strictInsertFill(metaObject,"createUser", Long::class.javaObjectType, BaseContext.getCurrentId())
        this.setFieldValByName("createUser", BaseContext.getCurrentId(), metaObject)
        this.setFieldValByName("updateUser", BaseContext.getCurrentId(), metaObject)
    }
    override fun updateFill(metaObject: MetaObject?) {
        log.info("开始更新填充...")
        this.strictUpdateFill(metaObject,"updateUser", Long::class.javaObjectType, BaseContext.getCurrentId())
        this.strictUpdateFill(metaObject,"createUser", Long::class.javaObjectType, BaseContext.getCurrentId())
    }
}
  • strictInsertFill / strictUpdateFill 是 MyBatis-Plus 官方推荐的严格规范写法,拥有类型检查和判空保护,不会意外覆盖已有值。
  • setFieldValByName 是较老/通用的强制赋值方法,无论该字段当前是否有值,都会被直接覆盖。

Upload File

add path in the project. just for test

reggie:
  path: ${user.dir}/src/main/resources/images/
@Service
class FileServiceImpl: FileService {
    @Value($$"${reggie.path}")
    private lateinit var basePath: String
    override fun upload(file: MultipartFile) {
        val dir = File(basePath)
        if (!dir.exists()) dir.mkdirs()
        val originalFileName = file.originalFilename
        val suffix = originalFileName?.substring(originalFileName.lastIndexOf('.')) // .png or .jpg and so on...
        val fileName = UUID.randomUUID().toString() // random string name
        val path = "$basePath${fileName}${originalFileName?.substringBeforeLast('.')}$suffix"
        try {
            file.transferTo(File(path))
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

download file:

新增菜品逻辑

数据库的表dish

查看表结构

DESCRIBE dish
FieldTypeNullKeyDefaultExtra
idbigintNOPRInullauto_increment
namevarchar64NOUNInull
category_idbigintNOnull
pricedecimal10,2YESnull
codevarchar64NOnull
imagevarchar200NOnull
descriptionvarchar400YESnull
statusintNO1
sortintNO0
create_timedatetimeNOnull
update_timedatetimeNOnull
create_userbigintNOnull
update_userbigintNOnull
is_deletedintNO0
​
idnamecategory_idpricecodeimagedescriptionstatussortcreate_timeupdate_timecreate_userupdate_useris_deleted
1413342036832100354北冰洋1413341197421846529500.00c99e0aab-3cb7-4eaa-80fd-f47d4ffea694.png102021-07-09 11:39:352021-07-09 15:12:18110
1413384757047271425王老吉1413341197421846529500.0000874a5e-0df2-446b-8f69-a30eb7d88ee8.png102021-07-09 14:29:202021-07-12 09:09:16110
1413385247889891330米饭1413384954989060097200.00ee04a05a-1230-46b6-8ad5-1a95b140fff3.png102021-07-09 14:31:172021-07-11 16:35:26110
2083607448767238149解剖图14133849549890600971000.0038f5f0ad-76f5-40d5-a6c8-8b597c5099ea2026_07_30_02_25_24.jpgTEST102026-08-02 01:41:322026-08-02 01:41:32110

dish_flavor

iddish_idnamevaluecreate_timeupdate_timecreate_userupdate_useris_deleted
14133895405922631691413384757047271425温度[“常温”,“冷藏”]2021-07-12 09:09:162021-07-12 09:09:16110
14133896840206827541413342036832100354温度[“常温”,“冷藏”]2021-07-09 15:12:182021-07-09 15:12:18110
20836090844283453452083607448767238149甜味[“无糖”,“少糖”,“半糖”,“多糖”,“全糖”]2026-08-02 01:41:322026-08-02 01:41:32110

前端发送 JSON

{
  "name": "解剖图",
  "price": 1000,
  "code": "",
  "image": "38f5f0ad-76f5-40d5-a6c8-8b597c5099ea2026_07_30_02_25_24.jpg",
  "description": "TEST",
  "status": 1,
  "categoryId": "1413384954989060097",
  "flavors": [
    {
      "name": "甜味",
      "value": "[\"无糖\",\"少糖\",\"半糖\",\"多糖\",\"全糖\"]",
      "showOption": false
    }
  ]
}

DTO

@TableName("dish")
data class Dish(
    @TableId(value = "id", type = IdType.AUTO)
    var id: Long? = null,
    var name: String? = null,
    var categoryId: Long? = null,
    var price: BigDecimal? = null,
    var code: String? = null,
    var image: String? = null,
    var description: String? = null,
    var status: Int? = null,
    var sort: Int? = null,
    @TableField("create_time", fill = FieldFill.INSERT)
    var createTime: LocalDateTime? = null,
    @TableField("update_time", fill = FieldFill.INSERT_UPDATE)
    var updateTime: LocalDateTime? = null,
    @TableField("create_user", fill = FieldFill.INSERT)
    var createUser: Long? = null,
    @TableField("update_user", fill = FieldFill.INSERT_UPDATE)
    var updateUser: Long? = null,
    var isDeleted: Int? = null,
) : Serializable
data class DishDto(
    val copies: Int?=null,
    val flavors: MutableList<DishFlavor>,
    
    @TableId(value = "id", type = IdType.AUTO)
    var id: Long? = null,
    var name: String? = null,
    var categoryId: Long? = null,
    var price: BigDecimal? = null,
    var code: String? = null,
    var image: String? = null,
    var description: String? = null,
    var status: Int? = null,
    var sort: Int? = null,
    var createTime: LocalDateTime? = null,
    var updateTime: LocalDateTime? = null,
    var createUser: Long? = null,
    var updateUser: Long? = null,
    var isDeleted: Int? = null,
)

业务逻辑

@RestController
@RequestMapping("/dish")
public class DishController {
    @Resource
    private DishService dishService;
    @PostMapping
    public Result<String> save(@RequestBody DishDto dishDto) {
        dishService.saveWithFlavor(dishDto);
        return Result.success("新增菜品成功");
    }
}

pasted_1785614499494_w3w85e.png

@Service
class DishServiceImpl(
    private val dishFlavorService: DishFlavorService,
) : ServiceImpl<DishMapper, Dish>(), DishService {
    @Transactional
    override fun saveWithFlavor(dto: DishDto) {
        // DTO -> Entity
        val dish = Dish().apply {
            BeanUtils.copyProperties(dto, this)
        }
        save(dish)
        val dishId = dish.id
        var flavors = dto.flavors
        flavors = flavors.onEach {
            it.dishId = dishId
        }
        dishFlavorService.saveBatch(flavors)
    }
}

Copy 属性后
pasted_1785614664029_viso9z.png

save() → 执行sql → 插入dish表

idnamecategory_idpricecodeimagedescriptionstatussortcreate_timeupdate_timecreate_userupdate_useris_deleted
2083607448767238149解剖图14133849549890600971000.0038f5f0ad-76f5-40d5-a6c8-8b597c5099ea2026_07_30_02_25_24.jpgTEST102026-08-02 01:41:322026-08-02 01:41:32110
val dishId = dish.id
var flavors = dto.flavors
flavors = flavors.onEach {
		it.dishId = dishId
}

把每个dishId都分给 flavor数组里的 dishId

iddish_idnamevaluecreate_timeupdate_timecreate_userupdate_useris_deleted
20836090844283453452083607448767238149甜味[“无糖”,“少糖”,“半糖”,“多糖”,“全糖”]2026-08-02 01:41:322026-08-02 01:41:32110