跳到主要内容

基于SpringBoot + Vue3的客户关系管理系统(CRM)

项目基本信息

提示
  • 项目说明:该项目是一个客户关系管理系统,主要解决在市场活动中从采集信息到完成交易的一系列过程的记录。主要采用了SpringBoot+Vue3的前后端分离方式构建,后端采用SpringSecurity做客户与用户之间权限的管理。从三个层面进行了限制:数据层面,菜单层面以及功能层面;在数据的查询中使用Redis做分页缓存对未知数据进行空值处理避免缓存穿透问题;

  • 项目地址:https://demo.believesun.cn

  • 项目Github地址:https://github.com/2022zhang125/SpringSecurityProject.git

  • 项目账密:admin / aaa111 ; yuyan / aaa111

  • 项目来源:CRM客户关系管理系统 - 动力节点

危险

注意:该项目仅实现了主要功能,对于重复功能这里暂未开发,因此只做demo项目,请酌情查看。

:::


项目使用技术

项目所用的框架以及技术。

  • 后端

    • Controller层:SpringBoot
    • ORM层:MybatisMysql
    • 缓存层:Redis
  • 前端

    • Web层:Vue3
    • 组件样式:ElementPlus
    • 路由:vue-router
  • 部署

    • 反向代理:Nginx
    • 容器化部署:Docker + Redis + Mysql

项目亮点(主要是对于后端)

  • 采用SpringSecurity + JWT的方式对登录进行统一管理,解决了传统请求交叉导致项目条理不清的问题。
  • 从三个层面实现了数据隔离:
    • 数据层面:在数据库上,我们采用注解 + 切面 的形式对SQL进行拼接,已达到不同用户查询不同数据的结果。
    • 菜单层面:通过数据库的权限控制代码,在用户登录时以参数形式传入前端,进行菜单的选择性展示。
    • 功能层面:对不同的操作(CRUD)在Controller进行API调用的限制。前端使用自定义指令对其进行过滤。
  • 采用Redis + 分页做缓存,对Mysql数据进行分页展示与缓存;以及对用户登录时间的控制。
  • 采用注解 + Aspect切面利用延迟双删去解决Redis + Mysql双写不一致问题,达到了最终一致性

项目展示

提示

这里就只展示Dashboard和首页,具体细节请转项目地址:https://demo.believesun.cn

首页

image-20250913122248939

Dashboard页面

提示

左侧是截图软件长截图导致的异常,项目本身不存在。

PixPin_2025-09-13_12-24-24


项目具体技术分析

备注

这里我将对以上亮点进行展示与解析

第一点:SpringSecurity + Jwt实现的登录权限管理

在用户第一次登录时会被重定向到自定义的登录页:https://demo.believesun.cn,然后进行登录操作。如果登录成功!则在后端生成一个 Login(username) Key Login(Jwt) Value 的键值对进行存储,其存储时间 默认:30min。画的比较糙

登录

代码实现:

登录成功执行的Handler

@Component
public class SuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private RedisTemplate<String, String> redisTemplate;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = null;
// 生成JWT
TUser user = (TUser) authentication.getPrincipal();
String jwt = JWTUtil.createToken(Map.of("user", user), Constants.SECRET.getBytes());

String rememberMe = request.getParameter("rememberMe");
// 用户勾选记住我,基于token 7 天时限,否则只有30分钟
if (rememberMe.equals("true")) {
redisTemplate.opsForValue().set(user.getLoginAct(), jwt, Constants.JWT_LONG_TIME, TimeUnit.SECONDS);
} else {
// 将JWT存入Redis中 30分钟的时效性
redisTemplate.opsForValue().set(user.getLoginAct(), jwt, Constants.JWT_SHORT_TIME, TimeUnit.SECONDS);
}
try {
out = response.getWriter();
// 登录成功后将jwt发给前端
String jsonStr = JSONUtil.toJsonStr(R.SUCCESS("登录成功!", user.getName() + ":" + jwt));
out.write(jsonStr);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (out != null) {
out.flush();
out.close();
}
}
}
}

JWT验证过滤器

@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private RedisTemplate redisTemplate;
private static final String[] passList = {"/user/login", "/"};

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 放行不需要过滤的请求
if (shouldNotFilter(request)) {
filterChain.doFilter(request, response);
return;
}
try {
String authorization = request.getHeader("Authorization");
// 验证token是否存在且有效
if (!StringUtils.hasText(authorization) || !JWTUtil.verify(authorization, Constants.SECRET.getBytes())) {
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "验证不通过,请登录后再试");
return;
}
// 验证token是否与redis中一致
TUser user = JSONUtil.toBean(JWTUtil.parseToken(authorization).getPayload("user").toString(), TUser.class);
String redis_token = (String) redisTemplate.opsForValue().get(user.getUsername());

if (redis_token == null || !redis_token.equals(authorization)) {
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "Token已失效,请重新登录");
return;
}

// 验证通过,设置SecurityContext
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(
user, user.getPassword(), user.getAuthorities()));

filterChain.doFilter(request, response);
} catch (Exception e) {
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
}
}

private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(200);
response.setContentType("application/json;charset=UTF-8");
try (PrintWriter out = response.getWriter()) {
out.write(JSONUtil.toJsonStr(R.FAIL(message, null)));
}
}

protected boolean shouldNotFilter(HttpServletRequest request) {
return Arrays.asList(passList).contains(request.getRequestURI());
}
}

第二点:数据隔离

数据层面

通过 Mapper上的注解,在执行SQL之前,将其拦截下来进行SQL段的拼接。

DynamicSql注解

/**
* @author BelieveSun
* @date 2025/9/10 15:04
* 动态SQL解决权限问题,除了管理员外只能看自己的信息,也就是在SQL后拼接SQL片段;
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DynamicSql {
String sqlPattern() default "";
}

DynamicSqlAspect 切面。使用LocalThread对当前线程下的SQL片段进行保存,避免多线程的问题。

@Slf4j
@Aspect
@Component
public class DynamicSqlAspect {
@Pointcut("@annotation(cn.believesun.ssproback.annotation.DynamicSql)")
public void dynamicSqlPointcut() {
}

@Around("dynamicSqlPointcut()")
public Object dynamicSql(ProceedingJoinPoint joinPoint) {
try {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
DynamicSql annotation = methodSignature.getMethod().getAnnotation(DynamicSql.class);
String patternSql = annotation.sqlPattern();

Integer owner_id = LoginUserInfo.getUser().getId();
String finalSql = "";
if (owner_id == 1) {
return joinPoint.proceed();
} else {
String replacePattern = "\\{userId}";
finalSql = patternSql.replaceAll(replacePattern, String.valueOf(owner_id));
log.info("正在插入SQL语句:{}", finalSql);
SqlContextHolder.setSql(finalSql);
return joinPoint.proceed();
}
} catch (Throwable e) {
log.error("动态SQL插入错误!");
throw new RuntimeException(e);
} finally {
// 清除ThreadLocal
SqlContextHolder.clear();
}
}
/**
* 静态内部类,用于存储当前线程下用户对SQL的操作
*/
public static class SqlContextHolder {
private static final ThreadLocal<String> SQL_HOLDER = new ThreadLocal<>();

public static void setSql(String sql) {
SQL_HOLDER.set(sql);
}

public static String getSql() {
return SQL_HOLDER.get();
}

public static void clear() {
SQL_HOLDER.remove();
}
}
}

配置SQL拦截器,对DQL语句进行拦截实际上就是拦截StatementHandler,这个Handler会执行SQL。

@Slf4j
@Intercepts({
@Signature(type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class})
})
public class SqlInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();

String originSql = boundSql.getSql();
String dynamicSql = DynamicSqlAspect.SqlContextHolder.getSql();

if (StringUtils.isNotBlank(dynamicSql) && !Objects.equals(originSql, dynamicSql)) {
String finalSql = buildFinalSql(originSql, dynamicSql);
log.info("最终执行的SQL为:{}", finalSql);
Field sqlField = boundSql.getClass().getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, finalSql);
}
return invocation.proceed();
}

private String buildFinalSql(String originSql, String dynamicSql) {
String upperSql = originSql.toUpperCase();
int limitIndex = upperSql.indexOf("LIMIT");
if (limitIndex != -1) {
String beforeLimit = originSql.substring(0, limitIndex);
String afterLimit = originSql.substring(limitIndex);
if (beforeLimit.toUpperCase().contains("WHERE")) {
return beforeLimit + " AND " + dynamicSql + " " + afterLimit;
} else {
return beforeLimit + " " + dynamicSql + " " + afterLimit;
}
} else {
if (upperSql.contains("WHERE")) {
return originSql + " AND " + dynamicSql;
} else {
return originSql + " " + dynamicSql;
}
}
}
}

菜单层面

我们在UserService层去数据库获取当前登录用户的所有权限控制代码,以参数的形式传递给前端做菜单的动态展示。

UserServiceImpl

@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Autowired
private TPermissionMapper tPermissionMapper;
// ...
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
TUser tUser = userMapper.selectByUsername(username);
if (tUser == null) {
throw new UsernameNotFoundException(username);
}
// ...
// 获取当前用户的菜单权限
List<TPermission> userMenuPermissionList = tPermissionMap7per.selectUserMenuPermissionById(tUser.getId());
// ...
tUser.setMenuPermissionList(userMenuPermissionList);
return tUser;
}
}

前端获取菜单并动态展示

const userData = ref();
const loadUserDate = async () => {
try {
const res = await getLoginInfo();
if (res.data.code === 200) {
// console.log("当前登录用户:%o", res.data)
userData.value = res.data.data;
menuList.value = convertMenuPermissions(res.data.data.menuPermissionList);
store.commit('setUserButtonPermission', res.data.data.roleList);
} else {
ElMessage.error(res.data.message)
}
} catch (error) {
ElMessage.error("网络错误!")
}
}
<!-- 动态生成菜单 -->
<template v-for="menu in menuList" :key="menu.index">
<el-sub-menu :index="menu.index">
<template #title>
<el-icon>
<component :is="menu.icon" />
</el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item-group>
<el-menu-item v-for="item in menu.children" :key="item.index" :index="item.index">
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu-item-group>
</el-sub-menu>
</template>

功能层面

与菜单相似,主要时利用 SpringSecurityhasAuthority注解来进行Controller层API的控制。

Service层

@Slf4j
@Service
public class UserServiceImpl implements UserService {
// ...
@Autowired
private TPermissionMapper tPermissionMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
TUser tUser = userMapper.selectByUsername(username);
if (tUser == null) {
throw new UsernameNotFoundException(username);
}
// ...
// 获取当前用户Button权限
List<TPermission> userButtonPermissionList = tPermissionMapper.selectUserButtonPermissionById(tUser.getId());
ArrayList<String> temp = new ArrayList<>();
userButtonPermissionList.forEach(list -> {
temp.add(list.getCode());
});
tUser.setRoleList(temp);
return tUser;
}
}

Controller层

@PreAuthorize("hasAuthority('activity:list')")注解,对getActivityList() 这个方法进行只有 activity:list 权限的限制

@PreAuthorize("hasAuthority('activity:list')")
@GetMapping("/list")
public R getActivityList(@RequestParam("current") Integer page) {
PageInfo<TActivity> activityList = activityService.getList(page);
return R.SUCCESS(Constants.DATA_LOAD_SUCCESS, activityList);
}

前端自定义指令,去隐藏对应Button

自定义指令:v-hasPermission="" 对其值进行判断是否存在与后端的 权限控制代码。

app.directive("hasPermission", {
mounted(el, binding) {
const ubp = store.state.userButtonPermission;
const requiredPermission = binding.value;
if (!ubp.includes(requiredPermission)) {
el.style.display = "none";
}
},
updated(el, binding) {
const ubp = store.state.userButtonPermission;
const requiredPermission = binding.value;

if (ubp.includes(requiredPermission)) {
el.style.display = "";
} else {
el.style.display = "none";
}
},
});
<!-- 新增市场活动 -->
<el-button type="primary" @click="insertActivity" class="btn" v-hasPermission="'activity:add'">新增市场活动</el-button>

第三点:用Redis做分页缓存

主要思路,就是使用JDK8的生产者消费者模型,在Manager层进行缓存的管理。给我的感觉就像是一个模板,然后对着填就行了。

RedisUtils 在最后我们对Null值进行空值处理,去避免缓存穿透

Redis缓存图

@Slf4j
public class RedisUtils {
public static <T> T getData(Supplier<T> redisSelector, Supplier<T> databaseSelector,
Consumer<T> redisConsumer,
Supplier<T> nullValueSupplier) {
// 先从Redis中查
T data = redisSelector.get();
if (Objects.isNull(data)) {
// Redis中查不到,去数据库查询
data = databaseSelector.get();
if (!Objects.isNull(data)) {
// 不为Null,存入Redis中作缓存
redisConsumer.accept(data);
} else {
// 数据库为空时,我们做Null值处理,让Redis设置Null值,去避免缓存穿透。
data = nullValueSupplier.get();
redisConsumer.accept(data);
}
}
return Objects.equals(data, nullValueSupplier.get()) ? null : data;
}
}

这里就随便举个例子

public PageInfo<TActivity> getList(Integer page) {
// 为每个分页使用不同的缓存键
String cacheKey = Constants.REDIS_ACTIVITIES_DATA_KEY + ":" + page;

PageInfo<TActivity> pageInfo = RedisUtils.getData(() -> {
try {
// 判断Redis中是否存在数据
String pageInfoJson = (String) redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(pageInfoJson)) {
// 存在数据,将其进行反序列化
return objectMapper.readValue(pageInfoJson, new TypeReference<PageInfo<TActivity>>() {
});
}
// Redis中没有查到,此时data == null
return null;
} catch (Exception e) {
log.error("Redis数据反序列化失败,key: {}", cacheKey, e);
return null;
}
}, () -> {
try {
// 数据库查询,并进行分页操作
PageHelper.startPage(page, Constants.ACTIVITY_PAGE_SIZE);
List<TActivity> activities = activityMapper.selectAll(page);

// OwnerId -> OwnerName
enrichActivitiesWithUserInfo(activities);

// 创建PageInfo对象
return new PageInfo<TActivity>(activities);
} catch (Exception e) {
log.error("数据库查询失败", e);
return new PageInfo<>(Collections.emptyList());
}
}, data -> {
try {
// 将数据库查出的data数据,以JSON格式存入Redis中作缓存
if (data != null && data.getList() != null && !data.getList().isEmpty()) {
// 序列化整个PageInfo对象到Redis
String json = objectMapper.writeValueAsString(data);
// 设置缓存失效
redisTemplate.opsForValue().set(cacheKey, json, Constants.REDIS_CACHE_OUT_TIME, TimeUnit.SECONDS);
} else {
// 缓存空值
redisTemplate.opsForValue().set(cacheKey, "{}", Constants.REDIS_CACHE_NULL_OUT_TIME, TimeUnit.SECONDS);
}
} catch (Exception e) {
log.error("Redis数据存储失败", e);
}
},
// 如果数据库也没有就进行空值处理
() -> new PageInfo<>(Collections.emptyList()));
// 判断数据是否为null
return pageInfo != null ? pageInfo : new PageInfo<>(Collections.emptyList());
}

第四点:双写一致性

使用注解 + 切面 + 延迟双删的形式解决双写不一致的问题,达到最终一致性。

CacheEvictPattern

@Retention(RetentionPolicy.RUNTIME)
public @interface CacheEvictPattern {
String[] pattern();

//延迟双删时间 默认500ms
long delay() default 500;

TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}

CacheAspect 缓存切面

提示

第二次删除时另起了一个线程进行操作的,毕竟要延迟。不应该影响主线程

@Aspect
@Component
@Slf4j
public class CacheAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Pointcut("@annotation(cn.believesun.ssproback.annotation.CacheEvictPattern)")
public void cacheEvictPointcut() {
}

@Around("cacheEvictPointcut()")
public Object delayDoubleDelete(ProceedingJoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
CacheEvictPattern annotation = methodSignature.getMethod().getAnnotation(CacheEvictPattern.class);
String[] pattern = annotation.pattern();

// 第一次删除
deleteCache(pattern);
R proceed;
try {
// 执行原操作,CRUD
proceed = (R) joinPoint.proceed();

// 第二次删除:延迟删除
scheduleDelayDelete(pattern, annotation.delay());
} catch (Throwable e) {
throw new RuntimeException(e);
}
return proceed;
}

@Autowired
private ThreadPoolTaskExecutor threadPoolTaskExecutor;

/**
* 延迟双删
*
* @param patterns 待删除Key
* @param delay 延迟时间,默认500ms
*/
private void scheduleDelayDelete(String[] patterns, long delay) {
// 开个线程
threadPoolTaskExecutor.execute(() -> {
try {
Thread.sleep(delay);
deleteCache(patterns);
} catch (InterruptedException e) {
// 抛异常让线程停下来
Thread.currentThread().interrupt();
log.error("延迟删除任务被中断", e);
} catch (Exception e) {
log.error("延迟删除执行失败", e);
}
});
}

/**
* 第一次删除缓存
*
* @param patterns 删除缓存Key
*/
private void deleteCache(String[] patterns) {
for (String pattern : patterns) {
Set<String> keys = redisTemplate.keys(pattern);
if (!keys.isEmpty()) {
// 删除缓存
Long delete = redisTemplate.delete(keys);
log.info("删除执行:删除{} 个数,pattern:{}", delete, pattern);
}
}
}
}

在 DML 操作上加上即可。

@CacheEvictPattern(pattern = Constants.REDIS_ACTIVITIES_DATA_KEY + ":*")
public R addActivity(TActivity activity) {
// 包装数据
activity.setCreateTime(new Date());
// 获取当前登录对象的ID
activity.setCreateBy(LoginUserInfo.getUser().getId());
// OwnerName -> OwnerId
if (tUserMapper.selectUserIdByUserRealName(activity.getOwnerName()) != null) {
activity.setOwnerId(tUserMapper.selectUserIdByUserRealName(activity.getOwnerName()));
} else {
// 抛出用户不存在异常
return R.FAIL("用户不存在", activity.getOwnerName());
}
System.out.println(activity);
// 重置自增
activityMapper.initializeAutoKey();
int insert = activityMapper.insert(activity);
if (insert != 1) {
return R.FAIL(activity.getName() + "插入失败", activity.getName());
}
return R.SUCCESS(Constants.DATA_ADD_SUCCESS, activity);
}

最后

很感谢能看到这里,这个项目前前后后忙活了半个多月,说难不难,到最后就是简单的重复:发请求->写Controller->写表格渲染数据都给我写麻了,所以有些重复的功能就不想写了。在此再次感谢这个项目的来源:CRM客户关系管理系统 - 动力节点