2025.10.23开始,记录shieldchain后端开发。

10.23

项目结构创建

按照苍穹外卖的工程格式创建shieldChain-backend,依赖编译正确需要先创建一个入口main函数

数据库

参考苍穹外卖:数据库中的字段都是下划线命名,pojo实体里面都是驼峰命名

导入数据库.sql,需要忽略错误继续执行,navicat一页只显示一千条数据

目前有五张表:user,software,cve_main, cve_cvss, cve_configurations

1. cve_main 表(漏洞基本信息表)-- 用于给用户展示漏洞清单

字段名 含义说明
id 自增主键(表内唯一标识,无业务含义)
cve_id CVE 漏洞编号(如CVE-2024-0001,全球唯一标识,核心关联字段)
assigner 漏洞分配机构(如psirt@purestorage.com,负责发布该漏洞的机构)
problem_type 漏洞类型(如CWE-1188,对应 CWE 漏洞分类标准)
description 漏洞描述(详细说明漏洞的成因、影响等)
published_date 漏洞发布时间(首次公开的时间)
last_modified_date 漏洞最后更新时间(漏洞信息被修改的最新时间)

2. cve_cvss 表(漏洞评分信息表)-- 用于单个评分渲染查询

字段名 含义说明
id 自增主键(表内唯一标识)
cve_id 关联的 CVE 漏洞编号(与cve_main表的cve_id对应)
base_score CVSS 基础评分(如9.8,0-10 分,分数越高风险越高)
base_severity 基础严重级别(如CRITICAL,通常分 CRITICAL/High/Medium/Low)
vector_string CVSS 向量字符串(如CVSS:3.1/AV:N/AC:L/...,详细描述评分维度)

3. cve_configurations 表(漏洞影响范围表)-- 用于生成漏洞清单匹配漏洞

字段名 含义说明
id 自增主键(表内唯一标识)
cve_id 关联的 CVE 漏洞编号(与cve_main表的cve_id对应)
cpe23_uri CPE 标识(如cpe:2.3:a:purestorage:purity//fa:*,标识受影响的产品 / 版本)
version_start_including 受影响的起始版本(包含该版本)
version_end_including 受影响的结束版本(包含该版本)
vulnerable 是否存在漏洞(1表示受影响,0表示不受影响)

其中,表1是漏洞详细信息,用于用户查看漏洞清单时,点开某一条漏洞才进行查询;表2是漏洞的cvss评分,用于用户查看漏洞风险时,查询显示,需要渲染;表3用于漏洞扫描过程,在匹配漏洞时使用。

所以设计3张表,而不是合在一起。

如果合并到一张表,会导致:

  • 表结构冗余:每个受影响的版本都要重复存储漏洞的基本信息(如描述、发布时间)。
  • 扩展性差:新增一个受影响版本时,需重复录入大量冗余字段。

数据库知识回顾:1NF要求列不再分;2NF要求非主键列完全依赖于主键,不能存在部分依赖;3NF要求非主键不能传递依赖于主键。

使用Navicat导出ER图详细教程-CSDN博客

设置外键约束

1. 三张漏洞表的id外键约束

2. software和user表的用户所有者外键约束

关于DID要不要单独建一张表:单独创建,DID是单独的实体,而且对软件和DID的权限 有不同控制策略。

software和user表应该使用id做主键还是did

保留 id 作为主键,did 设为唯一键(推荐):继续使用 id(自增 int 类型),给 did 字段添加 UNIQUE KEY,确保其业务唯一性。

优点:

  1. 性能更优id 是自增 int 类型,作为主键时索引效率(B + 树索引)远高于字符串类型的 did(varchar (255)),尤其在关联查询、排序、分页时性能差距明显。
  2. 兼容性更好:外键关联通常更适合用数值型主键(若未来 software 表需要被其他表关联,用 id 作为外键比 did 更高效)。
  3. 业务与存储分离id 仅用于数据库内部标识(无业务含义),did 作为业务唯一标识(可单独维护,即使未来 did 规则变更,也不影响主键和关联关系)。

数据库完善:

1. 创建商业软件申请表 biz_software_apply

字段名 数据类型 主键 / 外键 约束 说明
apply_id INT PK AUTO_INCREMENT 申请唯一标识
buyer_id INT FK NOT NULL 采购方 ID,关联user.id
soft_id INT FK NOT NULL 申请的软件 ID,关联software.id
reason TEXT NOT NULL 申请原因(如 “评估软件组件安全性”)
status TINYINT NOT NULL, DEFAULT 0 申请状态:0 - 待审批,1 - 通过,2 - 拒绝
approver_id INT FK 审批人 ID(软件所属团队的管理员),关联user.id
create_time DATETIME NOT NULL, DEFAULT NOW() 申请提交时间
handle_time DATETIME 记录处理完成时间(审批通过/拒绝时填写)

2. 创建表格sys_role

  1. “Shield Chain” 系统基于角色(管理员、只读用户、普通用户)分配系统权限。系统每位用户只能在一个团队中,当前用户默认是团队管理员。系统支持邮件或链接形式为用户发送团队加入邀请。
  2. 系统中只读用户的权限是:查看所有软件资产DID、SBOM、漏洞清单,不可修改。
  3. 系统中普通用户的权限是:查看所有软件资产DID、SBOM、漏洞清单,可以进行软件管理相关操作(DID冻结、注销);可以上传软件包(系统自动颁发DID、生成SBOM、漏洞清单);无团队管理权限,无商业软件批准权限。
  4. 系统中管理员的权限是:查看所有软件资产DID、SBOM、漏洞清单,可以进行软件管理相关操作;上传软件包;进行团队管理(邀请新成员、移除成员);批准/拒绝采购方提交的商业软件申请,提交采购申请。
  5. 系统默认用户注册后没有团队,只有在用户向别的用户发起邀请才创建一个团队,邀请发起者默认是管理员,只有收到邀请的人确认之后才可以真正加入这个团队,一个团队里可有多个管理员。

3. 创建表格sys_team

4. 完善表格user

由于团队管理功能中,每个人可以直接看到所有成员的角色,为了缓解数据库压力,应对高并发,给user表设计了一个冗余字段:role_name,减少表的join

由于团队不是默认创建的,是在用户邀请别人时才创建,所以role_id设置为默认null ,意思是如果不创建团队,team_role_id、role_name、team_id都默认是null

5. 建团队管理员表sys_team_admin

因为系统支持一个团队有多个管理员,一个用户最多只能在一个团队中

高并发场景下,查询 “某团队的所有管理员” 是高频操作(比如展示管理员列表、判断操作权限):

  • 若所有成员都在这个表中,查询时需要从 “所有成员” 里过滤出team_role_id=3的管理员,相当于扫描全表的团队成员,效率低;
  • 现在的设计中,sys_team_admin表只存管理员,查询时直接按team_id取数据,无需过滤,速度更快(尤其团队成员数量多的时候,差异更明显)。

6. 创建团队成员邀请表

7. 将software和did分离,创建did表

8. DID操作审计表 

建表。

审计的逻辑:

1. 用触发器,当DID表发生变化时触发器自动填写审计表

2. 在后端开发实现,灵活性更高、可维护性更强,且能更好地结合业务逻辑和用户上下文(如当前操作人信息)。AOP(面向切面编程)是最优选择,通过切面拦截数据的 CRUD 操作,自动注入审计逻辑,完全与业务代码解耦。   此外,还可以在service层手动显式实现审计填写。

9. 建表software_file_info

第一次为软件包生成SBOM和漏洞清单时,存储到本地特定文件夹下,存到这张表中,可以采用AOP切面编程或在service层手动编程写入表记录。

数据库所有表设计:

由于AOP,将所有表格中统一命名create_time, create_user, update_time, update_user

1. biz_software_apply

2. cve_configurations:用于匹配漏洞清单

3. cve_cvss评分表

4. cve_main

5. software表

6. did表

7. did_audit_log审计表

8. software_file_info

9. user

10. sys_role

11. sys_team表

12. sys_invitation

13. sys_team_admin团队管理员表

10.27 

了解前端结构

项目根目录文件

  • .vscode:存放 VS Code 编辑器的配置文件,用于定制开发环境(如代码格式化、语法检查等)。
  • dist:项目构建(打包)后的产物目录,包含可直接部署到服务器的静态文件(HTML、CSS、JS、图片等)。
  • node_modules:项目依赖的第三方库(如 Vue、Vue Router、Axios 等)都安装在这个目录,由 package.json 管理。
  • public:存放不需要经过编译处理的静态资源,这些文件会被直接复制到 dist 目录中。
    • mock:可能用于存放模拟数据(模拟后端接口返回的假数据,方便前端在无后端支持时开发)。
    • templates:可能存放 HTML 模板文件(如邮件模板、页面模板等)。
    • favicon.ico:网站的图标,显示在浏览器标签页上。
  • src:项目的源代码目录,前端开发的核心工作都在这个目录中。
    • api:存放与后端接口交互的代码(如封装 Axios 请求、定义接口地址和参数等)。
    • assets:存放静态资源,如图片、字体、样式文件(CSS、SCSS)等。
    • components:存放可复用的 Vue 组件(如按钮、弹窗、表格等通用组件)。
    • router:存放路由配置文件,用于管理页面之间的跳转(如 Vue Router 的路由规则定义)。
    • stores:如果使用了状态管理库(如 Vuex、Pinia),这里存放全局状态的定义和管理逻辑。
    • utils:存放工具函数(如时间格式化、数据加密、本地存储操作等通用功能)。
    • views:存放页面级的 Vue 组件(每个文件对应一个完整的页面,如登录页、首页等)。
      • Function:可能是一个分类文件夹,用于存放某一类功能相关的页面组件。
      • DIDManagement.vue:一个具体的页面组件,可能是 “DID 管理” 页面。
      • FirstPage.vue:“首页” 页面组件。
      • Function.vue:可能是 “功能列表” 页面组件。
      • Introduction.vue:“介绍” 页面组件。
      • Login.vue:“登录” 页面组件。
      • Major.vue:可能是 “主要功能” 或 “专业模块” 页面组件。
      • Test.vue:“测试” 页面组件(用于开发时测试功能)。
    • App.vue:项目的根组件,是所有页面组件的父组件,可在这里定义全局布局(如导航栏、页脚)。
    • main.js:项目的入口文件,用于初始化 Vue 应用、挂载根组件、引入全局资源(如样式、插件)等。
  • .gitignore:Git 版本控制的忽略文件配置,指定哪些文件或目录不提交到代码仓库(如 node_modulesdist 等)。
  • index.html:项目的入口 HTML 文件,Vue 应用会挂载到这个文件的某个 DOM 节点上。
  • jsconfig.json:用于配置 JavaScript 项目的编译选项(如模块解析规则、编译目标等),让编辑器能更好地理解代码结构。
  • package-lock.json:锁定项目依赖的具体版本,确保团队成员安装的依赖完全一致,避免版本差异导致的问题。
  • package.json:项目的配置文件,定义了项目的名称、版本、依赖库、脚本命令(如启动、构建项目)等。
  • README.md:项目的说明文档,通常包含项目介绍、安装步骤、运行方法等信息。
  • vite.config.js:Vite 构建工具的配置文件,用于自定义项目的构建、开发服务器等配置(如配置代理、别名、打包选项等)。

配置swagger接口文档

可以生成接口文档,实现接口在线调试

1. 在pom.xml导入maven坐标

2. server新建一个config类,WebMvcConfiguration

3. 后端运行起来,访问 https://localhost:8080/doc.html

苍穹外卖-Day1 | 环境搭建、nginx、git、令牌、登录加密、接口文档、Swagger-CSDN博客

server-WebMvcConfiguration

/**
     * 通过knife4j生成接口文档
     * @return
     */
    @Bean
    public Docket docket(){
        log.info("准备生成接口文档...");
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("链盾系统接口文档")
                .version("1.0")
                .description("基于分布式数字身份的软件可信标识系统")
                .build();

        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo)
                .select()
                //指定生成接口需要扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
                .paths(PathSelectors.any())
                .build();

        return docket;
    }


    /**
     * 设置静态资源映射
     * @param registry
     */
    protected void addResourceHandlers(ResourceHandlerRegistry registry){
        log.info("开始设置静态资源映射...");
        registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

用户登录功能开发

1. 配置application.yml和application-dev.yml

2. 配置数据库连接

苍穹外卖-Day2 | 员工管理、分类模块、分页查询、编辑员工、启用禁用员工账号、IDE配置SQL提示、ThreadLocal-CSDN博客

3. entity新建User实体,和数据库字段对应

package com.sky.entity;

import io.swagger.models.auth.In;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {

    // 序列化机制的版本标识,用于反序列化时验证类的版本一致性(如果类结构发生变化,修改此值可以避免反序列化异常)
    private static final long serialVersionUID = 1L;

    private Integer id;

    private String did;

    private String username;

    private String password;

    private String socialCode;

    private LocalDateTime createdTime;

    private LocalDateTime updatedTime;

    private String createdBy;

    // 账号状态 1-正常,0-禁用
    private Integer status;

    // 团队内角色ID(1-只读,2-普通,3-管理员;NULL表示未加入团队)
    private Integer teamRoleId;

    // 团队内角色名称-(冗余,与team_role_id对应:1-只读用户,2-普通用户,3-管理员;NULL表示未加入团队)
    private String roleName;

    // 所属团队ID(NULL表示未加入团队)
    private Integer teamId;

}

4. 设计UserLoginDTO和UserLoginVO

一定要注意:DTO的字段设计要和前端数据每个字段都相同,VO的设计要和前端处理时一一对应(比如在stores/新建一个)。

        DTO(Data Transfer Object)用于接收前端传递的登录参数(如用户名、密码),是前端→后端的 “输入型” 对象。前端传递 JSON 格式的请求体,字段与DTO匹配。

        VO(View Object)用于后端返回给前端的登录结果,是后端→前端的 “输出型” 对象。VO 绝对不能包含敏感信息(如密码、身份证号),敏感字段需在传输前过滤。

        DTO/VO 只保留必要字段,避免冗余传输(如 DTO 不包含用户 ID,VO 不包含密码)。

  • Controller 层接收UserLoginDTO参数,进行校验;
  • 服务层校验用户名密码,生成令牌(如 JWT);
  • 构建UserLoginVO对象,包含用户基本信息和令牌,返回给前端。

在前端src/views的Login.vue里面,找到了在登录时,前端传给后端的数据

// 表单数据
const loginForm = ref({
    username: '',
    password: '',
    agreement: false
})
//登录按钮的提交逻辑
import { useTokenStore } from '@/stores/token.js'
import { userLoginService } from '@/api/user.js'
const tokenStore = useTokenStore()
const handleLogin = async () => {
    if (!loginForm.value.agreement) {
        ElMessage.warning('请阅读并同意服务条款和隐私政策')
        return
    }

    try {
        loginLoading.value = true
        let result = await userLoginService(loginForm.value)
        if (result.code === 1) {
            ElMessage.success(result.msg ? result.msg : '登录成功')
            tokenStore.setToken(result.data)

            // 处理重定向逻辑
            const redirectPath = route.query.redirect ? route.query.redirect : '/Major'
            router.push(redirectPath)
        } else {
            ElMessage.error(result.msg ? result.msg : '登录失败')
        }
    } catch (error) {
        ElMessage.error('登录请求失败')
    } finally {
        loginLoading.value = false
    }
}

所以UserLoginDTO如下所示,DTO和前端传过来的数据字段名称要一模一样!

@Data
@ApiModel(description = "用户登录时传递的数据模型")
public class UserLoginDTO implements Serializable{

    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("密码")
    private String password;
}

UserLoginVO:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "员工登录返回的数据格式")
public class UserLoginVO implements Serializable {

    @ApiModelProperty("主键值")
    private Integer id;

    @ApiModelProperty("用户名")
    private String userName;

    @ApiModelProperty("jwt令牌")
    private String token;

}

前端修改:

将stores/token.js改成user.js,因为后端登录成功后传过来的是上面的VO,因为需要在页面右上角一直展示用户名称

import { defineStore } from "pinia"

/*
defineStore参数描述:
    第一个参数:给状态起名,具有唯一性
    第二个参数:函数,可以定义该状态中拥有的内容 
defineStore返回值描述:
    返回的是一个函数,将来可以调用该函数,得到第二个参数中返回的内容
*/
/*
以后就可以通过
const tokenStore = useTokenStore()
tokenStore.token
tokenStore.setToken()
tokenStore.removeToken()
获取token,设置,移除token
*/
// 补充 persist: true,和原来的代码保持一致
export const useUserStore = defineStore('user', {
    state: () => ({
      token: '',
      id: null,
      username: ''
    }),
    actions: {
      setUserInfo(info) {
        this.token = info.token
        this.id = info.id
        this.username = info.username
      },
      logout() {
        this.token = ''
        this.id = null
        this.username = ''
      }
    }
  }, {
    persist: true // 必须加上,否则刷新页面后状态丢失
  })

同时修改登录逻辑

//登录按钮的提交逻辑
import { useUserStore } from '@/stores/user.js'
import { userLoginService } from '@/api/user.js'
const tokenStore = useUserStore()
const handleLogin = async () => {
    if (!loginForm.value.agreement) {
        ElMessage.warning('请阅读并同意服务条款和隐私政策')
        return
    }

    try {
        loginLoading.value = true
        let result = await userLoginService(loginForm.value)
        // 根据后端的Result设计,成功是1,失败是0
        if (result.code === 1) {
            ElMessage.success(result.msg ? result.msg : '登录成功')
            // 2. 从result.data中提取token字段(因为后端返回的data是UserLoginVO对象)
            tokenStore.setUserInfo(result.data)

            // 处理重定向逻辑
            const redirectPath = route.query.redirect ? route.query.redirect : '/Major'
            router.push(redirectPath)
        } else {
            ElMessage.error(result.msg ? result.msg : '登录失败')
        }
    } catch (error) {
        ElMessage.error('登录请求失败')
    } finally {
        loginLoading.value = false
    }
}

JWT令牌/token

JWT 本质是一个经过加密的字符串,由三部分组成(用.分隔):

  • Header(头部):声明签名算法(如 HS256)。
  • Payload(载荷):存储实际需要传递的信息(如用户 ID、角色等,即JwtUtil中的claims)。
  • Signature(签名):用密钥对 Header 和 Payload 进行加密生成的签名,用于验证令牌是否被篡改。

登录验证→生成令牌→前端存储→请求携带→后端验证→处理业务

前端通过登录表单提交用户名和密码;后端接收登录参数,查询数据库验证是否正确; 若验证通过,后端使用JwtUtil.createJwt()生成令牌; 后端将令牌封装到相应结果(UserLoginVO)中返回;前端接收到令牌后,通常存储在localStoragesessionStorageCookie中(根据需求选择,如localStorage可持久化存储)。

前端调用需要身份认证的接口时,必须在请求头中携带JWT(通常放在Authorization字段);后端从请求头Authorization中提取令牌,使用JwtUtil.parseJWT()验证令牌;如果令牌验证通过,后端根据解析出的用户信息(如userId)处理请求。

当令牌过期或验证失败时,后端返回401状态码;前端接收到401相应时,通常会跳转到登录页,要求用户重新登录并获取令牌。

JWT需要设计:

1. 在common新建constant,JwtClaimsConstant,里面是自定义的JWT载荷claims部分字段设计

设计 JWT 的 Claims(载荷)需要结合你的系统业务场景,核心原则是:只放必要的、非敏感的身份标识和基础信息,避免冗余或敏感数据(如密码)。

  1. 前端通过 userLoginService(loginForm.value) 把用户名和密码传给后端的登录接口。
  2. 后端接收到请求后,先校验用户名和密码是否正确(比如查询数据库比对)。
  3. 如果校验成功,后端会从数据库中查询该用户的核心信息(如 userIdusernamerole 等)。
  4. 接着,后端会创建一个 JWT 令牌,在生成令牌的过程中,将查询到的用户信息(如 userId: 1001username: "zhangsan")填充到 JWT 的 claims 中。
  5. 最后,后端把生成的 JWT 令牌(包含已填充的 claims)通过 result.data 返回给前端,前端再用 tokenStore.setToken 存储起来。
package com.sky.constant;

public class JwtClaimsConstant {

    //用户ID(数据库主键,Integer类型)
    public static final String USER_ID = "userId";
    public static final String USERNAME = "username";
    public static final String EMAIL = "email";
    public static final String USER_DID = "user_did";
    
}

2. JwtProperties

package com.sky.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
// 在server中的application.yml中,将指定前缀的配置项,自动绑定到当前类的属性上
@ConfigurationProperties(prefix = "sky.jwt")
@Data
public class JwtProperties {

    /**
     * 用户统一生成jwt令牌相关配置
     */
    private String secretKey;
    private long ttl;
    private String tokenName;
}

这个 JwtProperties 类是用来统一管理 JWT 相关配置的,核心作用是把配置文件(application.yml)里的 JWT 配置项,自动绑定到 Java 类的属性上,方便代码中统一调用,避免硬编码。

3. 编写JwtUtil类,两个函数,createJWT生成jwt令牌,parseJWT用于token解密

package com.sky.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;

public class JwtUtil {

    /**
     * 生成jwt
     * 使用HS256算法,私钥使用固定密钥
     * @param secretKey
     * @param ttlMillis
     * @param claims
     * @return
     */
    public static String createJwt(String secretKey, long ttlMillis, Map<String, Object> claims){
        // 指定签名的时候使用的签名算法,也就是header那部分
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        // 过期时间 = 生成JWT的时间 + 期限
        long expMillis = System.currentTimeMillis() + ttlMillis;
        Date exp = new Date(expMillis); // 转成date类型

        // 设置jwt的body
        JwtBuilder builder = Jwts.builder()
                // 如果有私有声明,一定要先设置这个自己创建的私有声明,这是给builder的claim赋值,一旦写在标准的声明赋值之后,就会覆盖标准声明
                .setClaims(claims)
                // 设置签名使用的签名算法和签名使用的密钥
                .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
                // 设置过期时间
                .setExpiration(exp);

        return builder.compact();
    }

    public static Claims parseJWT(String secretKey, String token){
        // 得到DefaultJwtParser
        Claims claims = Jwts.parser()
                // 设置签名的私钥
                .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
                // 设置需要解析的jwt
                .parseClaimsJws(token).getBody();
        return claims;
    }
}

4. common新建context包新建BaseContext类--ThreadLocal

使用ThreadLocal动态获取当前登录用户的id

package com.sky.context;

public class BaseContext {
    
    public static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
    
    public static void setCurrentId(Integer id){
        threadLocal.set(id);
    }
    
    public static Integer getCurrentId(){
        return threadLocal.get();
    }
    
    public static void removeCurrentId(){
        threadLocal.remove();
    }
}

5. server新建一个interceptor拦截器类,JwtTokenInterceptor

在这个拦截器里面就可以存入当前用户id到ThreadLocal

package com.sky.interceptor;


import com.sky.constant.JwtClaimsConstant;
import com.sky.context.BaseContext;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * jwt令牌校验的拦截器
 */
@Component
@Slf4j
public class JwtTokenInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 校验jwt
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断当前拦截到的是Controller的方法还是其他资源
        if (!(handler instanceof HandlerMethod)){
            /// 当前拦截道德不是动态方法,直接放行
            return true;
        }

        // 1. 从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getTokenName());

        // 2. 校验令牌
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getSecretKey(), token);
            Integer userId = Integer.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
            log.info("当前用户id:{}", userId);

            BaseContext.setCurrentId(userId);

            // 3. 通过,放行
            return true;
        } catch (Exception ex){
            // 4. 不通过,响应401状态码
            response.setStatus(401);
            return false;
        }
    }

}

6. 注册拦截器interceptor

在server-config

package com.sky.config;

import com.sky.interceptor.JwtTokenInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

/**
 * 配置类,注册web层相关组件
 */
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {

    @Autowired
    private JwtTokenInterceptor jwtTokenInterceptor;

    protected void addInterceptors(InterceptorRegistry registry){
        log.info("开始注册自定义拦截器...");
        registry.addInterceptor(jwtTokenInterceptor)
                .addPathPatterns("/user/**")
                .excludePathPatterns("/user/login");
    }

    /**
     * 通过knife4j生成接口文档
     * @return
     */
    public Docket docket(){
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("链盾系统接口文档")
                .version("1.0")
                .description("基于分布式数字身份的软件可信标识系统")
                .build();

        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo)
                .select()
                //指定生成接口需要扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
                .paths(PathSelectors.any())
                .build();

        return docket;
    }
}

7. Jwt令牌的生成在UserController

package com.sky.controller;

import com.sky.constant.JwtClaimsConstant;
import com.sky.dto.UserLoginDTO;
import com.sky.entity.User;
import com.sky.properties.JwtProperties;
import com.sky.result.Result;
import com.sky.service.UserService;
import com.sky.utils.JwtUtil;
import com.sky.vo.UserLoginVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.annotations.Results;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * 用户管理
 */
@RestController
@RequestMapping("/user")
@Slf4j
@Api(tags = "用户相关接口")
public class UserController {

    @Autowired
    private UserService userService;
    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 登录
     * @param userLoginDTO
     * @return
     */
    @PostMapping("/login")
    @ApiOperation(value = "用户登录")
    // @RequestBody注解将HTTP请求体中的数据绑定到控制器方法的参数上
    public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO){
        log.info("用户登录:{}", userLoginDTO);

        User user = userService.login(userLoginDTO);

        // 登录成功后,生成jwt令牌
        Map<String, Object> claims = new HashMap<>();
        claims.put(JwtClaimsConstant.USER_ID, user.getId());//通过user实体获取id
        String token = JwtUtil.createJwt(
                jwtProperties.getSecretKey(),
                jwtProperties.getTtl(),
                claims);

        UserLoginVO userLoginVO = UserLoginVO.builder()
                .id(user.getId())
                .username(user.getUsername())
                .token(token)
                .build();

        return Result.success(userLoginVO);
    }

    /**
     * 退出
     * @return
     */
    @PostMapping("/logout")
    @ApiOperation(value = "员工退出")
    public Result<String> logout(){
        return Result.success();
    }


}

8. 编写service层

    /**
     * 用户登录
     * @param userLoginDTO
     * @return
     */
    User login(UserLoginDTO userLoginDTO);

9. UserServiceImpl

提前补充3个exception:AccountNotFoundException、PasswordErrorException、AccountLockedException

package com.sky.exception;

/**
 * 账号不存在异常
 */
public class AccountNotFoundException extends BaseException{
    public AccountNotFoundException(){
    }
    
    public AccountNotFoundException(String msg){
        super(msg);
    }
}
package com.sky.exception;

/**
 * 账号被锁定异常
 */
public class AccountLockedException extends BaseException{
    
    public AccountLockedException(){
    }
    
    public AccountLockedException(String msg){
        super(msg);
    }
}

编写一个StatusConstant,账户状态、DID状态等

package com.sky.constant;

/**
 * 状态常量,启用或者禁用
 */
public class StatusConstant {

    //启用
    public static final Integer ENABLE = 1;

    //禁用
    public static final Integer DISABLE = 0;
}

下面是UserServiceImpl

@Service
public class UserServiceImpl implements UserService{

    @Autowired
    private UserMapper userMapper;

    /**
     * 用户登录
     * @param userLoginDTO
     * @return
     */
    public User login(UserLoginDTO userLoginDTO) {
        String username = userLoginDTO.getUsername();
        String password = userLoginDTO.getPassword();

        //1. 根据用户名查询数据库中的数据
        User user = userMapper.getByUsername(username);

        //2. 处理各种异常情况(用户名不存在、密码不对、账号被锁定)
        if (user == null){
            // 账号不存在
            throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
        }

        // 密码比对
        // TODO 后期需要进行md5加密,然后再进行比对

        if (!password.equals(user.getPassword())){
            //密码错误
            throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
        }

        if (user.getStatus() == StatusConstant.DISABLE){
            // 账户被锁定
            throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
        }

        // 3. 返回实体对象
        return user;
    }
}

9. mapper层

package com.sky.mapper;

import com.sky.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;


@Mapper
public interface UserMapper {
    
    /**
     * 根据用户名查询用户
     * @param username
     * @return
     */
    @Select("select * from user where username = #{username}}")
    User getByUsername(String username);
}

用户登录功能测试

每次写完代码通过右侧maven的全局compile进行编译,然后运行application

由于将前端的tokenStore改成了UserStore,所以全局查找token进行修改

前端页面白屏时使用F12查看错误

出错: Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported]

后端接口不支持前端发送的请求数据格式导致的,具体是后端期望接收 JSON 格式application/json)的数据,但前端实际发送的是 表单格式application/x-www-form-urlencoded)的数据,所以后端抛出 HttpMediaTypeNotSupportedException

修改api/user.js

export const userLoginService = (loginData) => {
    //直接将loginData(JSON对象)作为请求体发送,axios自动设置Content-Type 为 application/json
    return request.post('/user/login', loginData)
}

问题2:昨天可以正常登录,但是今天启动前后端无需登录就可以直接访问。

原因是前端user.js使用persist: true,会把token存在localStorage(默认),localStorage是永久存储(除非手动删除或代码清空),不会随 JWT 过期自动失效。即使 JWT 已过期,前端仍会从localStorage读取旧 token 传给后端。

修改:在前端加入token过期时间校验

stores/user.js

import { defineStore } from "pinia"
import { jwtDecode } from 'jwt-decode'
/*
defineStore参数描述:
    第一个参数:给状态起名,具有唯一性
    第二个参数:函数,可以定义该状态中拥有的内容 
defineStore返回值描述:
    返回的是一个函数,将来可以调用该函数,得到第二个参数中返回的内容
*/
/*
以后就可以通过
const tokenStore = useTokenStore()
tokenStore.token
tokenStore.setToken()
tokenStore.removeToken()
获取token,设置,移除token
*/
// 补充 persist: true,和原来的代码保持一致
export const useUserStore = defineStore('user', {
    state: () => ({
      token: '',
      id: null,
      username: ''
    }),
    actions: {
      setUserInfo(info) {
        this.token = info.token
        this.id = info.id
        this.username = info.username
      },
      logout() {
        this.token = ''
        this.id = null
        this.username = ''
      },
      // 新增:校验token是否过期
      isTokenExpired() {
        if (!this.token) return true // 无token视为过期
        try {
            const decoded = jwtDecode(this.token) // 解析token
            const currentTime = Date.now() / 1000 // 当前时间(转秒,与exp格式一致)
            // exp是token过期时间(秒级时间戳),若当前时间>exp则过期
            return decoded.exp < currentTime
        } catch (error) {
            return true // 解析失败视为过期
        }
      }
    }
  }, {
    persist: true // 必须加上,否则刷新页面后状态丢失
  })

在路由守卫中添加过期校验

每次路由跳转前,先判断 token 是否过期,若过期则清空状态并跳转登录页:router/index.js加入

// 增强型路由守卫
router.beforeEach((to, from) => {
    const userStore = useUserStore()
    const isAuthenticated = !!userStore.token
    const isTokenExpired = userStore.isTokenExpired() // 校验token是否过期

    // 设置页面标题
    if (to.meta.title) {
        document.title = `${to.meta.title} - 链盾系统`
    }

    // 1. 若token存在但已过期,强制登出
    if (isAuthenticated && isTokenExpired) {
        userStore.logout()
        ElMessage.error('登录已过期,请重新登录')
        return { path: '/login', replace: true }
    }

    // 认证检查
    if (to.matched.some(record => record.meta.requiresAuth)) {
        if (!isAuthenticated) {
            ElMessage.error('请先登录以访问该功能')
            return {
                path: '/login',
                query: { redirect: to.fullPath },
                replace: true
            }
        }
    }

    // 来宾限制
    if (to.matched.some(record => record.meta.guestOnly) && isAuthenticated) {
        ElMessage.warning('您已登录,将跳转到主页面')
        return { path: '/Major', replace: true }
    }


})

11.2 

后端结构总览

Controller处理交互,Service处理业务,Mapper处理数据

  1. 📡 Controller层(控制器)

    • 职责:它是应用的“门面”,专门负责与客户端(如浏览器、APP)进行HTTP交互。包括接收请求参数、调用业务服务、封装并返回响应数据。

    • 交互:它通过依赖注入(使用 @Autowired或 @Resource注解)调用Service接口来执行业务逻辑,自身不处理具体的业务规则。通常会使用像 Vo(View Object)或 Dto(Data Transfer Object)这样的对象来传输数据,以避免直接暴露数据库实体。

  2. 🛡️ Interceptor(拦截器)

    • 职责:拦截器是面向切面编程(AOP)思想的体现,它在请求到达Controller之前和响应返回之后的整个过程中,提供横切关注点的通用处理能力。常见场景包括身份认证、日志记录、性能监控等。

    • 交互:当请求进入时,会先经过拦截器链。拦截器的 preHandle方法可以进行判断,若放行则请求继续流向Controller;若拦截则直接返回响应。在Controller处理完毕后,还会经过 postHandle(视图渲染前)和 afterCompletion(请求完全结束后)方法,用于进行后续处理或资源清理。

  3. ⚙️ Service层(服务接口)与ServiceImpl层(服务实现)

    • 职责:这一层封装了核心的业务逻辑,是应用程序的大脑。通常采用“接口+实现类”的设计模式。Service接口定义了业务功能契约,而 ServiceImpl类则包含具体的实现代码。

    • 交互:Controller层依赖于Service接口,而非具体的实现类。ServiceImpl类通过依赖注入调用Mapper层来存取数据,并在此过程中完成复杂的业务规则校验和处理。这种接口与实现分离的设计,有利于业务逻辑的独立性和解耦,也方便后续维护和测试。

  4. 💾 Mapper层(数据映射)

    • 职责:也被称为DAO层,是唯一与数据库直接打交道的层。它负责执行SQL语句,完成对数据库的增删改查操作。

    • 交互:MyBatis等持久层框架会为Mapper接口动态生成实现。ServiceImpl层注入Mapper接口,并通过其方法实现对数据库的访问,从而将业务逻辑和数据持久化彻底分离。

层级 / 组件 作用 敲代码时注意的事项
Interceptor 拦截 HTTP 请求,实现请求预处理(如登录校验、日志记录、参数统一处理)、响应后处理

1. 拦截路径要精准,避免误拦截无关请求;

2. 异常需妥善处理,避免阻断正常请求流程;

3. 对请求参数的修改需保证线程安全

Handler (通常指 HandlerMethod,Spring MVC 中处理请求的核心组件)封装请求处理逻辑的方法

1. 方法参数要与请求参数 / 路径变量正确映射;

2. 返回值需与响应格式(如 JSON、视图)匹配;

3. 异常需抛出或交给全局异常处理器处理

Controller 层 接收 HTTP 请求,参数校验,调用 Service 层逻辑,返回响应给前端

1. 方法上要标注请求映射(如@GetMapping);

2. 参数校验可结合@ValidBindingResult

3. 记得添加日志(如log.info)用于调试和问题排查

Service 层 定义业务逻辑接口,封装核心业务规则和流程

1. 接口设计要符合业务语义,方法名清晰表达业务意图;

2. 入参和返回值要明确,可通过 DTO/VO 做数据传输;

3. 需考虑业务异常的定义和抛出

ServiceImpl 层 实现 Service 接口,编写具体业务逻辑,是事务管理的核心层

1. 要添加@Transactional注解来控制事务,明确rollbackFor

2. 业务逻辑中需抛出自定义异常(如BusinessException);

3. 对复杂业务可拆分方法,保持代码可读性

Mapper 层 定义数据库操作的接口(与 XML 或注解 SQL 映射),负责数据持久化操作

1. 方法名要体现 SQL 操作意图(如selectByIdinsert);

2. 参数和返回值要与实体类 / 数据库字段匹配;

3. 复杂 SQL 建议用 XML 映射,避免注解 SQL 过于臃肿

 Controller层设计

Controller 层作为前端与后端的交互入口,需遵循 “职责单一、接口清晰、参数校验、统一响应” 的原则设计。

模块 Controller 类名 核心职责
用户管理 UserController 用户注册、登录、信息修改、权限查询
团队管理 TeamController 团队创建、成员邀请、角色分配、团队解散
软件管理 SoftwareController 软件上传、信息查询、更新、删除
DID 管理 DidController DID 创建、状态变更、密钥轮换、信息查询
文件操作(SBOM 等) FileController SBOM / 漏洞清单生成、下载、格式转换
审计日志 AuditLogController 审计日志查询(供管理员查看)
系统配置 SystemConfigController 存储路径配置、密钥轮换周期配置等

1. 统一响应格式

common新建result包Result类

后端接口统一返回的结果模型 Result<T>,是前后端数据交互的标准化格式。这种设计在 Spring Boot 等 Java 后端项目中非常常见,主要目的是让前端能一致地解析接口返回数据,同时规范后端的响应格式。成功code=1,失败=0

package com.sky.result;

import lombok.Data;

import java.io.Serializable;

/**
 * 后端统一返回结果
 * @param <T>
 */
@Data
public class Result<T> implements Serializable {

    private Integer code; //编码:1成功,0和其他数字失败
    private String msg;//错误信息
    private T data; // 数据

    // 无数据的成功响应
    public static <T> Result<T> success(){
        Result<T> result = new Result<T>();
        result.code = 1;
        return result;
    }

    // 带数据的成功响应
    public static <T> Result<T> success(T object){
        Result<T> result = new Result<T>();
        result.data = object;
        result.code = 1;
        return result;
    }

    public static <T> Result<T> error(String msg){
        Result result = new Result();
        result.msg = msg;
        result.code = 0;
        return result;
    }
}

Result还需要一个PageResult

2. 统一异常处理

common的exception包新建多个异常类

BaseException:主要用于在业务逻辑中标识和处理 “预期内的业务错误”(而非程序本身的 Bug)

package com.sky.exception;

import java.net.PortUnreachableException;

/**
 * 业务异常
 */
public class BaseException extends RuntimeException{

    public BaseException(){
    }

    public BaseException(String msg){
        super(msg);
    }
}

common-constant新建MessageConstant,提示错误信息

server新建handler包GlobalExceptionHandler类:通过@RestControllerAdvice全局捕获异常,避免 Controller 中充斥 try-catch 代码:

package com.sky.handler;

import com.aliyuncs.exceptions.ErrorMessageConstant;
import com.sky.constant.MessageConstant;
import com.sky.exception.BaseException;
import com.sky.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.sql.SQLIntegrityConstraintViolationException;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 捕获业务异常
     * @param ex
     * @return
     */
    @ExceptionHandler
    public Result exceptionHandler(BaseException ex){
        log.error("异常信息:{}", ex.getMessage());
        return Result.error(ex.getMessage());
    }

    /**
     * 处理SQL异常
     * @param ex
     * @return
     */
    @ExceptionHandler
    public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
        // Duplicate entry 'zhangsan' for key 'user.name'
        String message = ex.getMessage();
        // 处理唯一键冲突
        if (message.contains("Duplicate entry")){
            String[] split = message.split(" ");//按空格分隔错误信息字符串
            String username = split[2]; //提取重复的值,如zhangsan
            String msg = username + MessageConstant.ALREADY_EXISTS;
            return Result.error(msg);
        } else {
            return Result.error(MessageConstant.UNKNOWN_ERROR);//未知错误
        }
    }

}
路径前缀与请求方法规范
  • 所有接口路径统一前缀(如/api),便于前端区分 API 请求和静态资源;
  • 严格遵循 HTTP 方法语义:GET(查询)、POST(新增)、PUT(全量更新)、DELETE(删除)、PATCH(部分更新)。

用户注册功能

1. 定义实体类和DTO

最终的User实体类

package com.sky.entity;

import io.swagger.models.auth.In;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {

    // 序列化机制的版本标识,用于反序列化时验证类的版本一致性(如果类结构发生变化,修改此值可以避免反序列化异常)
    private static final long serialVersionUID = 1L;

    private Integer id;

    private String did;

    private String username;

    private String password;

    private LocalDateTime createTime;

    private LocalDateTime updateTime;

    private Integer createUser;

    private Integer updateUser;

    // 账号状态 1-正常,0-禁用
    private Integer status;

    // 团队内角色ID(1-只读,2-普通,3-管理员;NULL表示未加入团队)
    private Integer teamRoleId;

    // 团队内角色名称-(冗余,与team_role_id对应:1-只读用户,2-普通用户,3-管理员;NULL表示未加入团队)
    private String roleName;

    // 所属团队ID(NULL表示未加入团队)
    private Integer teamId;

    private String publicKey;//用户公钥

    private String privateKeyEncrypted;//加密后的私钥

}

UserRegisterDTO

package com.sky.dto;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.io.Serializable;

@Data
@ApiModel(description = "用户注册时传递的数据模型")
public class UserRegisterDTO implements Serializable {

    @ApiModelProperty("用户名")
    @NotBlank(message = "用户名不能为空") // 非空校验
    @Size(min = 3, max = 16, message = "用户名长度为3-16个字符") // 长度校验
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线") // 格式校验
    private String username;


    @ApiModelProperty("密码")
    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 20, message = "密码长度6-20位")
    private String password;

    @ApiModelProperty("确认密码")
    @NotBlank(message = "请确认密码")
    private String confirmPassword;
}

2. userController

/**
     * 注册
     * @param userRegisterDTO
     * @return
     */
    @PostMapping("/register")
    @ApiOperation(value = "用户注册")
    // @Validated触发参数校验,@RequestBody接收JSON
    public Result register(@Validated @RequestBody UserRegisterDTO userRegisterDTO){
        log.info("用户注册:{}",userRegisterDTO);
        boolean registerSuccess = userService.register(userRegisterDTO);
        if (registerSuccess){
            log.info(userRegisterDTO.getUsername() + "注册成功!");
            return Result.success();
        } else {
            log.info("注册失败!");
            return Result.error("注册失败");
        }
    }

3. service和serviceImpl

定义新异常KeyPairGenException、DidGenException

解决问题:为用户生成一个公私钥对、基于公钥生成did

package com.sky.service.impl;

import com.sky.constant.MessageConstant;
import com.sky.constant.StatusConstant;
import com.sky.dto.UserLoginDTO;
import com.sky.dto.UserRegisterDTO;
import com.sky.entity.User;
import com.sky.exception.*;
import com.sky.mapper.UserMapper;
import com.sky.service.UserService;
import io.lettuce.core.codec.Base16;
import org.bitcoinj.core.Base58;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;

@Service
public class UserServiceImpl implements UserService{

    private static final String ALGORITHM = "AES";
    private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
    private static final int IV_LENGTH = 16; // 128位

    @Autowired
    private UserMapper userMapper;

    /**
     * 用户登录
     * @param userLoginDTO
     * @return
     */
    public User login(UserLoginDTO userLoginDTO) {
        String username = userLoginDTO.getUsername();
        String password = userLoginDTO.getPassword();

        //1. 根据用户名查询数据库中的数据
        User user = userMapper.getByUsername(username);

        //2. 处理各种异常情况(用户名不存在、密码不对、账号被锁定)
        if (user == null){
            // 账号不存在
            throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
        }

        // 密码比对
        // TODO 后期需要进行md5加密,然后再进行比对

        if (!password.equals(user.getPassword())){
            //密码错误
            throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
        }

        if (user.getStatus() == StatusConstant.DISABLE){
            // 账户被锁定
            throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
        }

        // 3. 返回实体对象
        return user;
    }

    /**
     * 新增用户--用户注册
     * 生成公私钥对(椭圆曲线加密算法ECC)、基于公钥生成did标识符
     * @param userRegisterDTO
     * @return
     */
    @Transactional
    public boolean register(UserRegisterDTO userRegisterDTO) {
        // 1.校验用户名是否已经存在(避免重复注册)
        User existUser = userMapper.getByUsername(userRegisterDTO.getUsername());
        if (existUser != null){
            //用户已经存在
            throw new AccountAlreadyExistException(MessageConstant.ALREADY_EXISTS);
        }

        //2. 校验密码与确认密码是否一致(前端已校验,后端二次保障)
        if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getConfirmPassword())){
            throw new PasswordErrorException(MessageConstant.PASSWORD_NOT_EQUAL);
        }

        User user = new User();

        // BeanUtils只拷贝两个对象中“字段名和类型都相同” 的属性:username,password
        BeanUtils.copyProperties(userRegisterDTO, user);

        // 1. 生成ECC密钥对
        KeyPair keyPair = generateECCKeyPair();

        // 2. 从KeyPair中获取公钥和私钥
        PublicKey publicKey = keyPair.getPublic();
        PrivateKey privateKey = keyPair.getPrivate();

        // 3. 基于公钥生成DID标识符
        String didIdentifier = generateDidFromPublicKey(publicKey);

        // 4. 构建完整的DID字符串
        String fullDid = "did:shieldchain:user:" + didIdentifier;

        //设置剩余属性
        user.setDid(fullDid);
        user.setStatus(StatusConstant.ENABLE);
        user.setPublicKey(Base64.getEncoder().encodeToString(publicKey.getEncoded()));
        // 私钥加密存储
        String encryptedPrivateKey = encryptWithPassword(Base64.getEncoder().encodeToString(privateKey.getEncoded()), user.getPassword());
        user.setPrivateKeyEncrypted(encryptedPrivateKey);

        userMapper.insertUser(user);

        return true;
    }

    /**
     * 生成ECC密钥对
     * @return
     */
    private KeyPair generateECCKeyPair(){
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
            // 替换为Java支持的标准曲线(secp256r1)
            ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1");
            keyPairGenerator.initialize(ecSpec, new SecureRandom());// 使用强随机数
            return keyPairGenerator.generateKeyPair();
        } catch (Exception e) {
            // 打印详细异常信息,方便排查
            e.printStackTrace();
            throw new KeyPairGenException("为用户生成ECC密钥对失败");
        }
    }

    /**
     * 根据生成的公钥两次哈希得到DID
     * @param publicKey
     * @return
     */
    private String generateDidFromPublicKey(PublicKey publicKey){
        try {
            // 注册BouncyCastle加密提供者(支持RIPEMD-160)
           // Security.addProvider(new BouncyCastleProvider());

            // 获取公钥的原始编码
            byte[] publicKeyBytes = publicKey.getEncoded();

            // 计算SHA-256哈希,得到256位,32字节
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            byte[] hash1 = sha256.digest(publicKeyBytes);

            // 计算RIPEMD-160哈希(指定使用BouncyCastle提供者)
           // MessageDigest ripemd160 = MessageDigest.getInstance("RIPEMD160", "BC");
            //byte[] hash2 = ripemd160.digest(hash1);

            // 3. 转为十六进制字符串(Base16编码,全小写)
            StringBuilder hexBuilder = new StringBuilder();
            for (byte b : hash1) {
                // %02x 表示:按2位十六进制小写输出,不足2位补0(确保每个字节对应2个字符)
                hexBuilder.append(String.format("%02x", b));
            }

            // 4. 最终结果为64位全小写十六进制字符串
            return hexBuilder.toString();
        } catch (Exception e) {
            // 打印完整异常信息,方便排查(如算法名错误、依赖未引入)
            e.printStackTrace();
            throw new DidGenException("为用户生成DID标识符失败");
        }
    }

    /**
     * 使用用户密码加密数据
     * @param plainText 待加密的明文
     * @param password 用户密码
     * @return Base64编码的加密结果(包含IV)
     */
    public static String encryptWithPassword(String plainText, String password) {
        try {
            // 1. 从密码生成AES密钥(使用SHA-256哈希)
            byte[] key = generateKeyFromPassword(password);
            SecretKeySpec secretKey = new SecretKeySpec(key, ALGORITHM);

            // 2. 生成随机IV
            byte[] iv = new byte[IV_LENGTH];
            java.security.SecureRandom random = new java.security.SecureRandom();
            random.nextBytes(iv);
            IvParameterSpec ivSpec = new IvParameterSpec(iv);

            // 3. 初始化加密器并执行加密
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
            byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));

            // 4. 组合IV和加密数据,并进行Base64编码
            byte[] combined = new byte[iv.length + encryptedBytes.length];
            System.arraycopy(iv, 0, combined, 0, iv.length);
            System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length);

            return Base64.getEncoder().encodeToString(combined);

        } catch (Exception e) {
            throw new RuntimeException("加密失败", e);
        }
    }

    /**
     * 使用用户密码解密数据
     * @param encryptedText Base64编码的加密数据(包含IV)
     * @param password 用户密码
     * @return 解密后的明文
     */
    public static String decryptWithPassword(String encryptedText, String password) {
        try {
            // 1. 从密码生成AES密钥
            byte[] key = generateKeyFromPassword(password);
            SecretKeySpec secretKey = new SecretKeySpec(key, ALGORITHM);

            // 2. 解码并分离IV和加密数据
            byte[] combined = Base64.getDecoder().decode(encryptedText);
            byte[] iv = new byte[IV_LENGTH];
            byte[] encryptedBytes = new byte[combined.length - IV_LENGTH];

            System.arraycopy(combined, 0, iv, 0, iv.length);
            System.arraycopy(combined, iv.length, encryptedBytes, 0, encryptedBytes.length);

            IvParameterSpec ivSpec = new IvParameterSpec(iv);

            // 3. 初始化解密器并执行解密
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
            byte[] decryptedBytes = cipher.doFinal(encryptedBytes);

            return new String(decryptedBytes, StandardCharsets.UTF_8);

        } catch (Exception e) {
            throw new RuntimeException("解密失败", e);
        }
    }

    /**
     * 从密码生成AES密钥(SHA-256哈希)
     */
    private static byte[] generateKeyFromPassword(String password) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        return digest.digest(password.getBytes(StandardCharsets.UTF_8));
    }

}

4. 面向切面编程AOP--公共字段自动填充

AOP:面向切面编程。将与核心业务无关的代码独立抽取出来,形成一个独立的组件,然后以横向交叉的方式应用到业务流程当中

所以需要将数据库相关字段统一命名,将entity也统一命名

common-enumeration新建枚举类OperationType

package com.sky.enumeration;

/**
 * 数据库操作类型
 */
public enum OperationType {

    /**
     * 更新操作
     */
    UPDATE,

    /**
     * 插入操作
     */
    INSERT
}

AutoFillConstant

package com.sky.constant;

/**
 * 公共字段自动填充相关常量
 */
public class AutoFillConstant {

    /**
     * 实体类中的方法名称
     */
    public static final String SET_CREATE_TIME = "setCreateTime";
    public static final String SET_UPDATE_TIME = "setUpdateTime";
    public static final String SET_CREATE_USER = "setCreateUser";
    public static final String SET_UPDATE_USER = "setUpdateUser";
}

sever-annotation-AutoFill

package com.sky.annotation;

import com.sky.enumeration.OperationType;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

// 当前注解加在什么位置:这个注解只能加在方法上
@Target(ElementType.METHOD)
// 固定写法
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {

    // 使用枚举方式指定当前操作数据库的类型(update,insert)
    // 在common的enumeration
    OperationType value();
}

server-新建aspect--AutoFillAspect

package com.sky.aspect;

import com.sky.annotation.AutoFill;
import com.sky.constant.AutoFillConstant;
import com.sky.context.BaseContext;
import com.sky.enumeration.OperationType;
import io.swagger.models.auth.In;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.time.LocalDateTime;

/**
 * 自定义切面
 */
@Aspect
@Component
@Slf4j
public class AutoFillAspect {


    /**
     * 切入点
     */
    // 切点表达式-前半部分mapper包下面所有类所有方法,匹配所有参数类型,粒度太粗,有一些不需要被拦截(如查询、删除
    // 还需要:这个方法上加上了自定义AutoFill注解的
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    /**
     * 前置通知,在通知中进行公共字段的赋值
     * @param joinPoint
     */
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint){
        log.info("开始进行公共字段自动填充...");

        // 获取当前被拦截的方法上的数据库的操作类型 INSERT/UPDATE
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
        AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
        OperationType operationType = autoFill.value();//获取数据库操作类型

        // 获取当前被拦截的方法的参数--实体对象
        // 对拦截的方法,要求把实体对象放在参数的第一个,方便获取
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0){
            return; //如果没有参数直接返回
        }

        Object entity = args[0];//使用object可以接受所有不同的实体

        // 准备赋值数据 -- ThreadLocal
        LocalDateTime now = LocalDateTime.now();
        Integer currentId = BaseContext.getCurrentId();

        // 根据当前不同操作类型,为对应的属性通过反射来赋值
        if (operationType == OperationType.INSERT){
            // 为4个公共字段赋值
            try {
                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Integer.class);
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Integer.class);

                // 通过反射为对象属性赋值
                setCreateTime.invoke(entity, now);
                setCreateUser.invoke(entity, currentId);
                setUpdateTime.invoke(entity, now);
                setUpdateUser.invoke(entity, currentId);
            }catch (Exception e) {
                e.printStackTrace();
            }
        } else if (operationType == OperationType.UPDATE){
            // 为2个公共字段赋值
            try{
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Integer.class);

                // 通过反射为对象属性赋值
                setUpdateTime.invoke(entity,now);
                setUpdateUser.invoke(entity,currentId);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

AOP自动填充的使用:mapper包下面的INSERT/UPDATE + 该方法加了@AutoFill注解

对于INSERT,自动填充四个字段:create_user、create_time、update_user、update_time

@AutoFill(value = OperationType.INSERT)

对于UPDATE,自动填充两个字段:update_user、update_time

@AutoFill(value = OperationType.UPDATE)

5. UserMapper

/**
     * 根据用户名查询用户
     * @param username
     * @return
     */
    @Select("select * from user where username = #{username}")
    User getByUsername(String username);

    /**
     * 插入用户数据
     * @param user
     */
    //单表新增操作,没有必要写到XML中,可以直接使用注解
    @Insert("insert into user (did, username, password, create_time, update_time, create_user, update_user, status, public_key, private_key_encrypted) " +
            "values" +
            "(#{did}, #{username}, #{password}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{status}, #{publicKey}, #{privateKeyEncrypted})")
    @AutoFill(value = OperationType.INSERT)
    void insertUser(User user);
}

用户注册功能测试

核心问题是:注册时用户未登录,BaseContext.getCurrentId() 无法获取 currentId(登录态才会存储用户 ID),导致切面填充 createUser 和 updateUser 时为 null。解决方案是 注册场景单独处理,手动传递注册用户的 ID 到切面

但是实际上user的这两个字段没有什么意义,所以直接删掉这两个字段

异常情况测试:

问题:有时候前端会直接渲染到登录页面,但是我希望每次都从首页开始

前端默认显示登录页,大概率是路由默认指向了登录页或有强制跳登录的逻辑。

修改:router/index.js

// 增强型路由守卫
router.beforeEach((to, from) => {
    const userStore = useUserStore()
    const isAuthenticated = !!userStore.token
    const isTokenExpired = userStore.isTokenExpired() // 校验token是否过期

    // 设置页面标题
    if (to.meta.title) {
        document.title = `${to.meta.title} - 链盾系统`
    }

    // 1. 若token存在但已过期,强制登出
    if (isAuthenticated && isTokenExpired) {
        userStore.logout()
        ElMessage.error('登录已过期,请重新登录')
        return { path: '/login', replace: true }
    }

    // 认证检查
    if (to.matched.some(record => record.meta.requiresAuth)) {
        if (!isAuthenticated) {
            ElMessage.error('请先登录以访问该功能')
            return {
                path: '/login',
                query: { redirect: to.fullPath },
                replace: true
            }
        }
    }

    // 来宾限制
    if (to.matched.some(record => record.meta.guestOnly) && isAuthenticated) {
        ElMessage.warning('您已登录,将跳转到主页面')
        return { path: '/Major', replace: true }
    }

    // 4. 关键新增:默认访问路径为空时,强制跳首页(解决启动默认跳登录问题)
    if (to.path === '*' || to.path === '') {
        return { path: '/', replace: true }
    }

    // 5. 放行所有无需权限的路由(包括首页)
    return true

})

11.11

标识分页功能开发

前端代码:api/software.js

import request from '@/utils/request'

// 分页查询软件列表
export const softwareSelectByPageService = (params) => {
    return request.get('/software/selectByPage', {
        params: params
    })
} 

逻辑处理:views/Function/Identification.vue

// 实现搜索逻辑
const handleSearch = async () => {
    loading.value = true
    try {
        const params = {
            page: currentPage.value,
            size: pageSize.value
        }

        if (searchValue.value) {
            if (searchType.value === 'did') {
                params.did = searchValue.value
            } else {
                params.name = searchValue.value
            }
        }

        const result = await softwareSelectByPageService(params)
        if (result.code === 1) {
            // 为返回的数据添加默认类型
            const records = result.data.records.map(item => ({
                ...item,
                type: 'export' // 添加默认类型
            }))
            
            // 检查是否需要显示示例软件
            let shouldShowFixedData = true
            if (searchValue.value) {
                if (searchType.value === 'did') {
                    shouldShowFixedData = fixedData.did.toLowerCase().includes(searchValue.value.toLowerCase())
                } else {
                    shouldShowFixedData = fixedData.name.toLowerCase().includes(searchValue.value.toLowerCase())
                }
            }
            
            // 根据搜索条件决定是否显示示例软件
            didList.value = shouldShowFixedData ? [fixedData, ...records] : records
            total.value = shouldShowFixedData ? result.data.total + 1 : result.data.total
        } else {
            ElMessage.error(result.msg || '查询失败')
        }
    } catch (error) {
        console.error('查询出错:', error)
        ElMessage.error('查询失败,请稍后重试')
    } finally {
        loading.value = false
    }
}

const handleRevoke = (did) => {
    ElMessage.warning(`正在注销 ${did}`)
}

// 修改分页处理逻辑
const handleSizeChange = async (size) => {
    pageSize.value = size
    currentPage.value = 1  // 切换每页条数时重置为第一页
    await handleSearch()
}

const handlePageChange = async (page) => {
    currentPage.value = page
    await handleSearch()
}

// 添加初始化加载函数
onMounted(() => {
    handleSearch()
})

// 申请查看SBOM的方法
const handleRequestSBOM = (row) => {
    requestForm.value = {
        did: row.did,
        name: row.name,
        reason: '',
        duration: '7'
    }
    requestDialogVisible.value = true
}

// 提交申请的方法
const submitRequest = async () => {
    if (!requestForm.value.reason) {
        ElMessage.warning('请填写申请原因')
        return
    }

    try {
        // 这里添加实际的API调用
        // const result = await requestSBOMService(requestForm.value)
        
        // 模拟API调用
        await new Promise(resolve => setTimeout(resolve, 1000))
        
        ElMessage.success('申请已提交,请等待审核')
        requestDialogVisible.value = false
    } catch (error) {
        console.error('申请提交失败:', error)
        ElMessage.error('申请提交失败,请稍后重试')
    }
}

参考苍穹外卖-Day2 | 员工管理、分类模块、分页查询、编辑员工、启用禁用员工账号、IDE配置SQL提示、ThreadLocal-CSDN博客

1. 定义PageResult

package com.sky.result;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.List;

/**
 * 封装分页查询结果
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {
    
    private long total;//总记录数
    
    private List records;//当前页数据集合
}

2. SoftwarePageQueryDTO

package com.sky.dto;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

@Data
@ApiModel(description = "软件标识分页查询传递使用的数据模型")
public class SoftwarePageQueryDTO implements Serializable {
    // 分页参数(必选,前端传递,默认第1页,每页10条)
    @ApiModelProperty("页码")
    private int page;
    @ApiModelProperty("每页条数")
    private int pageSize;

    // 查询条件(可选,前端传递时才参与过滤)
    @ApiModelProperty("软件名称(模糊查询,不区分大小写)")
    private String name;
    @ApiModelProperty("DID(模糊查询)")
    private String did;
}

3. 设计实体类Software

删除depend_rate--不删了

package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Software implements Serializable {
    // 序列化机制的版本标识,用于反序列化时验证类的版本一致性(如果类结构发生变化,修改此值可以避免反序列化异常)
    private static final long serialVersionUID = 1L;

    private Integer id;

    private String did;

    private String name;

    private String dependRate;

    private String versions;

    // 软件类型 0-开源软件,1-商业软件
    private Integer softwareType;

    //归属团队 ID,关联team.id
    private Integer teamId;

    private String language;

    //状态:1-有效,2-冻结,0-吊销
    private Integer status;

    private LocalDateTime createTime;

    private LocalDateTime updateTime;

    private Integer createUser;

    private Integer updateUser;
}

4. 新建SoftwareController

package com.sky.controller;

import com.sky.dto.SoftwarePageQueryDTO;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.SoftwareService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/software")
@Api(tags = "软件相关接口")
public class SoftwareController {

    @Autowired
    private SoftwareService softwareService;

    /**
     * 标识分页查询--根据软件名称(不区分大小写)或者did模糊查询
     * @param softwarePageQueryDTO
     * @return
     */
    @GetMapping("/selectByPage")
    @ApiOperation(value = "软件库分页标识查询")
    public Result<PageResult> softwareLibPage(SoftwarePageQueryDTO softwarePageQueryDTO){
        log.info("软件库分页标识查询,参数为:{}", softwarePageQueryDTO);
        PageResult pageResult = softwareService.softwareLibPageQuery(softwarePageQueryDTO);
        return Result.success(pageResult);
    }
}

5. SoftwareService

package com.sky.service;

import com.sky.dto.SoftwarePageQueryDTO;
import com.sky.result.PageResult;

public interface SoftwareService {

    /**
     * 标识分页查询--根据软件名称(不区分大小写)或者did模糊查询
     * @param softwarePageQueryDTO
     * @return
     */
    // 这里需要动态SQL,用注解不方便,因为要使用动态标签
    PageResult softwareLibPageQuery(SoftwarePageQueryDTO softwarePageQueryDTO);
}

6. SoftwareServiceImpl

package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.dto.SoftwarePageQueryDTO;
import com.sky.entity.Software;
import com.sky.mapper.SoftwareMapper;
import com.sky.result.PageResult;
import com.sky.service.SoftwareService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@Slf4j
public class SoftwareServiceImpl implements SoftwareService {

    @Autowired
    private SoftwareMapper softwareMapper;

    /**
     * 标识分页查询--根据软件名称(不区分大小写)或者did模糊查询
     * @param softwarePageQueryDTO
     * @return
     */
    public PageResult softwareLibPageQuery(SoftwarePageQueryDTO softwarePageQueryDTO) {
        // 底层是基于mysql的limit关键字实现的分页查询
        // 开始分页查询,借助插件(底层借助Mybatis的拦截器、sql拼接)
        PageHelper.startPage(softwarePageQueryDTO.getPage(), softwarePageQueryDTO.getPageSize());

        Page<Software> page = softwareMapper.softwareLibPageQuery(softwarePageQueryDTO);//实现按照件名称(不区分大小写)或者did模糊查询

        long total = page.getTotal();
        List<Software> records = page.getResult();

        return new PageResult(total, records);
    }
}

7. SoftwareMapper

package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.dto.SoftwarePageQueryDTO;
import com.sky.entity.Software;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface SoftwareMapper {

    /**
     * 标识分页查询--实现按照件名称(不区分大小写)或者did模糊查询
     * @param softwarePageQueryDTO
     * @return
     */
    Page<Software> softwareLibPageQuery(SoftwarePageQueryDTO softwarePageQueryDTO);
}

编写动态sql--xml映射文件

在server--resources/mapper包新建SoftwareMapper.xml,命名空间要一致,点击小蓝鸟可以跳转

<?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" >
<mapper namespace="com.sky.mapper.SoftwareMapper">
    <!-- 分页查询:支持全量、软件名称模糊(不区分大小写)、DID模糊 -->
    <select id="softwareLibPageQuery" parameterType="com.sky.dto.SoftwarePageQueryDTO" resultType="com.sky.entity.Software">
        SELECT * FROM software
        <where>
            <!-- 软件名称模糊查询(不区分大小写:将字段和参数都转小写) -->
            <if test="name != null and name != ''">
                AND LOWER(name) LIKE CONCAT('%', LOWER(#{name}), '%')
            </if>

            <!-- DID模糊查询 -->
            <if test="did != null and did != ''">
                AND did LIKE CONCAT('%', #{did}, '%')
            </if>
        </where>
        <!-- 按创建时间倒序(最新的在前) -->
        ORDER BY create_time DESC
    </select>
</mapper>

调试


日期渲染问题

Spring MVC 框架中扩展消息转换器(MessageConverter)的典型实现,核心作用是统一处理后端返回给前端的数据格式(尤其是日期格式化),确保数据在 HTTP 请求 / 响应中正确序列化和反序列化。

1. common新建json包--JacksonObjectMapper
package com.sky.json;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

/**
 * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
 * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
 */
public class JacksonObjectMapper extends ObjectMapper {

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    //public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    public JacksonObjectMapper() {
        super();
        //收到未知属性时不报异常
        this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        //反序列化时,属性不存在的兼容处理
        this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        SimpleModule simpleModule = new SimpleModule()
                .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
                .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

        //注册功能模块 例如,可以添加自定义序列化器和反序列化器
        this.registerModule(simpleModule);
    }
}
2. 在WebMvcConfiguration中新加
 /**
     * 扩展SpringMVC框架的消息转换器:统一处理后端给前端的数据
     * 现在进行日期的格式化
     * @param converters
     */
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters){
        log.info("扩展消息转换器...");
        // 创建一个消息转换器对象
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        // 需要为消息转换器设置一个对象转换器,对象转换器可以将java对象系列化为json数据
        converter.setObjectMapper(new JacksonObjectMapper());
        // 将自己的消息转换器加入到容器中,前面的0索引标识自己加入的这个优先级最高
        converters.add(0, converter);
    }

软件状态渲染相反问题

在views/Function/Identification.vue里面修改

删除固定模拟数据,都从数据库中查找

渲染不对的原因:前后端status、did_status命名不一致,全部修改成status

<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElLoading } from 'element-plus'
import { softwareSelectByPageService } from '@/api/software'
import { Document, DocumentCopy, Search, Reading } from '@element-plus/icons-vue'

// 修改 didList 的定义为空数组
const didList = ref([])
// 1. 状态转换函数:解决“有效/已注销”互换问题(核心修改)
const formatStatus = (status) => {
    // 后端存储规则:1=有效,0=已注销,2=冻结(根据实际后端规则调整!)
    switch(status) {
        case 1: return '有效'
        case 0: return '已注销'
        case 2: return '冻结'
        default: return '未知'
    }
}

// 实现搜索逻辑
const handleSearch = async () => {
    loading.value = true
    try {
        const params = {
            page: currentPage.value,
            pageSize: pageSize.value
        }

        if (searchValue.value) {
            if (searchType.value === 'did') {
                params.did = searchValue.value
            } else {
                params.name = searchValue.value
            }
        }

        const result = await softwareSelectByPageService(params)
        if (result.code === 1) {
            // 直接使用接口返回的所有数据,无需拼接固定数据
            didList.value = result.data.records
            total.value = result.data.total
            
        } else {
            ElMessage.error(result.msg || '查询失败')
        }
    } catch (error) {
        console.error('查询出错:', error)
        ElMessage.error('查询失败,请稍后重试')
    } finally {
        loading.value = false
    }
}

const handleRevoke = (did) => {
    ElMessage.warning(`正在注销 ${did}`)
}

// 修改分页处理逻辑
const handleSizeChange = async (size) => {
    pageSize.value = size
    currentPage.value = 1  // 切换每页条数时重置为第一页
    await handleSearch()
}

const handlePageChange = async (page) => {
    currentPage.value = page
    await handleSearch()
}

按名称模糊查找

按照did标识模糊查找

下一步开发“操作”部分,商业软件申请,开源软件导出

商业软件申请查看功能开发

前端目前所有软件都是两个按钮,没有区分开源软件和商业软件

// 申请相关的状态
const requestDialogVisible = ref(false)
const requestForm = ref({
    did: '',
    name: '',
    reason: '',
    duration: '7' // 默认申请7天
})
// 申请时长选项
const durationOptions = [
    { label: '7天', value: '7' },
    { label: '15天', value: '15' },
    { label: '30天', value: '30' },
    { label: '90天', value: '90' }
]

// 申请查看SBOM的方法
const handleRequestSBOM = (row) => {
    requestForm.value = {
        did: row.did,
        name: row.name,
        reason: '',
        duration: '7'
    }
    requestDialogVisible.value = true
}

// 提交申请的方法
const submitRequest = async () => {
    if (!requestForm.value.reason) {
        ElMessage.warning('请填写申请原因')
        return
    }

    try {
        // 这里添加实际的API调用
        // const result = await requestSBOMService(requestForm.value)
        
        // 模拟API调用
        await new Promise(resolve => setTimeout(resolve, 1000))
        
        ElMessage.success('申请已提交,请等待审核')
        requestDialogVisible.value = false
    } catch (error) {
        console.error('申请提交失败:', error)
        ElMessage.error('申请提交失败,请稍后重试')
    }
}

<div class="operation-buttons">
                        <!-- 根据 type 字段判断显示不同的操作按钮 -->
                        <template v-if="row.softwareType === 1">
                            <!-- 商业软件(softwareType=1):仅“申请查看SBOM”按钮 -->
                            <el-tooltip content="申请查看SBOM" placement="top">
                                <el-button type="primary" link @click="handleRequestSBOM(row)">
                                    <el-icon>
                                        <Reading />
                                    </el-icon>
                                </el-button>
                            </el-tooltip>
                        </template>
                        <template v-else>
                            <el-tooltip content="导出SBOM" placement="top">
                                <el-button type="primary" link @click="handleExportSBOM(row)">
                                    <el-icon>
                                        <Document />
                                    </el-icon>
                                </el-button>
                            </el-tooltip>
                            <el-tooltip content="导出报告" placement="top">
                                <el-button type="primary" link @click="handleExportReport(row)">
                                    <el-icon>
                                        <DocumentCopy />
                                    </el-icon>
                                </el-button>
                            </el-tooltip>
                        </template>
                    </div>

现在可以正常显示不同类别的按钮,下面需要开发按下按钮对应的接口。

由于申请查看SBOM和漏洞清单目前是直接在前端下载,暂时不考虑。

只考虑商业软件申请按钮的后端开发,因为需要写入数据库。

1. 前端修改:定义 API 请求函数

在api/software.js添加

// 商业软件提交SBOM查看申请
export const requestSBOMService = (data) => {
    return request({
        url: 'software/sbom/apply', //后端接口路径,需与后端controller一致
        method: 'post',// 提交数据用post方法
        data //请求体参数(json格式)
    })
}

2. 前端修改:submitRequest 方法,调用真实 API

Identification.vue

// 申请查看SBOM的方法
const handleRequestSBOM = (row) => {
    requestForm.value = {
        did: row.did,
        name: row.name,
        reason: '',
        duration: '7'
    }
    requestDialogVisible.value = true
}

// 提交申请的方法
const submitRequest = async () => {
    if (!requestForm.value.reason) {
        ElMessage.warning('请填写申请原因')
        return
    }

    try {
        // 这里添加实际的API调用
        const result = await requestSBOMService(requestForm.value)
        
        // 假设后端返回格式:{ code: 1, msg: "" }
        if (result.code === 1) {
            ElMessage.success('申请已提交,请等待审核')
            requestDialogVisible.value = false // 关闭对话框
            requestForm.value = { did: '', name: '', reason: '', duration: '7' } // 重置表单
        } else {
            ElMessage.error('申请提交失败')
        }
    } catch (error) {
        console.error('申请提交失败:', error)
        ElMessage.error('申请提交失败,请稍后重试')
    }
}

3. 后端:设计实体SbomApply

package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SbomApply implements Serializable {
    // 序列化机制的版本标识,用于反序列化时验证类的版本一致性(如果类结构发生变化,修改此值可以避免反序列化异常)
    private static final long serialVersionUID = 1L;

    private Integer applyId;

    private Integer softwareId;

    // 申请状态:0-待审批,1-通过,2-拒绝
    private Integer status;
    
    // 申请时长(天)
    private Integer duration;

    private String reason;

    private LocalDateTime createTime;

    private LocalDateTime updateTime;

    private Integer createUser;

    private Integer updateUser;
}

4. 设计DTO,接受前端参数

package com.sky.dto;

import io.swagger.annotations.ApiModel;
import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Data
@ApiModel(description = "商业软件申请查看SBOM使用的数据模型")
public class SbomApplyDTO implements Serializable {

    @NotBlank(message = "DID标识不能为空")
    private String did; // 软件DID

    @NotBlank(message = "软件名称不能为空")
    private String name; // 软件名称

    @NotBlank(message = "申请原因不能为空")
    private String reason; // 申请原因

    @NotNull(message = "申请时长不能为空")
    private Integer duration; // 申请时长(天)
}

5. Controller

    /**
     * 申请查看商业软件SBOM
     * @param sbomApplyDTO
     * @return
     */
    @PostMapping("/sbom/apply")
    @ApiOperation(value = "申请查看商业软件SBOM")
    public Result bizSBOMApply(@Validated @RequestBody SbomApplyDTO sbomApplyDTO){
        log.info("申请查看商业软件SBOM,参数为:{}", sbomApplyDTO);
        softwareService.bizSBOMApply(sbomApplyDTO);
        return Result.success();
    }

6.  service

首先编写BusinessSoftwareNotExistException。
    /**
     * 申请查看商业软件SBOM
     * @param sbomApplyDTO
     */
    void bizSBOMApply(SbomApplyDTO sbomApplyDTO);

7. serviceImpl

 /**
     * 申请查看商业软件SBOM
     * @param sbomApplyDTO
     */
    // 写操作,保证事务一致性
    @Transactional
    public void bizSBOMApply(SbomApplyDTO sbomApplyDTO) {
        // 1. 校验软件是否存在且为商业软件(softwareType=1)
        Software software = softwareMapper.selectByDid(sbomApplyDTO.getDid());
        if (software == null){
            throw new BusinessSoftwareNotExistException("商业软件不存在");
        }
        if (software.getSoftwareType() != 1){
            throw new BusinessSoftwareNotExistException("仅支持商业软件申请查看SBOM");
        }

        // 2. 封装申请记录到实体
        SbomApply sbomApply = new SbomApply();
        BeanUtils.copyProperties(sbomApplyDTO, sbomApply);//拷贝reason/duration
        sbomApply.setSoftwareId(software.getId());//软件id
        sbomApply.setStatus(0);//0-待审批

        // 3. 保存到数据库
        softwareMapper.insertSoftwareApply(sbomApply);
    }

8. softwareMapper

细节:这里虽然是insert操作,但是只想通过自动填充,填充两个create_time, create_user字段,不想填充update字段,不可以把操作类型写成UPDATE,可以在写插入语句的时候不写两个update字段

/**
     * 插入商业软件申请表
     * @param sbomApply
     */
    @Insert("INSERT INTO biz_software_apply (software_id, status, duration, reason, create_time, create_user) " +
            "VALUES (#{softwareId}, #{status}, #{duration}, #{reason}, #{createTime}, #{createUser})")
    @AutoFill(value = OperationType.INSERT)
    void insertSoftwareApply(SbomApply sbomApply);

    /**
     * 根据软件DID查询软件
     * @param did
     * @return
     */
    @Select("select * from software where did = #{did}")
    Software selectByDid(String did);
<?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" >
<mapper namespace="com.sky.mapper.SoftwareMapper">
    <!-- 分页查询:支持全量、软件名称模糊(不区分大小写)、DID模糊 -->
    <select id="softwareLibPageQuery" parameterType="com.sky.dto.SoftwarePageQueryDTO" resultType="com.sky.entity.Software">
        SELECT * FROM software
        <where>
            <!-- 软件名称模糊查询(不区分大小写:将字段和参数都转小写) -->
            <if test="name != null and name != ''">
                AND LOWER(name) LIKE CONCAT('%', LOWER(#{name}), '%')
            </if>

            <!-- DID模糊查询 -->
            <if test="did != null and did != ''">
                AND did LIKE CONCAT('%', #{did}, '%')
            </if>
        </where>
        <!-- 按创建时间倒序(最新的在前) -->
        ORDER BY create_time DESC
    </select>
</mapper>

调试


核心问题是 create_user/update_user 为 null 导致数据库插入失败

通过打印发现controller层丢失currentID。然后发现关于software的所有接口都没有进行jwt校验,是在注册的时候,没有加上路径


    /**
     * 注册自定义拦截器
     * @param registry
     */
    protected void addInterceptors(InterceptorRegistry registry){
        log.info("开始注册自定义拦截器...");
        registry.addInterceptor(jwtTokenInterceptor)
                .addPathPatterns("/user/**")
                .addPathPatterns("/software/**")
                .excludePathPatterns("/user/login");
    }

终于可以了!!

申请消息推送功能开发

核心逻辑是 “申请提交后生成消息 → 目标用户登录后点击消息中心触发API调用 → 查看消息→ 处理申请并更新状态

前端代码

// 消息相关的状态
const messages = ref([
  {
    id: 1,
    title: '标识密钥更新提醒',
    content: '您的软件标识密钥即将过期,请及时更新',
    detail: '尊敬的用户:\n\n您的软件"OpenCV"的标识密钥将在7天后过期。为确保软件的正常使用和安全性,请尽快更新密钥。\n\n详细信息:\n- 当前密钥过期时间:2024-03-27\n- 密钥状态:即将过期\n- 影响范围:软件身份验证、安全通信\n\n更新建议:\n1. 请在密钥到期前完成更新\n2. 进入系统设置更新密钥\n3. 更新后请重启软件以应用新密钥\n\n如需帮助,请联系技术支持。',
    time: '2024-03-20 10:30',
    read: false,
    needsAction: true
  },
  {
    id: 2,
    title: '新的SBOM查看申请',
    content: '收到来自用户 "TechCorp" 的SBOM查看申请',
    detail: '您收到了一个新的SBOM查看申请:\n\n申请详情:\n- 申请方:TechCorp Inc.\n- 申请时间:2024-03-19 15:45\n- 目标软件:OpenCV\n- 申请原因:技术合作评估\n\n申请方信息:\n- 企业认证:已通过\n- 信用等级:A级\n- 历史合作:3次\n\n您可以:\n1. 登录平台查看完整申请信息\n2. 选择接受或拒绝该申请\n3. 设置访问权限和期限\n\n请在3个工作日内处理该申请。',
    time: '2024-03-19 15:45',
    read: false,
    needsAction: true
  },
  {
    id: 3,
    title: '安全扫描完成',
    content: '您的软件安全扫描已完成,发现潜在风险',
    detail: '安全扫描报告摘要:\n\n1. 扫描范围:\n- 代码安全性\n- 依赖组件\n- 配置文件\n- 网络通信\n\n2. 发现问题:\n- 2个中危漏洞\n- 1个配置安全风险\n- 3个过时依赖\n\n3. 建议措施:\n- 更新 log4j 组件到 2.17.1 版本\n- 修改默认配置文件权限\n- 升级 OpenSSL 到最新版本\n\n详细报告已生成,请登录平台查看完整信息并及时处理发现的安全隐患。',
    time: '2024-03-18 09:20',
    read: false
  }
])

// 计算未读消息数量
const messageCount = computed(() => {
  return messages.value.filter(msg => !msg.read).length
})

const messageDialogVisible = ref(false)
const messageDetailVisible = ref(false)
const currentMessage = ref(null)



// 消息中心点击处理
const handleMessageClick = () => {
  messageDialogVisible.value = true
}

// 标记消息为已读
const markMessageAsRead = (message) => {
  const msg = messages.value.find(m => m.id === message.id)
  if (msg) {
    msg.read = true
  }
}

// 标记所有消息为已读
const markAllAsRead = () => {
  messages.value.forEach(msg => {
    msg.read = true
  })
  ElMessage.success('已全部标记为已读')
}

// 删除消息
const deleteMessage = (message) => {
  const index = messages.value.findIndex(m => m.id === message.id)
  if (index !== -1) {
    messages.value.splice(index, 1)
    ElMessage.success('消息已删除')
  }
}

// 查看消息详情
const viewMessageDetail = (message) => {
  currentMessage.value = message
  messageDetailVisible.value = true
  markMessageAsRead(message)
}// 处理SBOM申请
const handleApproveRequest = async () => {
  const loading = ElLoading.service({
    lock: true,
    text: '正在处理申请...',
    background: 'rgba(0, 0, 0, 0.7)'
  })

  try {
    // 模拟处理过程
    await new Promise(resolve => setTimeout(resolve, 1500))
    ElMessage.success('已同意SBOM查看申请')
    // 移除第二条消息
    messages.value = messages.value.filter(msg => msg.id !== 2)
  } finally {
    loading.close()
  }
}
<!-- 消息中心弹窗 -->
    <el-dialog v-model="messageDialogVisible" width="1000px" destroy-on-close :close-on-click-modal="false"
      :show-close="false" class="message-dialog">
      <template #header="{ close }">
        <div class="dialog-header">
          <div class="dialog-title">
            <el-icon class="message-icon">
              <Bell />
            </el-icon>
            <span>消息中心</span>
          </div>
          <el-button class="close-btn" link @click="close">
            <el-icon class="close-icon">
              <Close />
            </el-icon>
          </el-button>
        </div>
      </template>

      <div class="message-header">
        <div class="message-stats">
          <span class="total">共 {{ messages.length }} 条消息</span>
          <el-divider direction="vertical" />
          <span class="unread">{{ messageCount }} 条未读</span>
        </div>
        <el-button type="primary" link class="mark-all-btn" :disabled="messageCount === 0" @click="markAllAsRead">
          <el-icon>
            <Check />
          </el-icon>
          全部标为已读
        </el-button>
      </div>

      <el-scrollbar height="400px" class="message-scrollbar">
        <div v-if="messages.length === 0" class="no-message">
          <el-empty description="暂无消息" />
        </div>
        <div v-else class="message-list">
          <div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ 'message-unread': !msg.read }"
            @click="viewMessageDetail(msg)">
            <div class="message-content">
              <div class="message-title">
                <span>{{ msg.title }}</span>
                <el-tag v-if="!msg.read" size="small" effect="light" class="unread-tag">未读</el-tag>
              </div>
              <div class="message-body">{{ msg.content }}</div>
              <div class="message-footer">
                <span class="message-time">
                  <el-icon>
                    <Timer />
                  </el-icon>
                  {{ msg.time }}
                </span>
                <el-button type="danger" link class="delete-btn" @click.stop="deleteMessage(msg)">
                  <el-icon>
                    <Delete />
                  </el-icon>
                  删除
                </el-button>
              </div>
            </div>
          </div>
        </div>
      </el-scrollbar>
    </el-dialog>

    <!-- 消息详情弹窗 -->
    <el-dialog v-model="messageDetailVisible" width="800px" destroy-on-close :close-on-click-modal="false"
      :show-close="false" class="message-detail-dialog">
      <template #header="{ close }">
        <div class="dialog-header">
          <div class="dialog-title">
            <el-icon class="message-icon">
              <Document />
            </el-icon>
            <span>消息详情</span>
          </div>
          <el-button class="close-btn" link @click="close">
            <el-icon class="close-icon">
              <Close />
            </el-icon>
          </el-button>
        </div>
      </template>

      <div v-if="currentMessage" class="message-detail">
        <h3 class="detail-title">{{ currentMessage.title }}</h3>
        <div class="detail-time">
          <el-icon>
            <Timer />
          </el-icon>
          <span>{{ currentMessage.time }}</span>
        </div>
        <div class="detail-content">
          <pre>{{ currentMessage.detail }}</pre>
        </div>
        <!-- 添加操作按钮 -->
        <div v-if="currentMessage.needsAction" class="detail-actions">
          <el-button v-if="currentMessage.id === 1" type="primary" class="action-button" @click="handleKeyUpdate">
            <el-icon>
              <RefreshRight />
            </el-icon>
            立即更新密钥
          </el-button>
          <el-button v-if="currentMessage.id === 2" type="success" class="action-button" @click="handleApproveRequest">
            <el-icon>
              <Check />
            </el-icon>
            同意申请
          </el-button>
        </div>
      </div>
    </el-dialog>
  </div>

1. 新建MessageSbomVO

package com.sky.vo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "待审批的商业SBOM申请返回的数据格式")
public class MessageSbomVO implements Serializable {
    //private Integer id; // 消息ID(=申请ID)
    @ApiModelProperty("消息标题")
    private String title; // 消息标题

    @ApiModelProperty("消息简介")
    private String content; // 消息简介

    @ApiModelProperty("消息详情")
    private String detail; // 消息详情

    @ApiModelProperty("创建时间")
    private String time; // 创建时间(格式化后)

    @ApiModelProperty("是否已读")
    private Boolean read; // 是否已读

    @ApiModelProperty("是否需要操作")
    private Boolean needsAction; // 是否需要操作

    @ApiModelProperty("申请ID")
    private Integer applyId; // 申请ID(处理时用)

}

2.新建MessageController

package com.sky.controller;

import com.sky.result.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

/**
 * 消息管理
 */
@RestController
@RequestMapping("/message")
@Slf4j
@Api(tags = "消息相关接口")
public class MessageController {

    @Autowired
    private MessageService messageService;

    /**
     * 登录用户查询接收到的SBOM申请消息
     * @return
     */
    @GetMapping("/sbom-apply-list")
    @ApiOperation("查询SBOM申请消息列表")
    public Result<List<MessageVO>> getSbomApplyMessages() {
        List<MessageVO> messageVOList = userService.getSbomApplyMessages();
        return Result.success(messageVOList);
    }
}

3. 新建MessageService

package com.sky.service;

import com.sky.vo.MessageSbomVO;

import java.util.List;

public interface MessageService {

    /**
     * 根据当前用户id获取需要处理的SBOM申请信息
     * @return
     */
    List<MessageSbomVO> getSbomApplyMessages();
}

4. MessageServiceImpl

package com.sky.service.impl;

import com.sky.context.BaseContext;
import com.sky.entity.SbomApply;
import com.sky.exception.AccountNotFoundException;
import com.sky.mapper.SoftwareMapper;
import com.sky.mapper.UserMapper;
import com.sky.service.MessageService;
import com.sky.vo.MessageSbomVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;

@Service
@Slf4j
public class MessageServiceImpl implements MessageService {

    @Autowired
    private SoftwareMapper softwareMapper;
    @Autowired
    private UserMapper userMapper;

    /**
     * 登录用户查询自己接收到的商业软件申请(消息中心数据)
     * @return
     */
    public List<MessageSbomVO> getSbomApplyMessages() {
        // 1.获取当前用户id
        Integer currentId = BaseContext.getCurrentId();
        if (currentId == null){
            throw new AccountNotFoundException("用户未登录");
        }

        // 2.查询该用户接收的待审批申请
        List<SbomApply> applyList = softwareMapper.selectSbomApplyByReceiveId(currentId);

        // 3.转换为前端需要的消息格式
        return applyList.stream().map(apply -> {
            // 关联软件表,获取软件名称
            String softwareName = softwareMapper.selectNameById(apply.getSoftwareId());

            // 构建消息详情
            String detail = String.format(
                    "您收到了一个新的SBOM查看申请:\n\n" +  // 换行用 \n 即可,无需 \\n
                            "申请详情:\n" +
                            "- 申请方:%s\n" +
                            "- 申请时间:%s\n" +
                            "- 目标软件:%s\n" +
                            "- 申请时长:%d天\n" +
                            "- 申请原因:%s\n\n" +
                            "申请方信息:\n" +
                            "- 企业认证:已通过\n" +
                            "- 信用等级:A级\n" +
                            "- 历史合作:3次\n\n" +
                            "您可以:\n" +
                            "1. 点击同意/拒绝处理该申请\n" +
                            "2. 同意后申请方将获得临时查看权限(有效期按申请时长)\n" +
                            "3. 拒绝后申请方将收到系统通知",  // 去掉末尾多余的 ",
                    userMapper.getNameById(apply.getCreateUser()),
                    apply.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")),
                    softwareName,
                    apply.getDuration(),
                    apply.getReason()
            );

            // 封装VO
            return MessageSbomVO.builder()
                    .applyId(apply.getApplyId())
                    .title("新的SBOM查看申请")
                    .content(String.format("收到SBOM查看申请(软件:%s)", softwareName))
                    .detail(detail)
                    .time(apply.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")))
                    .read(false) // 初始未读
                    .needsAction(true) // 需要操作(同意/拒绝)
                    .applyId(apply.getApplyId()) // 关联申请ID(后续处理用)
                    .build();
        }).collect(Collectors.toList());

    }
}

5. SoftwareMapper

package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.annotation.AutoFill;
import com.sky.dto.SoftwarePageQueryDTO;
import com.sky.entity.SbomApply;
import com.sky.entity.Software;
import com.sky.enumeration.OperationType;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;

@Mapper
public interface SoftwareMapper {

    /**
     * 标识分页查询--实现按照件名称(不区分大小写)或者did模糊查询
     * @param softwarePageQueryDTO
     * @return
     */
    Page<Software> softwareLibPageQuery(SoftwarePageQueryDTO softwarePageQueryDTO);


    /**
     * 插入商业软件申请表
     * @param sbomApply
     */
    @Insert("INSERT INTO biz_software_apply (software_id, status, duration, reason, create_time, create_user, receive_id) " +
            "VALUES (#{softwareId}, #{status}, #{duration}, #{reason}, #{createTime}, #{createUser}, #{receiveId})")
    @AutoFill(value = OperationType.INSERT)
    void insertSoftwareApply(SbomApply sbomApply);

    /**
     * 根据软件DID查询软件
     * @param did
     * @return
     */
    @Select("select * from software where did = #{did}")
    Software selectByDid(String did);

    /**
     * 查询当前用户收到的SBOM申请
     * @param currentId
     * @return
     */
    @Select("select * from biz_software_apply where receive_id = #{currentId}")
    List<SbomApply> selectSbomApplyByReceiveId(Integer currentId);


    /**
     * 根据软件ID查询软件名称
     * @param softwareId
     * @return
     */
    @Select("select name from software where id = #{softwareId}")
    String selectNameById(Integer softwareId);
}

后端接口文档测试

6. 前端开发:

api下面新增message.js

import request from '@/utils/request'

/**
 * 查询登录用户接收的SBOM申请消息
 */
export const getSbomApplyMessages = () => {
  return request({
    url: '/message/sbom-apply-list',
    method: 'get'
  })
}

修改Major.vue

<script setup>
import router from '@/router'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user.js'
import { ArrowDown, User, Bell, SwitchButton, Close, Check, Timer, Delete, Document, RefreshRight } from '@element-plus/icons-vue'
import { ElMessage, ElLoading } from 'element-plus'
import { ref, computed, watch, onMounted } from 'vue';
import { getSbomApplyMessages } from '@/api/message.js' // 新增API函数

const userStore = useUserStore()
const route = useRoute()
const activeMenu = computed(() => {
  return route.path.startsWith('/function') ? '/function' : route.path
})
const isScrolled = ref(false)
const isLoggedIn = computed(() => !!userStore.token)
// 消息列表(仅存储SBOM申请消息)
const messages = ref([])
//const loading = ref(false)  // 新增:消息加载状态
const loadingMessages = ref(false); // 声明加载状态变量(之前漏了)

// 计算未读消息数量
const messageCount = computed(() => {
  return messages.value.filter(msg => !msg.read).length
})

const messageDialogVisible = ref(false)
const messageDetailVisible = ref(false)
const currentMessage = ref(null)

// 监听登录状态变化,当用户登录后获取消息
watch(
  () => isLoggedIn.value,
  (newVal) => {
    if (newVal) {
      fetchSbomMessages()
    } else {
      // 退出登录时清空消息
      messages.value = []
    }
  }
)

watch(
  () => route.path,
  (newPath) => {
    console.log('当前激活菜单:', newPath)
  },
  { immediate: true }
)

const handleMenuSelect = (index) => {
  if (index === '/function' && !route.path.startsWith('/function')) {
    router.push('/function')
  } else {
    router.push(index)
  }
}

const handleScroll = ({ scrollTop }) => {
  isScrolled.value = scrollTop > 60
}

const handleLogin = () => {
  router.push({
    path: '/login',
    query: {
      redirect: route.fullPath
    }
  })
}

const handleRegister = () => {
  router.push({
    path: '/login',
    query: {
      form: 'register',
      redirect: route.fullPath
    }
  })
}

// 新增:获取SBOM申请消息的方法
const fetchSbomMessages = async () => {
  // 如果用户未登录,不获取消息
  if (!isLoggedIn.value) return

  loadingMessages.value = true
  try {
    const response = await getSbomApplyMessages()
    console.log('API返回结果:', response); // 打印响应,确认格式
    // 假设API返回格式为{ code: 1, data: [...] }
    if (response.code === 1) {
      // 确保消息有必要的字段,没有的话添加默认值
      messages.value = response.data.map(msg => ({
        ...msg,
        id: msg.applyId, // 用applyId作为消息唯一标识
        read: msg.read || false,
        needsAction: msg.needsAction !== undefined ? msg.needsAction : true
      }))
    } else {
      ElMessage.error('获取消息失败:' + (response.msg || '未知错误'))
    }
  } catch (error) {
    console.error('获取消息出错:', error)
    ElMessage.error('获取消息失败,请稍后重试')
  } finally {
    loadingMessages.value = false
  }
}
// 消息中心点击处理
const handleMessageClick = () => {
  messageDialogVisible.value = true
  fetchSbomMessages();//打开弹窗时立刻拉取消息
}

// 页面初始化时,若已登录则加载消息
onMounted(() => {
  if (isLoggedIn.value) {
    fetchSbomMessages();
  }
});

// 标记消息为已读 - 修改为API调用版本
const markMessageAsRead = async (message) => {
  if (message.read) return  // 已读消息无需处理
  
  try {
    // 假设存在标记已读的API
    // await markMessageReadApi(message.id)
    const msg = messages.value.find(m => m.id === message.id)
    if (msg) {
      msg.read = true
    }
    ElMessage.success('已标记为已读')
  } catch (error) {
    console.error('标记消息已读失败:', error)
    ElMessage.error('标记已读失败')
  }
}

// 标记所有消息为已读 - 修改为API调用版本
const markAllAsRead = async () => {
  const unreadMessages = messages.value.filter(msg => !msg.read)
  if (unreadMessages.length === 0) {
    ElMessage.info('没有未读消息')
    return
  }
  
  try {
    // 假设存在标记全部已读的API
    // await markAllMessagesReadApi()
    messages.value.forEach(msg => {
      msg.read = true
    })
    ElMessage.success('已全部标记为已读')
  } catch (error) {
    console.error('标记全部已读失败:', error)
    ElMessage.error('标记全部已读失败')
  }
}

// 删除消息 - 修改为API调用版本
const deleteMessage = async (message) => {
  try {
    // 假设存在删除消息的API
    // await deleteMessageApi(message.id)
    const index = messages.value.findIndex(m => m.id === message.id)
    if (index !== -1) {
      messages.value.splice(index, 1)
      ElMessage.success('消息已删除')
    }
  } catch (error) {
    console.error('删除消息失败:', error)
    ElMessage.error('删除消息失败')
  }
}


// 查看消息详情
const viewMessageDetail = (message) => {
  currentMessage.value = message
  messageDetailVisible.value = true
  markMessageAsRead(message)
}


const handleLogout = () => {
  userStore.logout()

  // 已完成:跳转到登录页
  router.push({
    path: '/login',
    replace: true // 重要:清除当前页的路由历史
  })
}

// 处理密钥更新
const handleKeyUpdate = async () => {
  const loading = ElLoading.service({
    lock: true,
    text: '正在更新密钥...',
    background: 'rgba(0, 0, 0, 0.7)'
  })

  try {
    //TODO  // 实际项目中替换为真实API调用
    // 模拟更新过程
    await new Promise(resolve => setTimeout(resolve, 2000))
    ElMessage.success('密钥更新成功')
    // 移除第一条消息
    messages.value = messages.value.filter(msg => msg.id !== 1)
  } finally {
    loading.close()
  }
}

// 处理SBOM申请 - 修改为更通用的版本
const handleApproveRequest = async (approve = true) => {  // 添加approve参数支持同意/拒绝
  if (!currentMessage.value) return
  
  const loading = ElLoading.service({
    lock: true,
    text: `正在${approve ? '同意' : '拒绝'}申请...`,
    background: 'rgba(0, 0, 0, 0.7)'
  })

  try {
    // 实际项目中替换为真实API调用
    // await handleSbomRequestApi(currentMessage.value.id, approve)
    await new Promise(resolve => setTimeout(resolve, 1500))
    ElMessage.success(`${approve ? '已同意' : '已拒绝'}SBOM查看申请`)
    // 从列表中移除该消息
    messages.value = messages.value.filter(msg => msg.id !== currentMessage.value.id)
    messageDetailVisible.value = false
  } finally {
    loading.close()
  }
}

// 新增:刷新消息列表
//const refreshMessages = () => {
//  fetchSbomMessages()
//}
</script>


<!-- 消息中心弹窗 -->
    <el-dialog v-model="messageDialogVisible" width="1000px" destroy-on-close :close-on-click-modal="false"
      :show-close="false" class="message-dialog" @close="handleCloseDetail">
      <template #header="{ close }">
        <div class="dialog-header">
          <div class="dialog-title">
            <el-icon class="message-icon">
              <Bell />
            </el-icon>
            <span>消息中心</span>
          </div>
          <el-button class="close-btn" link @click="close">
            <el-icon class="close-icon">
              <Close />
            </el-icon>
          </el-button>
        </div>
      </template>

      <div class="message-header">
        <div class="message-stats">
          <span class="total">共 {{ messages.length }} 条消息</span>
          <el-divider direction="vertical" />
          <span class="unread">{{ messageCount }} 条未读</span>
        </div>
        <el-button type="primary" link class="mark-all-btn" :disabled="messageCount === 0" @click="markAllAsRead">
          <el-icon>
            <Check />
          </el-icon>
          全部标为已读
        </el-button>
      </div>

      <el-scrollbar height="400px" class="message-scrollbar">
        <div v-if="loading" class="loading-message">
          <el-loading text="加载中..." type="circle" />
        </div>
        <div v-else-if="messages.length === 0" class="no-message">
          <el-empty description="暂无消息" />
        </div>


        <div v-else class="message-list">
          <div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ 'message-unread': !msg.read }"
            @click="viewMessageDetail(msg)">
            <div class="message-content">
              <div class="message-title">
                <span>{{ msg.title }}</span>
                <el-tag v-if="!msg.read" size="small" effect="light" class="unread-tag">未读</el-tag>
              </div>
              <div class="message-body">{{ msg.content }}</div>
              <div class="message-footer">
                <span class="message-time">
                  <el-icon>
                    <Timer />
                  </el-icon>
                  {{ msg.time }}
                </span>
                <el-button type="danger" link class="delete-btn" @click.stop="deleteMessage(msg)">
                  <el-icon>
                    <Delete />
                  </el-icon>
                  删除
                </el-button>
              </div>
            </div>
          </div>
        </div>
      </el-scrollbar>
    </el-dialog>

    <!-- 消息详情弹窗 -->
    <!-- 弹窗关闭事件(如ESC键)绑定handleCloseDetail -->
    <el-dialog v-model="messageDetailVisible" width="800px" destroy-on-close :close-on-click-modal="false"
      :show-close="false" class="message-detail-dialog" @close="handleCloseDetail">
      <template #header="{ close }">
        <div class="dialog-header">
          <div class="dialog-title">
            <el-icon class="message-icon">
              <Document />
            </el-icon>
            <span>消息详情</span>
          </div>
          <el-button class="close-btn" link @click="close">
            <el-icon class="close-icon">
              <Close />
            </el-icon>
          </el-button>
        </div>
      </template>

      <div v-if="currentMessage" class="message-detail">
        <h3 class="detail-title">{{ currentMessage.title }}</h3>
        <div class="detail-time">
          <el-icon>
            <Timer />
          </el-icon>
          <span>{{ currentMessage.time }}</span>
        </div>
        <div class="detail-content">
          <pre>{{ currentMessage.detail }}</pre>
        </div>
        <!-- 添加操作按钮 -->
        <!-- 操作按钮:去掉硬编码id,按消息类型显示 -->
        <div v-if="currentMessage.needsAction" class="detail-actions">
          <!-- 1. 密钥类消息:仅显示「立即更新密钥」按钮 -->
          <el-button v-if="currentMessage.title.includes('密钥更新')" type="primary" class="action-button" @click="handleKeyUpdate">
            <el-icon>
              <RefreshRight />
            </el-icon>
            立即更新密钥
          </el-button>

          <!-- 2. SBOM查看申请:显示「同意+拒绝」两个按钮 -->
          <el-button v-if="currentMessage.title.includes('SBOM查看申请')" type="success" class="action-button" @click="handleApproveRequest">
            <el-icon>
              <Check />
            </el-icon>
            同意申请
          </el-button>

          <el-button v-if="currentMessage.title.includes('SBOM查看申请')" type="danger" class="action-button"  @click="handleSbomAction('reject')">
            <el-icon>
              <Close /> <!-- 用已导入的Close图标,替代未导入的X -->
            </el-icon>
            拒绝申请
          </el-button>

          <!-- 3. 安全类消息:无按钮(不写任何按钮逻辑即可) -->
        </div>

      </div>
    </el-dialog>


  </div>

11.12

消息已读/全部已读功能开发

1. 前端API

/**
 * 标记单条消息为已读
 * @param applyId 消息ID(即applyId)
 */
export const markMessageReadApi = (applyId) => {
  return request({
    url: '/message/mark-read',
    method: 'post',
    params: { applyId } // 以URL参数传递applyId
  })
}

/**
 * 标记所有消息为已读
 */
export const markAllMessagesReadApi = () => {
  return request({
    url: '/message/mark-all-read',
    method: 'post'
  })
}

2. Major.vue

import { getSbomApplyMessages,markMessageReadApi, markAllMessagesReadApi } from '@/api/message.js' // 新增API函数
// 标记消息为已读 - 修改为API调用版本
const markMessageAsRead = async (message) => {
  if (message.read) return  // 已读消息无需处理
  
  try {
    //  调用后端API(关键:传递message.id=applyId)
    await markMessageReadApi(message.id)

    // 前端本地更新状态
    const msg = messages.value.find(m => m.id === message.id)
    if (msg) {
      msg.read = true
    }
    ElMessage.success('已标记为已读')
  } catch (error) {
    console.error('标记消息已读失败:', error)
    ElMessage.error('标记已读失败')
  }
}

// 标记所有消息为已读 - 修改为API调用版本
const markAllAsRead = async () => {
  const unreadMessages = messages.value.filter(msg => !msg.read)
  if (unreadMessages.length === 0) {
    ElMessage.info('没有未读消息')
    return
  }
  
  try {
    // 调用后端API
    await markAllMessagesReadApi()

    // 前端本地更新所有消息状态
    messages.value.forEach(msg => {
      msg.read = true
    })
    ElMessage.success('已全部标记为已读')
  } catch (error) {
    console.error('标记全部已读失败:', error)
    ElMessage.error('标记全部已读失败')
  }
}

3. controller

记得在controller层写log.info调试信息

/**
     * 标记单条消息为已读
     * @param applyId 消息关联的申请ID
     * @return
     */
    // @RequeatParam需要前后端参数名称一致
    @PostMapping("/mark-read")
    @ApiOperation("标记单条消息为已读")
    public Result markAsRead(@RequestParam Integer applyId){
        messageService.markAsRead(applyId);
        return Result.success();
    }

    /**
     * 标记所有未读消息为已读
     */
    @PostMapping("/mark-all-read")
    @ApiOperation("标记所有消息为已读")
    public Result markAllAsRead() {
        messageService.markAllAsRead();
        return Result.success();
    }

4. service


    /**
     * 标记单条消息为已读
     * @param applyId
     */
    void markAsRead(Integer applyId);

    /**
     * 标记所有未读消息为已读
     */
    void markAllAsRead();

在serviceImpl层加transactional注解,抛出自定义异常

   /**
     * 标记单条消息为已读
     * @param applyId
     */
    @Transactional
    public void markAsRead(Integer applyId) {
        Integer receiveId = BaseContext.getCurrentId();//当前登录用户
        int rows = softwareMapper.markAsRead(applyId, receiveId);
        if (rows == 0) {
            throw new MessageException(MessageConstant.MESSAGE_NOT_EXIST);
        }
    }

    /**
     * 标记所有消息为已读
     */
    @Transactional
    public void markAllAsRead() {
        Integer receiveId = BaseContext.getCurrentId();
        softwareMapper.markAllAsRead(receiveId);
    }

5. mapper

    /**
     * 标记单条申请为已读(根据applyId和receiveId,确保只能标记自己的消息)
     * @param applyId
     * @param receiveId
     * @return
     */
    @Update("UPDATE biz_software_apply SET status = 3 WHERE apply_id = #{applyId} AND receive_id = #{receiveId}")
    int markAsRead(Integer applyId, Integer receiveId);


    /**
     * 标记所有申请SBOM为已读
     * @param receiveId
     */
    @Update("update biz_software_apply set status = 3 where receive_id = #{receiveId}")
    void markAllAsRead(Integer receiveId);

处理(同意 / 拒绝)

1. SbomApplyStatusConstant

package com.sky.constant;

/**
 * 申请查看SBOM的状态
 */
public class SbomApplyStatusConstant {

    // 待审批
    public static final Integer PENDING = 0;

    // 审批通过
    public static final Integer APPROVED = 1;

    // 审批拒绝
    public static final Integer REJECTED = 2;

    // 已读
    public static final Integer READ = 1;
}

2. controller

 /**
     * 处理SBOM查看申请(同意/拒绝)
     * @param param 接收前端传参:applyId(申请ID)、action(approve/reject)
     * @return
     */
    @PostMapping("/sbom-apply-handle")
    @ApiOperation("处理SBOM申请")
    public Result handleApply(@RequestBody Map<String, Object> param){
        // 解析前端参数
        Integer applyId = Integer.parseInt(param.get("applyId").toString());
        String action = param.get("action").toString();

        // 校验参数
        if (!"approve".equals(action) && !"reject".equals(action)) {
            return Result.error("操作类型无效(仅支持approve/reject)");
        }

        // 调用服务处理
        messageService.handleApply(applyId, action);
        return Result.success();
    }

3. service

/**
     * 处理SBOM申请(同意/拒绝)
     * @param applyId
     * @param action
     */
    void handleApply(Integer applyId, String action);

serviceImpl

 /**
     * 处理SBOM申请(同意/拒绝)
     * @param applyId 申请ID
     * @param action 操作类型(approve=同意,reject=拒绝)
     */
    @Transactional
    public void handleApply(Integer applyId, String action) {
        Integer receiveId = BaseContext.getCurrentId(); // 当前登录用户(消息接收者)
        Integer targetStatus = "approve".equals(action) ? SbomApplyStatusConstant.APPROVED : SbomApplyStatusConstant.REJECTED;

        // 构建更新参数:指定applyId、receiveId和目标状态,updateTime和updateUser由@AutoFill自动填充
        SbomApply sbomApply = SbomApply.builder()
                .applyId(applyId)
                .receiveId(receiveId)
                .status(targetStatus)
                .build();

        // 调用通用更新方法
        int rows = softwareMapper.updateSbomApplyStatus(sbomApply);
        if (rows == 0) {
            throw new MessageException("申请不存在或无权限操作");
        }
    }

4. Mapper

对单条已读、全部已读。同意/拒绝都使用同一个动态SQL

实际上如果要使用AutoFill,传入的应该是一个实体

 <update id="updateSbomApplyStatus" parameterType="com.sky.entity.SbomApply">
        UPDATE biz_software_apply
        <set>
            <!-- 状态必传(3-已读,1-通过,2-拒绝) -->
            status = #{status},
            <!-- 仅当更新审批状态时,才填充 update_time 和 update_user(通过 @AutoFill 注解自动填充) -->
            <if test="updateTime != null">
                update_time = #{updateTime},
            </if>
            <if test="updateUser != null">
                update_user = #{updateUser},
            </if>
        </set>
        WHERE receive_id = #{receiveId} <!-- 必传:确保只能操作自己的消息 -->
        <!-- 条件判断:单条更新(带 applyId)还是批量更新(不带 applyId) -->
        <if test="applyId != null">
            AND apply_id = #{applyId}
        </if>
    </update>
    /**
     * 动态更新 SBOM 申请状态(支持标记已读、批量标记已读、更新审批状态)
     * @param sbomApply 封装更新参数的实体类(包含 applyId、receiveId、status、updateTime、updateUser 等)
     * @return 影响行数
     */
    @AutoFill(value = OperationType.UPDATE)
    int updateSbomApplyStatus(SbomApply sbomApply);

5. 前端

api/meaasge.js

// 处理SBOM申请(同意/拒绝)
export const handleSbomApply = (data) => {
  return request({
    url: '/message/sbom-apply-handle',
    method: 'post',
    data
  })
}
 <!-- 2. SBOM查看申请:显示「同意+拒绝」两个按钮 -->
          <el-button v-if="currentMessage.title.includes('SBOM查看申请')" type="success" class="action-button" @click="handleSbomAction('approve')">
            <el-icon>
              <Check />
            </el-icon>
            同意申请
          </el-button>

          <el-button v-if="currentMessage.title.includes('SBOM查看申请')" type="danger" class="action-button"  @click="handleSbomAction('reject')">
            <el-icon>
              <Close /> <!-- 用已导入的Close图标,替代未导入的X -->
            </el-icon>
            拒绝申请
          </el-button>
// 处理SBOM申请(同意/拒绝)- 核心函数
const handleSbomAction = async (action) => {
  if (!currentMessage.value?.id) { // currentMessage.id 就是 applyId
    ElMessage.error('申请信息异常');
    return;
  }

  const loading = ElLoading.service({
    lock: true,
    text: `正在${action === 'approve' ? '同意' : '拒绝'}申请...`,
    background: 'rgba(0, 0, 0, 0.7)'
  });

  try {
    // 调用后端接口:传递applyId和action
    await handleSbomApply({
      applyId: currentMessage.value.id, // 申请ID(前端用id存储applyId)
      action: action // 操作类型:approve/reject
    });

    ElMessage.success(`${action === 'approve' ? '已同意' : '已拒绝'}SBOM查看申请`);
    // 从消息列表移除该消息(处理完成后不再显示)
    messages.value = messages.value.filter(msg => msg.id !== currentMessage.value.id);
    messageDetailVisible.value = false; // 关闭详情弹窗
  } catch (error) {
    console.error('处理申请失败:', error);
    ElMessage.error(`处理失败:${error.response?.data?.msg || '未知错误'}`);
  } finally {
    loading.close();
  }
};

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐