简单介绍
这个电商项目是一个B/S架构的 B2C电商平台系统,他是个一个分布式项目,平台由前后台两部分构成,前台面向买家,包括商品展示、购物车、订单、个人中心等模块;后台面向商家,包括商品管理、订单管理、权限管理等模块。商家之间的商品、订单数据是隔离的,商家只能看到自己家里的商品和订单。本项目使用springboot+mybatis-plus+springSecurity+JWT进行开发,采用分布式的系统架构,采用了Maven的多模块化的管理,首先是有一个父项目来,对他的子项目有一个垂直划分,分为了公共的子模块,后台管理系统,商品门户系统,搜索系统,订单系统,然后在像后台管理系统中采用了水平切分,将pojo、dao、service、controller分层开发,提高系统的重用性更高。分布式框架用的是Dubbo,数据库用的是redis和mysql,搜索系统用的是ElasticSearch搜索引擎,登录这里用的是单点登录,用rabbitMq在用户注册的时候发送消息。
功能描述
后台管理系统:管理商品、订单、类目、商品规格属性、用户管理以及内容发布等功能。
前台系统:用户可以在前台系统中进行注册、登录、浏览商品、首页、下单等操作。
订单系统:提供下单、查询订单、修改订单状态、定时处理订单,支付宝付款。
搜索系统:用ElasticSearch搜索引擎提供商品的搜索功能。
单点登录系统:为多个系统之间提供用户登录凭证token以及查询登录用户的信息。
问题
什么是分布式?
按照功能点把系统拆分,拆分成独立的功能。单独为某一个节点添加服务器。需要系统之间配合才能完成整个业务逻辑。叫做分布式。
你这个项目中使用什么构建的?多模块开发是如何划分的呢?为什么要这么做?
我们这个项目使用Maven进行构建,并使用了水平划分,这样划分层次清晰,代码重用性高,易于独立维护。
项目环境搭建 maven项目
1、有一个父项目:进行统一版本号控制<properties></properties>
和声明依赖 <dependencyManagement>
,这样子项目就不用再写版本号了,父项目是一个maven项目
2、有一个shop-common模块,用来写工具类,枚举类,返回的结果对象,公共pojo类等,他是一个maven项目
这里学到的一个点使用枚举类
首先使用枚举类可以将一组相关的常量值以可读性强的方式组织起来,使得代码更加易读、易懂。而且枚举类可以被多个类和方法引用,从而提高代码重用性,减少代码冗余。你像我们项目中会有返回对象,这个返回对象包括状态码、返回信息。这个状态码有的时候是固定的,因此我们可以使用枚举类把他给包装起来。同时,如果需要修改常量值,只需要在枚举类中进行修改,而不需要在整个代码中搜索并替换。而且我们还可以避免魔法数字(指在代码中出现的没有明确含义的数字常量)。同时,方便扩展,易维护。如果需要添加新的常量值,只需要在枚举类中添加一个新的枚举项即可,或者状态码固定值改变了,我们也可以只修改枚举项即可,不需要修改其他代码。枚举类本身是线程安全,可序列化的。
3、Mybatis 代码生成器
项目后台思路
他是一个springboot项目。
功能点:新增商品分类 —级联查询–上传图片–保存商品分类到数据库
点击新增按钮,跳转到添加商品分类的页面,前台传过来顶级分类列表,当选择顶级分类的时候,动态查询二级分类。点击上传图片,通过七牛云上传图片,点击提交按钮,像后台传入一个商品分类对象,保存到数据库中。注意这里有一个隐藏域,用来放我们商品分类对象的level。
商品分类页面:
动态查询二级分类的时候使用的是RestFul风格,在地址栏上,使用了
@PathVariable
,传过来选择的顶级对象的id,根据顶级对象的id作为二级分类对象的父id,查询出来二级分类列表,这里查出来的结果可以为null,因此不用判断,直接显示到前端,在前端进行判断。
@RequestMapping("/category/{parentId}")
@ResponseBody
public List<GoodsCategory> selectCategoryList(@PathVariable Short parentId) {
System.out.println("二级分类");
return goodsCategoryService.queryCategoryByParentId(parentId);
}
七牛云上传图片
点击提交按钮,前台提交整个表单数据到后台,在数据库中插入一条商品分类的数据,我一般会将前台传过来的数据和数据库的进行比较,看看是否需要在Controller层进行对象的数据赋值。
项目亮点:三级分类–使用jdk新特性提升系统的性能
进入商品分类列表,三级分类查询。进入列表显示的是一级分类,点击加号按钮显示二级分类,点击加号按钮显示三级分类。
这里首先要先知道如何实现这个三级查询,首先需要分析的是这个存过来的列表是什么?
可见是一个商品分类的列表,但是和普通的商品分类对象不同的是,他一个对象里面还有它的子对象,也就是说这个对象里面不仅包含该分类的属性还多一个孩子列表,因此我们需要新创建一个对象,这个对象包含商品分类对象的内容和一个孩子列表。因此我们需要在跳转到商品列表这个页面之前要查出这个列表并且显示在上面。
因此这个数据会比较大,所以我们使用了redis+jdk8的新特性stream流还处理这个问题。
首先是从数据库中查出所有的商品分类对象GoodCategory的list集合,通过stream流遍历集合并将集合中的元素映射为GoodCategoryVo类型并且赋值,进行copyProperties,然后对映射的元素归并成一个list集合。
List<GoodsCategoryVo> gcvList = list.stream().map(e -> {
GoodsCategoryVo gcv = new GoodsCategoryVo();
BeanUtils.copyProperties(e, gcv);
return gcv;
}).collect(Collectors.toList());
然后对这个集合根据parentid进行分组,就是获取他的stream流进行归并分组。得到一个map集合,map的key是parentid,value是list集合
Map<Short, List<GoodsCategoryVo>> map =
gcvList.stream().collect(Collectors.groupingBy(GoodsCategoryVo::getParentId));
然后再给每个vo对象的children进行赋值,调用gcvList集合的forEach的lambda表达式,从map集合中获取id,因为map集合中的id是parenid,如果map集合中的parentid和gcvlist中的id相等,那么map集合中的gcvlist就是孩子列表,直接就可以进行一个赋值。
//循环,给children赋值
gcvList.forEach(e->e.setChildren(map.get(e.getId())));
//拦截器,返回level为1的list,也就是顶级分类
List<GoodsCategoryVo> gcvList01 = gcvList.stream().filter(e -> 1 == e.getLevel()).collect(Collectors.toList());
然后将这个gcvList放入缓存中,redis缓存
点击增加商品按钮,有一个商品详情描述,用到的是UEditor
UEditor 和 转义
使用UEditor必须要转义,这是为什么?
是为了安全起见和防止数据库注入。
UEditor
注意因为我们的商品列表是放到了redis缓存中,所以我们添加商品之后要清除缓存,保证我们数据库和缓存的一致性。// 写操作中,清除Redis缓存数据,再次查询时新的数据将会放入缓存 redisTemplate.delete(redisTemplate.keys("goods*"));
还有就是如果有商品详情就必须要转义,还有就是这里会出现一个bug
,就是点击保存按钮之后再次点击保存按钮,会保存两次,导致我们的商品在数据库中重复保存,因此我们在这里需要进行一个判断,因为前台传过来的这个商品的属性,有一个是商品id,第一次存入的时候传过去是null,当商品保存成功后会从返回对象的信息中获取商品id并赋值给隐藏域,因此我们可以判断这个商品id是否为空,如果为空第一次存入,成功则赋值,否则的话返回失败信息。
功能点:商品列表 --分页查询商品列表
因为是分页查询,故前端传过来的是页数和页码,因为这里有品牌分类,一级分类的改动,还有页码和页数的改动,还有关键词的改动,因此这个查询的结果庞大,我们不可能每次都向mysql数据库去查询,因此我们使用缓存,但是缓存的key是要怎么写呢?
商品列表RedisKey可以根据前台传过来的参数来写,用goods进行包装,里面有动态的品牌,分类,关键词,根据判断这些是否为空来动态写rediskey,并来加入查询条件。
然后调动数组的stream流来归并这个字符串数组为一个字符串。然后将这个字符串设置为key,判断redis中有没有,有就返回列表,没有就去数据库中去查询。注意这里因为是分页查询,我们需要用PageHeper去构造页对象。如果去数据库中查询出来的对象是空的,那么就报错,否则就去加入这个分页对象PageInfo到BaseResult里面。
- 1.无条件查询 *
goods:pageNum_:PageSize_:catId_:brandId_:goodsName_: *
2.条件查询 *
goods:pageNum_:PageSize_:catId_123:brandId_:goodsName_: *
goods:pageNum_:PageSize_:catId_:brandId_123:goodsName_: *
goods:pageNum_:PageSize_:catId_:brandId_:goodsName_华为: *
goods:pageNum_:PageSize_:catId_123:brandId_123:goodsName_: *
goods:pageNum_:PageSize_:catId_123:brandId_:goodsName_华为: *
goods:pageNum_:PageSize_:catId_:brandId_123:goodsName_华为: *
goods:pageNum_:PageSize_:catId_123:brandId_123:goodsName_华为: */
使用到了分页插件,注意点–使用到了PageHelper
注意这里有一个有关分页插件的使用分页插件PageHelper
注意只有在查询数据库的时候pagehepler的分页才会生效,因此我们要把pageinfo集合放入redis缓存中,不能把list集合放入缓存中,如果没有查询操作,就没有结果集需要分页。
valueOperations.set(goodsListKey,JsonUtil.object2JsonStr(pageInfo));
主要就是通过调用之前编写的三级分类查询实现多级查询,通过parentID查询商品列表,前端使用DOT.js实现。
跳转到商品列表
门户页面后端实现
主要引入前端页面完成门户首页,
rpc–商品分类列表
其中商品分类列表在前台系统、后台系统都存在的,因此我们可以把商品分类列表给提取出来,封装到一个服务里面,让其他系统作为消费者去消费这个服务。
商品搜索ES
当用户进行搜索时,Elasticsearch会将用户输入的关键词转换成词项,并在对应的倒排列表中查找匹配的文档。由于倒排索引中记录了每个单词出现的所有文档信息,因此可以快速地定位到匹配的文档,从而实现快速搜索。
通过集成es搜索引擎实现搜索,前提提前准备好数据导入es中,以及es的节点映射配置,前端通过dot.js模板引擎实现商品模块的显示与排版和分页的展示。
单点登录思路
购物车模块 rpc
加入购物车,获取购物车的数量,这些前台、订单模块等都会用到。因此抽取出来作为一个服务。
注意在加入购物车,进入购物车列表,进入预订单页面的时候都是根据goodsId使用ElasticSearch实时搜索商品的价格,并且也要更新redis。解决我们数据不同步的问题。
订单系统
在登录状态下,进入预订单页面,进入订单页面,进入支付模块。
主要进入订单结算,需要用户登录状态下,因此引入拦截器通过拦截器实现请求控制,进行登录跳转,通过获取sesion中用户的id来获取购物车,通过购物车获取生成用户订单,以及总金额数量等
7、商品支付模块
通过集成支付宝第三方,获取公用密钥,获取用户订单ID,发起订单请求,有异步回调和同步回调,实现结果的返回
网络不好?导致重复提交?
在方法上方使用@Resubmit注解,在启动类上加上@EnableResubmit注解
9、使用的技术栈
① SpringMVC ②SpringBoot ③Mybatis ④ 七牛云、FastDFS ⑤Redis ⑥SSO ⑦ElasticSeach (es) ⑧RabbitMq ⑨ Dubbo、zookeeper ⑩权限控制、第三方支付、短信
隔离数据–使用的是自定义注解
可以通过自定义注解和AOP切面来实现商家只能看到自己商家的商品。下面是示例代码:
首先,我们创建一个自定义注解@HomeDataScope
:
package com.example.yourpackage;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface HomeDataScope {
}
然后,创建一个AOP切面类HomeDataScopeAspect
:
package com.bd.common.datascope.aspect;
import com.bd.common.core.utils.StringUtils;
import com.bd.common.core.web.domain.BaseEntity;
import com.bd.common.datascope.annotation.WmsDataScope;
import com.bd.common.security.utils.SecurityUtils;
import com.bd.system.api.domain.SysUser;
import com.bd.system.api.model.LoginUser;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
/**
* 数据过滤处理
*
*/
@Aspect
@Component
public class WmsDataScopeAspect
{
/**
* 数据权限过滤关键字
*/
public static final String DATA_SCOPE = "dataScope";
@Before("@annotation(controllerWmsDataScope)")
public void doBefore(JoinPoint point, WmsDataScope controllerWmsDataScope) throws Throwable
{
clearDataScope(point);
handleDataScope(point, controllerWmsDataScope);
}
protected void handleDataScope(final JoinPoint joinPoint, WmsDataScope controllerWmsDataScope)
{
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNotNull(loginUser))
{
SysUser currentUser = loginUser.getSysUser();
//如果是超级管理员,则不过滤
if (StringUtils.isNotNull(currentUser) && !currentUser.isAdmin()){
dataScopeFilter(joinPoint, currentUser, controllerWmsDataScope.wmsDepotAlias());
}
}
}
/**
* 数据范围过滤
*
* @param joinPoint 切点
* @param user 用户
*/
public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String wmsDepotAlias)
{
StringBuilder sqlString = new StringBuilder();
sqlString.append(StringUtils.format(
" JOIN (这里是需要拼接的sql) SUD ON {}=SUD.(需要join的字段) ", user.getSitecode(),
wmsDepotAlias));
if (StringUtils.isNotBlank(sqlString.toString()))
{
Object params = joinPoint.getArgs()[0];
if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
{
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, sqlString);
}
}
}
/**
* 拼接权限sql前先清空params.dataScope参数防止注入
*/
private void clearDataScope(final JoinPoint joinPoint)
{
Object params = joinPoint.getArgs()[0];
if (StringUtils.isNotNull(params) && params instanceof BaseEntity)
{
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, "");
}
}
}
上述代码中的User
类代表用户信息,ProductMapper
是商品的数据访问对象。
在使用@HomeDataScope
注解的控制器方法上,AOP切面会执行doBefore
方法。在doBefore
方法中,我们通过SecurityUtils.getCurrentUser()
获取当前登录用户的信息,并判断是否为管理员。如果不是管理员,则获取商家的homeId。然后,调用dataScopeFilter
方法进行数据过滤。
在dataScopeFilter
方法中,你可以在合适的地方根据homeId来过滤商品数据。示例中调用了ProductMapper
中的filterByHomeId()
方法来实现具体的数据过滤逻辑。
请根据你的实际业务需求,在相应的位置编写和完善逻辑。