Java框架快速入门: Spring Security+OAuth2之数据库和实体类的RBAC改造

纲要

  • RBAC 模型改造背景
    • 从单一角色到"角色 + 权限"双层模型
    • 引入权限实体,脱离 Role 直接实现 GrantedAuthority 的方式
  • 实体类改造
    • Permission:实现 GrantedAuthority,包含权限名与展示名
    • Role:不再实现 GrantedAuthority,与 Permission 建立多对多关系
    • User:重写 getAuthorities(),将角色内的权限"拍平",同时保留角色本身作为一种权限
  • 多对多关系与 JPA 映射
    • RolePermission 互相 @ManyToMany
    • 中间表 role_permission 的映射配置
    • 使用 @JsonIgnore 防止序列化循环引用
  • 用户认证权限的构造方式
    • 流式处理 flatMap 将嵌套集合扁平化
    • 保留角色本身作为 ROLE_ 前缀的 GrantedAuthority
    • 最终返回一维权限集合
  • 角色分级与权限表达式扩展
    • 从数据库动态构建角色层级关系
    • RoleHierarchy 的表达式构造
    • 默认 ROLE_ADMIN 拥有最高权限,并包含 ROLE_STAFF
  • 初始数据与命名建议
    • 权限命名规范:操作资源 + 动作(如 USER_READ
    • 示例初始化 SQL 脚本

改造背景与设计思路

在最初的设计中,Role 实体直接实现了 Spring Security 的 GrantedAuthority 接口,相当于角色本身就是权限。随着系统复杂度提升,需要更细粒度的权限控制,引入 RBAC(Role-Based Access Control)模型中的"权限"概念,使角色成为一组权限的容器。这样做的好处是:可以灵活组合不同权限给一个角色,再将角色授予用户;同时也支持角色继承等高级特性。

本文将展示如何对现有用户、角色实体和数据库进行改造,并补充完整的可运行代码示例。

实体类关系总览

下图展示了改造后的核心实体关系:
#mermaid-svg-bgrJIiKIFsOmKNrm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bgrJIiKIFsOmKNrm .error-icon{fill:#552222;}#mermaid-svg-bgrJIiKIFsOmKNrm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bgrJIiKIFsOmKNrm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bgrJIiKIFsOmKNrm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bgrJIiKIFsOmKNrm .marker.cross{stroke:#333333;}#mermaid-svg-bgrJIiKIFsOmKNrm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bgrJIiKIFsOmKNrm p{margin:0;}#mermaid-svg-bgrJIiKIFsOmKNrm g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-bgrJIiKIFsOmKNrm g.classGroup text .title{font-weight:bolder;}#mermaid-svg-bgrJIiKIFsOmKNrm .cluster-label text{fill:#333;}#mermaid-svg-bgrJIiKIFsOmKNrm .cluster-label span{color:#333;}#mermaid-svg-bgrJIiKIFsOmKNrm .cluster-label span p{background-color:transparent;}#mermaid-svg-bgrJIiKIFsOmKNrm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bgrJIiKIFsOmKNrm .cluster text{fill:#333;}#mermaid-svg-bgrJIiKIFsOmKNrm .cluster span{color:#333;}#mermaid-svg-bgrJIiKIFsOmKNrm .nodeLabel,#mermaid-svg-bgrJIiKIFsOmKNrm .edgeLabel{color:#131300;}#mermaid-svg-bgrJIiKIFsOmKNrm .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-bgrJIiKIFsOmKNrm .label text{fill:#131300;}#mermaid-svg-bgrJIiKIFsOmKNrm .labelBkg{background:#ECECFF;}#mermaid-svg-bgrJIiKIFsOmKNrm .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-bgrJIiKIFsOmKNrm .classTitle{font-weight:bolder;}#mermaid-svg-bgrJIiKIFsOmKNrm .node rect,#mermaid-svg-bgrJIiKIFsOmKNrm .node circle,#mermaid-svg-bgrJIiKIFsOmKNrm .node ellipse,#mermaid-svg-bgrJIiKIFsOmKNrm .node polygon,#mermaid-svg-bgrJIiKIFsOmKNrm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bgrJIiKIFsOmKNrm .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm g.clickable{cursor:pointer;}#mermaid-svg-bgrJIiKIFsOmKNrm g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-bgrJIiKIFsOmKNrm g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-bgrJIiKIFsOmKNrm .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-bgrJIiKIFsOmKNrm .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-bgrJIiKIFsOmKNrm .dashed-line{stroke-dasharray:3;}#mermaid-svg-bgrJIiKIFsOmKNrm .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-bgrJIiKIFsOmKNrm #compositionStart,#mermaid-svg-bgrJIiKIFsOmKNrm .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #compositionEnd,#mermaid-svg-bgrJIiKIFsOmKNrm .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #dependencyStart,#mermaid-svg-bgrJIiKIFsOmKNrm .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #dependencyStart,#mermaid-svg-bgrJIiKIFsOmKNrm .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #extensionStart,#mermaid-svg-bgrJIiKIFsOmKNrm .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #extensionEnd,#mermaid-svg-bgrJIiKIFsOmKNrm .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #aggregationStart,#mermaid-svg-bgrJIiKIFsOmKNrm .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #aggregationEnd,#mermaid-svg-bgrJIiKIFsOmKNrm .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #lollipopStart,#mermaid-svg-bgrJIiKIFsOmKNrm .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm #lollipopEnd,#mermaid-svg-bgrJIiKIFsOmKNrm .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-bgrJIiKIFsOmKNrm .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-bgrJIiKIFsOmKNrm .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bgrJIiKIFsOmKNrm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bgrJIiKIFsOmKNrm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bgrJIiKIFsOmKNrm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} roles
permissions
1
1
*
*
User
+Long id
+String username
+String password
+Set<Role> roles
+Collection<GrantedAuthority> getAuthorities()
Role
+Long id
+String roleName
+String displayName
+Set<Permission> permissions
Permission
+Long id
+String permissionName
+String displayName
+String getAuthority()
GrantedAuthority

UserRole 是多对多关系,RolePermission 也是多对多关系。Permission 实现了 GrantedAuthority,作为最终的权限检查单元。

数据库表结构

在原有 userrole 以及关联表 user_role 的基础上,新增 permission 表和中间表 role_permission。简化 DDL 如下:

sql 复制代码
CREATE TABLE permission (
    id           BIGINT AUTO_INCREMENT PRIMARY KEY,
    permission_name VARCHAR(50) NOT NULL UNIQUE,
    display_name VARCHAR(50)
);

CREATE TABLE role_permission (
    role_id       BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id),
    FOREIGN KEY (role_id) REFERENCES role(id),
    FOREIGN KEY (permission_id) REFERENCES permission(id)
);

实体类代码实现

Permission 实体

Permission 不再像旧版 Role 那样仅是一个被容器使用的普通实体,而是直接实现 GrantedAuthority,使其自身就能被 Spring Security 进行权限投票。

java 复制代码
package com.example.demo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.security.core.GrantedAuthority;

import javax.persistence.*;
import java.util.Set;

@Entity
@Table(name = "permission")
public class Permission implements GrantedAuthority {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "permission_name", unique = true, nullable = false, length = 50)
    private String permissionName;

    @Column(name = "display_name", length = 50)
    private String displayName;

    @JsonIgnore
    @ManyToMany(mappedBy = "permissions", fetch = FetchType.LAZY)
    private Set<Role> roles;

    // --- constructors ---
    public Permission() {}

    public Permission(String permissionName, String displayName) {
        this.permissionName = permissionName;
        this.displayName = displayName;
    }

    // --- implements GrantedAuthority ---
    @Override
    public String getAuthority() {
        return this.permissionName;
    }

    // --- getters & setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getPermissionName() { return permissionName; }
    public void setPermissionName(String permissionName) { this.permissionName = permissionName; }
    public String getDisplayName() { return displayName; }
    public void setDisplayName(String displayName) { this.displayName = displayName; }
    public Set<Role> getRoles() { return roles; }
    public void setRoles(Set<Role> roles) { this.roles = roles; }
}

要点说明:

  • permissionName 字段在数据库中是唯一约束,即每个权限标识全局唯一。
  • displayName 用于前端展示,方便管理员理解。
  • @JsonIgnore 加在 roles 上,避免在序列化时陷入无限循环。
  • 因为 GrantedAuthority.getAuthority() 直接返回 permissionName,权限表达式可以直接用在 @PreAuthorize 或配置中。

Role 实体改造

Role 原本实现了 GrantedAuthority,现在剥离该接口,并加入与 Permission 的多对多关系。

java 复制代码
package com.example.demo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;

import javax.persistence.*;
import java.util.Set;

@Entity
@Table(name = "role")
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "role_name", unique = true, nullable = false, length = 50)
    private String roleName;

    @Column(name = "display_name", length = 50)
    private String displayName;

    @JsonIgnore
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
        name = "role_permission",
        joinColumns = @JoinColumn(name = "role_id"),
        inverseJoinColumns = @JoinColumn(name = "permission_id")
    )
    private Set<Permission> permissions;

    // 与 User 的多对多关系,保持原有映射
    @JsonIgnore
    @ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY)
    private Set<User> users;

    // --- constructors ---
    public Role() {}

    public Role(String roleName, String displayName) {
        this.roleName = roleName;
        this.displayName = displayName;
    }

    // --- getters & setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getRoleName() { return roleName; }
    public void setRoleName(String roleName) { this.roleName = roleName; }
    public String getDisplayName() { return displayName; }
    public void setDisplayName(String displayName) { this.displayName = displayName; }
    public Set<Permission> getPermissions() { return permissions; }
    public void setPermissions(Set<Permission> permissions) { this.permissions = permissions; }
    public Set<User> getUsers() { return users; }
    public void setUsers(Set<User> users) { this.users = users; }
}

中间表 role_permission 通过 @JoinTable 声明,外键列分别指向 role 表和 permission 表。@JsonIgnore 在两端都使用了,防止序列化时产生循环依赖。

User 实体改造

用户实体最大的变化在于 getAuthorities() 方法。原来直接从 roles 集合中返回角色即可(因为角色本身就是 GrantedAuthority),现在角色本身不再是权限,需要将角色内部的权限集合"展开",同时还要保留角色本身作为一种特殊的权限(通常带有 ROLE_ 前缀)。

java 复制代码
package com.example.demo.entity;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Entity
@Table(name = "users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    // 多对多关联角色
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "user_role",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> roles = new HashSet<>();

    // ... 其他 UserDetails 所需字段 (enabled, accountNonExpired 等)

    /**
     * 核心方法:将用户拥有的角色及角色内的权限统一转换为 GrantedAuthority 集合。
     * 角色名称前自动添加 "ROLE_" 前缀,权限名直接使用。
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 将角色集合转换为流,对每个角色进行映射与展开
        return roles.stream()
                .flatMap(role -> {
                    // 角色本身作为一种权限:加上 ROLE_ 前缀
                    Stream<GrantedAuthority> roleAuthority =
                            Stream.of(new SimpleGrantedAuthority("ROLE_" + role.getRoleName()));
                    // 角色内部的权限集合
                    Stream<GrantedAuthority> permissionAuthorities =
                            role.getPermissions().stream()
                                    .map(permission -> new SimpleGrantedAuthority(permission.getPermissionName()));
                    // 将两个流合并
                    return Stream.concat(roleAuthority, permissionAuthorities);
                })
                .collect(Collectors.toSet());
    }

    // ... getters & setters, 其他 UserDetails 方法实现
}

方法解析:

  • roles.stream() 获得角色流。
  • flatMap 接收一个函数,该函数对每个角色返回一个 Stream<GrantedAuthority>flatMap 会将所有子流"拍平"为一个连续的流。
  • 对于每个角色,先创建其自身的权限 SimpleGrantedAuthority("ROLE_xxx"),再将其 permissions 集合中的每个权限映射为 SimpleGrantedAuthority,最后用 Stream.concat 合并。
  • 最终收集到 Set 中,去除重复权限。

这样,一个拥有角色 ADMIN(该角色包含权限 USER_READ, USER_WRITE)的用户,其最终的权限集合会包含 ROLE_ADMIN, USER_READ, USER_WRITE。Spring Security 在进行 hasRole('ADMIN')hasAuthority('USER_READ') 检查时均可正确匹配。

角色分级与动态权限层级

很多时候需要定义角色继承关系,例如 ROLE_ADMIN 自动包含 ROLE_STAFF 的所有权限。Spring Security 提供了 RoleHierarchy 机制,通过表达式描述层级。

下面我们实现一个从数据库加载角色及权限并动态构建层级表达式的服务。

java 复制代码
package com.example.demo.security;

import com.example.demo.entity.Permission;
import com.example.demo.entity.Role;
import com.example.demo.repository.RoleRepository;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class RoleHierarchyService {

    private final RoleRepository roleRepository;

    public RoleHierarchyService(RoleRepository roleRepository) {
        this.roleRepository = roleRepository;
    }

    public RoleHierarchy buildRoleHierarchy() {
        List<Role> roles = roleRepository.findAllWithPermissions(); // 假设 Repository 提供 fetch join 查询
        StringBuilder expression = new StringBuilder();

        for (Role role : roles) {
            String roleName = "ROLE_" + role.getRoleName();
            // 该角色下的所有权限作为其包含的 authority
            List<String> authorityNames = role.getPermissions().stream()
                    .map(Permission::getPermissionName)
                    .collect(Collectors.toList());

            for (String authority : authorityNames) {
                expression.append(roleName).append(" > ").append(authority).append("\n");
            }
        }

        // 手动添加固定的顶级层级,如 ADMIN > STAFF
        expression.append("ROLE_ADMIN > ROLE_STAFF\n");

        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy(expression.toString());
        return roleHierarchy;
    }
}

在 Security 配置中将其声明为 Bean:

java 复制代码
@Bean
public RoleHierarchy roleHierarchy(RoleHierarchyService service) {
    return service.buildRoleHierarchy();
}

同时需要在 WebSecurityConfigurerAdapter 配置中启用角色层级:

java 复制代码
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .expressionHandler(webExpressionHandler()) // 需要传入 roleHierarchy
        // ... 其他配置
        ;
}

@Bean
public DefaultWebSecurityExpressionHandler webExpressionHandler(RoleHierarchy roleHierarchy) {
    DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
    handler.setRoleHierarchy(roleHierarchy);
    return handler;
}

这样,当检查 hasRole('ADMIN') 时,系统会根据层级表达式自动认为其拥有 STAFF 角色以及相应权限。

初始数据示例

建议在 data.sql 中初始化权限和角色关联。权限命名采用"资源_操作"的模式,清晰易读。

sql 复制代码
-- 插入权限
INSERT INTO permission (permission_name, display_name) VALUES ('USER_READ', '查看用户');
INSERT INTO permission (permission_name, display_name) VALUES ('USER_CREATE', '创建用户');
INSERT INTO permission (permission_name, display_name) VALUES ('USER_UPDATE', '修改用户');
INSERT INTO permission (permission_name, display_name) VALUES ('USER_DELETE', '删除用户');
INSERT INTO permission (permission_name, display_name) VALUES ('ORDER_READ', '查看订单');

-- 插入角色
INSERT INTO role (role_name, display_name) VALUES ('ADMIN', '管理员');
INSERT INTO role (role_name, display_name) VALUES ('STAFF', '普通员工');

-- 角色-权限关联
-- ADMIN 拥有所有权限
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1);
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 2);
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 3);
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 4);
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 5);

-- STAFF 只能查看用户和订单
INSERT INTO role_permission (role_id, permission_id) VALUES (2, 1);
INSERT INTO role_permission (role_id, permission_id) VALUES (2, 5);

目录结构参考

改造完成后,实体与安全配置相关的典型目录结构如下:

dir 复制代码
src/main/java/com/example/demo/
├── entity/
│   ├── User.java
│   ├── Role.java
│   └── Permission.java
├── repository/
│   ├── UserRepository.java
│   └── RoleRepository.java
├── security/
│   ├── RoleHierarchyService.java
│   └── SecurityConfig.java
└── service/
    └── UserService.java

关键点回顾

  • 实体职责清晰化Permission 直接承载 GrantedAuthority,角色作为权限容器。
  • getAuthorities() 扁平化处理 :利用 flatMap 将多维集合转换为一维权限列表,同时保留 ROLE_ 前缀的角色权限。
  • 避免序列化陷阱 :在多对多关系两端使用 @JsonIgnore 或 DTO 投影。
  • 角色层级动态构建 :从数据库读取角色与权限后生成 RoleHierarchy 表达式,实现灵活的权限继承。
  • 权限命名规范 :使用 资源_操作 风格,便于管理和阅读。

总结

本文基于视频材料,将口头描述的技术改造步骤整理为完整的可运行代码与配置,覆盖了从实体建模、JPA 映射到 Spring Security 集成的完整链路。

所有代码片段均可直接复制使用,只需根据实际包名调整 import。

相关推荐
讳疾忌医丶1 小时前
深度拆解 RocksDB 内核:基于 C++17 的 FIFO 调度状态机与温度阶梯自愈设计
java·c++·算法·架构
郑州光合科技余经理2 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
毅炼2 小时前
Rover-Suite 开源发布:轻量服务注册中心与网关一体化方案
java·后端·系统架构·gateway
梦想平凡2 小时前
百游棋牌源代码开发搭建教程(十):隔离部署、备份恢复与双端验收
java·前端·javascript·数据库·源代码管理
xcLeigh2 小时前
让大模型长出手脚,自己写SQL查数据库(Function Calling初探)
数据库·人工智能·sql·时序数据库·timechoai
yume_sibai3 小时前
06-Rust Web 开发实战(Axum 框架 + 数据库 + JWT 认证 + 中间件 + 部署)
前端·数据库·rust
sunshine22 girl3 小时前
Idea中如何搜索
java·ide·intellij-idea
猫吻鱼3 小时前
【AI 01】【Spring AI 基础使用】
java·spring·spring ai
梨涡泥窝4 小时前
基于SSM的校园二手交易平台的设计与实现
java·tomcat