目录
[RBAC 权限控制](#RBAC 权限控制)
本部分内容主要来源于鱼皮智能协图云图库部分,并在笔者个人项目学习的基础上进行扩展衍生。由于项目开发文档已经足够详细,因此这里只记录要点。
团队共享空间
该部分内容与创建私人空间类似,基础的增删改查操作较多,复用代码较多。
方案设计
目的:开发新模块------团队共享空间,实现基本增删改查等操作。
创建过程:
1.库表设计------用空间表的spaceType字段区分出团队空间。为空间成员单独创建表。
2.数据模型------dto与entity补充spaceType字段,定义空间枚举类(用于空间类别判断)
3.空间业务开发------对原有空间创建方法复用,增添校验
4.空间成员业务开发------增删改查业务开发
这里难点不多,主要掌握业务流程与校验的细节。
空间成员权限控制
RBAC 权限控制
RBAC 权限控制模型(基于角色的访问控制,Role-Based Access Control),核心概念包括 用户、角色、权限。某接口需要A权限,因此先根据用户判断角色,才从角色中抽取拥有的权限进行判断。

方案设计
其实我觉得在前面普通用户---管理员的实现已经包含了RBAC权限验证,只是具体权限没有单独写出来(读权限,改权限等等),而是以某接口管理员可访问的方式体现出来的。
在这里可以有多种设计思路:
1.注解+aop切面鉴权,以权限为最细粒度进行判读(前面的实现是以角色为粒度)
2.拦截器/过滤器实现,在拦截器/过滤器中写具体业务判断逻辑
3.第三方校验框架(包装了查询当前角色权限再进行权限比较)
标准的 RBAC 实现需要 5 张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。这里为了简化,直接将具体的权限,角色类别定义在resource目录下的.json文件中。
具体实现流程:
1.引入依赖
2.写角色权限的.json文件
3.新建数据模型,接收配置文件
4.加载配置文件到对象
5.kit模式实现多账号体系认证
6.权限校验逻辑(获取当前用户的权限列表/角色列表)
7.注释/编程式方式使用sa-token
sa-token的本质还是包装,权限验证是包装获取权限列表和权限列表与注解权限(注解标记需要哪个权限)比较的过程。角色登录是包装生成token,封装用户信息到session的过程。这里获取权限列表是核心代码,怎么获取是主要思考点。
后端实现
1.JSON 配置文件来定义角色、权限、角色和权限之间的关系
bash
json代码{
"permissions": [
{
"key": "spaceUser:manage",
"name": "成员管理",
"description": "管理空间成员,添加或移除成员"
},
{
"key": "picture:view",
"name": "查看图片",
"description": "查看空间中的图片内容"
},
{
"key": "picture:upload",
"name": "上传图片",
"description": "上传图片到空间中"
},
{
"key": "picture:edit",
"name": "修改图片",
"description": "编辑已上传的图片信息"
},
{
"key": "picture:delete",
"name": "删除图片",
"description": "删除空间中的图片"
}
],
"roles": [
{
"key": "viewer",
"name": "浏览者",
"permissions": [
"picture:view"
],
"description": "查看图片"
},
{
"key": "editor",
"name": "编辑者",
"permissions": [
"picture:view",
"picture:upload",
"picture:edit",
"picture:delete"
],
"description": "查看图片、上传图片、修改图片、删除图片"
},
{
"key": "admin",
"name": "管理员",
"permissions": [
"spaceUser:manage",
"picture:view",
"picture:upload",
"picture:edit",
"picture:delete"
],
"description": "成员管理、查看图片、上传图片、修改图片、删除图片"
}
]
}
存放在 resources/biz 目录下,biz为business缩写,业务,商务。
在resources目录下在maven构建项目时会被自动打包,运行时可通过classpath便捷读取,不需要读取绝对路径。
2.SpaceUserAuthManager可加载配置文件到对象
java
@Component
public class SpaceUserAuthManager {
@Resource
private SpaceUserService spaceUserService;
@Resource
private UserService userService;
public static final SpaceUserAuthConfig SPACE_USER_AUTH_CONFIG;
static {
String json = ResourceUtil.readUtf8Str("biz/spaceUserAuthConfig.json");
SPACE_USER_AUTH_CONFIG = JSONUtil.toBean(json, SpaceUserAuthConfig.class);
}
/**
* 根据角色获取权限列表
*/
public List<String> getPermissionsByRole(String spaceUserRole) {
if (StrUtil.isBlank(spaceUserRole)) {
return new ArrayList<>();
}
// 找到匹配的角色
SpaceUserRole role = SPACE_USER_AUTH_CONFIG.getRoles().stream()
.filter(r -> spaceUserRole.equals(r.getKey()))
.findFirst()
.orElse(null);
if (role == null) {
return new ArrayList<>();
}
return role.getPermissions();
}
}
要点:
1.静态代码实现只执行一次
2.Hutool工具包读取资源文件ResourceUtil.readUtf8Str()
3.Hutool工具包实现类转换JsonUtil.toBean()
3.定义空间账号体系-实现登录逻辑
java
/**
* StpLogic 门面类,管理项目中所有的 StpLogic 账号体系
* 添加 @Component 注解的目的是确保静态属性 DEFAULT 和 SPACE 被初始化
*/
@Component
public class StpKit {
public static final String SPACE_TYPE = "space";
/**
* 默认原生会话对象,项目中目前没使用到
*/
public static final StpLogic DEFAULT = StpUtil.stpLogic;
/**
* Space 会话对象,管理 Space 表所有账号的登录、权限认证
*/
public static final StpLogic SPACE = new StpLogic(SPACE_TYPE);
}
使用:
java
// 在当前会话进行 Space 账号登录
StpKit.SPACE.login(10001);
// 检测当前会话是否以 Space 账号登录,并具有 picture:edit 权限
StpKit.SPACE.checkPermission("picture:edit");
// 获取当前 Space 会话的 Session 对象,并进行写值操作
StpKit.SPACE.getSession().set("user", "程序员鱼皮");
要点:
1.@Component实现静态属性初始化,Spring 启动时会主动扫描并加载该类则静态属性初始化
2.new StpLogic()创建实例时,sa-token底层会自动完成体系注册
3.StpKit实现了门面模式,统一入口
4.获取上下文对象
java
@Value("${server.servlet.context-path}")
private String contextPath;
/**
* 从请求中获取上下文对象
*/
private SpaceUserAuthContext getAuthContextByRequest() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
String contentType = request.getHeader(Header.CONTENT_TYPE.getValue());
SpaceUserAuthContext authRequest;
// 兼容 get 和 post 操作
if (ContentType.JSON.getValue().equals(contentType)) {
String body = ServletUtil.getBody(request);
authRequest = JSONUtil.toBean(body, SpaceUserAuthContext.class);
} else {
Map<String, String> paramMap = ServletUtil.getParamMap(request);
authRequest = BeanUtil.toBean(paramMap, SpaceUserAuthContext.class);
}
// 根据请求路径区分 id 字段的含义
Long id = authRequest.getId();
if (ObjUtil.isNotNull(id)) {
String requestUri = request.getRequestURI();
String partUri = requestUri.replace(contextPath + "/", "");
String moduleName = StrUtil.subBefore(partUri, "/", false);
switch (moduleName) {
case "picture":
authRequest.setPictureId(id);
break;
case "spaceUser":
authRequest.setSpaceUserId(id);
break;
case "space":
authRequest.setSpaceId(id);
break;
default:
}
}
return authRequest;
}
要点:
1.编写逻辑:这里涉及注解式与编程式的底层逻辑。我们最终都会通过getPermissionList方法获取当前用户的权限列表,sa-token再进行比较完成鉴权。但由于注解式触发时机是在接口方法之前,因此需要我们手动完成request的解析,封装为java对象。编程式是在业务内灵活调用,此时接口的入参已经被spring解析封装为Java对象了。
注解式:前端发送请求 → Sa-Token 拦截器拦截请求 → 执行注解对应的权限校验 → 校验通过 → 进入接口方法体执行业务逻辑 → 校验失败 → 直接返回无权限响应
编程式:前端发送请求 → 进入接口方法体执行业务逻辑→ 可将用户信息存入Threadlocal →Sa-Token 拦截器拦截请求 → 执行注解对应的权限校验 → 校验通过 → 继续执行 → 校验失败 → 直接返回无权限响应
java
public List<String> getPermissionList(Object loginId, String loginType) {
// 只能自己解析request获取更多信息
SpaceUserAuthContext authContext = getAuthContextByRequest();
// 后续权限校验逻辑...
}
java
public List<String> getPermissionList(Object loginId, String loginType) {
// 直接从 ThreadLocal 中获取 spaceId,无需依赖 Servlet 请求对象
Long spaceId = RequestContextHolder.getSpaceId();
// 后续权限校验逻辑...
}
2.非Controller层解析HttpServletRequest,涉及许多servlet的知识,改日再专门写篇文章攻克一下。
3.兼容 get 和 post 操作-- application/json格式与application/x-www-form-urlencoded。ServletUtil.getBody(),ServletUtil.getParamMap()。
4.HttpServletRequest 的 body 值是流,只支持读取一次,因此还需要额外的处理。
5.编写返回权限列表的逻辑
java
public List<String> getPermissionList(Object loginId, String loginType) {
// 判断 loginType,仅对类型为 "space" 进行权限校验
if (!StpKit.SPACE_TYPE.equals(loginType)) {
return new ArrayList<>();
}
// 管理员权限,表示权限校验通过
List<String> ADMIN_PERMISSIONS = spaceUserAuthManager.getPermissionsByRole(SpaceRoleEnum.ADMIN.getValue());
// 获取上下文对象
SpaceUserAuthContext authContext = getAuthContextByRequest();
// 如果所有字段都为空,表示查询公共图库,可以通过
if (isAllFieldsNull(authContext)) {
return ADMIN_PERMISSIONS;
}
// 获取 userId
User loginUser = (User) StpKit.SPACE.getSessionByLoginId(loginId).get(USER_LOGIN_STATE);
if (loginUser == null) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "用户未登录");
}
Long userId = loginUser.getId();
// 优先从上下文中获取 SpaceUser 对象
SpaceUser spaceUser = authContext.getSpaceUser();
if (spaceUser != null) {
return spaceUserAuthManager.getPermissionsByRole(spaceUser.getSpaceRole());
}
// 如果有 spaceUserId,必然是团队空间,通过数据库查询 SpaceUser 对象
Long spaceUserId = authContext.getSpaceUserId();
if (spaceUserId != null) {
spaceUser = spaceUserService.getById(spaceUserId);
if (spaceUser == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到空间用户信息");
}
// 取出当前登录用户对应的 spaceUser
SpaceUser loginSpaceUser = spaceUserService.lambdaQuery()
.eq(SpaceUser::getSpaceId, spaceUser.getSpaceId())
.eq(SpaceUser::getUserId, userId)
.one();
if (loginSpaceUser == null) {
return new ArrayList<>();
}
// 这里会导致管理员在私有空间没有权限,可以再查一次库处理
return spaceUserAuthManager.getPermissionsByRole(loginSpaceUser.getSpaceRole());
}
// 如果没有 spaceUserId,尝试通过 spaceId 或 pictureId 获取 Space 对象并处理
Long spaceId = authContext.getSpaceId();
if (spaceId == null) {
// 如果没有 spaceId,通过 pictureId 获取 Picture 对象和 Space 对象
Long pictureId = authContext.getPictureId();
// 图片 id 也没有,则默认通过权限校验
if (pictureId == null) {
return ADMIN_PERMISSIONS;
}
Picture picture = pictureService.lambdaQuery()
.eq(Picture::getId, pictureId)
.select(Picture::getId, Picture::getSpaceId, Picture::getUserId)
.one();
if (picture == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到图片信息");
}
spaceId = picture.getSpaceId();
// 公共图库,仅本人或管理员可操作
if (spaceId == null) {
if (picture.getUserId().equals(userId) || userService.isAdmin(loginUser)) {
return ADMIN_PERMISSIONS;
} else {
// 不是自己的图片,仅可查看
return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW);
}
}
}
// 获取 Space 对象
Space space = spaceService.getById(spaceId);
if (space == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到空间信息");
}
// 根据 Space 类型判断权限
if (space.getSpaceType() == SpaceTypeEnum.PRIVATE.getValue()) {
// 私有空间,仅本人或管理员有权限
if (space.getUserId().equals(userId) || userService.isAdmin(loginUser)) {
return ADMIN_PERMISSIONS;
} else {
return new ArrayList<>();
}
} else {
// 团队空间,查询 SpaceUser 并获取角色和权限
spaceUser = spaceUserService.lambdaQuery()
.eq(SpaceUser::getSpaceId, spaceId)
.eq(SpaceUser::getUserId, userId)
.one();
if (spaceUser == null) {
return new ArrayList<>();
}
return spaceUserAuthManager.getPermissionsByRole(spaceUser.getSpaceRole());
}
}
这里比较复杂,但主要是为了兼容公共图库、私有空间和团队空间共同校验。
6.权限校验注解简化
java
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册 Sa-Token 拦截器,打开注解式鉴权功能
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
}
@PostConstruct
public void rewriteSaStrategy() {
// 重写Sa-Token的注解处理器,增加注解合并功能
SaAnnotationStrategy.instance.getAnnotation = (element, annotationClass) -> {
return AnnotatedElementUtils.getMergedAnnotation(element, annotationClass);
};
}
}
java
/**
* 空间权限认证:必须具有指定权限才能进入该方法
* <p> 可标注在函数、类上(效果等同于标注在此类的所有方法上)
*/
@SaCheckPermission(type = StpKit.SPACE_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface SaSpaceCheckPermission {
/**
* 需要校验的权限码
*
* @return 需要校验的权限码
*/
@AliasFor(annotation = SaCheckPermission.class)
String[] value() default {};
/**
* 验证模式:AND | OR,默认AND
*
* @return 验证模式
*/
@AliasFor(annotation = SaCheckPermission.class)
SaMode mode() default SaMode.AND;
/**
* 在权限校验不通过时的次要选择,两者只要其一校验成功即可通过校验
*
* <p>
* 例1:@SaCheckPermission(value="user-add", orRole="admin"),
* 代表本次请求只要具有 user-add权限 或 admin角色 其一即可通过校验。
* </p>
*
* <p>
* 例2: orRole = {"admin", "manager", "staff"},具有三个角色其一即可。 <br>
* 例3: orRole = {"admin, manager, staff"},必须三个角色同时具备。
* </p>
*
* @return /
*/
@AliasFor(annotation = SaCheckPermission.class)
String[] orRole() default {};
}
要点:
1.注册 Sa-Token 拦截器,开启注解式鉴权。
2.@PostConstruct 标记该方法在当前bean初始化后立刻执行且只执行一次
3.重写注解策略,开启「注解合并」功能。即可继承Sa-Token原生注解@SaCheckPermission所以鉴权功能
4.自定义注解,提前指定type类型,简化注解使用
5.元注解@Retention表明注解生命周期,@Target表明注解范围,@AliasFor表明属性映射,映射到@SaCheckPermission
6.implements WebMvcConfigurer:实现 Spring MVC 的配置扩展接口,用于自定义 Spring MVC 的功能(这里主要是注册拦截器),这是 Spring MVC 扩展的标准方式。配置跨域请求,消息转换器等都需要实现这个接口。
接下来在接口上加上注解即可。
空间数据管理
由于数据庞大以及复杂性上升,需要进行分库分表。
方案设计
使用ShardingSphere 分库分表实现。开发者操作逻辑表,ShardingSphere自动化分表路由物理表。
2 大核心模块 ShardingSphere-JDBC 和 ShardingSphere-Proxy
| 维度 | ShardingSphere JDBC | ShardingSphere Proxy |
|---|---|---|
| 运行方式 | 嵌入式运行在应用内部 | 独立代理,运行在应用与数据库之间 |
| 性能 | 低网络开销,性能较高 | 引入网络开销,性能略低 |
| 支持语言 | 仅支持 Java | 支持多语言(Java、Python、Go 等) |
| 配置管理 | 分布式配置较复杂 | 支持集中配置和动态管理 |
| 扩展性 | 随着应用扩展,需单独调整配置 | 代理服务集中化管理,扩展性强 |
| 适用场景 | 单体或小型系统,对性能要求高的场景 | 多语言、大型分布式系统或需要统一管理的场景 |
静态分表
在设计阶段,分表的数量和规则固定,不会根据业务增长动态调整。分片规则简单(取模,哈希等)。
基本格式
XML
rules:
sharding:
tables:
picture:
actualDataNodes: ds0.picture_${0..2} # 3张分表:picture_0, picture_1, picture_2
tableStrategy:
standard:
shardingColumn: pictureId # 按 pictureId 分片
shardingAlgorithmName: pictureIdMod
shardingAlgorithms:
pictureIdMod:
type: INLINE
props:
algorithm-expression: picture_${pictureId % 3} # 分片表达式
动态分表
需要自定义分表算法,编写创建表的逻辑。
后端实现
1.动态分表配置
XML
spring:
# 空间图片分表
shardingsphere:
datasource:
names: yu_picture
yu_picture:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/yu_picture
username: root
password: 123456
rules:
sharding:
tables:
picture:
actual-data-nodes: yu_picture.picture # 动态分表
table-strategy:
standard:
sharding-column: spaceId
sharding-algorithm-name: picture_sharding_algorithm # 使用自定义分片算法
sharding-algorithms:
picture_sharding_algorithm:
type: CLASS_BASED
props:
strategy: standard
algorithmClassName: com.yupi.yupicturebackend.manager.sharding.PictureShardingAlgorithm
props:
sql-show: true
要点:
1.actual-data-nodes一般指定逻辑表名与分表范围,查询时会在这个范围内查询验证合法性。然而由于通过spaceid分表,且spaceid为长整型,范围太大。因此这里直接设置为逻辑表,放弃物理表预校验。
2.sharding-column指明分表字段
3.sharding-algorithm-name表明分表算法
4.配置分表算法,指明类路径等
2.新建分表算法类
java
public class PictureShardingAlgorithm implements StandardShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> preciseShardingValue) {
Long spaceId = preciseShardingValue.getValue();
String logicTableName = preciseShardingValue.getLogicTableName();
// spaceId 为 null 表示查询所有图片
if (spaceId == null) {
return logicTableName;
}
// 根据 spaceId 动态生成分表名
String realTableName = "picture_" + spaceId;
if (availableTargetNames.contains(realTableName)) {
return realTableName;
} else {
return logicTableName;
}
}
@Override
public Collection<String> doSharding(Collection<String> collection, RangeShardingValue<Long> rangeShardingValue) {
return new ArrayList<>();
}
@Override
public Properties getProps() {
return null;
}
@Override
public void init(Properties properties) {
}
}
要点:
1.实现路由操作
2.配置采用逻辑分表的表明,这里的判断可用分表无法实现。因此手写分表管理器。
java
@Component
@Slf4j
public class DynamicShardingManager {
@Resource
private DataSource dataSource;
@Resource
private SpaceService spaceService;
private static final String LOGIC_TABLE_NAME = "picture";
private static final String DATABASE_NAME = "logic_db"; // 配置文件中的数据库名称
@PostConstruct
public void initialize() {
log.info("初始化动态分表配置...");
updateShardingTableNodes();
}
/**
* 获取所有动态表名,包括初始表 picture 和分表 picture_{spaceId}
*/
private Set<String> fetchAllPictureTableNames() {
// 为了测试方便,直接对所有团队空间分表(实际上线改为仅对旗舰版生效)
Set<Long> spaceIds = spaceService.lambdaQuery()
.eq(Space::getSpaceType, SpaceTypeEnum.TEAM.getValue())
.list()
.stream()
.map(Space::getId)
.collect(Collectors.toSet());
Set<String> tableNames = spaceIds.stream()
.map(spaceId -> LOGIC_TABLE_NAME + "_" + spaceId)
.collect(Collectors.toSet());
tableNames.add(LOGIC_TABLE_NAME); // 添加初始逻辑表
return tableNames;
}
/**
* 更新 ShardingSphere 的 actual-data-nodes 动态表名配置
*/
private void updateShardingTableNodes() {
Set<String> tableNames = fetchAllPictureTableNames();
String newActualDataNodes = tableNames.stream()
.map(tableName -> "yu_picture." + tableName) // 确保前缀合法
.collect(Collectors.joining(","));
log.info("动态分表 actual-data-nodes 配置: {}", newActualDataNodes);
ContextManager contextManager = getContextManager();
ShardingSphereRuleMetaData ruleMetaData = contextManager.getMetaDataContexts()
.getMetaData()
.getDatabases()
.get(DATABASE_NAME)
.getRuleMetaData();
Optional<ShardingRule> shardingRule = ruleMetaData.findSingleRule(ShardingRule.class);
if (shardingRule.isPresent()) {
ShardingRuleConfiguration ruleConfig = (ShardingRuleConfiguration) shardingRule.get().getConfiguration();
List<ShardingTableRuleConfiguration> updatedRules = ruleConfig.getTables()
.stream()
.map(oldTableRule -> {
if (LOGIC_TABLE_NAME.equals(oldTableRule.getLogicTable())) {
ShardingTableRuleConfiguration newTableRuleConfig = new ShardingTableRuleConfiguration(LOGIC_TABLE_NAME, newActualDataNodes);
newTableRuleConfig.setDatabaseShardingStrategy(oldTableRule.getDatabaseShardingStrategy());
newTableRuleConfig.setTableShardingStrategy(oldTableRule.getTableShardingStrategy());
newTableRuleConfig.setKeyGenerateStrategy(oldTableRule.getKeyGenerateStrategy());
newTableRuleConfig.setAuditStrategy(oldTableRule.getAuditStrategy());
return newTableRuleConfig;
}
return oldTableRule;
})
.collect(Collectors.toList());
ruleConfig.setTables(updatedRules);
contextManager.alterRuleConfiguration(DATABASE_NAME, Collections.singleton(ruleConfig));
contextManager.reloadDatabase(DATABASE_NAME);
log.info("动态分表规则更新成功!");
} else {
log.error("未找到 ShardingSphere 的分片规则配置,动态分表更新失败。");
}
}
/**
* 获取 ShardingSphere ContextManager
*/
private ContextManager getContextManager() {
try (ShardingSphereConnection connection = dataSource.getConnection().unwrap(ShardingSphereConnection.class)) {
return connection.getContextManager();
} catch (SQLException e) {
throw new RuntimeException("获取 ShardingSphere ContextManager 失败", e);
}
}
}
生成完整的物理表列表并更新到配置actual-data-nodes中
3.动态创建分表
分表管理器中新增方法
java
public void createSpacePictureTable(Space space) {
// 动态创建分表
// 仅为旗舰版团队空间创建分表
if (space.getSpaceType() == SpaceTypeEnum.TEAM.getValue() && space.getSpaceLevel() == SpaceLevelEnum.FLAGSHIP.getValue()) {
Long spaceId = space.getId();
String tableName = "picture_" + spaceId;
// 创建新表
String createTableSql = "CREATE TABLE " + tableName + " LIKE picture";
try {
SqlRunner.db().update(createTableSql);
// 更新分表
updateShardingTableNodes();
} catch (Exception e) {
log.error("创建图片空间分表失败,空间 id = {}", space.getId());
}
}
}
要点:
1.开启MyBatis Plus 的 SqlRunner配置
2.使用SqlRunner.db().update(createTableSql);动态创建表
ShardingSphere 还提供了 hint 强制分表路由机制 来实现动态分表,允许在代码中强制指定具体的物理表,从而解决动态分表问题。但缺点是需要在每次查询或者操作数据时都显式设置表名,会给代码增加很多额外逻辑,不够优雅。所以不采用。
整个流程:静态路由配置(yml + 分片算法)→ 项目启动初始化(分表管理器更新actual-data-nodes)→ 运行时动态扩展(动态建表 + 更新配置),三者闭环,实现完整的动态分表。
