Compare commits

...

4 Commits

Author SHA1 Message Date
smallchill
df67adcf3e 🎉 5.0.1.RELEASE 增强角色判定与角色分配归属校验,建立全 TS 工程化底座
Some checks failed
Publish Snapshot / publish (push) Has been cancelled
Java CI / build on java17 (push) Has been cancelled
2026-08-11 21:39:37 +08:00
smallchill
583fd83b88 🐛 修复Blade时间模块与官方模块标识冲突导致Instant等类型序列化失败 2026-08-11 21:31:51 +08:00
smallchill
9bceec2067 🔒 角色判定改为精确匹配,租户守卫增强超管新增归属绑定与批量ID去重 2026-08-11 19:30:40 +08:00
smallchill
3d93b3d587 代码生成器前端模板升级 TS + Composition API 产出,增强实体租户与主键序列化判定 2026-08-07 16:24:46 +08:00
21 changed files with 453 additions and 93 deletions

View File

@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Release-V5.0.0-blue.svg" alt="Downloads"> <img src="https://img.shields.io/badge/Release-V5.0.1-blue.svg" alt="Downloads">
<img src="https://img.shields.io/badge/JDK-21+-green.svg" alt="Build Status"> <img src="https://img.shields.io/badge/JDK-21+-green.svg" alt="Build Status">
<img src="https://img.shields.io/badge/license-LGPL%20v3-blue.svg" alt="Build Status"> <img src="https://img.shields.io/badge/license-LGPL%20v3-blue.svg" alt="Build Status">
<img src="https://img.shields.io/badge/Spring%20Cloud-2025.1-blue.svg" alt="Coverage Status"> <img src="https://img.shields.io/badge/Spring%20Cloud-2025.1-blue.svg" alt="Coverage Status">

View File

@ -94,7 +94,7 @@ mybatis-plus:
swagger: swagger:
title: SpringBlade 接口文档系统 title: SpringBlade 接口文档系统
description: SpringBlade 接口文档系统 description: SpringBlade 接口文档系统
version: 5.0.0 version: 5.0.1
license: Powered By SpringBlade license: Powered By SpringBlade
licenseUrl: https://bladex.vip licenseUrl: https://bladex.vip
terms-of-service-url: https://bladex.vip terms-of-service-url: https://bladex.vip

View File

@ -41,6 +41,16 @@
<groupId>org.springframework.cloud</groupId> <groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId> <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency> </dependency>
<!--
Jackson 3 库本体。Spring Cloud 与 Sentinel 的多个组件已按「Jackson 3 在场」编写条件装配,
缺席时的表现从静默不注册Sentinel 规则数据源转换器到容器启动失败Feign 分页模型扩展、
Sentinel 熔断规则刷新监听器)不等,且都不属于本工程可修的范围。此处只引入库,
不引入 spring-boot-jackson 自动配置模块,序列化管线仍由 Jackson 2 承载,机制见父 POM 的门禁注释。
-->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Admin --> <!-- Admin -->
<dependency> <dependency>
<groupId>de.codecentric</groupId> <groupId>de.codecentric</groupId>

View File

@ -174,7 +174,7 @@ public class SecureUtil {
* @return boolean * @return boolean
*/ */
public static boolean isAdministrator() { public static boolean isAdministrator() {
return StringUtil.containsAny(getUserRole(), RoleConstant.ADMINISTRATOR); return hasRole(RoleConstant.ADMINISTRATOR);
} }
/** /**
@ -183,7 +183,17 @@ public class SecureUtil {
* @return boolean * @return boolean
*/ */
public static boolean isAdmin() { public static boolean isAdmin() {
return StringUtil.containsAny(getUserRole(), RoleConstant.ADMIN); return hasRole(RoleConstant.ADMIN);
}
/**
* 判定当前会话是否持有指定角色
*
* @param role 角色别名
* @return boolean
*/
private static boolean hasRole(String role) {
return CollectionUtil.contains(Func.toStrArray(getUserRole()), role);
} }
/** /**

View File

@ -16,6 +16,7 @@
package org.springblade.core.tool.jackson; package org.springblade.core.tool.jackson;
import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.PackageVersion; import com.fasterxml.jackson.datatype.jsr310.PackageVersion;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
@ -32,12 +33,18 @@ import java.time.LocalTime;
/** /**
* java 8 时间默认序列化 * java 8 时间默认序列化
* *
* <p>
* 仅覆盖 LocalDateTimeLocalDateLocalTime 的格式其余类型由官方 {@link JavaTimeModule} 处理
* 模块名须显式声明SimpleModule Version artifactId 为模块标识沿用 jsr310 PackageVersion
* 会与官方模块同名被去重逻辑判为重复注册而互相顶替
* </p>
*
* @author L.cm * @author L.cm
*/ */
public class BladeJavaTimeModule extends SimpleModule { public class BladeJavaTimeModule extends SimpleModule {
public BladeJavaTimeModule() { public BladeJavaTimeModule() {
super(PackageVersion.VERSION); super("BladeJavaTimeModule", PackageVersion.VERSION);
this.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeUtil.DATETIME_FORMAT)); this.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeUtil.DATETIME_FORMAT));
this.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeUtil.DATE_FORMAT)); this.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeUtil.DATE_FORMAT));
this.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeUtil.TIME_FORMAT)); this.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeUtil.TIME_FORMAT));

View File

@ -979,12 +979,12 @@ public class JsonUtil {
super.setTimeZone(TimeZone.getTimeZone(ZoneId.systemDefault())); super.setTimeZone(TimeZone.getTimeZone(ZoneId.systemDefault()));
//序列化时日期的统一格式 //序列化时日期的统一格式
super.setDateFormat(new SimpleDateFormat(DateUtil.PATTERN_DATETIME, Locale.CHINA)); super.setDateFormat(new SimpleDateFormat(DateUtil.PATTERN_DATETIME, Locale.CHINA));
//日期格式化
super.registerModule(new BladeJavaTimeModule());
//视图注解内省器 Jackson 识别 @BladeView //视图注解内省器 Jackson 识别 @BladeView
super.setAnnotationIntrospector(new BladeViewAnnotationIntrospector()); super.setAnnotationIntrospector(new BladeViewAnnotationIntrospector());
//注册模块 //注册模块
super.findAndRegisterModules(); super.findAndRegisterModules();
//日期格式化须后于官方模块注册以覆盖其格式
super.registerModule(new BladeJavaTimeModule());
} }
@Override @Override

View File

@ -60,7 +60,7 @@ public class CodeGenerator {
/** /**
* 基础业务字段 * 基础业务字段
*/ */
public static String[] SUPER_ENTITY_COLUMNS = {"create_time", "create_user", "update_time", "update_user", "status", "is_deleted"}; public static String[] SUPER_ENTITY_COLUMNS = {"create_time", "create_user", "create_dept", "update_time", "update_user", "status", "is_deleted"};
/** /**
* 是否包含包装器 * 是否包含包装器
*/ */

View File

@ -15,11 +15,13 @@
*/ */
package org.springblade.develop.support; package org.springblade.develop.support;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.StringPool; import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile; import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.config.builder.CustomFile; import com.baomidou.mybatisplus.generator.config.builder.CustomFile;
import com.baomidou.mybatisplus.generator.config.builder.Entity;
import com.baomidou.mybatisplus.generator.config.po.TableInfo; import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.DateType; import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy; import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
@ -148,25 +150,33 @@ public class BladeCodeGenerator {
customFile.put("wrapper.java", "/templates/api/wrapper.java.vm"); customFile.put("wrapper.java", "/templates/api/wrapper.java.vm");
} }
if (Func.isNotBlank(packageWebDir)) { if (Func.isNotBlank(packageWebDir)) {
customFile.put("api.js", "/templates/saber/api.js.vm"); customFile.put("api.ts", "/templates/saber/api.ts.vm");
customFile.put("crud.vue", "/templates/saber/crud.vue.vm"); customFile.put("crud.vue", "/templates/saber/crud.vue.vm");
} }
// 无基础业务字段时实体不再继承 BaseEntity服务层须同步降级为 MyBatis-Plus 原生接口3.5.17 起位于 spring
String superServiceClass = hasSuperEntity ? "org.springblade.core.mp.base.BaseService" : "com.baomidou.mybatisplus.spring.service.IService";
String superServiceImplClass = hasSuperEntity ? "org.springblade.core.mp.base.BaseServiceImpl" : "com.baomidou.mybatisplus.spring.service.impl.ServiceImpl";
FastAutoGenerator.create(url, username, password) FastAutoGenerator.create(url, username, password)
.globalConfig(builder -> builder.author(props.getProperty("author")).dateType(DateType.TIME_PACK).enableSwagger().outputDir(getOutputDir()).disableOpenDir()) .globalConfig(builder -> builder.author(props.getProperty("author")).dateType(DateType.TIME_PACK).enableSwagger().outputDir(getOutputDir()).disableOpenDir())
.packageConfig(builder -> builder.parent(packageName).controller("controller").entity("pojo.entity").service("service").serviceImpl("service.impl").mapper("mapper").xml("mapper")) .packageConfig(builder -> builder.parent(packageName).controller("controller").entity("pojo.entity").service("service").serviceImpl("service.impl").mapper("mapper").xml("mapper"))
.strategyConfig(builder -> builder.addTablePrefix(tablePrefix).addInclude(includeTables).addExclude(excludeTables) .strategyConfig(builder -> {
.entityBuilder().naming(NamingStrategy.underline_to_camel).columnNaming(NamingStrategy.underline_to_camel).enableLombok().superClass("org.springblade.core.mp.base.BaseEntity").addSuperEntityColumns(superEntityColumns).enableFileOverride() Entity.Builder entityBuilder = builder.addTablePrefix(tablePrefix).addInclude(includeTables).addExclude(excludeTables)
.javaTemplate("/templates/api/entity.java.vm") .entityBuilder().naming(NamingStrategy.underline_to_camel).columnNaming(NamingStrategy.underline_to_camel).enableLombok().idType(IdType.ASSIGN_ID).enableFileOverride()
.serviceBuilder().superServiceClass("org.springblade.core.mp.base.BaseService").superServiceImplClass("org.springblade.core.mp.base.BaseServiceImpl").formatServiceFileName("I%sService").formatServiceImplFileName("%sServiceImpl").enableFileOverride() .javaTemplate("/templates/api/entity.java.vm");
if (hasSuperEntity) {
entityBuilder.superClass("org.springblade.core.mp.base.BaseEntity").addSuperEntityColumns(superEntityColumns);
}
entityBuilder.serviceBuilder().superServiceClass(superServiceClass).superServiceImplClass(superServiceImplClass).formatServiceFileName("I%sService").formatServiceImplFileName("%sServiceImpl").enableFileOverride()
.serviceTemplate("/templates/api/service.java.vm") .serviceTemplate("/templates/api/service.java.vm")
.serviceImplTemplate("/templates/api/serviceImpl.java.vm") .serviceImplTemplate("/templates/api/serviceImpl.java.vm")
.mapperBuilder().mapperAnnotation(Mapper.class).enableBaseResultMap().enableBaseColumnList().formatMapperFileName("%sMapper").formatXmlFileName("%sMapper").enableFileOverride() .mapperBuilder().mapperAnnotation(Mapper.class).enableBaseResultMap().enableBaseColumnList().formatMapperFileName("%sMapper").formatXmlFileName("%sMapper").enableFileOverride()
.mapperTemplate("/templates/api/mapper.java.vm") .mapperTemplate("/templates/api/mapper.java.vm")
.mapperXmlTemplate("/templates/api/mapper.xml.vm") .mapperXmlTemplate("/templates/api/mapper.xml.vm")
.controllerBuilder().superClass("org.springblade.core.boot.ctrl.BladeController").formatFileName("%sController").enableRestStyle().enableHyphenStyle().enableFileOverride() .controllerBuilder().superClass("org.springblade.core.boot.ctrl.BladeController").formatFileName("%sController").enableRestStyle().enableHyphenStyle().enableFileOverride()
.template("/templates/api/controller.java.vm") .template("/templates/api/controller.java.vm");
) })
.injectionConfig(builder -> builder.beforeOutputFile( .injectionConfig(builder -> builder.beforeOutputFile(
(tableInfo, objectMap) -> System.out.println("tableInfo: " + tableInfo.getEntityName() + " objectMap: " + objectMap.size()) (tableInfo, objectMap) -> System.out.println("tableInfo: " + tableInfo.getEntityName() + " objectMap: " + objectMap.size())
).customMap(customMap).customFile(customFile) ).customMap(customMap).customFile(customFile)
@ -202,8 +212,8 @@ public class BladeCodeGenerator {
outputPath = getOutputDir() + StringPool.SLASH + packageName.replace(StringPool.DOT, StringPool.SLASH) + StringPool.SLASH + "wrapper" + StringPool.SLASH + entityName + "Wrapper" + StringPool.DOT_JAVA; outputPath = getOutputDir() + StringPool.SLASH + packageName.replace(StringPool.DOT, StringPool.SLASH) + StringPool.SLASH + "wrapper" + StringPool.SLASH + entityName + "Wrapper" + StringPool.DOT_JAVA;
} }
if (StringUtil.equals(key, "api.js")) { if (StringUtil.equals(key, "api.ts")) {
outputPath = getOutputWebDir() + StringPool.SLASH + "api" + StringPool.SLASH + servicePackage.toLowerCase() + StringPool.SLASH + entityNameLower + ".js"; outputPath = getOutputWebDir() + StringPool.SLASH + "api" + StringPool.SLASH + servicePackage.toLowerCase() + StringPool.SLASH + entityNameLower + ".ts";
} }
if (StringUtil.equals(key, "crud.vue")) { if (StringUtil.equals(key, "crud.vue")) {

View File

@ -1,11 +1,11 @@
/** /**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com). * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p> * <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* <p> * <p>
* http://www.gnu.org/licenses/lgpl.html * http://www.apache.org/licenses/LICENSE-2.0
* <p> * <p>
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
@ -15,35 +15,30 @@
*/ */
package $!{package.Controller}; package $!{package.Controller};
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import jakarta.validation.Valid; #if($!{superControllerClassPackage})
import $!{superControllerClassPackage};
#end
import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query; import org.springblade.core.mp.support.Query;
import org.springblade.core.swagger.annotation.ApiOrder; import org.springblade.core.swagger.annotation.ApiOrder;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springframework.web.bind.annotation.*;
#if($!{superEntityClass})
import org.springframework.web.bind.annotation.RequestParam;
#end
import com.baomidou.mybatisplus.core.metadata.IPage;
import $!{package.Entity}.$!{entity}; import $!{package.Entity}.$!{entity};
#set($voPackage=$package.Entity.replace("entity","vo")) #set($voPackage=$package.Entity.replace("entity","vo"))
import $!{voPackage}.$!{entity}VO; import $!{voPackage}.$!{entity}VO;
#set($wrapperPackage=$package.Entity.replace("pojo.entity","wrapper"))
#if($!{hasWrapper}) #if($!{hasWrapper})
#set($wrapperPackage=$package.Entity.replace("pojo.entity","wrapper"))
import $!{wrapperPackage}.$!{entity}Wrapper; import $!{wrapperPackage}.$!{entity}Wrapper;
#end #end
import $!{package.Service}.$!{table.serviceName}; import $!{package.Service}.$!{table.serviceName};
#if($!{superControllerClassPackage}) import org.springframework.web.bind.annotation.*;
import $!{superControllerClassPackage};
#end import jakarta.validation.Valid;
#if(!$!{superEntityClass})
#end
/** /**
* $!{table.comment} 控制器 * $!{table.comment} 控制器
@ -84,7 +79,6 @@ public class $!{table.controllerName} {
IPage<$!{entity}> pages = $!{table.entityPath}Service.page(Condition.getPage(query), Condition.getQueryWrapper($!{table.entityPath})); IPage<$!{entity}> pages = $!{table.entityPath}Service.page(Condition.getPage(query), Condition.getQueryWrapper($!{table.entityPath}));
return R.data($!{entity}Wrapper.build().pageVO(pages)); return R.data($!{entity}Wrapper.build().pageVO(pages));
} }
#else #else
/** /**
* 详情 * 详情
@ -144,8 +138,7 @@ public class $!{table.controllerName} {
return R.status($!{table.entityPath}Service.saveOrUpdate($!{table.entityPath})); return R.status($!{table.entityPath}Service.saveOrUpdate($!{table.entityPath}));
} }
#if($!{superEntityClass}) #if($!{superEntityClass})
/** /**
* 删除 $!{table.comment} * 删除 $!{table.comment}
*/ */
@ -155,8 +148,7 @@ public class $!{table.controllerName} {
return R.status($!{table.entityPath}Service.deleteLogic(Func.toLongList(ids))); return R.status($!{table.entityPath}Service.deleteLogic(Func.toLongList(ids)));
} }
#else #else
/** /**
* 删除 $!{table.comment} * 删除 $!{table.comment}
*/ */
@ -166,6 +158,5 @@ public class $!{table.controllerName} {
return R.status($!{table.entityPath}Service.removeByIds(Func.toLongList(ids))); return R.status($!{table.entityPath}Service.removeByIds(Func.toLongList(ids)));
} }
#end #end
} }

View File

@ -1,3 +1,21 @@
#set($hasTenant = false)
#set($hasKeyField = false)
#set($hasLongKey = false)
#foreach($field in $!{table.fields})
#if($!{field.name} == $!{tenantColumn})
#set($hasTenant = true)
#end
#if($!{field.keyFlag})
#set($hasKeyField = true)
#end
#if($!{field.keyFlag} && $!{field.propertyType} == "Long")
#set($hasLongKey = true)
#end
#end
#set($useTenantEntity = false)
#if($hasTenant && "$!superEntityClass" == "BaseEntity")
#set($useTenantEntity = true)
#end
/** /**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com). * Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p> * <p>
@ -16,8 +34,20 @@
package $!{package.Entity}; package $!{package.Entity};
#foreach($pkg in $!{table.importPackages}) #foreach($pkg in $!{table.importPackages})
#if($useTenantEntity && $!{pkg} == "org.springblade.core.mp.base.BaseEntity")
import org.springblade.core.mp.base.TenantEntity;
#elseif($!{pkg} == "java.io.Serializable" && $!{superEntityClass} && !$!{activeRecord})
## 继承父类时不直接实现 Serializable跳过强制注入的导入
#elseif(!$hasKeyField && ($!{pkg} == "com.baomidou.mybatisplus.annotation.IdType" || $!{pkg} == "com.baomidou.mybatisplus.annotation.TableId"))
## 主键列归入公共字段时实体内不产出主键注解,跳过全局 idType 注入的导入
#else
import $!{pkg}; import $!{pkg};
#end #end
#end
#if($hasLongKey)
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
#end
#if($!{entityLombokModel}) #if($!{entityLombokModel})
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@ -46,7 +76,7 @@ import java.io.Serial;
@Schema(description = #if ("$!{table.comment}"=="")"$!{entity}对象"#else"$!{table.comment}"#end) @Schema(description = #if ("$!{table.comment}"=="")"$!{entity}对象"#else"$!{table.comment}"#end)
#end #end
#if($!{superEntityClass}) #if($!{superEntityClass})
public class $!{entity} extends $!{superEntityClass}#if($!{activeRecord})<$!{entity}>#end { public class $!{entity} extends #if($useTenantEntity)TenantEntity#{else}$!{superEntityClass}#end#if($!{activeRecord})<$!{entity}>#end {
#elseif($!{activeRecord}) #elseif($!{activeRecord})
@Accessors(chain = true) @Accessors(chain = true)
public class $!{entity} extends Model<$!{entity}> { public class $!{entity} extends Model<$!{entity}> {
@ -59,7 +89,7 @@ public class $!{entity} implements Serializable {
## ---------- BEGIN 字段循环遍历 ---------- ## ---------- BEGIN 字段循环遍历 ----------
#foreach($field in $!{table.fields}) #foreach($field in $!{table.fields})
#if($!{field.name}!=$!{tenantColumn}) #if($!{field.name} != $!{tenantColumn} || !$useTenantEntity)
#if($!{field.keyFlag}) #if($!{field.keyFlag})
#set($keyPropertyName=$!{field.propertyName}) #set($keyPropertyName=$!{field.propertyName})
#end #end
@ -80,6 +110,9 @@ public class $!{entity} implements Serializable {
#elseif($!{field.convert}) #elseif($!{field.convert})
@TableId("$!{field.name}") @TableId("$!{field.name}")
#end #end
#if($!{field.propertyType} == "Long")
@JsonSerialize(using = ToStringSerializer.class)
#end
## 普通字段 ## 普通字段
#elseif($!{field.fill}) #elseif($!{field.fill})
## ----- 存在字段填充设置 ----- ## ----- 存在字段填充设置 -----

View File

@ -15,11 +15,12 @@
*/ */
package $!{package.Mapper}; package $!{package.Mapper};
import $!{superMapperClassPackage};
import com.baomidou.mybatisplus.core.metadata.IPage;
import $!{package.Entity}.$!{entity}; import $!{package.Entity}.$!{entity};
#set($voPackage=$package.Entity.replace("entity","vo")) #set($voPackage=$package.Entity.replace("entity","vo"))
import $!{voPackage}.$!{entity}VO; import $!{voPackage}.$!{entity}VO;
import $!{superMapperClassPackage};
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.util.List; import java.util.List;
/** /**

View File

@ -1,3 +1,13 @@
#set($hasTenant = false)
#foreach($field in $!{table.fields})
#if($!{field.name} == $!{tenantColumn})
#set($hasTenant = true)
#end
#end
#set($useTenantEntity = false)
#if($hasTenant && "$!superEntityClass" == "BaseEntity")
#set($useTenantEntity = true)
#end
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="$!{package.Mapper}.$!{table.mapperName}"> <mapper namespace="$!{package.Mapper}.$!{table.mapperName}">
@ -19,7 +29,7 @@
<result column="$!{field.name}" property="$!{field.propertyName}"/> <result column="$!{field.name}" property="$!{field.propertyName}"/>
#end #end
#foreach($field in $!{table.fields}) #foreach($field in $!{table.fields})
#if(!$!{field.keyFlag} && $!{field.name} != $!{tenantColumn})##生成普通字段 #if(!$!{field.keyFlag} && ($!{field.name} != $!{tenantColumn} || !$useTenantEntity))##生成普通字段
<result column="$!{field.name}" property="$!{field.propertyName}"/> <result column="$!{field.name}" property="$!{field.propertyName}"/>
#end #end
#end #end
@ -28,7 +38,11 @@
#end #end
<select id="select$!{entity}Page" resultMap="$!{table.entityPath}ResultMap"> <select id="select$!{entity}Page" resultMap="$!{table.entityPath}ResultMap">
#if($!{superEntityClass})
select * from $!{table.name} where is_deleted = 0 select * from $!{table.name} where is_deleted = 0
#else
select * from $!{table.name}
#end
</select> </select>
</mapper> </mapper>

View File

@ -15,11 +15,11 @@
*/ */
package $!{package.Service}; package $!{package.Service};
import com.baomidou.mybatisplus.core.metadata.IPage;
import $!{superServiceClassPackage};
import $!{package.Entity}.$!{entity}; import $!{package.Entity}.$!{entity};
#set($voPackage=$package.Entity.replace("entity","vo")) #set($voPackage=$package.Entity.replace("entity","vo"))
import $!{voPackage}.$!{entity}VO; import $!{voPackage}.$!{entity}VO;
import $!{superServiceClassPackage};
import com.baomidou.mybatisplus.core.metadata.IPage;
/** /**
* $!{table.comment} 服务类 * $!{table.comment} 服务类

View File

@ -15,14 +15,14 @@
*/ */
package $!{package.ServiceImpl}; package $!{package.ServiceImpl};
import com.baomidou.mybatisplus.core.metadata.IPage;
import $!{superServiceImplClassPackage};
import $!{package.Entity}.$!{entity}; import $!{package.Entity}.$!{entity};
#set($voPackage=$package.Entity.replace("entity","vo")) #set($voPackage=$package.Entity.replace("entity","vo"))
import $!{voPackage}.$!{entity}VO; import $!{voPackage}.$!{entity}VO;
import $!{package.Mapper}.$!{table.mapperName}; import $!{package.Mapper}.$!{table.mapperName};
import $!{package.Service}.$!{table.serviceName}; import $!{package.Service}.$!{table.serviceName};
import $!{superServiceImplClassPackage};
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.metadata.IPage;
/** /**
* $!{table.comment} 服务实现类 * $!{table.comment} 服务实现类

View File

@ -16,7 +16,6 @@
#set($wrapperPackage=$package.Entity.replace("pojo.entity","wrapper")) #set($wrapperPackage=$package.Entity.replace("pojo.entity","wrapper"))
package $!{wrapperPackage}; package $!{wrapperPackage};
import lombok.AllArgsConstructor;
import org.springblade.core.mp.support.BaseEntityWrapper; import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.BeanUtil;
import $!{package.Entity}.$!{entity}; import $!{package.Entity}.$!{entity};

View File

@ -1,14 +1,23 @@
#set($hasTenant = false)
#foreach($field in $!{table.fields})
#if($!{field.name} == $!{tenantColumn})
#set($hasTenant = true)
#end
#end
#set($useTenantEntity = false)
#if($hasTenant && "$!superEntityClass" == "BaseEntity")
#set($useTenantEntity = true)
#end
<template> <template>
<basic-container> <basic-container>
<avue-crud :option="option" <avue-crud :option="option"
:table-loading="loading" :table-loading="loading"
:data="data" :data="data"
ref="crudRef" :page="page"
v-model="form"
v-model:search="search"
v-model:page="page"
:permission="permissionList" :permission="permissionList"
:before-open="beforeOpen" :before-open="beforeOpen"
v-model="form"
ref="crudRef"
@row-update="rowUpdate" @row-update="rowUpdate"
@row-save="rowSave" @row-save="rowSave"
@row-del="rowDel" @row-del="rowDel"
@ -17,6 +26,7 @@
@selection-change="selectionChange" @selection-change="selectionChange"
@current-change="currentChange" @current-change="currentChange"
@size-change="sizeChange" @size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"> @on-load="onLoad">
<template #menu-left> <template #menu-left>
<el-button type="danger" <el-button type="danger"
@ -41,8 +51,8 @@ import { validData } from '@/utils/util';
interface $!{entity}Entity { interface $!{entity}Entity {
id: string; id: string;
#foreach($field in $!{table.fields}) #foreach($field in $!{table.fields})
#if($!{field.name}!=$!{tenantColumn}) #if($!{field.name} != $!{tenantColumn} || !$useTenantEntity)
#if($!{field.propertyName} != "id") #if(!$!{field.keyFlag})
#if($!{field.propertyType} == "Integer" || $!{field.propertyType} == "Long" || $!{field.propertyType} == "Short" || $!{field.propertyType} == "Byte" || $!{field.propertyType} == "Double" || $!{field.propertyType} == "Float" || $!{field.propertyType} == "BigDecimal") #if($!{field.propertyType} == "Integer" || $!{field.propertyType} == "Long" || $!{field.propertyType} == "Short" || $!{field.propertyType} == "Byte" || $!{field.propertyType} == "Double" || $!{field.propertyType} == "Float" || $!{field.propertyType} == "BigDecimal")
$!{field.propertyName}?: number; $!{field.propertyName}?: number;
#elseif($!{field.propertyType} == "Boolean") #elseif($!{field.propertyType} == "Boolean")
@ -65,14 +75,13 @@ const permission = computed(() => store.getters.permission);
// 表格实例与数据状态 // 表格实例与数据状态
const crudRef = ref(); const crudRef = ref();
const form = ref<$!{entity}Form>({}); const form = ref<$!{entity}Form>({});
const search = ref<Partial<$!{entity}Entity>>({});
const data = ref<$!{entity}Entity[]>([]); const data = ref<$!{entity}Entity[]>([]);
const selectionList = ref<$!{entity}Entity[]>([]); const selectionList = ref<$!{entity}Entity[]>([]);
const query = ref<Partial<$!{entity}Entity>>({}); const query = ref<Partial<$!{entity}Entity>>({});
const loading = ref(true); const loading = ref(true);
// 分页参数(整体替换,需用 ref // 分页参数
const page = ref({ const page = reactive({
pageSize: 10, pageSize: 10,
currentPage: 1, currentPage: 1,
total: 0, total: 0,
@ -85,16 +94,17 @@ const ids = computed(() => selectionList.value.map(ele => ele.id).join(','));
const option = reactive({ const option = reactive({
height: 'auto', height: 'auto',
calcHeight: 210, calcHeight: 210,
tip: false,
searchShow: true, searchShow: true,
searchMenuSpan: 6, searchMenuSpan: 6,
tip: false,
border: true, border: true,
index: true, index: true,
viewBtn: true, viewBtn: true,
selection: true, selection: true,
column: [ column: [
#foreach($field in $!{table.fields}) #foreach($field in $!{table.fields})
#if($!{field.name}!=$!{tenantColumn}) #if($!{field.name} != $!{tenantColumn} || !$useTenantEntity)
#if(!$!{field.keyFlag})
{ {
label: '$!{field.comment}', label: '$!{field.comment}',
prop: '$!{field.propertyName}', prop: '$!{field.propertyName}',
@ -105,6 +115,7 @@ const option = reactive({
}], }],
}, },
#end #end
#end
#end #end
], ],
}); });
@ -121,9 +132,9 @@ const permissionList = computed(() => ({
const onLoad = (pageData: { currentPage: number; pageSize: number }, params: Partial<$!{entity}Entity> = {}) => { const onLoad = (pageData: { currentPage: number; pageSize: number }, params: Partial<$!{entity}Entity> = {}) => {
loading.value = true; loading.value = true;
getList(pageData.currentPage, pageData.pageSize, Object.assign(params, query.value)).then(res => { getList(pageData.currentPage, pageData.pageSize, Object.assign(params, query.value)).then(res => {
const list = res.data.data; const listData = res.data.data;
page.value.total = list.total; page.total = listData.total;
data.value = list.records; data.value = listData.records;
loading.value = false; loading.value = false;
selectionClear(); selectionClear();
}); });
@ -132,25 +143,30 @@ const onLoad = (pageData: { currentPage: number; pageSize: number }, params: Par
// 条件检索 // 条件检索
const searchChange = (params: Partial<$!{entity}Entity>, done: () => void) => { const searchChange = (params: Partial<$!{entity}Entity>, done: () => void) => {
query.value = params; query.value = params;
page.value.currentPage = 1; page.currentPage = 1;
onLoad(page.value, params); onLoad(page, params);
done(); done();
}; };
// 重置检索条件 // 重置检索条件
const searchReset = () => { const searchReset = () => {
query.value = {}; query.value = {};
onLoad(page.value); onLoad(page);
}; };
// 切换页码 // 切换页码
const currentChange = (currentPage: number) => { const currentChange = (currentPage: number) => {
page.value.currentPage = currentPage; page.currentPage = currentPage;
}; };
// 调整每页条数 // 调整每页条数
const sizeChange = (pageSize: number) => { const sizeChange = (pageSize: number) => {
page.value.pageSize = pageSize; page.pageSize = pageSize;
};
// 刷新当前列表
const refreshChange = () => {
onLoad(page, query.value);
}; };
// 记录当前选中行 // 记录当前选中行
@ -168,7 +184,7 @@ const selectionClear = () => {
const rowSave = (row: $!{entity}Form, done: () => void, loading: () => void) => { const rowSave = (row: $!{entity}Form, done: () => void, loading: () => void) => {
add(row).then(() => { add(row).then(() => {
done(); done();
onLoad(page.value); onLoad(page);
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: '操作成功!', message: '操作成功!',
@ -183,7 +199,7 @@ const rowSave = (row: $!{entity}Form, done: () => void, loading: () => void) =>
const rowUpdate = (row: $!{entity}Form, index: number, done: () => void, loading: () => void) => { const rowUpdate = (row: $!{entity}Form, index: number, done: () => void, loading: () => void) => {
update(row).then(() => { update(row).then(() => {
done(); done();
onLoad(page.value); onLoad(page);
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: '操作成功!', message: '操作成功!',
@ -205,7 +221,7 @@ const rowDel = (row: $!{entity}Entity) => {
return remove(row.id); return remove(row.id);
}) })
.then(() => { .then(() => {
onLoad(page.value); onLoad(page);
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: '操作成功!', message: '操作成功!',
@ -228,7 +244,7 @@ const handleDelete = () => {
return remove(ids.value); return remove(ids.value);
}) })
.then(() => { .then(() => {
onLoad(page.value); onLoad(page);
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: '操作成功!', message: '操作成功!',
@ -237,7 +253,7 @@ const handleDelete = () => {
}); });
}; };
// 打开弹窗前置处理:编辑与查看回填详情 // 打开弹窗前加载编辑/查看详情
const beforeOpen = (done: () => void, type: string) => { const beforeOpen = (done: () => void, type: string) => {
if (['edit', 'view'].includes(type)) { if (['edit', 'view'].includes(type)) {
getDetail(form.value.id).then(res => { getDetail(form.value.id).then(res => {

View File

@ -0,0 +1,72 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.loadbalancer.config;
import net.dreamlu.mica.auto.annotation.AutoEnvPostProcessor;
import org.jspecify.annotations.NonNull;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.DefaultPropertiesPropertySource;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.util.ClassUtils;
import java.util.Map;
/**
* Feign 分页模型 JSON 支持的装配兼容处理
*
* <p>
* Feign Spring Data 的分页与排序模型提供的两个 JSON 扩展仅有 Jackson 3 实现
* 而其装配条件本应校验的 Jackson 模块基类在 Jackson 3 中已改名上游条件退化为恒成立
* 于是只要分页与排序模型在类路径上就会装配Jackson 3 缺席时中断容器启动
* </p>
*
* <p>
* 故仅在 Jackson 3 缺席时给出关闭的默认值该默认值优先级最低下游可显式开启
* Jackson 3 在场时不再干预交由上游条件判定
* </p>
*
* @author Chill
*/
@AutoEnvPostProcessor
public class FeignJacksonEnvPostProcessor implements EnvironmentPostProcessor, Ordered {
/**
* Feign 分页模型 JSON 支持开关
*/
private static final String FEIGN_JACKSON_KEY = "spring.cloud.openfeign.autoconfiguration.jackson.enabled";
/**
* Jackson 3 模块基类上述两个扩展的直接父类
*/
private static final String JACKSON_MODULE_CLASS = "tools.jackson.databind.JacksonModule";
@Override
public void postProcessEnvironment(@NonNull ConfigurableEnvironment environment, SpringApplication application) {
if (ClassUtils.isPresent(JACKSON_MODULE_CLASS, application.getClassLoader())) {
return;
}
Map<String, Object> defaults = Map.of(FEIGN_JACKSON_KEY, Boolean.FALSE.toString());
DefaultPropertiesPropertySource.addOrMerge(defaults, environment.getPropertySources());
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}

View File

@ -20,6 +20,7 @@ import org.springblade.core.secure.utils.SecureUtil;
import org.springblade.core.tenant.exception.TenantException; import org.springblade.core.tenant.exception.TenantException;
import org.springblade.core.tool.utils.CollectionUtil; import org.springblade.core.tool.utils.CollectionUtil;
import org.springblade.core.tool.utils.ReflectUtil; import org.springblade.core.tool.utils.ReflectUtil;
import org.springblade.core.tool.utils.StringUtil;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Collections; import java.util.Collections;
@ -100,7 +101,8 @@ public class TenantGuard {
/** /**
* 提交时新增 / 修改的租户绑定守卫 * 提交时新增 / 修改的租户绑定守卫
* <p> * <p>
* 新增id 为空超管放行非超管强制写入当前会话 tenantId避免前端注入<br/> * 新增id 为空非超管强制写入当前会话 tenantId 避免前端注入超管保留指定归属租户的能力
* 入参未指定时兜底为会话 tenantId<br/>
* 修改id 不空调用 {@link #verify} 校验归属并把已存在实体的 tenantId 回写到入参防止 update 篡改 * 修改id 不空调用 {@link #verify} 校验归属并把已存在实体的 tenantId 回写到入参防止 update 篡改
* *
* @param service MyBatis-Plus IService 实例 * @param service MyBatis-Plus IService 实例
@ -112,13 +114,13 @@ public class TenantGuard {
public static <T> void bindTenant(IService<T> service, T entity, EntityType entityType) { public static <T> void bindTenant(IService<T> service, T entity, EntityType entityType) {
Long id = idOf(entity); Long id = idOf(entity);
if (id == null) { if (id == null) {
if (!SecureUtil.isAdministrator()) { bindTenantOnCreate(entity);
bindTenantId(entity, SecureUtil.getTenantId());
}
return; return;
} }
T existEntity = verify(service, id, entityType); T existEntity = verify(service, id, entityType);
if (existEntity == null) { if (existEntity == null) {
// 超管提交了库中不存在的 idsaveOrUpdate 将退化为新增按新增路径绑定归属
bindTenantOnCreate(entity);
return; return;
} }
// 非超管路径下 verify 已确保 existEntity.tenantId 等于会话 tenantId直接复用避免重复反射 // 非超管路径下 verify 已确保 existEntity.tenantId 等于会话 tenantId直接复用避免重复反射
@ -126,6 +128,19 @@ public class TenantGuard {
bindTenantId(entity, tenantId); bindTenantId(entity, tenantId);
} }
/**
* 新增路径的租户归属绑定
* <p>
* 非超管一律以会话 tenantId 覆盖入参杜绝伪造归属超管保留跨租户建数据的能力仅在入参未携带
* tenantId 时兜底为会话 tenantId 业务表单未必向超管暴露租户选择项不兜底则记录会以空归属落库
* 脱离所有租户的数据范围且无法被租户条件检索
*/
private static void bindTenantOnCreate(Object entity) {
if (!SecureUtil.isAdministrator() || StringUtil.isBlank(tenantIdOf(entity))) {
bindTenantId(entity, SecureUtil.getTenantId());
}
}
/** /**
* 校验批量 ids 全部归属当前会话租户并返回查询出的实体列表 * 校验批量 ids 全部归属当前会话租户并返回查询出的实体列表
* <p> * <p>
@ -143,12 +158,14 @@ public class TenantGuard {
if (CollectionUtil.isEmpty(ids)) { if (CollectionUtil.isEmpty(ids)) {
return Collections.emptyList(); return Collections.emptyList();
} }
List<T> list = service.listByIds(ids); // 去重入参主键避免重复 id 导致查询行数不等于入参数量而误判为越权
List<Long> distinctIds = ids.stream().distinct().toList();
List<T> list = service.listByIds(distinctIds);
if (SecureUtil.isAdministrator()) { if (SecureUtil.isAdministrator()) {
return list; return list;
} }
String currentTenantId = SecureUtil.getTenantId(); String currentTenantId = SecureUtil.getTenantId();
if (list.size() != ids.size() || list.stream().anyMatch(entity -> !currentTenantId.equals(tenantIdOf(entity)))) { if (list.size() != distinctIds.size() || list.stream().anyMatch(entity -> !currentTenantId.equals(tenantIdOf(entity)))) {
throw new TenantException("无权操作非本租户的" + entityType.label()); throw new TenantException("无权操作非本租户的" + entityType.label());
} }
return list; return list;

View File

@ -348,7 +348,7 @@ public class JacksonConfiguration {
> ⚠️【务必看清,这是升级中最隐蔽的一个坑】Spring 7 的 `WebMvcConfigurationSupport.getMessageConverters()` 执行顺序是:先 `configureMessageConverters(list)` → 若 list 仍为空才 `addDefaultHttpMessageConverters(list)`(这一步才注册 ByteArray/String/Resource/ResourceRegion/Form 以及 JSON 转换器)→ 最后 `extendMessageConverters(list)` > ⚠️【务必看清,这是升级中最隐蔽的一个坑】Spring 7 的 `WebMvcConfigurationSupport.getMessageConverters()` 执行顺序是:先 `configureMessageConverters(list)` → 若 list 仍为空才 `addDefaultHttpMessageConverters(list)`(这一步才注册 ByteArray/String/Resource/ResourceRegion/Form 以及 JSON 转换器)→ 最后 `extendMessageConverters(list)`
> 若重写 `configureMessageConverters` 并向 list 添加任何转换器,list 变为非空,`addDefaultHttpMessageConverters` 会被**整段跳过**,MVC 最终只剩下你加入的这一个转换器——文件下载、`byte[]`/图片/PDF、纯文本、表单请求体全部 406/`HttpMessageNotWritableException` 失败。**编译与纯 JSON 接口都正常,极难在冒烟测试中发现。** > 若重写 `configureMessageConverters` 并向 list 添加任何转换器,list 变为非空,`addDefaultHttpMessageConverters` 会被**整段跳过**,MVC 最终只剩下你加入的这一个转换器——文件下载、`byte[]`/图片/PDF、纯文本、表单请求体全部 406/`HttpMessageNotWritableException` 失败。**编译与纯 JSON 接口都正常,极难在冒烟测试中发现。**
> 正解是重写 `extendMessageConverters`:它在默认列表构建完成之后回调,拿到的是**完整**的默认转换器,再原地替换 JSON(因 §5.5⑤ 已强制 `preferred-json-mapper=jackson2`,默认 JSON 转换器为 `MappingJackson2HttpMessageConverter`,属 `AbstractJackson2HttpMessageConverter`)与 String 转换器即可,其余转换器原样保留。 > 正解是重写 `extendMessageConverters`:它在默认列表构建完成之后回调,拿到的是**完整**的默认转换器,再原地替换 JSON(因 §5.5⑤ 已在托管层剥离 Jackson 3,默认 JSON 转换器为 `MappingJackson2HttpMessageConverter`,属 `AbstractJackson2HttpMessageConverter`)与 String 转换器即可,其余转换器原样保留。
```java ```java
@Override @Override
@ -387,7 +387,28 @@ public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
> 与 Jackson 无关的 `org.springframework.lang.Nullable/NonNull` 在 Spring 7 也被弃用(官方推 JSpecify),那是一处独立、面广的迁移,不在本表范围,别混入 Jackson 的 `@SuppressWarnings` > 与 Jackson 无关的 `org.springframework.lang.Nullable/NonNull` 在 Spring 7 也被弃用(官方推 JSpecify),那是一处独立、面广的迁移,不在本表范围,别混入 Jackson 的 `@SuppressWarnings`
**⑤ 【关键】强制 preferred-json-mapper = jackson2**——新增一个 `LauncherService`,否则 Boot 4 默认注册 Jackson 3 转换器,§5.5③ 的 `instanceof AbstractJackson2HttpMessageConverter` 命不中,Blade 的 JSON 处理静默失效: **⑤ 【关键】让 Boot 4 选中 Jackson 2**——Boot 的择一逻辑写在 `org.springframework.boot.http.converter.autoconfigure` 下,读源码可见是**两条并列通路,满足其一即可**(`Jackson2HttpMessageConvertersConfiguration.PreferJackson2OrJacksonUnavailableCondition` 是 `AnyNestedCondition`):
```java
// Jackson 3 分支——三个条件同时成立才注册(javap 实测 JacksonJsonHttpMessageConverterConfiguration)
@ConditionalOnClass(tools.jackson.databind.json.JsonMapper.class)
@ConditionalOnBean(tools.jackson.databind.json.JsonMapper.class) // ← 关键:要 Bean,不只是类
@ConditionalOnProperty(name = "spring.http.converters.preferred-json-mapper",
havingValue = "jackson", matchIfMissing = true)
// Jackson 2 分支——以下任一命中即生效
@ConditionalOnProperty(name = "...preferred-json-mapper", havingValue = "jackson2") // 通路 A
@ConditionalOnMissingBean(JacksonJsonHttpMessageConvertersCustomizer.class) // 通路 B
```
- **通路 B(主)**:Jackson 3 分支要的是**容器里有 `JsonMapper` Bean**,而该 Bean 只由 `spring-boot-jackson` 自动配置模块产出;托管层已把该模块摘掉(见 §5.5⑥),Bean 不存在 → Jackson 3 分支不命中 → Jackson 2 分支的 `@ConditionalOnMissingBean` 成立。这是**编译期事实**,不依赖启动路径。
- **通路 A(兜底)**:`LauncherService` 设 `preferred-json-mapper=jackson2`,覆盖下游未继承 blade 托管层、`spring-boot-jackson` 漏进 classpath 的场景。
> ⚠️ 别把「剥离 Jackson 3」理解成「classpath 上不能有 `tools.jackson`」——**决定管线的是 `JsonMapper` Bean 在不在,不是 Jackson 3 的类在不在**。Jackson 3 的库照常引入不会翻转管线,反而是必需的,理由见 §5.5⑧。
> 两条都留的原因:通路 A 仅在 `BladeApplication` 启动路径执行,`@SpringBootTest`、blade-gateway(不含 blade-core-tool)覆盖不到;且 Jackson 3 的 **XML 转换器分支**(`JacksonXmlHttpMessageConverterConfiguration`)只有 `@ConditionalOnClass` + `@ConditionalOnBean`,**不受 `preferred-json-mapper` 约束**,属性通路结构性覆盖不到。
>
> 关于失效程度需说清:因 §5.5③ 的 `if (!jsonReplaced) converters.add(0, bladeJson)` 兜底,即使两条通路全漏、Jackson 3 转换器占位,blade 转换器仍会插到队首优先命中,**MVC 服务端不会失效**;真正会静默回落到 Jackson 3 的是 `RestClient`/`RestTemplate` 客户端转换器与 WebFlux 编解码器。
新增 `blade-core-tool/.../config/JacksonLauncherServiceImpl.java`: 新增 `blade-core-tool/.../config/JacksonLauncherServiceImpl.java`:
@ -408,6 +429,92 @@ public class JacksonLauncherServiceImpl implements LauncherService {
} }
``` ```
**⑥ 【主通路】托管层剥离 Jackson 3 的自动配置模块(只剥模块,不剥库)**——在父 POM `dependencyManagement``spring-boot-jackson` 从传递链上摘掉,容器里便不会出现 `JsonMapper` Bean,§5.5⑤ 的通路 B 恒成立。**剥离对象仅限这个自动配置模块,`tools.jackson` 系的库本体照常引入**(理由见 §5.5⑧)。**需要两个条目,缺一不可**:
```xml
<!-- 主路径:webmvc、restclient 等多个 starter 都经由 starter-jackson 传递 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
<version>${spring.boot.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- springdoc 走的是另一条路,webflux-ui / webmvc-ui 两个坐标都要单独排 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.0.3</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
</exclusion>
</exclusions>
</dependency>
```
> ⚠️ 为什么 springdoc 必须单独排:`springdoc-openapi-starter-common:3.0.3` **直接依赖** `org.springframework.boot:spring-boot-jackson`,而不是经 `spring-boot-starter-jackson` 传递,上面第一个条目的 `<exclusions>` 只约束它自己的子树,管不到 springdoc。可用 `mvn dependency:tree` 单独验证(注意:在完整工程里跑,Maven 的 nearest-wins 会把 springdoc 分支下的重复节点省略,须用只含 springdoc 的最小 POM 或加 `-Dverbose` 才看得见)。
>
> springdoc 内部仅 Spring Data REST 支持类引用 Jackson 3,且被 `@ConditionalOnClass(RepositoryRestConfiguration)` 守卫,排除无副作用。
>
> 子模块**不需要**再写一遍局部 `<exclusions>`:子模块继承父 `dependencyManagement`,下游工程经 `blade-core-bom` 展开同样继承,重复声明纯属冗余。
**⑦ 序列化管线门禁**——托管层排除是"当前依赖树"的快照,第三方库升级随时可能开出新的引入路径(springdoc 直连就是活例)。父 POM `<build><plugins>` 挂一条 enforcer 规则,把漏排从**运行期静默回落**提前成**构建期失败**,并顺带校验托管层排除是否随 `blade-core-bom` 正确传导:
```xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${maven.enforcer.version}</version>
<executions>
<execution>
<id>ban-jackson3-autoconfigure</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>org.springframework.boot:spring-boot-jackson</exclude>
<exclude>org.springframework.boot:spring-boot-jackson-test</exclude>
</excludes>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
```
> **封禁对象只有自动配置模块**,`tools.jackson` 系构件不在范围内——门禁守的是"`JsonMapper` Bean 不许出现",不是"Jackson 3 的类不许出现";把库一起禁掉会连带打死一批按「Jackson 3 在场」编写的第三方组件(见 §5.5⑧)。`spring-boot-jackson` / `spring-boot-jackson-test` 均为精确坐标匹配,**不会**误伤 `spring-boot-jackson2`
>
> 该门禁 Boot 单体 / Cloud 微服务两个下游工程需各自挂一份——它们以 Boot BOM import 方式引用 blade-tool,并非以其为 parent,`<build><plugins>` 不会继承过去。
**⑧ 【必读】Jackson 3 的库本体必须留着(Cloud 侧尤其)**——⑥ 只剥自动配置模块,而 `tools.jackson` 的库是随该模块传递进来的,剥掉模块库也就跟着没了。若不补回来,凡是**编译期直接 `import tools.jackson.*`** 的第三方组件都会踩雷:轻则条件不命中静默降级,重则 `NoClassDefFoundError: tools/jackson/databind/ObjectMapper` 打断启动。Boot 单体不受影响(这些组件只存在于 Cloud 技术栈),Cloud 侧实测清单:
| 组件 | 触发点 | 缺席后果 |
|---|---|---|
| `spring-cloud-circuitbreaker-sentinel` | `CircuitBreakerRuleChangeListener#afterSingletonsInstantiated``SentinelFeignClientProperties#copy()``new tools.jackson.databind.ObjectMapper()` | **容器 refresh 末尾抛错,服务起不来**;所有单例都建完了才炸,日志看着像启动成功 |
| `spring-cloud-gateway-server-webflux` | `RemoveJsonAttributesResponseBodyGatewayFilterFactory` 的实例字段 `private ObjectMapper mapper = new ObjectMapper()`,该 Bean 由 `@ConditionalOnEnabledFilter` 默认开启 | **网关起不来** |
| `spring-cloud-openfeign-core` | `PageJacksonModule` / `SortJacksonModule`,装配条件校验的模块基类在 Jackson 3 改名后退化为恒成立 | **容器启动失败**;见 `FeignJacksonEnvPostProcessor` 的兜底 |
| `spring-boot-admin-client` | `RestClientRegistrationClientConfig#registrationClient` 形参 `ObjectProvider<tools.jackson.databind.json.JsonMapper>` | 配了 `spring.boot.admin.client.url` 才装配,装配即失败 |
| `spring-cloud-alibaba-sentinel-datasource` | `JsonConverter` / `XmlConverter` | 静默不注册,Sentinel 规则数据源转换能力消失 |
| `spring-cloud-starter-alibaba-sentinel` | `SentinelAutoConfiguration$SentinelConverterConfiguration` | 自带 `@ConditionalOnClass` 守卫,整块跳过,同上静默降级 |
这些都不属于本工程可修的范围,逐个关开关是打地鼠——上表还只是**当前版本**的快照,三方库一升级又会冒出新的。所以口径是**留库**:
- `blade-core-cloud``tools.jackson.core:jackson-databind`,覆盖全部微服务(经 `blade-core-boot` 传递)。
- `blade-gateway` 不含 `blade-core-cloud`,需在自己的 POM 里单独引一份,否则会被上表第二行打挂。
- 版本由 Boot BOM 托管(`spring-boot-dependencies` 已 import `tools.jackson:jackson-bom`),不必自设版本属性。
留库**不会**翻转管线:§5.5⑤ 已实测 Jackson 3 转换器分支要的是 `@ConditionalOnBean(JsonMapper)`,而该 Bean 只由被剥离的 `spring-boot-jackson` 产出。`FeignJacksonEnvPostProcessor` 也据此加了 `ClassUtils.isPresent("tools.jackson.databind.JacksonModule")` 守卫——Jackson 3 在场就不干预,交回上游条件判定,只在缺席时才给出关闭默认值。
> 排查手法(升级三方库后建议复跑):对依赖树里的 jar 逐个扫常量池,`tools/jackson` 命中即为硬引用嫌疑,再回源码看有没有条件守卫。用 Python 的 `zipfile` 扫,别用 `strings`——macOS 的 `strings` 会把 `.class``0xCAFEBABE` magic 误判成 Mach-O fat 二进制,扫出空结果。
### 5.6 Redis pub/sub 容器与 Boot 4.1 新增 `@RedisListener` 自动配置的竞争 ### 5.6 Redis pub/sub 容器与 Boot 4.1 新增 `@RedisListener` 自动配置的竞争
**背景**:Boot 4.1 的 `spring-boot-data-redis` 新增了注解驱动监听自动配置 `DataRedisAnnotationDrivenConfiguration`(由 `DataRedisAutoConfiguration` `@Import`,条件 `@ConditionalOnClass({org.springframework.data.redis.annotation.EnableRedisListeners, org.springframework.messaging.Message})`),用于支撑新的 `@RedisListener` 注解。它会注册一个 `@Bean(name = "redisMessageListenerContainer")` + `@ConditionalOnMissingBean` 的默认容器。 **背景**:Boot 4.1 的 `spring-boot-data-redis` 新增了注解驱动监听自动配置 `DataRedisAnnotationDrivenConfiguration`(由 `DataRedisAutoConfiguration` `@Import`,条件 `@ConditionalOnClass({org.springframework.data.redis.annotation.EnableRedisListeners, org.springframework.messaging.Message})`),用于支撑新的 `@RedisListener` 注解。它会注册一个 `@Bean(name = "redisMessageListenerContainer")` + `@ConditionalOnMissingBean` 的默认容器。
@ -652,7 +759,7 @@ import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
**⑥ 安全放行(springdoc 路径)**:springdoc UI 与规格端点需匿名可达。框架默认放行清单 `blade-core-secure``SecureRegistry.defaultExcludePatterns` 已含 `/v3/api-docs/**`、`/swagger-ui/**`、`/swagger-ui.html`(`/swagger-ui/**` 为 UI 静态资源,`/swagger-ui.html` 为跳转入口),下游 servlet 应用继承即可。若应用另有自建放行清单(Boot 单体的 `BladeConfiguration`、Cloud 网关的 `AuthProvider`),须同步补齐这三项,并去掉 `/doc.html`、`/swagger-resources/**` 等 knife4j / springfox 旧路径。 **⑥ 安全放行(springdoc 路径)**:springdoc UI 与规格端点需匿名可达。框架默认放行清单 `blade-core-secure``SecureRegistry.defaultExcludePatterns` 已含 `/v3/api-docs/**`、`/swagger-ui/**`、`/swagger-ui.html`(`/swagger-ui/**` 为 UI 静态资源,`/swagger-ui.html` 为跳转入口),下游 servlet 应用继承即可。若应用另有自建放行清单(Boot 单体的 `BladeConfiguration`、Cloud 网关的 `AuthProvider`),须同步补齐这三项,并去掉 `/doc.html`、`/swagger-resources/**` 等 knife4j / springfox 旧路径。
**⑦ 代码生成器模板**:`blade-starter-develop/src/main/resources/templates/controller.java.vm` 里的 `@ApiOperationSupport` import 与注解一并删除——它是 `.vm` 资源,下游按 `*.java` 批量清理的脚本覆盖不到,须单独处理,否则该生成器产出的 Controller 引用已移除的 knife4j 包、编译不过(排序改用 §9⑧ 的 `@ApiOrder`)。删干净后**还须在模板 `@Tag` 上方补一行无参 `@ApiOrder` + 其 import**(`org.springblade.core.swagger.annotation.ApiOrder`),让生成的 Controller 与手写控制器一致带排序(见下「落地约定」);下游 Boot(`src/{main,test}/resources/templates/`)、Cloud(`blade-ops/blade-develop/src/{main,test}/resources/templates/`)的同名模板副本一并补。 **⑦ 代码生成器模板**:`blade-starter-develop/src/main/resources/templates/controller.java.vm` 里的 `@ApiOperationSupport` import 与注解一并删除——它是 `.vm` 资源,下游按 `*.java` 批量清理的脚本覆盖不到,须单独处理,否则该生成器产出的 Controller 引用已移除的 knife4j 包、编译不过(排序改用 §9⑧ 的 `@ApiOrder`)。删干净后**还须在模板 `@Tag` 上方补一行无参 `@ApiOrder` + 其 import**(`org.springblade.core.swagger.annotation.ApiOrder`),让生成的 Controller 与手写控制器一致带排序(见下「落地约定」);下游 Boot(`src/main/resources/templates/`)、Cloud(`blade-ops/blade-develop/src/main/resources/templates/`)的同名模板副本一并补。`src/test/resources/templates/` 下的旧扁平路径 `.vm` 副本自模板重组为 `api/` 子目录后不再被加载,应直接删除,test 侧仅保留 `code.properties` 作为数据源配置覆盖点。
**⑧ 接口排序:`@ApiOrder`(替代 knife4j 的 `@ApiOperationSupport(order = n)`)** **⑧ 接口排序:`@ApiOrder`(替代 knife4j 的 `@ApiOperationSupport(order = n)`)**
@ -664,7 +771,7 @@ springdoc / OpenAPI 3 无接口排序原生注解,blade-starter-swagger 新增 `
实现在 `SwaggerAutoConfiguration`:`GlobalOperationCustomizer` 读注解写入 `x-order` 扩展(类级用字节码行号取声明序,反射不保证方法顺序)并记录「tag → 类级序号」;`GlobalOpenApiCustomizer` 按 `x-order` 重排 `paths`、按类级序号重排顶层 `tags`。swagger-ui 仅在两个 sorter 未设时保留 spec 顺序(§9⑤),并须保持 `springdoc.writer-with-order-by-keys` 默认 `false` 实现在 `SwaggerAutoConfiguration`:`GlobalOperationCustomizer` 读注解写入 `x-order` 扩展(类级用字节码行号取声明序,反射不保证方法顺序)并记录「tag → 类级序号」;`GlobalOpenApiCustomizer` 按 `x-order` 重排 `paths`、按类级序号重排顶层 `tags`。swagger-ui 仅在两个 sorter 未设时保留 spec 顺序(§9⑤),并须保持 `springdoc.writer-with-order-by-keys` 默认 `false`
**落地约定(本次统一)**:三工程**每个带 `@Tag` 的控制器**均在 `@Tag` 上方补一行**无参 `@ApiOrder`**(值取默认 `Integer.MAX_VALUE`),方法体只保留 `@Operation`——效果是分组内接口按**源码声明顺序**展示、各 `@Tag` 分组之间因类级序号相等而回落 tag 名字母序;仅当个别接口要打破源码顺序时,才在该方法上补 `@ApiOrder(n)` 精确覆盖。删 knife4j 排序注解(§3.1 / §4.2)后**只删不补**会让接口在 springdoc UI 下按字母序、丢失原有顺序,故「删旧 + 补 `@ApiOrder`」是同一件事的两半。代码生成器模板 `controller.java.vm`(blade-tool `blade-starter-develop` 源模板 + 下游 Boot/Cloud `blade-develop` 的 main/test 副本,共 5 份)同步在 `@Tag` 上方生成 `@ApiOrder`,新生成的 Controller 开箱即带排序。 **落地约定(本次统一)**:三工程**每个带 `@Tag` 的控制器**均在 `@Tag` 上方补一行**无参 `@ApiOrder`**(值取默认 `Integer.MAX_VALUE`),方法体只保留 `@Operation`——效果是分组内接口按**源码声明顺序**展示、各 `@Tag` 分组之间因类级序号相等而回落 tag 名字母序;仅当个别接口要打破源码顺序时,才在该方法上补 `@ApiOrder(n)` 精确覆盖。删 knife4j 排序注解(§3.1 / §4.2)后**只删不补**会让接口在 springdoc UI 下按字母序、丢失原有顺序,故「删旧 + 补 `@ApiOrder`」是同一件事的两半。代码生成器模板 `controller.java.vm`(blade-tool `blade-starter-develop` 源模板 + 下游 Boot/Cloud `blade-develop` 的 main 副本,共 3 份)同步在 `@Tag` 上方生成 `@ApiOrder`,新生成的 Controller 开箱即带排序。
> 提示:类级 `@ApiOrder(n)``value` 用作 `@Tag` 分组之间的排序序号(`SwaggerAutoConfiguration#recordTagOrder` 写入、`GlobalOpenApiCustomizer` 据此重排顶层 `tags`);分组内各接口不受此值影响,按源码声明顺序排列。本次全量采用**无参**形式,`value` 恒为默认值、各分组落 tag 名字母序;如需固定分组顺序,给对应控制器标 `@ApiOrder(n)` 即可。 > 提示:类级 `@ApiOrder(n)``value` 用作 `@Tag` 分组之间的排序序号(`SwaggerAutoConfiguration#recordTagOrder` 写入、`GlobalOpenApiCustomizer` 据此重排顶层 `tags`);分组内各接口不受此值影响,按源码声明顺序排列。本次全量采用**无参**形式,`value` 恒为默认值、各分组落 tag 名字母序;如需固定分组顺序,给对应控制器标 `@ApiOrder(n)` 即可。

75
pom.xml
View File

@ -36,7 +36,7 @@
<properties> <properties>
<!-- Blade-Tool Version --> <!-- Blade-Tool Version -->
<revision>5.0.0</revision> <revision>5.0.1</revision>
<module.name>org.springblade.blade.tool</module.name> <module.name>org.springblade.blade.tool</module.name>
<!-- jdk Version --> <!-- jdk Version -->
<java.version>21</java.version> <java.version>21</java.version>
@ -60,6 +60,7 @@
<druid.version>1.2.28</druid.version> <druid.version>1.2.28</druid.version>
<spring.retry.version>2.0.13</spring.retry.version> <spring.retry.version>2.0.13</spring.retry.version>
<httpclient.version>4.5.14</httpclient.version> <httpclient.version>4.5.14</httpclient.version>
<maven.enforcer.version>3.6.3</maven.enforcer.version>
<spring.boot.version>4.1.0</spring.boot.version> <spring.boot.version>4.1.0</spring.boot.version>
<spring.boot.admin.version>4.1.1</spring.boot.admin.version> <spring.boot.admin.version>4.1.1</spring.boot.admin.version>
@ -127,6 +128,23 @@
<version>${mica.auto.version}</version> <version>${mica.auto.version}</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!--
业务序列化由 Jackson 2 承载,中间件按各自条件使用 Jackson 3。
消息转换器改用哪一代,取决于容器中是否存在 JsonMapper Bean该 Bean 只由
spring-boot-jackson 产出故此处只剥离该模块Jackson 3 的库照常引入。
webmvc、restclient 等多个 starter 均经 spring-boot-starter-jackson 传递,在此一并收口。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
<version>${spring.boot.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- blade tool --> <!-- blade tool -->
<dependency> <dependency>
<groupId>org.springblade</groupId> <groupId>org.springblade</groupId>
@ -380,15 +398,28 @@
<artifactId>swagger-annotations-jakarta</artifactId> <artifactId>swagger-annotations-jakarta</artifactId>
<version>2.2.52</version> <version>2.2.52</version>
</dependency> </dependency>
<!-- springdoc 直连 spring-boot-jackson不受上方 starter 条目的排除约束,须单独剥离 -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId> <artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>3.0.3</version> <version>3.0.3</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.0.3</version> <version>3.0.3</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<!-- protostuff --> <!-- protostuff -->
<dependency> <dependency>
@ -420,6 +451,12 @@
<artifactId>easy-captcha</artifactId> <artifactId>easy-captcha</artifactId>
<version>1.6.2</version> <version>1.6.2</version>
</dependency> </dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.64</version>
</dependency>
<!-- fastexcel --> <!-- fastexcel -->
<dependency> <dependency>
<groupId>cn.idev.excel</groupId> <groupId>cn.idev.excel</groupId>
@ -532,6 +569,42 @@
</resource> </resource>
</resources> </resources>
<plugins> <plugins>
<!--
序列化管线门禁:把托管层排除的漏排转为构建期失败。
封禁对象仅为 Jackson 3 的自动配置模块,其库不在范围内;
spring-boot-jackson2 为精确坐标匹配,同样不受影响。
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${maven.enforcer.version}</version>
<executions>
<execution>
<id>ban-jackson3-autoconfigure</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>org.springframework.boot:spring-boot-jackson</exclude>
<exclude>org.springframework.boot:spring-boot-jackson-test</exclude>
</excludes>
<message>
检测到 spring-boot-jackson 进入依赖树。该模块会注册 JsonMapper Bean
使 Boot 4 改用 Jackson 3 的消息转换器,
@BladeView 视图过滤、大数转字符串、日期格式等序列化约定将静默失效。
请执行 mvn dependency:tree -Dincludes='org.springframework.boot:spring-boot-jackson'
定位引入路径,并在父 POM dependencyManagement 对应条目补充排除。
注意tools.jackson 系构件本身不在封禁范围,可以正常引入。
</message>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>