当前位置: 首页 > news >正文

链盾shieldchain | 数据库、用户注册、登录、标识查询、商业软件申请和处理、消息

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_idCVE 漏洞编号(如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_scoreCVSS 基础评分(如9.8,0-10 分,分数越高风险越高)
base_severity基础严重级别(如CRITICAL,通常分 CRITICAL/High/Medium/Low)
vector_stringCVSS 向量字符串(如CVSS:3.1/AV:N/AC:L/...,详细描述评分维度)

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

字段名含义说明
id自增主键(表内唯一标识)
cve_id关联的 CVE 漏洞编号(与cve_main表的cve_id对应)
cpe23_uriCPE 标识(如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_idINTPKAUTO_INCREMENT申请唯一标识
buyer_idINTFKNOT NULL采购方 ID,关联user.id
soft_idINTFKNOT NULL申请的软件 ID,关联software.id
reasonTEXTNOT NULL申请原因(如 “评估软件组件安全性”)
statusTINYINTNOT NULL, DEFAULT 0申请状态:0 - 待审批,1 - 通过,2 - 拒绝
approver_idINTFK审批人 ID(软件所属团队的管理员),关联user.id
create_timeDATETIMENOT NULL, DEFAULT NOW()申请提交时间
handle_timeDATETIME记录处理完成时间(审批通过/拒绝时填写)

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*/@Beanpublic 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 = truelet 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.tokenthis.id = info.idthis.username = info.username},logout() {this.token = ''this.id = nullthis.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 = truelet result = await userLoginService(loginForm.value)// 根据后端的Result设计,成功是1,失败是0if (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的bodyJwtBuilder 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){// 得到DefaultJwtParserClaims 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 {@Autowiredprivate 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 {@Autowiredprivate 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 {@Autowiredprivate UserService userService;@Autowiredprivate 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实体获取idString 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{@Autowiredprivate 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/jsonreturn 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.tokenthis.id = info.idthis.username = info.username},logout() {this.token = ''this.id = nullthis.username = ''},// 新增:校验token是否过期isTokenExpired() {if (!this.token) return true // 无token视为过期try {const decoded = jwtDecode(this.token) // 解析tokenconst 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.tokenconst 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 管理DidControllerDID 创建、状态变更、密钥轮换、信息查询
文件操作(SBOM 等)FileControllerSBOM / 漏洞清单生成、下载、格式转换
审计日志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*/@ExceptionHandlerpublic Result exceptionHandler(BaseException ex){log.error("异常信息:{}", ex.getMessage());return Result.error(ex.getMessage());}/*** 处理SQL异常* @param ex* @return*/@ExceptionHandlerpublic 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]; //提取重复的值,如zhangsanString 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接收JSONpublic 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位@Autowiredprivate 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*/@Transactionalpublic 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,passwordBeanUtils.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. 生成随机IVbyte[] 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的enumerationOperationType 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/UPDATEMethodSignature 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可以接受所有不同的实体// 准备赋值数据 -- ThreadLocalLocalDateTime 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.tokenconst 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 = truetry {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 = trueif (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] : recordstotal.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 = sizecurrentPage.value = 1  // 切换每页条数时重置为第一页await handleSearch()
}const handlePageChange = async (page) => {currentPage.value = pageawait 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.idprivate 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 {@Autowiredprivate 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 {@Autowiredprivate 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*/@Overrideprotected 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 = truetry {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.recordstotal.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 = sizecurrentPage.value = 1  // 切换每页条数时重置为第一页await handleSearch()
}const handlePageChange = async (page) => {currentPage.value = pageawait 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*/// 写操作,保证事务一致性@Transactionalpublic 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/durationsbomApply.setSoftwareId(software.getId());//软件idsbomApply.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 = messagemessageDetailVisible.value = truemarkMessageAsRead(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 {@Autowiredprivate 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 {@Autowiredprivate SoftwareMapper softwareMapper;@Autowiredprivate UserMapper userMapper;/*** 登录用户查询自己接收到的商业软件申请(消息中心数据)* @return*/public List<MessageSbomVO> getSbomApplyMessages() {// 1.获取当前用户idInteger 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());// 封装VOreturn 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) returnloadingMessages.value = truetry {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 = truefetchSbomMessages();//打开弹窗时立刻拉取消息
}// 页面初始化时,若已登录则加载消息
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 = messagemessageDetailVisible.value = truemarkMessageAsRead(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) returnconst 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 {// 调用后端APIawait 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*/@Transactionalpublic void markAsRead(Integer applyId) {Integer receiveId = BaseContext.getCurrentId();//当前登录用户int rows = softwareMapper.markAsRead(applyId, receiveId);if (rows == 0) {throw new MessageException(MessageConstant.MESSAGE_NOT_EXIST);}}/*** 标记所有消息为已读*/@Transactionalpublic 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=拒绝)*/@Transactionalpublic 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 就是 applyIdElMessage.error('申请信息异常');return;}const loading = ElLoading.service({lock: true,text: `正在${action === 'approve' ? '同意' : '拒绝'}申请...`,background: 'rgba(0, 0, 0, 0.7)'});try {// 调用后端接口:传递applyId和actionawait 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();}
};

http://www.dtcms.com/a/601548.html

相关文章:

  • C++ set 容器:有序唯一元素集合的深度解析与实战
  • 前端的dist包放到后端springboot项目下一起打包
  • Swift 6.2 列传(第六篇):内存安全的 “峨眉戒令”
  • 毕设用别人网站做原型企业英语培训哪里好
  • 网站排名优化系统百度竞价什么意思
  • 网站群项目建设实施进度计划衡水网站建设电话
  • 【自然语言处理】基于混合基的句子边界检测算法
  • 快快测(KKCE)TCping 检测全面升级:IPv6 深度覆盖 + 多维度可视化,重构网络性能监测新体验
  • 句容网站移动互联网软件开发
  • vs编译c语言 | 详细解析如何配置与调试Visual Studio环境
  • 浙江火电建设有限公司网站营销策划公司名字简单大气
  • 自动驾驶与联网车辆网络安全:系统级威胁分析与韧性框架
  • 野火fpga笔记
  • 在 Ubuntu 上安装 MySQL 的详细指南
  • 智慧医疗:FHIR R5、联邦学习与MLOps三位一体的AI产品化实战指南(上)
  • Unity Shader Graph 3D 实例 - 基础的模型颜色渲染
  • 做二手货的网站咋建网站
  • 专业苏州房产网站建设网站定制与模板开发
  • 黄牛群算法详细原理,黄牛群算法公式,黄牛群算法应用
  • html语法
  • 移动终端安全:实验4-中间人攻击
  • 【前端面试】JS篇
  • 网站模板怎么用法企业做pc网站需要什么资料
  • 简单医院网站wordpress xiu 5.5
  • APP上架应用市场全解析:安卓商店与苹果App Store流程及资质要求
  • ECS 事件监控钉钉通知优化实践
  • 2025年ChatGPT Plus、Pro、Business用户Codex使用限制详解(附Codex额度查询入口)
  • Android垃圾回收算法详解
  • wordpress做管理网站百度网盟有哪些网站
  • 东莞企业网站哪家好平顶山网站建设电话